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:
9
geo-service/api/__init__.py
Normal file
9
geo-service/api/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
GeoEdu Service API Endpoints
|
||||
"""
|
||||
from .tiles import router as tiles_router
|
||||
from .terrain import router as terrain_router
|
||||
from .aoi import router as aoi_router
|
||||
from .learning import router as learning_router
|
||||
|
||||
__all__ = ["tiles_router", "terrain_router", "aoi_router", "learning_router"]
|
||||
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",
|
||||
],
|
||||
}
|
||||
289
geo-service/api/learning.py
Normal file
289
geo-service/api/learning.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Learning Nodes API Endpoints
|
||||
Generates and manages educational content for AOI regions
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import structlog
|
||||
|
||||
from config import settings
|
||||
from models.learning_node import LearningNode, LearningNodeRequest, LearningTheme
|
||||
from services.learning_generator import LearningGeneratorService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize learning generator service
|
||||
learning_service = LearningGeneratorService()
|
||||
|
||||
|
||||
class GenerateNodesRequest(BaseModel):
|
||||
"""Request model for generating learning nodes."""
|
||||
|
||||
aoi_id: str = Field(..., description="AOI UUID to generate nodes for")
|
||||
theme: LearningTheme = Field(LearningTheme.TOPOGRAPHIE, description="Learning theme")
|
||||
difficulty: str = Field("mittel", pattern="^(leicht|mittel|schwer)$", description="Difficulty level")
|
||||
node_count: int = Field(5, ge=1, le=20, description="Number of nodes to generate")
|
||||
grade_level: Optional[str] = Field(None, description="Target grade level (e.g., '5-6', '7-8')")
|
||||
language: str = Field("de", description="Content language")
|
||||
|
||||
|
||||
class GenerateNodesResponse(BaseModel):
|
||||
"""Response model for generated learning nodes."""
|
||||
|
||||
aoi_id: str
|
||||
theme: str
|
||||
nodes: list[LearningNode]
|
||||
total_count: int
|
||||
generation_model: str
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerateNodesResponse)
|
||||
async def generate_learning_nodes(
|
||||
request: GenerateNodesRequest,
|
||||
):
|
||||
"""
|
||||
Generate learning nodes for an AOI using LLM.
|
||||
|
||||
Uses Ollama with the configured model to generate educational content
|
||||
based on the AOI's geographic features and selected theme.
|
||||
|
||||
Themes:
|
||||
- topographie: Landforms, elevation, terrain features
|
||||
- landnutzung: Land use, settlement patterns, agriculture
|
||||
- orientierung: Navigation, compass, map reading
|
||||
- geologie: Rock types, geological formations
|
||||
- hydrologie: Water features, drainage, watersheds
|
||||
- vegetation: Plant communities, climate zones
|
||||
"""
|
||||
try:
|
||||
nodes = await learning_service.generate_nodes(
|
||||
aoi_id=request.aoi_id,
|
||||
theme=request.theme,
|
||||
difficulty=request.difficulty,
|
||||
node_count=request.node_count,
|
||||
grade_level=request.grade_level,
|
||||
language=request.language,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Learning nodes generated",
|
||||
aoi_id=request.aoi_id,
|
||||
theme=request.theme.value,
|
||||
count=len(nodes),
|
||||
)
|
||||
|
||||
return GenerateNodesResponse(
|
||||
aoi_id=request.aoi_id,
|
||||
theme=request.theme.value,
|
||||
nodes=nodes,
|
||||
total_count=len(nodes),
|
||||
generation_model=settings.ollama_model,
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="AOI not found. Please create an AOI first.",
|
||||
)
|
||||
except ConnectionError:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="LLM service (Ollama) not available. Please check if Ollama is running.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error generating learning nodes", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error generating learning nodes")
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def get_learning_templates():
|
||||
"""
|
||||
Get available learning theme templates.
|
||||
|
||||
Returns theme definitions with descriptions, suitable grade levels,
|
||||
and example questions.
|
||||
"""
|
||||
return {
|
||||
"themes": [
|
||||
{
|
||||
"id": "topographie",
|
||||
"name": "Topographie",
|
||||
"description": "Landschaftsformen, Höhen und Geländemerkmale",
|
||||
"icon": "mountain",
|
||||
"grade_levels": ["5-6", "7-8", "9-10"],
|
||||
"example_questions": [
|
||||
"Welche Höhe hat der höchste Punkt in diesem Gebiet?",
|
||||
"Beschreibe die Hangneigung im nördlichen Bereich.",
|
||||
"Wo befinden sich Täler und wo Bergrücken?",
|
||||
],
|
||||
"keywords": ["Höhe", "Hang", "Tal", "Berg", "Hügel", "Ebene"],
|
||||
},
|
||||
{
|
||||
"id": "landnutzung",
|
||||
"name": "Landnutzung",
|
||||
"description": "Siedlungen, Landwirtschaft und Flächennutzung",
|
||||
"icon": "home",
|
||||
"grade_levels": ["5-6", "7-8", "9-10", "11-12"],
|
||||
"example_questions": [
|
||||
"Welche Arten von Gebäuden sind in diesem Bereich zu finden?",
|
||||
"Wie viel Prozent der Fläche wird landwirtschaftlich genutzt?",
|
||||
"Wo verläuft die Grenze zwischen Siedlung und Naturraum?",
|
||||
],
|
||||
"keywords": ["Siedlung", "Acker", "Wald", "Industrie", "Straße"],
|
||||
},
|
||||
{
|
||||
"id": "orientierung",
|
||||
"name": "Orientierung",
|
||||
"description": "Kartenlesen, Kompass und Navigation",
|
||||
"icon": "compass",
|
||||
"grade_levels": ["5-6", "7-8"],
|
||||
"example_questions": [
|
||||
"In welcher Himmelsrichtung liegt der See?",
|
||||
"Wie lang ist der Weg von A nach B?",
|
||||
"Beschreibe den Weg vom Parkplatz zum Aussichtspunkt.",
|
||||
],
|
||||
"keywords": ["Norden", "Süden", "Entfernung", "Maßstab", "Legende"],
|
||||
},
|
||||
{
|
||||
"id": "geologie",
|
||||
"name": "Geologie",
|
||||
"description": "Gesteinsarten und geologische Formationen",
|
||||
"icon": "layers",
|
||||
"grade_levels": ["7-8", "9-10", "11-12"],
|
||||
"example_questions": [
|
||||
"Welches Gestein dominiert in diesem Gebiet?",
|
||||
"Wie sind die Felsformationen entstanden?",
|
||||
"Welche Rolle spielt die Geologie für die Landschaft?",
|
||||
],
|
||||
"keywords": ["Gestein", "Formation", "Erosion", "Schicht", "Mineral"],
|
||||
},
|
||||
{
|
||||
"id": "hydrologie",
|
||||
"name": "Hydrologie",
|
||||
"description": "Gewässer, Einzugsgebiete und Wasserkreislauf",
|
||||
"icon": "droplet",
|
||||
"grade_levels": ["5-6", "7-8", "9-10"],
|
||||
"example_questions": [
|
||||
"Wohin fließt das Wasser in diesem Gebiet?",
|
||||
"Welche Gewässerarten sind vorhanden?",
|
||||
"Wo könnte sich bei Starkregen Wasser sammeln?",
|
||||
],
|
||||
"keywords": ["Fluss", "See", "Bach", "Einzugsgebiet", "Quelle"],
|
||||
},
|
||||
{
|
||||
"id": "vegetation",
|
||||
"name": "Vegetation",
|
||||
"description": "Pflanzengemeinschaften und Klimazonen",
|
||||
"icon": "tree",
|
||||
"grade_levels": ["5-6", "7-8", "9-10", "11-12"],
|
||||
"example_questions": [
|
||||
"Welche Waldtypen sind in diesem Gebiet zu finden?",
|
||||
"Wie beeinflusst die Höhenlage die Vegetation?",
|
||||
"Welche Pflanzen würdest du hier erwarten?",
|
||||
],
|
||||
"keywords": ["Wald", "Wiese", "Laubbaum", "Nadelbaum", "Höhenstufe"],
|
||||
},
|
||||
],
|
||||
"difficulties": [
|
||||
{"id": "leicht", "name": "Leicht", "description": "Grundlegende Beobachtungen"},
|
||||
{"id": "mittel", "name": "Mittel", "description": "Verknüpfung von Zusammenhängen"},
|
||||
{"id": "schwer", "name": "Schwer", "description": "Analyse und Transfer"},
|
||||
],
|
||||
"supported_languages": ["de", "en"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{aoi_id}/nodes")
|
||||
async def get_aoi_learning_nodes(
|
||||
aoi_id: str = Path(..., description="AOI UUID"),
|
||||
theme: Optional[LearningTheme] = Query(None, description="Filter by theme"),
|
||||
):
|
||||
"""
|
||||
Get all learning nodes for an AOI.
|
||||
|
||||
Returns previously generated nodes, optionally filtered by theme.
|
||||
"""
|
||||
nodes = await learning_service.get_nodes_for_aoi(aoi_id, theme)
|
||||
|
||||
if nodes is None:
|
||||
raise HTTPException(status_code=404, detail="AOI not found")
|
||||
|
||||
return {
|
||||
"aoi_id": aoi_id,
|
||||
"nodes": nodes,
|
||||
"total_count": len(nodes),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{aoi_id}/nodes/{node_id}")
|
||||
async def update_learning_node(
|
||||
aoi_id: str = Path(..., description="AOI UUID"),
|
||||
node_id: str = Path(..., description="Node UUID"),
|
||||
node_update: LearningNode,
|
||||
):
|
||||
"""
|
||||
Update a learning node (teacher review/edit).
|
||||
|
||||
Allows teachers to modify AI-generated content before use.
|
||||
"""
|
||||
success = await learning_service.update_node(aoi_id, node_id, node_update)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
logger.info("Learning node updated", aoi_id=aoi_id, node_id=node_id)
|
||||
|
||||
return {"message": "Node updated successfully", "node_id": node_id}
|
||||
|
||||
|
||||
@router.delete("/{aoi_id}/nodes/{node_id}")
|
||||
async def delete_learning_node(
|
||||
aoi_id: str = Path(..., description="AOI UUID"),
|
||||
node_id: str = Path(..., description="Node UUID"),
|
||||
):
|
||||
"""
|
||||
Delete a learning node.
|
||||
"""
|
||||
success = await learning_service.delete_node(aoi_id, node_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return {"message": "Node deleted successfully", "node_id": node_id}
|
||||
|
||||
|
||||
@router.post("/{aoi_id}/nodes/{node_id}/approve")
|
||||
async def approve_learning_node(
|
||||
aoi_id: str = Path(..., description="AOI UUID"),
|
||||
node_id: str = Path(..., description="Node UUID"),
|
||||
):
|
||||
"""
|
||||
Approve a learning node for student use.
|
||||
|
||||
Only approved nodes will be visible to students.
|
||||
"""
|
||||
success = await learning_service.approve_node(aoi_id, node_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Node not found")
|
||||
|
||||
return {"message": "Node approved", "node_id": node_id}
|
||||
|
||||
|
||||
@router.get("/statistics")
|
||||
async def get_learning_statistics():
|
||||
"""
|
||||
Get statistics about learning node usage.
|
||||
"""
|
||||
stats = await learning_service.get_statistics()
|
||||
|
||||
return {
|
||||
"total_nodes_generated": stats.get("total_nodes", 0),
|
||||
"nodes_by_theme": stats.get("by_theme", {}),
|
||||
"nodes_by_difficulty": stats.get("by_difficulty", {}),
|
||||
"average_nodes_per_aoi": stats.get("avg_per_aoi", 0),
|
||||
"most_popular_theme": stats.get("popular_theme", "topographie"),
|
||||
}
|
||||
230
geo-service/api/terrain.py
Normal file
230
geo-service/api/terrain.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Terrain/DEM API Endpoints
|
||||
Serves heightmap tiles from Copernicus DEM data
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
import structlog
|
||||
|
||||
from config import settings
|
||||
from services.dem_service import DEMService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize DEM service
|
||||
dem_service = DEMService()
|
||||
|
||||
|
||||
@router.get("/{z}/{x}/{y}.png", response_class=Response)
|
||||
async def get_heightmap_tile(
|
||||
z: int = Path(..., ge=0, le=14, description="Zoom level"),
|
||||
x: int = Path(..., ge=0, description="Tile X coordinate"),
|
||||
y: int = Path(..., ge=0, description="Tile Y coordinate"),
|
||||
):
|
||||
"""
|
||||
Get a heightmap tile as 16-bit PNG (Mapbox Terrain-RGB encoding).
|
||||
|
||||
Heightmaps are generated from Copernicus DEM GLO-30 (30m resolution).
|
||||
The encoding allows for ~0.1m precision: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)
|
||||
"""
|
||||
try:
|
||||
tile_data = await dem_service.get_heightmap_tile(z, x, y)
|
||||
|
||||
if tile_data is None:
|
||||
return Response(status_code=204) # No terrain data for this tile
|
||||
|
||||
return Response(
|
||||
content=tile_data,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=604800", # 7 days cache
|
||||
"X-Tile-Source": "copernicus-dem",
|
||||
},
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("DEM data not found", z=z, x=x, y=y)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="DEM data not available. Please download Copernicus DEM first.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error serving heightmap tile", z=z, x=x, y=y, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error serving heightmap tile")
|
||||
|
||||
|
||||
@router.get("/hillshade/{z}/{x}/{y}.png", response_class=Response)
|
||||
async def get_hillshade_tile(
|
||||
z: int = Path(..., ge=0, le=14, description="Zoom level"),
|
||||
x: int = Path(..., ge=0, description="Tile X coordinate"),
|
||||
y: int = Path(..., ge=0, description="Tile Y coordinate"),
|
||||
azimuth: float = Query(315, ge=0, le=360, description="Light azimuth in degrees"),
|
||||
altitude: float = Query(45, ge=0, le=90, description="Light altitude in degrees"),
|
||||
):
|
||||
"""
|
||||
Get a hillshade tile for terrain visualization.
|
||||
|
||||
Hillshade is rendered from DEM with configurable light direction.
|
||||
Default light comes from northwest (azimuth=315) at 45° altitude.
|
||||
"""
|
||||
try:
|
||||
tile_data = await dem_service.get_hillshade_tile(z, x, y, azimuth, altitude)
|
||||
|
||||
if tile_data is None:
|
||||
return Response(status_code=204)
|
||||
|
||||
return Response(
|
||||
content=tile_data,
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=604800",
|
||||
"X-Hillshade-Azimuth": str(azimuth),
|
||||
"X-Hillshade-Altitude": str(altitude),
|
||||
},
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=503, detail="DEM data not available")
|
||||
except Exception as e:
|
||||
logger.error("Error serving hillshade tile", z=z, x=x, y=y, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error serving hillshade tile")
|
||||
|
||||
|
||||
@router.get("/contours/{z}/{x}/{y}.pbf", response_class=Response)
|
||||
async def get_contour_tile(
|
||||
z: int = Path(..., ge=0, le=14, description="Zoom level"),
|
||||
x: int = Path(..., ge=0, description="Tile X coordinate"),
|
||||
y: int = Path(..., ge=0, description="Tile Y coordinate"),
|
||||
interval: int = Query(20, ge=5, le=100, description="Contour interval in meters"),
|
||||
):
|
||||
"""
|
||||
Get contour lines as vector tile.
|
||||
|
||||
Contours are generated from DEM at the specified interval.
|
||||
Useful for topographic map overlays.
|
||||
"""
|
||||
try:
|
||||
tile_data = await dem_service.get_contour_tile(z, x, y, interval)
|
||||
|
||||
if tile_data is None:
|
||||
return Response(status_code=204)
|
||||
|
||||
return Response(
|
||||
content=tile_data,
|
||||
media_type="application/x-protobuf",
|
||||
headers={
|
||||
"Content-Encoding": "gzip",
|
||||
"Cache-Control": "public, max-age=604800",
|
||||
"X-Contour-Interval": str(interval),
|
||||
},
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=503, detail="DEM data not available")
|
||||
except Exception as e:
|
||||
logger.error("Error serving contour tile", z=z, x=x, y=y, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error serving contour tile")
|
||||
|
||||
|
||||
@router.get("/elevation")
|
||||
async def get_elevation_at_point(
|
||||
lat: float = Query(..., ge=47.27, le=55.06, description="Latitude"),
|
||||
lon: float = Query(..., ge=5.87, le=15.04, description="Longitude"),
|
||||
):
|
||||
"""
|
||||
Get elevation at a specific point.
|
||||
|
||||
Returns elevation in meters from Copernicus DEM.
|
||||
Only works within Germany bounds.
|
||||
"""
|
||||
try:
|
||||
elevation = await dem_service.get_elevation(lat, lon)
|
||||
|
||||
if elevation is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No elevation data available for this location",
|
||||
)
|
||||
|
||||
return {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"elevation_m": round(elevation, 1),
|
||||
"source": "Copernicus DEM GLO-30",
|
||||
"resolution_m": 30,
|
||||
}
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=503, detail="DEM data not available")
|
||||
except Exception as e:
|
||||
logger.error("Error getting elevation", lat=lat, lon=lon, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error getting elevation")
|
||||
|
||||
|
||||
@router.post("/elevation/profile")
|
||||
async def get_elevation_profile(
|
||||
coordinates: list[list[float]],
|
||||
samples: int = Query(100, ge=10, le=1000, description="Number of sample points"),
|
||||
):
|
||||
"""
|
||||
Get elevation profile along a path.
|
||||
|
||||
Takes a list of [lon, lat] coordinates and returns elevations sampled along the path.
|
||||
Useful for hiking/cycling route profiles.
|
||||
"""
|
||||
if len(coordinates) < 2:
|
||||
raise HTTPException(status_code=400, detail="At least 2 coordinates required")
|
||||
|
||||
if len(coordinates) > 100:
|
||||
raise HTTPException(status_code=400, detail="Maximum 100 coordinates allowed")
|
||||
|
||||
try:
|
||||
profile = await dem_service.get_elevation_profile(coordinates, samples)
|
||||
|
||||
return {
|
||||
"profile": profile,
|
||||
"statistics": {
|
||||
"min_elevation_m": min(p["elevation_m"] for p in profile if p["elevation_m"]),
|
||||
"max_elevation_m": max(p["elevation_m"] for p in profile if p["elevation_m"]),
|
||||
"total_ascent_m": sum(
|
||||
max(0, profile[i + 1]["elevation_m"] - profile[i]["elevation_m"])
|
||||
for i in range(len(profile) - 1)
|
||||
if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"]
|
||||
),
|
||||
"total_descent_m": sum(
|
||||
max(0, profile[i]["elevation_m"] - profile[i + 1]["elevation_m"])
|
||||
for i in range(len(profile) - 1)
|
||||
if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"]
|
||||
),
|
||||
},
|
||||
"source": "Copernicus DEM GLO-30",
|
||||
}
|
||||
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=503, detail="DEM data not available")
|
||||
except Exception as e:
|
||||
logger.error("Error getting elevation profile", error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error getting elevation profile")
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
async def get_dem_metadata():
|
||||
"""
|
||||
Get metadata about available DEM data.
|
||||
"""
|
||||
metadata = await dem_service.get_metadata()
|
||||
|
||||
return {
|
||||
"name": "Copernicus DEM GLO-30",
|
||||
"description": "Global 30m Digital Elevation Model",
|
||||
"resolution_m": 30,
|
||||
"coverage": "Germany (Deutschland)",
|
||||
"bounds": [5.87, 47.27, 15.04, 55.06],
|
||||
"vertical_datum": "EGM2008",
|
||||
"horizontal_datum": "WGS84",
|
||||
"license": "Copernicus Data (free, attribution required)",
|
||||
"attribution": "© Copernicus Service Information 2024",
|
||||
"data_available": metadata.get("data_available", False),
|
||||
"tiles_generated": metadata.get("tiles_generated", 0),
|
||||
}
|
||||
221
geo-service/api/tiles.py
Normal file
221
geo-service/api/tiles.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Tile Server API Endpoints
|
||||
Serves Vector Tiles from PMTiles or generates on-demand from PostGIS
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
import structlog
|
||||
|
||||
from config import settings
|
||||
from services.tile_server import TileServerService
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize tile server service
|
||||
tile_service = TileServerService()
|
||||
|
||||
|
||||
@router.get("/{z}/{x}/{y}.pbf", response_class=Response)
|
||||
async def get_vector_tile(
|
||||
z: int = Path(..., ge=0, le=22, description="Zoom level"),
|
||||
x: int = Path(..., ge=0, description="Tile X coordinate"),
|
||||
y: int = Path(..., ge=0, description="Tile Y coordinate"),
|
||||
):
|
||||
"""
|
||||
Get a vector tile in Protocol Buffers format.
|
||||
|
||||
Returns OSM data as vector tiles suitable for MapLibre GL JS.
|
||||
Tiles are served from pre-generated PMTiles or cached on-demand.
|
||||
"""
|
||||
try:
|
||||
tile_data = await tile_service.get_tile(z, x, y)
|
||||
|
||||
if tile_data is None:
|
||||
# Return empty tile (204 No Content is standard for empty tiles)
|
||||
return Response(status_code=204)
|
||||
|
||||
return Response(
|
||||
content=tile_data,
|
||||
media_type="application/x-protobuf",
|
||||
headers={
|
||||
"Content-Encoding": "gzip",
|
||||
"Cache-Control": "public, max-age=86400", # 24h cache
|
||||
"X-Tile-Source": "pmtiles",
|
||||
},
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.warning("PMTiles file not found", z=z, x=x, y=y)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Tile data not available. Please run the data download script first.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error serving tile", z=z, x=x, y=y, error=str(e))
|
||||
raise HTTPException(status_code=500, detail="Error serving tile")
|
||||
|
||||
|
||||
@router.get("/style.json")
|
||||
async def get_maplibre_style(
|
||||
base_url: str = Query(None, description="Base URL for tile server"),
|
||||
):
|
||||
"""
|
||||
Get MapLibre GL JS style specification.
|
||||
|
||||
Returns a style document configured for the self-hosted tile server.
|
||||
"""
|
||||
# Use provided base_url or construct from settings
|
||||
if base_url is None:
|
||||
base_url = f"http://localhost:{settings.port}"
|
||||
|
||||
style = {
|
||||
"version": 8,
|
||||
"name": "GeoEdu Germany",
|
||||
"metadata": {
|
||||
"description": "Self-hosted OSM tiles for DSGVO-compliant education",
|
||||
"attribution": "© OpenStreetMap contributors",
|
||||
},
|
||||
"sources": {
|
||||
"osm": {
|
||||
"type": "vector",
|
||||
"tiles": [f"{base_url}/api/v1/tiles/{{z}}/{{x}}/{{y}}.pbf"],
|
||||
"minzoom": 0,
|
||||
"maxzoom": 14,
|
||||
"attribution": "© OpenStreetMap contributors (ODbL)",
|
||||
},
|
||||
"terrain": {
|
||||
"type": "raster-dem",
|
||||
"tiles": [f"{base_url}/api/v1/terrain/{{z}}/{{x}}/{{y}}.png"],
|
||||
"tileSize": 256,
|
||||
"maxzoom": 14,
|
||||
"attribution": "© Copernicus DEM GLO-30",
|
||||
},
|
||||
},
|
||||
"sprite": "",
|
||||
"glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
# Background
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"paint": {"background-color": "#f8f4f0"},
|
||||
},
|
||||
# Water
|
||||
{
|
||||
"id": "water",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "water",
|
||||
"paint": {"fill-color": "#a0c8f0"},
|
||||
},
|
||||
# Landuse - Parks
|
||||
{
|
||||
"id": "landuse-park",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "park"],
|
||||
"paint": {"fill-color": "#c8e6c8", "fill-opacity": 0.5},
|
||||
},
|
||||
# Landuse - Forest
|
||||
{
|
||||
"id": "landuse-forest",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["==", "class", "wood"],
|
||||
"paint": {"fill-color": "#94d294", "fill-opacity": 0.5},
|
||||
},
|
||||
# Buildings
|
||||
{
|
||||
"id": "building",
|
||||
"type": "fill",
|
||||
"source": "osm",
|
||||
"source-layer": "building",
|
||||
"minzoom": 13,
|
||||
"paint": {"fill-color": "#d9d0c9", "fill-opacity": 0.8},
|
||||
},
|
||||
# Roads - Minor
|
||||
{
|
||||
"id": "road-minor",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "$type", "LineString"], ["in", "class", "minor", "service"]],
|
||||
"paint": {"line-color": "#ffffff", "line-width": 1},
|
||||
},
|
||||
# Roads - Major
|
||||
{
|
||||
"id": "road-major",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "$type", "LineString"], ["in", "class", "primary", "secondary", "tertiary"]],
|
||||
"paint": {"line-color": "#ffc107", "line-width": 2},
|
||||
},
|
||||
# Roads - Highway
|
||||
{
|
||||
"id": "road-highway",
|
||||
"type": "line",
|
||||
"source": "osm",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "$type", "LineString"], ["==", "class", "motorway"]],
|
||||
"paint": {"line-color": "#ff6f00", "line-width": 3},
|
||||
},
|
||||
# Place labels
|
||||
{
|
||||
"id": "place-label",
|
||||
"type": "symbol",
|
||||
"source": "osm",
|
||||
"source-layer": "place",
|
||||
"layout": {
|
||||
"text-field": "{name}",
|
||||
"text-font": ["Open Sans Regular"],
|
||||
"text-size": 12,
|
||||
},
|
||||
"paint": {"text-color": "#333333", "text-halo-color": "#ffffff", "text-halo-width": 1},
|
||||
},
|
||||
],
|
||||
"terrain": {"source": "terrain", "exaggeration": 1.5},
|
||||
}
|
||||
|
||||
return JSONResponse(content=style)
|
||||
|
||||
|
||||
@router.get("/metadata")
|
||||
async def get_tile_metadata():
|
||||
"""
|
||||
Get metadata about available tiles.
|
||||
|
||||
Returns information about data coverage, zoom levels, and update status.
|
||||
"""
|
||||
metadata = await tile_service.get_metadata()
|
||||
|
||||
return {
|
||||
"name": "GeoEdu Germany Tiles",
|
||||
"description": "Self-hosted OSM vector tiles for Germany",
|
||||
"format": "pbf",
|
||||
"scheme": "xyz",
|
||||
"minzoom": metadata.get("minzoom", 0),
|
||||
"maxzoom": metadata.get("maxzoom", 14),
|
||||
"bounds": metadata.get("bounds", [5.87, 47.27, 15.04, 55.06]), # Germany bbox
|
||||
"center": metadata.get("center", [10.45, 51.16, 6]), # Center of Germany
|
||||
"attribution": "© OpenStreetMap contributors (ODbL)",
|
||||
"data_available": metadata.get("data_available", False),
|
||||
"last_updated": metadata.get("last_updated"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/bounds")
|
||||
async def get_tile_bounds():
|
||||
"""
|
||||
Get the geographic bounds of available tile data.
|
||||
|
||||
Returns bounding box for Germany in [west, south, east, north] format.
|
||||
"""
|
||||
return {
|
||||
"bounds": [5.87, 47.27, 15.04, 55.06], # Germany bounding box
|
||||
"center": [10.45, 51.16],
|
||||
"description": "Germany (Deutschland)",
|
||||
}
|
||||
Reference in New Issue
Block a user