fix:parzellen-fetching und geolinien
This commit is contained in:
parent
ce434dd74d
commit
c56da4f51c
5 changed files with 1150 additions and 251 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue