"""Calendar models — TimeSlot, SchedulePlan and related enums. TimeSlot represents a single scheduled slot on a user's calendar. Slots can be created manually or materialized from a SchedulePlan. SchedulePlan represents a recurring schedule rule that generates virtual slots on matching dates. Virtual slots are materialized into real TimeSlot rows on demand (daily pre-compute, or when edited/cancelled). See: NEXT_WAVE_DEV_DIRECTION.md §1.1 – §1.3 """ from sqlalchemy import ( Column, Integer, String, Text, DateTime, Date, Time, ForeignKey, Enum, Boolean, JSON, CheckConstraint, ) from sqlalchemy.orm import relationship, validates from sqlalchemy.sql import func from app.core.config import Base import enum # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- class SlotType(str, enum.Enum): """What kind of slot this is.""" WORK = "work" ON_CALL = "on_call" ENTERTAINMENT = "entertainment" SYSTEM = "system" class SlotStatus(str, enum.Enum): """Lifecycle status of a slot.""" NOT_STARTED = "not_started" ONGOING = "ongoing" DEFERRED = "deferred" SKIPPED = "skipped" PAUSED = "paused" FINISHED = "finished" ABORTED = "aborted" class EventType(str, enum.Enum): """High-level event category stored alongside the slot.""" JOB = "job" ENTERTAINMENT = "entertainment" SYSTEM_EVENT = "system_event" class DayOfWeek(str, enum.Enum): """Day-of-week for SchedulePlan.on_day.""" SUN = "sun" MON = "mon" TUE = "tue" WED = "wed" THU = "thu" FRI = "fri" SAT = "sat" class MonthOfYear(str, enum.Enum): """Month for SchedulePlan.on_month.""" JAN = "jan" FEB = "feb" MAR = "mar" APR = "apr" MAY = "may" JUN = "jun" JUL = "jul" AUG = "aug" SEP = "sep" OCT = "oct" NOV = "nov" DEC = "dec" # --------------------------------------------------------------------------- # TimeSlot model # --------------------------------------------------------------------------- class TimeSlot(Base): __tablename__ = "time_slots" id = Column(Integer, primary_key=True, index=True) user_id = Column( Integer, ForeignKey("users.id"), nullable=False, index=True, comment="Owner of this slot", ) date = Column( Date, nullable=False, index=True, comment="Calendar date for this slot", ) slot_type = Column( Enum(SlotType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="work | on_call | entertainment | system", ) estimated_duration = Column( Integer, nullable=False, comment="Estimated duration in minutes (1-50)", ) scheduled_at = Column( Time, nullable=False, comment="Planned start time (00:00-23:00)", ) started_at = Column( Time, nullable=True, comment="Actual start time (filled when slot begins)", ) attended = Column( Boolean, default=False, nullable=False, comment="Whether the slot has been attended", ) actual_duration = Column( Integer, nullable=True, comment="Actual duration in minutes (0-65535), no upper design limit", ) event_type = Column( Enum(EventType, values_callable=lambda x: [e.value for e in x]), nullable=True, comment="job | entertainment | system_event", ) event_data = Column( JSON, nullable=True, comment="Event details JSON — structure depends on event_type", ) priority = Column( Integer, nullable=False, default=0, comment="Priority 0-99, higher = more important", ) status = Column( Enum(SlotStatus, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SlotStatus.NOT_STARTED, comment="Lifecycle status of this slot", ) plan_id = Column( Integer, ForeignKey("schedule_plans.id"), nullable=True, comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel", ) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # relationship ---------------------------------------------------------- plan = relationship("SchedulePlan", back_populates="materialized_slots") # --------------------------------------------------------------------------- # SchedulePlan model # --------------------------------------------------------------------------- class SchedulePlan(Base): """A recurring schedule rule that generates virtual TimeSlots. Hierarchy constraint for the period parameters: • ``at_time`` is always required. • ``on_month`` requires ``on_week`` (which in turn requires ``on_day``). • ``on_week`` requires ``on_day``. Examples: • ``--at 09:00`` → every day at 09:00 • ``--at 09:00 --on-day sun`` → every Sunday at 09:00 • ``--at 09:00 --on-day sun --on-week 1`` → 1st-week Sunday each month • ``--at … --on-day sun --on-week 1 --on-month jan`` → Jan 1st-week Sunday """ __tablename__ = "schedule_plans" __table_args__ = ( # on_month requires on_week CheckConstraint( "(on_month IS NULL) OR (on_week IS NOT NULL)", name="ck_plan_month_requires_week", ), # on_week requires on_day CheckConstraint( "(on_week IS NULL) OR (on_day IS NOT NULL)", name="ck_plan_week_requires_day", ), ) id = Column(Integer, primary_key=True, index=True) user_id = Column( Integer, ForeignKey("users.id"), nullable=False, index=True, comment="Owner of this plan", ) # -- slot template fields ----------------------------------------------- slot_type = Column( Enum(SlotType, values_callable=lambda x: [e.value for e in x]), nullable=False, comment="work | on_call | entertainment | system", ) estimated_duration = Column( Integer, nullable=False, comment="Estimated duration in minutes (1-50)", ) event_type = Column( Enum(EventType, values_callable=lambda x: [e.value for e in x]), nullable=True, comment="job | entertainment | system_event", ) event_data = Column( JSON, nullable=True, comment="Event details JSON — copied to materialized slots", ) # -- period parameters -------------------------------------------------- at_time = Column( Time, nullable=False, comment="Daily scheduled time (--at HH:mm), always required", ) on_day = Column( Enum(DayOfWeek, values_callable=lambda x: [e.value for e in x]), nullable=True, comment="Day of week (--on-day); NULL = every day", ) on_week = Column( Integer, nullable=True, comment="Week-of-month 1-4 (--on-week); NULL = every week", ) on_month = Column( Enum(MonthOfYear, values_callable=lambda x: [e.value for e in x]), nullable=True, comment="Month (--on-month); NULL = every month", ) is_active = Column( Boolean, default=True, nullable=False, comment="Soft-delete / plan-cancel flag", ) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # relationship ---------------------------------------------------------- materialized_slots = relationship( "TimeSlot", back_populates="plan", lazy="dynamic", ) # -- application-level validation --------------------------------------- @validates("on_week") def _validate_on_week(self, _key: str, value: int | None) -> int | None: if value is not None and not (1 <= value <= 4): raise ValueError("on_week must be between 1 and 4") return value @validates("on_month") def _validate_on_month(self, _key: str, value): """Enforce: on_month requires on_week (and transitively on_day).""" if value is not None and self.on_week is None: raise ValueError( "on_month requires on_week to be set " "(hierarchy: on_month → on_week → on_day)" ) return value @validates("estimated_duration") def _validate_estimated_duration(self, _key: str, value: int) -> int: if not (1 <= value <= 50): raise ValueError("estimated_duration must be between 1 and 50") return value