diff --git a/app/models/calendar.py b/app/models/calendar.py index e74c7f6..94d06f7 100644 --- a/app/models/calendar.py +++ b/app/models/calendar.py @@ -1,15 +1,21 @@ -"""Calendar models — TimeSlot and related enums. +"""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. -See: NEXT_WAVE_DEV_DIRECTION.md §1.1 +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, + ForeignKey, Enum, Boolean, JSON, CheckConstraint, ) +from sqlalchemy.orm import relationship, validates from sqlalchemy.sql import func from app.core.config import Base import enum @@ -45,6 +51,33 @@ class EventType(str, enum.Enum): 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 # --------------------------------------------------------------------------- @@ -141,3 +174,142 @@ class TimeSlot(Base): 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