feat(backend): consolidate queue logic for scheduling
This commit is contained in:
4
backend/.gitignore
vendored
4
backend/.gitignore
vendored
@@ -181,5 +181,5 @@ site/
|
||||
*.old
|
||||
|
||||
# sqlite db
|
||||
database.db
|
||||
database.db-journal
|
||||
*.db
|
||||
*.db-journal
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal 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`` – primary‑key 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 context‑manager interface
|
||||
(``with DatabaseConnection(...):``) which is handy for tests.
|
||||
* No external dependencies – everything stays pure‑Python/SQLite.
|
||||
|
||||
The implementation below is deliberately tiny: it only does what the
|
||||
application needs while remaining easy to extend later (e.g. add
|
||||
connection‑pooling 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 context‑manager 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 / context‑manager 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 dictionary‑like 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 “don’t suppress exceptions”
|
||||
return None
|
||||
|
||||
def execute_query_with_return(self, query: str, params: Tuple = None):
|
||||
if params:
|
||||
self.cursor.execute(query, params)
|
||||
# -----------------------------------------------------------------
|
||||
# Low‑level 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 don’t leave the connection in a half‑committed 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()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
49
backend/database/models/_base.py
Normal file
49
backend/database/models/_base.py
Normal 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 dict‑like 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})"
|
||||
11
backend/database/models/acceptedlog.py
Normal file
11
backend/database/models/acceptedlog.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
14
backend/database/models/declinelog.py
Normal file
14
backend/database/models/declinelog.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
17
backend/database/models/schedule.py
Normal file
17
backend/database/models/schedule.py
Normal 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
|
||||
12
backend/database/models/scheduledlog.py
Normal file
12
backend/database/models/scheduledlog.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
9
backend/database/models/serviceavailability.py
Normal file
9
backend/database/models/serviceavailability.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
from ._base import BaseModel
|
||||
|
||||
|
||||
@dataclass()
|
||||
class ServiceAvailability(BaseModel):
|
||||
ServiceAvailabilityId: int
|
||||
MemberId: int
|
||||
ServiceTypeId: int
|
||||
8
backend/database/models/servicetype.py
Normal file
8
backend/database/models/servicetype.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from ._base import BaseModel
|
||||
|
||||
|
||||
@dataclass()
|
||||
class ServiceType(BaseModel):
|
||||
ServiceTypeId: int
|
||||
TypeName: str
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
130
backend/database/schema.sql
Normal file
130
backend/database/schema.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- ==============================================================
|
||||
-- SQLite schema for the scheduling system
|
||||
-- * round‑robin queue is driven by Members.LastAcceptedAt
|
||||
-- * one‑day cool‑off 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 round‑robin timestamps and cool‑off 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)),
|
||||
|
||||
-- Queue‑related 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)
|
||||
);
|
||||
310
backend/main.py
310
backend/main.py
@@ -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="utf‑8") 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 data‑set, run the round‑robin 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 = [
|
||||
# 1‑5 (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),
|
||||
|
||||
# 6‑10 (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),
|
||||
|
||||
# 11‑15 (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),
|
||||
|
||||
# 16‑20 (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),
|
||||
|
||||
# 21‑25 (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),
|
||||
|
||||
# 26‑30 (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️⃣ Hand‑crafted overrides (edge‑cases you want to guarantee)
|
||||
# -----------------------------------------------------------------
|
||||
# Tenor block (IDs 1‑5 & 21‑25) → 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 16‑20) → 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️⃣ Bulk‑insert 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.")
|
||||
|
||||
Reference in New Issue
Block a user