import datetime as dt from typing import Optional, Tuple, List from backend.db.connection import DatabaseConnection from backend.models import ( Classification, Member, ServiceType, Service, ServiceAvailability, Schedule, AcceptedLog, DeclineLog, ScheduledLog, ) class Repository: """ High‑level data‑access layer. Responsibilities ---------------- * CRUD helpers for the core tables. * Round‑robin queue that respects: - Members.LastAcceptedAt (fair order) - Members.LastDeclinedAt (one‑day cool‑off) * “Reservation” handling using the **Schedules** table (pending → accepted → declined). * Audit logging (AcceptedLog, DeclineLog, ScheduledLog). """ def __init__(self, db: DatabaseConnection): self.db = db # ----------------------------------------------------------------- # CRUD helpers – they now return model objects (or IDs) # ----------------------------------------------------------------- # ----------------------------------------------------------------- # CREATE # ----------------------------------------------------------------- def create_classification(self, classification_name: str) -> Classification: """Insert a new classification and return the saved model.""" classification = Classification( ClassificationId=-1, # placeholder – will be replaced by DB ClassificationName=classification_name, ) # Build INSERT statement from the dataclass dict (skip PK) data = classification.to_dict() data.pop("ClassificationId") # AUTOINCREMENT column cols = ", ".join(data.keys()) placeholders = ", ".join("?" for _ in data) sql = f"INSERT INTO Classifications ({cols}) VALUES ({placeholders})" self.db.execute(sql, tuple(data.values())) classification.ClassificationId = self.db.lastrowid return classification def create_member( self, first_name: str, last_name: str, email: Optional[str] = None, phone_number: Optional[str] = None, classification_id: Optional[int] = None, notes: Optional[str] = None, is_active: int = 1, ) -> Member: """Insert a new member and return the saved model.""" member = Member( MemberId=-1, FirstName=first_name, LastName=last_name, Email=email, PhoneNumber=phone_number, ClassificationId=classification_id, Notes=notes, IsActive=is_active, LastAcceptedAt=None, LastDeclinedAt=None, ) data = member.to_dict() data.pop("MemberId") # let SQLite fill the PK cols = ", ".join(data.keys()) placeholders = ", ".join("?" for _ in data) sql = f"INSERT INTO Members ({cols}) VALUES ({placeholders})" self.db.execute(sql, tuple(data.values())) member.MemberId = self.db.lastrowid return member def create_service_type(self, type_name: str) -> ServiceType: """Insert a new service type.""" st = ServiceType(ServiceTypeId=-1, TypeName=type_name) data = st.to_dict() data.pop("ServiceTypeId") cols = ", ".join(data.keys()) placeholders = ", ".join("?" for _ in data) sql = f"INSERT INTO ServiceTypes ({cols}) VALUES ({placeholders})" self.db.execute(sql, tuple(data.values())) st.ServiceTypeId = self.db.lastrowid return st def create_service(self, service_type_id: int, service_date: dt.date) -> Service: """Insert a new service row (date + type).""" sv = Service(ServiceId=-1, ServiceTypeId=service_type_id, ServiceDate=service_date) data = sv.to_dict() data.pop("ServiceId") cols = ", ".join(data.keys()) placeholders = ", ".join("?" for _ in data) sql = f"INSERT INTO Services ({cols}) VALUES ({placeholders})" self.db.execute(sql, tuple(data.values())) sv.ServiceId = self.db.lastrowid return sv def create_service_availability(self, member_id: int, service_type_id: int) -> ServiceAvailability: """Link a member to a service type (availability matrix).""" sa = ServiceAvailability( ServiceAvailabilityId=-1, MemberId=member_id, ServiceTypeId=service_type_id, ) data = sa.to_dict() data.pop("ServiceAvailabilityId") cols = ", ".join(data.keys()) placeholders = ", ".join("?" for _ in data) sql = f"INSERT INTO ServiceAvailability ({cols}) VALUES ({placeholders})" self.db.execute(sql, tuple(data.values())) sa.ServiceAvailabilityId = self.db.lastrowid return sa # ----------------------------------------------------------------- # READ – return **lists of models** # ----------------------------------------------------------------- def get_all_classifications(self) -> List[Classification]: rows = self.db.fetchall("SELECT * FROM Classifications") return [Classification.from_row(r) for r in rows] def get_all_members(self) -> List[Member]: rows = self.db.fetchall("SELECT * FROM Members") return [Member.from_row(r) for r in rows] def get_all_service_types(self) -> List[ServiceType]: rows = self.db.fetchall("SELECT * FROM ServiceTypes") return [ServiceType.from_row(r) for r in rows] def get_all_services(self) -> List[Service]: rows = self.db.fetchall("SELECT * FROM Services") return [Service.from_row(r) for r in rows] def get_all_service_availability(self) -> List[ServiceAvailability]: rows = self.db.fetchall("SELECT * FROM ServiceAvailability") return [ServiceAvailability.from_row(r) for r in rows] # ----------------------------------------------------------------- # INTERNAL helpers used by the queue logic # ----------------------------------------------------------------- def _lookup_classification(self, name: str) -> int: """Return ClassificationId for a given name; raise if missing.""" row = self.db.fetchone( "SELECT ClassificationId FROM Classifications WHERE ClassificationName = ?", (name,), ) if row is None: raise ValueError(f'Classification "{name}" does not exist') return row["ClassificationId"] def _ensure_service(self, service_date: dt.date) -> int: """ Return a ServiceId for ``service_date``. If the row does not exist we create a generic Service row (using the first ServiceType as a default). """ row = self.db.fetchone( "SELECT ServiceId FROM Services WHERE ServiceDate = ?", (service_date,) ) if row: return row["ServiceId"] default_type = self.db.fetchone( "SELECT ServiceTypeId FROM ServiceTypes LIMIT 1" ) if not default_type: raise RuntimeError( "No ServiceTypes defined – cannot create a Service row" ) self.db.execute( "INSERT INTO Services (ServiceTypeId, ServiceDate) VALUES (?,?)", (default_type["ServiceTypeId"], service_date), ) return self.db.lastrowid def has_schedule_for_service( self, member_id: int, service_id: int, status: str, include_expired: bool = False, ) -> bool: """ Return True if the member has a schedule row for the given ``service_id`` with the specified ``status``. For ``status='pending'`` the default behaviour is to ignore rows whose ``ExpiresAt`` timestamp is already in the past (they are not actionable). Set ``include_expired=True`` if you deliberately want to see *any* pending row regardless of its expiration. Parameters ---------- member_id : int The member we are inspecting. service_id : int The service we are interested in. status : str One of the schedule statuses (e.g. ``'accepted'`` or ``'pending'``). include_expired : bool, optional When checking for pending rows, ignore the expiration guard if set to ``True``. Defaults to ``False`` (i.e. only non‑expired pending rows count). Returns ------- bool True if a matching row exists, otherwise False. """ sql = """ SELECT 1 FROM Schedules WHERE MemberId = ? AND ServiceId = ? AND Status = ? """ args = [member_id, service_id, status] # Guard against expired pending rows unless the caller explicitly wants them. if not include_expired and status == "pending": sql += " AND ExpiresAt > CURRENT_TIMESTAMP" sql += " LIMIT 1" row = self.db.fetchone(sql, tuple(args)) return row is not None def schedule_next_member( self, classification_id: int, service_id: int, only_active: bool = True, ) -> Optional[Tuple[int, str, str, int]]: """ Choose the next member for ``service_id`` while respecting ServiceAvailability. Ordering (high‑level): 1️⃣ 5‑day decline boost – only if DeclineStreak < 2. 2️⃣ Oldest LastAcceptedAt (round‑robin). 3️⃣ Oldest LastScheduledAt (tie‑breaker). Skipped if any of the following is true: • Member lacks a ServiceAvailability row for the ServiceType of ``service_id``. • Member already has an *accepted* schedule for this service. • Member already has a *pending* schedule for this service. • Member already has a *declined* schedule for this service. """ # ----------------------------------------------------------------- # 0️⃣ Resolve ServiceTypeId (and ServiceDate) from the Services table. # ----------------------------------------------------------------- svc_row = self.db.fetchone( "SELECT ServiceTypeId, ServiceDate FROM Services WHERE ServiceId = ?", (service_id,), ) if not svc_row: # No such service – nothing to schedule. return None service_type_id = svc_row["ServiceTypeId"] # If you need the actual calendar date later you can use: # service_date = dt.datetime.strptime(svc_row["ServiceDate"], "%Y-%m-%d").date() # ----------------------------------------------------------------- # 1️⃣ Pull the candidate queue, ordered per the existing rules. # ----------------------------------------------------------------- BOOST_SECONDS = 5 * 24 * 60 * 60 # 5 days now_iso = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") sql = f""" SELECT MemberId, FirstName, LastName, LastAcceptedAt, LastScheduledAt, LastDeclinedAt, DeclineStreak FROM Members WHERE ClassificationId = ? {"AND IsActive = 1" if only_active else ""} ORDER BY /* ① 5‑day boost (only when streak < 2) */ CASE WHEN DeclineStreak < 2 AND LastDeclinedAt IS NOT NULL AND julianday(?) - julianday(LastDeclinedAt) <= (? / 86400.0) THEN 0 -- boosted to the front ELSE 1 END, /* ② Round‑robin: oldest acceptance first */ COALESCE(LastAcceptedAt, '1970-01-01') ASC, /* ③ Tie‑breaker: oldest offer first */ COALESCE(LastScheduledAt, '1970-01-01') ASC """ queue = self.db.fetchall(sql, (classification_id, now_iso, BOOST_SECONDS)) # ----------------------------------------------------------------- # 2️⃣ Walk the ordered queue and apply availability + status constraints. # ----------------------------------------------------------------- for member in queue: member_id = member["MemberId"] # ----- Availability check ------------------------------------------------- # Skip members that do NOT have a row in ServiceAvailability for this # ServiceType. avail_ok = self.db.fetchone( """ SELECT 1 FROM ServiceAvailability WHERE MemberId = ? AND ServiceTypeId = ? LIMIT 1 """, (member_id, service_type_id), ) if not avail_ok: continue # Not eligible for this service type. # ----- Status constraints (all by service_id) ---------------------------- # a) Already *accepted* for this service? if self.has_schedule_for_service(member_id, service_id, status="accepted"): continue # b) Existing *pending* reservation for this service? if self.has_schedule_for_service(member_id, service_id, status="pending"): continue # c) Already *declined* this service? if self.has_schedule_for_service(member_id, service_id, status="declined"): continue # ------------------------------------------------------------- # SUCCESS – create a pending schedule (minimal columns). # ------------------------------------------------------------- self.db.execute( """ INSERT INTO Schedules (ServiceId, MemberId, Status) VALUES (?,?,?) """, (service_id, member_id, "pending"), ) schedule_id = self.db.lastrowid # ------------------------------------------------------------- # Update the member's LastScheduledAt so the round‑robin stays fair. # ------------------------------------------------------------- self.db.execute( """ UPDATE Members SET LastScheduledAt = CURRENT_TIMESTAMP WHERE MemberId = ? """, (member_id,), ) # ------------------------------------------------------------- # Audit log – historic record (no ScheduleId column any more). # ------------------------------------------------------------- self.db.execute( """ INSERT INTO ScheduledLog (MemberId, ServiceId) VALUES (?,?) """, (member_id, service_id), ) # ------------------------------------------------------------- # Return the useful bits to the caller. # ------------------------------------------------------------- return ( member_id, member["FirstName"], member["LastName"], schedule_id, ) # ----------------------------------------------------------------- # No eligible member found. # ----------------------------------------------------------------- return None # ----------------------------------------------------------------- # ACCEPT / DECLINE workflow (operates on the schedule row) # ----------------------------------------------------------------- def accept_schedule(self, schedule_id: int) -> None: """ Convert a *pending* schedule into a real assignment. - Updates the schedule row (status → accepted, timestamp). - Writes an entry into ``AcceptedLog``. - Updates ``Members.LastAcceptedAt`` (advances round‑robin) and clears any cool‑off. """ # Load the pending schedule – raise if it does not exist or is not pending sched = self.db.fetchone( """ SELECT ScheduleId, ServiceId, MemberId FROM Schedules WHERE ScheduleId = ? AND Status = 'pending' """, (schedule_id,), ) if not sched: raise ValueError("Schedule not found or not pending") service_id = sched["ServiceId"] member_id = sched["MemberId"] # 1️⃣ Mark the schedule as accepted self.db.execute( """ UPDATE Schedules SET Status = 'accepted', AcceptedAt = CURRENT_TIMESTAMP, ExpiresAt = CURRENT_TIMESTAMP -- no longer expires WHERE ScheduleId = ? """, (schedule_id,), ) # 2️⃣ Audit log self.db.execute( """ INSERT INTO AcceptedLog (MemberId, ServiceId) VALUES (?,?) """, (member_id, service_id), ) # 3️⃣ Advance round‑robin for the member self.db.execute( """ UPDATE Members SET LastAcceptedAt = CURRENT_TIMESTAMP, LastDeclinedAt = NULL -- a successful accept clears any cool‑off WHERE MemberId = ? """, (member_id,), ) def decline_schedule( self, schedule_id: int, reason: Optional[str] = None ) -> None: """ Record that the member declined the offered slot. Effects ------- * Inserts a row into ``DeclineLog`` (with the service day). * Updates ``Members.LastDeclinedAt`` – this implements the one‑day cool‑off. * Marks the schedule row as ``declined`` (so it can be offered to someone else). """ # Load the pending schedule – raise if not found / not pending sched = self.db.fetchone( """ SELECT ScheduleId, ServiceId, MemberId FROM Schedules WHERE ScheduleId = ? AND Status = 'pending' """, (schedule_id,), ) if not sched: raise ValueError("Schedule not found or not pending") service_id = sched["ServiceId"] member_id = sched["MemberId"] # Need the service *day* for the one‑day cool‑off svc = self.db.fetchone( "SELECT ServiceDate FROM Services WHERE ServiceId = ?", (service_id,) ) if not svc: raise RuntimeError("Service row vanished while processing decline") service_day = svc["ServiceDate"] # stored as TEXT 'YYYY‑MM‑DD' # 1️⃣ Insert into DeclineLog self.db.execute( """ INSERT INTO DeclineLog (MemberId, ServiceId, DeclineDate, Reason) VALUES (?,?,?,?) """, (member_id, service_id, service_day, reason), ) # 2️⃣ Update the member's cool‑off day self.db.execute( """ UPDATE Members SET LastDeclinedAt = ? WHERE MemberId = ? """, (service_day, member_id), ) # 3️⃣ Mark the schedule row as declined self.db.execute( """ UPDATE Schedules SET Status = 'declined', DeclinedAt = CURRENT_TIMESTAMP, DeclineReason = ? WHERE ScheduleId = ? """, (reason, schedule_id), )