# myapp/repositories/schedule.py # ------------------------------------------------------------ # Persistence layer for the ``Schedule`` model. # ------------------------------------------------------------ from __future__ import annotations from typing import Any, List, Optional, Sequence from backend.db import BaseRepository from backend.models import Schedule as ScheduleModel from backend.models import ScheduleStatus class ScheduleRepository(BaseRepository[ScheduleModel]): """Data‑access object for the ``Schedules`` table.""" # ------------------------------------------------------------------ # Table‑level constants – change them in one place if the schema evolves. # ------------------------------------------------------------------ _TABLE = "Schedules" _PK = "ScheduleId" # ------------------------------------------------------------------ # CRUD helpers # ------------------------------------------------------------------ def create( self, *, service_id: int, member_id: int, status: ScheduleStatus = ScheduleStatus.PENDING, reason: Optional[str] = None, scheduled_at: Optional[Any] = None, expires_at: Optional[Any] = None, ) -> ScheduleModel: """ Insert a brand‑new schedule row. Parameters ---------- service_id, member_id : int FK references. status : ScheduleStatus Desired initial status (PENDING, DECLINED, …). reason : str | None Stored in ``DeclineReason`` when the status is ``DECLINED``. scheduled_at, expires_at : datetime‑compatible | None ``scheduled_at`` defaults to SQLite’s ``CURRENT_TIMESTAMP``. """ # Handle timestamp - use actual datetime if not provided import datetime if scheduled_at is None: scheduled_at = datetime.datetime.now(datetime.UTC).isoformat() schedule = ScheduleModel( ScheduleId=-1, # placeholder – will be replaced ServiceId=service_id, MemberId=member_id, Status=status.value, ScheduledAt=scheduled_at, AcceptedAt=None, DeclinedAt=None, ExpiresAt=expires_at, DeclineReason=reason if status == ScheduleStatus.DECLINED else None, ) return self._insert(self._TABLE, schedule, self._PK) def get_by_id(self, schedule_id: int) -> Optional[ScheduleModel]: """Fetch a schedule by its primary key.""" sql = f"SELECT * FROM {self._TABLE} WHERE {self._PK} = ?" row = self.db.fetchone(sql, (schedule_id,)) return ScheduleModel.from_row(row) if row else None def list_all(self) -> List[ScheduleModel]: """Return every schedule row.""" return self._select_all(self._TABLE, ScheduleModel) # ------------------------------------------------------------------ # Helper used by the SchedulingService to locate an existing row. # ------------------------------------------------------------------ def get_one(self, *, member_id: int, service_id: int) -> Optional[ScheduleModel]: """ Return the *first* schedule (any status) for the supplied ``member_id`` / ``service_id`` pair, or ``None`` if none exists. """ sql = f""" SELECT * FROM {self._TABLE} WHERE MemberId = ? AND ServiceId = ? LIMIT 1 """ row = self.db.fetchone(sql, (member_id, service_id)) return ScheduleModel.from_row(row) if row else None # ------------------------------------------------------------------ # Generic status‑change helper (used for “decline” and similar ops). # ------------------------------------------------------------------ def update_status( self, *, schedule_id: int, new_status: ScheduleStatus, reason: Optional[str] = None, ) -> None: """ Switch a schedule’s status and optionally store a reason. If ``new_status`` is ``DECLINED`` the ``DeclineReason`` column is populated; otherwise it is cleared. """ # Build the SET clause dynamically – we only touch the columns we need. set_clause = "Status = ?, DeclinedAt = NULL, DeclineReason = NULL" params: list[Any] = [new_status.value] if new_status == ScheduleStatus.DECLINED: set_clause = "Status = ?, DeclinedAt = datetime('now'), DeclineReason = ?" params.extend([reason]) params.append(schedule_id) # WHERE clause param sql = f""" UPDATE {self._TABLE} SET {set_clause} WHERE {self._PK} = ? """ self.db.execute(sql, tuple(params)) # ------------------------------------------------------------------ # Query helpers used by the scheduling service # ------------------------------------------------------------------ def has_any( self, member_id: int, service_id: int, statuses: Sequence[ScheduleStatus], ) -> bool: """True if a schedule exists for the pair with any of the given statuses.""" if not statuses: return False placeholders = ",".join("?" for _ in statuses) sql = f""" SELECT 1 FROM {self._TABLE} WHERE MemberId = ? AND ServiceId = ? AND Status IN ({placeholders}) LIMIT 1 """ params = (member_id, service_id, *[s.value for s in statuses]) row = self.db.fetchone(sql, params) return row is not None # ------------------------------------------------------------------ # Status‑transition helpers (accept / decline) – kept for completeness. # ------------------------------------------------------------------ def mark_accepted( self, schedule_id: int, accepted_at: Optional[Any] = None, ) -> None: if accepted_at is None: sql = f""" UPDATE {self._TABLE} SET Status = ?, AcceptedAt = datetime('now'), DeclinedAt = NULL, DeclineReason = NULL WHERE {self._PK} = ? """ self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, schedule_id)) else: sql = f""" UPDATE {self._TABLE} SET Status = ?, AcceptedAt = ?, DeclinedAt = NULL, DeclineReason = NULL WHERE {self._PK} = ? """ self.db.execute(sql, (ScheduleStatus.ACCEPTED.value, accepted_at, schedule_id)) def mark_declined( self, schedule_id: int, declined_at: Optional[Any] = None, decline_reason: Optional[str] = None, ) -> None: if declined_at is None: sql = f""" UPDATE {self._TABLE} SET Status = ?, DeclinedAt = datetime('now'), DeclineReason = ? WHERE {self._PK} = ? """ self.db.execute(sql, (ScheduleStatus.DECLINED.value, decline_reason, schedule_id)) else: sql = f""" UPDATE {self._TABLE} SET Status = ?, DeclinedAt = ?, DeclineReason = ? WHERE {self._PK} = ? """ self.db.execute(sql, (ScheduleStatus.DECLINED.value, declined_at, decline_reason, schedule_id)) # ------------------------------------------------------------------ # Same‑day helper – used by the scheduling service # ------------------------------------------------------------------ def has_schedule_on_date(self, member_id: int, service_date: str) -> bool: """ Return ``True`` if any *active* schedule (pending or accepted) exists for ``member_id`` on the calendar day ``service_date`` (format YYYY‑MM‑DD). This abstracts the "a member can only be actively scheduled once per day" rule so the service layer does not need to know the underlying table layout. Declined schedules do not count as blocking. """ sql = f""" SELECT 1 FROM {self._TABLE} AS s JOIN Services AS sv ON s.ServiceId = sv.ServiceId WHERE s.MemberId = ? AND sv.ServiceDate = ? AND s.Status IN (?, ?) LIMIT 1 """ row = self.db.fetchone(sql, (member_id, service_date, ScheduleStatus.PENDING.value, ScheduleStatus.ACCEPTED.value)) return row is not None # ------------------------------------------------------------------ # Miscellaneous convenience queries # ------------------------------------------------------------------ def get_pending_for_service(self, service_id: int) -> List[ScheduleModel]: """All PENDING schedules for a given service.""" sql = f""" SELECT * FROM {self._TABLE} WHERE ServiceId = ? AND Status = ? """ rows = self.db.fetchall(sql, (service_id, ScheduleStatus.PENDING.value)) return [ScheduleModel.from_row(r) for r in rows] def delete_schedule(self, schedule_id: int) -> bool: """ Delete a schedule by ID. Returns: bool: True if a schedule was deleted, False if not found """ sql = f"DELETE FROM {self._TABLE} WHERE {self._PK} = ?" cursor = self.db.execute(sql, (schedule_id,)) return cursor.rowcount > 0