feat(backend): consolidate queue logic for scheduling

This commit is contained in:
2025-08-21 17:17:42 -04:00
parent 8f0dc0d658
commit d0dbba21fb
19 changed files with 1256 additions and 146 deletions

View File

@@ -1,24 +1,170 @@
# backend/database/connection.py
"""
Thin convenience layer over the builtin ``sqlite3`` module.
Why we need a wrapper
---------------------
* The repository (`repository.py`) expects the following public API:
- ``execute`` run an INSERT/UPDATE/DELETE.
- ``fetchone`` / ``fetchall`` run a SELECT and get the result(s).
- ``lastrowid`` primarykey of the most recent INSERT.
- ``close`` close the DB connection.
* A wrapper lets us:
• Set a sensible ``row_factory`` (``sqlite3.Row``) so column names are
accessible as ``row["ColumnName"]``.
• Centralise ``commit``/``rollback`` handling.
• Provide type hints and a contextmanager interface
(``with DatabaseConnection(...):``) which is handy for tests.
* No external dependencies everything stays purePython/SQLite.
The implementation below is deliberately tiny: it only does what the
application needs while remaining easy to extend later (e.g. add
connectionpooling or logging).
"""
from __future__ import annotations
import sqlite3
from typing import List, Tuple
from pathlib import Path
from typing import Any, Iterable, Tuple, List, Optional, Union
class DatabaseConnection:
def __init__(self, db_name: str):
self.conn = sqlite3.connect(db_name)
self.cursor = self.conn.cursor()
"""
Simple wrapper around a SQLite connection.
def close_connection(self):
self.conn.close()
Core behaviour
---------------
* ``row_factory`` is set to :class:`sqlite3.Row` callers can use
``row["col_name"]`` or treat the row like a mapping.
* All ``execute`` calls are automatically committed.
If an exception bubbles out, the transaction is rolled back.
* ``execute`` returns the cursor so callers can chain
``cursor.lastrowid`` if they need the autogenerated PK.
* Implements the contextmanager protocol (``with ... as db:``).
def execute_query(self, query: str, params: Tuple = None):
if params:
self.cursor.execute(query, params)
The public API matches what ``Repository`` expects:
- execute(sql, params=None) → None
- fetchone(sql, params=None) → Optional[sqlite3.Row]
- fetchall(sql, params=None) → List[sqlite3.Row]
- lastrowid → int
- close()
"""
# -----------------------------------------------------------------
# Construction / contextmanager protocol
# -----------------------------------------------------------------
def __init__(
self,
db_path: Union[str, Path],
*,
timeout: float = 5.0,
detect_types: int = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
) -> None:
"""
Parameters
----------
db_path
Path to the SQLite file. ``":memory:"`` works for tests.
timeout
Seconds to wait for a lock before raising ``sqlite3.OperationalError``.
detect_types
Enable type conversion (so DATE/DATETIME are returned as ``datetime``).
"""
self._conn: sqlite3.Connection = sqlite3.connect(
str(db_path), timeout=timeout, detect_types=detect_types
)
# ``Row`` makes column access dictionarylike and preserves order.
self._conn.row_factory = sqlite3.Row
self._cursor: sqlite3.Cursor = self._conn.cursor()
def __enter__(self) -> "DatabaseConnection":
"""Allow ``with DatabaseConnection(...) as db:`` usage."""
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> Optional[bool]:
"""
On normal exit commit the transaction, otherwise roll back.
Returning ``False`` propagates any exception.
"""
if exc_type is None:
try:
self._conn.commit()
finally:
self.close()
else:
self.cursor.execute(query)
self.conn.commit()
# Something went wrong roll back to keep the DB clean.
self._conn.rollback()
self.close()
# ``None`` means “dont suppress exceptions”
return None
def execute_query_with_return(self, query: str, params: Tuple = None):
if params:
self.cursor.execute(query, params)
# -----------------------------------------------------------------
# Lowlevel helpers used by the repository
# -----------------------------------------------------------------
@property
def cursor(self) -> sqlite3.Cursor:
"""Expose the underlying cursor rarely needed outside the repo."""
return self._cursor
@property
def lastrowid(self) -> int:
"""PK of the most recent ``INSERT`` executed on this connection."""
return self._cursor.lastrowid
# -----------------------------------------------------------------
# Public API the four methods used throughout the code base
# -----------------------------------------------------------------
def execute(self, sql: str, params: Optional[Tuple[Any, ...]] = None) -> None:
"""
Run an INSERT/UPDATE/DELETE statement and commit immediately.
``params`` may be ``None`` (no placeholders) or a tuple of values.
"""
try:
if params is None:
self._cursor.execute(sql)
else:
self._cursor.execute(sql, params)
self._conn.commit()
except Exception:
# Ensure we dont leave the connection in a halfcommitted state.
self._conn.rollback()
raise
def fetchone(
self, sql: str, params: Optional[Tuple[Any, ...]] = None
) -> Optional[sqlite3.Row]:
"""
Execute a SELECT that returns at most one row.
Returns ``None`` when the result set is empty.
"""
if params is None:
self._cursor.execute(sql)
else:
self.cursor.execute(query)
return self.cursor.fetchall()
self._cursor.execute(sql, params)
return self._cursor.fetchone()
def fetchall(
self, sql: str, params: Optional[Tuple[Any, ...]] = None
) -> List[sqlite3.Row]:
"""
Execute a SELECT and return **all** rows as a list.
The rows are ``sqlite3.Row`` instances, which behave like dicts.
"""
if params is None:
self._cursor.execute(sql)
else:
self._cursor.execute(sql, params)
return self._cursor.fetchall()
def close(self) -> None:
"""Close the underlying SQLite connection."""
# ``cursor`` is automatically closed when the connection closes,
# but we explicitly close it for clarity.
try:
self._cursor.close()
finally:
self._conn.close()

