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

@@ -0,0 +1,49 @@
"""Shared types and constants used across multiple model modules.
This module defines types and constants that are used by multiple
model modules, ensuring a single source of truth for cross-model types.
"""
import math
from typing import Literal
#: The four supported time-range presets for the dashboard views.
TimeRange = Literal["24h", "7d", "30d", "365d"]
#: Number of seconds represented by each preset.
TIME_RANGE_SECONDS: dict[str, int] = {
"24h": 24 * 3600,
"7d": 7 * 24 * 3600,
"30d": 30 * 24 * 3600,
"365d": 365 * 24 * 3600,
}
#: Bucket size in seconds for each time-range preset.
BUCKET_SECONDS: dict[str, int] = {
"24h": 3_600, # 1 hour → 24 buckets
"7d": 6 * 3_600, # 6 hours → 28 buckets
"30d": 86_400, # 1 day → 30 buckets
"365d": 7 * 86_400, # 7 days → ~53 buckets
}
#: Human-readable bucket size label for each time-range preset.
BUCKET_SIZE_LABEL: dict[str, str] = {
"24h": "1h",
"7d": "6h",
"30d": "1d",
"365d": "7d",
}
def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely.
Args:
range_: One of the supported time-range presets.
Returns:
Ceiling division of the range duration by the bucket size so that
the last bucket is included even when the window is not an exact
multiple of the bucket size.
"""
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])

View File

@@ -3,7 +3,6 @@
Request, response, and domain models used by the ban router and service.
"""
import math
from typing import Literal
from pydantic import Field
@@ -11,21 +10,6 @@ from pydantic import Field
from app.models.response import BanGuiBaseModel, CollectionResponse, PaginatedListResponse
# ---------------------------------------------------------------------------
# Time-range selector
# ---------------------------------------------------------------------------
#: The four supported time-range presets for the dashboard views.
TimeRange = Literal["24h", "7d", "30d", "365d"]
#: Number of seconds represented by each preset.
TIME_RANGE_SECONDS: dict[str, int] = {
"24h": 24 * 3600,
"7d": 7 * 24 * 3600,
"30d": 30 * 24 * 3600,
"365d": 365 * 24 * 3600,
}
class BanRequest(BanGuiBaseModel):
"""Payload for ``POST /api/bans`` (ban an IP)."""
@@ -204,36 +188,7 @@ class BansByCountryResponse(BanGuiBaseModel):
# ---------------------------------------------------------------------------
# Trend endpoint models
# ---------------------------------------------------------------------------
#: Bucket size in seconds for each time-range preset.
BUCKET_SECONDS: dict[str, int] = {
"24h": 3_600, # 1 hour → 24 buckets
"7d": 6 * 3_600, # 6 hours → 28 buckets
"30d": 86_400, # 1 day → 30 buckets
"365d": 7 * 86_400, # 7 days → ~53 buckets
}
#: Human-readable bucket size label for each time-range preset.
BUCKET_SIZE_LABEL: dict[str, str] = {
"24h": "1h",
"7d": "6h",
"30d": "1d",
"365d": "7d",
}
def bucket_count(range_: TimeRange) -> int:
"""Return the number of buckets needed to cover *range_* completely.
Args:
range_: One of the supported time-range presets.
Returns:
Ceiling division of the range duration by the bucket size so that
the last bucket is included even when the window is not an exact
multiple of the bucket size.
"""
return math.ceil(TIME_RANGE_SECONDS[range_] / BUCKET_SECONDS[range_])
class BanTrendBucket(BanGuiBaseModel):
"""A single time bucket in the ban trend series."""

View File

@@ -4,14 +4,11 @@ Request, response, and domain models for the config router and service.
"""
import datetime
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator
from pydantic import Field
from app.config import get_settings
from app.models.response import BanGuiBaseModel, CollectionResponse
from app.utils.path_utils import validate_log_path
DNSMode = Literal["yes", "warn", "no", "raw"]
LogEncoding = Literal["auto", "ascii", "utf-8", "UTF-8", "latin-1"]
@@ -158,42 +155,6 @@ class GlobalConfigResponse(BanGuiBaseModel):
db_purge_age: int = Field(..., description="Seconds after which ban records are purged from the fail2ban DB.")
db_max_matches: int = Field(..., description="Maximum stored log-line matches per ban record.")
@field_validator("log_target", mode="after")
@classmethod
def validate_log_target(cls, value: str) -> str:
"""Validate that log_target is either a special value or a valid file path.
Args:
value: The log target to validate.
Returns:
The validated log target.
Raises:
ValueError: If the target is not a special value and not in allowed directories.
"""
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
return value
settings = get_settings()
try:
resolved_path = Path(value).resolve()
except (OSError, RuntimeError) as e:
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
for allowed_dir in settings.allowed_log_dirs:
allowed_path = Path(allowed_dir).resolve()
try:
resolved_path.relative_to(allowed_path)
return value
except ValueError:
continue
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
raise ValueError(
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
)
class GlobalConfigUpdate(BanGuiBaseModel):
"""Payload for ``PUT /api/config/global``."""
@@ -208,45 +169,6 @@ class GlobalConfigUpdate(BanGuiBaseModel):
db_purge_age: int | None = Field(default=None, ge=0)
db_max_matches: int | None = Field(default=None, ge=0)
@field_validator("log_target", mode="after")
@classmethod
def validate_log_target(cls, value: str | None) -> str | None:
"""Validate that log_target is either a special value or a valid file path.
Args:
value: The log target to validate, or None.
Returns:
The validated log target, or None if input was None.
Raises:
ValueError: If the target is not a special value and not in allowed directories.
"""
if value is None:
return None
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
return value
settings = get_settings()
try:
resolved_path = Path(value).resolve()
except (OSError, RuntimeError) as e:
raise ValueError(f"Cannot resolve path {value!r}: {e}") from e
for allowed_dir in settings.allowed_log_dirs:
allowed_path = Path(allowed_dir).resolve()
try:
resolved_path.relative_to(allowed_path)
return value
except ValueError:
continue
allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
raise ValueError(
f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
)
# ---------------------------------------------------------------------------
# Log observation / preview models
# ---------------------------------------------------------------------------
@@ -260,22 +182,6 @@ class AddLogPathRequest(BanGuiBaseModel):
description="If true, monitor from current end of file (tail). If false, read from the beginning.",
)
@field_validator("log_path", mode="after")
@classmethod
def validate_log_path_field(cls, value: str) -> str:
"""Validate that the log path is within allowed directories.
Args:
value: The log path to validate.
Returns:
The validated log path.
Raises:
ValueError: If the path is outside allowed log directories.
"""
return validate_log_path(value)
class LogPreviewRequest(BanGuiBaseModel):
"""Payload for ``POST /api/config/preview-log``."""

View File

@@ -7,10 +7,9 @@ from __future__ import annotations
from pydantic import Field
from app.models._common import TimeRange
from app.models.response import BanGuiBaseModel, PaginatedListResponse
from app.models.ban import TimeRange
__all__ = [
"HistoryBanItem",
"HistoryListResponse",