A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
305 lines
8.6 KiB
Python
305 lines
8.6 KiB
Python
"""
|
|
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",
|
|
],
|
|
}
|