View File

@@ -1,6 +1,22 @@
# database/models/__init__.py
# backend/database/models/__init__.py
from .classification import Classification
from .member import Member
from .service_type import ServiceType
from .servicetype import ServiceType
from .service import Service
from .service_availability import ServiceAvailability
from .serviceavailability import ServiceAvailability
from .schedule import Schedule
from .acceptedlog import AcceptedLog
from .declinelog import DeclineLog
from .scheduledlog import ScheduledLog
__all__ = [
"Classification",
"Member",
"ServiceType",
"Service",
"ServiceAvailability",
"Schedule",
"AcceptedLog",
"DeclineLog",
"ScheduledLog",
]

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from dataclasses import dataclass, asdict, fields
from datetime import date, datetime
from typing import Any, Dict, Tuple, Type, TypeVar, Union
Row = Tuple[Any, ...] | Dict[str, Any] # what sqlite3.Row returns
T = TypeVar("T", bound="BaseModel")
@dataclass()
class BaseModel:
"""A tiny helper that gives every model a common interface."""
@classmethod
def from_row(cls: Type[T], row: Row) -> T:
"""
Build a model instance from a sqlite3.Row (or a dictlike object).
Column names are matched to the dataclass field names.
"""
if isinstance(row, dict):
data = row
else: # sqlite3.Row behaves like a mapping, but we guard for safety
data = dict(row)
# Convert raw strings to proper Python types where we know the annotation
converted: Dict[str, Any] = {}
for f in fields(cls):
value = data.get(f.name)
if value is None:
converted[f.name] = None
continue
# datetime/date handling sqlite returns str in ISO format
if f.type is datetime:
converted[f.name] = datetime.fromisoformat(value)
elif f.type is date:
converted[f.name] = date.fromisoformat(value)
else:
converted[f.name] = value
return cls(**converted) # type: ignore[arg-type]
def to_dict(self) -> Dict[str, Any]:
"""Return a plain dict (useful for INSERT/UPDATE statements)."""
return asdict(self)
def __repr__(self) -> str: # a nicer representation when printing
field_vals = ", ".join(f"{f.name}={getattr(self, f.name)!r}" for f in fields(self))
return f"{self.__class__.__name__}({field_vals})"

View File

