cosmo/backend/app/api/system.py

305 lines
9.2 KiB
Python

"""
System Settings API Routes
"""
from fastapi import APIRouter, HTTPException, Query, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from typing import Optional, Dict, Any, List
from datetime import datetime
import logging
from pydantic import BaseModel
from app.services.system_settings_service import system_settings_service
from app.services.redis_cache import redis_cache
from app.services.cache import cache_service
from app.database import get_db
from app.models.db import Position
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/system", tags=["system"])
# Pydantic models
class SettingCreate(BaseModel):
key: str
value: Any
value_type: str = "string"
category: str = "general"
label: str
description: Optional[str] = None
is_public: bool = False
class SettingUpdate(BaseModel):
value: Optional[Any] = None
value_type: Optional[str] = None
category: Optional[str] = None
label: Optional[str] = None
description: Optional[str] = None
is_public: Optional[bool] = None
# ============================================================
# System Settings CRUD APIs
# ============================================================
@router.get("/settings")
async def list_settings(
category: Optional[str] = Query(None, description="Filter by category"),
is_public: Optional[bool] = Query(None, description="Filter by public status"),
db: AsyncSession = Depends(get_db)
):
"""
Get all system settings
Query parameters:
- category: Optional filter by category (e.g., 'visualization', 'cache', 'ui')
- is_public: Optional filter by public status (true for frontend-accessible settings)
"""
settings = await system_settings_service.get_all_settings(db, category, is_public)
result = []
for setting in settings:
# Parse value based on type
parsed_value = await system_settings_service.get_setting_value(setting.key, db)
result.append({
"id": setting.id,
"key": setting.key,
"value": parsed_value,
"raw_value": setting.value,
"value_type": setting.value_type,
"category": setting.category,
"label": setting.label,
"description": setting.description,
"is_public": setting.is_public,
"created_at": setting.created_at.isoformat() if setting.created_at else None,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
})
return {"settings": result}
@router.get("/settings/{key}")
async def get_setting(
key: str,
db: AsyncSession = Depends(get_db)
):
"""Get a single setting by key"""
setting = await system_settings_service.get_setting(key, db)
if not setting:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
parsed_value = await system_settings_service.get_setting_value(key, db)
return {
"id": setting.id,
"key": setting.key,
"value": parsed_value,
"raw_value": setting.value,
"value_type": setting.value_type,
"category": setting.category,
"label": setting.label,
"description": setting.description,
"is_public": setting.is_public,
"created_at": setting.created_at.isoformat() if setting.created_at else None,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
}
@router.post("/settings", status_code=status.HTTP_201_CREATED)
async def create_setting(
data: SettingCreate,
db: AsyncSession = Depends(get_db)
):
"""Create a new system setting"""
# Check if setting already exists
existing = await system_settings_service.get_setting(data.key, db)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Setting '{data.key}' already exists"
)
new_setting = await system_settings_service.create_setting(data.dict(), db)
await db.commit()
parsed_value = await system_settings_service.get_setting_value(data.key, db)
return {
"id": new_setting.id,
"key": new_setting.key,
"value": parsed_value,
"value_type": new_setting.value_type,
"category": new_setting.category,
"label": new_setting.label,
"description": new_setting.description,
"is_public": new_setting.is_public,
}
@router.put("/settings/{key}")
async def update_setting(
key: str,
data: SettingUpdate,
db: AsyncSession = Depends(get_db)
):
"""Update a system setting"""
update_data = {k: v for k, v in data.dict().items() if v is not None}
updated = await system_settings_service.update_setting(key, update_data, db)
if not updated:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
await db.commit()
parsed_value = await system_settings_service.get_setting_value(key, db)
return {
"id": updated.id,
"key": updated.key,
"value": parsed_value,
"value_type": updated.value_type,
"category": updated.category,
"label": updated.label,
"description": updated.description,
"is_public": updated.is_public,
}
@router.delete("/settings/{key}")
async def delete_setting(
key: str,
db: AsyncSession = Depends(get_db)
):
"""Delete a system setting"""
deleted = await system_settings_service.delete_setting(key, db)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found"
)
await db.commit()
return {"message": f"Setting '{key}' deleted successfully"}
# ============================================================
# Cache Management APIs
# ============================================================
@router.post("/cache/clear")
async def clear_all_caches():
"""
Clear all caches (memory + Redis)
This is a critical operation for platform management.
It clears:
- Memory cache (in-process)
- Redis cache (all positions and NASA data)
"""
logger.info("🧹 Starting cache clear operation...")
# Clear memory cache
cache_service.clear()
logger.info("✓ Memory cache cleared")
# Clear Redis cache
positions_cleared = await redis_cache.clear_pattern("positions:*")
nasa_cleared = await redis_cache.clear_pattern("nasa:*")
logger.info(f"✓ Redis cache cleared ({positions_cleared + nasa_cleared} keys)")
total_cleared = positions_cleared + nasa_cleared
return {
"message": f"All caches cleared successfully ({total_cleared} Redis keys deleted)",
"memory_cache": "cleared",
"redis_cache": {
"positions_keys": positions_cleared,
"nasa_keys": nasa_cleared,
"total": total_cleared
}
}
@router.get("/cache/stats")
async def get_cache_stats():
"""Get cache statistics"""
redis_stats = await redis_cache.get_stats()
return {
"redis": redis_stats,
"memory": {
"description": "In-memory cache (process-level)",
"note": "Statistics not available for in-memory cache"
}
}
@router.post("/settings/init-defaults")
async def initialize_default_settings(
db: AsyncSession = Depends(get_db)
):
"""Initialize default system settings (admin use)"""
await system_settings_service.initialize_default_settings(db)
await db.commit()
return {"message": "Default settings initialized successfully"}
@router.get("/data-cutoff-date")
async def get_data_cutoff_date(
db: AsyncSession = Depends(get_db)
):
"""
Get the data cutoff date based on the Sun's (ID=10) last available data
This endpoint returns the latest date for which we have position data
in the database. It's used by the frontend to determine:
- The current date to display on the homepage
- The maximum date for timeline playback
Returns:
- cutoff_date: ISO format date string (YYYY-MM-DD)
- timestamp: Unix timestamp
- datetime: Full ISO datetime string
"""
try:
# Query the latest position data for the Sun (body_id = 10)
stmt = select(func.max(Position.time)).where(
Position.body_id == '10'
)
result = await db.execute(stmt)
latest_time = result.scalar_one_or_none()
if latest_time is None:
# No data available, return current date as fallback
logger.warning("No position data found for Sun (ID=10), using current date as fallback")
latest_time = datetime.utcnow()
# Format the response
cutoff_date = latest_time.strftime("%Y-%m-%d")
return {
"cutoff_date": cutoff_date,
"timestamp": int(latest_time.timestamp()),
"datetime": latest_time.isoformat(),
"message": "Data cutoff date retrieved successfully"
}
except Exception as e:
logger.error(f"Error retrieving data cutoff date: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve data cutoff date: {str(e)}"
)