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

4
backend/.gitignore vendored
View File

@@ -181,5 +181,5 @@ site/
*.old
# sqlite db
database.db
database.db-journal
*.db
*.db-journal

0
backend/__init__.py Normal file
View File

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)
);

View File

@@ -1,38 +1,292 @@
from database.repository import Repository
from database.connection import DatabaseConnection
import os
import datetime as dt
import sqlite3
import random
from pathlib import Path
from typing import List, Dict, Set, Tuple
def main():
db = DatabaseConnection("database.db")
repository = Repository(db)
# ----------------------------------------------------------------------
# 1⃣ Helper that creates the database (runs the schema file)
# ----------------------------------------------------------------------
def init_db(db_path: Path) -> None:
"""
If the DB file does not exist, create it and run the schema.sql script.
The schema file lives in backend/database/schema.sql.
"""
if db_path.exists():
print(f"✅ Database already exists at {db_path}")
return
# Retrieve data
classifications = repository.get_all_classifications()
members = repository.get_all_members()
service_types = repository.get_all_service_types()
services = repository.get_all_services()
service_availability = repository.get_all_service_availability()
print(f"🗂️ Creating new SQLite DB at {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
print("Classifications:")
for classification in classifications:
print(classification)
schema_path = Path(__file__).parent / "database" / "schema.sql"
if not schema_path.is_file():
raise FileNotFoundError(f"Schema file not found: {schema_path}")
print("\nMembers:")
for member in members:
print(member)
with open(schema_path, "r", encoding="utf8") as f:
sql = f.read()
cur.executescript(sql)
conn.commit()
conn.close()
print("✅ Schema executed database is ready.")
print("\nService Types:")
for service_type in service_types:
print(service_type)
print("\nServices:")
for service in services:
print(service)
def next_n_sundays(n: int) -> list[dt.date]:
"""Return a list with the next `n` Sundays after today."""
today = dt.date.today()
print("\nService Availability:")
for availability in service_availability:
print(availability)
# weekday(): Mon=0 … Sun=6 → we want the offset to the *next* Sunday
days_until_sunday = (6 - today.weekday()) % 7
# If today is Sunday, days_until_sunday == 0 → we still want the *next* one
days_until_sunday = days_until_sunday or 7
db.close_connection()
first_sunday = today + dt.timedelta(days=days_until_sunday)
# Build the list of n Sundays
return [first_sunday + dt.timedelta(weeks=i) for i in range(n)]
def seed_db(repo) -> None:
"""
Populate a tiny dataset, run the roundrobin queue, accept one
schedule, decline another and print audit tables.
"""
print("\n=== 📦 Seeding reference data ===")
# ----- classifications -------------------------------------------------
baritone_cls = repo.create_classification("Baritone")
tenor_cls = repo.create_classification("Tenor")
alto_cls = repo.create_classification("Alto / Mezzo")
soprano_cls = repo.create_classification("Soprano")
print(f"""Created classifications →
{baritone_cls.ClassificationId}=Baritone,
{tenor_cls.ClassificationId}=Tenor,
{alto_cls.ClassificationId}=Alto,
{soprano_cls.ClassificationId}=Soprano\n""")
# ----- members --------------------------------------------------------
members = [
# 15 (Tenor)
repo.create_member("John", "Doe", "john.doe@example.com", "+155512340001", tenor_cls.ClassificationId),
repo.create_member("Mary", "Smith", "mary.smith@example.com", "+155512340002", tenor_cls.ClassificationId),
repo.create_member("David", "Lee", "david.lee@example.com", "+155512340003", tenor_cls.ClassificationId),
repo.create_member("Emma", "Clark", "emma.clark@example.com", "+155512340004", tenor_cls.ClassificationId),
repo.create_member("Jack", "Taylor", "jack.taylor@example.com", "+155512340005", tenor_cls.ClassificationId),
# 610 (Alto / Mezzo)
repo.create_member("Alice", "Brown", "alice.brown@example.com", "+155512340006", alto_cls.ClassificationId),
repo.create_member("Frank", "Davis", "frank.davis@example.com", "+155512340007", alto_cls.ClassificationId),
repo.create_member("Grace", "Miller", "grace.miller@example.com", "+155512340008", alto_cls.ClassificationId),
repo.create_member("Henry", "Wilson", "henry.wilson@example.com", "+155512340009", alto_cls.ClassificationId),
repo.create_member("Isla", "Anderson", "isla.anderson@example.com", "+155512340010", alto_cls.ClassificationId),
# 1115 (Soprano)
repo.create_member("Bob", "Johnson", "bob.johnson@example.com", "+155512340011", soprano_cls.ClassificationId),
repo.create_member("Kara", "Thomas", "kara.thomas@example.com", "+155512340012", soprano_cls.ClassificationId),
repo.create_member("Liam", "Jackson", "liam.jackson@example.com", "+155512340013", soprano_cls.ClassificationId),
repo.create_member("Mia", "White", "mia.white@example.com", "+155512340014", soprano_cls.ClassificationId),
repo.create_member("Noah", "Harris", "noah.harris@example.com", "+155512340015", soprano_cls.ClassificationId),
# 1620 (Baritone)
repo.create_member("Olivia", "Martin", "olivia.martin@example.com", "+155512340016", baritone_cls.ClassificationId),
repo.create_member("Paul", "Doe", "paul.doe@example.com", "+155512340017", baritone_cls.ClassificationId),
repo.create_member("Quinn", "Smith", "quinn.smith@example.com", "+155512340018", baritone_cls.ClassificationId),
repo.create_member("Ruth", "Brown", "ruth.brown@example.com", "+155512340019", baritone_cls.ClassificationId),
repo.create_member("Sam", "Lee", "sam.lee@example.com", "+155512340020", baritone_cls.ClassificationId),
# 2125 (Tenor again)
repo.create_member("Tina", "Clark", "tina.clark@example.com", "+155512340021", tenor_cls.ClassificationId),
repo.create_member("Umar", "Davis", "umar.davis@example.com", "+155512340022", tenor_cls.ClassificationId),
repo.create_member("Vera", "Miller", "vera.miller@example.com", "+155512340023", tenor_cls.ClassificationId),
repo.create_member("Walt", "Wilson", "walt.wilson@example.com", "+155512340024", tenor_cls.ClassificationId),
repo.create_member("Xena", "Anderson", "xena.anderson@example.com", "+155512340025", tenor_cls.ClassificationId),
# 2630 (Alto / Mezzo again)
repo.create_member("Yara", "Thomas", "yara.thomas@example.com", "+155512340026", alto_cls.ClassificationId),
repo.create_member("Zane", "Jackson", "zane.jackson@example.com", "+155512340027", alto_cls.ClassificationId),
repo.create_member("Anna", "White", "anna.white@example.com", "+155512340028", alto_cls.ClassificationId),
repo.create_member("Ben", "Harris", "ben.harris@example.com", "+155512340029", alto_cls.ClassificationId),
repo.create_member("Cara", "Martin", "cara.martin@example.com", "+155512340030", alto_cls.ClassificationId),
]
for m in members:
print(f" Member {m.MemberId}: {m.FirstName} {m.LastName} ({m.ClassificationId})")
print("\n")
print("=== 📦 Seeding Service Types & Availability ===")
# -----------------------------------------------------------------
# 1⃣ Service Types (keep the IDs for later use)
# -----------------------------------------------------------------
st_9am = repo.create_service_type("9AM")
st_11am = repo.create_service_type("11AM")
st_6pm = repo.create_service_type("6PM")
service_type_ids: Dict[str, int] = {
"9AM": st_9am.ServiceTypeId,
"11AM": st_11am.ServiceTypeId,
"6PM": st_6pm.ServiceTypeId,
}
print(
f"Created service types → "
f"{st_9am.ServiceTypeId}=9AM, "
f"{st_11am.ServiceTypeId}=11AM, "
f"{st_6pm.ServiceTypeId}=6PM"
)
# -----------------------------------------------------------------
# 2⃣ Build a baseline availability map (member_id → set of ServiceTypeIds)
# -----------------------------------------------------------------
def base_availability() -> Set[int]:
"""Return a set of ServiceTypeIds the member can take."""
roll = random.random()
if roll < 0.30: # ~30% get *all* three slots
return set(service_type_ids.values())
elif roll < 0.70: # ~40% get exactly two slots
return set(random.sample(list(service_type_ids.values()), 2))
else: # ~30% get a single slot
return {random.choice(list(service_type_ids.values()))}
# Populate the map for every member you created earlier
availability_map: Dict[int, Set[int]] = {}
for m in members: # `members` is the list you seeded above
availability_map[m.MemberId] = base_availability()
# -----------------------------------------------------------------
# 3⃣ Handcrafted overrides (edgecases you want to guarantee)
# -----------------------------------------------------------------
# Tenor block (IDs 15 & 2125) → only 9AM & 11AM
tenor_ids = [1, 2, 3, 4, 5, 21, 22, 23, 24, 25]
for mid in tenor_ids:
availability_map[mid] = {
service_type_ids["9AM"],
service_type_ids["11AM"],
}
# Baritone block (IDs 1620) → only 6PM
baritone_ids = [16, 17, 18, 19, 20]
for mid in baritone_ids:
availability_map[mid] = {service_type_ids["6PM"]}
# Ensure at least one member can do each slot (explicit adds)
availability_map[1].add(service_type_ids["9AM"]) # John Tenor → 9AM
availability_map[6].add(service_type_ids["11AM"]) # Alice Alto → 11AM
availability_map[11].add(service_type_ids["6PM"]) # Bob Soprano → 6PM
# -----------------------------------------------------------------
# 4⃣ Bulkinsert into ServiceAvailability
# -----------------------------------------------------------------
rows: List[Tuple[int, int]] = []
for member_id, type_set in availability_map.items():
for st_id in type_set:
rows.append((member_id, st_id))
for row in rows:
repo.db.execute(
"""
INSERT INTO ServiceAvailability (MemberId, ServiceTypeId)
VALUES (?, ?)
""",
row,
)
print(
f"Inserted {len(rows)} ServiceAvailability rows "
f"(≈ {len(members)} members × avg. {len(rows)//len(members)} slots each)."
)
# ----- service (the day we are scheduling) ---------------------------
service_dates = next_n_sundays(3)
services = []
for service_date in service_dates:
service = repo.create_service(st_6pm.ServiceTypeId, service_date)
print(f"Created Service → ServiceId={service.ServiceId}, Date={service.ServiceDate}")
services.append(service)
print("\n")
# --------------------------------------------------------------------
# 1⃣ Get the first Tenor and ACCEPT it
# --------------------------------------------------------------------
print("=== 🎯 FIRST SCHEDULE (should be John) ===")
scheduled_member = repo.schedule_next_member(
classification_id=soprano_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[2].ServiceId,
only_active=True,
)
print(scheduled_member)
# ----------------------------------------------------------------------
# 2⃣ Demo that exercises the full repository API
# ----------------------------------------------------------------------
def demo(repo) -> None:
return
# ----------------------------------------------------------------------
# 5⃣ Entrypoint
# ----------------------------------------------------------------------
if __name__ == "__main__":
main()
# --------------------------------------------------------------
# Path to the SQLite file (feel free to change)
# --------------------------------------------------------------
DB_PATH = Path(__file__).parent / "database_demo.db"
# --------------------------------------------------------------
# Initialise DB if necessary
# --------------------------------------------------------------
init_db(DB_PATH)
exit()
# --------------------------------------------------------------
# Build the connection / repository objects
# --------------------------------------------------------------
from backend.database.connection import DatabaseConnection
from backend.database.repository import Repository
db = DatabaseConnection(str(DB_PATH))
repo = Repository(db)
try:
demo(repo)
finally:
# Always close the connection SQLite locks the file while open
db.close()
print("\n✅ Demo finished connection closed.")