migrated to modern-rest

This commit is contained in:
straitz 2026-06-03 05:40:35 +09:00
parent d9a92569f0
commit 8e44c4501a
11 changed files with 1014 additions and 572 deletions

333
stratoflights_api/dtos.py Normal file
View file

@ -0,0 +1,333 @@
"""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