diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 5dde399d..13c6b4de 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -65,7 +65,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/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 5da41413..149fd8d8 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -7,12 +7,20 @@ 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 @@ -21,6 +29,257 @@ from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerCon 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( @@ -1210,3 +1469,581 @@ async def executeIntentBasedOperation( 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/interfaceDbRealEstateObjects.py b/modules/interfaces/interfaceDbRealEstateObjects.py index 85b13f97..48084d11 100644 --- a/modules/interfaces/interfaceDbRealEstateObjects.py +++ b/modules/interfaces/interfaceDbRealEstateObjects.py @@ -125,8 +125,8 @@ class RealEstateObjects: # Apply access control self.access.uam(Projekt, []) - # Save to database - self.db.recordCreate(Projekt, projekt.model_dump()) + # Save to database - use mode='json' to ensure nested Pydantic models are serialized + self.db.recordCreate(Projekt, projekt.model_dump(mode='json')) return projekt @@ -209,7 +209,8 @@ class RealEstateObjects: parzelle.mandateId = self.mandateId self.access.uam(Parzelle, []) - self.db.recordCreate(Parzelle, parzelle.model_dump()) + # Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized + self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json')) return parzelle diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index 73168af9..a554ce7d 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -30,7 +30,10 @@ from modules.datamodels.datamodelRealEstate import ( from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface # Import feature logic for AI-powered commands -from modules.features.realEstate.mainRealEstate import processNaturalLanguageCommand +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 @@ -325,29 +328,6 @@ async def get_table_data( # FastAPI will automatically serialize Pydantic models to JSON items = records - # If table is empty, create an empty instance with all fields set to None/empty - # This allows the frontend to extract column structure from the response - # All fields will be None/empty - no IDs or other values generated - if not items: - try: - # Get all model fields - model_fields = model_class.model_fields - empty_values = {} - - # Set all fields to None - explicitly set every field to None - # This ensures no default_factory is called and no IDs are generated - for field_name in model_fields.keys(): - empty_values[field_name] = None - - # Create instance with all None values - # Use model_validate with allow_none=True or construct directly - empty_instance = model_class.model_construct(**empty_values) - items = [empty_instance] - logger.debug(f"Created empty instance for {table} with all fields set to None") - except Exception as e: - logger.warning(f"Could not create empty instance for {table}: {str(e)}. Returning empty list.") - items = [] - # Parse pagination parameter paginationParams = None if pagination: @@ -425,7 +405,7 @@ async def create_table_record( Create a new record in a specific real estate table. Available tables: - - Projekt: Real estate projects + - Projekt: Real estate projects (with parcel data support) - Parzelle: Plots/parcels - Dokument: Documents - Gemeinde: Municipalities @@ -433,6 +413,20 @@ async def create_table_record( - 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: @@ -440,7 +434,7 @@ async def create_table_record( Examples: - POST /api/realestate/table/Projekt - Body: {"label": "Hauptstrasse 42", "statusProzess": "Eingang"} + Body: {"label": "Hauptstrasse 42", "parzelle": {...}} - POST /api/realestate/table/Parzelle Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"} """ @@ -472,6 +466,64 @@ async def create_table_record( 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}") @@ -886,203 +938,6 @@ async def search_parcel( ) -@router.post("/projekt/create", response_model=Dict[str, Any]) -@limiter.limit("60/minute") -async def create_project( - request: Request, - body: Dict[str, Any] = Body(...), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Create a new real estate project (Projekt). - - Request Body: - { - "label": "Projekt Hauptstrasse 42", - "statusProzess": "Eingang", // Optional - "location": "Hauptstrasse 42, 8000 Zürich", // Optional: auto-create parcel - "parcelIds": ["parcel-id-1", "parcel-id-2"] // Optional: link existing parcels - } - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - If 'location' is provided, the system will: - 1. Search for the parcel at that location - 2. Create a Parzelle record - 3. Create a Projekt and link the Parzelle - 4. Return both Projekt and Parzelle IDs - - Returns: - { - "projekt": {...}, - "parzellen": [...] // Parcels that were created or linked - } - """ - 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/create 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"Creating project for user {currentUser.id} (mandate: {currentUser.mandateId})") - - # Extract fields from body - label = body.get("label") - if not label: - raise ValueError("label is required") - - status_prozess = body.get("statusProzess", "Eingang") - location = body.get("location") - parcel_ids = body.get("parcelIds", []) - - # Get interface - realEstateInterface = getRealEstateInterface(currentUser) - - # Handle auto-create parcel from location - created_parcels = [] - if location: - logger.info(f"Auto-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_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) - ) - ] - } - - # Create Parzelle instance - parzelle_instance = Parzelle(**parzelle_data) - created_parzelle = realEstateInterface.createParzelle(parzelle_instance) - created_parcels.append(created_parzelle) - parcel_ids.append(created_parzelle.id) - - logger.info(f"Created Parzelle {created_parzelle.id} from location") - - # Fetch existing parcels if IDs provided - existing_parcels = [] - if parcel_ids: - for parcel_id in parcel_ids: - parcels = realEstateInterface.getParzellen( - recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId} - ) - if parcels: - existing_parcels.append(parcels[0]) - - # Calculate project perimeter from parcels - all_parcels = created_parcels + existing_parcels - projekt_perimeter = None - if all_parcels: - # Use first parcel's perimeter as project perimeter - # In a real system, you might want to merge all parcel perimeters - projekt_perimeter = all_parcels[0].perimeter - - # Create Projekt - projekt_data = { - "mandateId": currentUser.mandateId, - "label": label, - "statusProzess": status_prozess, - "perimeter": projekt_perimeter, - "baulinie": None, - "parzellen": all_parcels, - "dokumente": [], - "kontextInformationen": [] - } - - projekt_instance = Projekt(**projekt_data) - created_projekt = realEstateInterface.createProjekt(projekt_instance) - - logger.info(f"Created Projekt {created_projekt.id} with {len(all_parcels)} parcels") - - return { - "projekt": created_projekt.model_dump(), - "parzellen": [p.model_dump() for p in all_parcels] - } - - except ValueError as e: - logger.error(f"Validation error in create_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 creating project: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating project: {str(e)}" - ) - - @router.post("/projekt/{projekt_id}/add-parcel", response_model=Dict[str, Any]) @limiter.limit("60/minute") async def add_parcel_to_project(