diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index d8447b96..5e43ceab 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -15,6 +15,11 @@ FEATURE_ICON = "mdi-home-city" # UI Objects for RBAC catalog UI_OBJECTS = [ + { + "objectKey": "ui.feature.realestate.dashboard", + "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, + "meta": {"area": "dashboard"} + }, { "objectKey": "ui.feature.realestate.projects", "label": {"en": "Projects", "de": "Projekte", "fr": "Projets"}, @@ -70,6 +75,7 @@ TEMPLATE_ROLES = [ }, "accessRules": [ # UI access to main views - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.realestate.projects", "view": True}, {"context": "UI", "item": "ui.feature.realestate.parcels", "view": True}, # Group-level DATA access @@ -87,6 +93,7 @@ TEMPLATE_ROLES = [ }, "accessRules": [ # UI access to view-only views - vollqualifizierte ObjectKeys + {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.realestate.projects", "view": True}, {"context": "UI", "item": "ui.feature.realestate.parcels", "view": True}, # Read-only DATA access (my records) @@ -139,10 +146,139 @@ def registerFeature(catalogService) -> bool: meta=resObj.get("meta") ) + # Sync template roles to database (with AccessRules) + _syncTemplateRolesToDb() + return True except Exception as e: logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}") return False + + +def _syncTemplateRolesToDb() -> int: + """ + Sync template roles and their AccessRules to the database. + Creates global template roles (mandateId=None) if they don't exist. + """ + try: + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + + rootInterface = getRootInterface() + db = rootInterface.db + + existingRoles = db.getRecordset( + Role, + recordFilter={"featureCode": FEATURE_CODE, "mandateId": None} + ) + existingRoleLabels = {r.get("roleLabel"): r.get("id") for r in existingRoles} + + createdCount = 0 + for roleTemplate in TEMPLATE_ROLES: + roleLabel = roleTemplate["roleLabel"] + + if roleLabel in existingRoleLabels: + roleId = existingRoleLabels[roleLabel] + _ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", [])) + else: + newRole = Role( + roleLabel=roleLabel, + description=roleTemplate.get("description", {}), + featureCode=FEATURE_CODE, + mandateId=None, + featureInstanceId=None, + isSystemRole=False + ) + createdRole = db.recordCreate(Role, newRole.model_dump()) + roleId = createdRole.get("id") + existingRoleLabels[roleLabel] = roleId + _ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", [])) + logging.getLogger(__name__).info(f"Created template role '{roleLabel}' with ID {roleId}") + createdCount += 1 + + if createdCount > 0: + logging.getLogger(__name__).info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") + + _repairInstanceRolesAccessRules(db, existingRoleLabels) + return createdCount + except Exception as e: + logging.getLogger(__name__).error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") + return 0 + + +def _repairInstanceRolesAccessRules(db, templateRoleLabels: dict) -> int: + """Repair instance-specific roles by copying AccessRules from their template roles.""" + from modules.datamodels.datamodelRbac import Role, AccessRule + + repairedCount = 0 + allRoles = db.getRecordset(Role, recordFilter={"featureCode": FEATURE_CODE}) + instanceRoles = [r for r in allRoles if r.get("mandateId") is not None] + + for instanceRole in instanceRoles: + roleLabel = instanceRole.get("roleLabel") + instanceRoleId = instanceRole.get("id") + templateRoleId = templateRoleLabels.get(roleLabel) + if not templateRoleId: + continue + existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": instanceRoleId}) + if existingRules: + continue + templateRules = db.getRecordset(AccessRule, recordFilter={"roleId": templateRoleId}) + if not templateRules: + continue + for rule in templateRules: + newRule = AccessRule( + roleId=instanceRoleId, + context=rule.get("context"), + item=rule.get("item"), + view=rule.get("view", False), + read=rule.get("read"), + create=rule.get("create"), + update=rule.get("update"), + delete=rule.get("delete"), + ) + db.recordCreate(AccessRule, newRule.model_dump()) + repairedCount += 1 + return repairedCount + + +def _ensureAccessRulesForRole(db, roleId: str, ruleTemplates: list) -> int: + """Ensure AccessRules exist for a role based on templates.""" + from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext + + existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) + existingSignatures = {(r.get("context"), r.get("item")) for r in existingRules} + createdCount = 0 + + for template in ruleTemplates or []: + context = template.get("context", "UI") + item = template.get("item") + if (context, item) in existingSignatures: + continue + if context == "UI": + contextEnum = AccessRuleContext.UI + elif context == "DATA": + contextEnum = AccessRuleContext.DATA + elif context == "RESOURCE": + contextEnum = AccessRuleContext.RESOURCE + else: + contextEnum = context + newRule = AccessRule( + roleId=roleId, + context=contextEnum, + item=item, + view=template.get("view", False), + read=template.get("read"), + create=template.get("create"), + update=template.get("update"), + delete=template.get("delete"), + ) + db.recordCreate(AccessRule, newRule.model_dump()) + createdCount += 1 + existingSignatures.add((context, item)) + return createdCount + + import json from typing import Optional, Dict, Any, List from fastapi import HTTPException, status diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 3cb31a2c..09f28f13 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -13,7 +13,14 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Pat from modules.auth import limiter, getRequestContext, RequestContext # Import models -from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata +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, @@ -57,6 +64,403 @@ router = APIRouter( ) +# ===== 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 + + +async 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.isSysAdmin: + 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, +} + + +# ============================================================================ +# INSTANCE-ID ROUTES (backend-driven, analog to Trustee) +# ============================================================================ + +@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async 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.""" + await _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") +async 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 = await _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") +async 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 = await _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") +async def get_projects( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), + context: RequestContext = Depends(getRequestContext) +) -> PaginatedResponse[Projekt]: + """Get all projects for a feature instance with optional pagination.""" + mandateId = await _validateInstanceAccess(instanceId, context) + interface = getRealEstateInterface( + context.user, mandateId=mandateId, featureInstanceId=instanceId + ) + recordFilter = {"featureInstanceId": instanceId} + items = interface.getProjekte(recordFilter=recordFilter) + paginationParams = _parsePagination(pagination) + if paginationParams: + if paginationParams.sort: + for sort_field in reversed(paginationParams.sort): + field_name = sort_field.field + direction = sort_field.direction.lower() + items.sort( + key=lambda x: getattr(x, field_name, None), + reverse=(direction == "desc") + ) + total_items = len(items) + total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize + start_idx = (paginationParams.page - 1) * paginationParams.pageSize + end_idx = start_idx + paginationParams.pageSize + paginated_items = items[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") +async 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 = await _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") +async 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 = await _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") +async 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 = await _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="Update failed") + return updated + + +@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit("30/minute") +async 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 = await _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="Delete failed") + + +# ----- Parcels CRUD ----- + +@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle]) +@limiter.limit("30/minute") +async def get_parcels( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), + context: RequestContext = Depends(getRequestContext) +) -> PaginatedResponse[Parzelle]: + """Get all parcels for a feature instance with optional pagination.""" + mandateId = await _validateInstanceAccess(instanceId, context) + interface = getRealEstateInterface( + context.user, mandateId=mandateId, featureInstanceId=instanceId + ) + recordFilter = {"featureInstanceId": instanceId} + items = interface.getParzellen(recordFilter=recordFilter) + paginationParams = _parsePagination(pagination) + if paginationParams: + if paginationParams.sort: + for sort_field in reversed(paginationParams.sort): + field_name = sort_field.field + direction = sort_field.direction.lower() + items.sort( + key=lambda x: getattr(x, field_name, None), + reverse=(direction == "desc") + ) + total_items = len(items) + total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize + start_idx = (paginationParams.page - 1) * paginationParams.pageSize + end_idx = start_idx + paginationParams.pageSize + paginated_items = items[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") +async 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 = await _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") +async 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 = await _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") +async 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 = await _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="Update failed") + return updated + + +@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit("30/minute") +async 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 = await _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="Delete failed") + + +# ============================================================================ +# LEGACY / STATELESS ROUTES (unchanged) +# ============================================================================ + @router.post("/command", response_model=Dict[str, Any]) @limiter.limit("120/minute") async def process_command( diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py new file mode 100644 index 00000000..3f870957 --- /dev/null +++ b/modules/routes/routeRealEstate.py @@ -0,0 +1,1562 @@ +""" +Real Estate routes for the backend API. +Implements stateless endpoints for real estate database operations with AI-powered natural language processing. +""" + +import logging +import json +import requests +from typing import Optional, Dict, Any, List, Union +from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status + +# Import auth modules +from modules.auth import limiter, getCurrentUser + +# Import models +<<<<<<< Updated upstream:modules/routes/routeRealEstate.py +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata +from modules.datamodels.datamodelRealEstate import ( +======= +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 ( +>>>>>>> Stashed changes:modules/features/realEstate/routeFeatureRealEstate.py + Projekt, + Parzelle, + Dokument, + Gemeinde, + Kanton, + Land, + Kontext, + StatusProzess, +) + +# Import interfaces +from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface + +# Import feature logic for AI-powered commands +from modules.features.realEstate.mainRealEstate import ( + processNaturalLanguageCommand, + create_project_with_parcel_data, +) + +# Import Swiss Topo MapServer connector for testing +from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector + +# Import attribute utilities for model schema +from modules.shared.attributeUtils import getModelAttributeDefinitions + +# 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 + + +async 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.isSysAdmin: + 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, +} + + +# ============================================================================ +# INSTANCE-ID ROUTES (backend-driven, analog to Trustee) +# ============================================================================ + +@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async 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.""" + await _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") +async 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 = await _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") +async 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 = await _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") +async def get_projects( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), + context: RequestContext = Depends(getRequestContext) +) -> PaginatedResponse[Projekt]: + """Get all projects for a feature instance with optional pagination.""" + mandateId = await _validateInstanceAccess(instanceId, context) + interface = getRealEstateInterface( + context.user, mandateId=mandateId, featureInstanceId=instanceId + ) + recordFilter = {"featureInstanceId": instanceId} + items = interface.getProjekte(recordFilter=recordFilter) + paginationParams = _parsePagination(pagination) + if paginationParams: + if paginationParams.sort: + for sort_field in reversed(paginationParams.sort): + field_name = sort_field.field + direction = sort_field.direction.lower() + items.sort( + key=lambda x: getattr(x, field_name, None), + reverse=(direction == "desc") + ) + total_items = len(items) + total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize + start_idx = (paginationParams.page - 1) * paginationParams.pageSize + end_idx = start_idx + paginationParams.pageSize + paginated_items = items[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") +async 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 = await _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") +async 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 = await _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") +async 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 = await _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="Update failed") + return updated + + +@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit("30/minute") +async 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 = await _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="Delete failed") + + +# ----- Parcels CRUD ----- + +@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle]) +@limiter.limit("30/minute") +async def get_parcels( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), + context: RequestContext = Depends(getRequestContext) +) -> PaginatedResponse[Parzelle]: + """Get all parcels for a feature instance with optional pagination.""" + mandateId = await _validateInstanceAccess(instanceId, context) + interface = getRealEstateInterface( + context.user, mandateId=mandateId, featureInstanceId=instanceId + ) + recordFilter = {"featureInstanceId": instanceId} + items = interface.getParzellen(recordFilter=recordFilter) + paginationParams = _parsePagination(pagination) + if paginationParams: + if paginationParams.sort: + for sort_field in reversed(paginationParams.sort): + field_name = sort_field.field + direction = sort_field.direction.lower() + items.sort( + key=lambda x: getattr(x, field_name, None), + reverse=(direction == "desc") + ) + total_items = len(items) + total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize + start_idx = (paginationParams.page - 1) * paginationParams.pageSize + end_idx = start_idx + paginationParams.pageSize + paginated_items = items[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") +async 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 = await _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") +async 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 = await _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") +async 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 = await _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="Update failed") + return updated + + +@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT) +@limiter.limit("30/minute") +async 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 = await _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="Delete failed") + + +# ============================================================================ +# 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"), + currentUser: User = Depends(getCurrentUser) +) -> 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. + + Example user inputs: + - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" + - "Zeige mir alle Projekte in Zürich" + - "Aktualisiere Projekt XYZ mit Status 'Planung'" + - "Lösche Parzelle ABC" + - "SELECT * FROM Projekt WHERE plz = '8000'" + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Returns: + { + "success": true, + "intent": "CREATE|READ|UPDATE|DELETE|QUERY", + "entity": "Projekt|Parzelle|...|null", + "result": {...} + } + """ + try: + # Validate CSRF token (middleware also checks, but explicit validation for better error messages) + 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 POST /api/realestate/command from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"User input: {userInput}") + + # Process natural language command with AI + result = await processNaturalLanguageCommand( + currentUser=currentUser, + 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") +async def get_available_tables( + request: Request, + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Get all available real estate tables. + + Returns a list of available table names with their descriptions. + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Example: + - GET /api/realestate/tables + """ + try: + # Validate CSRF token if provided + 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 GET /api/realestate/tables from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})") + + # Define available tables with descriptions + 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") +async 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"), + currentUser: User = Depends(getCurrentUser) +) -> PaginatedResponse[Dict[str, Any]]: + """ + Get all data from a specific real estate table with optional pagination. + + Available tables: + - Projekt: Real estate projects + - Parzelle: Plots/parcels + - Dokument: Documents + - Gemeinde: Municipalities + - Kanton: Cantons + - Land: Countries + + Query Parameters: + - pagination: JSON-encoded PaginationParams object, or None for no pagination + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Examples: + - GET /api/realestate/table/Projekt (no pagination - returns all items) + - GET /api/realestate/table/Parzelle?pagination={"page":1,"pageSize":10,"sort":[]} + - GET /api/realestate/table/Gemeinde?pagination={"page":2,"pageSize":20,"sort":[{"field":"label","direction":"asc"}]} + """ + try: + # Validate CSRF token if provided + 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 GET /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})") + + # Map table names to model classes and getter methods + table_mapping = { + "Projekt": (Projekt, "getProjekte"), + "Parzelle": (Parzelle, "getParzellen"), + "Dokument": (Dokument, "getDokumente"), + "Gemeinde": (Gemeinde, "getGemeinden"), + "Kanton": (Kanton, "getKantone"), + "Land": (Land, "getLaender"), + } + + # Validate table name + if table not in table_mapping: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" + ) + + # Get interface and fetch data + realEstateInterface = getRealEstateInterface(currentUser) + model_class, method_name = table_mapping[table] + getter_method = getattr(realEstateInterface, method_name) + + # Fetch all records (no filter for now) + records = getter_method(recordFilter=None) + + # Keep records as model instances (like routeDataFiles does with FileItem) + # FastAPI will automatically serialize Pydantic models to JSON + items = records + + # Parse pagination parameter + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + paginationParams = PaginationParams(**paginationDict) if paginationDict else None + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid pagination parameter: {str(e)}" + ) + + # Apply pagination if requested + if paginationParams: + # Apply sorting if specified + if paginationParams.sort: + for sort_field in reversed(paginationParams.sort): # Reverse to apply in priority order + field_name = sort_field.field + direction = sort_field.direction.lower() + + def sort_key(item): + # Access attribute from model instance + value = getattr(item, field_name, None) + # Handle None values - put them at the end for asc, at the start for desc + if value is None: + return (1, None) # Use tuple to ensure None values sort consistently + return (0, value) + + items.sort(key=sort_key, reverse=(direction == "desc")) + + # Apply pagination + total_items = len(items) + total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize # Ceiling division + start_idx = (paginationParams.page - 1) * paginationParams.pageSize + end_idx = start_idx + paginationParams.pageSize + paginated_items = items[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, + filters=paginationParams.filters + ) + ) + else: + # No pagination - return all items (as model instances, like routeDataFiles) + return PaginatedResponse( + items=items, + pagination=None + ) + + 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"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Create a new record in a specific real estate table. + + Available tables: + - Projekt: Real estate projects (with parcel data support) + - Parzelle: Plots/parcels + - Dokument: Documents + - Gemeinde: Municipalities + - Kanton: Cantons + - Land: Countries + + Request Body: + For Projekt: + { + "label": "Projekt Bezeichnung", + "statusProzess": "Eingang", // Optional + "parzelle": { + "id": "OE5913", + "egrid": "CH252699779137", + "perimeter": {...}, + "geometry": {...}, // Used for baulinie + ... + } + } + + For other tables: + - JSON object with fields matching the table's data model + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Examples: + - POST /api/realestate/table/Projekt + Body: {"label": "Hauptstrasse 42", "parzelle": {...}} + - POST /api/realestate/table/Parzelle + Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"} + """ + try: + # Validate CSRF token + 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 POST /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Basic CSRF token format validation + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Validate token is hex string + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + # Special handling for Projekt with parcel data + if table == "Projekt" and ("parzelle" in data or "parzellen" in data): + logger.info(f"Creating Projekt with parcel data for user {currentUser.id} (mandate: {currentUser.mandateId})") + + # Extract fields + label = data.get("label") + if not label: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="label is required" + ) + + status_prozess = data.get("statusProzess", "Eingang") + + # Support both single parzelle and multiple parzellen + parzellen_data = [] + if "parzellen" in data: + # Multiple parcels + parzellen_data = data.get("parzellen", []) + if not isinstance(parzellen_data, list): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="parzellen must be an array" + ) + elif "parzelle" in data: + # Single parcel (backward compatibility) + parzelle_data = data.get("parzelle") + if parzelle_data: + parzellen_data = [parzelle_data] + + if not parzellen_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="parzelle or parzellen data is required" + ) + + # Use helper function to create project with parcel data + try: + result = await create_project_with_parcel_data( + currentUser=currentUser, + projekt_label=label, + parzellen_data=parzellen_data, + status_prozess=status_prozess, + ) + + # Return in format expected by frontend (single record, not nested) + return result.get("projekt", {}) + except HTTPException: + # Re-raise HTTPExceptions directly + raise + except Exception as e: + logger.error(f"Error creating Projekt with parcel data: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating Projekt: {str(e)}" + ) + + # Standard handling for other tables or Projekt without parcel data + logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"Record data: {data}") + + # Map table names to model classes and create methods + table_mapping = { + "Projekt": (Projekt, "createProjekt"), + "Parzelle": (Parzelle, "createParzelle"), + "Dokument": (Dokument, "createDokument"), + "Gemeinde": (Gemeinde, "createGemeinde"), + "Kanton": (Kanton, "createKanton"), + "Land": (Land, "createLand"), + } + + # Validate table name + if table not in table_mapping: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" + ) + + # Get interface + realEstateInterface = getRealEstateInterface(currentUser) + model_class, method_name = table_mapping[table] + create_method = getattr(realEstateInterface, method_name) + + # Ensure mandateId is set (will be set by interface if missing) + if "mandateId" not in data: + data["mandateId"] = currentUser.mandateId + + # Create model instance from data + try: + model_instance = model_class(**data) + except Exception as e: + logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid data for {table}: {str(e)}" + ) + + # Create record + try: + created_record = create_method(model_instance) + + # Convert to dictionary for response + if hasattr(created_record, 'model_dump'): + return created_record.model_dump() + else: + return created_record + + except Exception as e: + logger.error(f"Error creating {table} record: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating {table} record: {str(e)}" + ) + + 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/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"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Search for parcel information by address or coordinates. + + Returns comprehensive parcel information including: + - Parcel identification (number, EGRID, etc.) + - Precise boundary geometry for map display + - Administrative context (canton, municipality) + - Link to official cadastral map + - Optional: Adjacent parcels + + Query Parameters: + - location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string + - include_adjacent: If true, fetches information about adjacent parcels (slower) + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Examples: + - GET /api/realestate/parcel/search?location=2600000,1200000 + - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern + - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&include_adjacent=true + + Returns: + { + "parcel": { + "id": "823", + "egrid": "CH294676423526", + "number": "823", + "name": "823", + "identnd": "BE0200000042", + "canton": "BE", + "municipality_code": 351, + "municipality_name": "Bern", + "address": "Bundesplatz 3 3011 Bern", + "plz": "3011", + "perimeter": {...}, + "area_m2": 1234.56, + "centroid": {"x": 2600000, "y": 1200000}, + "geoportal_url": "https://...", + "realestate_type": null + }, + "map_view": { + "center": {"x": 2600000, "y": 1200000}, + "zoom_bounds": {"min_x": ..., "max_x": ..., "min_y": ..., "max_y": ...}, + "geometry_geojson": {...} + }, + "adjacent_parcels": [...] // Optional (only if include_adjacent=true) + } + """ + try: + # Validate CSRF token + 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 GET /api/realestate/parcel/search from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}") + + # Initialize connector + connector = SwissTopoMapServerConnector() + + # Search for parcel + parcel_data = await connector.search_parcel(location) + + if not parcel_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No parcel found for location: {location}" + ) + + # Extract and normalize attributes + extracted_attributes = connector.extract_parcel_attributes(parcel_data) + attributes = parcel_data.get("attributes", {}) + geometry = parcel_data.get("geometry", {}) + + # Calculate parcel area from perimeter + area_m2 = None + centroid = None + if extracted_attributes.get("perimeter"): + perimeter = extracted_attributes["perimeter"] + points = perimeter.get("punkte", []) + + # Calculate area using shoelace formula + if len(points) >= 3: + area = 0 + for i in range(len(points)): + j = (i + 1) % len(points) + area += points[i]["x"] * points[j]["y"] + area -= points[j]["x"] * points[i]["y"] + area_m2 = abs(area / 2) + + # Calculate centroid + sum_x = sum(p["x"] for p in points) + sum_y = sum(p["y"] for p in points) + centroid = { + "x": sum_x / len(points), + "y": sum_y / len(points) + } + + # Extract municipality name and address from Swiss Topo data + municipality_name = None + full_address = None + plz = None + + # First, try to use geocoded address info if available (more accurate than centroid query) + geocoded_address = parcel_data.get('geocoded_address') + if geocoded_address: + full_address = geocoded_address.get('full_address') + plz = geocoded_address.get('plz') + municipality_name = geocoded_address.get('municipality') + logger.debug(f"Using geocoded address: {full_address}") + + # If geocoded address not available, try to get address by querying the address layer + # Use query coordinates (where user clicked/geocoded) instead of parcel centroid + # This ensures we get the address at the exact location, not at the parcel center + query_coords = parcel_data.get('query_coordinates') + address_query_coords = query_coords if query_coords else centroid + + if not full_address and address_query_coords: + query_x = address_query_coords['x'] + query_y = address_query_coords['y'] + logger.debug(f"Querying address layer at query coordinates: ({query_x}, {query_y})") + + # Check if this was a coordinate search (not geocoded address) + is_coordinate_search = ',' in location and not any(c.isalpha() for c in location.split(',')[0]) + + # Use connector's helper method to query building layer + # Use tolerance=1 (minimum) for coordinate searches to get exact building + building_tolerance = 1 if is_coordinate_search else 10 + building_result = await connector._query_building_layer(query_x, query_y, tolerance=building_tolerance, buffer=25) + + if building_result: + addr_attrs = building_result.get("attributes", {}) + logger.debug(f"Address layer attributes: {addr_attrs}") + + # Extract address using connector's helper method + address_info = connector._extract_address_from_building_attrs(addr_attrs) + full_address = address_info.get('full_address') + plz = address_info.get('plz') + municipality_name = address_info.get('municipality') + + if full_address: + logger.debug(f"Constructed address: {full_address}") + + # If address not found via building layer, try to construct from available data + if not full_address: + # Check if location was provided as an address string + if location and any(c.isalpha() for c in location) and "CH" not in location: + # Location looks like an address (not an EGRID) + full_address = location + logger.debug(f"Using location as address: {full_address}") + + # Try to extract municipality name from BFSNR if not found + if not municipality_name: + # Common Swiss municipalities lookup (you can expand this) + bfsnr = attributes.get("bfsnr") + canton = attributes.get("ak", "") + + # Basic municipality lookup for common codes + common_municipalities = { + 351: "Bern", + 261: "Zürich", + 6621: "Genève", + 2701: "Basel", + 5586: "Lausanne", + 1061: "Luzern", + 3203: "Winterthur", + 230: "St. Gallen", + 5192: "Lugano", + 351: "Bern", + 1367: "Schwyz" + } + + if bfsnr and bfsnr in common_municipalities: + municipality_name = common_municipalities[bfsnr] + logger.debug(f"Looked up municipality: {municipality_name}") + else: + # Fallback: Use canton + code + municipality_name = f"{canton}-{bfsnr}" if canton and bfsnr else "Unknown" + logger.debug(f"Using fallback municipality: {municipality_name}") + + # Final validation: Don't use EGRID as address + if full_address and full_address.startswith("CH") and len(full_address) == 14 and full_address[2:].isdigit(): + # This is an EGRID, not an address + full_address = None + logger.debug("Removed EGRID from address field") + + # Build parcel info + parcel_info = { + "id": attributes.get("label") or attributes.get("number"), + "egrid": attributes.get("egris_egrid"), + "number": attributes.get("number"), + "name": attributes.get("name"), + "identnd": attributes.get("identnd"), + "canton": attributes.get("ak"), + "municipality_code": attributes.get("bfsnr"), + "municipality_name": municipality_name, + "address": full_address, + "plz": plz, + "perimeter": extracted_attributes.get("perimeter"), + "area_m2": area_m2, + "centroid": centroid, + "geoportal_url": attributes.get("geoportal_url"), + "realestate_type": attributes.get("realestate_type") + } + + # Build map view info + bbox = parcel_data.get("bbox", []) + map_view = { + "center": centroid, + "zoom_bounds": { + "min_x": bbox[0] if len(bbox) >= 4 else None, + "min_y": bbox[1] if len(bbox) >= 4 else None, + "max_x": bbox[2] if len(bbox) >= 4 else None, + "max_y": bbox[3] if len(bbox) >= 4 else None + }, + "geometry_geojson": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[p["x"], p["y"]] for p in extracted_attributes["perimeter"]["punkte"]] + ] if extracted_attributes.get("perimeter") else [] + }, + "properties": { + "id": parcel_info["id"], + "egrid": parcel_info["egrid"], + "number": parcel_info["number"] + } + } + } + + # Build response + response_data = { + "parcel": parcel_info, + "map_view": map_view + } + + # Fetch adjacent parcels if requested + if include_adjacent and parcel_data and parcel_data.get("geometry"): + try: + # Use the connector's method to find neighboring parcels by sampling along the boundary + # This ensures we find all parcels that actually touch the selected parcel + selected_parcel_id = parcel_info["id"] + adjacent_parcels_raw = await connector.find_neighboring_parcels( + parcel_data=parcel_data, + selected_parcel_id=selected_parcel_id, + sample_distance=20.0, # Sample every 20 meters (balanced for coverage and speed) + max_sample_points=30, # Allow up to 30 points to ensure all vertices are covered + max_neighbors=15, # Find up to 15 neighbors + max_concurrent=50 # Process up to 50 queries concurrently (maximum parallelization) + ) + + # Convert adjacent parcels to include GeoJSON geometry (optimized, minimal logging) + def convert_parcel_geometry(adj_parcel: Dict[str, Any]) -> Dict[str, Any]: + """Convert a single adjacent parcel to include GeoJSON geometry.""" + adj_parcel_with_geo = { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number"), + "perimeter": adj_parcel.get("perimeter") + } + + # Convert geometry to GeoJSON format if available + adj_geometry = adj_parcel.get("geometry") + adj_perimeter = adj_parcel.get("perimeter") + + if adj_geometry: + # Handle ESRI format (rings) + if "rings" in adj_geometry and adj_geometry["rings"]: + ring = adj_geometry["rings"][0] # Outer ring + coordinates = [[[p[0], p[1]] for p in ring]] + adj_parcel_with_geo["geometry_geojson"] = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": coordinates + }, + "properties": { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number") + } + } + # Handle GeoJSON format + elif adj_geometry.get("type") == "Polygon": + adj_parcel_with_geo["geometry_geojson"] = { + "type": "Feature", + "geometry": adj_geometry, + "properties": { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number") + } + } + + # If no geometry_geojson was created but we have perimeter, create it from perimeter + if "geometry_geojson" not in adj_parcel_with_geo and adj_perimeter and adj_perimeter.get("punkte"): + punkte = adj_perimeter["punkte"] + coordinates = [[[p["x"], p["y"]] for p in punkte]] + adj_parcel_with_geo["geometry_geojson"] = { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": coordinates + }, + "properties": { + "id": adj_parcel["id"], + "egrid": adj_parcel.get("egrid"), + "number": adj_parcel.get("number") + } + } + + return adj_parcel_with_geo + + # Convert all parcels in parallel (using list comprehension for speed) + adjacent_parcels = [convert_parcel_geometry(adj_parcel) for adj_parcel in adjacent_parcels_raw] + + response_data["adjacent_parcels"] = adjacent_parcels + logger.info(f"Found {len(adjacent_parcels)} neighboring parcels for parcel {selected_parcel_id}") + + except Exception as e: + logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True) + response_data["adjacent_parcels"] = [] + + return response_data + + 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("/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(...), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """ + Add a parcel to an existing project. + + This endpoint can either: + 1. Link an existing Parzelle to the Projekt + 2. Create a new Parzelle from location data and link it + + Request Body: + Option 1 - Link existing parcel: + { + "parcelId": "existing-parcel-id" + } + + Option 2 - Create new parcel from location: + { + "location": "Hauptstrasse 42, 8000 Zürich" + } + + Option 3 - Create new parcel with custom data: + { + "parcelData": { + "label": "Parzelle 123", + "strasseNr": "Hauptstrasse 42", + "plz": "8000", + "bauzone": "W3", + ... + } + } + + Headers: + - X-CSRF-Token: CSRF token (required for security) + + Returns: + { + "projekt": {...}, // Updated Projekt + "parzelle": {...} // Parcel that was added + } + """ + try: + # Validate CSRF token + 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 POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF token missing. Please include X-CSRF-Token header." + ) + + # Validate CSRF token format + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + try: + int(csrf_token, 16) + except ValueError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid CSRF token format" + ) + + logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})") + + # Get interface + realEstateInterface = getRealEstateInterface(currentUser) + + # Fetch existing Projekt + projekte = realEstateInterface.getProjekte( + recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId} + ) + if not projekte: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Projekt {projekt_id} not found" + ) + projekt = projekte[0] + + # Determine which option was used + parcel_id = body.get("parcelId") + location = body.get("location") + parcel_data_dict = body.get("parcelData") + + parzelle = None + + # Option 1: Link existing parcel + if parcel_id: + logger.info(f"Linking existing parcel {parcel_id}") + parcels = realEstateInterface.getParzellen( + recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId} + ) + if not parcels: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Parzelle {parcel_id} not found" + ) + parzelle = parcels[0] + + # Option 2: Create from location + elif location: + logger.info(f"Creating parcel from location: {location}") + + # Initialize connector and search for parcel + connector = SwissTopoMapServerConnector() + parcel_data = await connector.search_parcel(location) + + if not parcel_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No parcel found at location: {location}" + ) + + # Extract attributes + extracted_attributes = connector.extract_parcel_attributes(parcel_data) + attributes = parcel_data.get("attributes", {}) + + # Create Parzelle + parzelle_create_data = { + "mandateId": currentUser.mandateId, + "label": extracted_attributes.get("label") or attributes.get("number") or "Unknown", + "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], + "eigentuemerschaft": None, + "strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None, + "plz": None, + "perimeter": extracted_attributes.get("perimeter"), + "baulinie": None, + "kontextGemeinde": None, + "bauzone": None, + "az": None, + "bz": None, + "vollgeschossZahl": None, + "anrechenbarDachgeschoss": None, + "anrechenbarUntergeschoss": None, + "gebaeudehoeheMax": None, + "regelnGrenzabstand": [], + "regelnMehrlaengenzuschlag": [], + "regelnMehrhoehenzuschlag": [], + "parzelleBebaut": None, + "parzelleErschlossen": None, + "parzelleHanglage": None, + "laermschutzzone": None, + "hochwasserschutzzone": None, + "grundwasserschutzzone": None, + "parzellenNachbarschaft": [], + "dokumente": [], + "kontextInformationen": [ + Kontext( + thema="Swiss Topo Data", + inhalt=json.dumps({ + "egrid": attributes.get("egris_egrid"), + "identnd": attributes.get("identnd"), + "canton": attributes.get("ak"), + "municipality_code": attributes.get("bfsnr"), + "geoportal_url": attributes.get("geoportal_url") + }, ensure_ascii=False) + ) + ] + } + + parzelle_instance = Parzelle(**parzelle_create_data) + parzelle = realEstateInterface.createParzelle(parzelle_instance) + + # Option 3: Create from custom data + elif parcel_data_dict: + logger.info(f"Creating parcel from custom data") + parcel_data_dict["mandateId"] = currentUser.mandateId + parzelle_instance = Parzelle(**parcel_data_dict) + parzelle = realEstateInterface.createParzelle(parzelle_instance) + + else: + raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required") + + # Add parcel to project + if parzelle not in projekt.parzellen: + projekt.parzellen.append(parzelle) + + # Update projekt perimeter if needed (use first parcel's perimeter) + if not projekt.perimeter and parzelle.perimeter: + projekt.perimeter = parzelle.perimeter + + # Update Projekt + updated_projekt = realEstateInterface.updateProjekt(projekt) + + logger.info(f"Added Parzelle {parzelle.id} to Projekt {projekt_id}") + + return { + "projekt": updated_projekt.model_dump(), + "parzelle": parzelle.model_dump() + } + + 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)}" + ) + diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 4e2f9f8f..b4a40fc2 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -106,7 +106,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: from modules.features.trustee.mainTrustee import UI_OBJECTS return UI_OBJECTS elif featureCode == "realestate": - from modules.features.realestate.mainRealEstate import UI_OBJECTS + from modules.features.realEstate.mainRealEstate import UI_OBJECTS return UI_OBJECTS else: logger.warning(f"Unknown feature code: {featureCode}") diff --git a/requirements.txt b/requirements.txt index a3b5f92c..28d61715 100644 --- a/requirements.txt +++ b/requirements.txt @@ -71,9 +71,6 @@ google-cloud-texttospeech==2.16.3 ## MSFT Integration msal==1.24.1 -## Azure Integration -azure-communication-email>=1.0.0 # Azure Communication Services Email - ## Testing Dependencies pytest>=8.0.0 pytest-asyncio>=0.21.0