gateway/modules/routes/routeRealEstate.py

637 lines
24 KiB
Python

"""
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)}"
)