platform-core/modules/features/realEstate/routeFeatureRealEstate.py
ValueOn AG ebc4b2a080
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 12s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
cp adapted to 2026 poweron 2
2026-06-09 09:58:05 +02:00

930 lines
39 KiB
Python

# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Real Estate routes for the backend API.
Implements stateless endpoints for real estate database operations with AI-powered natural language processing.
"""
import json
import logging
from typing import Optional, Dict, Any, List
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
from fastapi.responses import JSONResponse
# Import auth modules
from modules.auth import limiter, getRequestContext, RequestContext
# Import models
from modules.datamodels.datamodelPagination import (
PaginationParams,
PaginatedResponse,
PaginationMetadata,
normalize_pagination_dict,
)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from .datamodelFeatureRealEstate import (
Projekt,
Parzelle,
Dokument,
Gemeinde,
Kanton,
Land,
)
# Import interfaces
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
# Import feature logic for AI-powered commands
from .mainRealEstate import (
processNaturalLanguageCommand,
extract_bzo_information,
)
from .parcelSelectionService import compute_selection_summary
# Import connectors still used directly in route file
from modules.connectors.connectorZhWfsParcels import ZhWfsParcelsConnector
# Import ComponentObjects interface for BZO routes
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
# Import handler functions for complex business logic
from .handlerRealEstate import (
processGemeindenSync,
processBzoDocumentsFetch,
processParcelDocuments,
processTableData,
processCreateTableRecord,
processParcelSearch,
processAddAdjacentParcel,
processAddParcelToProject,
)
# Import attribute utilities for model schema
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureRealEstate")
# Configure logger
logger = logging.getLogger(__name__)
# Create router for real estate endpoints
router = APIRouter(
prefix="/api/realestate",
tags=["Real Estate"],
responses={
404: {"description": "Not found"},
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
403: {"description": "Forbidden"},
500: {"description": "Internal server error"}
}
)
# ===== Helper Functions (instanceId-based routes, backend-driven like Trustee) =====
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
"""Parse pagination parameter from JSON string."""
if not pagination:
return None
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
return PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
return None
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""
Validate that the user has access to the feature instance.
Returns the mandateId for the instance.
"""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=404,
detail=f"Feature instance '{instanceId}' not found"
)
if instance.featureCode != "realestate":
raise HTTPException(
status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance"
)
if not context.isPlatformAdmin:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled
for fa in featureAccesses
)
if not hasAccess:
raise HTTPException(
status_code=403,
detail=f"Access denied to feature instance '{instanceId}'"
)
return str(instance.mandateId)
# Mapping of entity names to Pydantic model classes (for attributes endpoint)
_REALESTATE_ENTITY_MODELS = {
"Projekt": Projekt,
"Parzelle": Parzelle,
"Dokument": Dokument,
"Gemeinde": Gemeinde,
"Kanton": Kanton,
"Land": Land,
}
def _validateCsrfToken(request: "Request", routePath: str, userId: str) -> None:
"""Validate CSRF token from request headers (format + hex check)."""
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not csrf_token:
logger.warning(f"CSRF token missing for {routePath} from user {userId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64:
logger.warning(f"Invalid CSRF token format for {routePath} from user {userId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Invalid CSRF token format")
)
try:
int(csrf_token, 16)
except ValueError:
logger.warning(f"CSRF token is not a valid hex string for {routePath} from user {userId}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("Invalid CSRF token format")
)
# ============================================================================
# INSTANCE-ID ROUTES (backend-driven, analog to Trustee)
# ============================================================================
@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
def get_entity_attributes(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
entityType: str = Path(..., description="Entity type (e.g., Projekt, Parzelle)"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Get attribute definitions for a Real Estate entity. Used by FormGeneratorTable."""
_validateInstanceAccess(instanceId, context)
if entityType not in _REALESTATE_ENTITY_MODELS:
raise HTTPException(
status_code=404,
detail=f"Unknown entity type: {entityType}. Valid types: {list(_REALESTATE_ENTITY_MODELS.keys())}"
)
modelClass = _REALESTATE_ENTITY_MODELS[entityType]
try:
attrDefs = getModelAttributeDefinitions(modelClass)
visibleAttrs = [
attr for attr in attrDefs.get("attributes", [])
if isinstance(attr, dict) and attr.get("visible", True)
]
return {"attributes": visibleAttrs}
except Exception as e:
logger.error(f"Error getting attributes for {entityType}: {e}")
raise HTTPException(
status_code=500,
detail=f"Error getting attributes for {entityType}: {str(e)}"
)
@router.get("/{instanceId}/projects/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
def get_project_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""Get project options for select dropdowns. Returns: [{ value, label }]"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
items = interface.getProjekte(recordFilter={"featureInstanceId": instanceId})
return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
@router.get("/{instanceId}/parcels/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
def get_parcel_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""Get parcel options for select dropdowns. Returns: [{ value, label }]"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
items = interface.getParzellen(recordFilter={"featureInstanceId": instanceId})
return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
# ----- Projects CRUD -----
@router.get("/{instanceId}/projects", response_model=PaginatedResponse[Projekt])
@limiter.limit("30/minute")
def get_projects(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Projekt]:
"""Get all projects for a feature instance with optional pagination."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
recordFilter = {"featureInstanceId": instanceId}
if mode in ("filterValues", "ids"):
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory, handleIdsInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
items = interface.getProjekte(recordFilter=recordFilter)
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
enrichRowsWithFkLabels(itemDicts, Projekt, db=interface.db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
return handleIdsInMemory(itemDicts, pagination)
items = interface.getProjekte(recordFilter=recordFilter)
paginationParams = _parsePagination(pagination)
if paginationParams:
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
filtered = applyFiltersAndSort(itemDicts, paginationParams)
total_items = len(filtered)
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
start_idx = (paginationParams.page - 1) * paginationParams.pageSize
end_idx = start_idx + paginationParams.pageSize
paginated_items = filtered[start_idx:end_idx]
return PaginatedResponse(
items=paginated_items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=total_items,
totalPages=total_pages,
sort=paginationParams.sort or [],
filters=paginationParams.filters
)
)
return PaginatedResponse(items=items, pagination=None)
@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
@limiter.limit("30/minute")
def get_project_by_id(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
projectId: str = Path(..., description="Project ID"),
context: RequestContext = Depends(getRequestContext)
) -> Projekt:
"""Get a single project by ID."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
projekt = interface.getProjekt(projectId)
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
return projekt
@router.post("/{instanceId}/projects", response_model=Projekt)
@limiter.limit("30/minute")
def create_project(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Projekt:
"""Create a new project."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
if "mandateId" not in data:
data["mandateId"] = mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = instanceId
try:
projekt = Projekt(**data)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
return interface.createProjekt(projekt)
@router.put("/{instanceId}/projects/{projectId}", response_model=Projekt)
@limiter.limit("30/minute")
def update_project(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
projectId: str = Path(..., description="Project ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Projekt:
"""Update a project."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
projekt = interface.getProjekt(projectId)
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
updated = interface.updateProjekt(projectId, data)
if not updated:
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated
@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
def delete_project(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
projectId: str = Path(..., description="Project ID"),
context: RequestContext = Depends(getRequestContext)
) -> None:
"""Delete a project."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
projekt = interface.getProjekt(projectId)
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
if not interface.deleteProjekt(projectId):
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ----- Parcels CRUD -----
@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle])
@limiter.limit("30/minute")
def get_parcels(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Parzelle]:
"""Get all parcels for a feature instance with optional pagination."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
recordFilter = {"featureInstanceId": instanceId}
if mode in ("filterValues", "ids"):
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory, handleIdsInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
items = interface.getParzellen(recordFilter=recordFilter)
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
enrichRowsWithFkLabels(itemDicts, Parzelle, db=interface.db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
return handleIdsInMemory(itemDicts, pagination)
items = interface.getParzellen(recordFilter=recordFilter)
paginationParams = _parsePagination(pagination)
if paginationParams:
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
filtered = applyFiltersAndSort(itemDicts, paginationParams)
total_items = len(filtered)
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
start_idx = (paginationParams.page - 1) * paginationParams.pageSize
end_idx = start_idx + paginationParams.pageSize
paginated_items = filtered[start_idx:end_idx]
return PaginatedResponse(
items=paginated_items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=total_items,
totalPages=total_pages,
sort=paginationParams.sort or [],
filters=paginationParams.filters
)
)
return PaginatedResponse(items=items, pagination=None)
@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
@limiter.limit("30/minute")
def get_parcel_by_id(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
parcelId: str = Path(..., description="Parcel ID"),
context: RequestContext = Depends(getRequestContext)
) -> Parzelle:
"""Get a single parcel by ID."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parzelle = interface.getParzelle(parcelId)
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
return parzelle
@router.post("/{instanceId}/parcels", response_model=Parzelle)
@limiter.limit("30/minute")
def create_parcel(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Parzelle:
"""Create a new parcel."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
if "mandateId" not in data:
data["mandateId"] = mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = instanceId
try:
parzelle = Parzelle(**data)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
return interface.createParzelle(parzelle)
@router.put("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
@limiter.limit("30/minute")
def update_parcel(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
parcelId: str = Path(..., description="Parcel ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Parzelle:
"""Update a parcel."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parzelle = interface.getParzelle(parcelId)
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
updated = interface.updateParzelle(parcelId, data)
if not updated:
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
return updated
@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
def delete_parcel(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
parcelId: str = Path(..., description="Parcel ID"),
context: RequestContext = Depends(getRequestContext)
) -> None:
"""Delete a parcel."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parzelle = interface.getParzelle(parcelId)
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
if not interface.deleteParzelle(parcelId):
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
# ===== Helpers for Gemeinde/BZO routes =====
def _get_language_from_kanton(kanton_abk: Optional[str]) -> str:
"""Determine language (de/fr/it) based on Kanton abbreviation."""
if not kanton_abk:
return "de"
french_cantons = {"VD", "GE", "NE", "JU"}
italian_cantons = {"TI"}
kanton_upper = kanton_abk.upper()
if kanton_upper in french_cantons:
return "fr"
if kanton_upper in italian_cantons:
return "it"
return "de"
def _get_bzo_search_query(gemeinde_label: str, language: str) -> str:
"""Generate language-specific BZO search query for a Gemeinde."""
if language == "fr":
return f"Plan d'aménagement local {gemeinde_label} OR Règlement de construction {gemeinde_label}"
if language == "it":
return f"Piano di utilizzazione {gemeinde_label} OR Regolamento edilizio {gemeinde_label}"
return f"Bau und Zonenordnung {gemeinde_label}"
# ----- Instance-scoped Gemeinde and BZO routes -----
@router.get("/{instanceId}/gemeinden", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def get_instance_gemeinden(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
only_current: bool = Query(True, description="Only current municipalities (exclude historical)"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""
Fetch all Gemeinden from Swiss Topo and save to DB for this instance.
Creates Kantone as needed. Scoped to instance mandateId.
"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
return await processGemeindenSync(interface, instanceId, mandateId, onlyCurrent=only_current)
@router.post("/{instanceId}/gemeinden/fetch-bzo-documents", response_model=Dict[str, Any])
@limiter.limit("10/hour")
async def fetch_instance_bzo_documents(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Search for and download BZO documents for all Gemeinden of this instance (1 doc per Gemeinde, no duplicates)."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
componentInterface = getComponentInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
return await processBzoDocumentsFetch(interface, componentInterface, mandateId, instanceId)
@router.get("/{instanceId}/parcel-documents", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def get_parcel_documents(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
gemeinde: str = Query(..., description="Gemeinde name (e.g. Zürich)"),
bauzone: str = Query(..., description="Bauzone code (e.g. W5)"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""
Ensure BZO document exists for Gemeinde, return documents for parcel info display.
Creates Gemeinde (Swiss Topo) and BZO (Tavily) if not in DB.
Returns documents for preview - does NOT run the BZO extraction pipeline.
"""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
componentInterface = getComponentInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
return await processParcelDocuments(interface, componentInterface, gemeinde, bauzone, mandateId, instanceId)
@router.get("/{instanceId}/bzo-information", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def get_instance_bzo_information(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
gemeinde: str = Query(..., description="Gemeinde name or ID"),
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
parcel_ids: Optional[str] = Query(None, description="Comma-separated parcel IDs; total area computed from parcels"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Extract BZO information for a Bauzone in a Gemeinde. Runs the BZO extraction pipeline. With total_area_m2 or parcel_ids, includes Machbarkeitsstudie."""
mandateId = _validateInstanceAccess(instanceId, context)
parcels = None
if parcel_ids:
ids = [x.strip() for x in parcel_ids.split(",") if x.strip()]
if ids:
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parcels = []
for pid in ids:
p = interface.getParzelle(pid)
if p:
flat = dict(p) if hasattr(p, "keys") else (vars(p) if hasattr(p, "__dict__") else {})
parcels.append({"parcel": flat, "map_view": flat.get("map_view", {})})
return await extract_bzo_information(
currentUser=context.user,
gemeinde=gemeinde,
bauzone=bauzone,
mandateId=mandateId,
featureInstanceId=instanceId,
total_area_m2=total_area_m2,
parcels=parcels,
)
# ============================================================================
# LEGACY / STATELESS ROUTES (unchanged)
# ============================================================================
@router.post("/command", response_model=Dict[str, Any])
@limiter.limit("120/minute")
async def process_command(
request: Request,
userInput: str = Body(..., embed=True, description="Natural language command"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Process natural language command and execute corresponding CRUD operation.
Uses AI to analyze user intent and extract parameters, then executes the appropriate
CRUD operation. Works stateless without session management.
"""
try:
_validateCsrfToken(request, "POST /api/realestate/command", str(context.user.id))
logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})")
logger.debug(f"User input: {userInput}")
result = await processNaturalLanguageCommand(
currentUser=context.user,
mandateId=str(context.mandateId),
userInput=userInput
)
return result
except ValueError as e:
logger.error(f"Validation error in process_command: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Validation error: {str(e)}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error processing command: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error processing command: {str(e)}"
)
@router.get("/tables", response_model=Dict[str, Any])
@limiter.limit("120/minute")
def get_available_tables(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Get all available real estate tables."""
try:
_validateCsrfToken(request, "GET /api/realestate/tables", str(context.user.id))
logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})")
tables = [
{"name": "Projekt", "description": "Real estate projects", "model": "Projekt"},
{"name": "Parzelle", "description": "Plots/parcels", "model": "Parzelle"},
{"name": "Dokument", "description": "Documents", "model": "Dokument"},
{"name": "Gemeinde", "description": "Municipalities", "model": "Gemeinde"},
{"name": "Kanton", "description": "Cantons", "model": "Kanton"},
{"name": "Land", "description": "Countries", "model": "Land"},
]
return {"tables": tables, "count": len(tables)}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting available tables: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting available tables: {str(e)}"
)
@router.get("/table/{table}", response_model=PaginatedResponse[Any])
@limiter.limit("120/minute")
def get_table_data(
request: Request,
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Dict[str, Any]]:
"""Get all data from a specific real estate table with optional pagination."""
try:
_validateCsrfToken(request, f"GET /api/realestate/table/{table}", str(context.user.id))
logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})")
mandateId = str(context.mandateId) if context.mandateId else None
return processTableData(context.user, mandateId, table, pagination)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting table data for '{table}': {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting table data: {str(e)}"
)
@router.post("/table/{table}", response_model=Dict[str, Any])
@limiter.limit("120/minute")
async def create_table_record(
request: Request,
table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"),
data: Dict[str, Any] = Body(..., description="Record data to create"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Create a new record in a specific real estate table."""
try:
_validateCsrfToken(request, f"POST /api/realestate/table/{table}", str(context.user.id))
logger.info(f"Creating record in table '{table}' for user {context.user.id} (mandate: {context.mandateId})")
mandateId = str(context.mandateId) if context.mandateId else None
return await processCreateTableRecord(context.user, mandateId, table, data)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error creating record in table '{table}': {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating record: {str(e)}"
)
@router.get("/parcel/wfs")
@limiter.limit("60/minute")
def get_parcels_wfs(
request: Request,
bbox: str = Query(..., description="Bounding box as minx,miny,maxx,maxy in LV95 (EPSG:2056)"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""
Fetch parcel geometries from geodienste.ch OGC API (Swiss Liegenschaften) within bounding box.
Returns GeoJSON FeatureCollection in WGS84 for map display.
"""
try:
connector = ZhWfsParcelsConnector()
geojson = connector.get_parcels_by_bbox(bbox)
return JSONResponse(content=geojson)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Error fetching WFS parcels: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=routeApiMsg("Failed to fetch parcel data from WFS")
)
@router.get("/parcel/search", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def search_parcel(
request: Request,
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
include_bauzone: bool = Query(True, description="Include Bauzone from ÖREB WFS (zone information)"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Search for parcel information by address or coordinates.
Returns comprehensive parcel information including geometry, administrative context, and bauzone.
"""
try:
_validateCsrfToken(request, "GET /api/realestate/parcel/search", str(context.user.id))
logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}")
mandateId = str(context.mandateId) if context.mandateId else None
return await processParcelSearch(context.user, mandateId, location, include_bauzone, include_adjacent)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error searching parcel: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error searching parcel: {str(e)}"
)
@router.post("/parcel/selection-summary", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def parcel_selection_summary(
request: Request,
body: Dict[str, Any] = Body(..., description="Parcel selection data"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Compute combined outline, total area, and Bauzone grouping for selected parcels.
Request body: { "parcels": [ { parcel, map_view, perimeter, geometry_geojson, ... } ] }
"""
try:
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
parcels = body.get("parcels", [])
if not parcels:
return {
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
"total_area_m2": 0.0,
"bauzonen": [],
}
result = compute_selection_summary(parcels)
logger.info(f"Computed selection summary for {len(parcels)} parcels, total area {result['total_area_m2']}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error computing selection summary: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error computing selection summary: {str(e)}"
)
@router.post("/parcel/add-adjacent", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def add_adjacent_parcel(
request: Request,
body: Dict[str, Any] = Body(..., description="Location and selected parcels"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Add an adjacent parcel to the selection. Validates that the parcel at the given
location touches the current selection.
Request body: { "location": { "x": number, "y": number }, "selected_parcels": [...] }
"""
try:
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
if not csrf_token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.")
)
location = body.get("location")
selected_parcels = body.get("selected_parcels", [])
if not location or "x" not in location or "y" not in location:
raise HTTPException(status_code=400, detail=routeApiMsg("location with x,y required"))
return await processAddAdjacentParcel(location, selected_parcels)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding adjacent parcel: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error adding adjacent parcel: {str(e)}"
)
@router.post("/projekt/{projekt_id}/add-parcel", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def add_parcel_to_project(
request: Request,
projekt_id: str = Path(..., description="Projekt ID"),
body: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Add a parcel to an existing project.
Supports linking existing parcel, creating from location, or creating from custom data.
"""
try:
_validateCsrfToken(request, f"POST /api/realestate/projekt/{projekt_id}/add-parcel", str(context.user.id))
logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})")
mandateId = str(context.mandateId) if context.mandateId else None
return await processAddParcelToProject(context.user, mandateId, projekt_id, body)
except ValueError as e:
logger.error(f"Validation error in add_parcel_to_project: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Validation error: {str(e)}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding parcel to project: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error adding parcel to project: {str(e)}"
)