Fix: Consolidate divergent _since_unix implementations (T-09)

Consolidate the two divergent implementations of _since_unix from ban_service.py
and history_service.py into a single shared utility function in time_utils.py.

Changes:
- Move _since_unix to app/utils/time_utils.py with consistent time.time() approach
- Move TIME_RANGE_SLACK_SECONDS constant to app/utils/constants.py
- Update ban_service.py to import since_unix from time_utils
- Update history_service.py to import since_unix from time_utils
- Both services now use the same window boundary calculation with 60-second slack
- Add comprehensive tests for the shared since_unix function
- Document timestamp handling rationale in Backend-Development.md

This ensures dashboard and history queries return consistent row counts for the
same time range by using the same timestamp calculation and slack window across
all services.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-25 18:44:59 +02:00
parent 420ea18fd9
commit ac2028e1c2
6 changed files with 132 additions and 51 deletions

View File

@@ -13,7 +13,6 @@ from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import time
from typing import TYPE_CHECKING, cast
import structlog
@@ -23,7 +22,6 @@ from app.models.ban import (
BLOCKLIST_JAIL,
BUCKET_SECONDS,
BUCKET_SIZE_LABEL,
TIME_RANGE_SECONDS,
ActiveBan,
ActiveBanListResponse,
BanOrigin,
@@ -57,6 +55,7 @@ from app.utils.fail2ban_response import (
ok,
to_dict,
)
from app.utils.time_utils import since_unix
if TYPE_CHECKING:
import aiohttp
@@ -318,33 +317,6 @@ async def get_active_bans(
log.info("active_bans_fetched", total=len(bans))
return ActiveBanListResponse(bans=bans, total=len(bans))
_TIME_RANGE_SLACK_SECONDS: int = 60
def _since_unix(range_: TimeRange) -> int:
"""Return the Unix timestamp representing the start of the time window.
Uses :func:`time.time` (always UTC epoch seconds on all platforms) to be
consistent with how fail2ban stores ``timeofban`` values in its SQLite
database. fail2ban records ``time.time()`` values directly, so
comparing against a timezone-aware ``datetime.now(UTC).timestamp()`` would
theoretically produce the same number but using :func:`time.time` avoids
any tz-aware datetime pitfalls on misconfigured systems.
Args:
range_: One of the supported time-range presets.
Returns:
Unix timestamp (seconds since epoch) equal to *now range_* with a
small slack window for clock drift and test seeding delays.
"""
seconds: int = TIME_RANGE_SECONDS[range_]
return int(time.time()) - seconds - _TIME_RANGE_SLACK_SECONDS
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@@ -401,7 +373,7 @@ async def list_bans(
paginated items and total count.
"""
since: int = _since_unix(range_)
since: int = since_unix(range_)
effective_page_size: int = min(page_size, MAX_PAGE_SIZE)
offset: int = (page - 1) * effective_page_size
@@ -570,7 +542,7 @@ async def bans_by_country(
aggregation and the companion ban list.
"""
since: int = _since_unix(range_)
since: int = since_unix(range_)
if source not in ("fail2ban", "archive"):
raise ValueError(f"Unsupported source: {source!r}")
@@ -820,7 +792,7 @@ async def ban_trend(
:class:`~app.models.ban.BanTrendResponse` with a full bucket list
and the human-readable bucket-size label.
"""
since: int = _since_unix(range_)
since: int = since_unix(range_)
bucket_secs: int = BUCKET_SECONDS[range_]
num_buckets: int = bucket_count(range_)
@@ -920,7 +892,7 @@ async def bans_by_jail(
:class:`~app.models.ban.BansByJailResponse` with per-jail counts
sorted descending and the total ban count.
"""
since: int = _since_unix(range_)
since: int = since_unix(range_)
if source not in ("fail2ban", "archive"):
raise ValueError(f"Unsupported source: {source!r}")