feat: multiselect parcells and create projects

This commit is contained in:
Ida Dittrich 2026-01-05 18:04:01 +01:00
parent 766122bd13
commit 4aea154eaf
4 changed files with 929 additions and 226 deletions

View file

@ -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

View file

@ -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']}"
))
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

View file

@ -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

View file

@ -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(