Files
HarborForge.Backend/app/models/calendar.py

322 lines
8.9 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.
"""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",
)
wakeup_sent_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When Discord wakeup was sent for 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