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
|
*.old
|
||||||
|
|
||||||
# sqlite db
|
# sqlite db
|
||||||
database.db
|
*.db
|
||||||
database.db-journal
|
*.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
|
import sqlite3
|
||||||
from typing import List, Tuple
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Tuple, List, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConnection:
|
class DatabaseConnection:
|
||||||
def __init__(self, db_name: str):
|
"""
|
||||||
self.conn = sqlite3.connect(db_name)
|
Simple wrapper around a SQLite connection.
|
||||||
self.cursor = self.conn.cursor()
|
|
||||||
|
|
||||||
def close_connection(self):
|
Core behaviour
|
||||||
self.conn.close()
|
---------------
|
||||||
|
* ``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):
|
The public API matches what ``Repository`` expects:
|
||||||
if params:
|
- execute(sql, params=None) → None
|
||||||
self.cursor.execute(query, params)
|
- 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:
|
else:
|
||||||
self.cursor.execute(query)
|
# Something went wrong – roll back to keep the DB clean.
|
||||||
self.conn.commit()
|
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:
|
# Low‑level helpers used by the repository
|
||||||
self.cursor.execute(query, params)
|
# -----------------------------------------------------------------
|
||||||
|
@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:
|
else:
|
||||||
self.cursor.execute(query)
|
self._cursor.execute(sql, params)
|
||||||
return self.cursor.fetchall()
|
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 .classification import Classification
|
||||||
from .member import Member
|
from .member import Member
|
||||||
from .service_type import ServiceType
|
from .servicetype import ServiceType
|
||||||
from .service import Service
|
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):
|
@dataclass()
|
||||||
query = "INSERT INTO Classifications (ClassificationName) VALUES (?)"
|
class Classification(BaseModel):
|
||||||
db.execute_query(query, (self.classification_name,))
|
ClassificationId: int
|
||||||
|
ClassificationName: str
|
||||||
@classmethod
|
|
||||||
def get_all(cls, db: DatabaseConnection):
|
|
||||||
query = "SELECT * FROM Classifications"
|
|
||||||
return db.execute_query_with_return(query)
|
|
||||||
|
|||||||
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):
|
@dataclass
|
||||||
query = "INSERT INTO Members (FirstName, LastName, Email, PhoneNumber, ClassificationId, Notes) VALUES (?, ?, ?, ?, ?, ?)"
|
class Member(BaseModel):
|
||||||
db.execute_query(query, (self.first_name, self.last_name, self.email, self.phone_number, self.classification_id, self.notes))
|
MemberId: int
|
||||||
|
FirstName: str
|
||||||
@classmethod
|
LastName: str
|
||||||
def get_all(cls, db: DatabaseConnection):
|
Email: Optional[str] = None
|
||||||
query = "SELECT * FROM Members"
|
PhoneNumber: Optional[str] = None
|
||||||
return db.execute_query_with_return(query)
|
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):
|
@dataclass()
|
||||||
query = "INSERT INTO Services (ServiceTypeId, ServiceDate) VALUES (?, ?)"
|
class Service(BaseModel):
|
||||||
db.execute_query(query, (self.service_type_id, self.service_date))
|
ServiceId: int
|
||||||
|
ServiceTypeId: int
|
||||||
@classmethod
|
ServiceDate: date
|
||||||
def get_all(cls, db: DatabaseConnection):
|
|
||||||
query = "SELECT * FROM Services"
|
|
||||||
return db.execute_query_with_return(query)
|
|
||||||
|
|||||||
@@ -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 .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:
|
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):
|
def __init__(self, db: DatabaseConnection):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def create_classification(self, classification_name: str):
|
# -----------------------------------------------------------------
|
||||||
classification = Classification(classification_name)
|
# CRUD helpers – they now return model objects (or IDs)
|
||||||
classification.save(self.db)
|
# -----------------------------------------------------------------
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 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):
|
cols = ", ".join(data.keys())
|
||||||
member = Member(first_name, last_name, email, phone_number, classification_id, notes)
|
placeholders = ", ".join("?" for _ in data)
|
||||||
member.save(self.db)
|
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):
|
def create_member(
|
||||||
service_type = ServiceType(type_name)
|
self,
|
||||||
service_type.save(self.db)
|
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):
|
def create_service_type(self, type_name: str) -> ServiceType:
|
||||||
service = Service(service_type_id, service_date)
|
"""Insert a new service type."""
|
||||||
service.save(self.db)
|
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):
|
def create_service(self, service_type_id: int, service_date: dt.date) -> Service:
|
||||||
service_availability = ServiceAvailability(member_id, service_type_id)
|
"""Insert a new service row (date + type)."""
|
||||||
service_availability.save(self.db)
|
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):
|
def create_service_availability(self, member_id: int, service_type_id: int) -> ServiceAvailability:
|
||||||
return Classification.get_all(self.db)
|
"""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):
|
def get_all_members(self) -> List[Member]:
|
||||||
return ServiceType.get_all(self.db)
|
rows = self.db.fetchall("SELECT * FROM Members")
|
||||||
|
return [Member.from_row(r) for r in rows]
|
||||||
|
|
||||||
def get_all_services(self):
|
def get_all_service_types(self) -> List[ServiceType]:
|
||||||
return Service.get_all(self.db)
|
rows = self.db.fetchall("SELECT * FROM ServiceTypes")
|
||||||
|
return [ServiceType.from_row(r) for r in rows]
|
||||||
|
|
||||||
def get_all_service_availability(self):
|
def get_all_services(self) -> List[Service]:
|
||||||
return ServiceAvailability.get_all(self.db)
|
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
|
import os
|
||||||
from database.connection import DatabaseConnection
|
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")
|
# 1️⃣ Helper that creates the database (runs the schema file)
|
||||||
repository = Repository(db)
|
# ----------------------------------------------------------------------
|
||||||
|
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
|
print(f"🗂️ Creating new SQLite DB at {db_path}")
|
||||||
classifications = repository.get_all_classifications()
|
conn = sqlite3.connect(db_path)
|
||||||
members = repository.get_all_members()
|
cur = conn.cursor()
|
||||||
service_types = repository.get_all_service_types()
|
|
||||||
services = repository.get_all_services()
|
|
||||||
service_availability = repository.get_all_service_availability()
|
|
||||||
|
|
||||||
print("Classifications:")
|
schema_path = Path(__file__).parent / "database" / "schema.sql"
|
||||||
for classification in classifications:
|
if not schema_path.is_file():
|
||||||
print(classification)
|
raise FileNotFoundError(f"Schema file not found: {schema_path}")
|
||||||
|
|
||||||
print("\nMembers:")
|
with open(schema_path, "r", encoding="utf‑8") as f:
|
||||||
for member in members:
|
sql = f.read()
|
||||||
print(member)
|
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:")
|
def next_n_sundays(n: int) -> list[dt.date]:
|
||||||
for service in services:
|
"""Return a list with the next `n` Sundays after today."""
|
||||||
print(service)
|
today = dt.date.today()
|
||||||
|
|
||||||
print("\nService Availability:")
|
# weekday(): Mon=0 … Sun=6 → we want the offset to the *next* Sunday
|
||||||
for availability in service_availability:
|
days_until_sunday = (6 - today.weekday()) % 7
|
||||||
print(availability)
|
# 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__":
|
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