feat(backend): consolidate queue logic for scheduling
This commit is contained in:
@@ -1,41 +1,524 @@
|
||||
import datetime as dt
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
from .connection import DatabaseConnection
|
||||
from .models import Classification, Member, ServiceType, Service, ServiceAvailability
|
||||
from .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
|
||||
|
||||
def create_classification(self, classification_name: str):
|
||||
classification = Classification(classification_name)
|
||||
classification.save(self.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
|
||||
|
||||
def create_member(self, first_name: str, last_name: str, email: str, phone_number: str, classification_id: int, notes: str = None):
|
||||
member = Member(first_name, last_name, email, phone_number, classification_id, notes)
|
||||
member.save(self.db)
|
||||
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_service_type(self, type_name: str):
|
||||
service_type = ServiceType(type_name)
|
||||
service_type.save(self.db)
|
||||
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(self, service_type_id: int, service_date: str):
|
||||
service = Service(service_type_id, service_date)
|
||||
service.save(self.db)
|
||||
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_availability(self, member_id: int, service_type_id: int):
|
||||
service_availability = ServiceAvailability(member_id, service_type_id)
|
||||
service_availability.save(self.db)
|
||||
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 get_all_classifications(self):
|
||||
return Classification.get_all(self.db)
|
||||
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
|
||||
|
||||
def get_all_members(self):
|
||||
return Member.get_all(self.db)
|
||||
# -----------------------------------------------------------------
|
||||
# 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_service_types(self):
|
||||
return ServiceType.get_all(self.db)
|
||||
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_services(self):
|
||||
return Service.get_all(self.db)
|
||||
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_service_availability(self):
|
||||
return ServiceAvailability.get_all(self.db)
|
||||
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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user