""" 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 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, ) # Import interfaces from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface # Import feature logic from modules.features.realEstate.mainRealEstate import ( processNaturalLanguageCommand, executeDirectQuery, ) # 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.post("/query", response_model=Dict[str, Any]) @limiter.limit("120/minute") async def execute_query( request: Request, body: Dict[str, Any] = Body(...), currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: """ Execute a direct SQL query without session management. Executes the query directly and returns the result. No query history is saved. Request body: { "queryText": "SELECT * FROM Projekt WHERE plz = '8000'", "parameters": { // Optional "$1": "8000" } } Headers: - X-CSRF-Token: CSRF token (required for security) WARNING: This endpoint executes raw SQL queries. Ensure proper validation and sanitization on the frontend. Consider implementing query whitelisting or only allowing SELECT statements for production use. Returns: { "status": "success", "rows": [...], "columns": [...], "rowCount": 15, "executionTime": 0.123 } """ 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/query 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/query 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/query from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Invalid CSRF token format" ) # Extract fields from body queryText = body.get("queryText") if not queryText: raise ValueError("queryText is required") parameters = body.get("parameters") logger.info(f"Processing query request from user {currentUser.id} (mandate: {currentUser.mandateId})") logger.debug(f"Query text: {queryText}") if parameters: logger.debug(f"Query parameters: {parameters}") # Execute direct query result = await executeDirectQuery( currentUser=currentUser, queryText=queryText, parameters=parameters, ) return result except ValueError as e: logger.error(f"Validation error in execute_query: {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 executing query: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error executing query: {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)}" )