2340 lines
111 KiB
Python
2340 lines
111 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 re
|
|
import requests
|
|
import aiohttp
|
|
import asyncio
|
|
import ssl
|
|
from urllib.parse import urljoin, urlparse
|
|
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.datamodelPagination import (
|
|
PaginationParams,
|
|
PaginatedResponse,
|
|
PaginationMetadata,
|
|
normalize_pagination_dict,
|
|
)
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from modules.features.realEstate.datamodelFeatureRealEstate import (
|
|
Projekt,
|
|
Parzelle,
|
|
Dokument,
|
|
Gemeinde,
|
|
Kanton,
|
|
Land,
|
|
Kontext,
|
|
StatusProzess,
|
|
DokumentTyp,
|
|
)
|
|
|
|
# Import interfaces
|
|
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
|
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
|
|
|
# Import feature logic for AI-powered commands
|
|
from modules.features.realEstate.mainRealEstate import (
|
|
processNaturalLanguageCommand,
|
|
create_project_with_parcel_data,
|
|
extract_bzo_information,
|
|
)
|
|
|
|
# Import Swiss Topo MapServer connector for testing
|
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
|
from modules.connectors.connectorOerebWfs import OerebWfsConnector
|
|
|
|
# Import Tavily connector for BZO document search
|
|
from modules.aicore.aicorePluginTavily import AiTavily
|
|
|
|
# Import helper functions from scraping route
|
|
from modules.routes.routeRealEstateScraping import (
|
|
_get_language_from_kanton,
|
|
_get_bzo_search_query,
|
|
)
|
|
|
|
# Import attribute utilities for model schema
|
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
routeApiMsg = apiRouteContext("routeRealEstate")
|
|
|
|
# Configure logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Create router for real estate endpoints
|
|
router = APIRouter(
|
|
prefix="/api/realestate",
|
|
tags=["Real Estate"],
|
|
responses={
|
|
404: {"description": "Not found"},
|
|
400: {"description": "Bad request"},
|
|
401: {"description": "Unauthorized"},
|
|
403: {"description": "Forbidden"},
|
|
500: {"description": "Internal server error"}
|
|
}
|
|
)
|
|
|
|
|
|
# ===== Helper Functions (instanceId-based routes, backend-driven like Trustee) =====
|
|
|
|
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
|
|
"""Parse pagination parameter from JSON string."""
|
|
if not pagination:
|
|
return None
|
|
try:
|
|
paginationDict = json.loads(pagination)
|
|
if paginationDict:
|
|
paginationDict = normalize_pagination_dict(paginationDict)
|
|
return PaginationParams(**paginationDict)
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid pagination parameter: {str(e)}"
|
|
)
|
|
return None
|
|
|
|
|
|
async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
|
"""
|
|
Validate that the user has access to the feature instance.
|
|
Returns the mandateId for the instance.
|
|
"""
|
|
rootInterface = getRootInterface()
|
|
featureInterface = getFeatureInterface(rootInterface.db)
|
|
instance = featureInterface.getFeatureInstance(instanceId)
|
|
if not instance:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Feature instance '{instanceId}' not found"
|
|
)
|
|
if instance.featureCode != "realestate":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Instance '{instanceId}' is not a realestate instance"
|
|
)
|
|
if not context.hasSysAdminRole:
|
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
|
hasAccess = any(
|
|
str(fa.featureInstanceId) == instanceId and fa.enabled
|
|
for fa in featureAccesses
|
|
)
|
|
if not hasAccess:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Access denied to feature instance '{instanceId}'"
|
|
)
|
|
return str(instance.mandateId)
|
|
|
|
|
|
# Mapping of entity names to Pydantic model classes (for attributes endpoint)
|
|
_REALESTATE_ENTITY_MODELS = {
|
|
"Projekt": Projekt,
|
|
"Parzelle": Parzelle,
|
|
"Dokument": Dokument,
|
|
"Gemeinde": Gemeinde,
|
|
"Kanton": Kanton,
|
|
"Land": Land,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# INSTANCE-ID ROUTES (backend-driven, analog to Trustee)
|
|
# ============================================================================
|
|
|
|
@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
async def get_entity_attributes(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
entityType: str = Path(..., description="Entity type (e.g., Projekt, Parzelle)"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Dict[str, Any]:
|
|
"""Get attribute definitions for a Real Estate entity. Used by FormGeneratorTable."""
|
|
await _validateInstanceAccess(instanceId, context)
|
|
if entityType not in _REALESTATE_ENTITY_MODELS:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Unknown entity type: {entityType}. Valid types: {list(_REALESTATE_ENTITY_MODELS.keys())}"
|
|
)
|
|
modelClass = _REALESTATE_ENTITY_MODELS[entityType]
|
|
try:
|
|
attrDefs = getModelAttributeDefinitions(modelClass)
|
|
visibleAttrs = [
|
|
attr for attr in attrDefs.get("attributes", [])
|
|
if isinstance(attr, dict) and attr.get("visible", True)
|
|
]
|
|
return {"attributes": visibleAttrs}
|
|
except Exception as e:
|
|
logger.error(f"Error getting attributes for {entityType}: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Error getting attributes for {entityType}: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get("/{instanceId}/projects/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_project_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get project options for select dropdowns. Returns: [{ value, label }]"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
items = interface.getProjekte(recordFilter={"featureInstanceId": instanceId})
|
|
return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
|
|
|
|
|
|
@router.get("/{instanceId}/parcels/options", response_model=List[Dict[str, Any]])
|
|
@limiter.limit("60/minute")
|
|
async def get_parcel_options(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> List[Dict[str, Any]]:
|
|
"""Get parcel options for select dropdowns. Returns: [{ value, label }]"""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
items = interface.getParzellen(recordFilter={"featureInstanceId": instanceId})
|
|
return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
|
|
|
|
|
|
# ----- Projects CRUD -----
|
|
|
|
@router.get("/{instanceId}/projects", response_model=PaginatedResponse[Projekt])
|
|
@limiter.limit("30/minute")
|
|
async def get_projects(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[Projekt]:
|
|
"""Get all projects for a feature instance with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
recordFilter = {"featureInstanceId": instanceId}
|
|
paginationParams = _parsePagination(pagination)
|
|
if paginationParams:
|
|
result = interface.getProjekte(pagination=paginationParams, recordFilter=recordFilter)
|
|
if hasattr(result, 'items'):
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page,
|
|
pageSize=paginationParams.pageSize,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort or [],
|
|
filters=paginationParams.filters
|
|
)
|
|
)
|
|
items = interface.getProjekte(recordFilter=recordFilter)
|
|
return PaginatedResponse(items=items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
|
|
@limiter.limit("30/minute")
|
|
async def get_project_by_id(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
projectId: str = Path(..., description="Project ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Projekt:
|
|
"""Get a single project by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
projekt = interface.getProjekt(projectId)
|
|
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
|
|
return projekt
|
|
|
|
|
|
@router.post("/{instanceId}/projects", response_model=Projekt)
|
|
@limiter.limit("30/minute")
|
|
async def create_project(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: Dict[str, Any] = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Projekt:
|
|
"""Create a new project."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
if "mandateId" not in data:
|
|
data["mandateId"] = mandateId
|
|
if "featureInstanceId" not in data:
|
|
data["featureInstanceId"] = instanceId
|
|
try:
|
|
projekt = Projekt(**data)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
|
|
return interface.createProjekt(projekt)
|
|
|
|
|
|
@router.put("/{instanceId}/projects/{projectId}", response_model=Projekt)
|
|
@limiter.limit("30/minute")
|
|
async def update_project(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
projectId: str = Path(..., description="Project ID"),
|
|
data: Dict[str, Any] = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Projekt:
|
|
"""Update a project."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
projekt = interface.getProjekt(projectId)
|
|
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
|
|
updated = interface.updateProjekt(projectId, data)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
|
|
return updated
|
|
|
|
|
|
@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT)
|
|
@limiter.limit("30/minute")
|
|
async def delete_project(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
projectId: str = Path(..., description="Project ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> None:
|
|
"""Delete a project."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
projekt = interface.getProjekt(projectId)
|
|
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
|
|
if not interface.deleteProjekt(projectId):
|
|
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
|
|
|
|
|
|
# ----- Parcels CRUD -----
|
|
|
|
@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle])
|
|
@limiter.limit("30/minute")
|
|
async def get_parcels(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> PaginatedResponse[Parzelle]:
|
|
"""Get all parcels for a feature instance with optional pagination."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
recordFilter = {"featureInstanceId": instanceId}
|
|
paginationParams = _parsePagination(pagination)
|
|
if paginationParams:
|
|
result = interface.getParzellen(pagination=paginationParams, recordFilter=recordFilter)
|
|
if hasattr(result, 'items'):
|
|
return PaginatedResponse(
|
|
items=result.items,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page,
|
|
pageSize=paginationParams.pageSize,
|
|
totalItems=result.totalItems,
|
|
totalPages=result.totalPages,
|
|
sort=paginationParams.sort or [],
|
|
filters=paginationParams.filters
|
|
)
|
|
)
|
|
items = interface.getParzellen(recordFilter=recordFilter)
|
|
return PaginatedResponse(items=items, pagination=None)
|
|
|
|
|
|
@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
|
|
@limiter.limit("30/minute")
|
|
async def get_parcel_by_id(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
parcelId: str = Path(..., description="Parcel ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Parzelle:
|
|
"""Get a single parcel by ID."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
parzelle = interface.getParzelle(parcelId)
|
|
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
|
|
return parzelle
|
|
|
|
|
|
@router.post("/{instanceId}/parcels", response_model=Parzelle)
|
|
@limiter.limit("30/minute")
|
|
async def create_parcel(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
data: Dict[str, Any] = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Parzelle:
|
|
"""Create a new parcel."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
if "mandateId" not in data:
|
|
data["mandateId"] = mandateId
|
|
if "featureInstanceId" not in data:
|
|
data["featureInstanceId"] = instanceId
|
|
try:
|
|
parzelle = Parzelle(**data)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
|
|
return interface.createParzelle(parzelle)
|
|
|
|
|
|
@router.put("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
|
|
@limiter.limit("30/minute")
|
|
async def update_parcel(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
parcelId: str = Path(..., description="Parcel ID"),
|
|
data: Dict[str, Any] = Body(...),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> Parzelle:
|
|
"""Update a parcel."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
parzelle = interface.getParzelle(parcelId)
|
|
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
|
|
updated = interface.updateParzelle(parcelId, data)
|
|
if not updated:
|
|
raise HTTPException(status_code=500, detail=routeApiMsg("Update failed"))
|
|
return updated
|
|
|
|
|
|
@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT)
|
|
@limiter.limit("30/minute")
|
|
async def delete_parcel(
|
|
request: Request,
|
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
|
parcelId: str = Path(..., description="Parcel ID"),
|
|
context: RequestContext = Depends(getRequestContext)
|
|
) -> None:
|
|
"""Delete a parcel."""
|
|
mandateId = await _validateInstanceAccess(instanceId, context)
|
|
interface = getRealEstateInterface(
|
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
|
)
|
|
parzelle = interface.getParzelle(parcelId)
|
|
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
|
|
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
|
|
if not interface.deleteParzelle(parcelId):
|
|
raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed"))
|
|
|
|
|
|
# ============================================================================
|
|
# LEGACY / STATELESS ROUTES (unchanged)
|
|
# ============================================================================
|
|
|
|
@router.post("/command", response_model=Dict[str, Any])
|
|
@limiter.limit("120/minute")
|
|
async def process_command(
|
|
request: Request,
|
|
userInput: str = Body(..., embed=True, description="Natural language command"),
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Process natural language command and execute corresponding CRUD operation.
|
|
|
|
Uses AI to analyze user intent and extract parameters, then executes the appropriate
|
|
CRUD operation. Works stateless without session management.
|
|
|
|
Example user inputs:
|
|
- "Erstelle ein neues Projekt namens 'Hauptstrasse 42'"
|
|
- "Zeige mir alle Projekte in Zürich"
|
|
- "Aktualisiere Projekt XYZ mit Status 'Planung'"
|
|
- "Lösche Parzelle ABC"
|
|
- "SELECT * FROM Projekt WHERE plz = '8000'"
|
|
|
|
Headers:
|
|
- X-CSRF-Token: CSRF token (required for security)
|
|
|
|
Returns:
|
|
{
|
|
"success": true,
|
|
"intent": "CREATE|READ|UPDATE|DELETE|QUERY",
|
|
"entity": "Projekt|Parzelle|...|null",
|
|
"result": {...}
|
|
}
|
|
"""
|
|
try:
|
|
# Validate CSRF token (middleware also checks, but explicit validation for better error messages)
|
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
|
if not csrf_token:
|
|
logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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"),
|
|
fetch_documents: bool = Query(True, description="If true, fetch BZO documents for the Gemeinde (default: true)"),
|
|
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
|
|
- Optional: Gemeinde information and BZO documents (if fetch_documents=true)
|
|
|
|
Query Parameters:
|
|
- location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string
|
|
- include_adjacent: If true, fetches information about adjacent parcels (slower)
|
|
- fetch_documents: If true, checks for and fetches Bauzonenverordnung (BZO) documents for the Gemeinde (default: true, 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
|
|
- GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&fetch_documents=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,
|
|
"bauzone": "W3"
|
|
},
|
|
"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)
|
|
"gemeinde": { // Optional (only if fetch_documents=true)
|
|
"id": "...",
|
|
"label": "Bern",
|
|
"plz": "3011"
|
|
},
|
|
"documents": [ // Optional (only if fetch_documents=true and documents found/created)
|
|
{
|
|
"id": "...",
|
|
"label": "BZO Bern",
|
|
"dokumentTyp": "gemeindeBzoAktuell",
|
|
"dokumentReferenz": "...",
|
|
"quelle": "https://...",
|
|
"mimeType": "application/pdf"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
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=routeApiMsg("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
|
|
canton = attributes.get("ak") # Extract canton early so it's always available
|
|
|
|
# Debug: Log all available attributes to understand what we have
|
|
logger.debug(f"Parcel attributes keys: {list(attributes.keys())}")
|
|
logger.debug(f"Sample parcel attributes: {dict(list(attributes.items())[:10])}") # First 10 items
|
|
|
|
# First, check if municipality is directly in parcel attributes (ggdename or dplzname)
|
|
# These fields are often present in the parcel data itself from Swiss Topo
|
|
municipality_from_attrs = attributes.get("ggdename") or attributes.get("dplzname") or attributes.get("gemeinde") or attributes.get("gemeindename")
|
|
if municipality_from_attrs:
|
|
# Use connector's cleaning method to remove canton suffix
|
|
municipality_name = connector._clean_municipality_name(str(municipality_from_attrs))
|
|
logger.info(f"Found municipality '{municipality_name}' in parcel attributes (from {municipality_from_attrs})")
|
|
|
|
# Also check extracted_attributes for municipality
|
|
if not municipality_name:
|
|
municipality_from_extracted = extracted_attributes.get("kontextGemeinde")
|
|
if municipality_from_extracted:
|
|
municipality_name = str(municipality_from_extracted)
|
|
logger.info(f"Found municipality '{municipality_name}' in extracted attributes")
|
|
|
|
# Also check for PLZ in parcel attributes
|
|
if not plz:
|
|
plz_from_attrs = attributes.get("dplz4") or attributes.get("plz")
|
|
if plz_from_attrs:
|
|
plz = str(plz_from_attrs).strip()
|
|
logger.debug(f"Found PLZ '{plz}' in parcel attributes")
|
|
|
|
# Try to use geocoded address info if available (more accurate than centroid query)
|
|
geocoded_address = parcel_data.get('geocoded_address')
|
|
if geocoded_address:
|
|
if not full_address:
|
|
full_address = geocoded_address.get('full_address')
|
|
if not plz:
|
|
plz = geocoded_address.get('plz')
|
|
if not municipality_name:
|
|
geocoded_municipality = geocoded_address.get('municipality')
|
|
if geocoded_municipality:
|
|
municipality_name = connector._clean_municipality_name(geocoded_municipality)
|
|
logger.debug(f"Found municipality '{municipality_name}' from geocoded address")
|
|
if full_address:
|
|
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)
|
|
if not full_address:
|
|
full_address = address_info.get('full_address')
|
|
if not plz:
|
|
plz = address_info.get('plz')
|
|
if not municipality_name:
|
|
municipality_name = address_info.get('municipality')
|
|
if municipality_name:
|
|
logger.debug(f"Found municipality '{municipality_name}' from building layer")
|
|
|
|
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 address string if not found yet
|
|
if not municipality_name and full_address:
|
|
# Parse address string to extract municipality name
|
|
# Format is usually: "Street Number, PLZ Municipality" or "Street Number PLZ Municipality"
|
|
# Examples: "Forchstrasse 6c, 8610 Uster" or "Bundesplatz 3 3011 Bern"
|
|
# Try to match PLZ followed by municipality name
|
|
# PLZ is typically 4 digits, municipality name follows
|
|
plz_municipality_match = re.search(r'\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)', full_address)
|
|
if plz_municipality_match:
|
|
extracted_plz = plz_municipality_match.group(1)
|
|
extracted_municipality = plz_municipality_match.group(2).strip()
|
|
# Remove trailing commas or other punctuation
|
|
extracted_municipality = re.sub(r'[,;\.]+$', '', extracted_municipality).strip()
|
|
if extracted_municipality:
|
|
municipality_name = extracted_municipality
|
|
if not plz:
|
|
plz = extracted_plz
|
|
logger.debug(f"Extracted municipality '{municipality_name}' and PLZ '{plz}' from address string")
|
|
|
|
# Try to extract municipality name from BFSNR if still not found
|
|
if not municipality_name:
|
|
bfsnr = attributes.get("bfsnr")
|
|
|
|
logger.info(f"Attempting to resolve municipality name for BFS number {bfsnr} in canton {canton}")
|
|
|
|
# Try to query database for Gemeinde by BFS number
|
|
if bfsnr and canton:
|
|
try:
|
|
realEstateInterface = getRealEstateInterface(currentUser)
|
|
# Query Gemeinde by BFS number (stored in kontextInformationen)
|
|
gemeinden = realEstateInterface.getGemeinden(
|
|
recordFilter={"mandateId": currentUser.mandateId}
|
|
)
|
|
logger.debug(f"Found {len(gemeinden)} Gemeinden in database, searching for BFS {bfsnr}")
|
|
for gemeinde in gemeinden:
|
|
# Check kontextInformationen for BFS number
|
|
for kontext in gemeinde.kontextInformationen:
|
|
try:
|
|
kontext_data = json.loads(kontext.inhalt) if isinstance(kontext.inhalt, str) else kontext.inhalt
|
|
if isinstance(kontext_data, dict):
|
|
kontext_bfsnr = kontext_data.get("bfs_nummer") or kontext_data.get("bfsnr") or kontext_data.get("municipality_code")
|
|
if str(kontext_bfsnr) == str(bfsnr):
|
|
municipality_name = gemeinde.label
|
|
logger.info(f"Found Gemeinde '{municipality_name}' by BFS number {bfsnr} in database")
|
|
break
|
|
except (json.JSONDecodeError, AttributeError) as e:
|
|
logger.debug(f"Error parsing kontext: {e}")
|
|
continue
|
|
if municipality_name:
|
|
break
|
|
except Exception as e:
|
|
logger.warning(f"Error querying Gemeinde by BFS number: {e}", exc_info=True)
|
|
|
|
# If still not found, try to use Swiss Topo geocoding API to get municipality name from coordinates
|
|
# This is more reliable than BFS number lookup since coordinates are exact
|
|
if not municipality_name and centroid:
|
|
try:
|
|
# Use Swiss Topo geocoding to get municipality name from coordinates
|
|
geocode_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
|
|
params = {
|
|
"geometry": f"{centroid['x']},{centroid['y']}",
|
|
"geometryType": "esriGeometryPoint",
|
|
"layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill",
|
|
"tolerance": "0",
|
|
"returnGeometry": "false",
|
|
"sr": "2056"
|
|
}
|
|
import aiohttp
|
|
import ssl
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
connector_aiohttp = aiohttp.TCPConnector(ssl=ssl_context)
|
|
async with aiohttp.ClientSession(connector=connector_aiohttp) as session:
|
|
async with session.get(geocode_url, params=params) as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
results = data.get("results", [])
|
|
if results:
|
|
result_attrs = results[0].get("attributes", {})
|
|
geocoded_municipality = result_attrs.get("name") or result_attrs.get("gemeindename") or result_attrs.get("label")
|
|
if geocoded_municipality:
|
|
municipality_name = connector._clean_municipality_name(str(geocoded_municipality))
|
|
logger.info(f"Found municipality '{municipality_name}' via Swiss Topo geocoding API (from {geocoded_municipality})")
|
|
except Exception as e:
|
|
logger.debug(f"Error querying Swiss Topo geocoding API: {e}", exc_info=True)
|
|
|
|
# If still not found, try expanded Swiss municipalities lookup
|
|
if not municipality_name and bfsnr:
|
|
# Expanded Swiss municipalities lookup by BFS number
|
|
# Source: https://www.bfs.admin.ch/bfs/de/home/grundlagen/agvch.html
|
|
common_municipalities = {
|
|
# Zürich (ZH)
|
|
261: "Zürich",
|
|
198: "Pfäffikon", # ZH-198 is Pfäffikon
|
|
191: "Uster", # Uster is ZH-191
|
|
3203: "Winterthur",
|
|
# Bern (BE)
|
|
351: "Bern",
|
|
# Basel (BS)
|
|
2701: "Basel",
|
|
# Genève (GE)
|
|
6621: "Genève",
|
|
# Vaud (VD)
|
|
5586: "Lausanne",
|
|
# Luzern (LU)
|
|
1061: "Luzern",
|
|
# St. Gallen (SG)
|
|
230: "St. Gallen",
|
|
# Ticino (TI)
|
|
5192: "Lugano",
|
|
# Schwyz (SZ)
|
|
1367: "Schwyz",
|
|
}
|
|
|
|
if bfsnr in common_municipalities:
|
|
municipality_name = common_municipalities[bfsnr]
|
|
logger.info(f"Looked up municipality '{municipality_name}' from common list for BFS {bfsnr}")
|
|
|
|
# If still not found, log warning
|
|
if not municipality_name:
|
|
logger.warning(f"Could not determine municipality name for BFS number {bfsnr} in canton {canton}. Municipality name will be None.")
|
|
|
|
# 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")
|
|
|
|
# Query zone information (wohnzone/bauzone) from ÖREB WFS
|
|
bauzone = None
|
|
# Check if geometry has actual data (either rings or coordinates)
|
|
has_geometry = geometry and (geometry.get("rings") or geometry.get("coordinates"))
|
|
if canton and has_geometry:
|
|
try:
|
|
logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}")
|
|
oereb_connector = OerebWfsConnector()
|
|
egrid = attributes.get("egris_egrid", "")
|
|
x = centroid["x"] if centroid else None
|
|
y = centroid["y"] if centroid else None
|
|
|
|
# Query zone layer using parcel geometry
|
|
zone_results = await oereb_connector.query_zone_layer(
|
|
egrid=egrid,
|
|
x=x or 0.0,
|
|
y=y or 0.0,
|
|
canton=canton,
|
|
geometry=geometry
|
|
)
|
|
|
|
if zone_results and len(zone_results) > 0:
|
|
zone_attrs = zone_results[0].get("attributes", {})
|
|
typ_gde_abkuerzung = zone_attrs.get("typ_gde_abkuerzung")
|
|
if typ_gde_abkuerzung:
|
|
bauzone = typ_gde_abkuerzung
|
|
logger.info(f"Found bauzone/wohnzone: {bauzone} for parcel {attributes.get('label')}")
|
|
else:
|
|
logger.debug(f"No typ_gde_abkuerzung found in zone results for parcel {attributes.get('label')}")
|
|
else:
|
|
logger.debug(f"No zone results found for parcel {attributes.get('label')}")
|
|
except Exception as e:
|
|
logger.warning(f"Error querying zone information: {e}", exc_info=True)
|
|
# Continue without zone information if query fails
|
|
|
|
# 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"),
|
|
"bauzone": bauzone
|
|
}
|
|
|
|
# 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"] = []
|
|
|
|
# Fetch BZO documents if requested
|
|
gemeinde_info = None
|
|
bzo_documents = []
|
|
|
|
logger.debug(f"Document fetch check: fetch_documents={fetch_documents}, municipality_name={municipality_name}, canton={canton}")
|
|
|
|
if fetch_documents and municipality_name and canton:
|
|
logger.info(f"Fetching BZO documents for Gemeinde '{municipality_name}' in canton '{canton}'")
|
|
try:
|
|
# Get interfaces
|
|
realEstateInterface = getRealEstateInterface(currentUser)
|
|
componentInterface = getComponentInterface(currentUser)
|
|
logger.debug(f"Interfaces initialized for document fetching")
|
|
|
|
# Resolve or create Gemeinde
|
|
gemeinde = None
|
|
# First, ensure Land "Schweiz" exists
|
|
laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"})
|
|
if not laender:
|
|
land = Land(
|
|
mandateId=currentUser.mandateId,
|
|
label="Schweiz",
|
|
abk="CH"
|
|
)
|
|
land = realEstateInterface.createLand(land)
|
|
logger.debug(f"Created Land 'Schweiz' with ID: {land.id}")
|
|
else:
|
|
land = laender[0]
|
|
|
|
# Map canton abbreviations to full names
|
|
canton_names = {
|
|
"ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz",
|
|
"OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg",
|
|
"SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen",
|
|
"AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen",
|
|
"GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin",
|
|
"VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura"
|
|
}
|
|
|
|
# Get or create Kanton
|
|
kantone = realEstateInterface.getKantone(recordFilter={"abk": canton})
|
|
if not kantone:
|
|
kanton_label = canton_names.get(canton, canton)
|
|
kanton_obj = Kanton(
|
|
mandateId=currentUser.mandateId,
|
|
label=kanton_label,
|
|
abk=canton,
|
|
id_land=land.id
|
|
)
|
|
kanton_obj = realEstateInterface.createKanton(kanton_obj)
|
|
logger.debug(f"Created Kanton '{kanton_label}' ({canton})")
|
|
else:
|
|
kanton_obj = kantone[0]
|
|
|
|
# Get or create Gemeinde
|
|
gemeinden = realEstateInterface.getGemeinden(
|
|
recordFilter={"label": municipality_name, "id_kanton": kanton_obj.id}
|
|
)
|
|
if not gemeinden:
|
|
gemeinde = Gemeinde(
|
|
mandateId=currentUser.mandateId,
|
|
label=municipality_name,
|
|
id_kanton=kanton_obj.id,
|
|
plz=plz
|
|
)
|
|
gemeinde = realEstateInterface.createGemeinde(gemeinde)
|
|
logger.info(f"Created Gemeinde '{municipality_name}'")
|
|
else:
|
|
gemeinde = gemeinden[0]
|
|
logger.debug(f"Found existing Gemeinde '{municipality_name}'")
|
|
|
|
gemeinde_info = {
|
|
"id": gemeinde.id,
|
|
"label": gemeinde.label,
|
|
"plz": gemeinde.plz
|
|
}
|
|
|
|
# Check if Gemeinde already has BZO documents
|
|
existing_bzo = False
|
|
logger.debug(f"Checking for existing BZO documents in Gemeinde '{gemeinde.label}' (has {len(gemeinde.dokumente) if gemeinde.dokumente else 0} documents)")
|
|
if gemeinde.dokumente:
|
|
for doc in gemeinde.dokumente:
|
|
if (doc.label and ("BZO" in doc.label.upper() or "BAU UND ZONENORDNUNG" in doc.label.upper() or
|
|
"PLAN D'AMÉNAGEMENT" in doc.label.upper() or "RÈGLEMENT DE CONSTRUCTION" in doc.label.upper() or
|
|
"PIANO DI UTILIZZAZIONE" in doc.label.upper() or "REGOLAMENTO EDILIZIO" in doc.label.upper())) or \
|
|
(doc.dokumentTyp and doc.dokumentTyp in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]):
|
|
existing_bzo = True
|
|
logger.info(f"Found existing BZO document: {doc.label} (ID: {doc.id})")
|
|
bzo_documents.append({
|
|
"id": doc.id,
|
|
"label": doc.label,
|
|
"dokumentTyp": doc.dokumentTyp.value if doc.dokumentTyp else None,
|
|
"dokumentReferenz": doc.dokumentReferenz,
|
|
"quelle": doc.quelle,
|
|
"mimeType": doc.mimeType
|
|
})
|
|
|
|
if existing_bzo:
|
|
logger.info(f"Gemeinde '{municipality_name}' already has {len(bzo_documents)} BZO document(s), skipping search")
|
|
|
|
# If no BZO documents found, search and download
|
|
if not existing_bzo:
|
|
logger.info(f"No BZO documents found for {municipality_name}, searching with Tavily...")
|
|
|
|
# Determine language
|
|
language = _get_language_from_kanton(canton)
|
|
|
|
# Generate search query
|
|
search_query = _get_bzo_search_query(municipality_name, language)
|
|
logger.debug(f"Tavily search query: {search_query}")
|
|
|
|
# Initialize Tavily connector
|
|
tavily = AiTavily()
|
|
|
|
# Search with Tavily
|
|
search_results = await tavily._search(
|
|
query=search_query,
|
|
maxResults=5,
|
|
country="switzerland"
|
|
)
|
|
|
|
if search_results:
|
|
# First, check for direct PDF URLs in search results
|
|
pdf_urls = []
|
|
html_urls = []
|
|
|
|
for result in search_results:
|
|
url = result.url.lower()
|
|
# Check if it's a direct PDF link
|
|
if url.endswith('.pdf') or '/pdf/' in url or url.endswith('/pdf'):
|
|
if not any(skip in url for skip in ['.html', '.htm', '/page/', '/article/', '/news/']):
|
|
pdf_urls.append(result.url)
|
|
else:
|
|
# It's an HTML page - we'll crawl it to find PDF links
|
|
html_urls.append(result.url)
|
|
|
|
# If no direct PDFs found, scrape HTML pages directly to find PDF links
|
|
if not pdf_urls and html_urls:
|
|
logger.info(f"No direct PDF links found, scraping {len(html_urls)} HTML pages to find PDF documents...")
|
|
|
|
# Helper function to scrape HTML and find PDF links
|
|
async def scrape_html_for_pdfs(url: str) -> List[str]:
|
|
"""Scrape an HTML page to find PDF links."""
|
|
found_pdfs = []
|
|
try:
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
connector_aiohttp = aiohttp.TCPConnector(ssl=ssl_context)
|
|
|
|
timeout = aiohttp.ClientTimeout(total=15, connect=5)
|
|
headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8'
|
|
}
|
|
|
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector_aiohttp) as session:
|
|
async with session.get(url, allow_redirects=True) as response:
|
|
if response.status == 200:
|
|
# Check Content-Type header first
|
|
content_type = response.headers.get('Content-Type', '').lower()
|
|
|
|
# Read first few bytes to check if it's a PDF
|
|
raw_bytes = await response.read()
|
|
|
|
# Check if it's actually a PDF by magic bytes
|
|
if raw_bytes.startswith(b'%PDF'):
|
|
found_pdfs.append(url)
|
|
logger.info(f"Found direct PDF link (detected by magic bytes): {url}")
|
|
return found_pdfs
|
|
|
|
# If Content-Type says it's a PDF, add it
|
|
if 'application/pdf' in content_type:
|
|
found_pdfs.append(url)
|
|
logger.info(f"Found direct PDF link (Content-Type): {url}")
|
|
return found_pdfs
|
|
|
|
# If URL ends with .pdf, it's likely a PDF
|
|
if url.lower().endswith('.pdf'):
|
|
found_pdfs.append(url)
|
|
logger.info(f"Found direct PDF link (URL extension): {url}")
|
|
return found_pdfs
|
|
|
|
# Try to decode as text for HTML parsing
|
|
try:
|
|
# Try UTF-8 first
|
|
html_content = raw_bytes.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
try:
|
|
# Try ISO-8859-1 (common for German sites)
|
|
html_content = raw_bytes.decode('iso-8859-1')
|
|
except UnicodeDecodeError:
|
|
try:
|
|
# Try Windows-1252
|
|
html_content = raw_bytes.decode('windows-1252')
|
|
except UnicodeDecodeError:
|
|
# If all else fails, skip this URL
|
|
logger.warning(f"Could not decode content from {url} (not UTF-8, ISO-8859-1, or Windows-1252), skipping HTML parsing")
|
|
return found_pdfs
|
|
|
|
# Look for PDF links in various formats
|
|
# Pattern 1: Direct PDF URLs
|
|
pdf_pattern = r'https?://[^\s<>"\'\)]+\.pdf(?:\?[^\s<>"\'\)]*)?'
|
|
found = re.findall(pdf_pattern, html_content, re.IGNORECASE)
|
|
|
|
# Pattern 2: Relative PDF links (convert to absolute)
|
|
relative_pattern = r'href=["\']([^"\']+\.pdf[^"\']*)["\']'
|
|
relative_found = re.findall(relative_pattern, html_content, re.IGNORECASE)
|
|
|
|
# Convert relative URLs to absolute
|
|
base_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}"
|
|
|
|
for rel_url in relative_found:
|
|
# Remove query params and fragments for cleaner URLs
|
|
clean_url = rel_url.split('?')[0].split('#')[0]
|
|
if clean_url.endswith('.pdf'):
|
|
abs_url = urljoin(base_url, clean_url)
|
|
if abs_url not in found:
|
|
found.append(abs_url)
|
|
|
|
# Pattern 3: Look in data attributes and other places
|
|
data_pattern = r'data-[^=]*=["\']([^"\']+\.pdf[^"\']*)["\']'
|
|
data_found = re.findall(data_pattern, html_content, re.IGNORECASE)
|
|
for data_url in data_found:
|
|
clean_url = data_url.split('?')[0].split('#')[0]
|
|
if clean_url.endswith('.pdf'):
|
|
abs_url = urljoin(base_url, clean_url) if not clean_url.startswith('http') else clean_url
|
|
if abs_url not in found:
|
|
found.append(abs_url)
|
|
|
|
# Clean and deduplicate URLs
|
|
for pdf_link in found:
|
|
pdf_link = pdf_link.rstrip('.,;:!?)').strip()
|
|
# Remove common tracking parameters
|
|
if '?' in pdf_link:
|
|
base, params = pdf_link.split('?', 1)
|
|
# Keep only important params, remove tracking
|
|
important_params = []
|
|
for param in params.split('&'):
|
|
if param.split('=')[0].lower() not in ['utm_source', 'utm_medium', 'utm_campaign', 'ref', 'fbclid', 'gclid']:
|
|
important_params.append(param)
|
|
if important_params:
|
|
pdf_link = f"{base}?{'&'.join(important_params)}"
|
|
else:
|
|
pdf_link = base
|
|
|
|
if pdf_link not in found_pdfs and pdf_link.startswith('http'):
|
|
found_pdfs.append(pdf_link)
|
|
logger.debug(f"Found PDF link on {url}: {pdf_link}")
|
|
|
|
logger.info(f"Found {len(found_pdfs)} PDF links on {url}")
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Error scraping {url} for PDFs: {e}", exc_info=True)
|
|
|
|
return found_pdfs
|
|
|
|
# Scrape HTML pages to find PDF links
|
|
for html_url in html_urls[:5]: # Limit to first 5 URLs
|
|
try:
|
|
logger.debug(f"Scraping {html_url} to find PDF links...")
|
|
found_pdfs = await scrape_html_for_pdfs(html_url)
|
|
pdf_urls.extend(found_pdfs)
|
|
except Exception as e:
|
|
logger.warning(f"Error scraping {html_url} to find PDFs: {e}", exc_info=True)
|
|
continue
|
|
|
|
# Also check rawContent from search results for PDF links
|
|
for result in search_results:
|
|
if result.rawContent:
|
|
pdf_pattern = r'https?://[^\s<>"\'\)]+\.pdf(?:\?[^\s<>"\'\)]*)?'
|
|
found_pdfs = re.findall(pdf_pattern, result.rawContent, re.IGNORECASE)
|
|
for pdf_link in found_pdfs:
|
|
pdf_link = pdf_link.rstrip('.,;:!?)').strip()
|
|
if pdf_link not in pdf_urls and pdf_link.startswith('http'):
|
|
pdf_urls.append(pdf_link)
|
|
logger.debug(f"Found PDF link in rawContent: {pdf_link}")
|
|
|
|
if not pdf_urls:
|
|
logger.warning(f"No PDF URLs found in Tavily results for {municipality_name}. Results were HTML pages, not direct PDF links.")
|
|
logger.debug(f"Tavily returned URLs: {[r.url for r in search_results]}")
|
|
|
|
logger.info(f"Found {len(pdf_urls)} potential PDF documents for {municipality_name}")
|
|
|
|
# Helper function to download a single PDF
|
|
async def download_pdf(pdf_url: str) -> Optional[bytes]:
|
|
"""Download a PDF from a URL with retry logic."""
|
|
max_retries = 3
|
|
retry_delay = 2
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
if attempt > 0:
|
|
headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
'Accept': '*/*'
|
|
}
|
|
else:
|
|
headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
'Accept': 'application/pdf,application/octet-stream,*/*',
|
|
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
|
'Accept-Encoding': 'gzip, deflate, br',
|
|
'Connection': 'keep-alive',
|
|
'Upgrade-Insecure-Requests': '1'
|
|
}
|
|
|
|
# Create SSL context that doesn't verify certificates (for development)
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
|
|
# Create connector with SSL context
|
|
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
|
|
|
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector) as session:
|
|
async with session.get(pdf_url, allow_redirects=True) as response:
|
|
if response.status == 200:
|
|
# Check content-type header first
|
|
content_type = response.headers.get('Content-Type', '').lower()
|
|
if 'text/html' in content_type or 'text/xml' in content_type:
|
|
logger.warning(f"URL {pdf_url} returned HTML content (Content-Type: {content_type}), skipping")
|
|
raise Exception("Server returned HTML content instead of PDF")
|
|
|
|
pdf_content = await response.read()
|
|
|
|
if not pdf_content or len(pdf_content) < 100:
|
|
raise Exception("Downloaded file is too small or empty")
|
|
|
|
# Verify it's actually a PDF
|
|
if not pdf_content.startswith(b'%PDF'):
|
|
if pdf_content.startswith(b'<') or pdf_content.startswith(b'<!DOCTYPE'):
|
|
logger.warning(f"URL {pdf_url} returned HTML content (detected HTML markers), skipping")
|
|
raise Exception("Server returned HTML instead of PDF")
|
|
logger.warning(f"Downloaded file from {pdf_url} doesn't appear to be a PDF (no PDF magic bytes), skipping")
|
|
raise Exception("File doesn't appear to be a valid PDF")
|
|
|
|
return pdf_content
|
|
elif response.status == 406:
|
|
logger.warning(f"HTTP 406 for {pdf_url}, will retry with minimal headers (attempt {attempt + 1}/{max_retries})")
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(retry_delay)
|
|
continue
|
|
else:
|
|
raise Exception(f"HTTP {response.status} (Not Acceptable) - server rejected request after {max_retries} attempts")
|
|
else:
|
|
raise Exception(f"HTTP {response.status} when downloading PDF")
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"Timeout downloading PDF from {pdf_url} (attempt {attempt + 1}/{max_retries})")
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(retry_delay)
|
|
continue
|
|
else:
|
|
raise Exception("Connection timeout after retries")
|
|
except aiohttp.ClientError as e:
|
|
logger.warning(f"Connection error downloading PDF from {pdf_url} (attempt {attempt + 1}/{max_retries}): {str(e)}")
|
|
if attempt < max_retries - 1:
|
|
await asyncio.sleep(retry_delay)
|
|
continue
|
|
else:
|
|
raise Exception(f"Connection error: {str(e)}")
|
|
except Exception as e:
|
|
raise
|
|
|
|
return None
|
|
|
|
# Process PDF URLs
|
|
current_dokumente = list(gemeinde.dokumente) if gemeinde.dokumente else []
|
|
|
|
# Sanitize Gemeinde name for filename
|
|
safe_name = "".join(c for c in municipality_name if c.isalnum() or c in (' ', '-', '_')).strip()
|
|
safe_name = safe_name.replace(' ', '_')
|
|
if not safe_name:
|
|
safe_name = "Gemeinde"
|
|
|
|
# Determine base label based on language
|
|
if language == 'fr':
|
|
base_doc_label = f"Plan d'aménagement local {municipality_name}"
|
|
elif language == 'it':
|
|
base_doc_label = f"Piano di utilizzazione {municipality_name}"
|
|
else:
|
|
base_doc_label = f"BZO {municipality_name}"
|
|
|
|
# Process each PDF URL
|
|
for idx, pdf_url in enumerate(pdf_urls):
|
|
try:
|
|
logger.info(f"Downloading PDF {idx + 1}/{len(pdf_urls)} from {pdf_url} for {municipality_name}")
|
|
pdf_content = await download_pdf(pdf_url)
|
|
|
|
if not pdf_content or len(pdf_content) < 100:
|
|
logger.warning(f"Failed to download PDF from {pdf_url} for {municipality_name}")
|
|
continue
|
|
|
|
# Create unique file name
|
|
if len(pdf_urls) > 1:
|
|
file_name = f"BZO_{safe_name}_{idx + 1}.pdf"
|
|
doc_label = f"{base_doc_label} ({idx + 1})"
|
|
else:
|
|
file_name = f"BZO_{safe_name}.pdf"
|
|
doc_label = base_doc_label
|
|
|
|
# Store file using ComponentObjects
|
|
try:
|
|
file_item = componentInterface.createFile(
|
|
name=file_name,
|
|
mimeType="application/pdf",
|
|
content=pdf_content
|
|
)
|
|
|
|
componentInterface.createFileData(file_item.id, pdf_content)
|
|
logger.info(f"Stored file {file_name} with ID {file_item.id}")
|
|
except Exception as e:
|
|
logger.error(f"Error storing file {file_name}: {str(e)}", exc_info=True)
|
|
continue
|
|
|
|
# Create Dokument record
|
|
dokument = Dokument(
|
|
mandateId=currentUser.mandateId,
|
|
label=doc_label,
|
|
versionsbezeichnung="Aktuell",
|
|
dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL,
|
|
dokumentReferenz=file_item.id,
|
|
quelle=pdf_url,
|
|
mimeType="application/pdf",
|
|
kategorienTags=["BZO", "Bauordnung", municipality_name]
|
|
)
|
|
|
|
# Create Dokument record
|
|
created_dokument = realEstateInterface.createDokument(dokument)
|
|
logger.info(f"Created Dokument record with ID {created_dokument.id}")
|
|
|
|
current_dokumente.append(created_dokument)
|
|
|
|
# Add to response
|
|
bzo_documents.append({
|
|
"id": created_dokument.id,
|
|
"label": created_dokument.label,
|
|
"dokumentTyp": created_dokument.dokumentTyp.value if created_dokument.dokumentTyp else None,
|
|
"dokumentReferenz": created_dokument.dokumentReferenz,
|
|
"quelle": created_dokument.quelle,
|
|
"mimeType": created_dokument.mimeType
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing PDF {pdf_url}: {str(e)}", exc_info=True)
|
|
continue
|
|
|
|
# Update Gemeinde with new dokumente
|
|
if bzo_documents:
|
|
updated_gemeinde = realEstateInterface.updateGemeinde(
|
|
gemeinde.id,
|
|
{"dokumente": current_dokumente}
|
|
)
|
|
if updated_gemeinde:
|
|
logger.info(f"Successfully created {len(bzo_documents)} BZO document(s) for {municipality_name}")
|
|
else:
|
|
logger.warning(f"No search results found for {municipality_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching BZO documents for {municipality_name}: {e}", exc_info=True)
|
|
# Continue without documents - don't fail the request
|
|
elif fetch_documents:
|
|
if not municipality_name:
|
|
logger.warning("fetch_documents=true but municipality_name is not available, skipping document fetch")
|
|
elif not canton:
|
|
logger.warning("fetch_documents=true but canton is not available, skipping document fetch")
|
|
|
|
# Add Gemeinde and documents to response if available
|
|
logger.debug(f"Adding to response: gemeinde_info={gemeinde_info is not None}, bzo_documents count={len(bzo_documents)}")
|
|
if gemeinde_info:
|
|
response_data["gemeinde"] = gemeinde_info
|
|
logger.debug(f"Added gemeinde_info to response: {gemeinde_info}")
|
|
if bzo_documents:
|
|
response_data["documents"] = bzo_documents
|
|
logger.info(f"Added {len(bzo_documents)} BZO documents to response")
|
|
else:
|
|
logger.debug("No BZO documents to add to response")
|
|
|
|
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=routeApiMsg("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=routeApiMsg("Invalid CSRF token format")
|
|
)
|
|
try:
|
|
int(csrf_token, 16)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("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)}"
|
|
)
|
|
|
|
|
|
@router.get("/bzo-information", response_model=Dict[str, Any])
|
|
@limiter.limit("30/minute")
|
|
async def get_bzo_information(
|
|
request: Request,
|
|
gemeinde: str = Query(..., description="Gemeinde name or ID"),
|
|
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
|
|
total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde.
|
|
|
|
Uses a langgraph workflow to extract content from BZO PDF documents for the
|
|
specified Gemeinde, then uses AI to search for relevant information specific
|
|
to the specified Bauzone.
|
|
|
|
The workflow:
|
|
1. Finds BZO documents for the Gemeinde (by name or ID)
|
|
2. Extracts content from PDFs using langgraph workflow
|
|
3. Filters rules, zones, and articles by Bauzone
|
|
4. Uses AI to generate a summary and find relevant information
|
|
|
|
Query Parameters:
|
|
- gemeinde: Gemeinde name (e.g., "Zürich") or ID
|
|
- bauzone: Bauzone code (e.g., "W3", "W2/30", "Z3")
|
|
|
|
Headers:
|
|
- X-CSRF-Token: CSRF token (required for security)
|
|
|
|
Returns:
|
|
{
|
|
"bauzone": "W3",
|
|
"gemeinde": {
|
|
"id": "...",
|
|
"label": "...",
|
|
"plz": "..."
|
|
},
|
|
"extracted_content": {
|
|
"zones": [...], // Zone information filtered by Bauzone
|
|
"rules": [...], // Rules filtered by Bauzone
|
|
"articles": [...], // Articles filtered by Bauzone
|
|
"total_zones": N,
|
|
"total_rules": N,
|
|
"total_articles": N
|
|
},
|
|
"ai_summary": "...", // AI-generated summary
|
|
"relevant_rules": [...], // Rules specifically for this Bauzone
|
|
"documents_processed": [ // List of document IDs processed
|
|
{
|
|
"id": "...",
|
|
"label": "...",
|
|
"dokumentTyp": "..."
|
|
}
|
|
],
|
|
"errors": [...],
|
|
"warnings": [...]
|
|
}
|
|
|
|
Examples:
|
|
- GET /api/realestate/bzo-information?gemeinde=Zürich&bauzone=W3
|
|
- GET /api/realestate/bzo-information?gemeinde=Uster&bauzone=W2/30
|
|
|
|
Raises:
|
|
- 404: Gemeinde not found
|
|
- 404: No BZO documents found for Gemeinde
|
|
- 500: Error during extraction or processing
|
|
"""
|
|
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/bzo-information from user {currentUser.id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("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/bzo-information from user {currentUser.id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("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/bzo-information from user {currentUser.id}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=routeApiMsg("Invalid CSRF token format")
|
|
)
|
|
|
|
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {currentUser.mandateId})")
|
|
|
|
# Call the feature function
|
|
result = await extract_bzo_information(
|
|
currentUser=currentUser,
|
|
gemeinde=gemeinde,
|
|
bauzone=bauzone,
|
|
total_area_m2=total_area_m2,
|
|
)
|
|
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}': {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Error extracting BZO information: {str(e)}"
|
|
)
|
|
|