@@ -0,0 +1,11 @@
from dataclasses import dataclass
from datetime import datetime
from ._base import BaseModel
@dataclass()
class AcceptedLog(BaseModel):
LogId: int
MemberId: int
ServiceId: int
AcceptedAt: datetime

View File

@@ -1,14 +1,8 @@
from ..connection import DatabaseConnection
from dataclasses import dataclass
from ._base import BaseModel
class Classification:
def __init__(self, classification_name: str):
self.classification_name = classification_name
def save(self, db: DatabaseConnection):
query = "INSERT INTO Classifications (ClassificationName) VALUES (?)"
db.execute_query(query, (self.classification_name,))
@classmethod
def get_all(cls, db: DatabaseConnection):
query = "SELECT * FROM Classifications"
return db.execute_query_with_return(query)
@dataclass()
class Classification(BaseModel):
ClassificationId: int
ClassificationName: str

View File

@@ -0,0 +1,14 @@
from dataclasses import dataclass
from datetime import datetime, date
from typing import Optional
from ._base import BaseModel
@dataclass()
class DeclineLog(BaseModel):
DeclineId: int
MemberId: int
ServiceId: int
DeclinedAt: datetime
DeclineDate: date # the service day that was declined
Reason: Optional[str] = None

View File

@@ -1,19 +1,20 @@
from ..connection import DatabaseConnection
from dataclasses import dataclass
from datetime import datetime, date
from typing import Optional
from ._base import BaseModel
class Member:
def __init__(self, first_name: str, last_name: str, email: str, phone_number: str, classification_id: int, notes: str = None):
self.first_name = first_name
self.last_name = last_name
self.email = email
self.phone_number = phone_number
self.classification_id = classification_id
self.notes = notes
def save(self, db: DatabaseConnection):
query = "INSERT INTO Members (FirstName, LastName, Email, PhoneNumber, ClassificationId, Notes) VALUES (?, ?, ?, ?, ?, ?)"
db.execute_query(query, (self.first_name, self.last_name, self.email, self.phone_number, self.classification_id, self.notes))
@classmethod
def get_all(cls, db: DatabaseConnection):
query = "SELECT * FROM Members"
return db.execute_query_with_return(query)
@dataclass
class Member(BaseModel):
MemberId: int
FirstName: str
LastName: str
Email: Optional[str] = None
PhoneNumber: Optional[str] = None
ClassificationId: Optional[int] = None
Notes: Optional[str] = None
IsActive: int = 1
LastScheduledAt: Optional[datetime] = None
LastAcceptedAt: Optional[datetime] = None
LastDeclinedAt: Optional[datetime] = None
DeclineStreak: int = 0

View File

@@ -0,0 +1,17 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from ._base import BaseModel
@dataclass
class Schedule(BaseModel):
ScheduleId: int
ServiceId: int
MemberId: int
Status: str # 'pending' | 'accepted' | 'declined'
ScheduledAt: datetime # renamed from OfferedAt
AcceptedAt: Optional[datetime] = None
DeclinedAt: Optional[datetime] = None
ExpiresAt: Optional[datetime] = None
DeclineReason: Optional[str] = None

View File

@@ -0,0 +1,12 @@
from dataclasses import dataclass
from datetime import datetime
from ._base import BaseModel
@dataclass()
class ScheduledLog(BaseModel):
LogId: int
MemberId: int
ServiceId: int
ScheduledAt: datetime
ExpiresAt: datetime

View File

@@ -1,15 +1,10 @@
from ..connection import DatabaseConnection
from dataclasses import dataclass
from datetime import date
from ._base import BaseModel
class Service:
def __init__(self, service_type_id: int, service_date: str):
self.service_type_id = service_type_id
self.service_date = service_date
def save(self, db: DatabaseConnection):
query = "INSERT INTO Services (ServiceTypeId, ServiceDate) VALUES (?, ?)"
db.execute_query(query, (self.service_type_id, self.service_date))
@classmethod
def get_all(cls, db: DatabaseConnection):
query = "SELECT * FROM Services"
return db.execute_query_with_return(query)
@dataclass()
class Service(BaseModel):
ServiceId: int
ServiceTypeId: int
ServiceDate: date

View File

