fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
304
geo-service/api/aoi.py
Normal file
304
geo-service/api/aoi.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
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",
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user