BE-CAL-002: Add SchedulePlan model with period hierarchy constraints
- Add DayOfWeek and MonthOfYear enums for plan period parameters - Add SchedulePlan model with at_time/on_day/on_week/on_month fields - Add DB-level check constraints enforcing hierarchy: on_month requires on_week, on_week requires on_day - Add application-level @validates for on_week range (1-4), on_month hierarchy, and estimated_duration (1-50) - Add is_active flag for soft-delete (plan-cancel) - Add bidirectional relationship between SchedulePlan and TimeSlot - All existing tests pass (29/29)
This commit is contained in:
@@ -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.
|
TimeSlot represents a single scheduled slot on a user's calendar.
|
||||||
Slots can be created manually or materialized from a SchedulePlan.
|
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 (
|
from sqlalchemy import (
|
||||||
Column, Integer, String, Text, DateTime, Date, Time,
|
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 sqlalchemy.sql import func
|
||||||
from app.core.config import Base
|
from app.core.config import Base
|
||||||
import enum
|
import enum
|
||||||
@@ -45,6 +51,33 @@ class EventType(str, enum.Enum):
|
|||||||
SYSTEM_EVENT = "system_event"
|
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
|
# TimeSlot model
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -141,3 +174,142 @@ class TimeSlot(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=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
|
||||||
|
|||||||
Reference in New Issue
Block a user