feat: multiselect parcells and create projects
This commit is contained in:
parent
766122bd13
commit
4aea154eaf
4 changed files with 929 additions and 226 deletions
|
|
@ -65,7 +65,17 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
||||||
"messages",
|
"messages",
|
||||||
"stats",
|
"stats",
|
||||||
"tasks",
|
"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"
|
fields[field_name] = "JSONB"
|
||||||
# Simple type mapping
|
# Simple type mapping
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,20 @@ Stateless implementation without session management.
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
from typing import Optional, Dict, Any, List
|
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.datamodelUam import User
|
||||||
from modules.datamodels.datamodelRealEstate import (
|
from modules.datamodels.datamodelRealEstate import (
|
||||||
Projekt,
|
Projekt,
|
||||||
Parzelle,
|
Parzelle,
|
||||||
StatusProzess,
|
StatusProzess,
|
||||||
GeoPolylinie,
|
GeoPolylinie,
|
||||||
|
GeoPunkt,
|
||||||
|
Kontext,
|
||||||
|
Gemeinde,
|
||||||
|
Kanton,
|
||||||
|
Land,
|
||||||
)
|
)
|
||||||
from modules.services import getInterface as getServices
|
from modules.services import getInterface as getServices
|
||||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
||||||
|
|
@ -21,6 +29,257 @@ from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerCon
|
||||||
logger = logging.getLogger(__name__)
|
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 =====
|
# ===== Swisstopo Integration =====
|
||||||
|
|
||||||
async def fetch_parcel_polygon_from_swisstopo(
|
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)
|
logger.error(f"Error executing intent-based operation: {str(e)}", exc_info=True)
|
||||||
raise
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,8 +125,8 @@ class RealEstateObjects:
|
||||||
# Apply access control
|
# Apply access control
|
||||||
self.access.uam(Projekt, [])
|
self.access.uam(Projekt, [])
|
||||||
|
|
||||||
# Save to database
|
# Save to database - use mode='json' to ensure nested Pydantic models are serialized
|
||||||
self.db.recordCreate(Projekt, projekt.model_dump())
|
self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
|
||||||
|
|
||||||
return projekt
|
return projekt
|
||||||
|
|
||||||
|
|
@ -209,7 +209,8 @@ class RealEstateObjects:
|
||||||
parzelle.mandateId = self.mandateId
|
parzelle.mandateId = self.mandateId
|
||||||
|
|
||||||
self.access.uam(Parzelle, [])
|
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
|
return parzelle
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ from modules.datamodels.datamodelRealEstate import (
|
||||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
||||||
|
|
||||||
# Import feature logic for AI-powered commands
|
# 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
|
# Import Swiss Topo MapServer connector for testing
|
||||||
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
|
|
@ -325,29 +328,6 @@ async def get_table_data(
|
||||||
# FastAPI will automatically serialize Pydantic models to JSON
|
# FastAPI will automatically serialize Pydantic models to JSON
|
||||||
items = records
|
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
|
# Parse pagination parameter
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
|
|
@ -425,7 +405,7 @@ async def create_table_record(
|
||||||
Create a new record in a specific real estate table.
|
Create a new record in a specific real estate table.
|
||||||
|
|
||||||
Available tables:
|
Available tables:
|
||||||
- Projekt: Real estate projects
|
- Projekt: Real estate projects (with parcel data support)
|
||||||
- Parzelle: Plots/parcels
|
- Parzelle: Plots/parcels
|
||||||
- Dokument: Documents
|
- Dokument: Documents
|
||||||
- Gemeinde: Municipalities
|
- Gemeinde: Municipalities
|
||||||
|
|
@ -433,6 +413,20 @@ async def create_table_record(
|
||||||
- Land: Countries
|
- Land: Countries
|
||||||
|
|
||||||
Request Body:
|
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
|
- JSON object with fields matching the table's data model
|
||||||
|
|
||||||
Headers:
|
Headers:
|
||||||
|
|
@ -440,7 +434,7 @@ async def create_table_record(
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- POST /api/realestate/table/Projekt
|
- POST /api/realestate/table/Projekt
|
||||||
Body: {"label": "Hauptstrasse 42", "statusProzess": "Eingang"}
|
Body: {"label": "Hauptstrasse 42", "parzelle": {...}}
|
||||||
- POST /api/realestate/table/Parzelle
|
- POST /api/realestate/table/Parzelle
|
||||||
Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"}
|
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"
|
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.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})")
|
||||||
logger.debug(f"Record data: {data}")
|
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])
|
@router.post("/projekt/{projekt_id}/add-parcel", response_model=Dict[str, Any])
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def add_parcel_to_project(
|
async def add_parcel_to_project(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue