"""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_()`` / ``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