""" 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.security.auth import limiter, getCurrentUser # Import models from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata from modules.datamodels.datamodelRealEstate import ( 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 # 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"} } ) @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 # If table is empty, create an empty instance with all fields set to None/empty # This allows the frontend to extract column structure from the response # All fields will be None/empty - no IDs or other values generated if not items: try: # Get all model fields model_fields = model_class.model_fields empty_values = {} # Set all fields to None - explicitly set every field to None # This ensures no default_factory is called and no IDs are generated for field_name in model_fields.keys(): empty_values[field_name] = None # Create instance with all None values # Use model_validate with allow_none=True or construct directly empty_instance = model_class.model_construct(**empty_values) items = [empty_instance] logger.debug(f"Created empty instance for {table} with all fields set to None") except Exception as e: logger.warning(f"Could not create empty instance for {table}: {str(e)}. Returning empty list.") items = [] # 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 - Parzelle: Plots/parcels - Dokument: Documents - Gemeinde: Municipalities - Kanton: Cantons - Land: Countries Request Body: - 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", "statusProzess": "Eingang"} - 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" ) 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 # Try to get address by querying the address layer at the parcel centroid if centroid: try: import aiohttp # Use MapServer identify on address layer to get actual address identify_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" # Calculate extent around centroid buffer = 100 # 100m buffer map_extent = f"{centroid['x'] - buffer},{centroid['y'] - buffer},{centroid['x'] + buffer},{centroid['y'] + buffer}" params = { "geometry": f"{centroid['x']},{centroid['y']}", "geometryType": "esriGeometryPoint", "sr": "2056", "layers": "all:ch.bfs.gebaeude_wohnungs_register", # Building/address layer "tolerance": 50, "mapExtent": map_extent, "imageDisplay": "800,600,96", "returnGeometry": "false", "f": "json" } logger.debug(f"Querying address layer at centroid: ({centroid['x']}, {centroid['y']})") async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: async with session.get(identify_url, params=params) as response: if response.status == 200: address_data = await response.json() address_results = address_data.get("results", []) if address_results: # Get first address result addr_attrs = address_results[0].get("attributes", {}) logger.debug(f"Address layer attributes: {addr_attrs}") # Extract address components (note: strname is a list!) street_list = addr_attrs.get("strname", []) street = street_list[0] if isinstance(street_list, list) and street_list else None house_number = addr_attrs.get("deinr") plz = addr_attrs.get("dplz4") municipality_name = addr_attrs.get("dplzname") or addr_attrs.get("ggdename") # Clean municipality name (remove canton suffix like "Wald ZH" -> "Wald") if municipality_name and " " in municipality_name: # Format is often "City ZH" or "City (ZH)" municipality_name = municipality_name.split("(")[0].strip() # Remove canton code at the end if present parts = municipality_name.split() if len(parts) > 1 and len(parts[-1]) == 2 and parts[-1].isupper(): municipality_name = " ".join(parts[:-1]) # Construct full address if street and house_number and plz and municipality_name: full_address = f"{street} {house_number}, {plz} {municipality_name}" logger.debug(f"Constructed address: {full_address}") else: logger.debug("No address results from building layer") else: logger.debug(f"Address identify returned status {response.status}") except Exception as e: logger.debug(f"Could not query address layer: {e}") # 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 centroid: try: # Search in a radius around the parcel centroid # Note: This is a simplified approach - may need refinement adjacent_parcels = [] # Search in 4 directions from centroid (N, S, E, W) search_distance = 50 # meters search_coords = [ (centroid["x"], centroid["y"] + search_distance), # North (centroid["x"], centroid["y"] - search_distance), # South (centroid["x"] + search_distance, centroid["y"]), # East (centroid["x"] - search_distance, centroid["y"]), # West ] for coord in search_coords: try: adj_data = await connector.get_parcel_info(coord[0], coord[1], tolerance=5) if adj_data: adj_attrs = adj_data.get("attributes", {}) adj_id = adj_attrs.get("label") or adj_attrs.get("number") # Don't include the same parcel if adj_id != parcel_info["id"]: # Check if already in list if not any(p["id"] == adj_id for p in adjacent_parcels): adjacent_parcels.append({ "id": adj_id, "egrid": adj_attrs.get("egris_egrid"), "number": adj_attrs.get("number") }) except Exception as e: logger.debug(f"No adjacent parcel found at {coord}: {e}") continue response_data["adjacent_parcels"] = adjacent_parcels logger.info(f"Found {len(adjacent_parcels)} adjacent parcels") except Exception as e: logger.warning(f"Error fetching adjacent parcels: {e}") 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/create", response_model=Dict[str, Any]) @limiter.limit("60/minute") async def create_project( request: Request, body: Dict[str, Any] = Body(...), currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """ Create a new real estate project (Projekt). Request Body: { "label": "Projekt Hauptstrasse 42", "statusProzess": "Eingang", // Optional "location": "Hauptstrasse 42, 8000 Zürich", // Optional: auto-create parcel "parcelIds": ["parcel-id-1", "parcel-id-2"] // Optional: link existing parcels } Headers: - X-CSRF-Token: CSRF token (required for security) If 'location' is provided, the system will: 1. Search for the parcel at that location 2. Create a Parzelle record 3. Create a Projekt and link the Parzelle 4. Return both Projekt and Parzelle IDs Returns: { "projekt": {...}, "parzellen": [...] // Parcels that were created or linked } """ 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/create 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"Creating project for user {currentUser.id} (mandate: {currentUser.mandateId})") # Extract fields from body label = body.get("label") if not label: raise ValueError("label is required") status_prozess = body.get("statusProzess", "Eingang") location = body.get("location") parcel_ids = body.get("parcelIds", []) # Get interface realEstateInterface = getRealEstateInterface(currentUser) # Handle auto-create parcel from location created_parcels = [] if location: logger.info(f"Auto-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_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) ) ] } # Create Parzelle instance parzelle_instance = Parzelle(**parzelle_data) created_parzelle = realEstateInterface.createParzelle(parzelle_instance) created_parcels.append(created_parzelle) parcel_ids.append(created_parzelle.id) logger.info(f"Created Parzelle {created_parzelle.id} from location") # Fetch existing parcels if IDs provided existing_parcels = [] if parcel_ids: for parcel_id in parcel_ids: parcels = realEstateInterface.getParzellen( recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId} ) if parcels: existing_parcels.append(parcels[0]) # Calculate project perimeter from parcels all_parcels = created_parcels + existing_parcels projekt_perimeter = None if all_parcels: # Use first parcel's perimeter as project perimeter # In a real system, you might want to merge all parcel perimeters projekt_perimeter = all_parcels[0].perimeter # Create Projekt projekt_data = { "mandateId": currentUser.mandateId, "label": label, "statusProzess": status_prozess, "perimeter": projekt_perimeter, "baulinie": None, "parzellen": all_parcels, "dokumente": [], "kontextInformationen": [] } projekt_instance = Projekt(**projekt_data) created_projekt = realEstateInterface.createProjekt(projekt_instance) logger.info(f"Created Projekt {created_projekt.id} with {len(all_parcels)} parcels") return { "projekt": created_projekt.model_dump(), "parzellen": [p.model_dump() for p in all_parcels] } except ValueError as e: logger.error(f"Validation error in create_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 creating project: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error creating project: {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)}" )