Aniworld/src/server/api/config.py
Lukas 85d73b8294 feat: implement missing API endpoints for scheduler, logging, and diagnostics
- Add scheduler API endpoints for configuration and manual rescan triggers
- Add logging API endpoints for config management and log file operations
- Add diagnostics API endpoints for network and system information
- Extend config API with advanced settings, directory updates, export, and reset
- Update FastAPI app to include new routers
- Update API reference documentation with all new endpoints
- Update infrastructure documentation with endpoint listings
- Add comprehensive API implementation summary

All new endpoints follow project coding standards with:
- Type hints and Pydantic validation
- Proper authentication and authorization
- Comprehensive error handling and logging
- Security best practices (path validation, input sanitization)

Test results: 752/802 tests passing (93.8%)
2025-10-24 10:39:29 +02:00

350 lines
11 KiB
Python

from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from src.server.models.config import AppConfig, ConfigUpdate, ValidationResult
from src.server.services.config_service import (
ConfigBackupError,
ConfigServiceError,
ConfigValidationError,
get_config_service,
)
from src.server.utils.dependencies import require_auth
router = APIRouter(prefix="/api/config", tags=["config"])
@router.get("", response_model=AppConfig)
def get_config(auth: Optional[dict] = Depends(require_auth)) -> AppConfig:
"""Return current application configuration."""
try:
config_service = get_config_service()
return config_service.load_config()
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to load config: {e}"
) from e
@router.put("", response_model=AppConfig)
def update_config(
update: ConfigUpdate, auth: dict = Depends(require_auth)
) -> AppConfig:
"""Apply an update to the configuration and persist it.
Creates automatic backup before applying changes.
"""
try:
config_service = get_config_service()
return config_service.update_config(update)
except ConfigValidationError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid configuration: {e}"
) from e
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update config: {e}"
) from e
@router.post("/validate", response_model=ValidationResult)
def validate_config(
cfg: AppConfig, auth: dict = Depends(require_auth) # noqa: ARG001
) -> ValidationResult:
"""Validate a provided AppConfig without applying it.
Returns ValidationResult with any validation errors.
"""
try:
config_service = get_config_service()
return config_service.validate_config(cfg)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
) from e
@router.get("/backups", response_model=List[Dict[str, object]])
def list_backups(
auth: dict = Depends(require_auth)
) -> List[Dict[str, object]]:
"""List all available configuration backups.
Returns list of backup metadata including name, size, and created time.
"""
try:
config_service = get_config_service()
return config_service.list_backups()
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to list backups: {e}"
) from e
@router.post("/backups", response_model=Dict[str, str])
def create_backup(
name: Optional[str] = None, auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Create a backup of the current configuration.
Args:
name: Optional custom backup name (timestamp used if not provided)
Returns:
Dictionary with backup name and message
"""
try:
config_service = get_config_service()
backup_path = config_service.create_backup(name)
return {
"name": backup_path.name,
"message": "Backup created successfully"
}
except ConfigBackupError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to create backup: {e}"
) from e
@router.post("/backups/{backup_name}/restore", response_model=AppConfig)
def restore_backup(
backup_name: str, auth: dict = Depends(require_auth)
) -> AppConfig:
"""Restore configuration from a backup.
Creates backup of current config before restoring.
Args:
backup_name: Name of backup file to restore
Returns:
Restored configuration
"""
try:
config_service = get_config_service()
return config_service.restore_backup(backup_name)
except ConfigBackupError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Failed to restore backup: {e}"
) from e
@router.delete("/backups/{backup_name}")
def delete_backup(
backup_name: str, auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Delete a configuration backup.
Args:
backup_name: Name of backup file to delete
Returns:
Success message
"""
try:
config_service = get_config_service()
config_service.delete_backup(backup_name)
return {"message": f"Backup '{backup_name}' deleted successfully"}
except ConfigBackupError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Failed to delete backup: {e}"
) from e
@router.get("/section/advanced", response_model=Dict[str, object])
def get_advanced_config(
auth: Optional[dict] = Depends(require_auth)
) -> Dict[str, object]:
"""Get advanced configuration section.
Returns:
Dictionary with advanced configuration settings
"""
try:
config_service = get_config_service()
app_config = config_service.load_config()
return app_config.other.get("advanced", {})
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to load advanced config: {e}"
) from e
@router.post("/section/advanced", response_model=Dict[str, str])
def update_advanced_config(
config: Dict[str, object], auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Update advanced configuration section.
Args:
config: Advanced configuration settings
auth: Authentication token (required)
Returns:
Success message
"""
try:
config_service = get_config_service()
app_config = config_service.load_config()
# Update advanced section in other
if "advanced" not in app_config.other:
app_config.other["advanced"] = {}
app_config.other["advanced"].update(config)
config_service.save_config(app_config)
return {"message": "Advanced configuration updated successfully"}
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update advanced config: {e}"
) from e
@router.post("/directory", response_model=Dict[str, str])
def update_directory(
directory_config: Dict[str, str], auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Update anime directory configuration.
Args:
directory_config: Dictionary with 'directory' key
auth: Authentication token (required)
Returns:
Success message
"""
try:
directory = directory_config.get("directory")
if not directory:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Directory path is required"
)
config_service = get_config_service()
app_config = config_service.load_config()
# Store directory in other section
if "anime_directory" not in app_config.other:
app_config.other["anime_directory"] = directory
else:
app_config.other["anime_directory"] = directory
config_service.save_config(app_config)
return {"message": "Anime directory updated successfully"}
except ConfigServiceError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update directory: {e}"
) from e
@router.post("/export")
async def export_config(
export_options: Dict[str, bool], auth: dict = Depends(require_auth)
):
"""Export configuration to JSON file.
Args:
export_options: Options for export (include_sensitive, etc.)
auth: Authentication token (required)
Returns:
JSON file download response
"""
try:
import json
from fastapi.responses import Response
config_service = get_config_service()
app_config = config_service.load_config()
# Convert to dict
config_dict = app_config.model_dump()
# Optionally remove sensitive data
if not export_options.get("include_sensitive", False):
# Remove sensitive fields if present
config_dict.pop("password_salt", None)
config_dict.pop("password_hash", None)
# Create filename with timestamp
from datetime import datetime
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"aniworld_config_{timestamp}.json"
# Return as downloadable JSON
content = json.dumps(config_dict, indent=2)
return Response(
content=content,
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to export config: {e}"
) from e
@router.post("/reset", response_model=Dict[str, str])
def reset_config(
reset_options: Dict[str, bool], auth: dict = Depends(require_auth)
) -> Dict[str, str]:
"""Reset configuration to defaults.
Args:
reset_options: Options for reset (preserve_security, etc.)
auth: Authentication token (required)
Returns:
Success message
"""
try:
config_service = get_config_service()
# Create backup before resetting
config_service.create_backup("pre_reset")
# Load default config
default_config = AppConfig()
# If preserve_security is True, keep authentication settings
if reset_options.get("preserve_security", True):
current_config = config_service.load_config()
# Preserve security-related fields from other
if "password_salt" in current_config.other:
default_config.other["password_salt"] = (
current_config.other["password_salt"]
)
if "password_hash" in current_config.other:
default_config.other["password_hash"] = (
current_config.other["password_hash"]
)
# Save default config
config_service.save_config(default_config)
return {
"message": "Configuration reset to defaults successfully"
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset config: {e}"
) from e