diff --git a/app/api/cache.py b/app/api/cache.py new file mode 100644 index 0000000..36ce847 --- /dev/null +++ b/app/api/cache.py @@ -0,0 +1,73 @@ +""" +Cache Management API routes +""" +import logging +from fastapi import APIRouter, HTTPException, Query + +from app.services.cache import cache_service +from app.services.redis_cache import redis_cache +from app.services.cache_preheat import ( + preheat_all_caches, + preheat_current_positions, + preheat_historical_positions +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/cache", tags=["cache"]) + + +@router.post("/clear") +async def clear_cache(): + """ + Clear the data cache (admin endpoint) + + Clears both memory cache and Redis cache + """ + # Clear memory cache + cache_service.clear() + + # Clear Redis cache + positions_cleared = await redis_cache.clear_pattern("positions:*") + nasa_cleared = await redis_cache.clear_pattern("nasa:*") + + total_cleared = positions_cleared + nasa_cleared + + return { + "message": f"Cache cleared successfully ({total_cleared} Redis keys deleted)", + "memory_cache": "cleared", + "redis_cache": { + "positions_keys": positions_cleared, + "nasa_keys": nasa_cleared, + "total": total_cleared + } + } + + +@router.post("/preheat") +async def preheat_cache( + mode: str = Query("all", description="Preheat mode: 'all', 'current', 'historical'"), + days: int = Query(3, description="Number of days for historical preheat", ge=1, le=30) +): + """ + Manually trigger cache preheat (admin endpoint) + + Args: + mode: 'all' (both current and historical), 'current' (current positions only), 'historical' (historical only) + days: Number of days to preheat for historical mode (default: 3, max: 30) + """ + try: + if mode == "all": + await preheat_all_caches() + return {"message": f"Successfully preheated all caches (current + {days} days historical)"} + elif mode == "current": + await preheat_current_positions() + return {"message": "Successfully preheated current positions"} + elif mode == "historical": + await preheat_historical_positions(days=days) + return {"message": f"Successfully preheated {days} days of historical positions"} + else: + raise HTTPException(status_code=400, detail=f"Invalid mode: {mode}. Use 'all', 'current', or 'historical'") + except Exception as e: + logger.error(f"Cache preheat failed: {e}") + raise HTTPException(status_code=500, detail=f"Preheat failed: {str(e)}") diff --git a/app/api/celestial_body.py b/app/api/celestial_body.py new file mode 100644 index 0000000..78e7360 --- /dev/null +++ b/app/api/celestial_body.py @@ -0,0 +1,219 @@ +""" +Celestial Body Management API routes +Handles CRUD operations for celestial bodies (planets, dwarf planets, satellites, probes, etc.) +""" +import logging +from fastapi import APIRouter, HTTPException, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from typing import Optional, Dict, Any + +from app.database import get_db +from app.models.celestial import BodyInfo +from app.services.horizons import horizons_service +from app.services.db_service import celestial_body_service, resource_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/celestial", tags=["celestial-body"]) + + +# Pydantic models for CRUD +class CelestialBodyCreate(BaseModel): + id: str + name: str + name_zh: Optional[str] = None + type: str + description: Optional[str] = None + is_active: bool = True + extra_data: Optional[Dict[str, Any]] = None + + +class CelestialBodyUpdate(BaseModel): + name: Optional[str] = None + name_zh: Optional[str] = None + type: Optional[str] = None + description: Optional[str] = None + is_active: Optional[bool] = None + extra_data: Optional[Dict[str, Any]] = None + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_celestial_body( + body_data: CelestialBodyCreate, + db: AsyncSession = Depends(get_db) +): + """Create a new celestial body""" + # Check if exists + existing = await celestial_body_service.get_body_by_id(body_data.id, db) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Body with ID {body_data.id} already exists" + ) + + new_body = await celestial_body_service.create_body(body_data.dict(), db) + return new_body + + +@router.get("/search") +async def search_celestial_body( + name: str = Query(..., description="Body name or ID to search in NASA Horizons") +): + """ + Search for a celestial body in NASA Horizons database by name or ID + + Returns body information if found, including suggested ID and full name + """ + logger.info(f"Searching for celestial body: {name}") + + try: + result = horizons_service.search_body_by_name(name) + + if result["success"]: + logger.info(f"Found body: {result['full_name']}") + return { + "success": True, + "data": { + "id": result["id"], + "name": result["name"], + "full_name": result["full_name"], + } + } + else: + logger.warning(f"Search failed: {result['error']}") + return { + "success": False, + "error": result["error"] + } + except Exception as e: + logger.error(f"Search error: {e}") + raise HTTPException( + status_code=500, + detail=f"Search failed: {str(e)}" + ) + + +@router.get("/{body_id}/nasa-data") +async def get_celestial_nasa_data( + body_id: str, + db: AsyncSession = Depends(get_db) +): + """ + Get raw text data from NASA Horizons for a celestial body + (Hacker terminal style output) + """ + # Check if body exists + body = await celestial_body_service.get_body_by_id(body_id, db) + if not body: + raise HTTPException(status_code=404, detail="Celestial body not found") + + try: + # Fetch raw text from Horizons using the body_id + # Note: body.id corresponds to JPL Horizons ID + raw_text = await horizons_service.get_object_data_raw(body.id) + return {"id": body.id, "name": body.name, "raw_data": raw_text} + except Exception as e: + logger.error(f"Failed to fetch raw data for {body_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to fetch NASA data: {str(e)}") + + +@router.put("/{body_id}") +async def update_celestial_body( + body_id: str, + body_data: CelestialBodyUpdate, + db: AsyncSession = Depends(get_db) +): + """Update a celestial body""" + # Filter out None values + update_data = {k: v for k, v in body_data.dict().items() if v is not None} + + updated = await celestial_body_service.update_body(body_id, update_data, db) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Body {body_id} not found" + ) + return updated + + +@router.delete("/{body_id}") +async def delete_celestial_body( + body_id: str, + db: AsyncSession = Depends(get_db) +): + """Delete a celestial body""" + deleted = await celestial_body_service.delete_body(body_id, db) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Body {body_id} not found" + ) + return {"message": "Body deleted successfully"} + + +@router.get("/info/{body_id}", response_model=BodyInfo) +async def get_body_info(body_id: str, db: AsyncSession = Depends(get_db)): + """ + Get detailed information about a specific celestial body + + Args: + body_id: JPL Horizons ID (e.g., '-31' for Voyager 1, '399' for Earth) + """ + body = await celestial_body_service.get_body_by_id(body_id, db) + if not body: + raise HTTPException(status_code=404, detail=f"Body {body_id} not found") + + # Extract extra_data fields + extra_data = body.extra_data or {} + + return BodyInfo( + id=body.id, + name=body.name, + type=body.type, + description=body.description, + launch_date=extra_data.get("launch_date"), + status=extra_data.get("status"), + ) + + +@router.get("/list") +async def list_bodies( + body_type: Optional[str] = Query(None, description="Filter by body type"), + db: AsyncSession = Depends(get_db) +): + """ + Get a list of all available celestial bodies + """ + bodies = await celestial_body_service.get_all_bodies(db, body_type) + + bodies_list = [] + for body in bodies: + # Get resources for this body + resources = await resource_service.get_resources_by_body(body.id, None, db) + + # Group resources by type + resources_by_type = {} + for resource in resources: + if resource.resource_type not in resources_by_type: + resources_by_type[resource.resource_type] = [] + resources_by_type[resource.resource_type].append({ + "id": resource.id, + "file_path": resource.file_path, + "file_size": resource.file_size, + "mime_type": resource.mime_type, + }) + + bodies_list.append( + { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "is_active": body.is_active, + "resources": resources_by_type, + "has_resources": len(resources) > 0, + } + ) + return {"bodies": bodies_list} diff --git a/app/api/celestial_orbit.py b/app/api/celestial_orbit.py new file mode 100644 index 0000000..4db23e8 --- /dev/null +++ b/app/api/celestial_orbit.py @@ -0,0 +1,214 @@ +""" +Orbit Management API routes +Handles precomputed orbital data for celestial bodies +""" +import logging +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional + +from app.database import get_db +from app.services.horizons import horizons_service +from app.services.db_service import celestial_body_service +from app.services.orbit_service import orbit_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/celestial", tags=["celestial-orbit"]) + + +@router.get("/orbits") +async def get_orbits( + body_type: Optional[str] = Query(None, description="Filter by body type (planet, dwarf_planet)"), + db: AsyncSession = Depends(get_db) +): + """ + Get all precomputed orbital data + + Query parameters: + - body_type: Optional filter by celestial body type (planet, dwarf_planet) + + Returns: + - List of orbits with points, colors, and metadata + """ + logger.info(f"Fetching orbits (type filter: {body_type})") + + try: + orbits = await orbit_service.get_all_orbits(db, body_type=body_type) + + result = [] + for orbit in orbits: + # Get body info + body = await celestial_body_service.get_body_by_id(orbit.body_id, db) + + result.append({ + "body_id": orbit.body_id, + "body_name": body.name if body else "Unknown", + "body_name_zh": body.name_zh if body else None, + "points": orbit.points, + "num_points": orbit.num_points, + "period_days": orbit.period_days, + "color": orbit.color, + "updated_at": orbit.updated_at.isoformat() if orbit.updated_at else None + }) + + logger.info(f"✅ Returning {len(result)} orbits") + return {"orbits": result} + + except Exception as e: + logger.error(f"Failed to fetch orbits: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/admin/orbits/generate") +async def generate_orbits( + body_ids: Optional[str] = Query(None, description="Comma-separated body IDs to generate. If empty, generates for all planets and dwarf planets"), + db: AsyncSession = Depends(get_db) +): + """ + Generate orbital data for celestial bodies + + This endpoint queries NASA Horizons API to get complete orbital paths + and stores them in the orbits table for fast frontend rendering. + + Query parameters: + - body_ids: Optional comma-separated list of body IDs (e.g., "399,999") + If not provided, generates orbits for all planets and dwarf planets + + Returns: + - List of generated orbits with success/failure status + """ + logger.info("🌌 Starting orbit generation...") + + # Orbital periods in days (from astronomical data) + # Note: NASA Horizons data is limited to ~2199 for most bodies + # We use single complete orbits that fit within this range + ORBITAL_PERIODS = { + # Planets - single complete orbit + "199": 88.0, # Mercury + "299": 224.7, # Venus + "399": 365.25, # Earth + "499": 687.0, # Mars + "599": 4333.0, # Jupiter (11.86 years) + "699": 10759.0, # Saturn (29.46 years) + "799": 30687.0, # Uranus (84.01 years) + "899": 60190.0, # Neptune (164.79 years) + # Dwarf Planets - single complete orbit + "999": 90560.0, # Pluto (247.94 years - full orbit) + "2000001": 1680.0, # Ceres (4.6 years) + "136199": 203500.0, # Eris (557 years - full orbit) + "136108": 104000.0, # Haumea (285 years - full orbit) + "136472": 112897.0, # Makemake (309 years - full orbit) + } + + # Default colors for orbits + DEFAULT_COLORS = { + "199": "#8C7853", # Mercury - brownish + "299": "#FFC649", # Venus - yellowish + "399": "#4A90E2", # Earth - blue + "499": "#CD5C5C", # Mars - red + "599": "#DAA520", # Jupiter - golden + "699": "#F4A460", # Saturn - sandy brown + "799": "#4FD1C5", # Uranus - cyan + "899": "#4169E1", # Neptune - royal blue + "999": "#8B7355", # Pluto - brown + "2000001": "#9E9E9E", # Ceres - gray + "136199": "#E0E0E0", # Eris - light gray + "136108": "#D4A574", # Haumea - tan + "136472": "#C49A6C", # Makemake - beige + } + + try: + # Determine which bodies to generate orbits for + if body_ids: + # Parse comma-separated list + target_body_ids = [bid.strip() for bid in body_ids.split(",")] + bodies_to_process = [] + + for bid in target_body_ids: + body = await celestial_body_service.get_body_by_id(bid, db) + if body: + bodies_to_process.append(body) + else: + logger.warning(f"Body {bid} not found in database") + else: + # Get all planets and dwarf planets + all_bodies = await celestial_body_service.get_all_bodies(db) + bodies_to_process = [ + b for b in all_bodies + if b.type in ["planet", "dwarf_planet"] and b.id in ORBITAL_PERIODS + ] + + if not bodies_to_process: + raise HTTPException(status_code=400, detail="No valid bodies to process") + + logger.info(f"📋 Generating orbits for {len(bodies_to_process)} bodies") + + results = [] + success_count = 0 + failure_count = 0 + + for body in bodies_to_process: + try: + period = ORBITAL_PERIODS.get(body.id) + if not period: + logger.warning(f"No orbital period defined for {body.name}, skipping") + continue + + color = DEFAULT_COLORS.get(body.id, "#CCCCCC") + + # Generate orbit + orbit = await orbit_service.generate_orbit( + body_id=body.id, + body_name=body.name_zh or body.name, + period_days=period, + color=color, + session=db, + horizons_service=horizons_service + ) + + results.append({ + "body_id": body.id, + "body_name": body.name_zh or body.name, + "status": "success", + "num_points": orbit.num_points, + "period_days": orbit.period_days + }) + success_count += 1 + + except Exception as e: + logger.error(f"Failed to generate orbit for {body.name}: {e}") + results.append({ + "body_id": body.id, + "body_name": body.name_zh or body.name, + "status": "failed", + "error": str(e) + }) + failure_count += 1 + + logger.info(f"🎉 Orbit generation complete: {success_count} succeeded, {failure_count} failed") + + return { + "message": f"Generated {success_count} orbits ({failure_count} failed)", + "results": results + } + + except Exception as e: + logger.error(f"Orbit generation failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/admin/orbits/{body_id}") +async def delete_orbit( + body_id: str, + db: AsyncSession = Depends(get_db) +): + """Delete orbit data for a specific body""" + logger.info(f"Deleting orbit for body {body_id}") + + deleted = await orbit_service.delete_orbit(body_id, db) + + if deleted: + return {"message": f"Orbit for {body_id} deleted successfully"} + else: + raise HTTPException(status_code=404, detail="Orbit not found") diff --git a/app/api/celestial_position.py b/app/api/celestial_position.py new file mode 100644 index 0000000..70222d2 --- /dev/null +++ b/app/api/celestial_position.py @@ -0,0 +1,431 @@ +""" +Celestial Position Query API routes +Handles the core position data query with multi-layer caching strategy +""" +import logging +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import Optional + +from app.database import get_db +from app.models.celestial import CelestialDataResponse +from app.services.horizons import horizons_service +from app.services.cache import cache_service +from app.services.redis_cache import redis_cache, make_cache_key, get_ttl_seconds +from app.services.db_service import ( + celestial_body_service, + position_service, + nasa_cache_service, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/celestial", tags=["celestial-position"]) + + +@router.get("/positions", response_model=CelestialDataResponse) +async def get_celestial_positions( + start_time: Optional[str] = Query( + None, + description="Start time in ISO 8601 format (e.g., 2025-01-01T00:00:00Z)", + ), + end_time: Optional[str] = Query( + None, + description="End time in ISO 8601 format", + ), + step: str = Query( + "1d", + description="Time step (e.g., '1d' for 1 day, '12h' for 12 hours)", + ), + body_ids: Optional[str] = Query( + None, + description="Comma-separated list of body IDs to fetch (e.g., '999,2000001')", + ), + db: AsyncSession = Depends(get_db), +): + """ + Get positions of all celestial bodies for a time range + + Multi-layer caching strategy: + 1. Redis cache (persistent across restarts) + 2. Memory cache (fastest) + 3. Database cache (NASA API responses) + 4. Positions table (prefetched historical data) + 5. NASA Horizons API (fallback) + + If only start_time is provided, returns a single snapshot. + If both start_time and end_time are provided, returns positions at intervals defined by step. + Use body_ids to filter specific bodies (e.g., body_ids=999,2000001 for Pluto and Ceres). + """ + try: + # Parse time strings + start_dt = None if start_time is None else datetime.fromisoformat(start_time.replace("Z", "+00:00")) + end_dt = None if end_time is None else datetime.fromisoformat(end_time.replace("Z", "+00:00")) + + # Parse body_ids filter + body_id_list = None + if body_ids: + body_id_list = [bid.strip() for bid in body_ids.split(',')] + logger.info(f"Filtering for bodies: {body_id_list}") + + # OPTIMIZATION: If no time specified, return most recent positions from database + if start_dt is None and end_dt is None: + logger.info("No time specified - fetching most recent positions from database") + + # Check Redis cache first (persistent across restarts) + start_str = "now" + end_str = "now" + redis_key = make_cache_key("positions", start_str, end_str, step) + redis_cached = await redis_cache.get(redis_key) + if redis_cached is not None: + logger.info("Cache hit (Redis) for recent positions") + return CelestialDataResponse(bodies=redis_cached) + + # Check memory cache (faster but not persistent) + cached_data = cache_service.get(start_dt, end_dt, step) + if cached_data is not None: + logger.info("Cache hit (Memory) for recent positions") + return CelestialDataResponse(bodies=cached_data) + + # Get all bodies from database + all_bodies = await celestial_body_service.get_all_bodies(db) + + # Filter bodies if body_ids specified + if body_id_list: + all_bodies = [b for b in all_bodies if b.id in body_id_list] + + # For each body, get the most recent position + bodies_data = [] + now = datetime.utcnow() + recent_window = now - timedelta(hours=24) # Look for positions in last 24 hours + + for body in all_bodies: + try: + # Get most recent position for this body + recent_positions = await position_service.get_positions( + body_id=body.id, + start_time=recent_window, + end_time=now, + session=db + ) + + if recent_positions and len(recent_positions) > 0: + # Use the most recent position + latest_pos = recent_positions[-1] + body_dict = { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "is_active": body.is_active, # Include probe active status + "positions": [{ + "time": latest_pos.time.isoformat(), + "x": latest_pos.x, + "y": latest_pos.y, + "z": latest_pos.z, + }] + } + bodies_data.append(body_dict) + else: + # For inactive probes without recent positions, try to get last known position + if body.type == 'probe' and body.is_active is False: + # Get the most recent position ever recorded + all_positions = await position_service.get_positions( + body_id=body.id, + start_time=None, + end_time=None, + session=db + ) + + if all_positions and len(all_positions) > 0: + # Use the last known position + last_pos = all_positions[-1] + body_dict = { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "is_active": False, + "positions": [{ + "time": last_pos.time.isoformat(), + "x": last_pos.x, + "y": last_pos.y, + "z": last_pos.z, + }] + } + bodies_data.append(body_dict) + else: + # No position data at all, still include with empty positions + body_dict = { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "is_active": False, + "positions": [] + } + bodies_data.append(body_dict) + logger.info(f"Including inactive probe {body.name} with no position data") + except Exception as e: + logger.warning(f"Error processing {body.name}: {e}") + # For inactive probes, still try to include them + if body.type == 'probe' and body.is_active is False: + body_dict = { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "is_active": False, + "positions": [] + } + bodies_data.append(body_dict) + continue + + # If we have recent data for all bodies, return it + if len(bodies_data) == len(all_bodies): + logger.info(f"✅ Returning recent positions from database ({len(bodies_data)} bodies) - FAST!") + # Cache in memory + cache_service.set(bodies_data, start_dt, end_dt, step) + # Cache in Redis for persistence across restarts + start_str = start_dt.isoformat() if start_dt else "now" + end_str = end_dt.isoformat() if end_dt else "now" + redis_key = make_cache_key("positions", start_str, end_str, step) + await redis_cache.set(redis_key, bodies_data, get_ttl_seconds("current_positions")) + return CelestialDataResponse(bodies=bodies_data) + else: + logger.info(f"Incomplete recent data ({len(bodies_data)}/{len(all_bodies)} bodies), falling back to Horizons") + # Fall through to query Horizons below + + # Check Redis cache first (persistent across restarts) + start_str = start_dt.isoformat() if start_dt else "now" + end_str = end_dt.isoformat() if end_dt else "now" + redis_key = make_cache_key("positions", start_str, end_str, step) + redis_cached = await redis_cache.get(redis_key) + if redis_cached is not None: + logger.info("Cache hit (Redis) for positions") + return CelestialDataResponse(bodies=redis_cached) + + # Check memory cache (faster but not persistent) + cached_data = cache_service.get(start_dt, end_dt, step) + if cached_data is not None: + logger.info("Cache hit (Memory) for positions") + return CelestialDataResponse(bodies=cached_data) + + # Check database cache (NASA API responses) + # For each body, check if we have cached NASA response + all_bodies = await celestial_body_service.get_all_bodies(db) + + # Filter bodies if body_ids specified + if body_id_list: + all_bodies = [b for b in all_bodies if b.id in body_id_list] + + use_db_cache = True + db_cached_bodies = [] + + for body in all_bodies: + cached_response = await nasa_cache_service.get_cached_response( + body.id, start_dt, end_dt, step, db + ) + if cached_response: + db_cached_bodies.append({ + "id": body.id, + "name": body.name, + "type": body.type, + "positions": cached_response.get("positions", []) + }) + else: + use_db_cache = False + break + + if use_db_cache and db_cached_bodies: + logger.info("Cache hit (Database) for positions") + # Cache in memory + cache_service.set(db_cached_bodies, start_dt, end_dt, step) + # Cache in Redis for faster access next time + await redis_cache.set(redis_key, db_cached_bodies, get_ttl_seconds("historical_positions")) + return CelestialDataResponse(bodies=db_cached_bodies) + + # Check positions table for historical data (prefetched data) + # This is faster than querying NASA Horizons for historical queries + if start_dt and end_dt: + logger.info(f"Checking positions table for historical data: {start_dt} to {end_dt}") + all_bodies_positions = [] + has_complete_data = True + + # Remove timezone info for database query (TIMESTAMP WITHOUT TIME ZONE) + start_dt_naive = start_dt.replace(tzinfo=None) + end_dt_naive = end_dt.replace(tzinfo=None) + + for body in all_bodies: + # Query positions table for this body in the time range + positions = await position_service.get_positions( + body_id=body.id, + start_time=start_dt_naive, + end_time=end_dt_naive, + session=db + ) + + if positions and len(positions) > 0: + # Convert database positions to API format + all_bodies_positions.append({ + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "is_active": body.is_active, + "positions": [ + { + "time": pos.time.isoformat(), + "x": pos.x, + "y": pos.y, + "z": pos.z, + } + for pos in positions + ] + }) + else: + # For inactive probes, missing data is expected and acceptable + if body.type == 'probe' and body.is_active is False: + logger.debug(f"Skipping inactive probe {body.name} with no data for {start_dt_naive}") + continue + + # Missing data for active body - need to query Horizons + has_complete_data = False + break + + if has_complete_data and all_bodies_positions: + logger.info(f"Using prefetched historical data from positions table ({len(all_bodies_positions)} bodies)") + # Cache in memory + cache_service.set(all_bodies_positions, start_dt, end_dt, step) + # Cache in Redis for faster access next time + await redis_cache.set(redis_key, all_bodies_positions, get_ttl_seconds("historical_positions")) + return CelestialDataResponse(bodies=all_bodies_positions) + else: + logger.info("Incomplete historical data in positions table, falling back to Horizons") + + # Query Horizons (no cache available) - fetch from database + Horizons API + logger.info(f"Fetching celestial data from Horizons: start={start_dt}, end={end_dt}, step={step}") + + # Get all bodies from database + all_bodies = await celestial_body_service.get_all_bodies(db) + + # Filter bodies if body_ids specified + if body_id_list: + all_bodies = [b for b in all_bodies if b.id in body_id_list] + + bodies_data = [] + for body in all_bodies: + try: + # Special handling for Sun (always at origin) + if body.id == "10": + sun_start = start_dt if start_dt else datetime.utcnow() + sun_end = end_dt if end_dt else sun_start + + positions_list = [{"time": sun_start.isoformat(), "x": 0.0, "y": 0.0, "z": 0.0}] + if sun_start != sun_end: + positions_list.append({"time": sun_end.isoformat(), "x": 0.0, "y": 0.0, "z": 0.0}) + + # Special handling for Cassini (mission ended 2017-09-15) + elif body.id == "-82": + cassini_date = datetime(2017, 9, 15, 11, 58, 0) + pos_data = horizons_service.get_body_positions(body.id, cassini_date, cassini_date, step) + positions_list = [ + {"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z} + for p in pos_data + ] + + else: + # Query NASA Horizons for other bodies + pos_data = horizons_service.get_body_positions(body.id, start_dt, end_dt, step) + positions_list = [ + {"time": p.time.isoformat(), "x": p.x, "y": p.y, "z": p.z} + for p in pos_data + ] + + body_dict = { + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "description": body.description, + "positions": positions_list + } + bodies_data.append(body_dict) + + except Exception as e: + logger.error(f"Failed to get data for {body.name}: {str(e)}") + # Continue with other bodies even if one fails + continue + + # Save to database cache and position records + for body_dict in bodies_data: + body_id = body_dict["id"] + positions = body_dict.get("positions", []) + + if positions: + # Save NASA API response to cache + await nasa_cache_service.save_response( + body_id=body_id, + start_time=start_dt, + end_time=end_dt, + step=step, + response_data={"positions": positions}, + ttl_days=7, + session=db + ) + + # Save position data to positions table + position_records = [] + for pos in positions: + # Parse time and remove timezone for database storage + pos_time = pos["time"] + if isinstance(pos_time, str): + pos_time = datetime.fromisoformat(pos["time"].replace("Z", "+00:00")) + # Remove timezone info for TIMESTAMP WITHOUT TIME ZONE + pos_time_naive = pos_time.replace(tzinfo=None) if hasattr(pos_time, 'replace') else pos_time + + position_records.append({ + "time": pos_time_naive, + "x": pos["x"], + "y": pos["y"], + "z": pos["z"], + "vx": pos.get("vx"), + "vy": pos.get("vy"), + "vz": pos.get("vz"), + }) + + if position_records: + await position_service.save_positions( + body_id=body_id, + positions=position_records, + source="nasa_horizons", + session=db + ) + logger.info(f"Saved {len(position_records)} positions for {body_id}") + + # Cache in memory + cache_service.set(bodies_data, start_dt, end_dt, step) + # Cache in Redis for persistence across restarts + start_str = start_dt.isoformat() if start_dt else "now" + end_str = end_dt.isoformat() if end_dt else "now" + redis_key = make_cache_key("positions", start_str, end_str, step) + # Use longer TTL for historical data that was fetched from Horizons + ttl = get_ttl_seconds("historical_positions") if start_dt and end_dt else get_ttl_seconds("current_positions") + await redis_cache.set(redis_key, bodies_data, ttl) + logger.info(f"Cached data in Redis with key: {redis_key} (TTL: {ttl}s)") + + return CelestialDataResponse(bodies=bodies_data) + + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid time format: {str(e)}") + except Exception as e: + logger.error(f"Error fetching celestial positions: {str(e)}") + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Failed to fetch data: {str(e)}") diff --git a/app/api/celestial_resource.py b/app/api/celestial_resource.py new file mode 100644 index 0000000..9e06012 --- /dev/null +++ b/app/api/celestial_resource.py @@ -0,0 +1,232 @@ +""" +Resource Management API routes +Handles file uploads and management for celestial body resources (textures, models, icons, etc.) +""" +import os +import logging +import aiofiles +from pathlib import Path +from datetime import datetime +from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from pydantic import BaseModel +from typing import Optional, Dict, Any + +from app.database import get_db +from app.models.db import Resource +from app.services.db_service import celestial_body_service, resource_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/celestial/resources", tags=["celestial-resource"]) + + +# Pydantic models +class ResourceUpdate(BaseModel): + extra_data: Optional[Dict[str, Any]] = None + + +@router.post("/upload") +async def upload_resource( + body_id: str = Query(..., description="Celestial body ID"), + resource_type: str = Query(..., description="Type: texture, model, icon, thumbnail, data"), + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db) +): + """ + Upload a resource file (texture, model, icon, etc.) + + Upload directory logic: + - Probes (type='probe'): upload to 'model' directory + - Others (planet, satellite, etc.): upload to 'texture' directory + """ + # Validate resource type + valid_types = ["texture", "model", "icon", "thumbnail", "data"] + if resource_type not in valid_types: + raise HTTPException( + status_code=400, + detail=f"Invalid resource_type. Must be one of: {valid_types}" + ) + + # Get celestial body to determine upload directory + body = await celestial_body_service.get_body_by_id(body_id, db) + if not body: + raise HTTPException(status_code=404, detail=f"Celestial body {body_id} not found") + + # Determine upload directory based on body type + # Probes -> model directory, Others -> texture directory + if body.type == 'probe' and resource_type in ['model', 'texture']: + upload_subdir = 'model' + elif resource_type in ['model', 'texture']: + upload_subdir = 'texture' + else: + # For icon, thumbnail, data, use resource_type as directory + upload_subdir = resource_type + + # Create upload directory structure + upload_dir = Path("upload") / upload_subdir + upload_dir.mkdir(parents=True, exist_ok=True) + + # Use original filename + original_filename = file.filename + file_path = upload_dir / original_filename + + # If file already exists, append timestamp to make it unique + if file_path.exists(): + name_without_ext = os.path.splitext(original_filename)[0] + file_ext = os.path.splitext(original_filename)[1] + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + original_filename = f"{name_without_ext}_{timestamp}{file_ext}" + file_path = upload_dir / original_filename + + # Save file + try: + async with aiofiles.open(file_path, 'wb') as f: + content = await file.read() + await f.write(content) + + # Get file size + file_size = os.path.getsize(file_path) + + # Store relative path (from upload directory) + relative_path = f"{upload_subdir}/{original_filename}" + + # Determine MIME type + mime_type = file.content_type + + # Create resource record + resource = await resource_service.create_resource( + { + "body_id": body_id, + "resource_type": resource_type, + "file_path": relative_path, + "file_size": file_size, + "mime_type": mime_type, + }, + db + ) + + # Commit the transaction + await db.commit() + await db.refresh(resource) + + logger.info(f"Uploaded resource for {body.name} ({body.type}): {relative_path} ({file_size} bytes)") + + return { + "id": resource.id, + "resource_type": resource.resource_type, + "file_path": resource.file_path, + "file_size": resource.file_size, + "upload_directory": upload_subdir, + "message": f"File uploaded successfully to {upload_subdir} directory" + } + + except Exception as e: + # Rollback transaction + await db.rollback() + # Clean up file if database operation fails + if file_path.exists(): + os.remove(file_path) + logger.error(f"Error uploading file: {e}") + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + + +@router.get("/{body_id}") +async def get_body_resources( + body_id: str, + resource_type: Optional[str] = Query(None, description="Filter by resource type"), + db: AsyncSession = Depends(get_db) +): + """ + Get all resources associated with a celestial body + """ + resources = await resource_service.get_resources_by_body(body_id, resource_type, db) + + result = [] + for resource in resources: + result.append({ + "id": resource.id, + "resource_type": resource.resource_type, + "file_path": resource.file_path, + "file_size": resource.file_size, + "mime_type": resource.mime_type, + "created_at": resource.created_at.isoformat(), + "extra_data": resource.extra_data, + }) + + return {"body_id": body_id, "resources": result} + + +@router.delete("/{resource_id}") +async def delete_resource( + resource_id: int, + db: AsyncSession = Depends(get_db) +): + """ + Delete a resource file and its database record + """ + # Get resource record + result = await db.execute( + select(Resource).where(Resource.id == resource_id) + ) + resource = result.scalar_one_or_none() + + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + + # Delete file if it exists + file_path = resource.file_path + if os.path.exists(file_path): + try: + os.remove(file_path) + logger.info(f"Deleted file: {file_path}") + except Exception as e: + logger.warning(f"Failed to delete file {file_path}: {e}") + + # Delete database record + deleted = await resource_service.delete_resource(resource_id, db) + + if deleted: + return {"message": "Resource deleted successfully"} + else: + raise HTTPException(status_code=500, detail="Failed to delete resource") + + +@router.put("/{resource_id}") +async def update_resource( + resource_id: int, + update_data: ResourceUpdate, + db: AsyncSession = Depends(get_db) +): + """ + Update resource metadata (e.g., scale parameter for models) + """ + # Get resource record + result = await db.execute( + select(Resource).where(Resource.id == resource_id) + ) + resource = result.scalar_one_or_none() + + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + + # Update extra_data + await db.execute( + update(Resource) + .where(Resource.id == resource_id) + .values(extra_data=update_data.extra_data) + ) + await db.commit() + + # Get updated resource + result = await db.execute( + select(Resource).where(Resource.id == resource_id) + ) + updated_resource = result.scalar_one_or_none() + + return { + "id": updated_resource.id, + "extra_data": updated_resource.extra_data, + "message": "Resource updated successfully" + } diff --git a/app/api/celestial_static.py b/app/api/celestial_static.py new file mode 100644 index 0000000..fb30eca --- /dev/null +++ b/app/api/celestial_static.py @@ -0,0 +1,124 @@ +""" +Static Data Management API routes +Handles static celestial data like stars, constellations, galaxies +""" +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel +from typing import Optional, Dict, Any + +from app.database import get_db +from app.services.db_service import static_data_service + +router = APIRouter(prefix="/celestial/static", tags=["celestial-static"]) + + +# Pydantic models +class StaticDataCreate(BaseModel): + category: str + name: str + name_zh: Optional[str] = None + data: Dict[str, Any] + + +class StaticDataUpdate(BaseModel): + category: Optional[str] = None + name: Optional[str] = None + name_zh: Optional[str] = None + data: Optional[Dict[str, Any]] = None + + +@router.get("/list") +async def list_static_data(db: AsyncSession = Depends(get_db)): + """Get all static data items""" + items = await static_data_service.get_all_items(db) + result = [] + for item in items: + result.append({ + "id": item.id, + "category": item.category, + "name": item.name, + "name_zh": item.name_zh, + "data": item.data + }) + return {"items": result} + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def create_static_data( + item_data: StaticDataCreate, + db: AsyncSession = Depends(get_db) +): + """Create new static data""" + new_item = await static_data_service.create_static(item_data.dict(), db) + return new_item + + +@router.put("/{item_id}") +async def update_static_data( + item_id: int, + item_data: StaticDataUpdate, + db: AsyncSession = Depends(get_db) +): + """Update static data""" + update_data = {k: v for k, v in item_data.dict().items() if v is not None} + updated = await static_data_service.update_static(item_id, update_data, db) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Static data {item_id} not found" + ) + return updated + + +@router.delete("/{item_id}") +async def delete_static_data( + item_id: int, + db: AsyncSession = Depends(get_db) +): + """Delete static data""" + deleted = await static_data_service.delete_static(item_id, db) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Static data {item_id} not found" + ) + return {"message": "Deleted successfully"} + + +@router.get("/categories") +async def get_static_categories(db: AsyncSession = Depends(get_db)): + """ + Get all available static data categories + """ + categories = await static_data_service.get_all_categories(db) + return {"categories": categories} + + +@router.get("/{category}") +async def get_static_data( + category: str, + db: AsyncSession = Depends(get_db) +): + """ + Get all static data items for a specific category + (e.g., 'star', 'constellation', 'galaxy') + """ + items = await static_data_service.get_by_category(category, db) + + if not items: + raise HTTPException( + status_code=404, + detail=f"No data found for category '{category}'" + ) + + result = [] + for item in items: + result.append({ + "id": item.id, + "name": item.name, + "name_zh": item.name_zh, + "data": item.data + }) + + return {"category": category, "items": result} diff --git a/app/api/nasa_download.py b/app/api/nasa_download.py new file mode 100644 index 0000000..9bd5635 --- /dev/null +++ b/app/api/nasa_download.py @@ -0,0 +1,284 @@ +""" +NASA Data Download API routes +Handles batch downloading of position data from NASA Horizons +""" +import logging +from datetime import datetime +from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from app.database import get_db +from app.services.horizons import horizons_service +from app.services.db_service import celestial_body_service, position_service +from app.services.task_service import task_service +from app.services.nasa_worker import download_positions_task + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/celestial/positions", tags=["nasa-download"]) + + +# Pydantic models +class DownloadPositionRequest(BaseModel): + body_ids: list[str] + dates: list[str] # List of dates in YYYY-MM-DD format + + +@router.get("/download/bodies") +async def get_downloadable_bodies( + db: AsyncSession = Depends(get_db) +): + """ + Get list of celestial bodies available for NASA data download, grouped by type + + Returns: + - Dictionary with body types as keys and lists of bodies as values + """ + logger.info("Fetching downloadable bodies for NASA data download") + + try: + # Get all active celestial bodies + all_bodies = await celestial_body_service.get_all_bodies(db) + + # Group bodies by type + grouped_bodies = {} + for body in all_bodies: + if body.type not in grouped_bodies: + grouped_bodies[body.type] = [] + + grouped_bodies[body.type].append({ + "id": body.id, + "name": body.name, + "name_zh": body.name_zh, + "type": body.type, + "is_active": body.is_active, + "description": body.description + }) + + # Sort each group by name + for body_type in grouped_bodies: + grouped_bodies[body_type].sort(key=lambda x: x["name"]) + + logger.info(f"✅ Returning {len(all_bodies)} bodies in {len(grouped_bodies)} groups") + return {"bodies": grouped_bodies} + + except Exception as e: + logger.error(f"Failed to fetch downloadable bodies: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/download/status") +async def get_download_status( + body_id: str = Query(..., description="Celestial body ID"), + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + db: AsyncSession = Depends(get_db) +): + """ + Get data availability status for a specific body within a date range + + Returns: + - List of dates that have position data + """ + logger.info(f"Checking download status for {body_id} from {start_date} to {end_date}") + + try: + # Parse dates + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59) + + # Get available dates + available_dates = await position_service.get_available_dates( + body_id=body_id, + start_time=start_dt, + end_time=end_dt, + session=db + ) + + # Convert dates to ISO format strings + available_date_strings = [ + date.isoformat() if hasattr(date, 'isoformat') else str(date) + for date in available_dates + ] + + logger.info(f"✅ Found {len(available_date_strings)} dates with data") + return { + "body_id": body_id, + "start_date": start_date, + "end_date": end_date, + "available_dates": available_date_strings + } + + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid date format: {str(e)}") + except Exception as e: + logger.error(f"Failed to check download status: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/download-async") +async def download_positions_async( + request: DownloadPositionRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) +): + """ + Start asynchronous background task to download position data + """ + # Create task record + task = await task_service.create_task( + db, + task_type="nasa_download", + description=f"Download positions for {len(request.body_ids)} bodies on {len(request.dates)} dates", + params=request.dict(), + created_by=None + ) + + # Add to background tasks + background_tasks.add_task( + download_positions_task, + task.id, + request.body_ids, + request.dates + ) + + return { + "message": "Download task started", + "task_id": task.id + } + + +@router.post("/download") +async def download_positions( + request: DownloadPositionRequest, + db: AsyncSession = Depends(get_db) +): + """ + Download position data for specified bodies on specified dates (Synchronous) + + This endpoint will: + 1. Query NASA Horizons API for the position at 00:00:00 UTC on each date + 2. Save the data to the positions table + 3. Return the downloaded data + + Args: + - body_ids: List of celestial body IDs + - dates: List of dates (YYYY-MM-DD format) + + Returns: + - Summary of downloaded data with success/failure status + """ + logger.info(f"Downloading positions (sync) for {len(request.body_ids)} bodies on {len(request.dates)} dates") + + try: + results = [] + total_success = 0 + total_failed = 0 + + for body_id in request.body_ids: + # Check if body exists + body = await celestial_body_service.get_body_by_id(body_id, db) + if not body: + results.append({ + "body_id": body_id, + "status": "failed", + "error": "Body not found" + }) + total_failed += 1 + continue + + body_results = { + "body_id": body_id, + "body_name": body.name_zh or body.name, + "dates": [] + } + + for date_str in request.dates: + try: + # Parse date and set to midnight UTC + target_date = datetime.strptime(date_str, "%Y-%m-%d") + + # Check if data already exists for this date + existing = await position_service.get_positions( + body_id=body_id, + start_time=target_date, + end_time=target_date.replace(hour=23, minute=59, second=59), + session=db + ) + + if existing and len(existing) > 0: + body_results["dates"].append({ + "date": date_str, + "status": "exists", + "message": "Data already exists" + }) + total_success += 1 + continue + + # Download from NASA Horizons + positions = horizons_service.get_body_positions( + body_id=body_id, + start_time=target_date, + end_time=target_date, + step="1d" + ) + + if positions and len(positions) > 0: + # Save to database + position_data = [{ + "time": target_date, + "x": positions[0].x, + "y": positions[0].y, + "z": positions[0].z, + "vx": getattr(positions[0], 'vx', None), + "vy": getattr(positions[0], 'vy', None), + "vz": getattr(positions[0], 'vz', None), + }] + + await position_service.save_positions( + body_id=body_id, + positions=position_data, + source="nasa_horizons", + session=db + ) + + body_results["dates"].append({ + "date": date_str, + "status": "success", + "position": { + "x": positions[0].x, + "y": positions[0].y, + "z": positions[0].z + } + }) + total_success += 1 + else: + body_results["dates"].append({ + "date": date_str, + "status": "failed", + "error": "No data returned from NASA" + }) + total_failed += 1 + + except Exception as e: + logger.error(f"Failed to download {body_id} on {date_str}: {e}") + body_results["dates"].append({ + "date": date_str, + "status": "failed", + "error": str(e) + }) + total_failed += 1 + + results.append(body_results) + + return { + "message": f"Downloaded {total_success} positions ({total_failed} failed)", + "total_success": total_success, + "total_failed": total_failed, + "results": results + } + + except Exception as e: + logger.error(f"Download failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/api/routes.py b/app/api/routes.py.bak similarity index 100% rename from app/api/routes.py rename to app/api/routes.py.bak diff --git a/app/api/task.py b/app/api/task.py new file mode 100644 index 0000000..f886fe0 --- /dev/null +++ b/app/api/task.py @@ -0,0 +1,73 @@ +""" +Task Management API routes +""" +from fastapi import APIRouter, HTTPException, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, desc + +from app.database import get_db +from app.models.db import Task +from app.services.task_service import task_service + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.get("") +async def list_tasks( + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db) +): + """ + List background tasks + + Args: + limit: Maximum number of tasks to return (1-100, default 20) + offset: Number of tasks to skip (default 0) + """ + result = await db.execute( + select(Task).order_by(desc(Task.created_at)).limit(limit).offset(offset) + ) + tasks = result.scalars().all() + return tasks + + +@router.get("/{task_id}") +async def get_task_status( + task_id: int, + db: AsyncSession = Depends(get_db) +): + """ + Get task status and progress + + Returns merged data from Redis (real-time progress) and database (persistent record) + """ + # Check Redis first for real-time progress + redis_data = await task_service.get_task_progress_from_redis(task_id) + + # Get DB record + task = await task_service.get_task(db, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + # Merge Redis data if available (Redis has fresher progress) + response = { + "id": task.id, + "task_type": task.task_type, + "status": task.status, + "progress": task.progress, + "description": task.description, + "created_at": task.created_at, + "started_at": task.started_at, + "completed_at": task.completed_at, + "error_message": task.error_message, + "result": task.result + } + + if redis_data: + response["status"] = redis_data.get("status", task.status) + response["progress"] = redis_data.get("progress", task.progress) + if "error" in redis_data: + response["error_message"] = redis_data["error"] + + return response diff --git a/app/api/user.py b/app/api/user.py index 5962738..ed625d7 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -2,13 +2,14 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlalchemy import select, func -from typing import List -from pydantic import BaseModel +from typing import List, Optional +from pydantic import BaseModel, EmailStr from app.database import get_db from app.models.db import User -from app.services.auth import hash_password -from app.services.auth_deps import get_current_user, require_admin # To protect endpoints +from app.services.auth import hash_password, verify_password +from app.services.auth_deps import get_current_user, require_admin +from app.services.system_settings_service import system_settings_service router = APIRouter(prefix="/users", tags=["users"]) @@ -24,11 +25,19 @@ class UserListItem(BaseModel): created_at: str class Config: - orm_mode = True + from_attributes = True class UserStatusUpdate(BaseModel): is_active: bool +class ProfileUpdateRequest(BaseModel): + full_name: Optional[str] = None + email: Optional[EmailStr] = None + +class PasswordChangeRequest(BaseModel): + old_password: str + new_password: str + @router.get("/list") async def get_user_list( db: AsyncSession = Depends(get_db), @@ -88,33 +97,137 @@ async def reset_user_password( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Reset a user's password to the default""" + """Reset a user's password to the system default""" if "admin" not in [role.name for role in current_user.roles]: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized") - + result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") - # Hardcoded default password for now. - # TODO: Move to a configurable system parameter. - default_password = "password123" + # Get default password from system settings + default_password = await system_settings_service.get_setting_value( + "default_password", + db, + default="cosmo" # Fallback if setting doesn't exist + ) + user.password_hash = hash_password(default_password) - + await db.commit() - return {"message": f"Password for user {user.username} has been reset."} + return { + "message": f"Password for user {user.username} has been reset to system default.", + "default_password": default_password + } @router.get("/count", response_model=dict) async def get_user_count( db: AsyncSession = Depends(get_db), - current_admin_user: User = Depends(require_admin) # Ensure only admin can access + current_user: User = Depends(get_current_user) # All authenticated users can access ): """ Get the total count of registered users. + Available to all authenticated users. """ result = await db.execute(select(func.count(User.id))) total_users = result.scalar_one() return {"total_users": total_users} + +@router.get("/me") +async def get_current_user_profile( + current_user: User = Depends(get_current_user) +): + """ + Get current user's profile information + """ + return { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "full_name": current_user.full_name, + "is_active": current_user.is_active, + "roles": [role.name for role in current_user.roles], + "created_at": current_user.created_at.isoformat(), + "last_login_at": current_user.last_login_at.isoformat() if current_user.last_login_at else None + } + + +@router.put("/me/profile") +async def update_current_user_profile( + profile_update: ProfileUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Update current user's profile information (nickname/full_name and email) + """ + # Check if email is being changed and if it's already taken + if profile_update.email and profile_update.email != current_user.email: + # Check if email is already in use by another user + result = await db.execute( + select(User).where(User.email == profile_update.email, User.id != current_user.id) + ) + existing_user = result.scalar_one_or_none() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already in use by another user" + ) + current_user.email = profile_update.email + + # Update full_name (nickname) + if profile_update.full_name is not None: + current_user.full_name = profile_update.full_name + + await db.commit() + await db.refresh(current_user) + + return { + "message": "Profile updated successfully", + "user": { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "full_name": current_user.full_name + } + } + + +@router.put("/me/password") +async def change_current_user_password( + password_change: PasswordChangeRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + Change current user's password + """ + # Verify old password + if not verify_password(password_change.old_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect" + ) + + # Validate new password + if len(password_change.new_password) < 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password must be at least 6 characters long" + ) + + if password_change.old_password == password_change.new_password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password must be different from the old password" + ) + + # Update password + current_user.password_hash = hash_password(password_change.new_password) + await db.commit() + + return {"message": "Password changed successfully"} + diff --git a/app/main.py b/app/main.py index cbe07d1..ac1ab9a 100644 --- a/app/main.py +++ b/app/main.py @@ -17,11 +17,18 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from app.config import settings -from app.api.routes import router as celestial_router from app.api.auth import router as auth_router from app.api.user import router as user_router from app.api.system import router as system_router from app.api.danmaku import router as danmaku_router +from app.api.task import router as task_router +from app.api.cache import router as cache_router +from app.api.celestial_static import router as celestial_static_router +from app.api.celestial_body import router as celestial_body_router +from app.api.celestial_resource import router as celestial_resource_router +from app.api.celestial_orbit import router as celestial_orbit_router +from app.api.nasa_download import router as nasa_download_router +from app.api.celestial_position import router as celestial_position_router from app.services.redis_cache import redis_cache from app.services.cache_preheat import preheat_all_caches from app.database import close_db @@ -101,12 +108,23 @@ app.add_middleware( ) # Include routers -app.include_router(celestial_router, prefix=settings.api_prefix) app.include_router(auth_router, prefix=settings.api_prefix) app.include_router(user_router, prefix=settings.api_prefix) app.include_router(system_router, prefix=settings.api_prefix) app.include_router(danmaku_router, prefix=settings.api_prefix) +# Celestial body related routers +app.include_router(celestial_body_router, prefix=settings.api_prefix) +app.include_router(celestial_position_router, prefix=settings.api_prefix) +app.include_router(celestial_resource_router, prefix=settings.api_prefix) +app.include_router(celestial_orbit_router, prefix=settings.api_prefix) +app.include_router(celestial_static_router, prefix=settings.api_prefix) + +# Admin and utility routers +app.include_router(cache_router, prefix=settings.api_prefix) +app.include_router(nasa_download_router, prefix=settings.api_prefix) +app.include_router(task_router, prefix=settings.api_prefix) + # Mount static files for uploaded resources upload_dir = Path(__file__).parent.parent / "upload" upload_dir.mkdir(exist_ok=True) diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 5390732..8c6dc65 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -143,6 +143,15 @@ class SystemSettingsService: async def initialize_default_settings(self, session: AsyncSession): """Initialize default system settings if they don't exist""" defaults = [ + { + "key": "default_password", + "value": "cosmo", + "value_type": "string", + "category": "security", + "label": "默认重置密码", + "description": "管理员重置用户密码时使用的默认密码", + "is_public": False + }, { "key": "timeline_interval_days", "value": "30",