migrated to modern-rest
This commit is contained in:
parent
d9a92569f0
commit
8e44c4501a
11 changed files with 1014 additions and 572 deletions
333
stratoflights_api/dtos.py
Normal file
333
stratoflights_api/dtos.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue