Files
nimbusflow/backend/repositories/member.py

253 lines
9.6 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 backend.db import BaseRepository, DatabaseConnection
from backend.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))
def reset_to_queue_front(self, member_id: int) -> None:
"""
Reset member timestamps to move them to the front of the round robin queue.
This sets LastScheduledAt and LastAcceptedAt to far past values, effectively
making them the highest priority for scheduling.
"""
sql = f"""
UPDATE {self._TABLE}
SET LastScheduledAt = '1970-01-01 00:00:00',
LastAcceptedAt = '1970-01-01 00:00:00',
LastDeclinedAt = NULL,
DeclineStreak = 0
WHERE {self._PK} = ?
"""
self.db.execute(sql, (member_id,))