Files
BanGUI/backend/app/repositories/history_archive_repo.py
Lukas b44b72053a T-11: Validate repository Protocol structural compatibility — minimal approach (Option B)
Problem: Repository modules use structural typing to satisfy Protocol interfaces via
cast(). A function rename, parameter change, or signature mismatch would silently pass
mypy but fail at runtime.

Solution (Option B — minimal):
1. Aligned Protocol signatures in protocols.py with actual implementations:
   - BlocklistRepository: dict[str, object] → dict[str, Any] (matches implementation)
   - ImportLogRepository: dict[str, object] → ImportLogRow (typed model)
   - GeoCacheRepository: dict[str, object] → GeoCacheRow; Iterable → Sequence
   - HistoryArchiveRepository: dict[str, object] → dict[str, Any]
   - ImportLogRepository: async compute_total_pages → sync (matches implementation)

2. Created CI validation script (backend/scripts/validate_repository_protocols.py)
   that runs at build time to ensure all repository modules satisfy their Protocol
   interfaces. Exit 0 if valid, 1 if any mismatch. Detects:
   - Missing functions
   - Parameter count mismatches
   - Type annotation mismatches
   - Return type mismatches

3. Updated backend/app/dependencies.py with explicit docstrings linking each
   get_*_repo() provider to Backend-Development.md § 13.7.1, explaining the
   module-as-Protocol pattern and that it is intentional and validated.

4. Documented the pattern in Backend-Development.md § 13.7.1:
   'Repository Module Pattern — Module-as-Protocol Structural Compatibility'
   explaining why the pattern works, risks (silent breakage), and how the
   validation mitigates it.

5. Fixed type annotation in history_archive_repo.py:
   - get_all_archived_history returns list[dict] → list[dict[str, Any]]
   - Imported Any type

Benefits:
- Prevents silent breakage of repository interfaces
- Formalizes the module-as-Protocol pattern as intentional
- CI validation prevents regressions without refactoring cost
- All repository tests pass (53/53)
- mypy --strict passes on modified files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 18:59:49 +02:00

166 lines
4.6 KiB
Python

"""Ban history archive repository.
Provides persistence APIs for the BanGUI archival history table in the
application database.
"""
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any
from app.models.ban import BLOCKLIST_JAIL, BanOrigin
if TYPE_CHECKING:
import aiosqlite
async def archive_ban_event(
db: aiosqlite.Connection,
jail: str,
ip: str,
timeofban: int,
bancount: int,
data: str,
action: str = "ban",
) -> bool:
"""Insert a new archived ban/unban event, ignoring duplicates."""
async with db.execute(
"""INSERT OR IGNORE INTO history_archive
(jail, ip, timeofban, bancount, data, action)
VALUES (?, ?, ?, ?, ?, ?)""",
(jail, ip, timeofban, bancount, data, action),
) as cursor:
inserted = cursor.rowcount == 1
await db.commit()
return inserted
async def get_max_timeofban(db: aiosqlite.Connection) -> int | None:
"""Return the latest archived ban timestamp or ``None`` when empty."""
async with db.execute("SELECT MAX(timeofban) FROM history_archive") as cursor:
row = await cursor.fetchone()
if row is None or row[0] is None:
return None
return int(row[0])
async def get_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
page: int = 1,
page_size: int = 100,
) -> tuple[list[dict[str, Any]], int]:
"""Return a paginated archived history result set."""
if isinstance(ip_filter, list) and len(ip_filter) == 0:
return [], 0
wheres: list[str] = []
params: list[object] = []
if since is not None:
wheres.append("timeofban >= ?")
params.append(since)
if jail is not None:
wheres.append("jail = ?")
params.append(jail)
if ip_filter is not None:
if isinstance(ip_filter, list):
placeholder = ", ".join("?" for _ in ip_filter)
wheres.append(f"ip IN ({placeholder})")
params.extend(ip_filter)
else:
wheres.append("ip LIKE ?")
params.append(f"{ip_filter}%")
if origin == "blocklist":
wheres.append("jail = ?")
params.append(BLOCKLIST_JAIL)
elif origin == "selfblock":
wheres.append("jail != ?")
params.append(BLOCKLIST_JAIL)
if action is not None:
wheres.append("action = ?")
params.append(action)
where_sql = "WHERE " + " AND ".join(wheres) if wheres else ""
offset = (page - 1) * page_size
async with db.execute(f"SELECT COUNT(*) FROM history_archive {where_sql}", params) as cur:
row = await cur.fetchone()
total = int(row[0]) if row is not None and row[0] is not None else 0
async with db.execute(
"SELECT jail, ip, timeofban, bancount, data, action "
"FROM history_archive "
f"{where_sql} "
"ORDER BY timeofban DESC LIMIT ? OFFSET ?",
[*params, page_size, offset],
) as cur:
rows = await cur.fetchall()
records = [
{
"jail": str(r[0]),
"ip": str(r[1]),
"timeofban": int(r[2]),
"bancount": int(r[3]),
"data": str(r[4]),
"action": str(r[5]),
}
for r in rows
]
return records, total
async def get_all_archived_history(
db: aiosqlite.Connection,
since: int | None = None,
jail: str | None = None,
ip_filter: str | list[str] | None = None,
origin: BanOrigin | None = None,
action: str | None = None,
) -> list[dict[str, Any]]:
"""Return all archived history rows for the given filters."""
page: int = 1
page_size: int = 500
all_rows: list[dict[str, Any]] = []
while True:
rows, total = await get_archived_history(
db=db,
since=since,
jail=jail,
ip_filter=ip_filter,
origin=origin,
action=action,
page=page,
page_size=page_size,
)
all_rows.extend(rows)
if len(rows) < page_size:
break
page += 1
return all_rows
async def purge_archived_history(db: aiosqlite.Connection, age_seconds: int) -> int:
"""Purge archived entries older than *age_seconds*; return rows deleted."""
threshold = int(datetime.datetime.now(datetime.UTC).timestamp()) - age_seconds
async with db.execute(
"DELETE FROM history_archive WHERE timeofban < ?",
(threshold,),
) as cursor:
deleted = cursor.rowcount
await db.commit()
return deleted