""" AOI (Area of Interest) API Endpoints Handles polygon selection, validation, and Unity bundle generation """ from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks from fastapi.responses import JSONResponse, FileResponse from pydantic import BaseModel, Field from typing import Optional from datetime import datetime import uuid import structlog from config import settings from models.aoi import AOIRequest, AOIResponse, AOIStatus, AOIManifest from services.aoi_packager import AOIPackagerService from services.osm_extractor import OSMExtractorService logger = structlog.get_logger(__name__) router = APIRouter() # Initialize services aoi_packager = AOIPackagerService() osm_extractor = OSMExtractorService() @router.post("", response_model=AOIResponse) async def create_aoi( request: AOIRequest, background_tasks: BackgroundTasks, ): """ Create a new AOI (Area of Interest) for Unity 3D export. Validates the polygon, checks size limits (max 4 km²), and queues bundle generation. Returns immediately with a status URL. The bundle will contain: - Terrain heightmap - OSM features (buildings, roads, water, etc.) - Learning node positions - Attribution information """ # Validate polygon try: area_km2 = aoi_packager.calculate_area_km2(request.polygon) except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid polygon: {str(e)}") # Check size limit if area_km2 > settings.max_aoi_size_km2: raise HTTPException( status_code=400, detail=f"AOI too large: {area_km2:.2f} km² exceeds maximum of {settings.max_aoi_size_km2} km²", ) # Check if polygon is within Germany bounds if not aoi_packager.is_within_germany(request.polygon): raise HTTPException( status_code=400, detail="AOI must be within Germany. Bounds: [5.87°E, 47.27°N] to [15.04°E, 55.06°N]", ) # Generate AOI ID aoi_id = str(uuid.uuid4()) # Create AOI record aoi_data = { "id": aoi_id, "polygon": request.polygon, "theme": request.theme, "quality": request.quality, "area_km2": area_km2, "status": AOIStatus.QUEUED, "created_at": datetime.utcnow().isoformat(), } # Start background processing background_tasks.add_task( aoi_packager.process_aoi, aoi_id=aoi_id, polygon=request.polygon, theme=request.theme, quality=request.quality, ) logger.info( "AOI created", aoi_id=aoi_id, area_km2=area_km2, theme=request.theme, ) return AOIResponse( aoi_id=aoi_id, status=AOIStatus.QUEUED, area_km2=area_km2, estimated_size_mb=aoi_packager.estimate_bundle_size_mb(area_km2, request.quality), message="AOI queued for processing", ) @router.get("/{aoi_id}", response_model=AOIResponse) async def get_aoi_status( aoi_id: str = Path(..., description="AOI UUID"), ): """ Get the status of an AOI processing job. Returns current status (queued, processing, completed, failed) and download URLs when ready. """ aoi_data = await aoi_packager.get_aoi_status(aoi_id) if aoi_data is None: raise HTTPException(status_code=404, detail="AOI not found") response = AOIResponse( aoi_id=aoi_id, status=aoi_data["status"], area_km2=aoi_data.get("area_km2", 0), estimated_size_mb=aoi_data.get("estimated_size_mb", 0), message=aoi_data.get("message", ""), ) # Add download URLs if completed if aoi_data["status"] == AOIStatus.COMPLETED: response.download_url = f"/api/v1/aoi/{aoi_id}/bundle.zip" response.manifest_url = f"/api/v1/aoi/{aoi_id}/manifest.json" response.completed_at = aoi_data.get("completed_at") return response @router.get("/{aoi_id}/manifest.json") async def get_aoi_manifest( aoi_id: str = Path(..., description="AOI UUID"), ): """ Get the Unity bundle manifest for an AOI. The manifest contains: - Terrain configuration - Asset list and paths - Learning node positions - Attribution requirements """ manifest = await aoi_packager.get_manifest(aoi_id) if manifest is None: raise HTTPException(status_code=404, detail="Manifest not found or AOI not ready") return JSONResponse(content=manifest) @router.get("/{aoi_id}/bundle.zip") async def download_aoi_bundle( aoi_id: str = Path(..., description="AOI UUID"), ): """ Download the complete AOI bundle as a ZIP file. Contains all assets needed for Unity 3D rendering: - terrain.heightmap (16-bit PNG) - osm_features.json (buildings, roads, etc.) - learning_nodes.json (educational content positions) - attribution.json (required license notices) """ bundle_path = await aoi_packager.get_bundle_path(aoi_id) if bundle_path is None: raise HTTPException(status_code=404, detail="Bundle not found or AOI not ready") return FileResponse( path=bundle_path, filename=f"geo-lernwelt-{aoi_id[:8]}.zip", media_type="application/zip", ) @router.delete("/{aoi_id}") async def delete_aoi( aoi_id: str = Path(..., description="AOI UUID"), ): """ Delete an AOI and its bundle. Implements DSGVO data minimization - users can delete their data. """ success = await aoi_packager.delete_aoi(aoi_id) if not success: raise HTTPException(status_code=404, detail="AOI not found") logger.info("AOI deleted", aoi_id=aoi_id) return {"message": "AOI deleted successfully", "aoi_id": aoi_id} @router.get("/{aoi_id}/preview") async def get_aoi_preview( aoi_id: str = Path(..., description="AOI UUID"), width: int = Query(512, ge=64, le=2048, description="Preview width"), height: int = Query(512, ge=64, le=2048, description="Preview height"), ): """ Get a preview image of the AOI. Returns a rendered preview showing terrain, OSM features, and learning node positions. """ preview_data = await aoi_packager.generate_preview(aoi_id, width, height) if preview_data is None: raise HTTPException(status_code=404, detail="Preview not available") from fastapi.responses import Response return Response( content=preview_data, media_type="image/png", ) @router.post("/validate") async def validate_aoi_polygon( polygon: dict, ): """ Validate an AOI polygon without creating it. Checks: - Valid GeoJSON format - Within Germany bounds - Within size limits - Not self-intersecting """ try: # Validate geometry is_valid, message = aoi_packager.validate_polygon(polygon) if not is_valid: return { "valid": False, "error": message, } # Calculate area area_km2 = aoi_packager.calculate_area_km2(polygon) # Check bounds within_germany = aoi_packager.is_within_germany(polygon) # Check size within_size_limit = area_km2 <= settings.max_aoi_size_km2 return { "valid": is_valid and within_germany and within_size_limit, "area_km2": round(area_km2, 3), "within_germany": within_germany, "within_size_limit": within_size_limit, "max_size_km2": settings.max_aoi_size_km2, "estimated_bundle_size_mb": aoi_packager.estimate_bundle_size_mb(area_km2, "medium"), } except Exception as e: return { "valid": False, "error": str(e), } @router.get("/templates/mainau") async def get_mainau_template(): """ Get pre-configured AOI template for Mainau island (demo location). Mainau is a small island in Lake Constance (Bodensee) - perfect for educational geography lessons. """ return { "name": "Insel Mainau", "description": "Blumeninsel im Bodensee - ideal fuer Erdkunde-Unterricht", "polygon": { "type": "Polygon", "coordinates": [ [ [9.1875, 47.7055], [9.1975, 47.7055], [9.1975, 47.7115], [9.1875, 47.7115], [9.1875, 47.7055], ] ], }, "center": [9.1925, 47.7085], "area_km2": 0.45, "suggested_themes": ["topographie", "vegetation", "landnutzung"], "features": [ "Schloss und Schlosskirche", "Botanischer Garten", "Bodensee-Ufer", "Waldgebiete", ], }