Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot) Refactor: gdprDeletion via onUserDelete lifecycle hooks Refactor: i18nBootSync accounting labels via app.py parameter injection Refactor: serviceHub moved to serviceCenter/serviceHub.py Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling Cleanup: remove obsolete methodTrustee, serviceExceptions shim Co-authored-by: Cursor <cursoragent@cursor.com>
928 lines
39 KiB
Python
928 lines
39 KiB
Python
"""
|
|
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']} m²")
|
|
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)}"
|
|
)
|