"""Registrant request/response schemas. The read schema has no `ssn` field — only `ssn_masked` (``XXX-XX-1234``). Masking happens here in the serializer, not the UI, so every API consumer sees the masked form (ARCHITECTURE.md §6.3; source RFP `C.5.7.50`). `user_id` is deliberately absent from the write schemas: linking a registrant to a portal account is a Phase 1 identity flow, not a PATCH. """ from __future__ import annotations import re from datetime import date, datetime from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator _ZIP = r"^\d{5}(-\d{4})?$" def normalize_ssn(value: str | None) -> str | None: """Accept ``123-45-6789`` or ``123456789``; return 9 digits. Rejects numbers the SSA never issues: area 000, 666, or 900–999; group 00; serial 0000. Error messages never echo the input. """ if value is None: return None digits = re.sub(r"[\s-]", "", value) if not re.fullmatch(r"\d{9}", digits): raise ValueError("SSN must be 9 digits") area, group, serial = digits[:3], digits[3:5], digits[5:] if area in {"000", "666"} or area >= "900" or group == "00" or serial == "0000": raise ValueError("SSN is not a valid issued number") return digits def _not_future(value: date | None) -> date | None: if value is not None and value > date.today(): raise ValueError("dob cannot be in the future") return value class RegistrantCreate(BaseModel): first_name: str = Field(min_length=1, max_length=100) middle_name: str | None = Field(default=None, max_length=100) last_name: str = Field(min_length=1, max_length=100) dob: date ssn: str | None = None email: EmailStr | None = None phone: str | None = Field(default=None, max_length=32) address_line1: str | None = Field(default=None, max_length=255) address_line2: str | None = Field(default=None, max_length=255) city: str | None = Field(default=None, max_length=100) state: str | None = Field(default=None, min_length=2, max_length=2) zip: str | None = Field(default=None, pattern=_ZIP) _ssn = field_validator("ssn")(normalize_ssn) _dob = field_validator("dob")(_not_future) class RegistrantUpdate(BaseModel): first_name: str | None = Field(default=None, min_length=1, max_length=100) middle_name: str | None = Field(default=None, max_length=100) last_name: str | None = Field(default=None, min_length=1, max_length=100) dob: date | None = None ssn: str | None = None email: EmailStr | None = None phone: str | None = Field(default=None, max_length=32) address_line1: str | None = Field(default=None, max_length=255) address_line2: str | None = Field(default=None, max_length=255) city: str | None = Field(default=None, max_length=100) state: str | None = Field(default=None, min_length=2, max_length=2) zip: str | None = Field(default=None, pattern=_ZIP) _ssn = field_validator("ssn")(normalize_ssn) _dob = field_validator("dob")(_not_future) @field_validator("first_name", "last_name", "dob") @classmethod def _required_not_null(cls, v: object) -> object: # Omit to leave unchanged; explicit null would violate NOT NULL. if v is None: raise ValueError("cannot be null") return v class RegistrantRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: int first_name: str middle_name: str | None last_name: str dob: date ssn_masked: str | None minor_flag: bool email: str | None phone: str | None address_line1: str | None address_line2: str | None city: str | None state: str | None zip: str | None version: int created_at: datetime updated_at: datetime deleted_at: datetime | None