333 lines
11 KiB
Python
333 lines
11 KiB
Python
"""Pydantic DTOs replacing the DRF serializers from serializers.py.
|
|
|
|
Mapping is 1:1 with the former serializers. Output DTOs enable ``from_attributes``
|
|
so an endpoint can build them straight from a Django model instance via
|
|
``SomeOut.model_validate(obj)`` (the equivalent of ``Serializer(obj).data``).
|
|
|
|
Field-level and cross-field validation that DRF kept in ``validate_<field>()`` /
|
|
``validate()`` is reproduced with pydantic ``field_validator`` / ``model_validator``.
|
|
Business logic DRF kept in ``create()`` (base64 curve decoding, ORM writes, and
|
|
resolving related objects from their PKs) is intentionally NOT here -- per DMR's
|
|
split it moves into the endpoints (Step 2.9).
|
|
"""
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
from django.contrib.auth.password_validation import validate_password
|
|
from django.core.validators import validate_email
|
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
field_validator,
|
|
model_validator,
|
|
)
|
|
|
|
from .validators import validate_custom_curve, rate_clip
|
|
|
|
|
|
# Profile constants (moved verbatim from serializers.py).
|
|
PROFILE_STANDARD = "standard_profile"
|
|
PROFILE_FLOAT = "float_profile"
|
|
PROFILE_REVERSE = "reverse_profile"
|
|
PROFILE_CUSTOM = "custom_profile"
|
|
LATEST_DATASET_KEYWORD = "latest"
|
|
SUPPORTED_PROFILES = [PROFILE_STANDARD, PROFILE_FLOAT, PROFILE_REVERSE, PROFILE_CUSTOM]
|
|
|
|
|
|
# --- Prediction request (was PredictionRequestSerializer) ---------------------
|
|
class PredictionRequest(BaseModel):
|
|
launch_latitude: float = Field(ge=-90, le=90)
|
|
launch_longitude: float = Field(ge=0, le=360)
|
|
launch_datetime: datetime
|
|
launch_altitude: Optional[float] = None
|
|
format: str = "json"
|
|
profile: str = PROFILE_STANDARD
|
|
dataset: str = LATEST_DATASET_KEYWORD
|
|
|
|
# profile-dependent fields
|
|
ascent_rate: Optional[float] = Field(default=None, ge=0.01)
|
|
descent_rate: Optional[float] = Field(default=None, ge=0.01)
|
|
burst_altitude: Optional[float] = None
|
|
float_altitude: Optional[float] = None
|
|
stop_datetime: Optional[datetime] = None
|
|
ascent_curve: Optional[str] = None
|
|
descent_curve: Optional[str] = None
|
|
interpolate: bool = False
|
|
# Related objects are accepted as PKs; existence is resolved in the endpoint
|
|
# (was PrimaryKeyRelatedField(queryset=...)).
|
|
start_point: Optional[int] = None
|
|
rate_profile: Optional[int] = None
|
|
template: Optional[int] = None
|
|
|
|
@field_validator("profile")
|
|
@classmethod
|
|
def _validate_profile(cls, value: str) -> str:
|
|
if value not in SUPPORTED_PROFILES:
|
|
raise ValueError(f'"{value}" is not a valid choice.')
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def _validate_cross_fields(self) -> "PredictionRequest":
|
|
launch_alt = self.launch_altitude if self.launch_altitude is not None else 0
|
|
|
|
if self.profile == PROFILE_STANDARD:
|
|
if self.burst_altitude is None:
|
|
raise ValueError("burst_altitude is required for standard profile.")
|
|
if self.burst_altitude <= launch_alt:
|
|
raise ValueError("burst_altitude must be greater than launch_altitude.")
|
|
|
|
elif self.profile == PROFILE_FLOAT:
|
|
if self.float_altitude is None or self.float_altitude <= launch_alt:
|
|
raise ValueError("float_altitude must be greater than launch_altitude.")
|
|
if self.stop_datetime is None or self.stop_datetime <= self.launch_datetime:
|
|
raise ValueError("stop_datetime must be later than launch_datetime.")
|
|
|
|
elif self.profile == PROFILE_CUSTOM:
|
|
if self.ascent_curve is None or not validate_custom_curve(self.ascent_curve):
|
|
raise ValueError("Invalid ascent_curve.")
|
|
if self.descent_curve is None or not validate_custom_curve(self.descent_curve):
|
|
raise ValueError("Invalid descent_curve.")
|
|
if self.burst_altitude is None or self.burst_altitude <= launch_alt:
|
|
raise ValueError("burst_altitude must be greater than launch_altitude.")
|
|
|
|
# custom clipping logic (was in validate())
|
|
if self.ascent_rate is not None:
|
|
self.ascent_rate = rate_clip(self.ascent_rate)
|
|
if self.descent_rate is not None:
|
|
self.descent_rate = rate_clip(self.descent_rate)
|
|
|
|
return self
|
|
|
|
|
|
# --- Prediction outputs -------------------------------------------------------
|
|
class PredictionOut(BaseModel):
|
|
"""Was PredictionSerializer."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
result: Any
|
|
|
|
|
|
class PredictionListOut(BaseModel):
|
|
"""Was PredictionListSerializer."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
start_point: Optional[int] = Field(default=None, validation_alias="start_point_id")
|
|
template: Optional[int] = Field(default=None, validation_alias="template_id")
|
|
rate_profile: Optional[int] = Field(default=None, validation_alias="rate_profile_id")
|
|
|
|
|
|
class PredictionDetailOut(BaseModel):
|
|
"""Was PredictionDetailSerializer."""
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
result: Any
|
|
start_point: Optional[int] = Field(default=None, validation_alias="start_point_id")
|
|
template: Optional[int] = Field(default=None, validation_alias="template_id")
|
|
rate_profile: Optional[int] = Field(default=None, validation_alias="rate_profile_id")
|
|
|
|
|
|
class PredictionCreateOut(BaseModel):
|
|
"""Custom create() response shape: {id, created_at, result} (not the full model)."""
|
|
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
result: Any
|
|
|
|
|
|
# --- Telemetry (was TelemetryPacketSerializer) --------------------------------
|
|
class TelemetryIn(BaseModel):
|
|
# timestamp is required (model BigIntegerField has no default), matching the
|
|
# former ModelSerializer; the endpoint still overwrites it with server time.
|
|
timestamp: int
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
alt: float = 0.0
|
|
payload: Any = Field(default_factory=dict)
|
|
|
|
|
|
class TelemetryOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: uuid.UUID
|
|
timestamp: int
|
|
lat: float
|
|
lon: float
|
|
alt: float
|
|
payload: Any
|
|
|
|
|
|
# --- SavedPoint (was SavedPointSerializer) ------------------------------------
|
|
# `user` was a HiddenField(CurrentUserDefault) -> set from request in the endpoint,
|
|
# not part of the wire input/output. The unique_together (user, name) check that
|
|
# DRF did via UniqueTogetherValidator is reproduced in the endpoint (Step 2.8).
|
|
class SavedPointIn(BaseModel):
|
|
name: str
|
|
lat: float = 0.0
|
|
lon: float = 0.0
|
|
alt: float = 0.0
|
|
|
|
|
|
class SavedPointPatchIn(BaseModel):
|
|
# All-optional variant for PATCH (partial_update). Only set fields apply.
|
|
name: Optional[str] = None
|
|
lat: Optional[float] = None
|
|
lon: Optional[float] = None
|
|
alt: Optional[float] = None
|
|
|
|
|
|
class SavedPointOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
name: str
|
|
lat: float
|
|
lon: float
|
|
alt: float
|
|
|
|
|
|
# --- SavedRateProfile (was SavedRateProfileSerializer) ------------------------
|
|
# NOTE: no view referenced this serializer in routing; kept for parity.
|
|
class SavedRateProfileIn(BaseModel):
|
|
name: str
|
|
type: str = "ascent"
|
|
rate_profile_data: Any = Field(default_factory=dict)
|
|
|
|
|
|
class SavedRateProfileOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
id: int
|
|
name: str
|
|
type: str
|
|
rate_profile_data: Any
|
|
|
|
|
|
# --- PredictionTemplate (was PreditctionTemplateSerializer) -------------------
|
|
class PredictionTemplateIn(BaseModel):
|
|
# protected_namespaces=() avoids pydantic's warning about the `model` field.
|
|
model_config = ConfigDict(protected_namespaces=())
|
|
|
|
name: str
|
|
is_default: bool = False
|
|
description: Optional[str] = None
|
|
prediction_mode: str = ""
|
|
model: str = ""
|
|
dataset: str = ""
|
|
flight_parameters: Any = Field(default_factory=dict)
|
|
|
|
|
|
class PredictionTemplatePatchIn(BaseModel):
|
|
# All-optional variant for PATCH (partial_update). Only set fields apply.
|
|
model_config = ConfigDict(protected_namespaces=())
|
|
|
|
name: Optional[str] = None
|
|
is_default: Optional[bool] = None
|
|
description: Optional[str] = None
|
|
prediction_mode: Optional[str] = None
|
|
model: Optional[str] = None
|
|
dataset: Optional[str] = None
|
|
flight_parameters: Optional[Any] = None
|
|
|
|
|
|
class PredictionTemplateOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
|
|
|
id: int
|
|
name: str
|
|
is_default: bool
|
|
description: Optional[str] = None
|
|
prediction_mode: str
|
|
model: str
|
|
dataset: str
|
|
flight_parameters: Any
|
|
|
|
|
|
# --- User (was UserSerializer) ------------------------------------------------
|
|
class UserOut(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
username: str
|
|
email: str
|
|
first_name: str
|
|
last_name: str
|
|
|
|
|
|
class UserUpdateIn(BaseModel):
|
|
# `username` was read_only -> excluded from input. PATCH is partial, so all
|
|
# fields are optional; the endpoint applies only fields that were set.
|
|
email: Optional[str] = None
|
|
first_name: Optional[str] = None
|
|
last_name: Optional[str] = None
|
|
|
|
@field_validator("email")
|
|
@classmethod
|
|
def _validate_email(cls, value: Optional[str]) -> Optional[str]:
|
|
if value is None:
|
|
return value
|
|
try:
|
|
validate_email(value)
|
|
except DjangoValidationError:
|
|
raise ValueError("Invalid email format")
|
|
return value
|
|
|
|
|
|
# --- Password / account (were ChangePasswordSerializer / DeleteAccountSerializer)
|
|
class ChangePasswordIn(BaseModel):
|
|
old_password: str
|
|
new_password: str
|
|
|
|
@field_validator("new_password")
|
|
@classmethod
|
|
def _validate_new_password(cls, value: str) -> str:
|
|
# validate_password raises Django's ValidationError (not a ValueError),
|
|
# which pydantic would not convert into a 4xx -- re-raise as ValueError.
|
|
try:
|
|
validate_password(value)
|
|
except DjangoValidationError as exc:
|
|
raise ValueError("; ".join(exc.messages))
|
|
return value
|
|
|
|
|
|
class DeleteAccountIn(BaseModel):
|
|
password: str
|
|
|
|
|
|
# --- Auth / session response shapes (were inline JsonResponse dicts) ----------
|
|
class DetailResponse(BaseModel):
|
|
detail: str
|
|
|
|
|
|
class SessionResponse(BaseModel):
|
|
isAuthenticated: bool
|
|
|
|
|
|
class WhoAmIResponse(BaseModel):
|
|
username: str
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
token: str
|
|
|
|
|
|
# --- Shared path parameters ---------------------------------------------------
|
|
class PkPath(BaseModel):
|
|
pk: int
|
|
|
|
|
|
class UuidPkPath(BaseModel):
|
|
pk: uuid.UUID
|