diff --git a/app.py b/app.py index 2061836f..5f7593a8 100644 --- a/app.py +++ b/app.py @@ -415,6 +415,9 @@ app.include_router(workflowRouter) from modules.routes.routeChatPlayground import router as chatPlaygroundRouter app.include_router(chatPlaygroundRouter) +from modules.routes.routeRealEstate import router as realEstateRouter +app.include_router(realEstateRouter) + from modules.routes.routeSecurityLocal import router as localRouter app.include_router(localRouter) diff --git a/config.ini b/config.ini index ab0b6712..88a80b11 100644 --- a/config.ini +++ b/config.ini @@ -34,4 +34,11 @@ Web_Crawl_RETRY_DELAY = 2 # Web Research configuration Web_Research_MAX_DEPTH = 2 Web_Research_MAX_LINKS_PER_DOMAIN = 4 -Web_Research_CRAWL_TIMEOUT_MINUTES = 10 \ No newline at end of file +Web_Research_CRAWL_TIMEOUT_MINUTES = 10 + +# STAC API Connector configuration (Swiss Topo) +Connector_StacSwisstopo_BASE_URL = https://data.geo.admin.ch/api/stac/v1 +Connector_StacSwisstopo_TIMEOUT = 30 +Connector_StacSwisstopo_MAX_RETRIES = 3 +Connector_StacSwisstopo_RETRY_DELAY = 1.0 +Connector_StacSwisstopo_ENABLE_CACHE = True \ No newline at end of file diff --git a/env_dev.env b/env_dev.env index 95b2b91e..c20be4e2 100644 --- a/env_dev.env +++ b/env_dev.env @@ -29,6 +29,13 @@ DB_MANAGEMENT_USER=poweron_dev DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9 DB_MANAGEMENT_PORT=5432 +# PostgreSQL Storage (new) +DB_REALESTATE_HOST=localhost +DB_REALESTATE_DATABASE=poweron_realestate +DB_REALESTATE_USER=poweron_dev +DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9 +DB_REALESTATE_PORT=5432 + # Security Configuration APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ== APP_TOKEN_EXPIRY=300 diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 9aadbe50..65ded736 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -67,7 +67,17 @@ def _get_model_fields(model_class) -> Dict[str, str]: "messages", "stats", "tasks", + "perimeter", # GeoPolylinie objects + "baulinie", # GeoPolylinie objects + "kontextInformationen", # List of Kontext objects + "parzellenNachbarschaft", # List of dictionaries + "dokumente", # List of Dokument objects + "parzellen", # List of Parzelle objects (in Projekt) ] + # Check if field type is a Pydantic BaseModel (for nested models like GeoPolylinie) + or (hasattr(field_type, "__origin__") and get_origin(field_type) is Union + and any(hasattr(arg, "__bases__") and BaseModel in getattr(arg, "__bases__", ()) + for arg in get_args(field_type))) ): fields[field_name] = "JSONB" # Simple type mapping diff --git a/modules/connectors/connectorSwissTopoMapServer.py b/modules/connectors/connectorSwissTopoMapServer.py new file mode 100644 index 00000000..97838507 --- /dev/null +++ b/modules/connectors/connectorSwissTopoMapServer.py @@ -0,0 +1,1206 @@ +""" +Swiss Topo MapServer Connector (Simplified) + +This connector handles interactions with the Swiss Federal Office of Topography +MapServer identify endpoint for parcel data retrieval. + +Endpoint: https://api3.geo.admin.ch/rest/services/api/MapServer/identify +""" + +import logging +import asyncio +import re +from typing import Dict, List, Any, Optional, Tuple, Union +import aiohttp +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + + +class SwissTopoMapServerConnector: + """ + Simplified connector for Swiss Topo MapServer identify endpoint. + + Provides methods for: + - Searching parcels by coordinates (LV95) + - Geocoding addresses to coordinates + - Extracting parcel information + """ + + # API endpoints + MAPSERVER_IDENTIFY_URL = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" + GEOCODING_URL = "https://api3.geo.admin.ch/rest/services/api/SearchServer" + + # Swiss official survey layer + LAYER_AMTLICHE_VERMESSUNG = "all:ch.swisstopo-vd.amtliche-vermessung" + + # Switzerland bounds in LV95 (EPSG:2056) + SWITZERLAND_BOUNDS = { + "min_x": 2480000, + "max_x": 2840000, + "min_y": 1070000, + "max_y": 1300000 + } + + def __init__( + self, + timeout: int = 30, + max_retries: int = 3, + retry_delay: float = 1.0 + ): + """ + Initialize MapServer connector. + + Args: + timeout: Request timeout in seconds + max_retries: Maximum number of retry attempts + retry_delay: Initial retry delay in seconds (exponential backoff) + """ + self.timeout = aiohttp.ClientTimeout(total=timeout) + self.max_retries = max_retries + self.retry_delay = retry_delay + + logger.info("Swiss Topo MapServer Connector initialized") + + def _point_in_polygon(self, point_x: float, point_y: float, polygon_rings: List[List[List[float]]]) -> bool: + """ + Check if a point is inside a polygon using ray casting algorithm. + + Args: + point_x: X coordinate of the point + point_y: Y coordinate of the point + polygon_rings: List of rings (each ring is a list of [x, y] coordinates) + First ring is outer boundary, subsequent rings are holes + + Returns: + True if point is inside polygon, False otherwise + """ + if not polygon_rings or not polygon_rings[0]: + return False + + # Use outer ring (first ring) + ring = polygon_rings[0] + if len(ring) < 3: + return False + + # Ray casting algorithm: count intersections of horizontal ray with polygon edges + inside = False + j = len(ring) - 1 + + for i in range(len(ring)): + xi, yi = ring[i][0], ring[i][1] + xj, yj = ring[j][0], ring[j][1] + + # Check if ray crosses edge + if ((yi > point_y) != (yj > point_y)) and (point_x < (xj - xi) * (point_y - yi) / (yj - yi) + xi): + inside = not inside + j = i + + # If point is inside outer ring, check if it's in any hole + if inside and len(polygon_rings) > 1: + for hole_ring in polygon_rings[1:]: + if self._point_in_polygon(point_x, point_y, [hole_ring]): + inside = False # Point is in a hole, so not in polygon + break + + return inside + + def _clean_municipality_name(self, municipality: str) -> str: + """ + Clean municipality name by removing canton suffix. + + Args: + municipality: Municipality name (e.g., "Zürich ZH" or "Lohn (SH)") + + Returns: + Cleaned municipality name (e.g., "Zürich" or "Lohn") + """ + if not municipality: + return municipality + + # Remove parentheses content like "Lohn (SH)" -> "Lohn" + municipality = municipality.split("(")[0].strip() + + # Remove canton code at the end if present (e.g., "Wald ZH" -> "Wald") + parts = municipality.split() + if len(parts) > 1 and len(parts[-1]) == 2 and parts[-1].isupper(): + municipality = " ".join(parts[:-1]) + + return municipality + + def _extract_address_from_building_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Optional[str]]: + """ + Extract address components from building layer attributes. + + Args: + attrs: Attributes dictionary from building layer query + + Returns: + Dictionary with street, house_number, plz, municipality, and full_address + """ + # Extract address components (note: strname is a list!) + street_list = attrs.get("strname", []) + street = street_list[0] if isinstance(street_list, list) and street_list else None + house_number = attrs.get("deinr") + plz = attrs.get("dplz4") + municipality = attrs.get("dplzname") or attrs.get("ggdename") + + # Clean municipality name + if municipality: + municipality = self._clean_municipality_name(municipality) + + # Construct full address + full_address = None + if street and house_number and plz and municipality: + full_address = f"{street} {house_number}, {plz} {municipality}" + elif street and house_number: + full_address = f"{street} {house_number}" + if plz: + full_address += f", {plz}" + if municipality: + full_address += f" {municipality}" + + return { + 'street': street, + 'house_number': str(house_number) if house_number else None, + 'plz': str(plz) if plz else None, + 'municipality': municipality, + 'full_address': full_address + } + + async def _query_building_layer( + self, + x: float, + y: float, + tolerance: int = 1, # Default to 1 pixel (minimum) for maximum precision + buffer: int = 25 # Reduced buffer for more precise queries + ) -> Optional[Dict[str, Any]]: + """ + Query the building/address layer at given coordinates. + + Args: + x: X coordinate (LV95) + y: Y coordinate (LV95) + tolerance: Tolerance in pixels for identify operation + buffer: Buffer in meters for map extent + + Returns: + First building result dictionary, or None if not found + """ + try: + building_extent = f"{x - buffer},{y - buffer},{x + buffer},{y + buffer}" + + building_params = { + "geometry": f"{x},{y}", + "geometryType": "esriGeometryPoint", + "sr": "2056", + "layers": "all:ch.bfs.gebaeude_wohnungs_register", + "tolerance": tolerance, + "mapExtent": building_extent, + "imageDisplay": "800,600,96", + "returnGeometry": "false", + "f": "json" + } + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: + async with session.get(self.MAPSERVER_IDENTIFY_URL, params=building_params) as response: + if response.status == 200: + building_data = await response.json() + building_results = building_data.get("results", []) + if building_results: + return building_results[0] + return None + except Exception as e: + logger.debug(f"Could not query building layer: {e}") + return None + + def _validate_coordinates(self, x: float, y: float) -> None: + """ + Validate coordinates are within Switzerland bounds (LV95). + + Args: + x: East coordinate (LV95) + y: North coordinate (LV95) + + Raises: + ValueError: If coordinates are out of bounds + """ + if not (self.SWITZERLAND_BOUNDS["min_x"] <= x <= self.SWITZERLAND_BOUNDS["max_x"]): + raise ValueError( + f"X coordinate {x} out of bounds " + f"({self.SWITZERLAND_BOUNDS['min_x']} - {self.SWITZERLAND_BOUNDS['max_x']})" + ) + if not (self.SWITZERLAND_BOUNDS["min_y"] <= y <= self.SWITZERLAND_BOUNDS["max_y"]): + raise ValueError( + f"Y coordinate {y} out of bounds " + f"({self.SWITZERLAND_BOUNDS['min_y']} - {self.SWITZERLAND_BOUNDS['max_y']})" + ) + + def _extract_coordinates_from_search_result(self, result: Dict[str, Any]) -> Optional[Tuple[float, float]]: + """ + Extract LV95 coordinates from SearchServer API result. + Handles different response formats and coordinate swapping. + + Args: + result: Search result from SearchServer API + + Returns: + Tuple of (x, y) in LV95 coordinates, or None if not found + """ + attrs = result.get("attrs", {}) + + # Try multiple extraction patterns + x = None + y = None + + # Pattern 1: From 'geom_st_box2d' (most reliable - has correct X,Y order) + if 'geom_st_box2d' in attrs: + bbox_str = attrs['geom_st_box2d'] + match = re.search(r'BOX\(([0-9.]+) ([0-9.]+),([0-9.]+) ([0-9.]+)\)', bbox_str) + if match: + xmin, ymin, xmax, ymax = map(float, match.groups()) + x = (xmin + xmax) / 2 # Easting + y = (ymin + ymax) / 2 # Northing + logger.debug(f"Extracted coordinates from geom_st_box2d: X={x}, Y={y}") + # Pattern 2: From attrs with 'x' and 'y' keys (WARNING: these are SWAPPED!) + # The Swiss Geo Admin API returns x=northing, y=easting (opposite of standard) + elif 'x' in attrs and 'y' in attrs: + # SWAP: their 'x' is our Y, their 'y' is our X + x = attrs.get('y') # Their Y is easting (our X) + y = attrs.get('x') # Their X is northing (our Y) + logger.debug(f"Extracted swapped coordinates from attrs: X={x} (from 'y'), Y={y} (from 'x')") + # Pattern 3: From attrs with 'easting' and 'northing' keys + elif 'easting' in attrs and 'northing' in attrs: + x = attrs.get('easting') + y = attrs.get('northing') + logger.debug(f"Extracted coordinates from easting/northing: X={x}, Y={y}") + # Pattern 4: From top-level result (also likely swapped) + elif 'x' in result and 'y' in result: + x = result.get('y') # Swap + y = result.get('x') # Swap + logger.debug(f"Extracted swapped coordinates from result: X={x}, Y={y}") + + if x is None or y is None: + return None + + # Convert to float + x = float(x) + y = float(y) + + # Validate that coordinates are in LV95 range + if not (self.SWITZERLAND_BOUNDS["min_x"] <= x <= self.SWITZERLAND_BOUNDS["max_x"]): + logger.warning(f"Extracted X coordinate {x} is out of LV95 bounds") + return None + + if not (self.SWITZERLAND_BOUNDS["min_y"] <= y <= self.SWITZERLAND_BOUNDS["max_y"]): + logger.warning(f"Extracted Y coordinate {y} is out of LV95 bounds") + return None + + return (x, y) + + async def _make_request( + self, + url: str, + params: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Make HTTP GET request with retry logic and exponential backoff. + + Args: + url: API endpoint URL + params: Query parameters + + Returns: + Response JSON data + + Raises: + aiohttp.ClientError: If request fails after all retries + """ + for attempt in range(self.max_retries + 1): + try: + async with aiohttp.ClientSession(timeout=self.timeout) as session: + async with session.get(url, params=params) as response: + # Don't retry on 4xx errors (client errors) + if 400 <= response.status < 500: + error_text = await response.text() + logger.error(f"API client error: {response.status} - {error_text}") + response.raise_for_status() + + # Retry on 5xx errors (server errors) + if response.status >= 500: + if attempt < self.max_retries: + delay = self.retry_delay * (2 ** attempt) + logger.warning( + f"API server error {response.status}, " + f"retrying in {delay}s (attempt {attempt + 1}/{self.max_retries + 1})" + ) + await asyncio.sleep(delay) + continue + else: + error_text = await response.text() + logger.error(f"API request failed after {self.max_retries + 1} attempts: {response.status} - {error_text}") + response.raise_for_status() + + return await response.json() + + except aiohttp.ClientError as e: + if attempt < self.max_retries: + delay = self.retry_delay * (2 ** attempt) + logger.warning( + f"API network error, retrying in {delay}s " + f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}" + ) + await asyncio.sleep(delay) + continue + else: + logger.error(f"API request failed after {self.max_retries + 1} attempts: {e}") + raise + except Exception as e: + logger.error(f"Unexpected error in API request: {e}") + raise + + raise Exception(f"API request failed after {self.max_retries + 1} attempts") + + async def geocode_address(self, address: str, return_full_result: bool = False) -> Optional[Union[Tuple[float, float], Dict[str, Any]]]: + """ + Geocode an address to LV95 coordinates. + + Args: + address: Address string (e.g. "Bundesplatz 3, 3003 Bern") + return_full_result: If True, returns dict with coords and full result. If False, returns just coords tuple. + + Returns: + If return_full_result=False: Tuple of (x, y) in LV95 coordinates, or None if not found + If return_full_result=True: Dict with 'coords' (x, y tuple) and 'result' (full geocoding result), or None if not found + """ + try: + # Normalize address: ensure postal code is properly formatted + # Swiss postal codes are 4 digits, often separated from street + normalized_address = address.strip() + + # Try to detect and format postal code pattern: "Street Number POSTALCODE" + # Pattern: ends with 4 digits (postal code) + postal_code_pattern = r'\b(\d{4})\b' + postal_code_match = re.search(postal_code_pattern, normalized_address) + + if postal_code_match: + postal_code = postal_code_match.group(1) + # Format as "Street Number, POSTALCODE" for better geocoding + # This helps the API understand the structure better + parts = normalized_address.split() + if len(parts) >= 2 and parts[-1] == postal_code: + # Already ends with postal code, format with comma + street_part = " ".join(parts[:-1]) + normalized_address = f"{street_part}, {postal_code}" + logger.debug(f"Normalized address format: '{normalized_address}'") + + # Request multiple results to find the best match + # Use "address" origin to prioritize address results over parcels + params = { + "searchText": normalized_address, + "type": "locations", + "origins": "address", # Prioritize address results + "sr": "2056", # Request coordinates in LV95 (EPSG:2056) + "limit": 10 # Get more results to find best match + } + + logger.info(f"Geocoding address: {address} (normalized: {normalized_address})") + response = await self._make_request(self.GEOCODING_URL, params) + + results = response.get("results", []) + if not results: + logger.warning(f"No geocoding results for address: {address}") + return None + + # Normalize input address for comparison + input_normalized = address.lower().strip() + input_parts = [p.strip() for p in input_normalized.split() if p.strip()] + + # Try to find the best matching result + best_match = None + best_score = 0 + + for result in results: + # Extract address label from result (remove HTML tags) + result_label_raw = result.get("label", "") + # Remove HTML tags like ... + result_label = re.sub(r'<[^>]+>', '', result_label_raw).lower().strip() + attrs = result.get("attrs", {}) + + # Extract address components + street = attrs.get("strname", "") + if isinstance(street, list): + street = street[0] if street else "" + house_number = str(attrs.get("deinr", "")).strip() + plz = str(attrs.get("dplz4", "")).strip() + municipality = attrs.get("dplzname", "") or attrs.get("ggdename", "") + + # Build address components for comparison + result_parts = [] + if street: + result_parts.append(street.lower()) + if house_number: + result_parts.append(house_number) + if plz: + result_parts.append(plz) + if municipality: + # Remove canton suffix + municipality_clean = municipality.split("(")[0].strip().split() + if len(municipality_clean) > 1 and len(municipality_clean[-1]) == 2: + municipality_clean = municipality_clean[:-1] + result_parts.append(" ".join(municipality_clean).lower()) + + # Also use the label for matching (it often has the full address) + if result_label: + label_parts = [p.strip() for p in result_label.split() if len(p.strip()) > 2] + result_parts.extend(label_parts) + + # Calculate match score + score = 0 + matched_parts = 0 + + # Check if input parts match result parts + for input_part in input_parts: + for result_part in result_parts: + if input_part in result_part or result_part in input_part: + score += 1 + matched_parts += 1 + break + + # Prefer results with higher match score + if matched_parts > 0: + score = matched_parts / max(len(input_parts), len(result_parts)) + + logger.debug(f"Geocoding result match: label='{result_label}', score={score:.2f}, matched={matched_parts}/{len(input_parts)}") + + if score > best_score: + best_score = score + best_match = result + + # Use best match or fall back to first result + result = best_match if best_match else results[0] + + if best_match: + logger.info(f"Selected best match with score {best_score:.2f}: {result.get('label', 'Unknown')}") + else: + logger.info(f"Using first result: {result.get('label', 'Unknown')}") + + logger.debug(f"Geocoding result: {result}") + + coords = self._extract_coordinates_from_search_result(result) + if coords: + x, y = coords + logger.info(f"Geocoded address '{address}' to LV95 coordinates: ({x}, {y})") + + if return_full_result: + return { + 'coords': coords, + 'result': result + } + else: + return coords + else: + logger.warning(f"No coordinates found in geocoding result. Result: {result}") + return None + + except Exception as e: + logger.error(f"Error geocoding address '{address}': {e}", exc_info=True) + return None + + async def search_parcel( + self, + location: str, + tolerance: int = 5 # Reduced default tolerance for more precise parcel matching + ) -> Optional[Dict[str, Any]]: + """ + Search for parcel by address or coordinates using MapServer identify. + + Args: + location: Either coordinates as "x,y" (LV95) or address string + tolerance: Tolerance in pixels for identify operation + + Returns: + Parcel information dictionary with geocoded address info, or None if not found + """ + try: + original_location = location + geocoded_address_info = None + geocoded_coords = None + + # Try to parse as coordinates first + parts = location.split(",") + if len(parts) == 2: + try: + x = float(parts[0].strip()) + y = float(parts[1].strip()) + logger.info(f"Parsed location as coordinates: ({x}, {y})") + # Validate coordinates + self._validate_coordinates(x, y) + except ValueError: + # Not coordinates, try geocoding - get full result + geocode_result = await self.geocode_address(location, return_full_result=True) + if geocode_result is None: + logger.warning(f"Could not geocode location: {location}") + return None + x, y = geocode_result['coords'] + geocoded_address_info = geocode_result['result'] + else: + # Treat as address and geocode - get full result including address info + geocode_result = await self.geocode_address(location, return_full_result=True) + if geocode_result is None: + logger.warning(f"Could not geocode location: {location}") + return None + + x, y = geocode_result['coords'] + geocoded_address_info = geocode_result['result'] + + # Log the geocoded address info + if geocoded_address_info: + label_raw = geocoded_address_info.get('label', '') + label_clean = re.sub(r'<[^>]+>', '', label_raw).strip() + logger.info(f"Geocoded address info: {label_clean}") + + # First, query the building layer with minimal tolerance to get the exact building + # This is critical for precise map clicks - we want the exact building, not nearby ones + is_coordinate_search = len(parts) == 2 and not geocoded_address_info + # Use 1 pixel tolerance (minimum) for coordinate clicks to ensure we get results + building_tolerance = 1 if is_coordinate_search else 10 + building_result = await self._query_building_layer(x, y, tolerance=building_tolerance, buffer=25) + + building_parcel_id = None + if building_result: + building_attrs = building_result.get("attributes", {}) + building_parcel_id = building_attrs.get("lparz") + logger.debug(f"Found building with parcel ID: {building_parcel_id}") + + # Use MapServer identify to get parcel info + # For coordinate searches, use minimal tolerance (1 pixel) for spatial containment + # Note: tolerance=0 might be too strict, use 1 pixel minimum + parcel_tolerance = 1 if is_coordinate_search else tolerance + extent_buffer = 1000 # 1km buffer + map_extent = f"{x - extent_buffer},{y - extent_buffer},{x + extent_buffer},{y + extent_buffer}" + + params = { + "geometry": f"{x},{y}", + "geometryType": "esriGeometryPoint", + "sr": "2056", # LV95 + "layers": self.LAYER_AMTLICHE_VERMESSUNG, + "tolerance": parcel_tolerance, + "mapExtent": map_extent, + "imageDisplay": "800,600,96", + "returnGeometry": "true", + "f": "json" + } + + logger.info(f"Querying parcel at coordinates: ({x}, {y}) with tolerance: {parcel_tolerance}") + response = await self._make_request(self.MAPSERVER_IDENTIFY_URL, params) + + results = response.get("results", []) + if not results: + logger.warning(f"No parcel found at coordinates: ({x}, {y})") + return None + + # Strategy 1: If we have a building parcel ID, find the matching parcel + parcel_data = None + if building_parcel_id: + building_parcel_id_normalized = str(building_parcel_id).strip().upper() + + for result in results: + result_label = str(result.get('attributes', {}).get('label', '')).strip().upper() + result_number = str(result.get('attributes', {}).get('number', '')).strip().upper() + + if (result_label == building_parcel_id_normalized or + result_number == building_parcel_id_normalized or + result_label.endswith(building_parcel_id_normalized) or + building_parcel_id_normalized in result_label): + parcel_data = result + logger.info(f"Found matching parcel by building parcel ID: {result_label} (building had: {building_parcel_id})") + break + + # Strategy 2: For coordinate searches, always check spatial containment (point-in-polygon) + # This ensures we get the parcel that actually contains the point, not just the nearest one + # We MUST use spatial containment for coordinate searches - no legacy fallback + if is_coordinate_search: + logger.debug("Checking spatial containment for coordinate search") + + containing_parcels = [] + + for result in results: + geometry = result.get('geometry', {}) + if not geometry: + continue + + # Extract polygon rings from geometry + rings = geometry.get('rings', []) + if rings: + try: + # Check if point is inside this parcel's polygon + if self._point_in_polygon(x, y, rings): + containing_parcels.append(result) + parcel_label = result.get('attributes', {}).get('label', 'Unknown') + logger.debug(f"Point is inside parcel: {parcel_label}") + except Exception as e: + logger.debug(f"Error checking point-in-polygon for parcel {result.get('attributes', {}).get('label', 'Unknown')}: {e}") + continue + + if not containing_parcels: + # No parcels contain the point - this should not happen for valid coordinates + logger.warning(f"No parcels contain point ({x}, {y}) - this may indicate a data issue") + return None + + # If we already have a parcel from building match, verify it's in containing parcels + if parcel_data: + parcel_label = parcel_data.get('attributes', {}).get('label', 'Unknown') + parcel_in_containing = any( + p.get('attributes', {}).get('label') == parcel_label + for p in containing_parcels + ) + if not parcel_in_containing: + logger.warning(f"Building-matched parcel {parcel_label} does not contain point, using spatial containment instead") + parcel_data = None + + # If no parcel yet or building match was invalid, use spatial containment + if not parcel_data: + if building_parcel_id: + # Prefer parcel matching building parcel ID among containing parcels + building_parcel_id_normalized = str(building_parcel_id).strip().upper() + for parcel in containing_parcels: + result_label = str(parcel.get('attributes', {}).get('label', '')).strip().upper() + if (result_label == building_parcel_id_normalized or + result_label.endswith(building_parcel_id_normalized) or + building_parcel_id_normalized in result_label): + parcel_data = parcel + logger.info(f"Found matching containing parcel by building parcel ID: {result_label}") + break + + # If no building match, use first containing parcel + if not parcel_data: + parcel_data = containing_parcels[0] + parcel_label = parcel_data.get('attributes', {}).get('label', 'Unknown') + logger.info(f"Found parcel by spatial containment: {parcel_label} (from {len(containing_parcels)} containing parcels)") + + # Fallback for address searches only (not coordinate searches) + if not parcel_data and not is_coordinate_search: + parcel_data = results[0] + if building_parcel_id: + logger.debug(f"Could not find parcel matching building parcel ID {building_parcel_id}, using first result: {parcel_data.get('attributes', {}).get('label', 'Unknown')}") + + parcel_label = parcel_data.get('attributes', {}).get('label', 'Unknown') + logger.info(f"Found parcel: {parcel_label}") + + # Store the query coordinates (used for address lookup, not parcel centroid) + parcel_data['query_coordinates'] = {'x': x, 'y': y} + + # Attach geocoded address info to parcel_data for use by the route + if geocoded_address_info: + # Extract address components from geocoded result + attrs = geocoded_address_info.get('attrs', {}) + + # Get label and clean HTML tags first + label_raw = geocoded_address_info.get('label', '') + label_clean = re.sub(r'<[^>]+>', '', label_raw).strip() + + # Extract address components - try multiple sources + street_list = attrs.get("strname", []) + street = street_list[0] if isinstance(street_list, list) and street_list else None + + # House number can be in attrs.deinr or top-level num + house_number = attrs.get("deinr") or geocoded_address_info.get("num") + if house_number: + house_number = str(house_number).strip() + + # Postal code + plz = attrs.get("dplz4") + if plz: + plz = str(plz).strip() + + # Municipality + municipality = attrs.get("dplzname") or attrs.get("ggdename") + + # If street not found in attrs, try to parse from label + if not street and label_clean: + # Parse "Street Number" from label (e.g., "Ueberlandstrasse 11 8050 Zürich") + label_parts = label_clean.split() + if len(label_parts) >= 2: + # Try to find street name (everything before the number) + for i in range(len(label_parts) - 1, 0, -1): + if label_parts[i].isdigit(): + street = " ".join(label_parts[:i]) + break + + # If postal code not found, try to extract from label + if not plz and label_clean: + # Look for 4-digit postal code in label + postal_match = re.search(r'\b(\d{4})\b', label_clean) + if postal_match: + plz = postal_match.group(1) + + # If municipality not found, try to extract from label + if not municipality and label_clean: + # Municipality is usually after postal code + # Format: "Street Number POSTALCODE Municipality" + label_parts = label_clean.split() + if len(label_parts) >= 2: + # Find postal code position and take what comes after + for i, part in enumerate(label_parts): + if len(part) == 4 and part.isdigit(): + if i + 1 < len(label_parts): + municipality = " ".join(label_parts[i+1:]) + break + + # Clean municipality name + if municipality: + municipality = self._clean_municipality_name(municipality) + + # Construct full address - prefer label_clean, but build from components if needed + if label_clean: + full_address = label_clean + elif street and house_number and plz and municipality: + full_address = f"{street} {house_number}, {plz} {municipality}" + elif street and house_number: + full_address = f"{street} {house_number}" + if plz: + full_address += f", {plz}" + if municipality: + full_address += f" {municipality}" + else: + full_address = label_clean or original_location + + parcel_data['geocoded_address'] = { + 'label': label_clean, + 'street': street, + 'house_number': house_number, + 'plz': plz, + 'municipality': municipality, + 'full_address': full_address + } + + logger.debug(f"Attached geocoded address to parcel: {full_address}") + logger.debug(f"Address components - street: {street}, house_number: {house_number}, plz: {plz}, municipality: {municipality}") + + # Log warning if geocoded address doesn't match input (for debugging) + if geocoded_address_info and original_location and any(c.isalpha() for c in original_location): + label_raw = geocoded_address_info.get('label', '') + geocoded_label = re.sub(r'<[^>]+>', '', label_raw).lower().strip() + original_lower = original_location.lower() + + # Check if key parts match + original_parts = set(p.strip() for p in original_lower.split() if len(p.strip()) > 2) + geocoded_parts = set(p.strip() for p in geocoded_label.split() if len(p.strip()) > 2) + + # Check if street name and number are similar + match_score = len(original_parts.intersection(geocoded_parts)) / max(len(original_parts), 1) + + if match_score < 0.5: + logger.warning( + f"Geocoded address may not match input! " + f"Input: '{original_location}' -> Geocoded: '{geocoded_label}' " + f"(match score: {match_score:.2f})" + ) + + return parcel_data + + except Exception as e: + logger.error(f"Error searching parcel for location '{location}': {e}") + raise + + def extract_parcel_attributes(self, parcel_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract and normalize parcel attributes from MapServer response. + + Args: + parcel_data: Raw parcel data from MapServer identify + + Returns: + Dictionary with normalized parcel attributes compatible with Parzelle model + """ + attributes = parcel_data.get("attributes", {}) + geometry = parcel_data.get("geometry") + + # Extract common parcel attributes + # Note: Attribute names depend on the actual data structure from the API + # These are common fields, adjust based on actual API response + extracted = { + "label": attributes.get("label") or attributes.get("parzellennummer") or attributes.get("nummer") or "Unknown", + "parzellenAliasTags": [], + "strasseNr": attributes.get("adresse") or attributes.get("strasse"), + "plz": attributes.get("plz"), + "eigentuemerschaft": attributes.get("eigentuemer") or attributes.get("eigentuemerschaft"), + "bauzone": attributes.get("bauzone") or attributes.get("zonierung"), + "perimeter": self._convert_geometry_to_geopolylinie(geometry) if geometry else None, + "kontextGemeinde": attributes.get("gemeinde") or attributes.get("gemeindename"), + # Additional metadata + "raw_attributes": attributes # Keep raw data for reference + } + + return extracted + + async def get_parcel_polygon( + self, + gemeinde: str, + parzellen_nr: str, + sr: int = 2056 + ) -> Optional[Dict[str, Any]]: + """ + Holt die vollständige Polygon-Geometrie einer Parzelle nach Gemeinde und Parzellennummer. + + Args: + gemeinde: Name der Gemeinde (z.B. "Bern") + parzellen_nr: Parzellennummer (z.B. "1234") + sr: Koordinatensystem (2056=LV95, 4326=WGS84) + + Returns: + GeoJSON-Feature mit Polygon-Koordinaten oder None wenn nicht gefunden + """ + try: + # Schritt 1: Parzelle suchen + search_params = { + "searchText": f"{gemeinde} {parzellen_nr}", + "type": "locations", + "origins": "parcel", + "sr": str(sr) + } + + logger.info(f"Searching for parcel: {gemeinde} {parzellen_nr}") + response = await self._make_request(self.GEOCODING_URL, search_params) + + search_results = response.get("results", []) + if not search_results: + logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden") + return None + + # Extract coordinates using shared helper method + search_result = search_results[0] + coords = self._extract_coordinates_from_search_result(search_result) + + if coords is None: + logger.warning(f"Could not extract coordinates from search result") + return None + + x, y = coords + logger.info(f"Parzelle gefunden: {parcel.get('label', 'Unknown')}, Zentrum: E={x}, N={y}") + + # Schritt 2: Polygon-Geometrie abrufen + identify_params = { + "geometry": f"{x},{y}", + "geometryType": "esriGeometryPoint", + "layers": self.LAYER_AMTLICHE_VERMESSUNG, + "tolerance": "0", + "returnGeometry": "true", + "geometryFormat": "geojson", + "sr": str(sr), + "imageDisplay": "0,0,0", + "mapExtent": "0,0,0,0" + } + + logger.info(f"Fetching polygon geometry for parcel at ({x}, {y})") + identify_response = await self._make_request(self.MAPSERVER_IDENTIFY_URL, identify_params) + + identify_results = identify_response.get("results", []) + if not identify_results: + logger.warning("Keine Geometrie gefunden") + return None + + # Return the GeoJSON feature + feature = identify_results[0] + logger.info(f"Successfully retrieved polygon geometry with {len(feature.get('geometry', {}).get('coordinates', [[]])[0])} points") + + return feature + + except Exception as e: + logger.error(f"Error getting parcel polygon for {gemeinde} {parzellen_nr}: {e}", exc_info=True) + return None + + def extract_boundary_points(self, feature: Dict[str, Any]) -> List[List[float]]: + """ + Extrahiert alle Grenzpunkte aus dem GeoJSON-Feature. + + Args: + feature: GeoJSON Feature mit Polygon-Geometrie + + Returns: + Liste von Koordinatenpaaren [[x, y], ...] in LV95 + """ + try: + geometry = feature.get("geometry", {}) + + if geometry.get("type") == "Polygon": + # Äusserer Ring = erste Liste von Koordinaten + return geometry["coordinates"][0] + elif geometry.get("type") == "MultiPolygon": + # Alle Ringe aller Polygone + all_points = [] + for polygon in geometry["coordinates"]: + all_points.extend(polygon[0]) + return all_points + + return [] + + except Exception as e: + logger.error(f"Error extracting boundary points: {e}") + return [] + + async def find_neighboring_parcels( + self, + parcel_data: Dict[str, Any], + selected_parcel_id: str, + sample_distance: float = 20.0, # Balanced distance for good coverage + max_sample_points: int = 30, # Increased to ensure all vertices + some intermediate points + max_neighbors: int = 15, # Increased to find more neighbors + max_concurrent: int = 50 # Process up to 50 queries concurrently (maximum parallelization) + ) -> List[Dict[str, Any]]: + """ + Find all parcels that touch the boundary of the selected parcel. + + This method samples points along the parcel boundary and queries parcels + at each point to find all neighboring parcels that actually touch the boundary. + Optimized for speed with intelligent sampling and early stopping. + + Args: + parcel_data: The selected parcel's data dictionary (must contain geometry) + selected_parcel_id: The ID of the selected parcel (to exclude from results) + sample_distance: Distance in meters between sample points along the boundary (default: 15m) + max_sample_points: Maximum number of sample points to query (default: 30) + max_neighbors: Stop early if we find this many unique neighbors (default: 15) + + Returns: + List of neighboring parcel dictionaries with id, egrid, and number + """ + try: + geometry = parcel_data.get("geometry", {}) + if not geometry: + logger.warning("No geometry found in parcel_data for finding neighbors") + return [] + + # Extract boundary points from geometry + boundary_points = [] + + # Handle ESRI format (rings) + if "rings" in geometry and geometry["rings"]: + ring = geometry["rings"][0] # Outer ring + for coord in ring: + if len(coord) >= 2: + boundary_points.append((coord[0], coord[1])) + + # Handle GeoJSON format + elif geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + for coord in ring: + if len(coord) >= 2: + boundary_points.append((coord[0], coord[1])) + + if not boundary_points: + logger.warning("No boundary points found in parcel geometry") + return [] + + # Sample points along the boundary, offset outward + # Increased offset to ensure we're outside the selected parcel + offset_distance = 8.0 # meters - increased from 2.0 to ensure we're outside + sampled_points = [] + + # Calculate centroid for determining outward direction + centroid_x = sum(p[0] for p in boundary_points) / len(boundary_points) + centroid_y = sum(p[1] for p in boundary_points) / len(boundary_points) + + # First pass: Always include ALL vertices (critical for finding all neighbors) + # Don't limit vertices - they're essential for coverage + edge_info = [] # Store edge information for intermediate sampling + for i in range(len(boundary_points)): + p1 = boundary_points[i] + p2 = boundary_points[(i + 1) % len(boundary_points)] + + # Calculate edge vector + edge_dx = p2[0] - p1[0] + edge_dy = p2[1] - p1[1] + edge_length = (edge_dx**2 + edge_dy**2)**0.5 + + if edge_length > 0: + # Calculate perpendicular vector (pointing outward) + perp_dx = -edge_dy / edge_length + perp_dy = edge_dx / edge_length + + # Check if this points outward (away from centroid) + to_point_dx = p1[0] - centroid_x + to_point_dy = p1[1] - centroid_y + if (perp_dx * to_point_dx + perp_dy * to_point_dy) < 0: + perp_dx = -perp_dx + perp_dy = -perp_dy + + # Offset vertex point outward + offset_p1 = (p1[0] + perp_dx * offset_distance, p1[1] + perp_dy * offset_distance) + sampled_points.append(offset_p1) + + # Store edge info for intermediate sampling + edge_info.append({ + 'p1': p1, + 'edge_length': edge_length, + 'perp_dx': perp_dx, + 'perp_dy': perp_dy, + 'edge_dx': edge_dx, + 'edge_dy': edge_dy + }) + + # Second pass: Add intermediate points for edges longer than sample_distance + # This ensures we don't miss neighbors that touch in the middle of long edges + for edge in edge_info: + if len(sampled_points) >= max_sample_points: + break + + # Add intermediate points for edges longer than sample_distance + if edge['edge_length'] > sample_distance: + # Calculate how many intermediate points we need + num_samples = int(edge['edge_length'] / sample_distance) + # Limit to reasonable number per edge (max 3) to avoid too many points + num_samples = min(num_samples, 3) + + for j in range(1, num_samples + 1): + if len(sampled_points) >= max_sample_points: + break + t = j / (num_samples + 1) + # Interpolate along edge + interp_x = edge['p1'][0] + t * edge['edge_dx'] + interp_y = edge['p1'][1] + t * edge['edge_dy'] + # Offset outward + offset_x = interp_x + edge['perp_dx'] * offset_distance + offset_y = interp_y + edge['perp_dy'] * offset_distance + sampled_points.append((offset_x, offset_y)) + + # Don't limit vertices, but limit total if we have too many intermediate points + # This ensures we always have all vertices for complete coverage + if len(sampled_points) > max_sample_points: + # Keep all vertices, limit intermediate points + vertex_count = len(boundary_points) + if vertex_count <= max_sample_points: + # Keep vertices + some intermediate points + sampled_points = sampled_points[:max_sample_points] + else: + # Too many vertices - keep all vertices anyway (they're critical) + sampled_points = sampled_points[:len(sampled_points)] + + # Query parcels in parallel batches for much faster performance + neighboring_parcels = {} + tolerance = 2 # Small tolerance for boundary queries + + async def query_point(point: Tuple[float, float]) -> Optional[Dict[str, Any]]: + """Query a single point and return parcel data if found.""" + try: + x, y = point + location_str = f"{x},{y}" + adj_data = await self.search_parcel(location_str, tolerance=tolerance) + + if adj_data: + adj_attrs = adj_data.get("attributes", {}) + adj_id = adj_attrs.get("label") or adj_attrs.get("number") + + # Exclude the selected parcel itself + if adj_id and adj_id != selected_parcel_id: + # Extract geometry information + adj_geometry = adj_data.get("geometry", {}) + extracted_attrs = self.extract_parcel_attributes(adj_data) + + return { + "id": adj_id, + "egrid": adj_attrs.get("egris_egrid"), + "number": adj_attrs.get("number"), + "perimeter": extracted_attrs.get("perimeter"), + "geometry": adj_geometry + } + except Exception: + # Silently skip errors for speed + pass + return None + + # Process ALL queries concurrently for maximum speed + # Use semaphore to limit concurrent connections to avoid overwhelming the API + semaphore = asyncio.Semaphore(max_concurrent) + + async def query_point_with_semaphore(point: Tuple[float, float]) -> Optional[Dict[str, Any]]: + """Query a point with semaphore-controlled concurrency.""" + async with semaphore: + return await query_point(point) + + # Launch all queries concurrently (semaphore will limit actual concurrency) + results = await asyncio.gather(*[query_point_with_semaphore(point) for point in sampled_points], return_exceptions=True) + + # Process all results (minimal logging for speed) + for result in results: + if isinstance(result, Exception): + continue + + if result and result["id"] not in neighboring_parcels: + neighboring_parcels[result["id"]] = result + + # Early stop if we've found enough neighbors + if len(neighboring_parcels) >= max_neighbors: + break + + result_list = list(neighboring_parcels.values()) + logger.info(f"Found {len(result_list)} neighboring parcels for parcel {selected_parcel_id} (queried {len(sampled_points)} points)") + + return result_list + + except Exception as e: + logger.error(f"Error finding neighboring parcels: {e}", exc_info=True) + return [] + + def _convert_geometry_to_geopolylinie(self, geometry: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Convert ESRI geometry or GeoJSON to GeoPolylinie format. + + Args: + geometry: ESRI geometry (rings) or GeoJSON geometry from MapServer response + + Returns: + GeoPolylinie-compatible dictionary or None + """ + try: + # Handle GeoJSON format (from get_parcel_polygon) + if geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = { + "koordinatensystem": "LV95", + "x": coord[0], # GeoJSON: [x, y] = [easting, northing] + "y": coord[1], + "z": coord[2] if len(coord) > 2 else None + } + punkte.append(punkt) + + return { + "closed": True, + "punkte": punkte + } + + # Handle ESRI format (rings) - existing code + elif "rings" in geometry and geometry["rings"]: + ring = geometry["rings"][0] # Take first ring (outer boundary) + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = { + "koordinatensystem": "LV95", + "x": coord[0], + "y": coord[1], + "z": coord[2] if len(coord) > 2 else None + } + punkte.append(punkt) + + return { + "closed": True, + "punkte": punkte + } + + return None + + except Exception as e: + logger.error(f"Error converting geometry to GeoPolylinie: {e}") + return None + diff --git a/modules/datamodels/datamodelPagination.py b/modules/datamodels/datamodelPagination.py index f9fdebfd..9815027f 100644 --- a/modules/datamodels/datamodelPagination.py +++ b/modules/datamodels/datamodelPagination.py @@ -80,3 +80,28 @@ class PaginatedResponse(BaseModel, Generic[T]): model_config = ConfigDict(arbitrary_types_allowed=True) + +def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]: + """ + Normalize pagination dictionary to handle frontend variations. + Moves top-level "search" field into filters if present. + + Args: + pagination_dict: Raw pagination dictionary from frontend + + Returns: + Normalized pagination dictionary ready for PaginationParams parsing + """ + if not pagination_dict: + return pagination_dict + + # Create a copy to avoid modifying the original + normalized = dict(pagination_dict) + + # Move top-level "search" into filters if present + if "search" in normalized: + if "filters" not in normalized or normalized["filters"] is None: + normalized["filters"] = {} + normalized["filters"]["search"] = normalized.pop("search") + + return normalized diff --git a/modules/datamodels/datamodelRealEstate.py b/modules/datamodels/datamodelRealEstate.py new file mode 100644 index 00000000..fa9e717e --- /dev/null +++ b/modules/datamodels/datamodelRealEstate.py @@ -0,0 +1,667 @@ +""" +Real Estate data models for Architektur-Planungs-App. +Implements a general Swiss architecture planning data model. +(PEK is one example implementation, but the model is general-purpose) +""" + +from typing import List, Dict, Any, Optional, ForwardRef +from enum import Enum +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels +from modules.shared.timeUtils import getUtcTimestamp +import uuid + +# ===== Enums ===== + +class StatusProzess(str, Enum): + """Project process status""" + EINGANG = "Eingang" + ANALYSE = "Analyse" + STUDIE = "Studie" + PLANUNG = "Planung" + BAURECHTSVERFAHREN = "Baurechtsverfahren" + UMSETZUNG = "Umsetzung" + ARCHIV = "Archiv" + + +class DokumentTyp(str, Enum): + """Document type for categorization""" + KANTON_BAUREGLEMENT_AKTUELL = "kantonBaureglementAktuell" + KANTON_BAUREGLEMENT_REVISION = "kantonBaureglementRevision" + KANTON_BAUVERORDNUNG_AKTUELL = "kantonBauverordnungAktuell" + KANTON_BAUVERORDNUNG_REVISION = "kantonBauverordnungRevision" + GEMEINDE_BZO_AKTUELL = "gemeindeBzoAktuell" + GEMEINDE_BZO_REVISION = "gemeindeBzoRevision" + + +class JaNein(str, Enum): + """Three-valued state for optional yes/no questions""" + UNBEKANNT = "" # Empty string for unknown/not captured + JA = "Ja" + NEIN = "Nein" + + +class GeoTag(str, Enum): + """Geopoint categories""" + K1 = "K1" # Fixpunkt höchster Genauigkeit + K2 = "K2" # Fixpunkt mittlerer Genauigkeit + K3 = "K3" # Fixpunkt niedriger Genauigkeit + GEOMETER = "Geometer" # Vom Geometer vermessener Punkt + + +# ===== Helper Models (must be defined before main models) ===== + +class GeoPunkt(BaseModel): + """Represents a 3D point with reference.""" + koordinatensystem: str = Field( + description="Coordinate system (e.g. 'LV95', 'EPSG:2056')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + x: float = Field( + description="East value (E) [m], typically 2'480'000 - 2'840'000", + frontend_type="number", + frontend_readonly=False, + frontend_required=True, + ) + y: float = Field( + description="North value (N) [m], typically 1'070'000 - 1'300'000", + frontend_type="number", + frontend_readonly=False, + frontend_required=True, + ) + z: Optional[float] = Field( + None, + description="Height above sea level [m]", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + referenz: Optional[GeoTag] = Field( + None, + description="Point categorization", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + + +class GeoPolylinie(BaseModel): + """Represents a line or polygon from multiple GeoPunkte.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + ) + closed: bool = Field( + description="Is the GeoPolylinie closed (polygon)?", + frontend_type="boolean", + frontend_readonly=False, + frontend_required=True, + ) + punkte: List[GeoPunkt] = Field( + default_factory=list, + description="List of GeoPunkte forming the GeoPolylinie", + frontend_type="json", + frontend_readonly=False, + frontend_required=True, + ) + + +class Dokument(BaseModel): + """Supporting data object for file and URL management with versioning.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate this document belongs to", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Document label", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + versionsbezeichnung: Optional[str] = Field( + None, + description="Version number or designation (e.g. 'v1.0', 'Rev. A')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumentTyp: Optional[DokumentTyp] = Field( + None, + description="Document type", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + dokumentReferenz: str = Field( + description="File path or URL", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + quelle: Optional[str] = Field( + None, + description="Source of the document", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + mimeType: Optional[str] = Field( + None, + description="MIME type of the document (e.g. 'application/pdf', 'image/png')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + kategorienTags: List[str] = Field( + default_factory=list, + description="Document categorization tags", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Kontext(BaseModel): + """Supporting data object for flexible additional information.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + ) + thema: str = Field( + description="Theme designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + inhalt: str = Field( + description="Detailed information (text)", + frontend_type="textarea", + frontend_readonly=False, + frontend_required=True, + ) + + +class Land(BaseModel): + """National level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Country name (e.g. 'Schweiz')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + abk: Optional[str] = Field( + None, + description="Abbreviation (e.g. 'CH')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="National laws/documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="National context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Kanton(BaseModel): + """Cantonal level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Canton name (e.g. 'Zürich')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + id_land: Optional[str] = Field( + None, + description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + abk: Optional[str] = Field( + None, + description="Abbreviation (e.g. 'ZH')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Cantonal documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Canton-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Gemeinde(BaseModel): + """Municipal level administrative entity.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Municipality name (e.g. 'Zürich')", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + id_kanton: Optional[str] = Field( + None, + description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + plz: Optional[str] = Field( + None, + description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Municipal documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Municipality-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# ===== Main Models (use ForwardRef for circular references) ===== + +# Forward references for circular dependencies +ParzelleRef = ForwardRef('Parzelle') + + +class Parzelle(BaseModel): + """Represents a plot with all building law properties.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + + # Grunddaten + label: str = Field( + description="Plot designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + parzellenAliasTags: List[str] = Field( + default_factory=list, + description="Additional plot names or field names", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + eigentuemerschaft: Optional[str] = Field( + None, + description="Owner of the plot", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + strasseNr: Optional[str] = Field( + None, + description="Street and house number", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + plz: Optional[str] = Field( + None, + description="Postal code of the plot", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Geografischer Kontext + perimeter: Optional[GeoPolylinie] = Field( + None, + description="Plot boundary as closed GeoPolylinie", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + baulinie: Optional[GeoPolylinie] = Field( + None, + description="Building line of the plot", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + kontextGemeinde: Optional[str] = Field( + None, + description="Municipality ID (Foreign Key)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Bebauungsparameter + bauzone: Optional[str] = Field( + None, + description="Building zone designation (e.g. W3, WG2, etc.)", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + az: Optional[float] = Field( + None, + description="Ausnützungsziffer", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + bz: Optional[float] = Field( + None, + description="Bebauungsziffer", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + vollgeschossZahl: Optional[int] = Field( + None, + description="Number of allowed full floors", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + anrechenbarDachgeschoss: Optional[float] = Field( + None, + description="Accountable portion of attic (0.0 - 1.0)", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + anrechenbarUntergeschoss: Optional[float] = Field( + None, + description="Accountable portion of basement (0.0 - 1.0)", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + gebaeudehoeheMax: Optional[float] = Field( + None, + description="Maximum building height in meters", + frontend_type="number", + frontend_readonly=False, + frontend_required=False, + ) + + # Abstandsregelungen + regelnGrenzabstand: List[str] = Field( + default_factory=list, + description="Regulations for boundary distance", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + regelnMehrlaengenzuschlag: List[str] = Field( + default_factory=list, + description="Regulations for additional length surcharge", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + regelnMehrhoehenzuschlag: List[str] = Field( + default_factory=list, + description="Regulations for additional height surcharge", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + # Eigenschaften (Ja/Nein) + parzelleBebaut: Optional[JaNein] = Field( + None, + description="Is the plot built?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + parzelleErschlossen: Optional[JaNein] = Field( + None, + description="Is the plot developed?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + parzelleHanglage: Optional[JaNein] = Field( + None, + description="Is the plot on a slope?", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + + # Schutzzonen + laermschutzzone: Optional[str] = Field( + None, + description="Noise protection zone (e.g. 'II')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + hochwasserschutzzone: Optional[str] = Field( + None, + description="Flood protection zone (e.g. 'tief')", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + grundwasserschutzzone: Optional[str] = Field( + None, + description="Groundwater protection zone", + frontend_type="text", + frontend_readonly=False, + frontend_required=False, + ) + + # Beziehungen (stored as JSONB in database) + parzellenNachbarschaft: List[Dict[str, Any]] = Field( + default_factory=list, + description="Neighboring plots (stored as list of Parzelle IDs or full objects)", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Plot-specific documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Plot-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +class Projekt(BaseModel): + """Core object representing a construction project.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + mandateId: str = Field( + description="ID of the mandate", + frontend_type="text", + frontend_readonly=True, + frontend_required=False, + ) + label: str = Field( + description="Project designation", + frontend_type="text", + frontend_readonly=False, + frontend_required=True, + ) + statusProzess: Optional[StatusProzess] = Field( + None, + description="Project status", + frontend_type="select", + frontend_readonly=False, + frontend_required=False, + ) + perimeter: Optional[GeoPolylinie] = Field( + None, + description="Envelope of all plots in the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + baulinie: Optional[GeoPolylinie] = Field( + None, + description="Building line of the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + parzellen: List[Parzelle] = Field( + default_factory=list, + description="All plots of the project", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + dokumente: List[Dokument] = Field( + default_factory=list, + description="Project-specific documents", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + kontextInformationen: List[Kontext] = Field( + default_factory=list, + description="Project-specific context information", + frontend_type="json", + frontend_readonly=False, + frontend_required=False, + ) + + +# Resolve forward references +Parzelle.model_rebuild() +Projekt.model_rebuild() + + +# Register labels for frontend +registerModelLabels( + "Projekt", + {"en": "Project", "fr": "Projet", "de": "Projekt"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + "statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, + }, +) + +registerModelLabels( + "Parzelle", + {"en": "Plot", "fr": "Parcelle", "de": "Parzelle"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, + }, +) + +registerModelLabels( + "Dokument", + {"en": "Document", "fr": "Document", "de": "Dokument"}, + { + "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, + }, +) + diff --git a/modules/features/realEstate/__init__.py b/modules/features/realEstate/__init__.py new file mode 100644 index 00000000..48368b52 --- /dev/null +++ b/modules/features/realEstate/__init__.py @@ -0,0 +1,4 @@ +""" +Real Estate feature module. +""" + diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py new file mode 100644 index 00000000..149fd8d8 --- /dev/null +++ b/modules/features/realEstate/mainRealEstate.py @@ -0,0 +1,2049 @@ +""" +Real Estate feature main logic. +Handles database operations with AI-powered natural language processing. +Stateless implementation without session management. +""" + +import logging +import json +from typing import Optional, Dict, Any, List +from fastapi import HTTPException, status +from shapely.geometry import Polygon +from shapely.ops import unary_union +from modules.datamodels.datamodelUam import User +from modules.datamodels.datamodelRealEstate import ( + Projekt, + Parzelle, + StatusProzess, + GeoPolylinie, + GeoPunkt, + Kontext, + Gemeinde, + Kanton, + Land, +) +from modules.services import getInterface as getServices +from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface +from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector + +logger = logging.getLogger(__name__) + + +# ===== Geometry Utilities ===== + +def geopolylinie_to_shapely_polygon(geopolylinie: GeoPolylinie) -> Polygon: + """ + Convert GeoPolylinie to Shapely Polygon. + + Args: + geopolylinie: GeoPolylinie instance with punkte list + + Returns: + Shapely Polygon object + """ + if not geopolylinie or not geopolylinie.punkte: + raise ValueError("GeoPolylinie must have at least one point") + + # Extract coordinates from punkte + coordinates = [] + for punkt in geopolylinie.punkte: + coordinates.append((punkt.x, punkt.y)) + + # Ensure polygon is closed (first point == last point) + if len(coordinates) < 3: + raise ValueError("Polygon must have at least 3 points") + + # Close polygon if not already closed + if coordinates[0] != coordinates[-1]: + coordinates.append(coordinates[0]) + + return Polygon(coordinates) + + +def shapely_polygon_to_geopolylinie(polygon: Polygon) -> GeoPolylinie: + """ + Convert Shapely Polygon to GeoPolylinie. + + Args: + polygon: Shapely Polygon object + + Returns: + GeoPolylinie instance with LV95 coordinate system + """ + if not polygon or polygon.is_empty: + raise ValueError("Polygon must not be empty") + + # Extract exterior coordinates + exterior_coords = list(polygon.exterior.coords) + + # Remove duplicate last point if present (Shapely includes it) + if len(exterior_coords) > 1 and exterior_coords[0] == exterior_coords[-1]: + exterior_coords = exterior_coords[:-1] + + # Convert to GeoPunkt list + punkte = [] + for coord in exterior_coords: + punkt = GeoPunkt( + koordinatensystem="LV95", + x=float(coord[0]), + y=float(coord[1]), + z=None + ) + punkte.append(punkt) + + return GeoPolylinie( + closed=True, + punkte=punkte + ) + + +def combine_parcel_geometries(geometries: List[GeoPolylinie]) -> GeoPolylinie: + """ + Combine multiple parcel geometries into a single outer outline. + + Uses Shapely union operation to merge polygons and automatically + removes internal edges. The result is a clean outer boundary. + + Args: + geometries: List of GeoPolylinie instances to combine + + Returns: + Combined GeoPolylinie representing the outer outline + + Raises: + ValueError: If geometries list is empty or invalid + """ + if not geometries or len(geometries) == 0: + raise ValueError("At least one geometry is required") + + if len(geometries) == 1: + # Single geometry - return as-is + return geometries[0] + + # Convert all geometries to Shapely Polygons + shapely_polygons = [] + for geo in geometries: + try: + polygon = geopolylinie_to_shapely_polygon(geo) + if not polygon.is_empty: + shapely_polygons.append(polygon) + except Exception as e: + logger.warning(f"Error converting geometry to Shapely Polygon: {e}") + continue + + if not shapely_polygons: + raise ValueError("No valid geometries to combine") + + if len(shapely_polygons) == 1: + # Only one valid polygon - convert back + return shapely_polygon_to_geopolylinie(shapely_polygons[0]) + + # Perform union operation - automatically removes internal edges + try: + combined = unary_union(shapely_polygons) + + # Handle MultiPolygon case (disconnected parcels) + if hasattr(combined, 'geoms'): + # Multiple separate polygons - combine their exteriors + # For now, take the largest polygon or combine all exteriors + # In practice, we might want to keep them separate or combine differently + largest = max(combined.geoms, key=lambda p: p.area) + combined = largest + + # Extract outer boundary + if combined.is_empty: + raise ValueError("Union resulted in empty geometry") + + # Convert back to GeoPolylinie + result = shapely_polygon_to_geopolylinie(combined) + logger.info(f"Combined {len(geometries)} geometries into single outline with {len(result.punkte)} points") + return result + + except Exception as e: + logger.error(f"Error combining geometries: {e}", exc_info=True) + raise ValueError(f"Failed to combine geometries: {str(e)}") + + +def filter_neighbor_parcels( + neighbors: List[Dict[str, Any]], + selected_geometries: List[GeoPolylinie] +) -> List[Dict[str, Any]]: + """ + Filter neighbor parcels to exclude those that are part of the selected parcels. + + Uses geometric comparison to check if neighbor parcels intersect or touch + any of the selected parcel geometries. + + Args: + neighbors: List of neighbor parcel dictionaries (must have 'perimeter' or 'geometry_geojson') + selected_geometries: List of GeoPolylinie instances representing selected parcels + + Returns: + Filtered list of neighbor parcels (excluding selected ones) + """ + if not neighbors or not selected_geometries: + return neighbors + + # Convert selected geometries to Shapely Polygons for comparison + selected_polygons = [] + for geo in selected_geometries: + try: + polygon = geopolylinie_to_shapely_polygon(geo) + if not polygon.is_empty: + selected_polygons.append(polygon) + except Exception as e: + logger.warning(f"Error converting selected geometry for filtering: {e}") + continue + + if not selected_polygons: + # No valid selected geometries - return all neighbors + return neighbors + + # Filter neighbors + filtered_neighbors = [] + for neighbor in neighbors: + try: + # Try to get geometry from neighbor + neighbor_geometry = None + + # Check for perimeter (GeoPolylinie format) + if neighbor.get("perimeter"): + perimeter = neighbor["perimeter"] + if isinstance(perimeter, dict) and perimeter.get("punkte"): + # Convert to GeoPolylinie + punkte = [] + for p in perimeter["punkte"]: + punkt = GeoPunkt( + koordinatensystem=p.get("koordinatensystem", "LV95"), + x=float(p.get("x", 0)), + y=float(p.get("y", 0)), + z=p.get("z") + ) + punkte.append(punkt) + neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte) + + # Check for geometry_geojson + elif neighbor.get("geometry_geojson"): + geo_json = neighbor["geometry_geojson"] + geometry = geo_json.get("geometry") if isinstance(geo_json, dict) else geo_json + + if geometry and geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = GeoPunkt( + koordinatensystem="LV95", + x=float(coord[0]), + y=float(coord[1]), + z=float(coord[2]) if len(coord) > 2 else None + ) + punkte.append(punkt) + neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte) + + if not neighbor_geometry: + # No geometry available - include neighbor (can't filter without geometry) + filtered_neighbors.append(neighbor) + continue + + # Convert neighbor geometry to Shapely Polygon + neighbor_polygon = geopolylinie_to_shapely_polygon(neighbor_geometry) + + # Check if neighbor intersects or touches any selected parcel + is_selected = False + for selected_polygon in selected_polygons: + if neighbor_polygon.intersects(selected_polygon) or neighbor_polygon.touches(selected_polygon): + # Check if they're actually the same (within tolerance) + # If areas are very similar, it's likely the same parcel + area_diff = abs(neighbor_polygon.area - selected_polygon.area) + if area_diff < 1.0: # Less than 1 m² difference + is_selected = True + break + # Also check if one contains the other (shouldn't happen for neighbors, but check anyway) + if neighbor_polygon.contains(selected_polygon) or selected_polygon.contains(neighbor_polygon): + is_selected = True + break + + if not is_selected: + filtered_neighbors.append(neighbor) + else: + logger.debug(f"Filtered out neighbor parcel {neighbor.get('id')} - part of selected parcels") + + except Exception as e: + logger.warning(f"Error filtering neighbor parcel {neighbor.get('id')}: {e}") + # On error, include neighbor (better to show too many than too few) + filtered_neighbors.append(neighbor) + + logger.info(f"Filtered {len(neighbors)} neighbors to {len(filtered_neighbors)} (removed {len(neighbors) - len(filtered_neighbors)} selected parcels)") + return filtered_neighbors + + +# ===== Swisstopo Integration ===== + +async def fetch_parcel_polygon_from_swisstopo( + gemeinde: str, + parzellen_nr: str, + sr: int = 2056 +) -> Optional[Dict[str, Any]]: + """ + Holt die vollständige Polygon-Geometrie einer Parzelle von Swisstopo API. + + Args: + gemeinde: Name der Gemeinde (z.B. "Bern") + parzellen_nr: Parzellennummer (z.B. "1234") + sr: Koordinatensystem (2056=LV95, 4326=WGS84) + + Returns: + Dictionary mit GeoPolylinie-Format für perimeter-Feld, oder None wenn nicht gefunden + Format: {"closed": True, "punkte": [{"koordinatensystem": "LV95", "x": ..., "y": ..., "z": None}, ...]} + """ + try: + connector = SwissTopoMapServerConnector() + + # Get GeoJSON feature from Swisstopo + feature = await connector.get_parcel_polygon(gemeinde, parzellen_nr, sr) + + if not feature: + logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden in Swisstopo") + return None + + # Convert GeoJSON to GeoPolylinie format + geometry = feature.get("geometry", {}) + if geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] # Outer ring + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = { + "koordinatensystem": "LV95" if sr == 2056 else "WGS84", + "x": coord[0], # GeoJSON: [x, y] = [easting, northing] + "y": coord[1], + "z": coord[2] if len(coord) > 2 else None + } + punkte.append(punkt) + + logger.info(f"Successfully fetched polygon with {len(punkte)} points for {gemeinde} {parzellen_nr}") + + return { + "closed": True, + "punkte": punkte + } + + logger.warning(f"Unexpected geometry type in Swisstopo response: {geometry.get('type')}") + return None + + except Exception as e: + logger.error(f"Error fetching parcel polygon from Swisstopo: {e}", exc_info=True) + return None + + +# ===== Direkte Query-Ausführung (stateless) ===== + +async def executeDirectQuery( + currentUser: User, + queryText: str, + parameters: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Execute a database query directly without session management. + + Args: + currentUser: Current authenticated user + queryText: SQL query text + parameters: Optional parameters for parameterized queries + + Returns: + Dictionary containing query result (rows, columns, rowCount) + + Note: + - No session or query history is saved + - Query is executed directly and result is returned + - For production, validate and sanitize queries before execution + - TODO: Implement actual database query execution via interface + """ + try: + logger.info(f"Executing direct query for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"Query text: {queryText}") + if parameters: + logger.debug(f"Query parameters: {parameters}") + + # Execute query via Real Estate interface (stateless) + realEstateInterface = getRealEstateInterface(currentUser) + result = realEstateInterface.executeQuery(queryText, parameters) + + logger.info( + f"Query executed successfully: {result['rowCount']} rows in {result.get('executionTime', 0):.3f}s" + ) + + return { + "status": "success", + "rows": result["rows"], + "columns": result["columns"], + "rowCount": result["rowCount"], + "executionTime": result.get("executionTime", 0), + } + + except Exception as e: + logger.error(f"Error executing query: {str(e)}", exc_info=True) + raise + + +# ===== AI-basierte Intent-Erkennung und CRUD-Operationen ===== + +def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters: Dict[str, Any]) -> str: + """ + Format a human-readable summary of query results. + + Args: + entity_type: Type of entity (Projekt, Parzelle, etc.) + items: List of entity data dictionaries + filters: Filter parameters used in the query + + Returns: + Human-readable summary string + """ + if not items: + return f"Keine {entity_type} gefunden" + + count = len(items) + filter_desc = "" + if filters: + # Format filter description + if "kontextGemeinde" in filters: + filter_desc = f" in {filters['kontextGemeinde']}" + elif "plz" in filters: + filter_desc = f" mit PLZ {filters['plz']}" + elif "location_filter" in filters: + filter_desc = f" in {filters['location_filter']}" + + # Start with count + summary = f"Gefunden: {count} {entity_type}{filter_desc}" + + # Add details based on entity type + if entity_type == "Parzelle": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): # Limit to first 10 + parts = [] + + # Add label or ID + if item.get("label"): + parts.append(f"Parzelle '{item['label']}'") + elif item.get("id"): + parts.append(f"Parzelle {item['id'][:8]}...") + + # Add address + if item.get("strasseNr"): + parts.append(item["strasseNr"]) + + # Add PLZ and municipality + location_parts = [] + if item.get("plz"): + location_parts.append(item["plz"]) + if item.get("kontextGemeinde"): + location_parts.append(item["kontextGemeinde"]) + if location_parts: + parts.append(" ".join(location_parts)) + + # Add building zone + if item.get("bauzone"): + parts.append(f"Bauzone: {item['bauzone']}") + + summary += f"\n{i}. {', '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + elif entity_type == "Projekt": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + # Add label + if item.get("label"): + parts.append(f"'{item['label']}'") + + # Add status + if item.get("statusProzess"): + parts.append(f"Status: {item['statusProzess']}") + + # Add parcel count + parzellen = item.get("parzellen", []) + if parzellen: + parts.append(f"{len(parzellen)} Parzelle(n)") + + summary += f"\n{i}. {' - '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + elif entity_type == "Gemeinde": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + if item.get("label"): + parts.append(item["label"]) + if item.get("plz"): + parts.append(f"PLZ: {item['plz']}") + if item.get("abk"): + parts.append(f"Abk: {item['abk']}") + + summary += f"\n{i}. {', '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + elif entity_type == "Dokument": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + if item.get("label"): + parts.append(item["label"]) + if item.get("dokumentTyp"): + parts.append(f"Typ: {item['dokumentTyp']}") + if item.get("quelle"): + parts.append(f"Quelle: {item['quelle']}") + + summary += f"\n{i}. {', '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + else: + # Generic format for other entity types + if count <= 5: + summary += "\n\nDetails:" + for i, item in enumerate(items, 1): + label = item.get("label") or item.get("id", "") + if label: + summary += f"\n{i}. {label}" + + return summary + + +async def processNaturalLanguageCommand( + currentUser: User, + userInput: str, +) -> Dict[str, Any]: + """ + Process natural language user input and execute corresponding CRUD operations. + + Uses AI to analyze user intent and extract parameters, then executes the appropriate + CRUD operation through the interface. Works stateless without session management. + + Args: + currentUser: Current authenticated user + userInput: Natural language command from user + + Returns: + Dictionary containing operation result and metadata + + 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'" + """ + try: + logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {currentUser.mandateId})") + logger.debug(f"User input: {userInput}") + + # Initialize services for AI access + services = getServices(currentUser, workflow=None) + aiService = services.ai + + # Step 1: Analyze user intent with AI + intentAnalysis = await analyzeUserIntent(aiService, userInput) + + logger.info(f"Intent analysis result: intent={intentAnalysis.get('intent')}, entity={intentAnalysis.get('entity')}") + + # Step 2: Execute CRUD operation based on intent + result = await executeIntentBasedOperation( + currentUser=currentUser, + intent=intentAnalysis["intent"], + entity=intentAnalysis.get("entity"), + parameters=intentAnalysis.get("parameters", {}), + ) + + # Build user-friendly response + response = { + "success": True, + "intent": intentAnalysis["intent"], + "entity": intentAnalysis.get("entity"), + "result": result, + } + + # Add human-readable summary for operations + if intentAnalysis["intent"] == "CREATE" and isinstance(result, dict): + # Add confirmation message for CREATE operations + operation_result = result.get("result") + if isinstance(operation_result, dict): + entity_name = intentAnalysis.get('entity', 'Eintrag') + label = operation_result.get("label", operation_result.get("id", "")) + + # Build detailed message + msg_parts = [f"✅ {entity_name} '{label}' erfolgreich erstellt"] + + if entity_name == "Parzelle": + if operation_result.get("plz"): + msg_parts.append(f"PLZ: {operation_result['plz']}") + if operation_result.get("kontextGemeinde"): + msg_parts.append(f"Gemeinde: {operation_result['kontextGemeinde']}") + if operation_result.get("bauzone"): + msg_parts.append(f"Bauzone: {operation_result['bauzone']}") + + kontext_items = operation_result.get("kontextInformationen", []) + if kontext_items: + msg_parts.append(f"\n📋 {len(kontext_items)} Kontextinformationen gespeichert:") + for kontext in kontext_items[:5]: # Show first 5 + thema = kontext.get("thema", "") + inhalt = kontext.get("inhalt", "") + if thema and inhalt: + msg_parts.append(f" • {thema}: {inhalt}") + if len(kontext_items) > 5: + msg_parts.append(f" • ... und {len(kontext_items) - 5} weitere") + + elif entity_name == "Projekt": + if operation_result.get("statusProzess"): + msg_parts.append(f"Status: {operation_result['statusProzess']}") + parzellen = operation_result.get("parzellen", []) + if parzellen: + msg_parts.append(f"{len(parzellen)} Parzelle(n)") + + response["message"] = "\n".join(msg_parts) + + elif intentAnalysis["intent"] == "READ" and isinstance(result, dict): + operation_result = result.get("result") + if isinstance(operation_result, list): + response["count"] = len(operation_result) + entity_name = intentAnalysis.get('entity', 'Einträge') + + if len(operation_result) == 0: + # Provide helpful message for empty results + filter_info = intentAnalysis.get('parameters', {}) + if filter_info: + filter_desc = ", ".join([f"{k}={v}" for k, v in filter_info.items()]) + response["message"] = f"Keine {entity_name} gefunden mit Filter: {filter_desc}. Möglicherweise sind noch keine Daten vorhanden oder der Filter ist zu spezifisch." + else: + response["message"] = f"Keine {entity_name} vorhanden. Erstellen Sie zuerst neue Einträge." + else: + # Create detailed summary based on entity type + response["message"] = _formatEntitySummary( + entity_name, + operation_result, + intentAnalysis.get('parameters', {}) + ) + elif isinstance(operation_result, dict): + response["count"] = 1 + # Format single entity + entity_name = intentAnalysis.get('entity', 'Eintrag') + response["message"] = _formatEntitySummary(entity_name, [operation_result], {}) + + return response + + except Exception as e: + logger.error(f"Error processing natural language command: {str(e)}", exc_info=True) + raise + + +async def analyzeUserIntent( + aiService, + userInput: str +) -> Dict[str, Any]: + """ + Use AI to analyze user input and extract intent, entity, and parameters. + + Args: + aiService: AI service instance + userInput: Natural language user input + + Returns: + Dictionary with 'intent', 'entity', and 'parameters' + """ + # Create a structured prompt for intent analysis with accurate field information + intentPrompt = f""" +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "{userInput}" + +Available intents: +- CREATE: User wants to create a new entity +- READ: User wants to read/query entities +- UPDATE: User wants to update an existing entity +- DELETE: User wants to delete an entity +- QUERY: User wants to execute a database query (SQL statements) + +Available entities and their fields: + +**Projekt** (Real estate project): +- id: string (primary key) +- mandateId: string (mandate ID) +- label: string (project designation/name) +- statusProzess: string enum (Eingang, Analyse, Studie, Planung, Baurechtsverfahren, Umsetzung, Archiv) +- perimeter: GeoPolylinie (geographic boundary, JSONB) +- baulinie: GeoPolylinie (building line, JSONB) +- parzellen: List[Parzelle] (plots belonging to project, JSONB) +- dokumente: List[Dokument] (documents, JSONB) +- kontextInformationen: List[Kontext] (context info, JSONB) + +**Parzelle** (Plot/parcel): +- id: string (primary key) +- mandateId: string (mandate ID) +- label: string (plot designation) +- strasseNr: string (street and house number) +- plz: string (postal code) +- kontextGemeinde: string (municipality ID, Foreign Key to Gemeinde table) +- bauzone: string (building zone, e.g. W3, WG2) +- az: float (Ausnützungsziffer) +- bz: float (Bebauungsziffer) +- vollgeschossZahl: int (number of allowed full floors) +- gebaeudehoeheMax: float (maximum building height in meters) +- laermschutzzone: string (noise protection zone) +- hochwasserschutzzone: string (flood protection zone) +- grundwasserschutzzone: string (groundwater protection zone) +- parzelleBebaut: JaNein enum (is plot built) +- parzelleErschlossen: JaNein enum (is plot developed) +- parzelleHanglage: JaNein enum (is plot on slope) +- kontextInformationen: List[Kontext] (metadata - each item has 'thema' and 'inhalt' fields only) + +**Kontext** (Context information for metadata): +- thema: string (topic/subject, e.g. "EGRID", "Fläche", "Zentrum") +- inhalt: string (content as text, e.g. "CH887199917793", "6514.99 m²", "X: 123, Y: 456") + +**Important relationships:** +- Projekte contain Parzellen (projects have plots) +- Parzelle links to Gemeinde (via kontextGemeinde) +- Gemeinde links to Kanton (via id_kanton) +- Kanton links to Land (via id_land) +- Location queries (city, postal code) should use Parzelle.kontextGemeinde (municipality name will be resolved to ID) +- Projekt does NOT have location fields directly - location is stored in associated Parzellen + +Return a JSON object with the following structure: +{{ + "intent": "CREATE|READ|UPDATE|DELETE|QUERY", + "entity": "Projekt|Parzelle|Dokument|Kanton|Gemeinde|null", + "parameters": {{ + // Extracted parameters from user input + // For CREATE/UPDATE: include all relevant fields using EXACT field names from above + // For READ: include filter criteria using EXACT field names (id, label, plz, kontextGemeinde, etc.) + // For DELETE: include entity ID if mentioned + // For QUERY: include queryText if SQL is detected + // IMPORTANT: Use only field names that exist in the entity definition above + }}, + "confidence": 0.0-1.0 // Confidence score for the analysis +}} + +Examples: +- Input: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" + Output: {{"intent": "CREATE", "entity": "Projekt", "parameters": {{"label": "Hauptstrasse 42"}}, "confidence": 0.95}} + +- Input: "Erstelle eine Parzelle mit Label 123, PLZ 8000, Gemeinde Zürich, Bauzone W3" + Output: {{"intent": "CREATE", "entity": "Parzelle", "parameters": {{"label": "123", "plz": "8000", "kontextGemeinde": "Zürich", "bauzone": "W3"}}, "confidence": 0.95}} + +- Input: "Parzellen-Informationen: ID:AA1704, Nummer:AA1704, EGRID:CH887199917793, Kanton:ZH, Gemeinde:Zürich, Gemeinde-Code:261, Fläche:6514.99 m², Zentrum:2682951.44,1247622.91" + Output: {{ + "intent": "CREATE", + "entity": "Parzelle", + "parameters": {{ + "label": "AA1704", + "parzellenAliasTags": ["AA1704"], + "kontextGemeinde": "Zürich", + "kontextInformationen": [ + {{"thema": "EGRID", "inhalt": "CH887199917793"}}, + {{"thema": "Kanton", "inhalt": "ZH"}}, + {{"thema": "BFS-Nummer", "inhalt": "261"}}, + {{"thema": "Fläche", "inhalt": "6514.99 m²"}}, + {{"thema": "Zentrum (LV95)", "inhalt": "X: 2682951.44 m, Y: 1247622.91 m (EPSG:2056)"}} + ] + }}, + "confidence": 0.9 + }} + Note: Extract structured data from detailed input. Use kontextInformationen for metadata. Each item has 'thema' (topic) and 'inhalt' (content as text). + +- Input: "Zeige mir alle Projekte" + Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{}}, "confidence": 0.9}} + +- Input: "Zeige mir Projekte in Zürich" or "Wie viele Projekte in Zürich" + Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{"location_filter": "Zürich"}}, "confidence": 0.9}} + Note: For project location queries, use Projekt entity with location_filter parameter + +- Input: "Zeige mir Parzellen mit PLZ 8000" + Output: {{"intent": "READ", "entity": "Parzelle", "parameters": {{"plz": "8000"}}, "confidence": 0.95}} + +- Input: "Aktualisiere Projekt XYZ mit Status 'Planung'" + Output: {{"intent": "UPDATE", "entity": "Projekt", "parameters": {{"id": "XYZ", "statusProzess": "Planung"}}, "confidence": 0.85}} + +- Input: "SELECT * FROM Projekt WHERE label = 'Test'" + Output: {{"intent": "QUERY", "entity": null, "parameters": {{"queryText": "SELECT * FROM Projekt WHERE label = 'Test'", "queryType": "sql"}}, "confidence": 1.0}} + +- Input: "Lösche Parzelle ABC" + Output: {{"intent": "DELETE", "entity": "Parzelle", "parameters": {{"id": "ABC"}}, "confidence": 0.9}} + +IMPORTANT EXTRACTION RULES: +1. For CREATE operations, extract ALL mentioned data fields from the user input +2. Use kontextInformationen array for metadata that doesn't have dedicated fields (EGRID, BFS numbers, area, coordinates, etc.) +3. Each kontextInformationen item MUST have exactly two fields: 'thema' (topic/subject) and 'inhalt' (content as text string) +4. Format kontextInformationen values as readable text strings, including units (e.g., "6514.99 m²", "X: 123, Y: 456") +5. Match field names EXACTLY to the entity definition above +6. Convert data types correctly (strings for text, numbers for numeric values) +7. Extract coordinates, areas, and other numeric values from text +8. When multiple values are mentioned for the same concept (ID, Nummer, Name), use the most relevant one for 'label' and put alternatives in parzellenAliasTags +""" + + try: + # Use AI planning call for structured JSON response + response = await aiService.callAiPlanning( + prompt=intentPrompt, + debugType="intentanalysis" + ) + + # Extract JSON from response (handles markdown code blocks) + jsonStart = response.find('{') + jsonEnd = response.rfind('}') + 1 + + if jsonStart == -1 or jsonEnd == 0: + raise ValueError("No JSON found in AI response") + + jsonStr = response[jsonStart:jsonEnd] + + # Parse JSON response + intentData = json.loads(jsonStr) + + # Validate response structure + if "intent" not in intentData: + raise ValueError("Invalid intent analysis response: missing 'intent' field") + + # Ensure parameters exists + if "parameters" not in intentData: + intentData["parameters"] = {} + + logger.debug(f"Parsed intent analysis: {intentData}") + + return intentData + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse AI intent analysis response: {e}") + logger.error(f"Raw response: {response}") + raise ValueError(f"AI returned invalid JSON: {str(e)}") + except Exception as e: + logger.error(f"Error analyzing user intent: {str(e)}", exc_info=True) + raise + + +async def executeIntentBasedOperation( + currentUser: User, + intent: str, + entity: Optional[str], + parameters: Dict[str, Any], +) -> Dict[str, Any]: + """ + Execute CRUD operation based on analyzed intent. + + Args: + currentUser: Current authenticated user + intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY) + entity: Entity type from AI analysis + parameters: Extracted parameters from AI analysis + + Returns: + Operation result + + Note: + - TODO: Implement actual interface calls once datamodels are ready + - Currently returns test responses showing what would be executed + """ + try: + logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}") + logger.debug(f"Parameters: {parameters}") + + if intent == "QUERY": + # Execute database query directly (stateless) + queryText = parameters.get("queryText", "") + + if not queryText: + raise ValueError("QUERY intent requires queryText in parameters") + + result = await executeDirectQuery( + currentUser=currentUser, + queryText=queryText, + parameters=parameters.get("queryParameters"), + ) + return result + + elif intent == "CREATE": + # Create new entity + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + # Create Projekt from parameters + projekt = Projekt( + mandateId=currentUser.mandateId, + label=parameters.get("label", ""), + statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None, + ) + created = realEstateInterface.createProjekt(projekt) + return { + "operation": "CREATE", + "entity": "Projekt", + "result": created.model_dump() + } + + elif entity == "Parzelle": + # Create Parzelle from parameters + # Import Kontext for kontextInformationen + from modules.datamodels.datamodelRealEstate import Kontext, GeoPolylinie + + # Build parzelle data with all extracted parameters + parzelle_data = { + "mandateId": currentUser.mandateId, + "label": parameters.get("label", ""), + } + + # Add optional fields if present + optional_fields = [ + "parzellenAliasTags", "eigentuemerschaft", "strasseNr", "plz", + "bauzone", "az", "bz", "vollgeschossZahl", "anrechenbarDachgeschoss", + "anrechenbarUntergeschoss", "gebaeudehoeheMax", "kontextGemeinde", + "regelnGrenzabstand", "regelnMehrlaengenzuschlag", "regelnMehrhoehenzuschlag", + "parzelleBebaut", "parzelleErschlossen", "parzelleHanglage", + "laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone" + ] + + for field in optional_fields: + if field in parameters and parameters[field] is not None: + parzelle_data[field] = parameters[field] + + # Handle complex objects + if "perimeter" in parameters and parameters["perimeter"]: + parzelle_data["perimeter"] = GeoPolylinie(**parameters["perimeter"]) + elif "kontextGemeinde" in parameters and parameters.get("kontextGemeinde"): + # Try to fetch polygon from Swisstopo if gemeinde and parzellen_nr are available + gemeinde = parameters.get("kontextGemeinde") + parzellen_nr = parameters.get("label") or parameters.get("parzellen_nr") or parameters.get("parzellennummer") + + if gemeinde and parzellen_nr: + logger.info(f"Attempting to fetch polygon from Swisstopo for {gemeinde} {parzellen_nr}") + try: + # Try to resolve gemeinde name if it's an ID + gemeinde_name = gemeinde + if len(gemeinde) == 36: # UUID format + # Try to get gemeinde name from interface (realEstateInterface already initialized above) + gemeinde_obj = realEstateInterface.getGemeinde(gemeinde) + if gemeinde_obj: + gemeinde_name = gemeinde_obj.label + + polygon_data = await fetch_parcel_polygon_from_swisstopo( + gemeinde=gemeinde_name, + parzellen_nr=str(parzellen_nr), + sr=2056 + ) + + if polygon_data: + parzelle_data["perimeter"] = GeoPolylinie(**polygon_data) + logger.info(f"Successfully fetched and set perimeter from Swisstopo") + else: + logger.warning(f"Could not fetch polygon from Swisstopo for {gemeinde_name} {parzellen_nr}") + except Exception as e: + logger.warning(f"Error fetching polygon from Swisstopo (continuing without): {e}") + + if "baulinie" in parameters and parameters["baulinie"]: + parzelle_data["baulinie"] = GeoPolylinie(**parameters["baulinie"]) + + # Handle kontextInformationen (convert dicts to Kontext objects) + if "kontextInformationen" in parameters and parameters["kontextInformationen"]: + kontext_list = [] + for kontext_data in parameters["kontextInformationen"]: + if isinstance(kontext_data, dict): + # Ensure only thema and inhalt are passed (Kontext model only has these fields) + kontext_obj = Kontext( + thema=kontext_data.get("thema", ""), + inhalt=kontext_data.get("inhalt", "") + ) + kontext_list.append(kontext_obj) + else: + kontext_list.append(kontext_data) + parzelle_data["kontextInformationen"] = kontext_list + + parzelle = Parzelle(**parzelle_data) + created = realEstateInterface.createParzelle(parzelle) + + logger.info(f"Created Parzelle '{created.label}' with {len(created.kontextInformationen)} context items") + + return { + "operation": "CREATE", + "entity": "Parzelle", + "result": created.model_dump() + } + elif entity == "Gemeinde": + # Create Gemeinde from parameters + from modules.datamodels.datamodelRealEstate import Gemeinde + gemeinde = Gemeinde( + mandateId=currentUser.mandateId, + label=parameters.get("label", ""), + id_kanton=parameters.get("id_kanton"), + plz=parameters.get("plz"), + ) + created = realEstateInterface.createGemeinde(gemeinde) + return { + "operation": "CREATE", + "entity": "Gemeinde", + "result": created.model_dump() + } + elif entity == "Kanton": + # Create Kanton from parameters + from modules.datamodels.datamodelRealEstate import Kanton + kanton = Kanton( + mandateId=currentUser.mandateId, + label=parameters.get("label", ""), + id_land=parameters.get("id_land"), + abk=parameters.get("abk"), + ) + created = realEstateInterface.createKanton(kanton) + return { + "operation": "CREATE", + "entity": "Kanton", + "result": created.model_dump() + } + elif entity == "Land": + # Create Land from parameters + from modules.datamodels.datamodelRealEstate import Land + land = Land( + mandateId=currentUser.mandateId, + label=parameters.get("label", ""), + abk=parameters.get("abk"), + ) + created = realEstateInterface.createLand(land) + return { + "operation": "CREATE", + "entity": "Land", + "result": created.model_dump() + } + elif entity == "Dokument": + # Create Dokument from parameters + from modules.datamodels.datamodelRealEstate import Dokument + dokument = Dokument( + mandateId=currentUser.mandateId, + label=parameters.get("label", ""), + dokumentReferenz=parameters.get("dokumentReferenz", ""), + versionsbezeichnung=parameters.get("versionsbezeichnung"), + dokumentTyp=parameters.get("dokumentTyp"), + quelle=parameters.get("quelle"), + mimeType=parameters.get("mimeType"), + ) + created = realEstateInterface.createDokument(dokument) + return { + "operation": "CREATE", + "entity": "Dokument", + "result": created.model_dump() + } + else: + raise ValueError(f"CREATE operation not supported for entity: {entity}") + + elif intent == "READ": + # Read entities + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + projektId = parameters.get("id") + if projektId: + # Get single Projekt by ID + projekt = realEstateInterface.getProjekt(projektId) + if not projekt: + raise ValueError(f"Projekt {projektId} not found") + return { + "operation": "READ", + "entity": "Projekt", + "result": projekt.model_dump() + } + else: + # List all Projekte (with optional filters) + # Validate filter fields against Projekt model + validProjektFields = {"id", "mandateId", "label", "statusProzess"} + recordFilter = { + k: v for k, v in parameters.items() + if k != "id" and k in validProjektFields + } + + # Handle location_filter specially (filter projects by parcel location) + location_filter = parameters.get("location_filter") + + # Get all projects first + projekte = realEstateInterface.getProjekte(recordFilter=recordFilter if recordFilter else None) + + # If location filter is present, filter by parcels in that location + if location_filter: + logger.info(f"Filtering projects by location: {location_filter}") + + # Try to resolve location to Gemeinde ID for UUID comparison + location_id = None + try: + # Check if it's already a UUID + import re + uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) + if not uuid_pattern.match(location_filter): + # Try to resolve name to ID + gemeinde_records = realEstateInterface.getGemeinden(recordFilter={"label": location_filter}) + if gemeinde_records: + location_id = gemeinde_records[0].id + logger.debug(f"Resolved location '{location_filter}' to ID '{location_id}'") + except Exception as e: + logger.debug(f"Could not resolve location filter: {e}") + + filtered_projekte = [] + + for projekt in projekte: + # Check if any parcel in the project matches the location + for parzelle in projekt.parzellen: + # Check kontextGemeinde (both UUID and string), plz, or strasseNr for location match + location_lower = location_filter.lower() + matches = False + + # Check if kontextGemeinde matches (as UUID or string) + if parzelle.kontextGemeinde: + if (parzelle.kontextGemeinde == location_id or # UUID match + parzelle.kontextGemeinde == location_filter or # Exact match + location_lower in parzelle.kontextGemeinde.lower()): # Partial string match + matches = True + + # Check PLZ or address + if not matches and ( + (parzelle.plz and location_lower in parzelle.plz) or + (parzelle.strasseNr and location_lower in parzelle.strasseNr.lower()) + ): + matches = True + + if matches: + filtered_projekte.append(projekt) + break # Found a matching parcel, no need to check more + + projekte = filtered_projekte + logger.info(f"Found {len(projekte)} projects in location '{location_filter}'") + + return { + "operation": "READ", + "entity": "Projekt", + "result": [p.model_dump() for p in projekte], + "count": len(projekte) + } + elif entity == "Parzelle": + parzelleId = parameters.get("id") + if parzelleId: + # Get single Parzelle by ID + parzelle = realEstateInterface.getParzelle(parzelleId) + if not parzelle: + raise ValueError(f"Parzelle {parzelleId} not found") + return { + "operation": "READ", + "entity": "Parzelle", + "result": parzelle.model_dump() + } + else: + # List all Parzellen (with optional filters) + # Validate filter fields against Parzelle model + # Note: kontextKanton and kontextLand are NOT direct fields on Parzelle + # Parzelle links to Gemeinde, Gemeinde links to Kanton, Kanton links to Land + validParzelleFields = { + "id", "mandateId", "label", "strasseNr", "plz", + "kontextGemeinde", # Only direct link - Gemeinde → Kanton → Land + "bauzone", "az", "bz", "vollgeschossZahl", "gebaeudehoeheMax", + "laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone", + "parzelleBebaut", "parzelleErschlossen", "parzelleHanglage" + } + recordFilter = { + k: v for k, v in parameters.items() + if k != "id" and k in validParzelleFields + } + # Warn about invalid fields + invalidFields = {k: v for k, v in parameters.items() if k not in validParzelleFields and k != "id"} + if invalidFields: + logger.warning(f"Invalid filter fields for Parzelle ignored: {list(invalidFields.keys())}") + + parzellen = realEstateInterface.getParzellen(recordFilter=recordFilter if recordFilter else None) + + # Debug logging for empty results + if not parzellen and recordFilter: + logger.info(f"No Parzellen found matching filter: {recordFilter}") + # Get total count to help debug + all_parzellen = realEstateInterface.getParzellen(recordFilter=None) + logger.info(f"Total Parzellen in database: {len(all_parzellen)}") + if all_parzellen: + # Show some sample kontextGemeinde values + sample_gemeinden = set() + for p in all_parzellen[:10]: + if p.kontextGemeinde: + sample_gemeinden.add(p.kontextGemeinde) + logger.info(f"Sample kontextGemeinde values in database: {sample_gemeinden}") + + return { + "operation": "READ", + "entity": "Parzelle", + "result": [p.model_dump() for p in parzellen], + "count": len(parzellen) + } + elif entity == "Gemeinde": + from modules.datamodels.datamodelRealEstate import Gemeinde + gemeindeId = parameters.get("id") + if gemeindeId: + gemeinde = realEstateInterface.getGemeinde(gemeindeId) + if not gemeinde: + raise ValueError(f"Gemeinde {gemeindeId} not found") + return { + "operation": "READ", + "entity": "Gemeinde", + "result": gemeinde.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + gemeinden = realEstateInterface.getGemeinden(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Gemeinde", + "result": [g.model_dump() for g in gemeinden], + "count": len(gemeinden) + } + elif entity == "Kanton": + from modules.datamodels.datamodelRealEstate import Kanton + kantonId = parameters.get("id") + if kantonId: + kanton = realEstateInterface.getKanton(kantonId) + if not kanton: + raise ValueError(f"Kanton {kantonId} not found") + return { + "operation": "READ", + "entity": "Kanton", + "result": kanton.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + kantone = realEstateInterface.getKantone(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Kanton", + "result": [k.model_dump() for k in kantone], + "count": len(kantone) + } + elif entity == "Land": + from modules.datamodels.datamodelRealEstate import Land + landId = parameters.get("id") + if landId: + land = realEstateInterface.getLand(landId) + if not land: + raise ValueError(f"Land {landId} not found") + return { + "operation": "READ", + "entity": "Land", + "result": land.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + laender = realEstateInterface.getLaender(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Land", + "result": [l.model_dump() for l in laender], + "count": len(laender) + } + elif entity == "Dokument": + from modules.datamodels.datamodelRealEstate import Dokument + dokumentId = parameters.get("id") + if dokumentId: + dokument = realEstateInterface.getDokument(dokumentId) + if not dokument: + raise ValueError(f"Dokument {dokumentId} not found") + return { + "operation": "READ", + "entity": "Dokument", + "result": dokument.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + dokumente = realEstateInterface.getDokumente(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Dokument", + "result": [d.model_dump() for d in dokumente], + "count": len(dokumente) + } + else: + raise ValueError(f"READ operation not supported for entity: {entity}") + + elif intent == "UPDATE": + # Update existing entity + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + projektId = parameters.get("id") + if not projektId: + raise ValueError("UPDATE operation requires entity ID") + + # Get existing projekt + projekt = realEstateInterface.getProjekt(projektId) + if not projekt: + raise ValueError(f"Projekt {projektId} not found") + + # Update fields + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateProjekt(projektId, updateData) + return { + "operation": "UPDATE", + "entity": "Projekt", + "result": updated.model_dump() + } + elif entity == "Parzelle": + parzelleId = parameters.get("id") + if not parzelleId: + raise ValueError("UPDATE operation requires entity ID") + + # Get existing parzelle + parzelle = realEstateInterface.getParzelle(parzelleId) + if not parzelle: + raise ValueError(f"Parzelle {parzelleId} not found") + + # Update fields + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateParzelle(parzelleId, updateData) + return { + "operation": "UPDATE", + "entity": "Parzelle", + "result": updated.model_dump() + } + elif entity == "Gemeinde": + from modules.datamodels.datamodelRealEstate import Gemeinde + gemeindeId = parameters.get("id") + if not gemeindeId: + raise ValueError("UPDATE operation requires entity ID") + + gemeinde = realEstateInterface.getGemeinde(gemeindeId) + if not gemeinde: + raise ValueError(f"Gemeinde {gemeindeId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateGemeinde(gemeindeId, updateData) + return { + "operation": "UPDATE", + "entity": "Gemeinde", + "result": updated.model_dump() + } + elif entity == "Kanton": + from modules.datamodels.datamodelRealEstate import Kanton + kantonId = parameters.get("id") + if not kantonId: + raise ValueError("UPDATE operation requires entity ID") + + kanton = realEstateInterface.getKanton(kantonId) + if not kanton: + raise ValueError(f"Kanton {kantonId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateKanton(kantonId, updateData) + return { + "operation": "UPDATE", + "entity": "Kanton", + "result": updated.model_dump() + } + elif entity == "Land": + from modules.datamodels.datamodelRealEstate import Land + landId = parameters.get("id") + if not landId: + raise ValueError("UPDATE operation requires entity ID") + + land = realEstateInterface.getLand(landId) + if not land: + raise ValueError(f"Land {landId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateLand(landId, updateData) + return { + "operation": "UPDATE", + "entity": "Land", + "result": updated.model_dump() + } + elif entity == "Dokument": + from modules.datamodels.datamodelRealEstate import Dokument + dokumentId = parameters.get("id") + if not dokumentId: + raise ValueError("UPDATE operation requires entity ID") + + dokument = realEstateInterface.getDokument(dokumentId) + if not dokument: + raise ValueError(f"Dokument {dokumentId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateDokument(dokumentId, updateData) + return { + "operation": "UPDATE", + "entity": "Dokument", + "result": updated.model_dump() + } + else: + raise ValueError(f"UPDATE operation not supported for entity: {entity}") + + elif intent == "DELETE": + # Delete entity + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + projektId = parameters.get("id") + if not projektId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteProjekt(projektId) + return { + "operation": "DELETE", + "entity": "Projekt", + "success": success + } + elif entity == "Parzelle": + parzelleId = parameters.get("id") + if not parzelleId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteParzelle(parzelleId) + return { + "operation": "DELETE", + "entity": "Parzelle", + "success": success + } + elif entity == "Gemeinde": + from modules.datamodels.datamodelRealEstate import Gemeinde + gemeindeId = parameters.get("id") + if not gemeindeId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteGemeinde(gemeindeId) + return { + "operation": "DELETE", + "entity": "Gemeinde", + "success": success + } + elif entity == "Kanton": + from modules.datamodels.datamodelRealEstate import Kanton + kantonId = parameters.get("id") + if not kantonId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteKanton(kantonId) + return { + "operation": "DELETE", + "entity": "Kanton", + "success": success + } + elif entity == "Land": + from modules.datamodels.datamodelRealEstate import Land + landId = parameters.get("id") + if not landId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteLand(landId) + return { + "operation": "DELETE", + "entity": "Land", + "success": success + } + elif entity == "Dokument": + from modules.datamodels.datamodelRealEstate import Dokument + dokumentId = parameters.get("id") + if not dokumentId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteDokument(dokumentId) + return { + "operation": "DELETE", + "entity": "Dokument", + "success": success + } + else: + raise ValueError(f"DELETE operation not supported for entity: {entity}") + + else: + raise ValueError(f"Unknown intent: {intent}") + + except Exception as e: + logger.error(f"Error executing intent-based operation: {str(e)}", exc_info=True) + raise + + +# ===== Project Creation with Parcel Data ===== + +async def create_project_with_parcel_data( + currentUser: User, + projekt_label: str, + parzellen_data: List[Dict[str, Any]], + status_prozess: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a Projekt with one or more Parzellen from provided parcel data. + + Args: + currentUser: Current authenticated user + projekt_label: Label for the Projekt + parzellen_data: List of dictionaries containing parcel information from request + status_prozess: Optional project status (defaults to "Eingang") + + Returns: + Dictionary containing created Projekt and list of Parzellen + + Raises: + HTTPException: If Gemeinde or Kanton not found, or validation fails + """ + try: + logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}") + + # Get interface + realEstateInterface = getRealEstateInterface(currentUser) + + # Validate required fields + if not projekt_label: + raise ValueError("Projekt label is required") + + if not parzellen_data or len(parzellen_data) == 0: + raise ValueError("At least one Parzelle data is required") + + # Validate all parcels have required fields + for idx, parzelle_data in enumerate(parzellen_data): + if not parzelle_data.get("perimeter"): + raise ValueError(f"Parzelle {idx + 1} perimeter is required") + + # Helper function to convert GeoJSON geometry to GeoPolylinie (defined early for use in geometry collection) + def convert_geojson_to_geopolylinie(geometry_data: Dict[str, Any]) -> Optional[GeoPolylinie]: + """Convert GeoJSON geometry to GeoPolylinie format.""" + if not geometry_data: + return None + + # Handle nested geometry structure (geometry.geometry.coordinates) + if "geometry" in geometry_data: + geometry_data = geometry_data["geometry"] + + geometry_type = geometry_data.get("type") + coordinates = geometry_data.get("coordinates") + + if not coordinates or geometry_type != "Polygon": + return None + + # Extract outer ring (first array of coordinates) + if not coordinates or len(coordinates) == 0: + return None + + ring = coordinates[0] # Outer ring + + # Convert coordinates to GeoPunkt list + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = GeoPunkt( + koordinatensystem="LV95", + x=float(coord[0]), + y=float(coord[1]), + z=float(coord[2]) if len(coord) > 2 else None + ) + punkte.append(punkt) + + if not punkte: + return None + + return GeoPolylinie( + closed=True, + punkte=punkte + ) + + # First pass: Collect all parcel geometries for neighbor filtering + # Convert all perimeters to GeoPolylinie format + all_parcel_geometries = [] + for parzelle_data in parzellen_data: + perimeter = parzelle_data.get("perimeter") + if perimeter: + # Convert to GeoPolylinie if needed + if isinstance(perimeter, dict): + if "punkte" in perimeter and "closed" in perimeter: + try: + geo_perimeter = GeoPolylinie(**perimeter) + all_parcel_geometries.append(geo_perimeter) + except Exception as e: + logger.warning(f"Error converting perimeter to GeoPolylinie: {e}") + else: + # Try GeoJSON conversion + converted = convert_geojson_to_geopolylinie(perimeter) + if converted: + all_parcel_geometries.append(converted) + elif isinstance(perimeter, GeoPolylinie): + all_parcel_geometries.append(perimeter) + + # Process all parcels - create each one or use existing + created_parzellen = [] + parcel_perimeters = [] # Collect all parcel perimeters for baulinie calculation + + for idx, parzelle_data in enumerate(parzellen_data): + logger.info(f"Processing Parzelle {idx + 1}/{len(parzellen_data)}") + + # Determine parcel label for uniqueness check + parcel_label = parzelle_data.get("id") or parzelle_data.get("number") or parzelle_data.get("label") or "Unknown" + + # Check if Parzelle with this label already exists + existing_parzellen = realEstateInterface.getParzellen( + recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId} + ) + + if existing_parzellen and len(existing_parzellen) > 0: + # Parzelle already exists - use existing one + existing_parzelle = existing_parzellen[0] + logger.info(f"Parzelle with label '{parcel_label}' already exists (ID: {existing_parzelle.id}), reusing it") + + # Collect perimeter for baulinie calculation + if existing_parzelle.perimeter: + parcel_perimeters.append(existing_parzelle.perimeter) + + # Add to list of created parcels (actually existing) + created_parzellen.append(existing_parzelle) + continue # Skip creation, use existing + + # Parzelle does not exist - create new one + logger.info(f"Parzelle with label '{parcel_label}' does not exist, creating new one") + + # Resolve Gemeinde and Kanton for this parcel (create if not found) + gemeinde_id = None + canton_abk = parzelle_data.get("canton") + municipality_name = parzelle_data.get("municipality_name") + + logger.debug(f"Resolving Gemeinde/Kanton: canton='{canton_abk}', municipality='{municipality_name}'") + + if municipality_name and canton_abk: + # Mapping of 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" + } + + # First, ensure Land "Schweiz" exists + logger.debug("Ensuring Land 'Schweiz' exists") + laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"}) + if not laender: + logger.info("Creating Land 'Schweiz'") + land = Land( + mandateId=currentUser.mandateId, + label="Schweiz", + abk="CH" + ) + land = realEstateInterface.createLand(land) + logger.info(f"Created Land 'Schweiz' with ID: {land.id}") + else: + land = laender[0] + logger.debug(f"Found Land 'Schweiz' with ID: {land.id}") + + # Then, lookup or create Kanton + logger.debug(f"Looking up Kanton with abk='{canton_abk}'") + kantone = realEstateInterface.getKantone(recordFilter={"abk": canton_abk}) + logger.debug(f"Found {len(kantone)} Kanton(e) with abk='{canton_abk}'") + if not kantone: + logger.info(f"Kanton '{canton_abk}' not found, creating it") + kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk + kanton = Kanton( + mandateId=currentUser.mandateId, + label=kanton_label, + abk=canton_abk, + id_land=land.id + ) + kanton = realEstateInterface.createKanton(kanton) + logger.info(f"Created Kanton '{kanton_label}' ({canton_abk}) with ID: {kanton.id}") + else: + kanton = kantone[0] + logger.debug(f"Found Kanton: ID={kanton.id}, Label={kanton.label}, abk={kanton.abk}") + + # Then, lookup or create Gemeinde + logger.debug(f"Looking up Gemeinde with label='{municipality_name}' and id_kanton='{kanton.id}'") + gemeinden = realEstateInterface.getGemeinden( + recordFilter={"label": municipality_name, "id_kanton": kanton.id} + ) + logger.debug(f"Found {len(gemeinden)} Gemeinde(n) with label='{municipality_name}' and id_kanton='{kanton.id}'") + if not gemeinden: + logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it") + gemeinde = Gemeinde( + mandateId=currentUser.mandateId, + label=municipality_name, + id_kanton=kanton.id, + plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API + ) + gemeinde = realEstateInterface.createGemeinde(gemeinde) + logger.info(f"Created Gemeinde '{municipality_name}' with ID: {gemeinde.id}") + else: + gemeinde = gemeinden[0] + logger.debug(f"Found Gemeinde: ID={gemeinde.id}, Label={gemeinde.label}") + + gemeinde_id = gemeinde.id + logger.info(f"Resolved Gemeinde '{municipality_name}' to ID '{gemeinde_id}'") + else: + logger.warning(f"Missing Gemeinde/Kanton data: municipality_name={municipality_name}, canton={canton_abk}") + + # Build parzellenAliasTags + alias_tags = [] + if parzelle_data.get("egrid"): + alias_tags.append(parzelle_data["egrid"]) + if parzelle_data.get("number") and parzelle_data["number"] != parzelle_data.get("id"): + alias_tags.append(parzelle_data["number"]) + + # Extract address information from Swiss Topo API data + # Each parcel should have its own address data from Swiss Topo API + # The address comes from the parcel search API response for THIS specific parcel + strasse_nr = None + plz = None + + # Use address from Swiss Topo API - this is specific to THIS parcel + # The address field contains the full address string from Swiss Topo + address = parzelle_data.get("address") + if address: + # Swiss Topo provides full address string like "Street Number, PLZ City" + # Parse to extract street and number (before comma) + parts = address.split(",") + if len(parts) >= 1: + strasse_nr = parts[0].strip() + # PLZ is provided separately by Swiss Topo API + plz = parzelle_data.get("plz") + + # Log address info for debugging + logger.debug(f"Parzelle {idx + 1} address data: strasse_nr='{strasse_nr}', plz='{plz}', full_address='{address}'") + + # If no address found, log warning but continue + if not strasse_nr and not plz: + logger.warning(f"No address data found for Parzelle {idx + 1} (label: {parcel_label})") + + # Build kontextInformationen + kontext_items = [] + + if parzelle_data.get("egrid"): + kontext_items.append(Kontext( + thema="EGRID", + inhalt=parzelle_data["egrid"] + )) + + if parzelle_data.get("identnd"): + kontext_items.append(Kontext( + thema="IdentND", + inhalt=parzelle_data["identnd"] + )) + + if parzelle_data.get("area_m2"): + kontext_items.append(Kontext( + thema="Fläche", + inhalt=f"{parzelle_data['area_m2']} m²" + )) + + if parzelle_data.get("centroid"): + centroid = parzelle_data["centroid"] + kontext_items.append(Kontext( + thema="Zentrum (LV95)", + inhalt=f"X: {centroid.get('x')} m, Y: {centroid.get('y')} m (EPSG:2056)" + )) + + if parzelle_data.get("geoportal_url"): + kontext_items.append(Kontext( + thema="Geoportal URL", + inhalt=parzelle_data["geoportal_url"] + )) + + if parzelle_data.get("municipality_code"): + kontext_items.append(Kontext( + thema="BFS-Nummer", + inhalt=str(parzelle_data["municipality_code"]) + )) + + # Handle adjacent parcels - filter out selected parcels geometrically + adjacent_parcel_refs = [] + if parzelle_data.get("adjacent_parcels"): + # Filter neighbors to exclude selected parcels + neighbors_to_filter = [] + for adj_parcel in parzelle_data["adjacent_parcels"]: + if isinstance(adj_parcel, dict): + neighbors_to_filter.append(adj_parcel) + elif isinstance(adj_parcel, str): + neighbors_to_filter.append({"id": adj_parcel}) + + # Filter using geometry comparison if we have geometries + if all_parcel_geometries and neighbors_to_filter: + try: + filtered_neighbors = filter_neighbor_parcels( + neighbors_to_filter, + all_parcel_geometries + ) + # Extract IDs from filtered neighbors + for filtered_neighbor in filtered_neighbors: + adj_id = filtered_neighbor.get("id") + if adj_id: + adjacent_parcel_refs.append({"id": adj_id}) + except Exception as e: + logger.warning(f"Error filtering neighbor parcels: {e}, including all neighbors") + # Fallback: include all neighbors if filtering fails + for adj_parcel in parzelle_data["adjacent_parcels"]: + if isinstance(adj_parcel, dict): + adj_id = adj_parcel.get("id") + if adj_id: + adjacent_parcel_refs.append({"id": adj_id}) + elif isinstance(adj_parcel, str): + adjacent_parcel_refs.append({"id": adj_parcel}) + else: + # No geometries available - include all neighbors + for adj_parcel in parzelle_data["adjacent_parcels"]: + if isinstance(adj_parcel, dict): + adj_id = adj_parcel.get("id") + if adj_id: + adjacent_parcel_refs.append({"id": adj_id}) + elif isinstance(adj_parcel, str): + adjacent_parcel_refs.append({"id": adj_parcel}) + + # Convert perimeter to GeoPolylinie if needed + perimeter = parzelle_data.get("perimeter") + if isinstance(perimeter, dict): + # Check if it's already in GeoPolylinie format (has punkte and closed) + if "punkte" in perimeter and "closed" in perimeter: + try: + perimeter = GeoPolylinie(**perimeter) + except Exception as e: + raise ValueError(f"Invalid perimeter format: {str(e)}") + else: + # Try to convert from GeoJSON format + converted = convert_geojson_to_geopolylinie(perimeter) + if converted: + perimeter = converted + else: + raise ValueError("Invalid perimeter format: cannot convert to GeoPolylinie") + elif isinstance(perimeter, GeoPolylinie): + # Already a GeoPolylinie instance, use as-is + pass + else: + raise ValueError("Invalid perimeter type: must be dict or GeoPolylinie") + + # Extract baulinie from geometry if provided + baulinie = None + geometry = parzelle_data.get("geometry") + logger.debug(f"Geometry present: {geometry is not None}") + if geometry: + logger.debug(f"Geometry type: {type(geometry)}, keys: {list(geometry.keys()) if isinstance(geometry, dict) else 'not a dict'}") + baulinie = convert_geojson_to_geopolylinie(geometry) + if baulinie: + logger.info(f"Extracted baulinie from geometry with {len(baulinie.punkte)} points") + else: + logger.warning("Failed to extract baulinie from geometry") + else: + logger.warning("No geometry found in parzelle_data") + + # Build Parzelle data + parzelle_create_data = { + "mandateId": currentUser.mandateId, + "label": parcel_label, # Use the label we determined earlier for uniqueness check + "parzellenAliasTags": alias_tags, + "eigentuemerschaft": None, + "strasseNr": strasse_nr, + "plz": plz, + "perimeter": perimeter, + "baulinie": baulinie, + "kontextGemeinde": gemeinde_id, + "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": adjacent_parcel_refs, + "dokumente": [], + "kontextInformationen": kontext_items, + } + + # Create Parzelle instance + logger.debug(f"Creating Parzelle with label: {parzelle_create_data.get('label')}") + logger.debug(f"Parzelle mandateId: {parzelle_create_data.get('mandateId')}") + logger.debug(f"Parzelle perimeter present: {parzelle_create_data.get('perimeter') is not None}") + + try: + parzelle_instance = Parzelle(**parzelle_create_data) + logger.debug(f"Parzelle instance created successfully with ID: {parzelle_instance.id}") + except Exception as e: + logger.error(f"Error creating Parzelle instance: {str(e)}", exc_info=True) + raise + + # Create Parzelle in database + try: + logger.info(f"Calling createParzelle for Parzelle '{parzelle_instance.label}' (ID: {parzelle_instance.id})") + logger.debug(f"Parzelle instance before createParzelle: {parzelle_instance.model_dump(mode='json', exclude={'perimeter', 'baulinie', 'kontextInformationen'})}") + + # Use model_dump with mode='json' to ensure nested Pydantic models are serialized + parzelle_dict = parzelle_instance.model_dump(mode='json') + logger.debug(f"Parzelle dict keys: {list(parzelle_dict.keys())}") + + # Create Parzelle using the interface, which will handle serialization + created_parzelle = realEstateInterface.createParzelle(parzelle_instance) + + logger.info(f"createParzelle returned: ID={created_parzelle.id if created_parzelle else 'None'}, Label={created_parzelle.label if created_parzelle else 'None'}") + + # Verify Parzelle was created successfully + if not created_parzelle: + raise ValueError("Failed to create Parzelle - createParzelle returned None") + + if not created_parzelle.id: + raise ValueError("Failed to create Parzelle - no ID returned") + + logger.info(f"Parzelle created with ID: {created_parzelle.id}") + + # Verify Parzelle exists in database by fetching it + logger.debug(f"Verifying Parzelle {created_parzelle.id} exists in database...") + verify_parzelle = realEstateInterface.getParzelle(created_parzelle.id) + if not verify_parzelle: + logger.error(f"Parzelle {created_parzelle.id} was not found in database after creation") + # Try to get all Parzellen to see what's in the database + all_parzellen = realEstateInterface.getParzellen(recordFilter=None) + logger.error(f"Total Parzellen in database: {len(all_parzellen)}") + if all_parzellen: + logger.error(f"Sample Parzelle IDs: {[p.id for p in all_parzellen[:5]]}") + raise ValueError(f"Parzelle {created_parzelle.id} was not found in database after creation") + + logger.info(f"Verified Parzelle {created_parzelle.id} exists in database") + # Use the verified Parzelle from database to ensure it has all fields + created_parzelle = verify_parzelle + + # Collect perimeter for baulinie calculation + if created_parzelle.perimeter: + parcel_perimeters.append(created_parzelle.perimeter) + + # Add to list of created parcels + created_parzellen.append(created_parzelle) + + except Exception as e: + logger.error(f"Error creating Parzelle {idx + 1}: {str(e)}", exc_info=True) + raise + + if not created_parzellen: + raise ValueError("No Parzellen were successfully created") + + logger.info(f"Successfully created {len(created_parzellen)} Parzelle(n)") + + # Calculate combined baulinie from all parcel perimeters + project_baulinie = None + if len(parcel_perimeters) > 0: + try: + if len(parcel_perimeters) == 1: + # Single parcel - use its perimeter as baulinie + project_baulinie = parcel_perimeters[0] + logger.info("Using single parcel perimeter as baulinie") + else: + # Multiple parcels - combine geometries to create outer outline + logger.info(f"Combining {len(parcel_perimeters)} parcel geometries to create baulinie") + project_baulinie = combine_parcel_geometries(parcel_perimeters) + logger.info(f"Created combined baulinie with {len(project_baulinie.punkte)} points") + except Exception as e: + logger.error(f"Error combining parcel geometries for baulinie: {e}", exc_info=True) + # Fallback: use first parcel's perimeter + if parcel_perimeters: + project_baulinie = parcel_perimeters[0] + logger.warning("Using first parcel perimeter as fallback baulinie") + + # Convert status_prozess to enum + status_prozess_enum = None + if status_prozess: + try: + # Try to convert string to enum + if isinstance(status_prozess, str): + status_prozess_enum = StatusProzess(status_prozess) + elif isinstance(status_prozess, StatusProzess): + status_prozess_enum = status_prozess + except (ValueError, KeyError): + logger.warning(f"Invalid statusProzess '{status_prozess}', using default 'Eingang'") + status_prozess_enum = StatusProzess.EINGANG + else: + status_prozess_enum = StatusProzess.EINGANG + + # Create Projekt with combined baulinie + # Use the verified Parzelle instance (from database) to ensure it has all fields properly set + logger.debug(f"Preparing Projekt creation with baulinie: {project_baulinie is not None}") + if project_baulinie: + logger.debug(f"Baulinie has {len(project_baulinie.punkte)} points") + + # Use first parcel's perimeter for project perimeter (or combine if needed) + project_perimeter = created_parzellen[0].perimeter if created_parzellen else None + + projekt_create_data = { + "mandateId": currentUser.mandateId, + "label": projekt_label, + "statusProzess": status_prozess_enum, + "perimeter": project_perimeter, # Use first parcel perimeter as project perimeter + "baulinie": project_baulinie, # Set baulinie from first parcel geometry + "parzellen": created_parzellen, # Link all created Parzelle instances + "dokumente": [], + "kontextInformationen": [], + } + + logger.debug(f"Projekt data prepared: label={projekt_label}, parzellen_count={len(projekt_create_data['parzellen'])}, baulinie={'present' if project_baulinie else 'None'}") + + try: + projekt_instance = Projekt(**projekt_create_data) + logger.debug(f"Projekt instance created successfully with ID: {projekt_instance.id}") + except Exception as e: + logger.error(f"Error creating Projekt instance: {str(e)}", exc_info=True) + raise + + # Log before creation for debugging + logger.debug(f"Creating Projekt with {len(projekt_instance.parzellen)} Parzelle(n)") + if projekt_instance.parzellen: + for idx, p in enumerate(projekt_instance.parzellen): + logger.debug(f" Parzelle {idx}: ID={p.id}, Label={p.label}") + + logger.debug(f"Projekt baulinie before save: {projekt_instance.baulinie is not None}") + if projekt_instance.baulinie: + logger.debug(f"Projekt baulinie has {len(projekt_instance.baulinie.punkte)} points") + + try: + created_projekt = realEstateInterface.createProjekt(projekt_instance) + logger.info(f"Created Projekt '{created_projekt.label}' (ID: {created_projekt.id})") + logger.debug(f"Created Projekt baulinie: {created_projekt.baulinie is not None}") + except Exception as e: + logger.error(f"Error calling createProjekt: {str(e)}", exc_info=True) + raise + + # Verify Projekt was created + if not created_projekt or not created_projekt.id: + raise ValueError("Failed to create Projekt - no ID returned") + + # Verify Parzelle is linked in the created Projekt + if not created_projekt.parzellen or len(created_projekt.parzellen) == 0: + logger.warning(f"Projekt {created_projekt.id} created but no Parzellen linked") + # Try to fetch the Projekt from database to see if Parzellen are there + verify_projekt = realEstateInterface.getProjekt(created_projekt.id) + if verify_projekt and verify_projekt.parzellen: + logger.info(f"Parzellen found when fetching Projekt from database: {len(verify_projekt.parzellen)}") + created_projekt = verify_projekt + else: + raise ValueError(f"Projekt {created_projekt.id} has no Parzellen linked after creation") + else: + logger.info(f"Projekt {created_projekt.id} successfully linked to {len(created_projekt.parzellen)} Parzelle(n)") + # Log Parzelle details + for idx, p in enumerate(created_projekt.parzellen): + logger.debug(f" Linked Parzelle {idx}: ID={p.id if hasattr(p, 'id') else 'NO ID'}, Label={p.label if hasattr(p, 'label') else 'NO LABEL'}") + + return { + "projekt": created_projekt.model_dump(), + "parzellen": [p.model_dump() for p in created_parzellen], + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating project with parcel data: {str(e)}", exc_info=True) + raise + diff --git a/modules/interfaces/interfaceDbComponentObjects.py b/modules/interfaces/interfaceDbComponentObjects.py index 8ed06a4e..9f6b80bb 100644 --- a/modules/interfaces/interfaceDbComponentObjects.py +++ b/modules/interfaces/interfaceDbComponentObjects.py @@ -493,6 +493,49 @@ class ComponentObjects: def getInitialId(self, model_class: type) -> Optional[str]: """Returns the initial ID for a table.""" return self.db.getInitialId(model_class) + + def _parse_size_string(self, size_str: str) -> Optional[int]: + """ + Parse a formatted size string (e.g., "2.13 MB", "1.5 GB") to bytes. + + Args: + size_str: Formatted size string like "2.13 MB", "1.5 GB", "500 KB" + + Returns: + Size in bytes, or None if parsing fails + """ + try: + size_str = size_str.strip().upper() + # Remove common separators and spaces + size_str = size_str.replace(",", "").replace(" ", "") + + # Extract number and unit - handle both "MB" and "M" formats + import re + # Match: number (with optional decimal) followed by optional unit (K/M/G/T with optional B) + match = re.match(r"^([\d.]+)([KMGT]?B?)$", size_str) + if not match: + return None + + number = float(match.group(1)) + unit = match.group(2) or "B" + + # Normalize unit (handle "M" as "MB", "K" as "KB", etc.) + if len(unit) == 1 and unit in "KMGT": + unit = unit + "B" + + # Convert to bytes + multipliers = { + "B": 1, + "KB": 1024, + "MB": 1024 * 1024, + "GB": 1024 * 1024 * 1024, + "TB": 1024 * 1024 * 1024 * 1024, + } + + multiplier = multipliers.get(unit, 1) + return int(number * multiplier) + except Exception: + return None diff --git a/modules/interfaces/interfaceDbRealEstateAccess.py b/modules/interfaces/interfaceDbRealEstateAccess.py new file mode 100644 index 00000000..b06b89f5 --- /dev/null +++ b/modules/interfaces/interfaceDbRealEstateAccess.py @@ -0,0 +1,90 @@ +""" +Access control for Real Estate interface. +Handles user access management and permission checks. +""" + +import logging +from typing import Dict, Any, List, Optional +from modules.datamodels.datamodelUam import User + +logger = logging.getLogger(__name__) + + +class RealEstateAccess: + """ + Access control class for Real Estate interface. + Handles user access management and permission checks. + """ + + def __init__(self, currentUser: User, db): + """Initialize with user context.""" + self.currentUser = currentUser + self.mandateId = currentUser.mandateId + self.userId = currentUser.id + self.roleLabels = currentUser.roleLabels or [] + + if not self.mandateId or not self.userId: + raise ValueError("Invalid user context: mandateId and userId are required") + + self.db = db + + def uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Unified user access management function that filters data based on user privileges. + + Args: + model_class: Pydantic model class for the table + recordset: Recordset to filter based on access rules + + Returns: + Filtered recordset with access control attributes + """ + filtered_records = [] + + # System admins see all records + if "sysadmin" in self.roleLabels: + filtered_records = recordset + # Admins see records in their mandate + elif "admin" in self.roleLabels: + filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId] + # Regular users see only their records + else: + filtered_records = [ + r for r in recordset + if r.get("mandateId", "-") == self.mandateId and r.get("_createdBy") == self.userId + ] + + # Add access control attributes + for record in filtered_records: + record["_hideView"] = False + record["_hideEdit"] = not self.canModify(model_class, record.get("id")) + record["_hideDelete"] = not self.canModify(model_class, record.get("id")) + + return filtered_records + + def canModify(self, model_class: type, recordId: Optional[str] = None) -> bool: + """Checks if the current user can modify records.""" + # System admins can modify all records + if "sysadmin" in self.roleLabels: + return True + + if recordId is not None: + records = self.db.getRecordset(model_class, recordFilter={"id": recordId}) + if not records: + return False + + record = records[0] + + # Admins can modify records in their mandate + if "admin" in self.roleLabels and record.get("mandateId", "-") == self.mandateId: + return True + + # Regular users can modify their own records + if (record.get("mandateId", "-") == self.mandateId and + record.get("_createdBy") == self.userId): + return True + + return False + else: + return True # Regular users can create records + diff --git a/modules/interfaces/interfaceDbRealEstateObjects.py b/modules/interfaces/interfaceDbRealEstateObjects.py new file mode 100644 index 00000000..48084d11 --- /dev/null +++ b/modules/interfaces/interfaceDbRealEstateObjects.py @@ -0,0 +1,744 @@ +""" +Interface to Real Estate database objects. +Uses PostgreSQL connector for data access with user/mandate filtering. +Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.). +""" + +import logging +from typing import Dict, Any, List, Optional, Union +from modules.datamodels.datamodelRealEstate import ( + Projekt, + Parzelle, + Dokument, + Kanton, + Gemeinde, + Land, + GeoPolylinie, + GeoPunkt, + Kontext, + StatusProzess, +) +from modules.datamodels.datamodelUam import User +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.shared.configuration import APP_CONFIG +# Import Access-Klasse aus separater Datei +from modules.interfaces.interfaceDbRealEstateAccess import RealEstateAccess + +logger = logging.getLogger(__name__) + +# Singleton factory for Real Estate interfaces +_realEstateInterfaces = {} + + +class RealEstateObjects: + """ + Interface to Real Estate database objects. + Uses PostgreSQL connector for data access with user/mandate filtering. + Handles CRUD operations on Real Estate entities. + """ + + def __init__(self, currentUser: Optional[User] = None): + """Initializes the Real Estate Interface.""" + self.currentUser = currentUser + self.userId = currentUser.id if currentUser else None + self.mandateId = currentUser.mandateId if currentUser else None + self.access = None + + # Initialize database + self._initializeDatabase() + + # Set user context if provided + if currentUser: + self.setUserContext(currentUser) + + def _initializeDatabase(self): + """Initialize PostgreSQL database connection.""" + try: + # Get database configuration from environment + dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost") + dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate") + dbUser = APP_CONFIG.get("DB_REALESTATE_USER") + dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432)) + + # Initialize database connector + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId if self.userId else None, + ) + + # Initialize database system (creates database and system table if needed) + # Note: This is also called in DatabaseConnector.__init__, but we call it explicitly + # for consistency with other interfaces and to ensure proper initialization + self.db.initDbSystem() + + # Ensure all supporting tables are created (Land, Kanton, Gemeinde, Dokument) + # These tables are needed for foreign key relationships + self._ensureSupportingTablesExist() + + logger.info(f"Real Estate database connector initialized for database: {dbDatabase}") + except Exception as e: + logger.error(f"Error initializing Real Estate database: {e}") + raise + + def _ensureSupportingTablesExist(self): + """Ensure all supporting tables (Land, Kanton, Gemeinde, Dokument) are created.""" + try: + # These tables are created on-demand when first accessed, but we ensure they exist here + # to avoid errors when resolving location names to IDs + self.db._ensureTableExists(Land) + self.db._ensureTableExists(Kanton) + self.db._ensureTableExists(Gemeinde) + self.db._ensureTableExists(Dokument) + logger.debug("Supporting tables (Land, Kanton, Gemeinde, Dokument) verified/created") + except Exception as e: + logger.warning(f"Error ensuring supporting tables exist: {e}") + # Don't raise - tables will be created on-demand anyway + + def setUserContext(self, currentUser: User): + """Sets the user context for the interface.""" + self.currentUser = currentUser + self.userId = currentUser.id + self.mandateId = currentUser.mandateId + + if not self.userId or not self.mandateId: + raise ValueError("Invalid user context: id and mandateId are required") + + # Initialize access control + self.access = RealEstateAccess(self.currentUser, self.db) + + # Update database context + self.db.updateContext(self.userId) + + # ===== Projekt Methods ===== + + def createProjekt(self, projekt: Projekt) -> Projekt: + """Create a new project.""" + # Ensure mandateId is set + if not projekt.mandateId: + projekt.mandateId = self.mandateId + + # Apply access control + self.access.uam(Projekt, []) + + # Save to database - use mode='json' to ensure nested Pydantic models are serialized + self.db.recordCreate(Projekt, projekt.model_dump(mode='json')) + + return projekt + + def getProjekt(self, projektId: str) -> Optional[Projekt]: + """Get a project by ID.""" + records = self.db.getRecordset( + Projekt, + recordFilter={"id": projektId} + ) + + if not records: + return None + + # Apply access control + filtered = self.access.uam(Projekt, records) + + if not filtered: + return None + + return Projekt(**filtered[0]) + + def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Projekt]: + """Get all projects matching the filter.""" + records = self.db.getRecordset(Projekt, recordFilter=recordFilter or {}) + + # Apply access control + filtered = self.access.uam(Projekt, records) + + return [Projekt(**r) for r in filtered] + + def updateProjekt(self, projektId_or_projekt: Union[str, Projekt], updateData: Optional[Dict[str, Any]] = None) -> Optional[Projekt]: + """Update a project. + + Args: + projektId_or_projekt: Either a project ID (str) or a Projekt object + updateData: Optional dict of fields to update (only used when projektId_or_projekt is a string) + """ + # Handle both Projekt object and projektId string + if isinstance(projektId_or_projekt, Projekt): + projekt = projektId_or_projekt + projektId = projekt.id + else: + projektId = projektId_or_projekt + projekt = self.getProjekt(projektId) + if not projekt: + return None + + # Update fields from updateData if provided + if updateData: + for key, value in updateData.items(): + if hasattr(projekt, key): + setattr(projekt, key, value) + + # Check if user can modify + if not self.access.canModify(Projekt, projektId): + raise PermissionError(f"User {self.userId} cannot modify project {projektId}") + + # Save to database + self.db.recordModify(Projekt, projektId, projekt.model_dump()) + + return projekt + + def deleteProjekt(self, projektId: str) -> bool: + """Delete a project.""" + projekt = self.getProjekt(projektId) + if not projekt: + return False + + # Check if user can modify + if not self.access.canModify(Projekt, projektId): + raise PermissionError(f"User {self.userId} cannot delete project {projektId}") + + return self.db.recordDelete(Projekt, projektId) + + # ===== Parzelle Methods ===== + + def createParzelle(self, parzelle: Parzelle) -> Parzelle: + """Create a new plot.""" + if not parzelle.mandateId: + parzelle.mandateId = self.mandateId + + self.access.uam(Parzelle, []) + # Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized + self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json')) + + return parzelle + + def getParzelle(self, parzelleId: str) -> Optional[Parzelle]: + """Get a plot by ID.""" + records = self.db.getRecordset( + Parzelle, + recordFilter={"id": parzelleId} + ) + + if not records: + return None + + filtered = self.access.uam(Parzelle, records) + + if not filtered: + return None + + return Parzelle(**filtered[0]) + + def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]: + """Get all plots matching the filter.""" + original_gemeinde_value = None + + # Resolve location names to IDs if needed + if recordFilter: + # Save original value before resolution for fallback search + if "kontextGemeinde" in recordFilter: + original_gemeinde_value = recordFilter["kontextGemeinde"] + + recordFilter = self._resolveLocationFilters(recordFilter) + + records = self.db.getRecordset(Parzelle, recordFilter=recordFilter or {}) + + # Fallback: If no records found and we resolved a Gemeinde name, + # try searching with the original name for backwards compatibility + # (handles case where data has string names instead of UUIDs) + if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter: + if recordFilter["kontextGemeinde"] != original_gemeinde_value: + logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'") + fallback_filter = recordFilter.copy() + fallback_filter["kontextGemeinde"] = original_gemeinde_value + records = self.db.getRecordset(Parzelle, recordFilter=fallback_filter) + if records: + logger.info(f"Found {len(records)} records using original name (legacy data format)") + + # Apply access control + filtered = self.access.uam(Parzelle, records) + + return [Parzelle(**r) for r in filtered] + + def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]: + """ + Resolve location names to IDs for foreign key fields. + Only handles kontextGemeinde (Parzelle → Gemeinde). + Note: Parzelle does NOT have direct links to Kanton or Land. + The relationship is: Parzelle → Gemeinde → Kanton → Land + """ + resolvedFilter = recordFilter.copy() + + # Resolve Gemeinde name to ID + # This is the only direct location link on Parzelle + if "kontextGemeinde" in resolvedFilter: + gemeindeValue = resolvedFilter["kontextGemeinde"] + # Check if it's a name (not a UUID-like string) + if not self._isUUID(gemeindeValue): + gemeindeId = self._resolveGemeindeByName(gemeindeValue) + if gemeindeId: + resolvedFilter["kontextGemeinde"] = gemeindeId + logger.debug(f"Resolved Gemeinde name '{gemeindeValue}' to ID '{gemeindeId}'") + else: + logger.warning(f"Gemeinde '{gemeindeValue}' not found, filter may return no results") + # Keep the original value - query will return empty if not found + + # Note: kontextKanton and kontextLand are NOT fields on Parzelle + # If they appear in the filter, they will be filtered out by the validation in mainRealEstate.py + + return resolvedFilter + + def _isUUID(self, value: str) -> bool: + """Check if a string looks like a UUID.""" + import re + uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) + return bool(uuid_pattern.match(value)) + + def _resolveGemeindeByName(self, name: str) -> Optional[str]: + """Resolve Gemeinde name to ID by looking up in Gemeinde table.""" + try: + # First try exact match + gemeinden = self.db.getRecordset( + Gemeinde, + recordFilter={"label": name} + ) + if gemeinden: + gemeindeId = gemeinden[0].get("id") + logger.debug(f"Found Gemeinde '{name}' with ID '{gemeindeId}'") + return gemeindeId + + # If no exact match, try case-insensitive search via SQL query + # This handles cases where the name might have different casing + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "Gemeinde" WHERE LOWER("label") = LOWER(%s) LIMIT 1', + (name,) + ) + result = cursor.fetchone() + if result: + # psycopg2 returns tuples, so result[0] is the id + gemeindeId = result[0] + logger.debug(f"Found Gemeinde '{name}' (case-insensitive) with ID '{gemeindeId}'") + return gemeindeId + + logger.warning(f"Gemeinde '{name}' not found in database") + return None + except Exception as e: + logger.error(f"Error resolving Gemeinde by name '{name}': {e}", exc_info=True) + return None + + def _resolveKantonByName(self, name: str) -> Optional[str]: + """Resolve Kanton name to ID by looking up in Kanton table.""" + try: + # First try exact match + kantone = self.db.getRecordset( + Kanton, + recordFilter={"label": name} + ) + if kantone: + kantonId = kantone[0].get("id") + logger.debug(f"Found Kanton '{name}' with ID '{kantonId}'") + return kantonId + + # Try case-insensitive search + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "Kanton" WHERE LOWER("label") = LOWER(%s) LIMIT 1', + (name,) + ) + result = cursor.fetchone() + if result: + # psycopg2 returns tuples, so result[0] is the id + kantonId = result[0] + logger.debug(f"Found Kanton '{name}' (case-insensitive) with ID '{kantonId}'") + return kantonId + + logger.warning(f"Kanton '{name}' not found in database") + return None + except Exception as e: + logger.error(f"Error resolving Kanton by name '{name}': {e}", exc_info=True) + return None + + def _resolveLandByName(self, name: str) -> Optional[str]: + """Resolve Land name to ID by looking up in Land table.""" + try: + # First try exact match + laender = self.db.getRecordset( + Land, + recordFilter={"label": name} + ) + if laender: + landId = laender[0].get("id") + logger.debug(f"Found Land '{name}' with ID '{landId}'") + return landId + + # Try case-insensitive search + self.db._ensure_connection() + with self.db.connection.cursor() as cursor: + cursor.execute( + 'SELECT "id" FROM "Land" WHERE LOWER("label") = LOWER(%s) LIMIT 1', + (name,) + ) + result = cursor.fetchone() + if result: + # psycopg2 returns tuples, so result[0] is the id + landId = result[0] + logger.debug(f"Found Land '{name}' (case-insensitive) with ID '{landId}'") + return landId + + logger.warning(f"Land '{name}' not found in database") + return None + except Exception as e: + logger.error(f"Error resolving Land by name '{name}': {e}", exc_info=True) + return None + + def updateParzelle(self, parzelleId: str, updateData: Dict[str, Any]) -> Optional[Parzelle]: + """Update a plot.""" + parzelle = self.getParzelle(parzelleId) + if not parzelle: + return None + + if not self.access.canModify(Parzelle, parzelleId): + raise PermissionError(f"User {self.userId} cannot modify plot {parzelleId}") + + for key, value in updateData.items(): + if hasattr(parzelle, key): + setattr(parzelle, key, value) + + self.db.recordModify(Parzelle, parzelleId, parzelle.model_dump()) + + return parzelle + + def deleteParzelle(self, parzelleId: str) -> bool: + """Delete a plot.""" + parzelle = self.getParzelle(parzelleId) + if not parzelle: + return False + + if not self.access.canModify(Parzelle, parzelleId): + raise PermissionError(f"User {self.userId} cannot delete plot {parzelleId}") + + return self.db.recordDelete(Parzelle, parzelleId) + + # ===== Dokument Methods ===== + + def createDokument(self, dokument: Dokument) -> Dokument: + """Create a new document.""" + if not dokument.mandateId: + dokument.mandateId = self.mandateId + + self.access.uam(Dokument, []) + self.db.recordCreate(Dokument, dokument.model_dump()) + + return dokument + + def getDokument(self, dokumentId: str) -> Optional[Dokument]: + """Get a document by ID.""" + records = self.db.getRecordset( + Dokument, + recordFilter={"id": dokumentId} + ) + + if not records: + return None + + filtered = self.access.uam(Dokument, records) + + if not filtered: + return None + + return Dokument(**filtered[0]) + + def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Dokument]: + """Get all documents matching the filter.""" + records = self.db.getRecordset(Dokument, recordFilter=recordFilter or {}) + filtered = self.access.uam(Dokument, records) + return [Dokument(**r) for r in filtered] + + def updateDokument(self, dokumentId: str, updateData: Dict[str, Any]) -> Optional[Dokument]: + """Update a document.""" + dokument = self.getDokument(dokumentId) + if not dokument: + return None + + if not self.access.canModify(Dokument, dokumentId): + raise PermissionError(f"User {self.userId} cannot modify document {dokumentId}") + + for key, value in updateData.items(): + if hasattr(dokument, key): + setattr(dokument, key, value) + + self.db.recordModify(Dokument, dokumentId, dokument.model_dump()) + return dokument + + def deleteDokument(self, dokumentId: str) -> bool: + """Delete a document.""" + dokument = self.getDokument(dokumentId) + if not dokument: + return False + + if not self.access.canModify(Dokument, dokumentId): + raise PermissionError(f"User {self.userId} cannot delete document {dokumentId}") + + return self.db.recordDelete(Dokument, dokumentId) + + # ===== Gemeinde Methods ===== + + def createGemeinde(self, gemeinde: Gemeinde) -> Gemeinde: + """Create a new municipality.""" + if not gemeinde.mandateId: + gemeinde.mandateId = self.mandateId + + self.access.uam(Gemeinde, []) + self.db.recordCreate(Gemeinde, gemeinde.model_dump()) + + return gemeinde + + def getGemeinde(self, gemeindeId: str) -> Optional[Gemeinde]: + """Get a municipality by ID.""" + records = self.db.getRecordset( + Gemeinde, + recordFilter={"id": gemeindeId} + ) + + if not records: + return None + + filtered = self.access.uam(Gemeinde, records) + + if not filtered: + return None + + return Gemeinde(**filtered[0]) + + def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Gemeinde]: + """Get all municipalities matching the filter.""" + records = self.db.getRecordset(Gemeinde, recordFilter=recordFilter or {}) + filtered = self.access.uam(Gemeinde, records) + return [Gemeinde(**r) for r in filtered] + + def updateGemeinde(self, gemeindeId: str, updateData: Dict[str, Any]) -> Optional[Gemeinde]: + """Update a municipality.""" + gemeinde = self.getGemeinde(gemeindeId) + if not gemeinde: + return None + + if not self.access.canModify(Gemeinde, gemeindeId): + raise PermissionError(f"User {self.userId} cannot modify municipality {gemeindeId}") + + for key, value in updateData.items(): + if hasattr(gemeinde, key): + setattr(gemeinde, key, value) + + self.db.recordModify(Gemeinde, gemeindeId, gemeinde.model_dump()) + return gemeinde + + def deleteGemeinde(self, gemeindeId: str) -> bool: + """Delete a municipality.""" + gemeinde = self.getGemeinde(gemeindeId) + if not gemeinde: + return False + + if not self.access.canModify(Gemeinde, gemeindeId): + raise PermissionError(f"User {self.userId} cannot delete municipality {gemeindeId}") + + return self.db.recordDelete(Gemeinde, gemeindeId) + + # ===== Kanton Methods ===== + + def createKanton(self, kanton: Kanton) -> Kanton: + """Create a new canton.""" + if not kanton.mandateId: + kanton.mandateId = self.mandateId + + self.access.uam(Kanton, []) + self.db.recordCreate(Kanton, kanton.model_dump()) + + return kanton + + def getKanton(self, kantonId: str) -> Optional[Kanton]: + """Get a canton by ID.""" + records = self.db.getRecordset( + Kanton, + recordFilter={"id": kantonId} + ) + + if not records: + return None + + filtered = self.access.uam(Kanton, records) + + if not filtered: + return None + + return Kanton(**filtered[0]) + + def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Kanton]: + """Get all cantons matching the filter.""" + records = self.db.getRecordset(Kanton, recordFilter=recordFilter or {}) + filtered = self.access.uam(Kanton, records) + return [Kanton(**r) for r in filtered] + + def updateKanton(self, kantonId: str, updateData: Dict[str, Any]) -> Optional[Kanton]: + """Update a canton.""" + kanton = self.getKanton(kantonId) + if not kanton: + return None + + if not self.access.canModify(Kanton, kantonId): + raise PermissionError(f"User {self.userId} cannot modify canton {kantonId}") + + for key, value in updateData.items(): + if hasattr(kanton, key): + setattr(kanton, key, value) + + self.db.recordModify(Kanton, kantonId, kanton.model_dump()) + return kanton + + def deleteKanton(self, kantonId: str) -> bool: + """Delete a canton.""" + kanton = self.getKanton(kantonId) + if not kanton: + return False + + if not self.access.canModify(Kanton, kantonId): + raise PermissionError(f"User {self.userId} cannot delete canton {kantonId}") + + return self.db.recordDelete(Kanton, kantonId) + + # ===== Land Methods ===== + + def createLand(self, land: Land) -> Land: + """Create a new country.""" + if not land.mandateId: + land.mandateId = self.mandateId + + self.access.uam(Land, []) + self.db.recordCreate(Land, land.model_dump()) + + return land + + def getLand(self, landId: str) -> Optional[Land]: + """Get a country by ID.""" + records = self.db.getRecordset( + Land, + recordFilter={"id": landId} + ) + + if not records: + return None + + filtered = self.access.uam(Land, records) + + if not filtered: + return None + + return Land(**filtered[0]) + + def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Land]: + """Get all countries matching the filter.""" + records = self.db.getRecordset(Land, recordFilter=recordFilter or {}) + filtered = self.access.uam(Land, records) + return [Land(**r) for r in filtered] + + def updateLand(self, landId: str, updateData: Dict[str, Any]) -> Optional[Land]: + """Update a country.""" + land = self.getLand(landId) + if not land: + return None + + if not self.access.canModify(Land, landId): + raise PermissionError(f"User {self.userId} cannot modify country {landId}") + + for key, value in updateData.items(): + if hasattr(land, key): + setattr(land, key, value) + + self.db.recordModify(Land, landId, land.model_dump()) + return land + + def deleteLand(self, landId: str) -> bool: + """Delete a country.""" + land = self.getLand(landId) + if not land: + return False + + if not self.access.canModify(Land, landId): + raise PermissionError(f"User {self.userId} cannot delete country {landId}") + + return self.db.recordDelete(Land, landId) + + # ===== Direct Query Execution (stateless) ===== + + def executeQuery(self, queryText: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Execute a SQL query directly on the database (stateless). + + WARNING: This method executes raw SQL. Ensure proper validation and sanitization + before calling this method. Consider implementing query whitelisting or + only allowing SELECT statements for production use. + + Args: + queryText: SQL query string (preferably SELECT only) + parameters: Optional parameters for parameterized queries + + Returns: + Dictionary with 'rows' (list of dicts), 'columns' (list of column names), + 'rowCount' (int), and 'executionTime' (float) + """ + import time + + try: + start_time = time.time() + + # Ensure connection is alive + self.db._ensure_connection() + + with self.db.connection.cursor() as cursor: + # Execute query + if parameters: + # Use parameterized query for safety + cursor.execute(queryText, parameters) + else: + cursor.execute(queryText) + + # Fetch results + rows = cursor.fetchall() + + # Convert to list of dictionaries + result_rows = [] + if rows: + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + result_rows = [dict(zip(columns, row)) for row in rows] + else: + columns = [] + + execution_time = time.time() - start_time + + return { + "rows": result_rows, + "columns": columns, + "rowCount": len(result_rows), + "executionTime": execution_time, + } + except Exception as e: + logger.error(f"Error executing query: {e}", exc_info=True) + raise + + +def getInterface(currentUser: User) -> RealEstateObjects: + """ + Factory function to get or create a Real Estate interface instance for a user. + Uses singleton pattern per user. + """ + userKey = f"{currentUser.id}_{currentUser.mandateId}" + + if userKey not in _realEstateInterfaces: + _realEstateInterfaces[userKey] = RealEstateObjects(currentUser) + + return _realEstateInterfaces[userKey] + diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 9caae7aa..ea660554 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -10,17 +10,20 @@ SECURITY NOTE: - This prevents security vulnerabilities where admin users could see other users' connections """ -from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response +from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query from typing import List, Dict, Any, Optional from fastapi import status import logging import json +import math from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelSecurity import Token from modules.auth import getCurrentUser, limiter +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.interfaces.interfaceDbAppObjects import getInterface from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp +from modules.interfaces.interfaceDbComponentObjects import ComponentObjects # Configure logger logger = logging.getLogger(__name__) @@ -89,20 +92,44 @@ router = APIRouter( responses={404: {"description": "Not found"}} ) -@router.get("/", response_model=List[UserConnection]) +@router.get("/", response_model=PaginatedResponse[UserConnection]) @limiter.limit("30/minute") async def get_connections( request: Request, + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), currentUser: User = Depends(getCurrentUser) -) -> List[UserConnection]: - """Get all connections for the current user +) -> PaginatedResponse[UserConnection]: + """Get connections for the current user with optional pagination, sorting, and filtering. SECURITY: This endpoint is secure - users can only see their own connections. Automatically refreshes expired OAuth tokens in the background. + + Query Parameters: + - pagination: JSON-encoded PaginationParams object, or None for no pagination + + Examples: + - GET /api/connections/ (no pagination - returns all items) + - GET /api/connections/?pagination={"page":1,"pageSize":10,"sort":[]} + - GET /api/connections/?pagination={"page":1,"pageSize":10,"filters":{"status":"active"}} """ try: interface = getInterface(currentUser) + # Parse pagination parameter + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + # Normalize pagination dict (handles top-level "search" field) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=400, + detail=f"Invalid pagination parameter: {str(e)}" + ) + # SECURITY FIX: All users (including admins) can only see their own connections # This prevents admin from seeing other users' connections and causing confusion connections = interface.getUserConnections(currentUser.id) @@ -119,33 +146,111 @@ async def get_connections( logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}") # Continue with original connections even if refresh fails - # Enhance each connection with token status information - enhanced_connections = [] + # Enhance each connection with token status information and convert to dict + enhanced_connections_dict = [] for connection in connections: # Get token status for this connection tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id) - # Create enhanced connection with token status - enhanced_connection = UserConnection( - id=connection.id, - userId=connection.userId, - authority=connection.authority, - externalId=connection.externalId, - externalUsername=connection.externalUsername, - externalEmail=connection.externalEmail, - status=connection.status, - connectedAt=connection.connectedAt, - lastChecked=connection.lastChecked, - expiresAt=connection.expiresAt, - tokenStatus=tokenStatus, - tokenExpiresAt=tokenExpiresAt + # Convert to dict for filtering/sorting + connection_dict = { + "id": connection.id, + "userId": connection.userId, + "authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority), + "externalId": connection.externalId, + "externalUsername": connection.externalUsername or "", + "externalEmail": connection.externalEmail, # Keep None instead of converting to empty string + "status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status), + "connectedAt": connection.connectedAt, + "lastChecked": connection.lastChecked, + "expiresAt": connection.expiresAt, + "tokenStatus": tokenStatus, + "tokenExpiresAt": tokenExpiresAt + } + enhanced_connections_dict.append(connection_dict) + + # If no pagination requested, return all items + if paginationParams is None: + # Convert back to UserConnection objects (enum strings are already in dict) + items = [] + for conn_dict in enhanced_connections_dict: + conn_dict_copy = dict(conn_dict) + if "authority" in conn_dict_copy and isinstance(conn_dict_copy["authority"], str): + try: + conn_dict_copy["authority"] = AuthAuthority(conn_dict_copy["authority"]) + except ValueError: + pass + if "status" in conn_dict_copy and isinstance(conn_dict_copy["status"], str): + try: + conn_dict_copy["status"] = ConnectionStatus(conn_dict_copy["status"]) + except ValueError: + pass + items.append(UserConnection(**conn_dict_copy)) + return PaginatedResponse( + items=items, + pagination=None ) - enhanced_connections.append(enhanced_connection) - return enhanced_connections + # Apply filtering if provided + if paginationParams.filters: + component_interface = ComponentObjects() + component_interface.setUserContext(currentUser) + enhanced_connections_dict = component_interface._applyFilters( + enhanced_connections_dict, + paginationParams.filters + ) + # Apply sorting if provided + if paginationParams.sort: + component_interface = ComponentObjects() + component_interface.setUserContext(currentUser) + enhanced_connections_dict = component_interface._applySorting( + enhanced_connections_dict, + paginationParams.sort + ) + + # Count total items after filters + totalItems = len(enhanced_connections_dict) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + + # Apply pagination (skip/limit) + startIdx = (paginationParams.page - 1) * paginationParams.pageSize + endIdx = startIdx + paginationParams.pageSize + paged_connections = enhanced_connections_dict[startIdx:endIdx] + + # Convert back to UserConnection objects (convert enum strings back to enums) + items = [] + for conn_dict in paged_connections: + # Convert enum strings back to enum objects + conn_dict_copy = dict(conn_dict) + if "authority" in conn_dict_copy and isinstance(conn_dict_copy["authority"], str): + try: + conn_dict_copy["authority"] = AuthAuthority(conn_dict_copy["authority"]) + except ValueError: + pass # Keep as string if invalid + if "status" in conn_dict_copy and isinstance(conn_dict_copy["status"], str): + try: + conn_dict_copy["status"] = ConnectionStatus(conn_dict_copy["status"]) + except ValueError: + pass # Keep as string if invalid + items.append(UserConnection(**conn_dict_copy)) + + return PaginatedResponse( + items=items, + pagination=PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ) + ) + + except HTTPException: + raise except Exception as e: - logger.error(f"Error getting connections: {str(e)}") + logger.error(f"Error getting connections: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get connections: {str(e)}" diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index d2b5b816..23db7170 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -14,7 +14,7 @@ import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObj from modules.datamodels.datamodelFiles import FileItem, FilePreview from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.datamodels.datamodelUam import User -from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict # Configure logger logger = logging.getLogger(__name__) @@ -59,7 +59,10 @@ async def get_files( if pagination: try: paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None + if paginationDict: + # Normalize pagination dict (handles top-level "search" field) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 9e3501ab..d3736b75 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -13,7 +13,7 @@ from modules.auth import limiter, getCurrentUser import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUam import User -from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict # Configure logger logger = logging.getLogger(__name__) @@ -48,7 +48,10 @@ async def get_prompts( if pagination: try: paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None + if paginationDict: + # Normalize pagination dict (handles top-level "search" field) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py new file mode 100644 index 00000000..a554ce7d --- /dev/null +++ b/modules/routes/routeRealEstate.py @@ -0,0 +1,1153 @@ +""" +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)}" + ) + diff --git a/modules/routes/routeWorkflows.py b/modules/routes/routeWorkflows.py index 352fcfd3..3c97883e 100644 --- a/modules/routes/routeWorkflows.py +++ b/modules/routes/routeWorkflows.py @@ -28,7 +28,7 @@ from modules.datamodels.datamodelChat import ( ) from modules.shared.attributeUtils import getModelAttributeDefinitions, AttributeResponse from modules.datamodels.datamodelUam import User -from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata +from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict # Configure logger @@ -71,7 +71,10 @@ async def get_workflows( if pagination: try: paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None + if paginationDict: + # Normalize pagination dict (handles top-level "search" field) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, @@ -264,7 +267,10 @@ async def get_workflow_logs( if pagination: try: paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None + if paginationDict: + # Normalize pagination dict (handles top-level "search" field) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, @@ -352,7 +358,10 @@ async def get_workflow_messages( if pagination: try: paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None + if paginationDict: + # Normalize pagination dict (handles top-level "search" field) + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, diff --git a/requirements.txt b/requirements.txt index 6377611d..f73ad2a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -102,4 +102,10 @@ xyzservices>=2021.09.1 # PostgreSQL connector dependencies psycopg2-binary==2.9.9 -asyncpg==0.30.0 \ No newline at end of file +asyncpg==0.30.0 + +## Geospatial libraries for STAC connector +pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326) +shapely>=2.0.0 # For geometric operations (intersections, area calculations) +geopandas>=0.14.0 # For reading and querying GeoPackage files +fiona>=1.9.0 # Required by geopandas for reading GeoPackage files \ No newline at end of file