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:
49
backend/app/models/_common.py
Normal file
49
backend/app/models/_common.py
Normal 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_])
|
||||
@@ -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."""
|
||||
|
||||
@@ -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``."""
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user