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:
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Query, Request, status
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dependencies import (
|
||||
AuthDep,
|
||||
Fail2BanSocketDep,
|
||||
@@ -36,6 +38,38 @@ log: structlog.stdlib.BoundLogger = structlog.get_logger()
|
||||
router: APIRouter = APIRouter(tags=["Config Misc"])
|
||||
|
||||
|
||||
def _validate_log_target(value: str) -> None:
|
||||
"""Validate that log_target is either a special value or a valid file path.
|
||||
|
||||
Args:
|
||||
value: The log target to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If the target is not a special value and not in allowed directories.
|
||||
"""
|
||||
if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
|
||||
return
|
||||
|
||||
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
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/global",
|
||||
response_model=GlobalConfigResponse,
|
||||
@@ -82,9 +116,11 @@ async def update_global_config(
|
||||
body: Partial update — only non-None fields are written.
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 when a set command is rejected.
|
||||
HTTPException: 400 when a set command is rejected or log_target is invalid.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
if body.log_target is not None:
|
||||
_validate_log_target(body.log_target)
|
||||
await config_service.update_global_config(socket_path, body)
|
||||
|
||||
|
||||
|
||||
@@ -25,21 +25,21 @@ from app.dependencies import (
|
||||
HttpSessionDep,
|
||||
ServerStatusDep,
|
||||
)
|
||||
from app.mappers import (
|
||||
map_domain_ban_trend_to_response,
|
||||
map_domain_bans_by_country_to_response,
|
||||
map_domain_bans_by_jail_to_response,
|
||||
map_domain_dashboard_ban_list_to_response,
|
||||
)
|
||||
from app.models._common import TimeRange
|
||||
from app.models.ban import (
|
||||
BanOrigin,
|
||||
BansByCountryResponse,
|
||||
BansByJailResponse,
|
||||
BanTrendResponse,
|
||||
DashboardBanListResponse,
|
||||
TimeRange,
|
||||
)
|
||||
from app.models.server import ServerStatus, ServerStatusResponse
|
||||
from app.mappers import (
|
||||
map_domain_dashboard_ban_list_to_response,
|
||||
map_domain_bans_by_country_to_response,
|
||||
map_domain_ban_trend_to_response,
|
||||
map_domain_bans_by_jail_to_response,
|
||||
)
|
||||
from app.services import ban_service
|
||||
from app.utils.constants import DEFAULT_PAGE_SIZE
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ from app.dependencies import (
|
||||
HttpSessionDep,
|
||||
)
|
||||
from app.exceptions import HistoryNotFoundError
|
||||
from app.models.ban import BanOrigin, TimeRange
|
||||
from app.models._common import TimeRange
|
||||
from app.models.ban import BanOrigin
|
||||
from app.models.history import HistoryListResponse, IpDetailResponse
|
||||
from app.services import history_service
|
||||
from app.utils.constants import DEFAULT_PAGE_SIZE
|
||||
|
||||
@@ -219,9 +219,10 @@ async def add_log_path(
|
||||
|
||||
Raises:
|
||||
HTTPException: 404 when the jail does not exist.
|
||||
HTTPException: 400 when the command is rejected.
|
||||
HTTPException: 400 when the command is rejected or path is invalid.
|
||||
HTTPException: 502 when fail2ban is unreachable.
|
||||
"""
|
||||
validate_log_path(body.log_path)
|
||||
await config_service.add_log_path(socket_path, name, body)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user