onboarding.py 2.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. """Pydantic schemas for the onboarding tour API.
  2. See docs/onboarding-tour-plan.md (Appendix B) for the state model.
  3. """
  4. import re
  5. from datetime import datetime
  6. from pydantic import BaseModel, Field, field_validator, model_validator
  7. # Step IDs use dotted-numeric form ("1.2", "2.2b", "3.7"); bound to 40 chars
  8. # so the full "tour_in_progress:<step>" string fits inside VARCHAR(64).
  9. _STEP_ID_RE = re.compile(r"^[a-zA-Z0-9._-]{1,40}$")
  10. _TERMINAL_STATUSES = frozenset({"dismissed", "snoozed", "completed_tour"})
  11. class OnboardingResponse(BaseModel):
  12. """Current onboarding state for the authenticated user.
  13. `status` is null for users who have not yet seen the welcome modal.
  14. """
  15. status: str | None = None
  16. snoozed_until: datetime | None = None
  17. class OnboardingUpdate(BaseModel):
  18. """PATCH body for /api/v1/users/me/onboarding.
  19. The `dismissed_at_migration` value is intentionally NOT acceptable here —
  20. it is set once by the column-add migration to mark pre-existing users as
  21. not-eligible and must not be replayable from the API.
  22. """
  23. status: str = Field(..., max_length=64)
  24. snoozed_until: datetime | None = None
  25. @field_validator("status")
  26. @classmethod
  27. def validate_status(cls, v: str) -> str:
  28. if v in _TERMINAL_STATUSES:
  29. return v
  30. if v.startswith("tour_in_progress:"):
  31. step_id = v[len("tour_in_progress:") :]
  32. if _STEP_ID_RE.match(step_id):
  33. return v
  34. raise ValueError("status must be one of: dismissed, snoozed, completed_tour, or tour_in_progress:<step_id>")
  35. @model_validator(mode="after")
  36. def validate_snooze_coherence(self) -> "OnboardingUpdate":
  37. if self.status == "snoozed":
  38. if self.snoozed_until is None:
  39. raise ValueError("snoozed_until is required when status='snoozed'")
  40. elif self.snoozed_until is not None:
  41. raise ValueError("snoozed_until is only valid when status='snoozed'")
  42. return self