"""Plan virtual-slot identification and materialization. BE-CAL-005: Implements the ``plan-{plan_id}-{date}`` virtual slot ID scheme, matching logic to determine which plans fire on a given date, and materialization (converting a virtual slot into a real TimeSlot row). Design references: - NEXT_WAVE_DEV_DIRECTION.md §2 (Slot ID策略) - NEXT_WAVE_DEV_DIRECTION.md §3 (存储与缓存策略) Key rules: 1. A virtual slot is identified by ``plan-{plan_id}-{YYYY-MM-DD}``. 2. A plan matches a date if all its period parameters (on_month, on_week, on_day, at_time) align with that date. 3. A virtual slot is **not** generated for a date if a materialized TimeSlot already exists for that (plan_id, date) pair. 4. Materialization creates a real TimeSlot row from the plan template and returns it. 5. After edit/cancel of a materialized slot, ``plan_id`` is set to NULL so the plan no longer "claims" that date — but the row persists. """ from __future__ import annotations import calendar as _cal import re from datetime import date, datetime, time from typing import Optional from sqlalchemy.orm import Session from app.models.calendar import ( DayOfWeek, MonthOfYear, SchedulePlan, SlotStatus, TimeSlot, ) # --------------------------------------------------------------------------- # Virtual-slot identifier helpers # --------------------------------------------------------------------------- _VIRTUAL_ID_RE = re.compile(r"^plan-(\d+)-(\d{4}-\d{2}-\d{2})$") def make_virtual_slot_id(plan_id: int, slot_date: date) -> str: """Build the canonical virtual-slot identifier string.""" return f"plan-{plan_id}-{slot_date.isoformat()}" def parse_virtual_slot_id(virtual_id: str) -> tuple[int, date] | None: """Parse ``plan-{plan_id}-{YYYY-MM-DD}`` → ``(plan_id, date)`` or *None*.""" m = _VIRTUAL_ID_RE.match(virtual_id) if m is None: return None plan_id = int(m.group(1)) slot_date = date.fromisoformat(m.group(2)) return plan_id, slot_date # --------------------------------------------------------------------------- # Plan-date matching # --------------------------------------------------------------------------- # Mapping from DayOfWeek enum to Python weekday (Mon=0 … Sun=6) _DOW_TO_WEEKDAY = { DayOfWeek.MON: 0, DayOfWeek.TUE: 1, DayOfWeek.WED: 2, DayOfWeek.THU: 3, DayOfWeek.FRI: 4, DayOfWeek.SAT: 5, DayOfWeek.SUN: 6, } # Mapping from MonthOfYear enum to calendar month number _MOY_TO_MONTH = { MonthOfYear.JAN: 1, MonthOfYear.FEB: 2, MonthOfYear.MAR: 3, MonthOfYear.APR: 4, MonthOfYear.MAY: 5, MonthOfYear.JUN: 6, MonthOfYear.JUL: 7, MonthOfYear.AUG: 8, MonthOfYear.SEP: 9, MonthOfYear.OCT: 10, MonthOfYear.NOV: 11, MonthOfYear.DEC: 12, } def _week_of_month(d: date) -> int: """Return the 1-based week-of-month for *d*. Week 1 contains the first occurrence of the same weekday in that month. For example, if the month starts on Wednesday: - Wed 1st → week 1 - Wed 8th → week 2 - Thu 2nd → week 1 (first Thu of month) """ first_day = d.replace(day=1) # How many days from the first occurrence of this weekday? first_occurrence = 1 + (d.weekday() - first_day.weekday()) % 7 return (d.day - first_occurrence) // 7 + 1 def plan_matches_date(plan: SchedulePlan, target_date: date) -> bool: """Return *True* if *plan*'s recurrence rule fires on *target_date*. Checks (most restrictive first): 1. on_month → target month must match 2. on_week → target week-of-month must match 3. on_day → target weekday must match 4. If none of the above are set → matches every day """ if not plan.is_active: return False # Month filter if plan.on_month is not None: if target_date.month != _MOY_TO_MONTH[plan.on_month]: return False # Week-of-month filter if plan.on_week is not None: if _week_of_month(target_date) != plan.on_week: return False # Day-of-week filter if plan.on_day is not None: if target_date.weekday() != _DOW_TO_WEEKDAY[plan.on_day]: return False return True # --------------------------------------------------------------------------- # Query helpers # --------------------------------------------------------------------------- def get_matching_plans( db: Session, user_id: int, target_date: date, ) -> list[SchedulePlan]: """Return all active plans for *user_id* that match *target_date*.""" plans = ( db.query(SchedulePlan) .filter( SchedulePlan.user_id == user_id, SchedulePlan.is_active.is_(True), ) .all() ) return [p for p in plans if plan_matches_date(p, target_date)] def get_materialized_plan_dates( db: Session, plan_id: int, target_date: date, ) -> bool: """Return *True* if a materialized slot already exists for (plan_id, date).""" return ( db.query(TimeSlot.id) .filter( TimeSlot.plan_id == plan_id, TimeSlot.date == target_date, ) .first() ) is not None def get_virtual_slots_for_date( db: Session, user_id: int, target_date: date, ) -> list[dict]: """Return virtual-slot dicts for plans that match *target_date* but have not yet been materialized. Each dict mirrors the TimeSlot column structure plus a ``virtual_id`` field, making it easy to merge with real slots in the API layer. """ plans = get_matching_plans(db, user_id, target_date) virtual_slots: list[dict] = [] for plan in plans: if get_materialized_plan_dates(db, plan.id, target_date): continue # already materialized — skip virtual_slots.append({ "virtual_id": make_virtual_slot_id(plan.id, target_date), "plan_id": plan.id, "user_id": plan.user_id, "date": target_date, "slot_type": plan.slot_type, "estimated_duration": plan.estimated_duration, "scheduled_at": plan.at_time, "started_at": None, "attended": False, "actual_duration": None, "event_type": plan.event_type, "event_data": plan.event_data, "priority": 0, "status": SlotStatus.NOT_STARTED, }) return virtual_slots # --------------------------------------------------------------------------- # Materialization # --------------------------------------------------------------------------- def materialize_slot( db: Session, plan_id: int, target_date: date, ) -> TimeSlot: """Materialize a virtual slot into a real TimeSlot row. Copies template fields from the plan. The returned row is flushed (has an ``id``) but the caller must ``commit()`` the transaction. Raises ``ValueError`` if the plan does not exist, is inactive, does not match the target date, or has already been materialized for that date. """ plan = db.query(SchedulePlan).filter(SchedulePlan.id == plan_id).first() if plan is None: raise ValueError(f"Plan {plan_id} not found") if not plan.is_active: raise ValueError(f"Plan {plan_id} is inactive") if not plan_matches_date(plan, target_date): raise ValueError( f"Plan {plan_id} does not match date {target_date.isoformat()}" ) if get_materialized_plan_dates(db, plan_id, target_date): raise ValueError( f"Plan {plan_id} already materialized for {target_date.isoformat()}" ) slot = TimeSlot( user_id=plan.user_id, date=target_date, slot_type=plan.slot_type, estimated_duration=plan.estimated_duration, scheduled_at=plan.at_time, event_type=plan.event_type, event_data=plan.event_data, priority=0, status=SlotStatus.NOT_STARTED, plan_id=plan.id, ) db.add(slot) db.flush() return slot def materialize_from_virtual_id( db: Session, virtual_id: str, ) -> TimeSlot: """Parse a virtual-slot identifier and materialize it. Convenience wrapper around :func:`materialize_slot`. """ parsed = parse_virtual_slot_id(virtual_id) if parsed is None: raise ValueError(f"Invalid virtual slot id: {virtual_id!r}") plan_id, target_date = parsed return materialize_slot(db, plan_id, target_date) # --------------------------------------------------------------------------- # Disconnect plan after edit/cancel # --------------------------------------------------------------------------- def detach_slot_from_plan(slot: TimeSlot) -> None: """Clear the ``plan_id`` on a materialized slot. Called after edit or cancel to ensure the plan no longer "claims" this date — the row persists with its own lifecycle. """ slot.plan_id = None # --------------------------------------------------------------------------- # Bulk materialization (daily pre-compute) # --------------------------------------------------------------------------- def materialize_all_for_date( db: Session, user_id: int, target_date: date, ) -> list[TimeSlot]: """Materialize every matching plan for *user_id* on *target_date*. Skips plans that are already materialized. Returns the list of newly created TimeSlot rows (flushed, caller must commit). """ plans = get_matching_plans(db, user_id, target_date) created: list[TimeSlot] = [] for plan in plans: if get_materialized_plan_dates(db, plan.id, target_date): continue slot = TimeSlot( user_id=plan.user_id, date=target_date, slot_type=plan.slot_type, estimated_duration=plan.estimated_duration, scheduled_at=plan.at_time, event_type=plan.event_type, event_data=plan.event_data, priority=0, status=SlotStatus.NOT_STARTED, plan_id=plan.id, ) db.add(slot) created.append(slot) if created: db.flush() return created