feat(backend): refactor mono repository

This commit is contained in:
2025-08-27 11:04:56 -04:00
parent d0dbba21fb
commit be1c729220
37 changed files with 2534 additions and 452 deletions

View File

@@ -1,292 +1,193 @@
import os
import datetime as dt
import sqlite3
import random
# demo.py
# ------------------------------------------------------------
# Demonstration script that creates a few services, loads the
# classifications, and then schedules members for each position
# using the new SchedulingService.
# ------------------------------------------------------------
from __future__ import annotations
from pathlib import Path
from typing import List, Dict, Set, Tuple
from datetime import date, timedelta
from typing import Dict, List, Tuple, Any
# Import the concrete repository classes that talk to SQLite (or any DB you use)
from backend.repositories import (
ClassificationRepository,
MemberRepository,
ServiceRepository,
ServiceAvailabilityRepository,
ScheduleRepository
)
# The service we just wrote
from backend.services.scheduling_service import SchedulingService
# ----------------------------------------------------------------------
# 1 Helper that creates the database (runs the schema file)
# Helper return the next *n* Sundays starting from today.
# ----------------------------------------------------------------------
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
print(f"🗂️ Creating new SQLite DB at {db_path}")
conn = sqlite3.connect(db_path)
cur = conn.cursor()
schema_path = Path(__file__).parent / "database" / "schema.sql"
if not schema_path.is_file():
raise FileNotFoundError(f"Schema file not found: {schema_path}")
with open(schema_path, "r", encoding="utf8") as f:
sql = f.read()
cur.executescript(sql)
conn.commit()
conn.close()
print("✅ Schema executed database is ready.")
def next_n_sundays(n: int) -> list[dt.date]:
"""Return a list with the next `n` Sundays after today."""
today = dt.date.today()
# weekday(): Mon=0 … Sun=6 → we want the offset to the *next* Sunday
def next_n_sundays(n: int) -> List[date]:
"""Return a list of the next *n* Sundays (including today if today is Sunday)."""
today = date.today()
# weekday(): Monday == 0 … Sunday == 6
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
first_sunday = today + timedelta(days=days_until_sunday)
return [first_sunday + timedelta(weeks=i) for i in range(n)]
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:
# ----------------------------------------------------------------------
# Demo entry point (updated for multiclassification support)
# ----------------------------------------------------------------------
def demo(
classification_repo: ClassificationRepository,
member_repo: MemberRepository,
service_repo: ServiceRepository,
availability_repo: ServiceAvailabilityRepository,
schedule_repo: ScheduleRepository,
) -> None:
"""
Populate a tiny dataset, run the roundrobin queue, accept one
schedule, decline another and print audit tables.
Populate a handful of services for the coming Sunday and run the
roundrobin scheduler for each choirposition.
The function prints what it does so you can see the flow in the console.
"""
print("\n=== 📦 Seeding reference data ===")
# ------------------------------------------------------------------
# 0⃣ Define the members we want to skip.
# ------------------------------------------------------------------
EXCLUDED_MEMBER_IDS = {20, 8, 3, 12, 4, 1, 44, 46, 28, 13, 11, 5, 16, 26, 35}
# ----- classifications -------------------------------------------------
baritone_cls = repo.create_classification("Baritone")
tenor_cls = repo.create_classification("Tenor")
alto_cls = repo.create_classification("Alto / Mezzo")
soprano_cls = repo.create_classification("Soprano")
print(f"""Created classifications →
{baritone_cls.ClassificationId}=Baritone,
{tenor_cls.ClassificationId}=Tenor,
{alto_cls.ClassificationId}=Alto,
{soprano_cls.ClassificationId}=Soprano\n""")
# ----- members --------------------------------------------------------
members = [
# 15 (Tenor)
repo.create_member("John", "Doe", "john.doe@example.com", "+155512340001", tenor_cls.ClassificationId),
repo.create_member("Mary", "Smith", "mary.smith@example.com", "+155512340002", tenor_cls.ClassificationId),
repo.create_member("David", "Lee", "david.lee@example.com", "+155512340003", tenor_cls.ClassificationId),
repo.create_member("Emma", "Clark", "emma.clark@example.com", "+155512340004", tenor_cls.ClassificationId),
repo.create_member("Jack", "Taylor", "jack.taylor@example.com", "+155512340005", tenor_cls.ClassificationId),
# 610 (Alto / Mezzo)
repo.create_member("Alice", "Brown", "alice.brown@example.com", "+155512340006", alto_cls.ClassificationId),
repo.create_member("Frank", "Davis", "frank.davis@example.com", "+155512340007", alto_cls.ClassificationId),
repo.create_member("Grace", "Miller", "grace.miller@example.com", "+155512340008", alto_cls.ClassificationId),
repo.create_member("Henry", "Wilson", "henry.wilson@example.com", "+155512340009", alto_cls.ClassificationId),
repo.create_member("Isla", "Anderson", "isla.anderson@example.com", "+155512340010", alto_cls.ClassificationId),
# 1115 (Soprano)
repo.create_member("Bob", "Johnson", "bob.johnson@example.com", "+155512340011", soprano_cls.ClassificationId),
repo.create_member("Kara", "Thomas", "kara.thomas@example.com", "+155512340012", soprano_cls.ClassificationId),
repo.create_member("Liam", "Jackson", "liam.jackson@example.com", "+155512340013", soprano_cls.ClassificationId),
repo.create_member("Mia", "White", "mia.white@example.com", "+155512340014", soprano_cls.ClassificationId),
repo.create_member("Noah", "Harris", "noah.harris@example.com", "+155512340015", soprano_cls.ClassificationId),
# 1620 (Baritone)
repo.create_member("Olivia", "Martin", "olivia.martin@example.com", "+155512340016", baritone_cls.ClassificationId),
repo.create_member("Paul", "Doe", "paul.doe@example.com", "+155512340017", baritone_cls.ClassificationId),
repo.create_member("Quinn", "Smith", "quinn.smith@example.com", "+155512340018", baritone_cls.ClassificationId),
repo.create_member("Ruth", "Brown", "ruth.brown@example.com", "+155512340019", baritone_cls.ClassificationId),
repo.create_member("Sam", "Lee", "sam.lee@example.com", "+155512340020", baritone_cls.ClassificationId),
# 2125 (Tenor again)
repo.create_member("Tina", "Clark", "tina.clark@example.com", "+155512340021", tenor_cls.ClassificationId),
repo.create_member("Umar", "Davis", "umar.davis@example.com", "+155512340022", tenor_cls.ClassificationId),
repo.create_member("Vera", "Miller", "vera.miller@example.com", "+155512340023", tenor_cls.ClassificationId),
repo.create_member("Walt", "Wilson", "walt.wilson@example.com", "+155512340024", tenor_cls.ClassificationId),
repo.create_member("Xena", "Anderson", "xena.anderson@example.com", "+155512340025", tenor_cls.ClassificationId),
# 2630 (Alto / Mezzo again)
repo.create_member("Yara", "Thomas", "yara.thomas@example.com", "+155512340026", alto_cls.ClassificationId),
repo.create_member("Zane", "Jackson", "zane.jackson@example.com", "+155512340027", alto_cls.ClassificationId),
repo.create_member("Anna", "White", "anna.white@example.com", "+155512340028", alto_cls.ClassificationId),
repo.create_member("Ben", "Harris", "ben.harris@example.com", "+155512340029", alto_cls.ClassificationId),
repo.create_member("Cara", "Martin", "cara.martin@example.com", "+155512340030", alto_cls.ClassificationId),
]
for m in members:
print(f" Member {m.MemberId}: {m.FirstName} {m.LastName} ({m.ClassificationId})")
print("\n")
print("=== 📦 Seeding Service Types & Availability ===")
# -----------------------------------------------------------------
# 1⃣ Service Types (keep the IDs for later use)
# -----------------------------------------------------------------
st_9am = repo.create_service_type("9AM")
st_11am = repo.create_service_type("11AM")
st_6pm = repo.create_service_type("6PM")
service_type_ids: Dict[str, int] = {
"9AM": st_9am.ServiceTypeId,
"11AM": st_11am.ServiceTypeId,
"6PM": st_6pm.ServiceTypeId,
}
print(
f"Created service types → "
f"{st_9am.ServiceTypeId}=9AM, "
f"{st_11am.ServiceTypeId}=11AM, "
f"{st_6pm.ServiceTypeId}=6PM"
# ------------------------------------------------------------------
# 1⃣ Build the highlevel SchedulingService from the repos.
# ------------------------------------------------------------------
scheduler = SchedulingService(
classification_repo=classification_repo,
member_repo=member_repo,
service_repo=service_repo,
availability_repo=availability_repo,
schedule_repo=schedule_repo,
)
# -----------------------------------------------------------------
# 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()))}
# ------------------------------------------------------------------
# 2Create a single Sunday of services (9AM, 11AM, 6PM).
# ------------------------------------------------------------------
# We only need one Sunday for the demo the second element of the
# list returned by ``next_n_sundays(6)`` matches the original code.
target_sunday = next_n_sundays(6)[4] # same as original slice [1:2]
print(f"🗓️ Target Sunday: {target_sunday}")
input()
# Populate the map for every member you created earlier
availability_map: Dict[int, Set[int]] = {}
for m in members: # `members` is the list you seeded above
availability_map[m.MemberId] = base_availability()
# -----------------------------------------------------------------
# 3⃣ Handcrafted overrides (edgecases you want to guarantee)
# -----------------------------------------------------------------
# Tenor block (IDs 15 & 2125) → only 9AM & 11AM
tenor_ids = [1, 2, 3, 4, 5, 21, 22, 23, 24, 25]
for mid in tenor_ids:
availability_map[mid] = {
service_type_ids["9AM"],
service_type_ids["11AM"],
}
# Baritone block (IDs 1620) → only 6PM
baritone_ids = [16, 17, 18, 19, 20]
for mid in baritone_ids:
availability_map[mid] = {service_type_ids["6PM"]}
# Ensure at least one member can do each slot (explicit adds)
availability_map[1].add(service_type_ids["9AM"]) # John Tenor → 9AM
availability_map[6].add(service_type_ids["11AM"]) # Alice Alto → 11AM
availability_map[11].add(service_type_ids["6PM"]) # Bob Soprano → 6PM
# -----------------------------------------------------------------
# 4⃣ Bulkinsert into ServiceAvailability
# -----------------------------------------------------------------
rows: List[Tuple[int, int]] = []
for member_id, type_set in availability_map.items():
for st_id in type_set:
rows.append((member_id, st_id))
for row in rows:
repo.db.execute(
"""
INSERT INTO ServiceAvailability (MemberId, ServiceTypeId)
VALUES (?, ?)
""",
row,
# Create the three service slots for that day.
service_ids_by_type: Dict[int, int] = {} # ServiceTypeId → ServiceId
for service_type_id in (1, 2, 3):
service = service_repo.create(service_type_id, target_sunday)
service_ids_by_type[service_type_id] = service.ServiceId
type_name = {1: "9AM", 2: "11AM", 3: "6PM"}[service_type_id]
print(
f"✅ Created Service → ServiceId={service.ServiceId}, "
f"ServiceType={type_name}, Date={service.ServiceDate}"
)
input()
print(
f"Inserted {len(rows)} ServiceAvailability rows "
f"(≈ {len(members)} members × avg. {len(rows)//len(members)} slots each)."
)
# ------------------------------------------------------------------
# 3⃣ Load the classification IDs well need later.
# ------------------------------------------------------------------
classifications = classification_repo.list_all()
def _cid(name: str) -> int:
return next(c.ClassificationId for c in classifications if c.ClassificationName == name)
# ----- 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)
baritone_id = _cid("Baritone")
tenor_id = _cid("Tenor")
mezzo_alto_id = _cid("Alto / Mezzo")
soprano_id = _cid("Soprano")
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
# ------------------------------------------------------------------
# 4⃣ Define the choirpositions and which classifications are acceptable.
# ------------------------------------------------------------------
# The mapping mirrors the comment block in the original script.
positions_to_classifications: Dict[int, List[int]] = {
1: [baritone_id, tenor_id], # 1 Baritone or Tenor
2: [tenor_id], # 2 Tenor
3: [tenor_id, mezzo_alto_id], # 3 Tenor
4: [mezzo_alto_id], # 4 Mezzo
5: [mezzo_alto_id, soprano_id], # 5 Mezzo or Soprano
6: [mezzo_alto_id, soprano_id], # 6 Mezzo or Soprano
7: [soprano_id], # 7 Soprano
8: [soprano_id], # 8 Soprano
}
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
# ------------------------------------------------------------------
# 5⃣ Run the scheduler for each position on each service slot.
# ------------------------------------------------------------------
# We keep a dict so the final printout resembles the original script.
full_schedule: Dict[int, List[Tuple[int, str, str, int]]] = {}
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
for service_type_id, service_id in service_ids_by_type.items():
service_type_name = {1: "9AM", 2: "11AM", 3: "6PM"}[service_type_id]
print(f"\n=== Sunday {target_sunday} @ {service_type_name} ===")
full_schedule[service_id] = []
input()
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[0].ServiceId,
only_active=True,
)
print(scheduled_member)
for position, allowed_cids in positions_to_classifications.items():
# --------------------------------------------------------------
# New roundrobin path: give the whole list of allowed
# classifications to the scheduler at once.
# --------------------------------------------------------------
result = scheduler.schedule_next_member(
classification_ids=allowed_cids,
service_id=service_id,
only_active=True,
exclude_member_ids=EXCLUDED_MEMBER_IDS,
)
scheduled_member = repo.schedule_next_member(
classification_id=tenor_cls.ClassificationId,
service_id=services[2].ServiceId,
only_active=True,
)
print(scheduled_member)
# --------------------------------------------------------------
# Store the outcome either a valid schedule tuple or a placeholder.
# --------------------------------------------------------------
if result:
full_schedule[service_id].append(result)
print(f"#{position}: {result[1]} {result[2]}")
input()
else:
placeholder = (None, "", "No eligible member", None)
full_schedule[service_id].append(placeholder)
print(f"#{position}: ❓ No eligible member")
input()
# ------------------------------------------------------------------
# 6⃣ Final dump mirrors the original ``print(schedule)``.
# ------------------------------------------------------------------
print("\n🗂️ Complete schedule dictionary:")
print(full_schedule)
# ----------------------------------------------------------------------
# 2⃣ Demo that exercises the full repository API
# ----------------------------------------------------------------------
def demo(repo) -> None:
return
# ----------------------------------------------------------------------
# 5⃣ Entrypoint
# Example of wiring everything together (you would normally do this in
# your application startup code).
# ----------------------------------------------------------------------
if __name__ == "__main__":
# --------------------------------------------------------------
# Path to the SQLite file (feel free to change)
# --------------------------------------------------------------
DB_PATH = Path(__file__).parent / "database_demo.db"
from backend.db import DatabaseConnection
from backend.repositories import MemberRepository, ScheduleRepository, ServiceRepository, ServiceAvailabilityRepository
from backend.services.scheduling_service import SchedulingService
# --------------------------------------------------------------
# Initialise DB if necessary
# --------------------------------------------------------------
init_db(DB_PATH)
exit()
DB_PATH = Path(__file__).parent / "database6_accepts_and_declines.db"
# --------------------------------------------------------------
# Build the connection / repository objects
# --------------------------------------------------------------
from backend.database.connection import DatabaseConnection
from backend.database.repository import Repository
# Initialise DB connection (adjust DSN as needed)
db = DatabaseConnection(DB_PATH)
# Instantiate each repository with the shared DB connection.
classification_repo = ClassificationRepository(db)
member_repo = MemberRepository(db)
service_repo = ServiceRepository(db)
availability_repo = ServiceAvailabilityRepository(db)
schedule_repo = ScheduleRepository(db)
# Run the demo.
demo(
classification_repo,
member_repo,
service_repo,
availability_repo,
schedule_repo,
)
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.")