@@ -1,15 +0,0 @@
from ..connection import DatabaseConnection
class ServiceAvailability:
def __init__(self, member_id: int, service_type_id: int):
self.member_id = member_id
self.service_type_id = service_type_id
def save(self, db: DatabaseConnection):
query = "INSERT INTO ServiceAvailability (MemberId, ServiceTypeId) VALUES (?, ?)"
db.execute_query(query, (self.member_id, self.service_type_id))
@classmethod
def get_all(cls, db: DatabaseConnection):
query = "SELECT * FROM ServiceAvailability"
return db.execute_query_with_return(query)

View File

@@ -1,14 +0,0 @@
from ..connection import DatabaseConnection
class ServiceType:
def __init__(self, type_name: str):
self.type_name = type_name
def save(self, db: DatabaseConnection):
query = "INSERT INTO ServiceTypes (TypeName) VALUES (?)"
db.execute_query(query, (self.type_name,))
@classmethod
def get_all(cls, db: DatabaseConnection):
query = "SELECT * FROM ServiceTypes"
return db.execute_query_with_return(query)

View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass
from ._base import BaseModel
@dataclass()
class ServiceAvailability(BaseModel):
ServiceAvailabilityId: int
MemberId: int
ServiceTypeId: int

View File

@@ -0,0 +1,8 @@
from dataclasses import dataclass
from ._base import BaseModel
@dataclass()
class ServiceType(BaseModel):
ServiceTypeId: int
TypeName: str

View File

