The API pagination infrastructure was already correctly implemented with:
- PaginatedListResponse base model containing 'items' and 'pagination' fields
- PaginationMetadata object with all required fields (page, page_size, total, total_pages, has_next_page, has_prev_page)
- All services correctly calling create_pagination_metadata()
However, there were two bugs preventing tests from passing:
1. IMPORT BUG: time_utils.py was importing TIME_RANGE_SECONDS from app.models.ban
when it's actually defined in app.models._common. This caused import errors
in tests that exercise time-range filtering.
2. TEST BUG: Test assertions were using outdated API structure, accessing
.total, .page, .page_size directly on paginated responses instead of
through the .pagination object.
Fixed locations:
- test_mappers/test_ban_mappers.py: 3 assertions updated to use .pagination.*
- test_services/test_blocklist_service.py: 6 assertions updated
- test_services/test_history_service.py: 14 assertions updated
All paginated API endpoints now correctly return pagination metadata:
- GET /api/history
- GET /api/history/archive
- GET /api/dashboard/bans
- GET /api/jails/{name}/banned
- GET /api/blocklists/log
Verified with 24 passing pagination tests demonstrating correct behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
97 lines
3.1 KiB
Python
97 lines
3.1 KiB
Python
"""Timezone-aware datetime helpers.
|
||
|
||
All datetimes in BanGUI are stored and transmitted in UTC.
|
||
Conversion to the user's display timezone happens only at the presentation
|
||
layer (frontend). These utilities provide a consistent, safe foundation
|
||
for working with time throughout the backend.
|
||
"""
|
||
|
||
import datetime
|
||
import time
|
||
|
||
|
||
def utc_now() -> datetime.datetime:
|
||
"""Return the current UTC time as a timezone-aware :class:`datetime.datetime`.
|
||
|
||
Returns:
|
||
Current UTC datetime with ``tzinfo=datetime.UTC``.
|
||
"""
|
||
return datetime.datetime.now(datetime.UTC)
|
||
|
||
|
||
def utc_from_timestamp(ts: float) -> datetime.datetime:
|
||
"""Convert a POSIX timestamp to a timezone-aware UTC datetime.
|
||
|
||
Args:
|
||
ts: POSIX timestamp (seconds since Unix epoch).
|
||
|
||
Returns:
|
||
Timezone-aware UTC :class:`datetime.datetime`.
|
||
"""
|
||
return datetime.datetime.fromtimestamp(ts, tz=datetime.UTC)
|
||
|
||
|
||
def add_minutes(dt: datetime.datetime, minutes: int) -> datetime.datetime:
|
||
"""Return a new datetime that is *minutes* ahead of *dt*.
|
||
|
||
Args:
|
||
dt: The source datetime (must be timezone-aware).
|
||
minutes: Number of minutes to add. May be negative.
|
||
|
||
Returns:
|
||
A new timezone-aware :class:`datetime.datetime`.
|
||
"""
|
||
return dt + datetime.timedelta(minutes=minutes)
|
||
|
||
|
||
def is_expired(expires_at: datetime.datetime) -> bool:
|
||
"""Return ``True`` if *expires_at* is in the past relative to UTC now.
|
||
|
||
Args:
|
||
expires_at: The expiry timestamp to check (must be timezone-aware).
|
||
|
||
Returns:
|
||
``True`` when the timestamp is past, ``False`` otherwise.
|
||
"""
|
||
return utc_now() >= expires_at
|
||
|
||
|
||
def hours_ago(hours: int) -> datetime.datetime:
|
||
"""Return a timezone-aware UTC datetime *hours* before now.
|
||
|
||
Args:
|
||
hours: Number of hours to subtract from the current time.
|
||
|
||
Returns:
|
||
Timezone-aware UTC :class:`datetime.datetime`.
|
||
"""
|
||
return utc_now() - datetime.timedelta(hours=hours)
|
||
|
||
|
||
def since_unix(range_: str) -> int:
|
||
"""Return the Unix timestamp for the start of a time-range 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 :func:`time.time()` values directly, so using
|
||
a timezone-aware :func:`datetime.datetime.now`\\ ``(UTC).timestamp()``
|
||
would theoretically produce the same result but using :func:`time.time`
|
||
avoids any timezone-aware datetime pitfalls on misconfigured systems.
|
||
|
||
A 60-second slack window is applied to accommodate clock drift and
|
||
test seeding delays. This ensures consistent query windows across services
|
||
(e.g., dashboard vs. history).
|
||
|
||
Args:
|
||
range_: One of the supported time-range presets (e.g., ``"24h"``).
|
||
|
||
Returns:
|
||
Unix timestamp (seconds since epoch) representing the start of the
|
||
time window: *now − range_ − slack*.
|
||
"""
|
||
from app.models._common import TIME_RANGE_SECONDS
|
||
from app.utils.constants import TIME_RANGE_SLACK_SECONDS
|
||
|
||
seconds: int = TIME_RANGE_SECONDS[range_]
|
||
return int(time.time()) - seconds - TIME_RANGE_SLACK_SECONDS
|