Files
YPMS/src/ypms/youth/schemas.py
T
Chris TonjesandClaude Opus 5.5 5dd828f0e5 Phase 0 milestone 0.5: Registrant vertical slice
Registrant model + schemas + router + migration 0003, exercising the
vendored framework end-to-end (CRUDService, RBAC, audit, version history).

- SSN app-layer encrypted (AES-256-GCM, HKDF key, column-bound AAD,
  versioned format) in security/column_crypto.py; write-only on the model;
  serializer exposes only ssn_masked (XXX-XX-1234). C.5.7.50 / SC-28.
- pii_read audit on GET, list (one row per page with ids), and history.
  C.5.6.9 #5 / AU-14.
- Portal-only callers (YOUTH/PARENT/HOST) limited to own row via
  Registrant.user_id; out-of-scope is 404. PARENT_USER sees nothing until
  Guardian lands. History gated on VersionHistory, not View.
- minor_flag derived from dob at read time.

Framework changes (flag to OWC on re-vendor):
- crud._to_dict honors __audit_redact__, keeping ciphertext out of
  audit_log and version_history.
- crud._to_dict serializes date (not only datetime) for JSON columns.
- CRUDService.history annotation uses builtins.list (method shadowed it).

98 tests passing, 94.83% coverage; ruff + mypy clean.
Coverage trace at docs/coverage/registrant.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:25:50 -04:00

109 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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