chore: standardize security and quality foundations
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""Shared HTTP request and error contracts."""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StrictRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
|
||||
|
||||
COMMON_ERROR_RESPONSES: dict[int, dict[str, Any]] = {
|
||||
400: {"model": ErrorResponse, "description": "Invalid request"},
|
||||
401: {"model": ErrorResponse, "description": "Authentication required"},
|
||||
403: {"model": ErrorResponse, "description": "Permission denied"},
|
||||
404: {"model": ErrorResponse, "description": "Resource not found"},
|
||||
409: {"model": ErrorResponse, "description": "Request conflict"},
|
||||
429: {"model": ErrorResponse, "description": "Rate limit exceeded"},
|
||||
500: {"model": ErrorResponse, "description": "Unexpected server error"},
|
||||
502: {"model": ErrorResponse, "description": "Upstream service error"},
|
||||
503: {"model": ErrorResponse, "description": "Service unavailable"},
|
||||
}
|
||||
|
||||
|
||||
class SignupRequest(StrictRequest):
|
||||
invite_code: str = Field(min_length=1, max_length=256)
|
||||
username: str = Field(min_length=1, max_length=100)
|
||||
password: str = Field(min_length=1, max_length=1024)
|
||||
email: Optional[str] = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class ForgotPasswordRequest(StrictRequest):
|
||||
identifier: Optional[str] = Field(default=None, max_length=320)
|
||||
username: Optional[str] = Field(default=None, max_length=100)
|
||||
email: Optional[str] = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class PasswordResetRequest(StrictRequest):
|
||||
token: str = Field(min_length=1, max_length=512)
|
||||
new_password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class ProfileEmailUpdateRequest(StrictRequest):
|
||||
email: Optional[str] = Field(default=None, max_length=320)
|
||||
|
||||
|
||||
class ChangePasswordRequest(StrictRequest):
|
||||
current_password: str = Field(min_length=1, max_length=1024)
|
||||
new_password: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
def request_data(payload: BaseModel | dict[str, Any]) -> dict[str, Any]:
|
||||
"""Keep direct service-level tests compatible while FastAPI validates HTTP input."""
|
||||
return payload if isinstance(payload, dict) else payload.model_dump()
|
||||
@@ -1,7 +1,6 @@
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import time
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
@@ -186,7 +185,6 @@ class JellyfinClient(ApiClient):
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Checking whether the title is available in Jellyfin…")
|
||||
url = f"{self.base_url}/Items"
|
||||
params = {
|
||||
@@ -214,7 +212,6 @@ class JellyfinClient(ApiClient):
|
||||
if isinstance(item, dict) and item.get('Id'):
|
||||
items[item['Id']] = item
|
||||
result = {'Items': list(items.values()), 'TotalRecordCount': len(items)}
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
@@ -223,7 +220,6 @@ class JellyfinClient(ApiClient):
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
@@ -277,7 +273,6 @@ class JellyfinClient(ApiClient):
|
||||
async def refresh_library(self, recursive: bool = True) -> None:
|
||||
if not self.base_url or not self.api_key:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("Jellyfin", "Asking Jellyfin to refresh its library…")
|
||||
url = f"{self.base_url}/Library/Refresh"
|
||||
headers = self._emby_headers()
|
||||
@@ -286,7 +281,6 @@ class JellyfinClient(ApiClient):
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
@@ -294,7 +288,6 @@ class JellyfinClient(ApiClient):
|
||||
message="Jellyfin accepted the library refresh and is scanning for new media.",
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
import logging
|
||||
import time
|
||||
from .base import ApiClient, _operation_error_message
|
||||
from ..services.operation_progress import finish_remote_call, start_remote_call
|
||||
|
||||
@@ -89,7 +88,6 @@ class QBittorrentClient(ApiClient):
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent", "Checking qBittorrent for matching downloads…")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
@@ -97,7 +95,6 @@ class QBittorrentClient(ApiClient):
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
@@ -106,7 +103,6 @@ class QBittorrentClient(ApiClient):
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
@@ -119,7 +115,6 @@ class QBittorrentClient(ApiClient):
|
||||
async def _get_text(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
@@ -127,7 +122,6 @@ class QBittorrentClient(ApiClient):
|
||||
response = await client.get(f"{self.base_url}{path}", params=params)
|
||||
response.raise_for_status()
|
||||
result = response.text.strip()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
@@ -136,7 +130,6 @@ class QBittorrentClient(ApiClient):
|
||||
)
|
||||
return result
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
@@ -149,14 +142,12 @@ class QBittorrentClient(ApiClient):
|
||||
async def _post_form(self, path: str, data: Dict[str, Any]) -> None:
|
||||
if not self.base_url:
|
||||
return None
|
||||
started_at = time.perf_counter()
|
||||
operation_event_id = start_remote_call("qBittorrent")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await self._login(client)
|
||||
response = await client.post(f"{self.base_url}{path}", data=data)
|
||||
response.raise_for_status()
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
success=True,
|
||||
@@ -164,7 +155,6 @@ class QBittorrentClient(ApiClient):
|
||||
message=_torrent_action_message(path),
|
||||
)
|
||||
except Exception as exc:
|
||||
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||
status_code = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
|
||||
finish_remote_call(
|
||||
operation_event_id,
|
||||
|
||||
@@ -67,6 +67,7 @@ class Settings(BaseSettings):
|
||||
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
|
||||
)
|
||||
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
|
||||
log_format: str = Field(default="text", validation_alias=AliasChoices("LOG_FORMAT"))
|
||||
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
|
||||
log_file_max_bytes: int = Field(
|
||||
default=20_000_000, validation_alias=AliasChoices("LOG_FILE_MAX_BYTES")
|
||||
|
||||
+2
-155
@@ -13,6 +13,7 @@ from .config import settings
|
||||
from .models import Snapshot
|
||||
from .security import hash_password, verify_and_update_password, verify_password
|
||||
from .secret_storage import decrypt_setting_value, encrypt_setting_value, is_sensitive_setting
|
||||
from .schema_migrations import run_schema_migrations
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -686,163 +687,9 @@ def init_db() -> None:
|
||||
ON user_activity (last_seen_at)
|
||||
"""
|
||||
)
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN last_login_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'local'")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN jellyfin_password_hash TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE signup_invites ADD COLUMN code_hint TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
run_schema_migrations(conn)
|
||||
_protect_legacy_signup_invite_codes(conn)
|
||||
_encrypt_legacy_sensitive_settings(conn)
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
|
||||
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
|
||||
ON portal_items (related_item_id, updated_at DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_profile_id
|
||||
ON users (profile_id)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_expires_at
|
||||
ON users (expires_at)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username_nocase
|
||||
ON users (username COLLATE NOCASE)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_nocase
|
||||
ON users (email COLLATE NOCASE)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
|
||||
ON requests_cache (requested_by_id)
|
||||
"""
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
try:
|
||||
conn.execute("PRAGMA optimize")
|
||||
except sqlite3.OperationalError:
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any, Mapping, Optional
|
||||
from urllib.parse import parse_qs
|
||||
@@ -39,6 +40,22 @@ class RequestContextFilter(logging.Filter):
|
||||
return True
|
||||
|
||||
|
||||
class JsonLogFormatter(logging.Formatter):
|
||||
"""Stable JSON output for production log collectors."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"request_id": getattr(record, "request_id", "-"),
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def bind_request_id(request_id: str) -> contextvars.Token[str]:
|
||||
return REQUEST_ID_CONTEXT.set(request_id or "-")
|
||||
|
||||
@@ -150,6 +167,7 @@ def configure_logging(
|
||||
log_file_backup_count: int = 10,
|
||||
log_http_client_level: Optional[str] = "INFO",
|
||||
log_background_sync_level: Optional[str] = "INFO",
|
||||
log_format: Optional[str] = "text",
|
||||
) -> None:
|
||||
level_name = (log_level or "INFO").upper()
|
||||
level = getattr(logging, level_name, logging.INFO)
|
||||
@@ -176,10 +194,13 @@ def configure_logging(
|
||||
handlers.append(file_handler)
|
||||
|
||||
context_filter = RequestContextFilter()
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
if str(log_format or "text").strip().lower() == "json":
|
||||
formatter: logging.Formatter = JsonLogFormatter()
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s | %(levelname)s | %(name)s | request_id=%(request_id)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
for handler in handlers:
|
||||
handler.addFilter(context_filter)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
@@ -259,6 +259,7 @@ async def startup() -> None:
|
||||
log_file_backup_count=settings.log_file_backup_count,
|
||||
log_http_client_level=settings.log_http_client_level,
|
||||
log_background_sync_level=settings.log_background_sync_level,
|
||||
log_format=settings.log_format,
|
||||
)
|
||||
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
|
||||
_log_security_configuration_warnings()
|
||||
@@ -273,6 +274,7 @@ async def startup() -> None:
|
||||
log_file_backup_count=runtime.log_file_backup_count,
|
||||
log_http_client_level=runtime.log_http_client_level,
|
||||
log_background_sync_level=runtime.log_background_sync_level,
|
||||
log_format=runtime.log_format,
|
||||
)
|
||||
logger.info(
|
||||
"runtime settings applied log_level=%s log_file=%s log_file_max_bytes=%s log_file_backup_count=%s log_http_client_level=%s log_background_sync_level=%s request_source=%s",
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..auth import (
|
||||
resolve_user_auth_provider,
|
||||
)
|
||||
from ..config import normalize_banner_color, settings as env_settings
|
||||
from ..api_models import COMMON_ERROR_RESPONSES
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..db import (
|
||||
delete_setting,
|
||||
@@ -35,8 +36,6 @@ from ..db import (
|
||||
get_user_by_id,
|
||||
get_user_by_username,
|
||||
get_user_request_stats,
|
||||
create_user_if_missing,
|
||||
set_user_jellyseerr_id,
|
||||
set_setting,
|
||||
set_user_blocked,
|
||||
delete_user_data_by_username,
|
||||
@@ -59,7 +58,6 @@ from ..db import (
|
||||
cleanup_history,
|
||||
update_request_cache_title,
|
||||
repair_request_cache_titles,
|
||||
delete_non_admin_users,
|
||||
list_user_profiles,
|
||||
get_user_profile,
|
||||
create_user_profile,
|
||||
@@ -73,6 +71,7 @@ from ..db import (
|
||||
delete_signup_invite,
|
||||
get_signup_invite_by_code,
|
||||
disable_signup_invites_by_creator,
|
||||
delete_non_admin_users, # noqa: F401 - retained for compatibility with maintenance tooling/tests
|
||||
)
|
||||
from ..runtime import get_runtime_settings
|
||||
from ..clients.sonarr import SonarrClient
|
||||
@@ -81,12 +80,8 @@ from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..services.jellyfin_sync import sync_jellyfin_users
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
extract_jellyseerr_user_email,
|
||||
find_matching_jellyseerr_user,
|
||||
get_cached_jellyfin_users,
|
||||
get_cached_jellyseerr_users,
|
||||
match_jellyseerr_user_id,
|
||||
save_jellyfin_users_cache,
|
||||
save_jellyseerr_users_cache,
|
||||
clear_user_import_caches,
|
||||
@@ -109,7 +104,12 @@ from ..logging_config import configure_logging
|
||||
from ..routers import requests as requests_router
|
||||
from ..routers.branding import save_branding_image
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
responses=COMMON_ERROR_RESPONSES,
|
||||
)
|
||||
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
@@ -247,6 +247,7 @@ SETTING_KEYS: List[str] = [
|
||||
"qbittorrent_username",
|
||||
"qbittorrent_password",
|
||||
"log_level",
|
||||
"log_format",
|
||||
"log_file",
|
||||
"log_file_max_bytes",
|
||||
"log_file_backup_count",
|
||||
@@ -741,7 +742,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
set_setting(key, value_to_store)
|
||||
updates += 1
|
||||
changed_keys.append(key)
|
||||
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
|
||||
if key in {"log_level", "log_format", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
|
||||
touched_logging = True
|
||||
if touched_logging:
|
||||
runtime = get_runtime_settings()
|
||||
@@ -752,6 +753,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
log_file_backup_count=runtime.log_file_backup_count,
|
||||
log_http_client_level=runtime.log_http_client_level,
|
||||
log_background_sync_level=runtime.log_background_sync_level,
|
||||
log_format=runtime.log_format,
|
||||
)
|
||||
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
|
||||
return {"status": "ok", "updated": updates}
|
||||
|
||||
@@ -60,6 +60,15 @@ from ..auth import (
|
||||
set_auth_cookies,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..api_models import (
|
||||
COMMON_ERROR_RESPONSES,
|
||||
ChangePasswordRequest,
|
||||
ForgotPasswordRequest,
|
||||
PasswordResetRequest,
|
||||
ProfileEmailUpdateRequest,
|
||||
SignupRequest,
|
||||
request_data,
|
||||
)
|
||||
from ..network_security import request_trusts_forwarded_headers
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
@@ -81,7 +90,7 @@ from ..services.password_reset import (
|
||||
verify_password_reset_token,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
router = APIRouter(prefix="/auth", tags=["auth"], responses=COMMON_ERROR_RESPONSES)
|
||||
logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
STREAM_TOKEN_TTL_SECONDS = 120
|
||||
@@ -869,7 +878,8 @@ async def invite_details(code: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/signup")
|
||||
async def signup(payload: dict, response: Response) -> dict:
|
||||
async def signup(payload: SignupRequest, response: Response) -> dict:
|
||||
payload = request_data(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
invite_code = str(payload.get("invite_code") or "").strip()
|
||||
@@ -1054,7 +1064,8 @@ async def signup(payload: dict, response: Response) -> dict:
|
||||
|
||||
|
||||
@router.post("/password/forgot")
|
||||
async def forgot_password(payload: dict, request: Request) -> dict:
|
||||
async def forgot_password(payload: ForgotPasswordRequest, request: Request) -> dict:
|
||||
payload = request_data(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
identifier = payload.get("identifier") or payload.get("username") or payload.get("email")
|
||||
@@ -1106,7 +1117,8 @@ async def password_reset_verify(token: str) -> dict:
|
||||
|
||||
|
||||
@router.post("/password/reset")
|
||||
async def password_reset(payload: dict) -> dict:
|
||||
async def password_reset(payload: PasswordResetRequest) -> dict:
|
||||
payload = request_data(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
token = payload.get("token")
|
||||
@@ -1169,7 +1181,10 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
|
||||
|
||||
@router.put("/profile/email")
|
||||
async def update_profile_email(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
async def update_profile_email(
|
||||
payload: ProfileEmailUpdateRequest, current_user: dict = Depends(get_current_user)
|
||||
) -> dict:
|
||||
payload = request_data(payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
|
||||
username = str(current_user.get("username") or "").strip()
|
||||
@@ -1435,7 +1450,10 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict:
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest, current_user: dict = Depends(get_current_user)
|
||||
) -> dict:
|
||||
payload = request_data(payload)
|
||||
current_password = payload.get("current_password") if isinstance(payload, dict) else None
|
||||
new_password = payload.get("new_password") if isinstance(payload, dict) else None
|
||||
if not isinstance(current_password, str) or not isinstance(new_password, str):
|
||||
|
||||
@@ -3,7 +3,7 @@ import warnings
|
||||
from io import BytesIO
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
import mimetypes
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
import httpx
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..api_models import COMMON_ERROR_RESPONSES
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..db import (
|
||||
add_portal_item_activity,
|
||||
@@ -34,7 +35,12 @@ from ..services.issue_resolution import (
|
||||
from ..services.notifications import send_portal_notification
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
|
||||
router = APIRouter(
|
||||
prefix="/portal",
|
||||
tags=["portal"],
|
||||
dependencies=[Depends(get_current_user), Depends(require_portal_access)],
|
||||
responses=COMMON_ERROR_RESPONSES,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PORTAL_KINDS = {"request", "issue", "feature"}
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..services.public_urls import magent_public_url
|
||||
from ..auth import get_current_user, require_admin
|
||||
from ..auth import require_admin
|
||||
from ..feature_guards import require_stats
|
||||
from ..services import email_recaps as recaps, recap_store as store
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..clients.sonarr import SonarrClient
|
||||
from ..clients.bazarr import BazarrClient
|
||||
from ..ai.triage import triage_snapshot
|
||||
from ..auth import get_current_user
|
||||
from ..api_models import COMMON_ERROR_RESPONSES
|
||||
from ..runtime import get_runtime_settings
|
||||
from .images import cache_tmdb_image, is_tmdb_cached
|
||||
from ..db import (
|
||||
@@ -30,7 +31,6 @@ from ..db import (
|
||||
save_action,
|
||||
get_recent_actions,
|
||||
get_recent_snapshots,
|
||||
get_cached_requests,
|
||||
get_cached_requests_since,
|
||||
get_cached_request_by_media_id,
|
||||
get_request_cache_lookup,
|
||||
@@ -62,6 +62,7 @@ from ..db import (
|
||||
)
|
||||
from ..services.media_repair import current_cycle_torrents
|
||||
from ..services.download_labels import label_episode_downloads
|
||||
from ..services.arr import RootFolderNotFoundError, resolve_root_folder_path
|
||||
from ..models import Snapshot, TriageResult, RequestType
|
||||
from ..services.snapshot import (
|
||||
_summarize_qbit,
|
||||
@@ -70,7 +71,12 @@ from ..services.snapshot import (
|
||||
jellyfin_item_matches_request,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
|
||||
router = APIRouter(
|
||||
prefix="/requests",
|
||||
tags=["requests"],
|
||||
dependencies=[Depends(get_current_user), Depends(require_request_access)],
|
||||
responses=COMMON_ERROR_RESPONSES,
|
||||
)
|
||||
|
||||
CACHE_TTL_SECONDS = 600
|
||||
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
|
||||
@@ -1753,7 +1759,6 @@ def _filter_arr_release_results(results: Any, include_rejected: bool = False) ->
|
||||
"approved": accepted,
|
||||
"rejected": item.get("rejected"),
|
||||
"temporarilyRejected": item.get("temporarilyRejected"),
|
||||
"rejections": item.get("rejections"),
|
||||
"downloadAllowed": item.get("downloadAllowed"),
|
||||
"fullSeason": item.get("fullSeason"),
|
||||
"seasonNumber": item.get("seasonNumber"),
|
||||
@@ -1971,16 +1976,10 @@ def _issue_season_payloads(episodes: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
|
||||
|
||||
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
|
||||
if root_folder.isdigit():
|
||||
folders = await client.get_root_folders()
|
||||
if isinstance(folders, list):
|
||||
for folder in folders:
|
||||
if folder.get("id") == int(root_folder):
|
||||
path = folder.get("path")
|
||||
if isinstance(path, str) and path:
|
||||
return path
|
||||
raise HTTPException(status_code=400, detail=f"{service_name} root folder id {root_folder} not found")
|
||||
return root_folder
|
||||
try:
|
||||
return await resolve_root_folder_path(client, root_folder, service_name)
|
||||
except RootFolderNotFoundError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{request_id}/issue-options")
|
||||
@@ -2979,7 +2978,6 @@ async def recent_requests(
|
||||
) -> dict:
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
mode = (runtime.requests_data_source or "prefer_cache").lower()
|
||||
# Browsing is always local. Synchronization is owned by background workers.
|
||||
allow_remote = False
|
||||
username_norm = _normalize_username(user.get("username", ""))
|
||||
@@ -3007,8 +3005,6 @@ async def recent_requests(
|
||||
allow_title_hydrate = False
|
||||
allow_artwork_hydrate = False
|
||||
stage_cache = await asyncio.to_thread(get_request_stage_cache)
|
||||
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
jellyfin_cache: Dict[str, bool] = {}
|
||||
results = []
|
||||
for row in rows:
|
||||
status = row.get("status")
|
||||
@@ -3946,7 +3942,7 @@ async def action_grab(
|
||||
release_title = receipt.get('title')
|
||||
arr_error: Optional[str] = None
|
||||
try:
|
||||
response = await arr_client.grab_release(str(guid), arr_indexer_id)
|
||||
await arr_client.grab_release(str(guid), arr_indexer_id)
|
||||
action_message = (
|
||||
f"{release_title or 'Selected release'} was sent through {service_label} for download and import."
|
||||
+ (' Profile limits explicitly overridden: ' + '; '.join(receipt['rejections']) if receipt['override'] else '')
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Transactional, versioned SQLite schema migrations for Magent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable
|
||||
|
||||
|
||||
MigrationStep = Callable[[sqlite3.Connection], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
version: int
|
||||
name: str
|
||||
apply: MigrationStep
|
||||
|
||||
|
||||
def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {str(row[1]) for row in conn.execute(f'PRAGMA table_info("{table}")').fetchall()}
|
||||
|
||||
|
||||
def _add_column(conn: sqlite3.Connection, table: str, definition: str) -> None:
|
||||
column = definition.split(maxsplit=1)[0].strip('"')
|
||||
if column not in _column_names(conn, table):
|
||||
conn.execute(f'ALTER TABLE "{table}" ADD COLUMN {definition}')
|
||||
|
||||
|
||||
def _migration_001_legacy_columns_and_indexes(conn: sqlite3.Connection) -> None:
|
||||
for definition in (
|
||||
"email TEXT",
|
||||
"last_login_at TEXT",
|
||||
"is_blocked INTEGER NOT NULL DEFAULT 0",
|
||||
"auth_provider TEXT NOT NULL DEFAULT 'local'",
|
||||
"jellyfin_password_hash TEXT",
|
||||
"last_jellyfin_auth_at TEXT",
|
||||
"jellyseerr_user_id INTEGER",
|
||||
"auto_search_enabled INTEGER NOT NULL DEFAULT 1",
|
||||
"invite_management_enabled INTEGER NOT NULL DEFAULT 0",
|
||||
"profile_id INTEGER",
|
||||
"expires_at TEXT",
|
||||
"invited_by_code TEXT",
|
||||
"invited_at TEXT",
|
||||
"auth_version INTEGER NOT NULL DEFAULT 1",
|
||||
):
|
||||
_add_column(conn, "users", definition)
|
||||
|
||||
for definition in ("recipient_email TEXT", "code_hint TEXT"):
|
||||
_add_column(conn, "signup_invites", definition)
|
||||
|
||||
for definition in (
|
||||
"related_item_id INTEGER",
|
||||
"workflow_request_status TEXT",
|
||||
"workflow_media_status TEXT",
|
||||
"issue_type TEXT",
|
||||
"issue_resolved_at TEXT",
|
||||
"metadata_json TEXT",
|
||||
):
|
||||
_add_column(conn, "portal_items", definition)
|
||||
|
||||
_add_column(conn, "requests_cache", "requested_by_id INTEGER")
|
||||
|
||||
statements = (
|
||||
"CREATE INDEX IF NOT EXISTS idx_portal_items_workflow ON portal_items "
|
||||
"(kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_portal_items_related_item ON portal_items "
|
||||
"(related_item_id, updated_at DESC, id DESC)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_profile_id ON users (profile_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users (expires_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_username_nocase ON users (username COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_email_nocase ON users (email COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id ON requests_cache (requested_by_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at ON requests_cache "
|
||||
"(requested_by_id, created_at DESC, request_id DESC)",
|
||||
)
|
||||
for statement in statements:
|
||||
conn.execute(statement)
|
||||
|
||||
|
||||
MIGRATIONS = (
|
||||
Migration(1, "legacy_columns_and_indexes", _migration_001_legacy_columns_and_indexes),
|
||||
)
|
||||
|
||||
|
||||
def run_schema_migrations(conn: sqlite3.Connection) -> list[int]:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
applied = {int(row[0]) for row in conn.execute("SELECT version FROM schema_migrations")}
|
||||
completed: list[int] = []
|
||||
for migration in MIGRATIONS:
|
||||
if migration.version in applied:
|
||||
continue
|
||||
savepoint = f"magent_migration_{migration.version}"
|
||||
conn.execute(f"SAVEPOINT {savepoint}")
|
||||
try:
|
||||
migration.apply(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
|
||||
(migration.version, migration.name, datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||
except Exception:
|
||||
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
||||
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||
raise
|
||||
completed.append(migration.version)
|
||||
return completed
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared Sonarr/Radarr configuration helpers."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RootFolderNotFoundError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
async def resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
|
||||
configured = str(root_folder or "").strip()
|
||||
if not configured.isdigit():
|
||||
return configured
|
||||
folders = await client.get_root_folders()
|
||||
if isinstance(folders, list):
|
||||
for folder in folders:
|
||||
if isinstance(folder, dict) and folder.get("id") == int(configured):
|
||||
path = str(folder.get("path") or "").strip()
|
||||
if path:
|
||||
return path
|
||||
raise RootFolderNotFoundError(f"{service_name} root folder id {configured} not found")
|
||||
@@ -1,4 +1,3 @@
|
||||
from .public_urls import magent_public_url
|
||||
"""Independent newsletter consent and immutable edition snapshots using the shared email queue."""
|
||||
|
||||
import hashlib
|
||||
@@ -11,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from .. import db
|
||||
from . import email_queue
|
||||
from .recap_store import read_one, transaction
|
||||
from .public_urls import magent_public_url
|
||||
|
||||
|
||||
class Conflict(ValueError):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from .public_urls import magent_public_url
|
||||
"""Durable consent, schedule and delivery records for personal email recaps."""
|
||||
|
||||
import hashlib
|
||||
@@ -11,6 +10,7 @@ from datetime import datetime
|
||||
from .. import db
|
||||
from .monthly_reports import shift_month
|
||||
from . import email_queue
|
||||
from .public_urls import magent_public_url
|
||||
|
||||
|
||||
def init_schema(conn: sqlite3.Connection) -> None:
|
||||
|
||||
@@ -32,6 +32,7 @@ from ..models import ActionOption, NormalizedState, RequestType, Snapshot, Timel
|
||||
from .collector_search import read_search_status
|
||||
from .media_repair import current_cycle_torrents, evaluate_media_repair
|
||||
from .download_labels import label_episode_downloads
|
||||
from .arr import RootFolderNotFoundError, resolve_root_folder_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1234,11 +1235,6 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
arr_item = None
|
||||
arr_queue = None
|
||||
episodes = None
|
||||
media_status = jelly_request.get("media", {}).get("status")
|
||||
try:
|
||||
media_status_code = int(media_status) if media_status is not None else None
|
||||
except (TypeError, ValueError):
|
||||
media_status_code = None
|
||||
if snapshot.request_type == RequestType.tv:
|
||||
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
|
||||
if tvdb_id:
|
||||
@@ -1390,11 +1386,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
if runtime.radarr_quality_profile_id and runtime.radarr_root_folder:
|
||||
radarr_client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||
if radarr_client.configured():
|
||||
root_folder = await _resolve_root_folder_path(
|
||||
radarr_client, runtime.radarr_root_folder, "Radarr"
|
||||
)
|
||||
try:
|
||||
root_folder = await resolve_root_folder_path(
|
||||
radarr_client, runtime.radarr_root_folder, "Radarr"
|
||||
)
|
||||
except RootFolderNotFoundError as exc:
|
||||
logger.warning("Skipping Jellyfin-to-Radarr sync: %s", exc)
|
||||
root_folder = ""
|
||||
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
|
||||
if tmdb_id:
|
||||
if tmdb_id and root_folder:
|
||||
try:
|
||||
await radarr_client.add_movie(
|
||||
int(tmdb_id),
|
||||
@@ -1409,11 +1409,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
if runtime.sonarr_quality_profile_id and runtime.sonarr_root_folder:
|
||||
sonarr_client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
if sonarr_client.configured():
|
||||
root_folder = await _resolve_root_folder_path(
|
||||
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
|
||||
)
|
||||
try:
|
||||
root_folder = await resolve_root_folder_path(
|
||||
sonarr_client, runtime.sonarr_root_folder, "Sonarr"
|
||||
)
|
||||
except RootFolderNotFoundError as exc:
|
||||
logger.warning("Skipping Jellyfin-to-Sonarr sync: %s", exc)
|
||||
root_folder = ""
|
||||
tvdb_id = jelly_request.get("media", {}).get("tvdbId")
|
||||
if tvdb_id:
|
||||
if tvdb_id and root_folder:
|
||||
try:
|
||||
await sonarr_client.add_series(
|
||||
int(tvdb_id),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
coverage==7.16.1
|
||||
pip-audit==2.10.1
|
||||
ruff==0.16.8
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.app.api_models import PasswordResetRequest, SignupRequest
|
||||
|
||||
|
||||
class ApiRequestModelTests(unittest.TestCase):
|
||||
def test_signup_rejects_unknown_fields(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
SignupRequest(
|
||||
invite_code="invite",
|
||||
username="viewer",
|
||||
password="strong password",
|
||||
unexpected="value",
|
||||
)
|
||||
|
||||
def test_password_reset_preserves_password_whitespace_for_policy_validation(self) -> None:
|
||||
request = PasswordResetRequest(token="token", new_password=" leading and trailing ")
|
||||
self.assertEqual(request.new_password, " leading and trailing ")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
from backend.app.services.arr import RootFolderNotFoundError, resolve_root_folder_path
|
||||
|
||||
|
||||
class _ArrClient:
|
||||
async def get_root_folders(self):
|
||||
return [{"id": 7, "path": "/media/tv"}]
|
||||
|
||||
|
||||
class ArrHelperTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_resolves_numeric_root_folder_id(self) -> None:
|
||||
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "7", "Sonarr"), "/media/tv")
|
||||
|
||||
async def test_preserves_configured_path(self) -> None:
|
||||
self.assertEqual(await resolve_root_folder_path(_ArrClient(), "/media/movies", "Radarr"), "/media/movies")
|
||||
|
||||
async def test_rejects_missing_root_folder_id(self) -> None:
|
||||
with self.assertRaises(RootFolderNotFoundError):
|
||||
await resolve_root_folder_path(_ArrClient(), "8", "Sonarr")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,25 @@
|
||||
import json
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from backend.app.logging_config import JsonLogFormatter, RequestContextFilter, bind_request_id, reset_request_id
|
||||
|
||||
|
||||
class JsonLoggingTests(unittest.TestCase):
|
||||
def test_json_formatter_includes_request_context(self) -> None:
|
||||
token = bind_request_id("request-123")
|
||||
try:
|
||||
record = logging.LogRecord("magent.test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||
RequestContextFilter().filter(record)
|
||||
payload = json.loads(JsonLogFormatter().format(record))
|
||||
finally:
|
||||
reset_request_id(token)
|
||||
|
||||
self.assertEqual(payload["level"], "INFO")
|
||||
self.assertEqual(payload["logger"], "magent.test")
|
||||
self.assertEqual(payload["request_id"], "request-123")
|
||||
self.assertEqual(payload["message"], "hello world")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from backend.app.schema_migrations import run_schema_migrations
|
||||
|
||||
|
||||
class SchemaMigrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.conn = sqlite3.connect(":memory:")
|
||||
self.conn.execute(
|
||||
"CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, role TEXT, created_at TEXT)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE TABLE signup_invites (id INTEGER PRIMARY KEY, code TEXT NOT NULL UNIQUE, created_at TEXT, updated_at TEXT)"
|
||||
)
|
||||
self.conn.execute("CREATE TABLE portal_items (id INTEGER PRIMARY KEY, kind TEXT, updated_at TEXT)")
|
||||
self.conn.execute("CREATE TABLE requests_cache (request_id INTEGER PRIMARY KEY, created_at TEXT)")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
def test_migrations_are_versioned_and_idempotent(self) -> None:
|
||||
self.assertEqual(run_schema_migrations(self.conn), [1])
|
||||
self.assertEqual(run_schema_migrations(self.conn), [])
|
||||
|
||||
user_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(users)")}
|
||||
self.assertIn("auth_version", user_columns)
|
||||
self.assertIn("email", user_columns)
|
||||
request_columns = {row[1] for row in self.conn.execute("PRAGMA table_info(requests_cache)")}
|
||||
self.assertIn("requested_by_id", request_columns)
|
||||
applied = self.conn.execute("SELECT version, name FROM schema_migrations").fetchall()
|
||||
self.assertEqual(applied, [(1, "legacy_columns_and_indexes")])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user