Refactor: Make model packages true leaf nodes - remove app-layer dependencies

Models in app/models/ are now pure data classes with no cross-layer dependencies.
This ensures the models layer remains a true leaf node in the dependency graph.

Changes:
- Create app/models/_common.py with shared types (TimeRange, bucket_count, constants)
- Move TimeRange and time-range constants from ban.py to _common.py
- Update history.py, routers, and services to import from _common.py
- Remove imports from app.config and app.utils from config.py models
- Move field validators from models to router layer:
  - Add log_target validation in config_misc router
  - Add log_path validation in jail_config router
- Update test_models.py to reflect validators moved to router layer
- Update documentation (Architekture.md, Backend-Development.md) with model layering rules
- Fix import ordering and type annotations in affected files

Model layering rule: Models may only import from:
✓ Standard library and third-party packages (Pydantic, typing)
✓ Other models in app/models/ (sibling models)
✓ app.models.response (response envelopes)
✗ app.services, app.config, app.utils, or any application layer

Validation requiring app-level state (settings, allowed directories) now happens
at the router or service layer, not in model validators.

Fixes: Models were not true leaf nodes due to circular imports and app-layer dependencies

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-30 19:31:11 +02:00
parent 3d1a6f5538
commit 100fd47c4b
15 changed files with 1542 additions and 396 deletions

View File

@@ -19,15 +19,17 @@ import aiohttp
import structlog
from app.exceptions import JailNotFoundError, JailOperationError
from app.models.ban import (
BLOCKLIST_JAIL,
from app.models._common import (
BUCKET_SECONDS,
BUCKET_SIZE_LABEL,
BanOrigin,
TimeRange,
_derive_origin,
bucket_count,
)
from app.models.ban import (
BLOCKLIST_JAIL,
BanOrigin,
_derive_origin,
)
from app.models.ban_domain import (
DomainActiveBan,
DomainActiveBanList,
@@ -320,7 +322,7 @@ async def get_active_bans(
except (TimeoutError, aiohttp.ClientError, OSError):
log.warning("active_bans_batch_geo_failed")
geo_map = {}
enriched: list[ActiveBan] = []
enriched: list[DomainActiveBan] = []
for ban in bans:
geo = geo_map.get(ban.ip)
if geo is not None:

View File

@@ -15,12 +15,12 @@ from typing import TYPE_CHECKING
import structlog
from app.models.ban import BanOrigin, TimeRange
if TYPE_CHECKING:
import aiohttp
import aiosqlite
from app.models._common import TimeRange
from app.models.ban import BanOrigin
from app.models.geo import GeoEnricher, GeoInfo
from app.repositories.protocols import HistoryArchiveRepository
from app.services.protocols import Fail2BanMetadataService

View File

@@ -14,8 +14,9 @@ if TYPE_CHECKING:
import aiohttp
import aiosqlite
from app.models._common import TimeRange
from app.models.auth import Session
from app.models.ban import BanOrigin, JailBannedIpsResponse, TimeRange
from app.models.ban import BanOrigin, JailBannedIpsResponse
from app.models.blocklist import (
BlocklistSource,
ImportLogListResponse,