1153 lines
46 KiB
Python
1153 lines
46 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
|
|
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
|
|
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,
|
|
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"}
|
|
}
|
|
)
|
|
|
|
|
|
@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)}"
|
|
)
|
|
|