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",
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -7,12 +7,20 @@ Stateless implementation without session management.
|
|||
import logging
|
||||
import json
|
||||
from typing import Optional, Dict, Any, List
|
||||
from fastapi import HTTPException, status
|
||||
from shapely.geometry import Polygon
|
||||
from shapely.ops import unary_union
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.datamodels.datamodelRealEstate import (
|
||||
Projekt,
|
||||
Parzelle,
|
||||
StatusProzess,
|
||||
GeoPolylinie,
|
||||
GeoPunkt,
|
||||
Kontext,
|
||||
Gemeinde,
|
||||
Kanton,
|
||||
Land,
|
||||
)
|
||||
from modules.services import getInterface as getServices
|
||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
||||
|
|
@ -21,6 +29,257 @@ from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerCon
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ===== Geometry Utilities =====
|
||||
|
||||
def geopolylinie_to_shapely_polygon(geopolylinie: GeoPolylinie) -> Polygon:
|
||||
"""
|
||||
Convert GeoPolylinie to Shapely Polygon.
|
||||
|
||||
Args:
|
||||
geopolylinie: GeoPolylinie instance with punkte list
|
||||
|
||||
Returns:
|
||||
Shapely Polygon object
|
||||
"""
|
||||
if not geopolylinie or not geopolylinie.punkte:
|
||||
raise ValueError("GeoPolylinie must have at least one point")
|
||||
|
||||
# Extract coordinates from punkte
|
||||
coordinates = []
|
||||
for punkt in geopolylinie.punkte:
|
||||
coordinates.append((punkt.x, punkt.y))
|
||||
|
||||
# Ensure polygon is closed (first point == last point)
|
||||
if len(coordinates) < 3:
|
||||
raise ValueError("Polygon must have at least 3 points")
|
||||
|
||||
# Close polygon if not already closed
|
||||
if coordinates[0] != coordinates[-1]:
|
||||
coordinates.append(coordinates[0])
|
||||
|
||||
return Polygon(coordinates)
|
||||
|
||||
|
||||
def shapely_polygon_to_geopolylinie(polygon: Polygon) -> GeoPolylinie:
|
||||
"""
|
||||
Convert Shapely Polygon to GeoPolylinie.
|
||||
|
||||
Args:
|
||||
polygon: Shapely Polygon object
|
||||
|
||||
Returns:
|
||||
GeoPolylinie instance with LV95 coordinate system
|
||||
"""
|
||||
if not polygon or polygon.is_empty:
|
||||
raise ValueError("Polygon must not be empty")
|
||||
|
||||
# Extract exterior coordinates
|
||||
exterior_coords = list(polygon.exterior.coords)
|
||||
|
||||
# Remove duplicate last point if present (Shapely includes it)
|
||||
if len(exterior_coords) > 1 and exterior_coords[0] == exterior_coords[-1]:
|
||||
exterior_coords = exterior_coords[:-1]
|
||||
|
||||
# Convert to GeoPunkt list
|
||||
punkte = []
|
||||
for coord in exterior_coords:
|
||||
punkt = GeoPunkt(
|
||||
koordinatensystem="LV95",
|
||||
x=float(coord[0]),
|
||||
y=float(coord[1]),
|
||||
z=None
|
||||
)
|
||||
punkte.append(punkt)
|
||||
|
||||
return GeoPolylinie(
|
||||
closed=True,
|
||||
punkte=punkte
|
||||
)
|
||||
|
||||
|
||||
def combine_parcel_geometries(geometries: List[GeoPolylinie]) -> GeoPolylinie:
|
||||
"""
|
||||
Combine multiple parcel geometries into a single outer outline.
|
||||
|
||||
Uses Shapely union operation to merge polygons and automatically
|
||||
removes internal edges. The result is a clean outer boundary.
|
||||
|
||||
Args:
|
||||
geometries: List of GeoPolylinie instances to combine
|
||||
|
||||
Returns:
|
||||
Combined GeoPolylinie representing the outer outline
|
||||
|
||||
Raises:
|
||||
ValueError: If geometries list is empty or invalid
|
||||
"""
|
||||
if not geometries or len(geometries) == 0:
|
||||
raise ValueError("At least one geometry is required")
|
||||
|
||||
if len(geometries) == 1:
|
||||
# Single geometry - return as-is
|
||||
return geometries[0]
|
||||
|
||||
# Convert all geometries to Shapely Polygons
|
||||
shapely_polygons = []
|
||||
for geo in geometries:
|
||||
try:
|
||||
polygon = geopolylinie_to_shapely_polygon(geo)
|
||||
if not polygon.is_empty:
|
||||
shapely_polygons.append(polygon)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error converting geometry to Shapely Polygon: {e}")
|
||||
continue
|
||||
|
||||
if not shapely_polygons:
|
||||
raise ValueError("No valid geometries to combine")
|
||||
|
||||
if len(shapely_polygons) == 1:
|
||||
# Only one valid polygon - convert back
|
||||
return shapely_polygon_to_geopolylinie(shapely_polygons[0])
|
||||
|
||||
# Perform union operation - automatically removes internal edges
|
||||
try:
|
||||
combined = unary_union(shapely_polygons)
|
||||
|
||||
# Handle MultiPolygon case (disconnected parcels)
|
||||
if hasattr(combined, 'geoms'):
|
||||
# Multiple separate polygons - combine their exteriors
|
||||
# For now, take the largest polygon or combine all exteriors
|
||||
# In practice, we might want to keep them separate or combine differently
|
||||
largest = max(combined.geoms, key=lambda p: p.area)
|
||||
combined = largest
|
||||
|
||||
# Extract outer boundary
|
||||
if combined.is_empty:
|
||||
raise ValueError("Union resulted in empty geometry")
|
||||
|
||||
# Convert back to GeoPolylinie
|
||||
result = shapely_polygon_to_geopolylinie(combined)
|
||||
logger.info(f"Combined {len(geometries)} geometries into single outline with {len(result.punkte)} points")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error combining geometries: {e}", exc_info=True)
|
||||
raise ValueError(f"Failed to combine geometries: {str(e)}")
|
||||
|
||||
|
||||
def filter_neighbor_parcels(
|
||||
neighbors: List[Dict[str, Any]],
|
||||
selected_geometries: List[GeoPolylinie]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter neighbor parcels to exclude those that are part of the selected parcels.
|
||||
|
||||
Uses geometric comparison to check if neighbor parcels intersect or touch
|
||||
any of the selected parcel geometries.
|
||||
|
||||
Args:
|
||||
neighbors: List of neighbor parcel dictionaries (must have 'perimeter' or 'geometry_geojson')
|
||||
selected_geometries: List of GeoPolylinie instances representing selected parcels
|
||||
|
||||
Returns:
|
||||
Filtered list of neighbor parcels (excluding selected ones)
|
||||
"""
|
||||
if not neighbors or not selected_geometries:
|
||||
return neighbors
|
||||
|
||||
# Convert selected geometries to Shapely Polygons for comparison
|
||||
selected_polygons = []
|
||||
for geo in selected_geometries:
|
||||
try:
|
||||
polygon = geopolylinie_to_shapely_polygon(geo)
|
||||
if not polygon.is_empty:
|
||||
selected_polygons.append(polygon)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error converting selected geometry for filtering: {e}")
|
||||
continue
|
||||
|
||||
if not selected_polygons:
|
||||
# No valid selected geometries - return all neighbors
|
||||
return neighbors
|
||||
|
||||
# Filter neighbors
|
||||
filtered_neighbors = []
|
||||
for neighbor in neighbors:
|
||||
try:
|
||||
# Try to get geometry from neighbor
|
||||
neighbor_geometry = None
|
||||
|
||||
# Check for perimeter (GeoPolylinie format)
|
||||
if neighbor.get("perimeter"):
|
||||
perimeter = neighbor["perimeter"]
|
||||
if isinstance(perimeter, dict) and perimeter.get("punkte"):
|
||||
# Convert to GeoPolylinie
|
||||
punkte = []
|
||||
for p in perimeter["punkte"]:
|
||||
punkt = GeoPunkt(
|
||||
koordinatensystem=p.get("koordinatensystem", "LV95"),
|
||||
x=float(p.get("x", 0)),
|
||||
y=float(p.get("y", 0)),
|
||||
z=p.get("z")
|
||||
)
|
||||
punkte.append(punkt)
|
||||
neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte)
|
||||
|
||||
# Check for geometry_geojson
|
||||
elif neighbor.get("geometry_geojson"):
|
||||
geo_json = neighbor["geometry_geojson"]
|
||||
geometry = geo_json.get("geometry") if isinstance(geo_json, dict) else geo_json
|
||||
|
||||
if geometry and geometry.get("type") == "Polygon":
|
||||
coordinates = geometry.get("coordinates", [])
|
||||
if coordinates and len(coordinates) > 0:
|
||||
ring = coordinates[0] # Outer ring
|
||||
punkte = []
|
||||
for coord in ring:
|
||||
if len(coord) >= 2:
|
||||
punkt = GeoPunkt(
|
||||
koordinatensystem="LV95",
|
||||
x=float(coord[0]),
|
||||
y=float(coord[1]),
|
||||
z=float(coord[2]) if len(coord) > 2 else None
|
||||
)
|
||||
punkte.append(punkt)
|
||||
neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte)
|
||||
|
||||
if not neighbor_geometry:
|
||||
# No geometry available - include neighbor (can't filter without geometry)
|
||||
filtered_neighbors.append(neighbor)
|
||||
continue
|
||||
|
||||
# Convert neighbor geometry to Shapely Polygon
|
||||
neighbor_polygon = geopolylinie_to_shapely_polygon(neighbor_geometry)
|
||||
|
||||
# Check if neighbor intersects or touches any selected parcel
|
||||
is_selected = False
|
||||
for selected_polygon in selected_polygons:
|
||||
if neighbor_polygon.intersects(selected_polygon) or neighbor_polygon.touches(selected_polygon):
|
||||
# Check if they're actually the same (within tolerance)
|
||||
# If areas are very similar, it's likely the same parcel
|
||||
area_diff = abs(neighbor_polygon.area - selected_polygon.area)
|
||||
if area_diff < 1.0: # Less than 1 m² difference
|
||||
is_selected = True
|
||||
break
|
||||
# Also check if one contains the other (shouldn't happen for neighbors, but check anyway)
|
||||
if neighbor_polygon.contains(selected_polygon) or selected_polygon.contains(neighbor_polygon):
|
||||
is_selected = True
|
||||
break
|
||||
|
||||
if not is_selected:
|
||||
filtered_neighbors.append(neighbor)
|
||||
else:
|
||||
logger.debug(f"Filtered out neighbor parcel {neighbor.get('id')} - part of selected parcels")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error filtering neighbor parcel {neighbor.get('id')}: {e}")
|
||||
# On error, include neighbor (better to show too many than too few)
|
||||
filtered_neighbors.append(neighbor)
|
||||
|
||||
logger.info(f"Filtered {len(neighbors)} neighbors to {len(filtered_neighbors)} (removed {len(neighbors) - len(filtered_neighbors)} selected parcels)")
|
||||
return filtered_neighbors
|
||||
|
||||
|
||||
# ===== Swisstopo Integration =====
|
||||
|
||||
async def fetch_parcel_polygon_from_swisstopo(
|
||||
|
|
@ -1210,3 +1469,581 @@ async def executeIntentBasedOperation(
|
|||
logger.error(f"Error executing intent-based operation: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
# ===== Project Creation with Parcel Data =====
|
||||
|
||||
async def create_project_with_parcel_data(
|
||||
currentUser: User,
|
||||
projekt_label: str,
|
||||
parzellen_data: List[Dict[str, Any]],
|
||||
status_prozess: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a Projekt with one or more Parzellen from provided parcel data.
|
||||
|
||||
Args:
|
||||
currentUser: Current authenticated user
|
||||
projekt_label: Label for the Projekt
|
||||
parzellen_data: List of dictionaries containing parcel information from request
|
||||
status_prozess: Optional project status (defaults to "Eingang")
|
||||
|
||||
Returns:
|
||||
Dictionary containing created Projekt and list of Parzellen
|
||||
|
||||
Raises:
|
||||
HTTPException: If Gemeinde or Kanton not found, or validation fails
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}")
|
||||
|
||||
# Get interface
|
||||
realEstateInterface = getRealEstateInterface(currentUser)
|
||||
|
||||
# Validate required fields
|
||||
if not projekt_label:
|
||||
raise ValueError("Projekt label is required")
|
||||
|
||||
if not parzellen_data or len(parzellen_data) == 0:
|
||||
raise ValueError("At least one Parzelle data is required")
|
||||
|
||||
# Validate all parcels have required fields
|
||||
for idx, parzelle_data in enumerate(parzellen_data):
|
||||
if not parzelle_data.get("perimeter"):
|
||||
raise ValueError(f"Parzelle {idx + 1} perimeter is required")
|
||||
|
||||
# Helper function to convert GeoJSON geometry to GeoPolylinie (defined early for use in geometry collection)
|
||||
def convert_geojson_to_geopolylinie(geometry_data: Dict[str, Any]) -> Optional[GeoPolylinie]:
|
||||
"""Convert GeoJSON geometry to GeoPolylinie format."""
|
||||
if not geometry_data:
|
||||
return None
|
||||
|
||||
# Handle nested geometry structure (geometry.geometry.coordinates)
|
||||
if "geometry" in geometry_data:
|
||||
geometry_data = geometry_data["geometry"]
|
||||
|
||||
geometry_type = geometry_data.get("type")
|
||||
coordinates = geometry_data.get("coordinates")
|
||||
|
||||
if not coordinates or geometry_type != "Polygon":
|
||||
return None
|
||||
|
||||
# Extract outer ring (first array of coordinates)
|
||||
if not coordinates or len(coordinates) == 0:
|
||||
return None
|
||||
|
||||
ring = coordinates[0] # Outer ring
|
||||
|
||||
# Convert coordinates to GeoPunkt list
|
||||
punkte = []
|
||||
for coord in ring:
|
||||
if len(coord) >= 2:
|
||||
punkt = GeoPunkt(
|
||||
koordinatensystem="LV95",
|
||||
x=float(coord[0]),
|
||||
y=float(coord[1]),
|
||||
z=float(coord[2]) if len(coord) > 2 else None
|
||||
)
|
||||
punkte.append(punkt)
|
||||
|
||||
if not punkte:
|
||||
return None
|
||||
|
||||
return GeoPolylinie(
|
||||
closed=True,
|
||||
punkte=punkte
|
||||
)
|
||||
|
||||
# First pass: Collect all parcel geometries for neighbor filtering
|
||||
# Convert all perimeters to GeoPolylinie format
|
||||
all_parcel_geometries = []
|
||||
for parzelle_data in parzellen_data:
|
||||
perimeter = parzelle_data.get("perimeter")
|
||||
if perimeter:
|
||||
# Convert to GeoPolylinie if needed
|
||||
if isinstance(perimeter, dict):
|
||||
if "punkte" in perimeter and "closed" in perimeter:
|
||||
try:
|
||||
geo_perimeter = GeoPolylinie(**perimeter)
|
||||
all_parcel_geometries.append(geo_perimeter)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error converting perimeter to GeoPolylinie: {e}")
|
||||
else:
|
||||
# Try GeoJSON conversion
|
||||
converted = convert_geojson_to_geopolylinie(perimeter)
|
||||
if converted:
|
||||
all_parcel_geometries.append(converted)
|
||||
elif isinstance(perimeter, GeoPolylinie):
|
||||
all_parcel_geometries.append(perimeter)
|
||||
|
||||
# Process all parcels - create each one or use existing
|
||||
created_parzellen = []
|
||||
parcel_perimeters = [] # Collect all parcel perimeters for baulinie calculation
|
||||
|
||||
for idx, parzelle_data in enumerate(parzellen_data):
|
||||
logger.info(f"Processing Parzelle {idx + 1}/{len(parzellen_data)}")
|
||||
|
||||
# Determine parcel label for uniqueness check
|
||||
parcel_label = parzelle_data.get("id") or parzelle_data.get("number") or parzelle_data.get("label") or "Unknown"
|
||||
|
||||
# Check if Parzelle with this label already exists
|
||||
existing_parzellen = realEstateInterface.getParzellen(
|
||||
recordFilter={"label": parcel_label, "mandateId": currentUser.mandateId}
|
||||
)
|
||||
|
||||
if existing_parzellen and len(existing_parzellen) > 0:
|
||||
# Parzelle already exists - use existing one
|
||||
existing_parzelle = existing_parzellen[0]
|
||||
logger.info(f"Parzelle with label '{parcel_label}' already exists (ID: {existing_parzelle.id}), reusing it")
|
||||
|
||||
# Collect perimeter for baulinie calculation
|
||||
if existing_parzelle.perimeter:
|
||||
parcel_perimeters.append(existing_parzelle.perimeter)
|
||||
|
||||
# Add to list of created parcels (actually existing)
|
||||
created_parzellen.append(existing_parzelle)
|
||||
continue # Skip creation, use existing
|
||||
|
||||
# Parzelle does not exist - create new one
|
||||
logger.info(f"Parzelle with label '{parcel_label}' does not exist, creating new one")
|
||||
|
||||
# Resolve Gemeinde and Kanton for this parcel (create if not found)
|
||||
gemeinde_id = None
|
||||
canton_abk = parzelle_data.get("canton")
|
||||
municipality_name = parzelle_data.get("municipality_name")
|
||||
|
||||
logger.debug(f"Resolving Gemeinde/Kanton: canton='{canton_abk}', municipality='{municipality_name}'")
|
||||
|
||||
if municipality_name and canton_abk:
|
||||
# Mapping of canton abbreviations to full names
|
||||
canton_names = {
|
||||
"ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz",
|
||||
"OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg",
|
||||
"SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen",
|
||||
"AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen",
|
||||
"GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin",
|
||||
"VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura"
|
||||
}
|
||||
|
||||
# First, ensure Land "Schweiz" exists
|
||||
logger.debug("Ensuring Land 'Schweiz' exists")
|
||||
laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"})
|
||||
if not laender:
|
||||
logger.info("Creating Land 'Schweiz'")
|
||||
land = Land(
|
||||
mandateId=currentUser.mandateId,
|
||||
label="Schweiz",
|
||||
abk="CH"
|
||||
)
|
||||
land = realEstateInterface.createLand(land)
|
||||
logger.info(f"Created Land 'Schweiz' with ID: {land.id}")
|
||||
else:
|
||||
land = laender[0]
|
||||
logger.debug(f"Found Land 'Schweiz' with ID: {land.id}")
|
||||
|
||||
# Then, lookup or create Kanton
|
||||
logger.debug(f"Looking up Kanton with abk='{canton_abk}'")
|
||||
kantone = realEstateInterface.getKantone(recordFilter={"abk": canton_abk})
|
||||
logger.debug(f"Found {len(kantone)} Kanton(e) with abk='{canton_abk}'")
|
||||
if not kantone:
|
||||
logger.info(f"Kanton '{canton_abk}' not found, creating it")
|
||||
kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk
|
||||
kanton = Kanton(
|
||||
mandateId=currentUser.mandateId,
|
||||
label=kanton_label,
|
||||
abk=canton_abk,
|
||||
id_land=land.id
|
||||
)
|
||||
kanton = realEstateInterface.createKanton(kanton)
|
||||
logger.info(f"Created Kanton '{kanton_label}' ({canton_abk}) with ID: {kanton.id}")
|
||||
else:
|
||||
kanton = kantone[0]
|
||||
logger.debug(f"Found Kanton: ID={kanton.id}, Label={kanton.label}, abk={kanton.abk}")
|
||||
|
||||
# Then, lookup or create Gemeinde
|
||||
logger.debug(f"Looking up Gemeinde with label='{municipality_name}' and id_kanton='{kanton.id}'")
|
||||
gemeinden = realEstateInterface.getGemeinden(
|
||||
recordFilter={"label": municipality_name, "id_kanton": kanton.id}
|
||||
)
|
||||
logger.debug(f"Found {len(gemeinden)} Gemeinde(n) with label='{municipality_name}' and id_kanton='{kanton.id}'")
|
||||
if not gemeinden:
|
||||
logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it")
|
||||
gemeinde = Gemeinde(
|
||||
mandateId=currentUser.mandateId,
|
||||
label=municipality_name,
|
||||
id_kanton=kanton.id,
|
||||
plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API
|
||||
)
|
||||
gemeinde = realEstateInterface.createGemeinde(gemeinde)
|
||||
logger.info(f"Created Gemeinde '{municipality_name}' with ID: {gemeinde.id}")
|
||||
else:
|
||||
gemeinde = gemeinden[0]
|
||||
logger.debug(f"Found Gemeinde: ID={gemeinde.id}, Label={gemeinde.label}")
|
||||
|
||||
gemeinde_id = gemeinde.id
|
||||
logger.info(f"Resolved Gemeinde '{municipality_name}' to ID '{gemeinde_id}'")
|
||||
else:
|
||||
logger.warning(f"Missing Gemeinde/Kanton data: municipality_name={municipality_name}, canton={canton_abk}")
|
||||
|
||||
# Build parzellenAliasTags
|
||||
alias_tags = []
|
||||
if parzelle_data.get("egrid"):
|
||||
alias_tags.append(parzelle_data["egrid"])
|
||||
if parzelle_data.get("number") and parzelle_data["number"] != parzelle_data.get("id"):
|
||||
alias_tags.append(parzelle_data["number"])
|
||||
|
||||
# Extract address information from Swiss Topo API data
|
||||
# Each parcel should have its own address data from Swiss Topo API
|
||||
# The address comes from the parcel search API response for THIS specific parcel
|
||||
strasse_nr = None
|
||||
plz = None
|
||||
|
||||
# Use address from Swiss Topo API - this is specific to THIS parcel
|
||||
# The address field contains the full address string from Swiss Topo
|
||||
address = parzelle_data.get("address")
|
||||
if address:
|
||||
# Swiss Topo provides full address string like "Street Number, PLZ City"
|
||||
# Parse to extract street and number (before comma)
|
||||
parts = address.split(",")
|
||||
if len(parts) >= 1:
|
||||
strasse_nr = parts[0].strip()
|
||||
# PLZ is provided separately by Swiss Topo API
|
||||
plz = parzelle_data.get("plz")
|
||||
|
||||
# Log address info for debugging
|
||||
logger.debug(f"Parzelle {idx + 1} address data: strasse_nr='{strasse_nr}', plz='{plz}', full_address='{address}'")
|
||||
|
||||
# If no address found, log warning but continue
|
||||
if not strasse_nr and not plz:
|
||||
logger.warning(f"No address data found for Parzelle {idx + 1} (label: {parcel_label})")
|
||||
|
||||
# Build kontextInformationen
|
||||
kontext_items = []
|
||||
|
||||
if parzelle_data.get("egrid"):
|
||||
kontext_items.append(Kontext(
|
||||
thema="EGRID",
|
||||
inhalt=parzelle_data["egrid"]
|
||||
))
|
||||
|
||||
if parzelle_data.get("identnd"):
|
||||
kontext_items.append(Kontext(
|
||||
thema="IdentND",
|
||||
inhalt=parzelle_data["identnd"]
|
||||
))
|
||||
|
||||
if parzelle_data.get("area_m2"):
|
||||
kontext_items.append(Kontext(
|
||||
thema="Fläche",
|
||||
inhalt=f"{parzelle_data['area_m2']} m²"
|
||||
))
|
||||
|
||||
if parzelle_data.get("centroid"):
|
||||
centroid = parzelle_data["centroid"]
|
||||
kontext_items.append(Kontext(
|
||||
thema="Zentrum (LV95)",
|
||||
inhalt=f"X: {centroid.get('x')} m, Y: {centroid.get('y')} m (EPSG:2056)"
|
||||
))
|
||||
|
||||
if parzelle_data.get("geoportal_url"):
|
||||
kontext_items.append(Kontext(
|
||||
thema="Geoportal URL",
|
||||
inhalt=parzelle_data["geoportal_url"]
|
||||
))
|
||||
|
||||
if parzelle_data.get("municipality_code"):
|
||||
kontext_items.append(Kontext(
|
||||
thema="BFS-Nummer",
|
||||
inhalt=str(parzelle_data["municipality_code"])
|
||||
))
|
||||
|
||||
# Handle adjacent parcels - filter out selected parcels geometrically
|
||||
adjacent_parcel_refs = []
|
||||
if parzelle_data.get("adjacent_parcels"):
|
||||
# Filter neighbors to exclude selected parcels
|
||||
neighbors_to_filter = []
|
||||
for adj_parcel in parzelle_data["adjacent_parcels"]:
|
||||
if isinstance(adj_parcel, dict):
|
||||
neighbors_to_filter.append(adj_parcel)
|
||||
elif isinstance(adj_parcel, str):
|
||||
neighbors_to_filter.append({"id": adj_parcel})
|
||||
|
||||
# Filter using geometry comparison if we have geometries
|
||||
if all_parcel_geometries and neighbors_to_filter:
|
||||
try:
|
||||
filtered_neighbors = filter_neighbor_parcels(
|
||||
neighbors_to_filter,
|
||||
all_parcel_geometries
|
||||
)
|
||||
# Extract IDs from filtered neighbors
|
||||
for filtered_neighbor in filtered_neighbors:
|
||||
adj_id = filtered_neighbor.get("id")
|
||||
if adj_id:
|
||||
adjacent_parcel_refs.append({"id": adj_id})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error filtering neighbor parcels: {e}, including all neighbors")
|
||||
# Fallback: include all neighbors if filtering fails
|
||||
for adj_parcel in parzelle_data["adjacent_parcels"]:
|
||||
if isinstance(adj_parcel, dict):
|
||||
adj_id = adj_parcel.get("id")
|
||||
if adj_id:
|
||||
adjacent_parcel_refs.append({"id": adj_id})
|
||||
elif isinstance(adj_parcel, str):
|
||||
adjacent_parcel_refs.append({"id": adj_parcel})
|
||||
else:
|
||||
# No geometries available - include all neighbors
|
||||
for adj_parcel in parzelle_data["adjacent_parcels"]:
|
||||
if isinstance(adj_parcel, dict):
|
||||
adj_id = adj_parcel.get("id")
|
||||
if adj_id:
|
||||
adjacent_parcel_refs.append({"id": adj_id})
|
||||
elif isinstance(adj_parcel, str):
|
||||
adjacent_parcel_refs.append({"id": adj_parcel})
|
||||
|
||||
# Convert perimeter to GeoPolylinie if needed
|
||||
perimeter = parzelle_data.get("perimeter")
|
||||
if isinstance(perimeter, dict):
|
||||
# Check if it's already in GeoPolylinie format (has punkte and closed)
|
||||
if "punkte" in perimeter and "closed" in perimeter:
|
||||
try:
|
||||
perimeter = GeoPolylinie(**perimeter)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid perimeter format: {str(e)}")
|
||||
else:
|
||||
# Try to convert from GeoJSON format
|
||||
converted = convert_geojson_to_geopolylinie(perimeter)
|
||||
if converted:
|
||||
perimeter = converted
|
||||
else:
|
||||
raise ValueError("Invalid perimeter format: cannot convert to GeoPolylinie")
|
||||
elif isinstance(perimeter, GeoPolylinie):
|
||||
# Already a GeoPolylinie instance, use as-is
|
||||
pass
|
||||
else:
|
||||
raise ValueError("Invalid perimeter type: must be dict or GeoPolylinie")
|
||||
|
||||
# Extract baulinie from geometry if provided
|
||||
baulinie = None
|
||||
geometry = parzelle_data.get("geometry")
|
||||
logger.debug(f"Geometry present: {geometry is not None}")
|
||||
if geometry:
|
||||
logger.debug(f"Geometry type: {type(geometry)}, keys: {list(geometry.keys()) if isinstance(geometry, dict) else 'not a dict'}")
|
||||
baulinie = convert_geojson_to_geopolylinie(geometry)
|
||||
if baulinie:
|
||||
logger.info(f"Extracted baulinie from geometry with {len(baulinie.punkte)} points")
|
||||
else:
|
||||
logger.warning("Failed to extract baulinie from geometry")
|
||||
else:
|
||||
logger.warning("No geometry found in parzelle_data")
|
||||
|
||||
# Build Parzelle data
|
||||
parzelle_create_data = {
|
||||
"mandateId": currentUser.mandateId,
|
||||
"label": parcel_label, # Use the label we determined earlier for uniqueness check
|
||||
"parzellenAliasTags": alias_tags,
|
||||
"eigentuemerschaft": None,
|
||||
"strasseNr": strasse_nr,
|
||||
"plz": plz,
|
||||
"perimeter": perimeter,
|
||||
"baulinie": baulinie,
|
||||
"kontextGemeinde": gemeinde_id,
|
||||
"bauzone": None,
|
||||
"az": None,
|
||||
"bz": None,
|
||||
"vollgeschossZahl": None,
|
||||
"anrechenbarDachgeschoss": None,
|
||||
"anrechenbarUntergeschoss": None,
|
||||
"gebaeudehoeheMax": None,
|
||||
"regelnGrenzabstand": [],
|
||||
"regelnMehrlaengenzuschlag": [],
|
||||
"regelnMehrhoehenzuschlag": [],
|
||||
"parzelleBebaut": None,
|
||||
"parzelleErschlossen": None,
|
||||
"parzelleHanglage": None,
|
||||
"laermschutzzone": None,
|
||||
"hochwasserschutzzone": None,
|
||||
"grundwasserschutzzone": None,
|
||||
"parzellenNachbarschaft": adjacent_parcel_refs,
|
||||
"dokumente": [],
|
||||
"kontextInformationen": kontext_items,
|
||||
}
|
||||
|
||||
# Create Parzelle instance
|
||||
logger.debug(f"Creating Parzelle with label: {parzelle_create_data.get('label')}")
|
||||
logger.debug(f"Parzelle mandateId: {parzelle_create_data.get('mandateId')}")
|
||||
logger.debug(f"Parzelle perimeter present: {parzelle_create_data.get('perimeter') is not None}")
|
||||
|
||||
try:
|
||||
parzelle_instance = Parzelle(**parzelle_create_data)
|
||||
logger.debug(f"Parzelle instance created successfully with ID: {parzelle_instance.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Parzelle instance: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Create Parzelle in database
|
||||
try:
|
||||
logger.info(f"Calling createParzelle for Parzelle '{parzelle_instance.label}' (ID: {parzelle_instance.id})")
|
||||
logger.debug(f"Parzelle instance before createParzelle: {parzelle_instance.model_dump(mode='json', exclude={'perimeter', 'baulinie', 'kontextInformationen'})}")
|
||||
|
||||
# Use model_dump with mode='json' to ensure nested Pydantic models are serialized
|
||||
parzelle_dict = parzelle_instance.model_dump(mode='json')
|
||||
logger.debug(f"Parzelle dict keys: {list(parzelle_dict.keys())}")
|
||||
|
||||
# Create Parzelle using the interface, which will handle serialization
|
||||
created_parzelle = realEstateInterface.createParzelle(parzelle_instance)
|
||||
|
||||
logger.info(f"createParzelle returned: ID={created_parzelle.id if created_parzelle else 'None'}, Label={created_parzelle.label if created_parzelle else 'None'}")
|
||||
|
||||
# Verify Parzelle was created successfully
|
||||
if not created_parzelle:
|
||||
raise ValueError("Failed to create Parzelle - createParzelle returned None")
|
||||
|
||||
if not created_parzelle.id:
|
||||
raise ValueError("Failed to create Parzelle - no ID returned")
|
||||
|
||||
logger.info(f"Parzelle created with ID: {created_parzelle.id}")
|
||||
|
||||
# Verify Parzelle exists in database by fetching it
|
||||
logger.debug(f"Verifying Parzelle {created_parzelle.id} exists in database...")
|
||||
verify_parzelle = realEstateInterface.getParzelle(created_parzelle.id)
|
||||
if not verify_parzelle:
|
||||
logger.error(f"Parzelle {created_parzelle.id} was not found in database after creation")
|
||||
# Try to get all Parzellen to see what's in the database
|
||||
all_parzellen = realEstateInterface.getParzellen(recordFilter=None)
|
||||
logger.error(f"Total Parzellen in database: {len(all_parzellen)}")
|
||||
if all_parzellen:
|
||||
logger.error(f"Sample Parzelle IDs: {[p.id for p in all_parzellen[:5]]}")
|
||||
raise ValueError(f"Parzelle {created_parzelle.id} was not found in database after creation")
|
||||
|
||||
logger.info(f"Verified Parzelle {created_parzelle.id} exists in database")
|
||||
# Use the verified Parzelle from database to ensure it has all fields
|
||||
created_parzelle = verify_parzelle
|
||||
|
||||
# Collect perimeter for baulinie calculation
|
||||
if created_parzelle.perimeter:
|
||||
parcel_perimeters.append(created_parzelle.perimeter)
|
||||
|
||||
# Add to list of created parcels
|
||||
created_parzellen.append(created_parzelle)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Parzelle {idx + 1}: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
if not created_parzellen:
|
||||
raise ValueError("No Parzellen were successfully created")
|
||||
|
||||
logger.info(f"Successfully created {len(created_parzellen)} Parzelle(n)")
|
||||
|
||||
# Calculate combined baulinie from all parcel perimeters
|
||||
project_baulinie = None
|
||||
if len(parcel_perimeters) > 0:
|
||||
try:
|
||||
if len(parcel_perimeters) == 1:
|
||||
# Single parcel - use its perimeter as baulinie
|
||||
project_baulinie = parcel_perimeters[0]
|
||||
logger.info("Using single parcel perimeter as baulinie")
|
||||
else:
|
||||
# Multiple parcels - combine geometries to create outer outline
|
||||
logger.info(f"Combining {len(parcel_perimeters)} parcel geometries to create baulinie")
|
||||
project_baulinie = combine_parcel_geometries(parcel_perimeters)
|
||||
logger.info(f"Created combined baulinie with {len(project_baulinie.punkte)} points")
|
||||
except Exception as e:
|
||||
logger.error(f"Error combining parcel geometries for baulinie: {e}", exc_info=True)
|
||||
# Fallback: use first parcel's perimeter
|
||||
if parcel_perimeters:
|
||||
project_baulinie = parcel_perimeters[0]
|
||||
logger.warning("Using first parcel perimeter as fallback baulinie")
|
||||
|
||||
# Convert status_prozess to enum
|
||||
status_prozess_enum = None
|
||||
if status_prozess:
|
||||
try:
|
||||
# Try to convert string to enum
|
||||
if isinstance(status_prozess, str):
|
||||
status_prozess_enum = StatusProzess(status_prozess)
|
||||
elif isinstance(status_prozess, StatusProzess):
|
||||
status_prozess_enum = status_prozess
|
||||
except (ValueError, KeyError):
|
||||
logger.warning(f"Invalid statusProzess '{status_prozess}', using default 'Eingang'")
|
||||
status_prozess_enum = StatusProzess.EINGANG
|
||||
else:
|
||||
status_prozess_enum = StatusProzess.EINGANG
|
||||
|
||||
# Create Projekt with combined baulinie
|
||||
# Use the verified Parzelle instance (from database) to ensure it has all fields properly set
|
||||
logger.debug(f"Preparing Projekt creation with baulinie: {project_baulinie is not None}")
|
||||
if project_baulinie:
|
||||
logger.debug(f"Baulinie has {len(project_baulinie.punkte)} points")
|
||||
|
||||
# Use first parcel's perimeter for project perimeter (or combine if needed)
|
||||
project_perimeter = created_parzellen[0].perimeter if created_parzellen else None
|
||||
|
||||
projekt_create_data = {
|
||||
"mandateId": currentUser.mandateId,
|
||||
"label": projekt_label,
|
||||
"statusProzess": status_prozess_enum,
|
||||
"perimeter": project_perimeter, # Use first parcel perimeter as project perimeter
|
||||
"baulinie": project_baulinie, # Set baulinie from first parcel geometry
|
||||
"parzellen": created_parzellen, # Link all created Parzelle instances
|
||||
"dokumente": [],
|
||||
"kontextInformationen": [],
|
||||
}
|
||||
|
||||
logger.debug(f"Projekt data prepared: label={projekt_label}, parzellen_count={len(projekt_create_data['parzellen'])}, baulinie={'present' if project_baulinie else 'None'}")
|
||||
|
||||
try:
|
||||
projekt_instance = Projekt(**projekt_create_data)
|
||||
logger.debug(f"Projekt instance created successfully with ID: {projekt_instance.id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating Projekt instance: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Log before creation for debugging
|
||||
logger.debug(f"Creating Projekt with {len(projekt_instance.parzellen)} Parzelle(n)")
|
||||
if projekt_instance.parzellen:
|
||||
for idx, p in enumerate(projekt_instance.parzellen):
|
||||
logger.debug(f" Parzelle {idx}: ID={p.id}, Label={p.label}")
|
||||
|
||||
logger.debug(f"Projekt baulinie before save: {projekt_instance.baulinie is not None}")
|
||||
if projekt_instance.baulinie:
|
||||
logger.debug(f"Projekt baulinie has {len(projekt_instance.baulinie.punkte)} points")
|
||||
|
||||
try:
|
||||
created_projekt = realEstateInterface.createProjekt(projekt_instance)
|
||||
logger.info(f"Created Projekt '{created_projekt.label}' (ID: {created_projekt.id})")
|
||||
logger.debug(f"Created Projekt baulinie: {created_projekt.baulinie is not None}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling createProjekt: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
# Verify Projekt was created
|
||||
if not created_projekt or not created_projekt.id:
|
||||
raise ValueError("Failed to create Projekt - no ID returned")
|
||||
|
||||
# Verify Parzelle is linked in the created Projekt
|
||||
if not created_projekt.parzellen or len(created_projekt.parzellen) == 0:
|
||||
logger.warning(f"Projekt {created_projekt.id} created but no Parzellen linked")
|
||||
# Try to fetch the Projekt from database to see if Parzellen are there
|
||||
verify_projekt = realEstateInterface.getProjekt(created_projekt.id)
|
||||
if verify_projekt and verify_projekt.parzellen:
|
||||
logger.info(f"Parzellen found when fetching Projekt from database: {len(verify_projekt.parzellen)}")
|
||||
created_projekt = verify_projekt
|
||||
else:
|
||||
raise ValueError(f"Projekt {created_projekt.id} has no Parzellen linked after creation")
|
||||
else:
|
||||
logger.info(f"Projekt {created_projekt.id} successfully linked to {len(created_projekt.parzellen)} Parzelle(n)")
|
||||
# Log Parzelle details
|
||||
for idx, p in enumerate(created_projekt.parzellen):
|
||||
logger.debug(f" Linked Parzelle {idx}: ID={p.id if hasattr(p, 'id') else 'NO ID'}, Label={p.label if hasattr(p, 'label') else 'NO LABEL'}")
|
||||
|
||||
return {
|
||||
"projekt": created_projekt.model_dump(),
|
||||
"parzellen": [p.model_dump() for p in created_parzellen],
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating project with parcel data: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue