Refactor ban management with domain models and mappers

- Add ban domain model for core business logic separation
- Implement mapper pattern for DTO/domain conversions
- Update ban service with new domain-driven approach
- Refactor router endpoints to use new architecture
- Add comprehensive mapper tests
- Update documentation with architecture changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 07:46:02 +02:00
parent 507f153ab9
commit 3888c5eb3f
11 changed files with 640 additions and 68 deletions

View File

@@ -0,0 +1,110 @@
"""Ban domain models (DTOs).
Internal domain-focused models used by ban_service. These represent the
business domain layer and are independent of HTTP response shapes.
Response models are defined in `app.models.ban` and mappers convert domain
models to response models at the router boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
# Domain-specific ban origin type
BanOriginDomain = Literal["blocklist", "selfblock"]
@dataclass(frozen=True)
class DomainActiveBan:
"""A currently active ban entry (domain model).
This is the service-layer representation, independent of API response shape.
"""
ip: str
jail: str
banned_at: str | None = None
expires_at: str | None = None
ban_count: int = 1
country: str | None = None
@dataclass(frozen=True)
class DomainActiveBanList:
"""List of currently active bans (domain model)."""
bans: list[DomainActiveBan]
total: int
@dataclass(frozen=True)
class DomainDashboardBanItem:
"""A single row in the dashboard ban-list table (domain model).
Populated from the fail2ban database and enriched with geo data.
"""
ip: str
jail: str
banned_at: str
service: str | None = None
country_code: str | None = None
country_name: str | None = None
asn: str | None = None
org: str | None = None
ban_count: int = 1
origin: BanOriginDomain = "selfblock"
@dataclass(frozen=True)
class DomainDashboardBanList:
"""Paginated dashboard ban-list (domain model)."""
items: list[DomainDashboardBanItem]
total: int
page: int
page_size: int
@dataclass(frozen=True)
class DomainBansByCountry:
"""Bans aggregated by country (domain model)."""
countries: dict[str, int]
country_names: dict[str, str]
items: list[DomainDashboardBanItem]
total: int
@dataclass(frozen=True)
class DomainBanTrendBucket:
"""A single time bucket in the ban trend series (domain model)."""
timestamp: str
count: int
@dataclass(frozen=True)
class DomainBanTrend:
"""Ban trend data over time (domain model)."""
buckets: list[DomainBanTrendBucket]
bucket_size: str
@dataclass(frozen=True)
class DomainJailBanCount:
"""Ban count for a single jail (domain model)."""
jail: str
count: int
@dataclass(frozen=True)
class DomainBansByJail:
"""Bans aggregated by jail (domain model)."""
jails: list[DomainJailBanCount]
total: int