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

@@ -0,0 +1,237 @@
# myapp/repositories/member.py
# ------------------------------------------------------------
# Repository that encapsulates all persistence concerns for the
# ``Member`` model. It builds on the generic ``BaseRepository`` that
# knows how to INSERT and SELECT rows.
# ------------------------------------------------------------
from __future__ import annotations
import datetime as _dt
from typing import List, Sequence, Optional
from ..db import BaseRepository, DatabaseConnection
from ..models import Member as MemberModel
class MemberRepository(BaseRepository[MemberModel]):
"""
Highlevel dataaccess object for ``Member`` rows.
Only *persistence* logic lives here any business rules (e.g. roundrobin
scheduling) should be implemented in a service layer that composes this
repository with others.
"""
# ------------------------------------------------------------------
# Tablelevel constants keep them in one place so a rename is easy.
# ------------------------------------------------------------------
_TABLE = "Members"
_PK = "MemberId"
# ------------------------------------------------------------------
# CRUD helpers
# ------------------------------------------------------------------
def create(
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,
) -> MemberModel:
"""
Insert a new member row and return the fullypopulated ``Member`` instance.
"""
member = MemberModel(
MemberId=-1, # placeholder will be overwritten
FirstName=first_name,
LastName=last_name,
Email=email,
PhoneNumber=phone_number,
ClassificationId=classification_id,
Notes=notes,
IsActive=is_active,
LastScheduledAt=None,
LastAcceptedAt=None,
LastDeclinedAt=None,
DeclineStreak=0,
)
return self._insert(self._TABLE, member, self._PK)
def get_by_id(self, member_id: int) -> Optional[MemberModel]:
"""
Return a single ``Member`` identified by ``member_id`` or ``None`` if it
does not exist.
"""
sql = f"SELECT * FROM {self._TABLE} WHERE {self._PK} = ?"
row = self.db.fetchone(sql, (member_id,))
return MemberModel.from_row(row) if row else None
def list_all(self) -> List[MemberModel]:
"""Convenient wrapper around ``BaseRepository._select_all``."""
return self._select_all(self._TABLE, MemberModel)
# ------------------------------------------------------------------
# Query helpers that are specific to the domain
# ------------------------------------------------------------------
def get_by_classification_ids(
self, classification_ids: Sequence[int]
) -> List[MemberModel]:
"""
Return all members whose ``ClassificationId`` is in the supplied
collection. Empty input yields an empty list (no DB roundtrip).
"""
if not classification_ids:
return []
placeholders = ",".join("?" for _ in classification_ids)
sql = (
f"SELECT * FROM {self._TABLE} "
f"WHERE ClassificationId IN ({placeholders})"
)
rows = self.db.fetchall(sql, tuple(classification_ids))
return [MemberModel.from_row(r) for r in rows]
def get_active(self) -> List[MemberModel]:
"""All members with ``IsActive = 1``."""
sql = f"SELECT * FROM {self._TABLE} WHERE IsActive = 1"
rows = self.db.fetchall(sql)
return [MemberModel.from_row(r) for r in rows]
# ------------------------------------------------------------------
# Helper used by the scheduling service builds the roundrobin queue.
# ------------------------------------------------------------------
def candidate_queue(
self,
classification_ids: Sequence[int],
*,
only_active: bool = True,
boost_seconds: int = 172_800, # 2 days in seconds
) -> List[MemberModel]:
"""
Return members ordered for the roundrobin scheduler.
Ordering follows the exact SQL logic required by the test suite:
1⃣ Boost members whose ``DeclineStreak`` <2 **and**
``LastDeclinedAt`` is within ``boost_seconds`` of *now*.
Those rows get a leading ``0`` in the ``CASE`` expression;
all others get ``1``.
2⃣ After the boost, order by ``LastAcceptedAt`` (oldest first,
``NULL`` → farpast sentinel).
3⃣ Finally break ties with ``LastScheduledAt`` (oldest first,
same ``NULL`` handling).
Parameters
----------
classification_ids:
Restrict the queue to members belonging to one of these
classifications.
only_active:
If ``True`` (default) filter out rows where ``IsActive != 1``.
boost_seconds:
Number of seconds that count as “recently declined”.
The default is **2days** (172800s).
Returns
-------
List[MemberModel]
Ordered list ready for the scheduling service.
"""
# ------------------------------------------------------------------
# Build the dynamic WHERE clause.
# ------------------------------------------------------------------
where_clauses: List[str] = []
params: List[Any] = []
if classification_ids:
placeholders = ",".join("?" for _ in classification_ids)
where_clauses.append(f"ClassificationId IN ({placeholders})")
params.extend(classification_ids)
if only_active:
where_clauses.append("IsActive = 1")
where_sql = " AND ".join(where_clauses)
if where_sql:
where_sql = "WHERE " + where_sql
# ------------------------------------------------------------------
# Current UTC timestamp in a format SQLites julianday() understands.
# ``%Y-%m-%d %H:%M:%S`` no fractional seconds.
# ------------------------------------------------------------------
now_iso = _dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# ------------------------------------------------------------------
# Full query note the threelevel ORDER BY.
# ------------------------------------------------------------------
sql = f"""
SELECT *
FROM {self._TABLE}
{where_sql}
ORDER BY
CASE
WHEN DeclineStreak < 2
AND LastDeclinedAt IS NOT NULL
AND julianday(?) - julianday(LastDeclinedAt) <= (? / 86400.0)
THEN 0
ELSE 1
END,
COALESCE(LastAcceptedAt, '1970-01-01') ASC,
COALESCE(LastScheduledAt, '1970-01-01') ASC
"""
# ``now_iso`` and ``boost_seconds`` are the two extra bind variables.
exec_params = tuple(params) + (now_iso, boost_seconds)
rows = self.db.fetchall(sql, exec_params)
return [MemberModel.from_row(r) for r in rows]
# ------------------------------------------------------------------
# Miscellaneous update helpers (optional add as needed)
# ------------------------------------------------------------------
def touch_last_scheduled(self, member_id: int) -> None:
"""
Update ``LastScheduledAt`` to the current UTC timestamp.
Used by the scheduling service after a schedule row is created.
"""
sql = f"""
UPDATE {self._TABLE}
SET LastScheduledAt = strftime('%Y-%m-%d %H:%M:%f', 'now')
WHERE {self._PK} = ?
"""
self.db.execute(sql, (member_id,))
def set_last_accepted(self, member_id: int) -> None:
"""
Record a successful acceptance clears any cooloff.
"""
sql = f"""
UPDATE {self._TABLE}
SET LastAcceptedAt = strftime('%Y-%m-%d %H:%M:%f', 'now'),
LastDeclinedAt = NULL,
DeclineStreak = 0
WHERE {self._PK} = ?
"""
self.db.execute(sql, (member_id,))
def set_last_declined(self, member_id: int, decline_date: str) -> None:
"""
Record a decline ``decline_date`` should be an ISOformatted date
(e.g. ``'2025-08-22'``). This implements the oneday cooloff rule
and bumps the ``DeclineStreak`` counter.
"""
sql = f"""
UPDATE {self._TABLE}
SET
LastDeclinedAt = ?,
DeclineStreak = COALESCE(DeclineStreak, 0) + 1
WHERE {self._PK} = ?
"""
self.db.execute(sql, (decline_date, member_id))