@@ -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:
"""
Highlevel dataaccess layer.
Responsibilities
----------------
* CRUD helpers for the core tables.
* Roundrobin queue that respects:
- Members.LastAcceptedAt (fair order)
- Members.LastDeclinedAt (oneday cooloff)
* “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 nonexpired 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 (highlevel):
1⃣ 5day decline boost only if DeclineStreak < 2.
2⃣ Oldest LastAcceptedAt (roundrobin).
3⃣ Oldest LastScheduledAt (tiebreaker).
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
/* ① 5day 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,
/* ② Roundrobin: oldest acceptance first */
COALESCE(LastAcceptedAt, '1970-01-01') ASC,
/* ③ Tiebreaker: 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 roundrobin 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 roundrobin) and clears any cooloff.
"""
# 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 roundrobin for the member
self.db.execute(
"""
UPDATE Members
SET LastAcceptedAt = CURRENT_TIMESTAMP,
LastDeclinedAt = NULL -- a successful accept clears any cooloff
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 oneday cooloff.
* 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 oneday cooloff
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 'YYYYMMDD'
# 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 cooloff 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),
)

130
backend/database/schema.sql Normal file
View File

@@ -0,0 +1,130 @@
-- ==============================================================
-- SQLite schema for the scheduling system
-- * roundrobin queue is driven by Members.LastAcceptedAt
-- * oneday cooloff is driven by Members.LastDeclinedAt (DATE)
-- * reservations are stored in Schedules (Status = 'pending' | 'accepted' | 'declined')
-- * audit tables: AcceptedLog, DeclineLog, ScheduledLog
-- ==============================================================
PRAGMA foreign_keys = ON;
-- -----------------------------------------------------------------
-- 1. Core lookup tables
-- -----------------------------------------------------------------
CREATE TABLE Classifications (
ClassificationId INTEGER PRIMARY KEY AUTOINCREMENT,
ClassificationName TEXT NOT NULL UNIQUE
);
CREATE TABLE ServiceTypes (
ServiceTypeId INTEGER PRIMARY KEY AUTOINCREMENT,
TypeName TEXT NOT NULL UNIQUE
);
-- -----------------------------------------------------------------
-- 2. Members stores roundrobin timestamps and cooloff date
-- -----------------------------------------------------------------
CREATE TABLE Members (
MemberId INTEGER PRIMARY KEY AUTOINCREMENT,
FirstName TEXT NOT NULL,
LastName TEXT NOT NULL,
Email TEXT UNIQUE,
PhoneNumber TEXT,
ClassificationId INTEGER,
Notes TEXT,
IsActive INTEGER DEFAULT 1 CHECK (IsActive IN (0,1)),
-- Queuerelated columns -------------------------------------------------
LastScheduledAt DATETIME, -- set when a position is SCHEDULED
LastAcceptedAt DATETIME, -- set when a position is ACCEPTED
LastDeclinedAt DATETIME, -- set the time a member last DECLINED
DeclineStreak INTEGER DEFAULT 0,
-------------------------------------------------------------------------
FOREIGN KEY (ClassificationId) REFERENCES Classifications(ClassificationId)
);
CREATE INDEX idx_members_queue
ON Members (ClassificationId, IsActive,
LastDeclinedAt, LastAcceptedAt, LastScheduledAt);
-- -----------------------------------------------------------------
-- 3. Services a concrete service on a given date
-- -----------------------------------------------------------------
CREATE TABLE Services (
ServiceId INTEGER PRIMARY KEY AUTOINCREMENT,
ServiceTypeId INTEGER NOT NULL,
ServiceDate DATE NOT NULL,
FOREIGN KEY (ServiceTypeId) REFERENCES ServiceTypes(ServiceTypeId)
);
-- -----------------------------------------------------------------
-- 4. ServiceAvailability which members are eligible for which service types
-- -----------------------------------------------------------------
CREATE TABLE ServiceAvailability (
ServiceAvailabilityId INTEGER PRIMARY KEY AUTOINCREMENT,
MemberId INTEGER NOT NULL,
ServiceTypeId INTEGER NOT NULL,
UNIQUE (MemberId, ServiceTypeId),
FOREIGN KEY (MemberId) REFERENCES Members(MemberId),
FOREIGN KEY (ServiceTypeId) REFERENCES ServiceTypes(ServiceTypeId)
);
-- -----------------------------------------------------------------
-- 5. Schedules also acts as the reservation/lock table
-- -----------------------------------------------------------------
CREATE TABLE Schedules (
ScheduleId INTEGER PRIMARY KEY AUTOINCREMENT,
ServiceId INTEGER NOT NULL,
MemberId INTEGER NOT NULL,
-- Reservation / status columns -----------------------------------------
Status TEXT NOT NULL CHECK (Status IN ('pending','accepted','declined')),
ScheduledAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- renamed from OfferedAt
AcceptedAt DATETIME, -- set when status -> 'accepted'
DeclinedAt DATETIME, -- set when status -> 'declined'
ExpiresAt DATETIME, -- pending rows expire after X minutes
DeclineReason TEXT,
-------------------------------------------------------------------------
UNIQUE (ServiceId, MemberId), -- one row per member per service
FOREIGN KEY (MemberId) REFERENCES Members(MemberId),
FOREIGN KEY (ServiceId) REFERENCES Services(ServiceId)
);
CREATE INDEX idx_sched_member_service_status
ON Schedules(MemberId, ServiceId, Status);
-- -----------------------------------------------------------------
-- 6. Audit tables
-- -----------------------------------------------------------------
CREATE TABLE AcceptedLog (
LogId INTEGER PRIMARY KEY AUTOINCREMENT,
MemberId INTEGER NOT NULL,
ServiceId INTEGER NOT NULL,
AcceptedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (MemberId) REFERENCES Members(MemberId),
FOREIGN KEY (ServiceId) REFERENCES Services(ServiceId)
);
CREATE TABLE DeclineLog (
DeclineId INTEGER PRIMARY KEY AUTOINCREMENT,
MemberId INTEGER NOT NULL,
ServiceId INTEGER NOT NULL,
DeclinedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
DeclineDate DATE NOT NULL, -- the service day that was declined
Reason TEXT,
FOREIGN KEY (MemberId) REFERENCES Members(MemberId),
FOREIGN KEY (ServiceId) REFERENCES Services(ServiceId)
);
CREATE TABLE ScheduledLog (
LogId INTEGER PRIMARY KEY AUTOINCREMENT,
MemberId INTEGER NOT NULL,
ServiceId INTEGER NOT NULL,
ScheduledAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ExpiresAt DATETIME,
FOREIGN KEY (MemberId) REFERENCES Members(MemberId),
FOREIGN KEY (ServiceId) REFERENCES Services(ServiceId)
);