fix:parzellen-fetching und geolinien

This commit is contained in:
Ida Dittrich 2025-12-15 09:22:42 +01:00
parent ce434dd74d
commit c56da4f51c
5 changed files with 1150 additions and 251 deletions

File diff suppressed because it is too large Load diff

View file

@ -12,13 +12,77 @@ from modules.datamodels.datamodelRealEstate import (
Projekt, Projekt,
Parzelle, Parzelle,
StatusProzess, StatusProzess,
GeoPolylinie,
) )
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
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ===== Swisstopo Integration =====
async def fetch_parcel_polygon_from_swisstopo(
gemeinde: str,
parzellen_nr: str,
sr: int = 2056
) -> Optional[Dict[str, Any]]:
"""
Holt die vollständige Polygon-Geometrie einer Parzelle von Swisstopo API.
Args:
gemeinde: Name der Gemeinde (z.B. "Bern")
parzellen_nr: Parzellennummer (z.B. "1234")
sr: Koordinatensystem (2056=LV95, 4326=WGS84)
Returns:
Dictionary mit GeoPolylinie-Format für perimeter-Feld, oder None wenn nicht gefunden
Format: {"closed": True, "punkte": [{"koordinatensystem": "LV95", "x": ..., "y": ..., "z": None}, ...]}
"""
try:
connector = SwissTopoMapServerConnector()
# Get GeoJSON feature from Swisstopo
feature = await connector.get_parcel_polygon(gemeinde, parzellen_nr, sr)
if not feature:
logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden in Swisstopo")
return None
# Convert GeoJSON to GeoPolylinie format
geometry = feature.get("geometry", {})
if geometry.get("type") == "Polygon":
coordinates = geometry.get("coordinates", [])
if coordinates and len(coordinates) > 0:
ring = coordinates[0] # Outer ring
punkte = []
for coord in ring:
if len(coord) >= 2:
punkt = {
"koordinatensystem": "LV95" if sr == 2056 else "WGS84",
"x": coord[0], # GeoJSON: [x, y] = [easting, northing]
"y": coord[1],
"z": coord[2] if len(coord) > 2 else None
}
punkte.append(punkt)
logger.info(f"Successfully fetched polygon with {len(punkte)} points for {gemeinde} {parzellen_nr}")
return {
"closed": True,
"punkte": punkte
}
logger.warning(f"Unexpected geometry type in Swisstopo response: {geometry.get('type')}")
return None
except Exception as e:
logger.error(f"Error fetching parcel polygon from Swisstopo: {e}", exc_info=True)
return None
# ===== Direkte Query-Ausführung (stateless) ===== # ===== Direkte Query-Ausführung (stateless) =====
async def executeDirectQuery( async def executeDirectQuery(
@ -600,6 +664,35 @@ async def executeIntentBasedOperation(
# Handle complex objects # Handle complex objects
if "perimeter" in parameters and parameters["perimeter"]: if "perimeter" in parameters and parameters["perimeter"]:
parzelle_data["perimeter"] = GeoPolylinie(**parameters["perimeter"]) parzelle_data["perimeter"] = GeoPolylinie(**parameters["perimeter"])
elif "kontextGemeinde" in parameters and parameters.get("kontextGemeinde"):
# Try to fetch polygon from Swisstopo if gemeinde and parzellen_nr are available
gemeinde = parameters.get("kontextGemeinde")
parzellen_nr = parameters.get("label") or parameters.get("parzellen_nr") or parameters.get("parzellennummer")
if gemeinde and parzellen_nr:
logger.info(f"Attempting to fetch polygon from Swisstopo for {gemeinde} {parzellen_nr}")
try:
# Try to resolve gemeinde name if it's an ID
gemeinde_name = gemeinde
if len(gemeinde) == 36: # UUID format
# Try to get gemeinde name from interface (realEstateInterface already initialized above)
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde)
if gemeinde_obj:
gemeinde_name = gemeinde_obj.label
polygon_data = await fetch_parcel_polygon_from_swisstopo(
gemeinde=gemeinde_name,
parzellen_nr=str(parzellen_nr),
sr=2056
)
if polygon_data:
parzelle_data["perimeter"] = GeoPolylinie(**polygon_data)
logger.info(f"Successfully fetched and set perimeter from Swisstopo")
else:
logger.warning(f"Could not fetch polygon from Swisstopo for {gemeinde_name} {parzellen_nr}")
except Exception as e:
logger.warning(f"Error fetching polygon from Swisstopo (continuing without): {e}")
if "baulinie" in parameters and parameters["baulinie"]: if "baulinie" in parameters and parameters["baulinie"]:
parzelle_data["baulinie"] = GeoPolylinie(**parameters["baulinie"]) parzelle_data["baulinie"] = GeoPolylinie(**parameters["baulinie"])

View file

@ -5,7 +5,7 @@ Handles user access management and permission checks.
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User, UserPrivilege from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,7 +21,7 @@ class RealEstateAccess:
self.currentUser = currentUser self.currentUser = currentUser
self.mandateId = currentUser.mandateId self.mandateId = currentUser.mandateId
self.userId = currentUser.id self.userId = currentUser.id
self.privilege = currentUser.privilege self.roleLabels = currentUser.roleLabels or []
if not self.mandateId or not self.userId: if not self.mandateId or not self.userId:
raise ValueError("Invalid user context: mandateId and userId are required") raise ValueError("Invalid user context: mandateId and userId are required")
@ -42,10 +42,10 @@ class RealEstateAccess:
filtered_records = [] filtered_records = []
# System admins see all records # System admins see all records
if self.privilege == UserPrivilege.SYSADMIN: if "sysadmin" in self.roleLabels:
filtered_records = recordset filtered_records = recordset
# Admins see records in their mandate # Admins see records in their mandate
elif self.privilege == UserPrivilege.ADMIN: elif "admin" in self.roleLabels:
filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId] filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId]
# Regular users see only their records # Regular users see only their records
else: else:

View file

@ -17,7 +17,7 @@ import math
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token from modules.datamodels.datamodelSecurity import Token
from modules.security.auth import getCurrentUser, limiter from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbAppObjects import getInterface from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp

View file

@ -10,7 +10,7 @@ from typing import Optional, Dict, Any, List, Union
from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status
# Import auth modules # Import auth modules
from modules.security.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
# Import models # Import models
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
@ -653,70 +653,45 @@ async def search_parcel(
full_address = None full_address = None
plz = None plz = None
# Try to get address by querying the address layer at the parcel centroid # First, try to use geocoded address info if available (more accurate than centroid query)
if centroid: geocoded_address = parcel_data.get('geocoded_address')
try: if geocoded_address:
import aiohttp full_address = geocoded_address.get('full_address')
plz = geocoded_address.get('plz')
municipality_name = geocoded_address.get('municipality')
logger.debug(f"Using geocoded address: {full_address}")
# Use MapServer identify on address layer to get actual address # If geocoded address not available, try to get address by querying the address layer
identify_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" # Use query coordinates (where user clicked/geocoded) instead of parcel centroid
# This ensures we get the address at the exact location, not at the parcel center
query_coords = parcel_data.get('query_coordinates')
address_query_coords = query_coords if query_coords else centroid
# Calculate extent around centroid if not full_address and address_query_coords:
buffer = 100 # 100m buffer query_x = address_query_coords['x']
map_extent = f"{centroid['x'] - buffer},{centroid['y'] - buffer},{centroid['x'] + buffer},{centroid['y'] + buffer}" query_y = address_query_coords['y']
logger.debug(f"Querying address layer at query coordinates: ({query_x}, {query_y})")
params = { # Check if this was a coordinate search (not geocoded address)
"geometry": f"{centroid['x']},{centroid['y']}", is_coordinate_search = ',' in location and not any(c.isalpha() for c in location.split(',')[0])
"geometryType": "esriGeometryPoint",
"sr": "2056",
"layers": "all:ch.bfs.gebaeude_wohnungs_register", # Building/address layer
"tolerance": 50,
"mapExtent": map_extent,
"imageDisplay": "800,600,96",
"returnGeometry": "false",
"f": "json"
}
logger.debug(f"Querying address layer at centroid: ({centroid['x']}, {centroid['y']})") # Use connector's helper method to query building layer
# Use tolerance=1 (minimum) for coordinate searches to get exact building
building_tolerance = 1 if is_coordinate_search else 10
building_result = await connector._query_building_layer(query_x, query_y, tolerance=building_tolerance, buffer=25)
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session: if building_result:
async with session.get(identify_url, params=params) as response: addr_attrs = building_result.get("attributes", {})
if response.status == 200:
address_data = await response.json()
address_results = address_data.get("results", [])
if address_results:
# Get first address result
addr_attrs = address_results[0].get("attributes", {})
logger.debug(f"Address layer attributes: {addr_attrs}") logger.debug(f"Address layer attributes: {addr_attrs}")
# Extract address components (note: strname is a list!) # Extract address using connector's helper method
street_list = addr_attrs.get("strname", []) address_info = connector._extract_address_from_building_attrs(addr_attrs)
street = street_list[0] if isinstance(street_list, list) and street_list else None full_address = address_info.get('full_address')
house_number = addr_attrs.get("deinr") plz = address_info.get('plz')
plz = addr_attrs.get("dplz4") municipality_name = address_info.get('municipality')
municipality_name = addr_attrs.get("dplzname") or addr_attrs.get("ggdename")
# Clean municipality name (remove canton suffix like "Wald ZH" -> "Wald") if full_address:
if municipality_name and " " in municipality_name:
# Format is often "City ZH" or "City (ZH)"
municipality_name = municipality_name.split("(")[0].strip()
# Remove canton code at the end if present
parts = municipality_name.split()
if len(parts) > 1 and len(parts[-1]) == 2 and parts[-1].isupper():
municipality_name = " ".join(parts[:-1])
# Construct full address
if street and house_number and plz and municipality_name:
full_address = f"{street} {house_number}, {plz} {municipality_name}"
logger.debug(f"Constructed address: {full_address}") logger.debug(f"Constructed address: {full_address}")
else:
logger.debug("No address results from building layer")
else:
logger.debug(f"Address identify returned status {response.status}")
except Exception as e:
logger.debug(f"Could not query address layer: {e}")
# If address not found via building layer, try to construct from available data # If address not found via building layer, try to construct from available data
if not full_address: if not full_address:
@ -813,46 +788,90 @@ async def search_parcel(
} }
# Fetch adjacent parcels if requested # Fetch adjacent parcels if requested
if include_adjacent and centroid: if include_adjacent and parcel_data and parcel_data.get("geometry"):
try: try:
# Search in a radius around the parcel centroid # Use the connector's method to find neighboring parcels by sampling along the boundary
# Note: This is a simplified approach - may need refinement # This ensures we find all parcels that actually touch the selected parcel
adjacent_parcels = [] selected_parcel_id = parcel_info["id"]
adjacent_parcels_raw = await connector.find_neighboring_parcels(
parcel_data=parcel_data,
selected_parcel_id=selected_parcel_id,
sample_distance=20.0, # Sample every 20 meters (balanced for coverage and speed)
max_sample_points=30, # Allow up to 30 points to ensure all vertices are covered
max_neighbors=15, # Find up to 15 neighbors
max_concurrent=50 # Process up to 50 queries concurrently (maximum parallelization)
)
# Search in 4 directions from centroid (N, S, E, W) # Convert adjacent parcels to include GeoJSON geometry (optimized, minimal logging)
search_distance = 50 # meters def convert_parcel_geometry(adj_parcel: Dict[str, Any]) -> Dict[str, Any]:
search_coords = [ """Convert a single adjacent parcel to include GeoJSON geometry."""
(centroid["x"], centroid["y"] + search_distance), # North adj_parcel_with_geo = {
(centroid["x"], centroid["y"] - search_distance), # South "id": adj_parcel["id"],
(centroid["x"] + search_distance, centroid["y"]), # East "egrid": adj_parcel.get("egrid"),
(centroid["x"] - search_distance, centroid["y"]), # West "number": adj_parcel.get("number"),
] "perimeter": adj_parcel.get("perimeter")
}
for coord in search_coords: # Convert geometry to GeoJSON format if available
try: adj_geometry = adj_parcel.get("geometry")
adj_data = await connector.get_parcel_info(coord[0], coord[1], tolerance=5) adj_perimeter = adj_parcel.get("perimeter")
if adj_data:
adj_attrs = adj_data.get("attributes", {})
adj_id = adj_attrs.get("label") or adj_attrs.get("number")
# Don't include the same parcel if adj_geometry:
if adj_id != parcel_info["id"]: # Handle ESRI format (rings)
# Check if already in list if "rings" in adj_geometry and adj_geometry["rings"]:
if not any(p["id"] == adj_id for p in adjacent_parcels): ring = adj_geometry["rings"][0] # Outer ring
adjacent_parcels.append({ coordinates = [[[p[0], p[1]] for p in ring]]
"id": adj_id, adj_parcel_with_geo["geometry_geojson"] = {
"egrid": adj_attrs.get("egris_egrid"), "type": "Feature",
"number": adj_attrs.get("number") "geometry": {
}) "type": "Polygon",
except Exception as e: "coordinates": coordinates
logger.debug(f"No adjacent parcel found at {coord}: {e}") },
continue "properties": {
"id": adj_parcel["id"],
"egrid": adj_parcel.get("egrid"),
"number": adj_parcel.get("number")
}
}
# Handle GeoJSON format
elif adj_geometry.get("type") == "Polygon":
adj_parcel_with_geo["geometry_geojson"] = {
"type": "Feature",
"geometry": adj_geometry,
"properties": {
"id": adj_parcel["id"],
"egrid": adj_parcel.get("egrid"),
"number": adj_parcel.get("number")
}
}
# If no geometry_geojson was created but we have perimeter, create it from perimeter
if "geometry_geojson" not in adj_parcel_with_geo and adj_perimeter and adj_perimeter.get("punkte"):
punkte = adj_perimeter["punkte"]
coordinates = [[[p["x"], p["y"]] for p in punkte]]
adj_parcel_with_geo["geometry_geojson"] = {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": coordinates
},
"properties": {
"id": adj_parcel["id"],
"egrid": adj_parcel.get("egrid"),
"number": adj_parcel.get("number")
}
}
return adj_parcel_with_geo
# Convert all parcels in parallel (using list comprehension for speed)
adjacent_parcels = [convert_parcel_geometry(adj_parcel) for adj_parcel in adjacent_parcels_raw]
response_data["adjacent_parcels"] = adjacent_parcels response_data["adjacent_parcels"] = adjacent_parcels
logger.info(f"Found {len(adjacent_parcels)} adjacent parcels") logger.info(f"Found {len(adjacent_parcels)} neighboring parcels for parcel {selected_parcel_id}")
except Exception as e: except Exception as e:
logger.warning(f"Error fetching adjacent parcels: {e}") logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True)
response_data["adjacent_parcels"] = [] response_data["adjacent_parcels"] = []
return response_data return response_data