fixed complete langgraph workflow and information fetching
This commit is contained in:
parent
3ff3cfd51c
commit
69aa73ed73
15 changed files with 2592 additions and 653 deletions
|
|
@ -75,3 +75,7 @@ PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switz
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
||||||
|
|
||||||
|
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
|
||||||
|
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
|
||||||
|
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ MapServer identify endpoint for parcel data retrieval.
|
||||||
Endpoint: https://api3.geo.admin.ch/rest/services/api/MapServer/identify
|
Endpoint: https://api3.geo.admin.ch/rest/services/api/MapServer/identify
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
|
@ -29,7 +30,9 @@ class SwissTopoMapServerConnector:
|
||||||
|
|
||||||
# API endpoints
|
# API endpoints
|
||||||
MAPSERVER_IDENTIFY_URL = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
|
MAPSERVER_IDENTIFY_URL = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
|
||||||
|
MAPSERVER_FIND_URL = "https://api3.geo.admin.ch/rest/services/ech/MapServer/find"
|
||||||
GEOCODING_URL = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
|
GEOCODING_URL = "https://api3.geo.admin.ch/rest/services/api/SearchServer"
|
||||||
|
LAYER_GEMEINDE = "ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill"
|
||||||
|
|
||||||
# Swiss official survey layer
|
# Swiss official survey layer
|
||||||
LAYER_AMTLICHE_VERMESSUNG = "all:ch.swisstopo-vd.amtliche-vermessung"
|
LAYER_AMTLICHE_VERMESSUNG = "all:ch.swisstopo-vd.amtliche-vermessung"
|
||||||
|
|
@ -46,7 +49,8 @@ class SwissTopoMapServerConnector:
|
||||||
self,
|
self,
|
||||||
timeout: int = 30,
|
timeout: int = 30,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
retry_delay: float = 1.0
|
retry_delay: float = 1.0,
|
||||||
|
oereb_connector: Optional[Any] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize MapServer connector.
|
Initialize MapServer connector.
|
||||||
|
|
@ -55,10 +59,12 @@ class SwissTopoMapServerConnector:
|
||||||
timeout: Request timeout in seconds
|
timeout: Request timeout in seconds
|
||||||
max_retries: Maximum number of retry attempts
|
max_retries: Maximum number of retry attempts
|
||||||
retry_delay: Initial retry delay in seconds (exponential backoff)
|
retry_delay: Initial retry delay in seconds (exponential backoff)
|
||||||
|
oereb_connector: Optional OerebWfsConnector for zone queries (used by scraping)
|
||||||
"""
|
"""
|
||||||
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
self.timeout = aiohttp.ClientTimeout(total=timeout)
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
self.retry_delay = retry_delay
|
self.retry_delay = retry_delay
|
||||||
|
self.oereb_connector = oereb_connector
|
||||||
|
|
||||||
logger.info("Swiss Topo MapServer Connector initialized")
|
logger.info("Swiss Topo MapServer Connector initialized")
|
||||||
|
|
||||||
|
|
@ -128,6 +134,136 @@ class SwissTopoMapServerConnector:
|
||||||
|
|
||||||
return municipality
|
return municipality
|
||||||
|
|
||||||
|
async def get_gemeinde_by_name(self, gemeinde_name: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetch a single Gemeinde from Swiss Topo by name (e.g. "Zürich").
|
||||||
|
Uses Find API with exact/contains search. Returns the best match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: name, bfs_nummer, kanton, or None if not found
|
||||||
|
"""
|
||||||
|
if not gemeinde_name or not gemeinde_name.strip():
|
||||||
|
return None
|
||||||
|
search_text = gemeinde_name.strip()
|
||||||
|
# Try exact match first (Zürich)
|
||||||
|
for q in [search_text, search_text.split("(")[0].strip()]:
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"layer": self.LAYER_GEMEINDE,
|
||||||
|
"searchText": q,
|
||||||
|
"searchField": "gemname",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
"contains": "true",
|
||||||
|
}
|
||||||
|
data = await self._make_request(self.MAPSERVER_FIND_URL, params)
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
continue
|
||||||
|
# Pick best match: exact name first, then by highest jahr
|
||||||
|
target_lower = (gemeinde_name or "").strip().lower()
|
||||||
|
best = None
|
||||||
|
best_score = -1
|
||||||
|
for feat in results:
|
||||||
|
attrs = feat.get("attributes", {})
|
||||||
|
gemname = attrs.get("gemname") or attrs.get("label", "")
|
||||||
|
cleaned = self._clean_municipality_name(gemname)
|
||||||
|
gde_nr = attrs.get("gde_nr")
|
||||||
|
kanton = attrs.get("kanton")
|
||||||
|
jahr = attrs.get("jahr", 0)
|
||||||
|
objektart = attrs.get("objektart", attrs.get("objval"))
|
||||||
|
if objektart is not None and int(objektart) != 11:
|
||||||
|
continue
|
||||||
|
if not gde_nr or not kanton:
|
||||||
|
continue
|
||||||
|
# Score: exact match = 100, partial = 50, else by jahr
|
||||||
|
cleaned_lower = cleaned.strip().lower()
|
||||||
|
if cleaned_lower == target_lower:
|
||||||
|
score = 1000 + jahr
|
||||||
|
elif target_lower in cleaned_lower or cleaned_lower in target_lower:
|
||||||
|
score = 500 + jahr
|
||||||
|
else:
|
||||||
|
score = jahr
|
||||||
|
if score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best = {"name": cleaned, "bfs_nummer": int(gde_nr), "kanton": str(kanton)}
|
||||||
|
if best:
|
||||||
|
logger.info(f"Found Gemeinde '{best['name']}' (BFS {best['bfs_nummer']}) for search '{gemeinde_name}'")
|
||||||
|
return best
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error fetching Gemeinde '{q}': {e}")
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_all_gemeinden(self, only_current: bool = True) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetch all Swiss municipalities (Gemeinden) from the Swiss Topo MapServer.
|
||||||
|
|
||||||
|
Uses the Find API to query the municipality layer. Iterates with search
|
||||||
|
strings to collect all municipalities, then deduplicates by BFS number.
|
||||||
|
|
||||||
|
Note: layerDefs is not used - the MapServer Find API reports that
|
||||||
|
is_current_jahr/objektart are not queryable. Filtering is done client-side.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
only_current: If True, keep only the latest jahr per BFS number (current municipalities).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with keys: name, bfs_nummer, kanton
|
||||||
|
"""
|
||||||
|
# Search strings to achieve broad coverage (Swiss municipality names)
|
||||||
|
search_chars = [
|
||||||
|
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
||||||
|
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
||||||
|
"ä", "ö", "ü", "é", "è", "à", "â", "î", "ô", "ç", "-", " ", "'",
|
||||||
|
]
|
||||||
|
|
||||||
|
seen: Dict[Tuple[int, str], Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for char in search_chars:
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"layer": self.LAYER_GEMEINDE,
|
||||||
|
"searchText": char,
|
||||||
|
"searchField": "gemname",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
"contains": "true",
|
||||||
|
}
|
||||||
|
# Do not use layerDefs - is_current_jahr/objektart are not queryable on this layer
|
||||||
|
data = await self._make_request(self.MAPSERVER_FIND_URL, params)
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
for feat in results:
|
||||||
|
attrs = feat.get("attributes", {})
|
||||||
|
gde_nr = attrs.get("gde_nr")
|
||||||
|
kanton = attrs.get("kanton")
|
||||||
|
gemname = attrs.get("gemname") or attrs.get("label", "")
|
||||||
|
jahr = attrs.get("jahr", 0)
|
||||||
|
objektart = attrs.get("objektart", attrs.get("objval"))
|
||||||
|
# Only include politische Gemeinden (objektart 11) when attribute present
|
||||||
|
if objektart is not None and only_current and int(objektart) != 11:
|
||||||
|
continue
|
||||||
|
if not gde_nr or not kanton:
|
||||||
|
continue
|
||||||
|
key = (int(gde_nr), str(kanton))
|
||||||
|
if key not in seen or jahr > (seen[key].get("_jahr") or 0):
|
||||||
|
cleaned_name = self._clean_municipality_name(gemname)
|
||||||
|
seen[key] = {
|
||||||
|
"name": cleaned_name,
|
||||||
|
"bfs_nummer": int(gde_nr),
|
||||||
|
"kanton": str(kanton),
|
||||||
|
"_jahr": jahr,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error fetching gemeinden for search '{char}': {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = [
|
||||||
|
{"name": v["name"], "bfs_nummer": v["bfs_nummer"], "kanton": v["kanton"]}
|
||||||
|
for v in seen.values()
|
||||||
|
]
|
||||||
|
logger.info(f"Fetched {len(result)} unique Gemeinden from Swiss Topo (only_current={only_current})")
|
||||||
|
return result
|
||||||
|
|
||||||
def _extract_address_from_building_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
def _extract_address_from_building_attrs(self, attrs: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Extract address components from building layer attributes.
|
Extract address components from building layer attributes.
|
||||||
|
|
|
||||||
89
modules/connectors/connectorZhWfsParcels.py
Normal file
89
modules/connectors/connectorZhWfsParcels.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""
|
||||||
|
Swiss Parcel (Liegenschaften) Connector
|
||||||
|
|
||||||
|
Fetches parcel data from geodienste.ch OGC API Features (Amtliche Vermessung).
|
||||||
|
Covers all of Switzerland. Returns GeoJSON in WGS84.
|
||||||
|
|
||||||
|
Uses: geodienste.ch OGC API - RESF collection (Liegenschaften)
|
||||||
|
No config override needed - this is the single working solution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# geodienste.ch OGC API - RESF = Liegenschaften (parcels), all Switzerland
|
||||||
|
# API returns WGS84 directly when bbox-crs=EPSG:2056 is used
|
||||||
|
_OGC_API_BASE = "https://www.geodienste.ch/db/av_0/deu/ogcapi/collections/RESF/items"
|
||||||
|
_MAX_ITEMS = 2000
|
||||||
|
_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
class ZhWfsParcelsConnector:
|
||||||
|
"""
|
||||||
|
Connector for Swiss parcel (Liegenschaften) data via geodienste.ch OGC API.
|
||||||
|
Returns GeoJSON FeatureCollection in WGS84.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, timeout: int = _TIMEOUT):
|
||||||
|
self.timeout = timeout
|
||||||
|
logger.info("ZhWfsParcelsConnector initialized (geodienste.ch OGC API)")
|
||||||
|
|
||||||
|
def get_parcels_by_bbox(self, bbox: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetch parcels within bounding box.
|
||||||
|
Returns GeoJSON FeatureCollection in WGS84 (EPSG:4326).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bbox: Bounding box as "minx,miny,maxx,maxy" in LV95 (EPSG:2056)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GeoJSON FeatureCollection with geometries in WGS84
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parts = [p.strip() for p in bbox.split(",")]
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError(f"Invalid bbox: expected minx,miny,maxx,maxy, got {bbox}")
|
||||||
|
minx, miny, maxx, maxy = (float(p) for p in parts)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"f": "json",
|
||||||
|
"limit": _MAX_ITEMS,
|
||||||
|
"bbox": f"{minx},{miny},{maxx},{maxy}",
|
||||||
|
"bbox-crs": "http://www.opengis.net/def/crs/EPSG/0/2056",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(f"Requesting parcels: bbox={bbox}")
|
||||||
|
resp = requests.get(_OGC_API_BASE, params=params, timeout=self.timeout)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.error(f"Parcel API failed: status={resp.status_code}, body={resp.text[:500]}")
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# OGC API returns FeatureCollection in WGS84 directly
|
||||||
|
features = data.get("features", [])
|
||||||
|
if not features:
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
|
||||||
|
# Pass through - geodienste returns WGS84 GeoJSON
|
||||||
|
result = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": features,
|
||||||
|
}
|
||||||
|
logger.info(f"Returned {len(features)} parcels in WGS84")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.warning(f"Invalid bbox: {e}")
|
||||||
|
raise
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"Parcel API request error: {e}")
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching parcels: {e}", exc_info=True)
|
||||||
|
return {"type": "FeatureCollection", "features": []}
|
||||||
|
|
@ -5,9 +5,9 @@ Queries Dokument table and retrieves PDF content from ComponentObjects.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from modules.datamodels.datamodelRealEstate import Dokument, DokumentTyp, Gemeinde
|
from .datamodelFeatureRealEstate import Dokument, DokumentTyp, Gemeinde
|
||||||
from modules.interfaces.interfaceDbRealEstateObjects import RealEstateObjects
|
from .interfaceFeatureRealEstate import RealEstateObjects
|
||||||
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -128,8 +128,8 @@ class BZODocumentRetriever:
|
||||||
logger.warning(f"Dokument {dokument.id} has no dokumentReferenz")
|
logger.warning(f"Dokument {dokument.id} has no dokumentReferenz")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Retrieve PDF bytes
|
# Retrieve PDF bytes (unrestricted - BZO documents are public, accessible to all users)
|
||||||
pdf_bytes = self.componentInterface.getFileData(dokument.dokumentReferenz)
|
pdf_bytes = self.componentInterface.getFileDataForPublicDocument(dokument.dokumentReferenz)
|
||||||
|
|
||||||
if not pdf_bytes:
|
if not pdf_bytes:
|
||||||
logger.warning(f"Could not retrieve PDF content for file {dokument.dokumentReferenz}")
|
logger.warning(f"Could not retrieve PDF content for file {dokument.dokumentReferenz}")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,6 +34,12 @@ RULE_TAXONOMY = {
|
||||||
"value_type": "numeric",
|
"value_type": "numeric",
|
||||||
"keywords": ["max", "maximal"]
|
"keywords": ["max", "maximal"]
|
||||||
},
|
},
|
||||||
|
"building_coverage": {
|
||||||
|
"patterns": ["überbauungsziffer", "überbauungsziffer max", "uz"],
|
||||||
|
"units": ["%", "prozent"],
|
||||||
|
"value_type": "numeric",
|
||||||
|
"keywords": ["max", "maximal"]
|
||||||
|
},
|
||||||
"building_mass_index": {
|
"building_mass_index": {
|
||||||
"patterns": ["baumassenziffer", "bmz"],
|
"patterns": ["baumassenziffer", "bmz"],
|
||||||
"units": [],
|
"units": [],
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,8 @@ class RealEstateObjects:
|
||||||
Dokument,
|
Dokument,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": dokumentId},
|
recordFilter={"id": dokumentId},
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -482,6 +484,8 @@ class RealEstateObjects:
|
||||||
Dokument,
|
Dokument,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter=recordFilter or {},
|
recordFilter=recordFilter or {},
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [Dokument(**r) for r in records]
|
return [Dokument(**r) for r in records]
|
||||||
|
|
@ -538,6 +542,8 @@ class RealEstateObjects:
|
||||||
Gemeinde,
|
Gemeinde,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": gemeindeId},
|
recordFilter={"id": gemeindeId},
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -553,6 +559,8 @@ class RealEstateObjects:
|
||||||
Gemeinde,
|
Gemeinde,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter=recordFilter or {},
|
recordFilter=recordFilter or {},
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [Gemeinde(**r) for r in records]
|
return [Gemeinde(**r) for r in records]
|
||||||
|
|
@ -609,6 +617,8 @@ class RealEstateObjects:
|
||||||
Kanton,
|
Kanton,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": kantonId},
|
recordFilter={"id": kantonId},
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -624,6 +634,8 @@ class RealEstateObjects:
|
||||||
Kanton,
|
Kanton,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter=recordFilter or {},
|
recordFilter=recordFilter or {},
|
||||||
|
mandateId=self.mandateId,
|
||||||
|
featureInstanceId=self.featureInstanceId,
|
||||||
featureCode=self.FEATURE_CODE
|
featureCode=self.FEATURE_CODE
|
||||||
)
|
)
|
||||||
return [Kanton(**r) for r in records]
|
return [Kanton(**r) for r in records]
|
||||||
|
|
|
||||||
|
|
@ -13,23 +13,13 @@ FEATURE_CODE = "realestate"
|
||||||
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}
|
FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}
|
||||||
FEATURE_ICON = "mdi-home-city"
|
FEATURE_ICON = "mdi-home-city"
|
||||||
|
|
||||||
# UI Objects for RBAC catalog
|
# UI Objects for RBAC catalog (only map view)
|
||||||
UI_OBJECTS = [
|
UI_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.realestate.dashboard",
|
"objectKey": "ui.feature.realestate.dashboard",
|
||||||
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
|
"label": {"en": "Map", "de": "Karte", "fr": "Carte"},
|
||||||
"meta": {"area": "dashboard"}
|
"meta": {"area": "dashboard"}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.realestate.projects",
|
|
||||||
"label": {"en": "Projects", "de": "Projekte", "fr": "Projets"},
|
|
||||||
"meta": {"area": "projects"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"objectKey": "ui.feature.realestate.parcels",
|
|
||||||
"label": {"en": "Parcels", "de": "Parzellen", "fr": "Parcelles"},
|
|
||||||
"meta": {"area": "parcels"}
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Resource Objects for RBAC catalog
|
# Resource Objects for RBAC catalog
|
||||||
|
|
@ -74,10 +64,8 @@ TEMPLATE_ROLES = [
|
||||||
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
|
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires"
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to main views - vollqualifizierte ObjectKeys
|
# UI access to map view
|
||||||
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
|
|
||||||
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
|
|
||||||
# Group-level DATA access
|
# Group-level DATA access
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
|
||||||
# Resource: create projects
|
# Resource: create projects
|
||||||
|
|
@ -92,10 +80,8 @@ TEMPLATE_ROLES = [
|
||||||
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
|
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
|
||||||
},
|
},
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
# UI access to view-only views - vollqualifizierte ObjectKeys
|
# UI access to map view (read-only)
|
||||||
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
|
||||||
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
|
|
||||||
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
|
|
||||||
# Read-only DATA access (my records)
|
# Read-only DATA access (my records)
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
||||||
]
|
]
|
||||||
|
|
@ -299,11 +285,16 @@ from .datamodelFeatureRealEstate import (
|
||||||
DokumentTyp,
|
DokumentTyp,
|
||||||
)
|
)
|
||||||
from modules.services import getInterface as getServices
|
from modules.services import getInterface as getServices
|
||||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||||
from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever
|
from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever
|
||||||
from modules.features.realEstate.bzoExtractionLangGraph import run_extraction
|
from modules.features.realEstate.bzoExtractionLangGraph import run_extraction, run_bzo_params_extraction
|
||||||
|
from modules.features.realEstate.parcelSelectionService import compute_selection_summary
|
||||||
|
from modules.features.realEstate.realEstateGemeindeService import (
|
||||||
|
ensure_single_gemeinde,
|
||||||
|
fetch_bzo_for_gemeinde,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -2342,64 +2333,73 @@ async def extract_bzo_information(
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
gemeinde: str,
|
gemeinde: str,
|
||||||
bauzone: str,
|
bauzone: str,
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None,
|
||||||
|
total_area_m2: Optional[float] = None,
|
||||||
|
parcels: Optional[List[Dict[str, Any]]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde.
|
Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde.
|
||||||
|
|
||||||
Retrieves BZO documents for the specified Gemeinde, extracts content using
|
Retrieves BZO documents for the specified Gemeinde, extracts content using
|
||||||
langgraph workflow, filters by Bauzone, and uses AI to find relevant information.
|
langgraph workflow, filters by Bauzone, and uses AI to find relevant information.
|
||||||
|
When total_area_m2 or parcels are provided, runs Machbarkeitsstudie for structured output.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
gemeinde: Gemeinde name (e.g., "Zürich") or ID
|
gemeinde: Gemeinde name (e.g., "Zürich") or ID
|
||||||
bauzone: Bauzone code (e.g., "W3", "W2/30")
|
bauzone: Bauzone code (e.g., "W3", "W2/30")
|
||||||
|
mandateId: Optional mandate ID for instance-scoped data (defaults to currentUser.mandateId)
|
||||||
|
featureInstanceId: Optional feature instance ID for instance-scoped data
|
||||||
|
total_area_m2: Optional total parcel area (m²) for Machbarkeitsstudie
|
||||||
|
parcels: Optional list of parcel dicts; total area computed via compute_selection_summary if not total_area_m2
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary containing:
|
Dictionary containing:
|
||||||
- bauzone: Bauzone code
|
- bauzone, gemeinde, extracted_content, ai_summary, relevant_rules, documents_processed
|
||||||
- gemeinde: Gemeinde information
|
- machbarkeitsstudie: Structured Machbarkeitsstudie output when total_area_m2/parcels provided
|
||||||
- extracted_content: Extracted content from PDFs
|
|
||||||
- ai_summary: AI-generated summary
|
|
||||||
- relevant_rules: Rules filtered by Bauzone
|
|
||||||
- documents_processed: List of document IDs processed
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: If Gemeinde not found or no documents found
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id})")
|
_mandateId = mandateId or (str(currentUser.mandateId) if currentUser.mandateId else None)
|
||||||
|
logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {_mandateId})")
|
||||||
|
|
||||||
# Get interfaces
|
# Get interfaces (instance-scoped when mandateId/featureInstanceId provided)
|
||||||
realEstateInterface = getRealEstateInterface(currentUser)
|
realEstateInterface = getRealEstateInterface(
|
||||||
componentInterface = getComponentInterface(currentUser)
|
currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
componentInterface = getComponentInterface(
|
||||||
|
currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
|
||||||
# Get Gemeinde - try by ID first, then by label
|
# Get Gemeinde - try by ID first, then by label
|
||||||
logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {currentUser.mandateId}")
|
logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {_mandateId}")
|
||||||
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde)
|
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde)
|
||||||
|
|
||||||
# If not found by ID, try searching by label
|
# If not found by ID, try searching by label
|
||||||
if not gemeinde_obj:
|
if not gemeinde_obj:
|
||||||
logger.debug(f"Gemeinde not found by ID, trying to search by label: {gemeinde}")
|
logger.debug(f"Gemeinde not found by ID, trying to search by label: {gemeinde}")
|
||||||
|
record_filter = {"label": gemeinde}
|
||||||
|
if _mandateId:
|
||||||
|
record_filter["mandateId"] = _mandateId
|
||||||
gemeinden_by_label = realEstateInterface.getGemeinden(
|
gemeinden_by_label = realEstateInterface.getGemeinden(
|
||||||
recordFilter={"label": gemeinde}
|
recordFilter=record_filter
|
||||||
)
|
)
|
||||||
if gemeinden_by_label and len(gemeinden_by_label) > 0:
|
if gemeinden_by_label and len(gemeinden_by_label) > 0:
|
||||||
gemeinde_obj = gemeinden_by_label[0]
|
gemeinde_obj = gemeinden_by_label[0]
|
||||||
logger.info(f"Found Gemeinde by label '{gemeinde}' with ID: {gemeinde_obj.id}")
|
logger.info(f"Found Gemeinde by label '{gemeinde}' with ID: {gemeinde_obj.id}")
|
||||||
else:
|
|
||||||
# Try to get all gemeinden to see what's available (for debugging)
|
# If still not found: fetch only this Gemeinde from Swiss Topo and create it
|
||||||
all_gemeinden = realEstateInterface.getGemeinden(recordFilter=None)
|
if not gemeinde_obj and _mandateId and featureInstanceId:
|
||||||
logger.warning(f"Gemeinde '{gemeinde}' not found by ID or label. Total Gemeinden in database: {len(all_gemeinden)}")
|
logger.info(f"Gemeinde '{gemeinde}' not in DB - fetching from Swiss Topo (this Gemeinde only)")
|
||||||
if all_gemeinden:
|
gemeinde_obj = await ensure_single_gemeinde(
|
||||||
sample_ids = [g.id for g in all_gemeinden[:5]]
|
realEstateInterface, _mandateId, featureInstanceId, gemeinde_name=gemeinde
|
||||||
sample_labels = [g.label for g in all_gemeinden[:5] if g.label]
|
)
|
||||||
logger.warning(f"Sample Gemeinde IDs: {sample_ids}")
|
|
||||||
if sample_labels:
|
if not gemeinde_obj:
|
||||||
logger.warning(f"Sample Gemeinde labels: {sample_labels}")
|
raise HTTPException(
|
||||||
raise HTTPException(
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
detail=f"Gemeinde '{gemeinde}' not found or not accessible"
|
||||||
detail=f"Gemeinde '{gemeinde}' not found or not accessible"
|
)
|
||||||
)
|
|
||||||
|
|
||||||
gemeinde_id = gemeinde_obj.id
|
gemeinde_id = gemeinde_obj.id
|
||||||
|
|
||||||
|
|
@ -2435,6 +2435,36 @@ async def extract_bzo_information(
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Document {doc_id} referenced in Gemeinde but not found in database")
|
logger.warning(f"Document {doc_id} referenced in Gemeinde but not found in database")
|
||||||
|
|
||||||
|
# If no BZO documents: auto-fetch from Tavily, then retry
|
||||||
|
if not bzo_documents and _mandateId and featureInstanceId:
|
||||||
|
logger.info(f"No BZO documents for Gemeinde '{gemeinde_obj.label}' - fetching from web")
|
||||||
|
fetched = await fetch_bzo_for_gemeinde(
|
||||||
|
realEstateInterface, componentInterface, gemeinde_obj, _mandateId, featureInstanceId
|
||||||
|
)
|
||||||
|
if fetched:
|
||||||
|
# Reload Gemeinde to get updated dokumente
|
||||||
|
gemeinde_obj = realEstateInterface.getGemeinde(gemeinde_obj.id)
|
||||||
|
bzo_documents = []
|
||||||
|
if gemeinde_obj and gemeinde_obj.dokumente:
|
||||||
|
for doc in gemeinde_obj.dokumente:
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
doc_id = doc.get("id")
|
||||||
|
doc_typ = doc.get("dokumentTyp")
|
||||||
|
else:
|
||||||
|
doc_id = doc.id if hasattr(doc, "id") else None
|
||||||
|
doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None
|
||||||
|
if doc_typ:
|
||||||
|
if isinstance(doc_typ, DokumentTyp):
|
||||||
|
is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]
|
||||||
|
elif isinstance(doc_typ, str):
|
||||||
|
is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
|
||||||
|
else:
|
||||||
|
is_bzo = str(doc_typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"]
|
||||||
|
if is_bzo and doc_id:
|
||||||
|
full_doc = realEstateInterface.getDokument(doc_id)
|
||||||
|
if full_doc:
|
||||||
|
bzo_documents.append(full_doc)
|
||||||
|
|
||||||
if not bzo_documents:
|
if not bzo_documents:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
|
@ -2498,11 +2528,13 @@ async def extract_bzo_information(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter rules by Bauzone
|
# Filter rules by Bauzone - only rules explicitly associated with this zone
|
||||||
relevant_rules = filter_rules_by_bauzone(
|
relevant_rules = filter_rules_by_bauzone(
|
||||||
all_extracted_content["rules"],
|
all_extracted_content["rules"],
|
||||||
bauzone
|
bauzone
|
||||||
)
|
)
|
||||||
|
logger.info(f"Extracting for Bauzone {bauzone}: {len(relevant_rules)} zone-specific rules, "
|
||||||
|
f"{len([t for t in all_extracted_content.get('zone_parameter_tables', []) if bauzone.upper() in str(t.get('zones', [])).upper()])} tables with zone data")
|
||||||
|
|
||||||
# Filter zones by Bauzone
|
# Filter zones by Bauzone
|
||||||
relevant_zones = filter_zones_by_bauzone(
|
relevant_zones = filter_zones_by_bauzone(
|
||||||
|
|
@ -2516,6 +2548,34 @@ async def extract_bzo_information(
|
||||||
bauzone
|
bauzone
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Compute total_area_m2 from parcels if not provided
|
||||||
|
_total_area_m2 = total_area_m2
|
||||||
|
if _total_area_m2 is None and parcels:
|
||||||
|
selection_summary = compute_selection_summary(parcels)
|
||||||
|
_total_area_m2 = selection_summary.get("total_area_m2") or 0.0
|
||||||
|
|
||||||
|
# Extract BZO parameters for Wohnzone via LangGraph + LLM (bullet list with sources)
|
||||||
|
bzo_params_result = None
|
||||||
|
try:
|
||||||
|
services = getServices(
|
||||||
|
currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
|
ai_service = services.ai
|
||||||
|
bzo_params_result = await run_bzo_params_extraction(
|
||||||
|
extracted_content=all_extracted_content,
|
||||||
|
bauzone=bauzone,
|
||||||
|
ai_service=ai_service,
|
||||||
|
gemeinde=gemeinde_obj.label,
|
||||||
|
relevant_rules=relevant_rules,
|
||||||
|
relevant_articles=relevant_articles,
|
||||||
|
total_area_m2=_total_area_m2,
|
||||||
|
)
|
||||||
|
except Exception as me:
|
||||||
|
logger.warning(f"BZO parameter extraction failed: {me}", exc_info=True)
|
||||||
|
all_extracted_content["warnings"] = all_extracted_content.get("warnings", []) + [
|
||||||
|
f"BZO-Parameter konnten nicht extrahiert werden: {str(me)}"
|
||||||
|
]
|
||||||
|
|
||||||
# Use AI to generate summary and find additional information
|
# Use AI to generate summary and find additional information
|
||||||
ai_summary = await generate_bauzone_ai_summary(
|
ai_summary = await generate_bauzone_ai_summary(
|
||||||
currentUser=currentUser,
|
currentUser=currentUser,
|
||||||
|
|
@ -2523,7 +2583,9 @@ async def extract_bzo_information(
|
||||||
gemeinde=gemeinde_obj.label,
|
gemeinde=gemeinde_obj.label,
|
||||||
extracted_content=all_extracted_content,
|
extracted_content=all_extracted_content,
|
||||||
relevant_rules=relevant_rules,
|
relevant_rules=relevant_rules,
|
||||||
relevant_zones=relevant_zones
|
relevant_zones=relevant_zones,
|
||||||
|
mandateId=_mandateId,
|
||||||
|
featureInstanceId=featureInstanceId,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build unified summary that includes zones and articles
|
# Build unified summary that includes zones and articles
|
||||||
|
|
@ -2602,7 +2664,8 @@ async def extract_bzo_information(
|
||||||
"relevant_rules": relevant_rules,
|
"relevant_rules": relevant_rules,
|
||||||
"documents_processed": documents_processed,
|
"documents_processed": documents_processed,
|
||||||
"errors": all_extracted_content.get("errors", []),
|
"errors": all_extracted_content.get("errors", []),
|
||||||
"warnings": all_extracted_content.get("warnings", [])
|
"warnings": all_extracted_content.get("warnings", []),
|
||||||
|
"machbarkeitsstudie": bzo_params_result, # Same key for frontend compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -2617,47 +2680,59 @@ async def extract_bzo_information(
|
||||||
|
|
||||||
def filter_rules_by_bauzone(rules: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
|
def filter_rules_by_bauzone(rules: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Filter rules by Bauzone code.
|
Filter rules by Bauzone code. Only keeps rules from SINGLE-zone articles to avoid
|
||||||
|
wrong values (e.g. article with W2,W3,W5 has different values per zone - we cannot
|
||||||
Args:
|
associate a rule value with a specific zone from article text alone).
|
||||||
rules: List of rule dictionaries from extraction
|
|
||||||
bauzone: Bauzone code to filter by (e.g., "W3", "W2/30")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filtered list of rules that match the Bauzone
|
|
||||||
"""
|
"""
|
||||||
relevant_rules = []
|
relevant_rules = []
|
||||||
bauzone_upper = bauzone.upper()
|
bauzone_upper = bauzone.upper()
|
||||||
|
|
||||||
|
def _zone_matches(z: str) -> bool:
|
||||||
|
zu = (z or "").upper().strip()
|
||||||
|
if not zu:
|
||||||
|
return False
|
||||||
|
if bauzone_upper in zu:
|
||||||
|
return True
|
||||||
|
if zu in bauzone_upper and len(zu) >= 2:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
for rule in rules:
|
for rule in rules:
|
||||||
# Check if rule has zone information
|
table_zones = rule.get("table_zones", []) or []
|
||||||
zone_raw = rule.get("zone_raw")
|
zone_raw = rule.get("zone_raw")
|
||||||
table_zones = rule.get("table_zones", [])
|
|
||||||
|
|
||||||
# Check if rule matches Bauzone
|
# Rule must be zone-associated
|
||||||
|
has_zone = bool(zone_raw) or bool(table_zones)
|
||||||
|
if not has_zone:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# CRITICAL: Only use rules from single-zone articles. Multi-zone articles
|
||||||
|
# (e.g. table with W2,W3,W5) have different values per zone - we cannot
|
||||||
|
# know which value applies to our zone from article text.
|
||||||
|
if len(table_zones) > 1:
|
||||||
|
# Check if ALL zones in article match our bauzone (e.g. W5, W5/50) - unlikely
|
||||||
|
matches_all = all(_zone_matches(str(z)) for z in table_zones)
|
||||||
|
if not matches_all:
|
||||||
|
continue # Ambiguous: exclude
|
||||||
|
|
||||||
|
# Zone must match our bauzone
|
||||||
matches = False
|
matches = False
|
||||||
|
if zone_raw and _zone_matches(zone_raw):
|
||||||
# Direct zone match
|
|
||||||
if zone_raw and bauzone_upper in zone_raw.upper():
|
|
||||||
matches = True
|
matches = True
|
||||||
|
|
||||||
# Table zone match
|
|
||||||
if not matches and table_zones:
|
if not matches and table_zones:
|
||||||
for table_zone in table_zones:
|
for tz in table_zones:
|
||||||
if bauzone_upper in str(table_zone).upper():
|
if _zone_matches(str(tz)):
|
||||||
matches = True
|
matches = True
|
||||||
break
|
break
|
||||||
|
|
||||||
# Check text snippet for Bauzone mention
|
|
||||||
if not matches:
|
if not matches:
|
||||||
text_snippet = rule.get("text_snippet", "")
|
ts = (rule.get("text_snippet") or "").upper()
|
||||||
if bauzone_upper in text_snippet.upper():
|
if bauzone_upper in ts and len(table_zones) <= 1:
|
||||||
matches = True
|
matches = True
|
||||||
|
|
||||||
if matches:
|
if matches:
|
||||||
relevant_rules.append(rule)
|
relevant_rules.append(rule)
|
||||||
|
|
||||||
logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total rules")
|
logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total (multi-zone articles excluded)")
|
||||||
return relevant_rules
|
return relevant_rules
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2768,7 +2843,9 @@ async def generate_bauzone_ai_summary(
|
||||||
gemeinde: str,
|
gemeinde: str,
|
||||||
extracted_content: Dict[str, Any],
|
extracted_content: Dict[str, Any],
|
||||||
relevant_rules: List[Dict[str, Any]],
|
relevant_rules: List[Dict[str, Any]],
|
||||||
relevant_zones: List[Dict[str, Any]]
|
relevant_zones: List[Dict[str, Any]],
|
||||||
|
mandateId: Optional[str] = None,
|
||||||
|
featureInstanceId: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Use AI to generate a summary of relevant information for a Bauzone.
|
Use AI to generate a summary of relevant information for a Bauzone.
|
||||||
|
|
@ -2785,8 +2862,10 @@ async def generate_bauzone_ai_summary(
|
||||||
AI-generated summary string
|
AI-generated summary string
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Initialize AI service
|
# Initialize AI service (mandateId required for billing)
|
||||||
services = getServices(currentUser, workflow=None)
|
services = getServices(
|
||||||
|
currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId
|
||||||
|
)
|
||||||
aiService = services.ai
|
aiService = services.ai
|
||||||
|
|
||||||
# Build context from extracted content, prioritizing zone-parameter tables
|
# Build context from extracted content, prioritizing zone-parameter tables
|
||||||
|
|
|
||||||
180
modules/features/realEstate/parcelSelectionService.py
Normal file
180
modules/features/realEstate/parcelSelectionService.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""
|
||||||
|
Parcel selection service: compute combined outline, total area, and Bauzone grouping.
|
||||||
|
Used for multi-parcel selection in PEK map view.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from shapely.geometry import Polygon
|
||||||
|
from shapely.ops import unary_union
|
||||||
|
from shapely.geometry.base import BaseGeometry
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _parcel_to_shapely_polygon(parcel: Dict[str, Any]) -> Optional[Polygon]:
|
||||||
|
"""
|
||||||
|
Convert a parcel dict (perimeter or geometry_geojson) to Shapely Polygon.
|
||||||
|
Returns None if conversion fails.
|
||||||
|
"""
|
||||||
|
# Try geometry_geojson first
|
||||||
|
geo = parcel.get("geometry_geojson") or parcel.get("map_view", {}).get("geometry_geojson")
|
||||||
|
if geo and isinstance(geo, dict):
|
||||||
|
geom = geo.get("geometry") or geo
|
||||||
|
if geom and isinstance(geom, dict) and geom.get("type") == "Polygon":
|
||||||
|
coords = geom.get("coordinates")
|
||||||
|
if coords and len(coords) > 0:
|
||||||
|
ring = coords[0] if isinstance(coords[0][0], list) else coords
|
||||||
|
if ring and len(ring) >= 3:
|
||||||
|
try:
|
||||||
|
poly = Polygon(ring)
|
||||||
|
if not poly.is_empty:
|
||||||
|
return poly
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"GeoJSON to Polygon failed: {e}")
|
||||||
|
|
||||||
|
# Try perimeter (punkte with x, y)
|
||||||
|
perimeter = parcel.get("perimeter")
|
||||||
|
if not perimeter and "parcel" in parcel:
|
||||||
|
perimeter = parcel.get("parcel", {}).get("perimeter")
|
||||||
|
if perimeter and isinstance(perimeter, dict):
|
||||||
|
punkte = perimeter.get("punkte", [])
|
||||||
|
if len(punkte) >= 3:
|
||||||
|
try:
|
||||||
|
coords = [(p.get("x"), p.get("y")) for p in punkte if "x" in p and "y" in p]
|
||||||
|
if len(coords) >= 3:
|
||||||
|
if coords[0] != coords[-1]:
|
||||||
|
coords.append(coords[0])
|
||||||
|
return Polygon(coords)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Perimeter to Polygon failed: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _shapely_to_geojson(geom: BaseGeometry) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convert Shapely geometry to GeoJSON dict (Polygon or MultiPolygon).
|
||||||
|
GeoJSON rings are closed (first point == last point).
|
||||||
|
"""
|
||||||
|
if geom is None or geom.is_empty:
|
||||||
|
return {"type": "Polygon", "coordinates": []}
|
||||||
|
|
||||||
|
if hasattr(geom, "geoms"):
|
||||||
|
# MultiPolygon: coordinates = [ [[ring1]], [[ring2]], ... ] per polygon
|
||||||
|
parts = []
|
||||||
|
for g in geom.geoms:
|
||||||
|
if not g.is_empty and hasattr(g, "exterior"):
|
||||||
|
ring = [list(c) for c in g.exterior.coords]
|
||||||
|
parts.append([ring])
|
||||||
|
return {"type": "MultiPolygon", "coordinates": parts}
|
||||||
|
elif hasattr(geom, "exterior"):
|
||||||
|
ring = [list(c) for c in geom.exterior.coords]
|
||||||
|
return {"type": "Polygon", "coordinates": [ring]}
|
||||||
|
return {"type": "Polygon", "coordinates": []}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_selection_summary(parcels: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Compute combined outline, total area, and Bauzone grouping for selected parcels.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parcels: List of parcel dicts with perimeter, geometry_geojson, bauzone, area_m2.
|
||||||
|
Can have structure { "parcel": {...}, "map_view": {...} } or flat.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"combined_outline_geojson": { "type": "Polygon"|"MultiPolygon", "coordinates": [...] },
|
||||||
|
"total_area_m2": float,
|
||||||
|
"bauzonen": [ { "bauzone": str, "parcels": [...], "area_m2": float } ]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not parcels:
|
||||||
|
return {
|
||||||
|
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
|
||||||
|
"total_area_m2": 0.0,
|
||||||
|
"bauzonen": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Normalize: extract parcel data for geometry and Bauzone
|
||||||
|
shapely_polygons = []
|
||||||
|
parcel_records = []
|
||||||
|
for p in parcels:
|
||||||
|
# Support both { parcel: {...}, map_view: {...} } and flat { id, perimeter, ... }
|
||||||
|
flat = dict(p.get("parcel", {}), **{k: v for k, v in p.items() if k != "parcel"})
|
||||||
|
flat["map_view"] = p.get("map_view", {})
|
||||||
|
flat["geometry_geojson"] = flat.get("geometry_geojson") or flat.get("map_view", {}).get("geometry_geojson")
|
||||||
|
flat["perimeter"] = flat.get("perimeter") or flat.get("map_view", {}).get("geometry_geojson") and None
|
||||||
|
poly = _parcel_to_shapely_polygon(flat)
|
||||||
|
if poly is not None and not poly.is_empty:
|
||||||
|
shapely_polygons.append(poly)
|
||||||
|
parcel_records.append(flat)
|
||||||
|
|
||||||
|
if not shapely_polygons:
|
||||||
|
return {
|
||||||
|
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
|
||||||
|
"total_area_m2": 0.0,
|
||||||
|
"bauzonen": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Union all polygons (keeps MultiPolygon if disconnected)
|
||||||
|
combined = unary_union(shapely_polygons)
|
||||||
|
total_area_m2 = float(combined.area) if combined and not combined.is_empty else 0.0
|
||||||
|
combined_geojson = _shapely_to_geojson(combined)
|
||||||
|
|
||||||
|
# Group parcels by Bauzone
|
||||||
|
bauzone_map: Dict[str, List[Dict]] = {}
|
||||||
|
for flat in parcel_records:
|
||||||
|
bz = flat.get("bauzone") or flat.get("parcel", {}).get("bauzone") or "Unbekannt"
|
||||||
|
if bz not in bauzone_map:
|
||||||
|
bauzone_map[bz] = []
|
||||||
|
bauzone_map[bz].append(flat)
|
||||||
|
|
||||||
|
bauzonen = []
|
||||||
|
for bz, plist in bauzone_map.items():
|
||||||
|
area_sum = sum(
|
||||||
|
float(p.get("area_m2") or p.get("parcel", {}).get("area_m2") or 0)
|
||||||
|
for p in plist
|
||||||
|
)
|
||||||
|
bauzonen.append({
|
||||||
|
"bauzone": bz,
|
||||||
|
"parcels": plist,
|
||||||
|
"area_m2": round(area_sum, 2),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"combined_outline_geojson": combined_geojson,
|
||||||
|
"total_area_m2": round(total_area_m2, 2),
|
||||||
|
"bauzonen": bauzonen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_parcel_adjacent_to_selection(
|
||||||
|
new_parcel: Dict[str, Any],
|
||||||
|
selected_parcels: List[Dict[str, Any]],
|
||||||
|
buffer_m: float = 0.01,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a parcel touches (is adjacent to) any selected parcel.
|
||||||
|
Uses small buffer for floating-point tolerance.
|
||||||
|
"""
|
||||||
|
new_poly = _parcel_to_shapely_polygon(new_parcel)
|
||||||
|
if new_poly is None or new_poly.is_empty:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for sel in selected_parcels:
|
||||||
|
flat = dict(sel.get("parcel", {}), **{k: v for k, v in sel.items() if k != "parcel"})
|
||||||
|
flat["map_view"] = sel.get("map_view", {})
|
||||||
|
flat["geometry_geojson"] = flat.get("geometry_geojson") or flat.get("map_view", {}).get("geometry_geojson")
|
||||||
|
sel_poly = _parcel_to_shapely_polygon(flat)
|
||||||
|
if sel_poly is None or sel_poly.is_empty:
|
||||||
|
continue
|
||||||
|
if buffer_m > 0:
|
||||||
|
sel_buf = sel_poly.buffer(buffer_m)
|
||||||
|
if new_poly.intersects(sel_buf) or new_poly.touches(sel_buf):
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if new_poly.touches(sel_poly) or new_poly.intersects(sel_poly):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
377
modules/features/realEstate/realEstateGemeindeService.py
Normal file
377
modules/features/realEstate/realEstateGemeindeService.py
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
"""
|
||||||
|
Gemeinde and BZO document services for Real Estate feature.
|
||||||
|
Provides ensure/import logic used by both routes and extract_bzo_information.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from .datamodelFeatureRealEstate import Gemeinde, Kanton, Dokument, DokumentTyp, Kontext
|
||||||
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
|
from modules.aicore.aicorePluginTavily import AiTavily
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KANTON_NAMES = {
|
||||||
|
"AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden",
|
||||||
|
"BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt",
|
||||||
|
"FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden",
|
||||||
|
"JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden",
|
||||||
|
"OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn",
|
||||||
|
"SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri",
|
||||||
|
"VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Known direct BZO PDF URLs for municipalities (by normalized name, lowercase)
|
||||||
|
# These are tried first to avoid SSL/HTML issues with Tavily search results
|
||||||
|
KNOWN_BZO_PDF_URLS: Dict[str, str] = {
|
||||||
|
"schlieren": "https://www.schlieren.ch/_docn/6239470/SKR_10.10_Bauordnung.pdf",
|
||||||
|
"zürich": "https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf",
|
||||||
|
"zurich": "https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_language_from_kanton(kanton_abk: Optional[str]) -> str:
|
||||||
|
if not kanton_abk:
|
||||||
|
return "de"
|
||||||
|
if kanton_abk.upper() in {"VD", "GE", "NE", "JU"}:
|
||||||
|
return "fr"
|
||||||
|
if kanton_abk.upper() == "TI":
|
||||||
|
return "it"
|
||||||
|
return "de"
|
||||||
|
|
||||||
|
|
||||||
|
# Swiss news/media domains to exclude from BZO search (return HTML articles, not PDFs)
|
||||||
|
_EXCLUDE_BZO_DOMAINS = [
|
||||||
|
"limmattalerzeitung.ch",
|
||||||
|
"20min.ch",
|
||||||
|
"tagesanzeiger.ch",
|
||||||
|
"nzz.ch",
|
||||||
|
"blick.ch",
|
||||||
|
"watson.ch",
|
||||||
|
"srf.ch",
|
||||||
|
"swissinfo.ch",
|
||||||
|
"zukunft-schlieren.ch", # project/development site, not official BZO
|
||||||
|
]
|
||||||
|
|
||||||
|
# Keywords that indicate the actual BZO regulation document (at least one required in URL/title)
|
||||||
|
_BZO_ORDINANCE_KEYWORDS = (
|
||||||
|
"bzo",
|
||||||
|
"zonenordnung",
|
||||||
|
"bauordnung",
|
||||||
|
"bau-und-zonenordnung",
|
||||||
|
"bau und zonenordnung",
|
||||||
|
"plan d'aménagement",
|
||||||
|
"règlement de construction",
|
||||||
|
"piano di utilizzazione",
|
||||||
|
"regolamento edilizio",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keywords that indicate articles or project docs (exclude if present in URL/title)
|
||||||
|
_BZO_ARTICLE_PROJECT_KEYWORDS = (
|
||||||
|
"ld.", # article ID (e.g. ld.2805321)
|
||||||
|
"warum", # "why" - typical in article headlines
|
||||||
|
"ruft ", # "calls [population to participate]"
|
||||||
|
"artikel", # article
|
||||||
|
"news",
|
||||||
|
"projektplanung", # project planning
|
||||||
|
"projekt/", # URL path for project pages
|
||||||
|
"/projekt",
|
||||||
|
"entwicklungsplan", # development plan (project doc)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_gemeinde_for_match(name: str) -> str:
|
||||||
|
"""Normalize Gemeinde name for URL/title matching (lowercase, no umlauts)."""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
s = name.lower().strip()
|
||||||
|
s = s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")
|
||||||
|
s = "".join(c for c in s if c.isalnum())
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bzo_search_query(gemeinde_label: str, language: str) -> str:
|
||||||
|
"""Build search query targeting BZO PDF documents (not articles)."""
|
||||||
|
if language == "fr":
|
||||||
|
return f"Plan d'aménagement local {gemeinde_label} PDF"
|
||||||
|
if language == "it":
|
||||||
|
return f"Piano di utilizzazione {gemeinde_label} PDF"
|
||||||
|
return f"Bau und Zonenordnung {gemeinde_label} PDF"
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_single_gemeinde(
|
||||||
|
interface: Any,
|
||||||
|
mandateId: str,
|
||||||
|
instanceId: str,
|
||||||
|
gemeinde_name: str,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
Ensure the given Gemeinde exists in DB. Fetches ONLY that one Gemeinde from Swiss Topo
|
||||||
|
and creates it if not found. No bulk import.
|
||||||
|
Returns the Gemeinde object if found/created, None otherwise.
|
||||||
|
"""
|
||||||
|
if not gemeinde_name or not gemeinde_name.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
connector = SwissTopoMapServerConnector()
|
||||||
|
gd = await connector.get_gemeinde_by_name(gemeinde_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Gemeinde '{gemeinde_name}' from Swiss Topo: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
if not gd:
|
||||||
|
logger.warning(f"Gemeinde '{gemeinde_name}' not found in Swiss Topo")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Any]:
|
||||||
|
try:
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
for g in gemeinden:
|
||||||
|
for k in (g.kontextInformationen or []):
|
||||||
|
try:
|
||||||
|
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
|
||||||
|
if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfs_nummer):
|
||||||
|
return g
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
continue
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error finding Gemeinde by BFS {bfs_nummer}: {ex}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
existing = find_gemeinde_by_bfs_nummer(str(gd["bfs_nummer"]))
|
||||||
|
if existing:
|
||||||
|
logger.info(f"Gemeinde '{gd['name']}' already in DB")
|
||||||
|
return existing
|
||||||
|
|
||||||
|
kanton_abk = gd.get("kanton")
|
||||||
|
kanton_id = None
|
||||||
|
if kanton_abk:
|
||||||
|
kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kanton_abk})
|
||||||
|
if kantone:
|
||||||
|
kanton_id = kantone[0].id
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
kanton_label = KANTON_NAMES.get(kanton_abk, kanton_abk)
|
||||||
|
kanton = Kanton(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=kanton_label,
|
||||||
|
abk=kanton_abk,
|
||||||
|
)
|
||||||
|
created_k = interface.createKanton(kanton)
|
||||||
|
if created_k and created_k.id:
|
||||||
|
kanton_id = created_k.id
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error creating Kanton {kanton_abk}: {ex}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
gemeinde = Gemeinde(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=gd["name"],
|
||||||
|
id_kanton=kanton_id,
|
||||||
|
kontextInformationen=[
|
||||||
|
Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": gd["bfs_nummer"]}, ensure_ascii=False))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
created = interface.createGemeinde(gemeinde)
|
||||||
|
if created and created.id:
|
||||||
|
logger.info(f"Created single Gemeinde '{gd['name']}' (BFS {gd['bfs_nummer']})")
|
||||||
|
return created
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error creating Gemeinde '{gd['name']}': {ex}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_quelle(doc: Any) -> Optional[str]:
|
||||||
|
"""Extract quelle (source URL) from a document."""
|
||||||
|
return getattr(doc, "quelle", None) or (doc.get("quelle") if isinstance(doc, dict) else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_bzo_for_gemeinde(
|
||||||
|
interface: Any,
|
||||||
|
componentInterface: Any,
|
||||||
|
gemeinde: Any,
|
||||||
|
mandateId: str,
|
||||||
|
instanceId: str,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Search for and download BZO documents for a single Gemeinde.
|
||||||
|
Returns True if at least one document was created.
|
||||||
|
Deduplication: re-fetches Gemeinde, skips if BZO exists, skips URLs we already have,
|
||||||
|
creates at most 1 new document per call to avoid duplicates from multiple Tavily URLs.
|
||||||
|
"""
|
||||||
|
# Re-fetch Gemeinde to get latest dokumente (avoid race with concurrent requests)
|
||||||
|
fresh = interface.getGemeinde(gemeinde.id)
|
||||||
|
if not fresh:
|
||||||
|
return False
|
||||||
|
gemeinde = fresh
|
||||||
|
|
||||||
|
existing_bzo = False
|
||||||
|
existing_quellen: Set[str] = set()
|
||||||
|
if gemeinde.dokumente:
|
||||||
|
for doc in gemeinde.dokumente:
|
||||||
|
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
|
||||||
|
label = getattr(doc, "label", None) or (doc.get("label") if isinstance(doc, dict) else None)
|
||||||
|
q = _extract_quelle(doc)
|
||||||
|
if q:
|
||||||
|
existing_quellen.add(q)
|
||||||
|
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]:
|
||||||
|
existing_bzo = True
|
||||||
|
break
|
||||||
|
if label and any(x in (label or "").upper() for x in ("BZO", "BAU UND ZONENORDNUNG", "PLAN D'AMÉNAGEMENT", "RÈGLEMENT DE CONSTRUCTION", "PIANO DI", "REGOLAMENTO EDILIZIO")):
|
||||||
|
existing_bzo = True
|
||||||
|
break
|
||||||
|
if existing_bzo:
|
||||||
|
return True
|
||||||
|
|
||||||
|
kanton_abk = None
|
||||||
|
if gemeinde.id_kanton:
|
||||||
|
k = interface.getKanton(gemeinde.id_kanton)
|
||||||
|
if k:
|
||||||
|
kanton_abk = k.abk
|
||||||
|
language = _get_language_from_kanton(kanton_abk)
|
||||||
|
search_query = _get_bzo_search_query(gemeinde.label, language)
|
||||||
|
|
||||||
|
logger.info(f"Tavily BZO search for {gemeinde.label}: {search_query}")
|
||||||
|
tavily = AiTavily()
|
||||||
|
gemeinde_normalized = _normalize_gemeinde_for_match(gemeinde.label or "")
|
||||||
|
|
||||||
|
search_results = await tavily._search(
|
||||||
|
query=search_query,
|
||||||
|
maxResults=10,
|
||||||
|
country="switzerland",
|
||||||
|
excludeDomains=_EXCLUDE_BZO_DOMAINS,
|
||||||
|
)
|
||||||
|
if not search_results:
|
||||||
|
logger.warning(f"No Tavily search results for BZO of {gemeinde.label}")
|
||||||
|
return False
|
||||||
|
logger.info(f"Tavily returned {len(search_results)} results for BZO of {gemeinde.label}")
|
||||||
|
|
||||||
|
# Filter: ONLY keep PDF URLs that are the actual BZO ordinance (not articles/project docs)
|
||||||
|
def _is_valid_bzo_result(url: str, title: str) -> bool:
|
||||||
|
combined = f"{url} {title}".lower()
|
||||||
|
combined_norm = _normalize_gemeinde_for_match(combined)
|
||||||
|
# 1. Gemeinde name MUST appear in URL or title
|
||||||
|
if not gemeinde_normalized or gemeinde_normalized not in combined_norm:
|
||||||
|
return False
|
||||||
|
# 2. MUST contain BZO ordinance keyword (actual regulation, not just "about" it)
|
||||||
|
if not any(kw in combined for kw in _BZO_ORDINANCE_KEYWORDS):
|
||||||
|
return False
|
||||||
|
# 3. EXCLUDE if it looks like an article or project planning doc
|
||||||
|
if any(kw in combined for kw in _BZO_ARTICLE_PROJECT_KEYWORDS):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
pdf_urls = [
|
||||||
|
r.url
|
||||||
|
for r in search_results
|
||||||
|
if (r.url.lower().endswith(".pdf") or "/pdf" in r.url.lower())
|
||||||
|
and _is_valid_bzo_result(r.url, r.title or "")
|
||||||
|
]
|
||||||
|
if not pdf_urls:
|
||||||
|
logger.warning(
|
||||||
|
f"No PDF URLs with matching Gemeinde name for {gemeinde.label} "
|
||||||
|
f"(filtered {len(search_results)} results, requiring .pdf and name in URL/title)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Prepend known direct PDF URLs for this Gemeinde (avoids SSL/HTML issues with Tavily results)
|
||||||
|
gemeinde_key = gemeinde.label.strip().lower() if gemeinde.label else ""
|
||||||
|
if gemeinde_key and gemeinde_key in KNOWN_BZO_PDF_URLS:
|
||||||
|
known_url = KNOWN_BZO_PDF_URLS[gemeinde_key]
|
||||||
|
pdf_urls = [known_url] + [u for u in pdf_urls if u != known_url]
|
||||||
|
logger.info(f"Using known BZO PDF URL for {gemeinde.label}")
|
||||||
|
|
||||||
|
# Use ssl.CERT_NONE to avoid CERTIFICATE_VERIFY_FAILED on Windows/corporate environments
|
||||||
|
# (same approach as routeRealEstate for external HTTP requests)
|
||||||
|
ssl_context = ssl.create_default_context()
|
||||||
|
ssl_context.check_hostname = False
|
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
|
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||||
|
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/pdf,*/*"}
|
||||||
|
timeout = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
async def download_pdf(session: aiohttp.ClientSession, url: str) -> Optional[bytes]:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
async with session.get(url, allow_redirects=True) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.read()
|
||||||
|
if data and len(data) >= 100 and data.startswith(b"%PDF"):
|
||||||
|
return data
|
||||||
|
if data.startswith(b"<") or data.startswith(b"<!DOCTYPE"):
|
||||||
|
raise Exception("Server returned HTML instead of PDF")
|
||||||
|
elif resp.status == 406 and attempt < 2:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise Exception(f"HTTP {resp.status}")
|
||||||
|
except Exception:
|
||||||
|
if attempt >= 2:
|
||||||
|
raise
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
created_dokumente: List[Any] = []
|
||||||
|
current_dokumente = list(gemeinde.dokumente) if gemeinde.dokumente else []
|
||||||
|
safe_name = "".join(c for c in gemeinde.label if c.isalnum() or c in (" ", "-", "_")).strip().replace(" ", "_") or "Gemeinde"
|
||||||
|
base_label = f"BZO {gemeinde.label}" if language == "de" else (f"Plan d'aménagement local {gemeinde.label}" if language == "fr" else f"Piano di utilizzazione {gemeinde.label}")
|
||||||
|
|
||||||
|
# Track content hashes to avoid duplicate PDFs from different URLs
|
||||||
|
seen_content_hashes: Set[str] = set()
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector) as session:
|
||||||
|
for idx, pdf_url in enumerate(pdf_urls[:5]):
|
||||||
|
# Skip URL we already have
|
||||||
|
if pdf_url in existing_quellen:
|
||||||
|
logger.debug(f"Skipping duplicate URL for {gemeinde.label}: {pdf_url[:60]}...")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pdf_content = await download_pdf(session, pdf_url)
|
||||||
|
if not pdf_content or len(pdf_content) < 100:
|
||||||
|
continue
|
||||||
|
# Deduplicate by content hash (same PDF from different URLs)
|
||||||
|
content_hash = hashlib.sha256(pdf_content[:8192]).hexdigest()
|
||||||
|
if content_hash in seen_content_hashes:
|
||||||
|
logger.debug(f"Skipping duplicate content for {gemeinde.label} (hash match)")
|
||||||
|
continue
|
||||||
|
seen_content_hashes.add(content_hash)
|
||||||
|
|
||||||
|
file_name = f"BZO_{safe_name}.pdf"
|
||||||
|
doc_label = base_label
|
||||||
|
file_item = componentInterface.createFile(name=file_name, mimeType="application/pdf", content=pdf_content)
|
||||||
|
componentInterface.createFileData(file_item.id, pdf_content)
|
||||||
|
dokument = Dokument(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=doc_label,
|
||||||
|
versionsbezeichnung="Aktuell",
|
||||||
|
dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL,
|
||||||
|
dokumentReferenz=file_item.id,
|
||||||
|
quelle=pdf_url,
|
||||||
|
mimeType="application/pdf",
|
||||||
|
kategorienTags=["BZO", "Bauordnung", gemeinde.label],
|
||||||
|
)
|
||||||
|
created_dok = interface.createDokument(dokument)
|
||||||
|
created_dokumente.append(created_dok)
|
||||||
|
current_dokumente.append(created_dok)
|
||||||
|
existing_quellen.add(pdf_url)
|
||||||
|
# Create at most 1 BZO document per Gemeinde to prevent duplicates
|
||||||
|
logger.info(f"Created BZO document for {gemeinde.label}, stopping (1 doc per Gemeinde)")
|
||||||
|
break
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(f"Error downloading BZO for {gemeinde.label} from {pdf_url}: {ex}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if created_dokumente:
|
||||||
|
interface.updateGemeinde(gemeinde.id, {"dokumente": current_dokumente})
|
||||||
|
logger.info(f"Created {len(created_dokumente)} BZO document(s) for {gemeinde.label}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
@ -3,11 +3,15 @@ Real Estate routes for the backend API.
|
||||||
Implements stateless endpoints for real estate database operations with AI-powered natural language processing.
|
Implements stateless endpoints for real estate database operations with AI-powered natural language processing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import aiohttp
|
||||||
import requests
|
import requests
|
||||||
from typing import Optional, Dict, Any, List, Union
|
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
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
|
@ -25,6 +29,7 @@ from .datamodelFeatureRealEstate import (
|
||||||
Projekt,
|
Projekt,
|
||||||
Parzelle,
|
Parzelle,
|
||||||
Dokument,
|
Dokument,
|
||||||
|
DokumentTyp,
|
||||||
Gemeinde,
|
Gemeinde,
|
||||||
Kanton,
|
Kanton,
|
||||||
Land,
|
Land,
|
||||||
|
|
@ -39,10 +44,18 @@ from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||||
from .mainRealEstate import (
|
from .mainRealEstate import (
|
||||||
processNaturalLanguageCommand,
|
processNaturalLanguageCommand,
|
||||||
create_project_with_parcel_data,
|
create_project_with_parcel_data,
|
||||||
|
extract_bzo_information,
|
||||||
)
|
)
|
||||||
|
from .parcelSelectionService import compute_selection_summary, is_parcel_adjacent_to_selection
|
||||||
|
|
||||||
# Import Swiss Topo MapServer connector for testing
|
# Import Swiss Topo MapServer, ÖREB and Zurich WFS connectors
|
||||||
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
|
from modules.connectors.connectorOerebWfs import OerebWfsConnector
|
||||||
|
from modules.connectors.connectorZhWfsParcels import ZhWfsParcelsConnector
|
||||||
|
|
||||||
|
# Import ComponentObjects and Tavily for BZO document fetch
|
||||||
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
|
from modules.aicore.aicorePluginTavily import AiTavily
|
||||||
|
|
||||||
# Import attribute utilities for model schema
|
# Import attribute utilities for model schema
|
||||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||||
|
|
@ -457,6 +470,314 @@ def delete_parcel(
|
||||||
raise HTTPException(status_code=500, detail="Delete failed")
|
raise HTTPException(status_code=500, detail="Delete failed")
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Helpers for Gemeinde/BZO routes =====
|
||||||
|
|
||||||
|
def _get_language_from_kanton(kanton_abk: Optional[str]) -> str:
|
||||||
|
"""Determine language (de/fr/it) based on Kanton abbreviation."""
|
||||||
|
if not kanton_abk:
|
||||||
|
return "de"
|
||||||
|
french_cantons = {"VD", "GE", "NE", "JU"}
|
||||||
|
italian_cantons = {"TI"}
|
||||||
|
kanton_upper = kanton_abk.upper()
|
||||||
|
if kanton_upper in french_cantons:
|
||||||
|
return "fr"
|
||||||
|
if kanton_upper in italian_cantons:
|
||||||
|
return "it"
|
||||||
|
return "de"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bzo_search_query(gemeinde_label: str, language: str) -> str:
|
||||||
|
"""Generate language-specific BZO search query for a Gemeinde."""
|
||||||
|
if language == "fr":
|
||||||
|
return f"Plan d'aménagement local {gemeinde_label} OR Règlement de construction {gemeinde_label}"
|
||||||
|
if language == "it":
|
||||||
|
return f"Piano di utilizzazione {gemeinde_label} OR Regolamento edilizio {gemeinde_label}"
|
||||||
|
return f"Bau und Zonenordnung {gemeinde_label}"
|
||||||
|
|
||||||
|
|
||||||
|
# ----- Instance-scoped Gemeinde and BZO routes -----
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/gemeinden", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def get_instance_gemeinden(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
only_current: bool = Query(True, description="Only current municipalities (exclude historical)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Fetch all Gemeinden from Swiss Topo and save to DB for this instance.
|
||||||
|
Creates Kantone as needed. Scoped to instance mandateId.
|
||||||
|
"""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getRealEstateInterface(
|
||||||
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
oereb_connector = OerebWfsConnector()
|
||||||
|
connector = SwissTopoMapServerConnector(oereb_connector=oereb_connector)
|
||||||
|
gemeinden_data = await connector.get_all_gemeinden(only_current=only_current)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Gemeinden from Swiss Topo: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching Gemeinden: {str(e)}")
|
||||||
|
gemeinden_created = 0
|
||||||
|
gemeinden_skipped = 0
|
||||||
|
kantone_created = 0
|
||||||
|
errors: List[str] = []
|
||||||
|
kanton_cache: Dict[str, str] = {}
|
||||||
|
|
||||||
|
def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Any]:
|
||||||
|
try:
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
for g in gemeinden:
|
||||||
|
for k in (g.kontextInformationen or []):
|
||||||
|
try:
|
||||||
|
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
|
||||||
|
if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfs_nummer):
|
||||||
|
return g
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
continue
|
||||||
|
except Exception as ex:
|
||||||
|
logger.error(f"Error finding Gemeinde by BFS {bfs_nummer}: {ex}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_or_create_kanton(kanton_abk: str) -> Optional[str]:
|
||||||
|
nonlocal kantone_created, errors
|
||||||
|
if not kanton_abk:
|
||||||
|
return None
|
||||||
|
if kanton_abk in kanton_cache:
|
||||||
|
return kanton_cache[kanton_abk]
|
||||||
|
kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kanton_abk})
|
||||||
|
if kantone:
|
||||||
|
kanton_cache[kanton_abk] = kantone[0].id
|
||||||
|
return kantone[0].id
|
||||||
|
kanton_names = {
|
||||||
|
"AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden",
|
||||||
|
"BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt",
|
||||||
|
"FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden",
|
||||||
|
"JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden",
|
||||||
|
"OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn",
|
||||||
|
"SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri",
|
||||||
|
"VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
kanton_label = kanton_names.get(kanton_abk, kanton_abk)
|
||||||
|
kanton = Kanton(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=kanton_label,
|
||||||
|
abk=kanton_abk,
|
||||||
|
)
|
||||||
|
created = interface.createKanton(kanton)
|
||||||
|
if created and created.id:
|
||||||
|
kanton_cache[kanton_abk] = created.id
|
||||||
|
kantone_created += 1
|
||||||
|
return created.id
|
||||||
|
except Exception as ex:
|
||||||
|
errors.append(f"Error creating Kanton {kanton_abk}: {ex}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
saved_gemeinden: List[Dict[str, Any]] = []
|
||||||
|
for gd in gemeinden_data:
|
||||||
|
try:
|
||||||
|
gemeinde_name = gd.get("name")
|
||||||
|
bfs_nummer = gd.get("bfs_nummer")
|
||||||
|
kanton_abk = gd.get("kanton")
|
||||||
|
if not gemeinde_name or bfs_nummer is None:
|
||||||
|
gemeinden_skipped += 1
|
||||||
|
continue
|
||||||
|
existing = find_gemeinde_by_bfs_nummer(str(bfs_nummer))
|
||||||
|
if existing:
|
||||||
|
gemeinden_skipped += 1
|
||||||
|
saved_gemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing)
|
||||||
|
continue
|
||||||
|
kanton_id = get_or_create_kanton(kanton_abk) if kanton_abk else None
|
||||||
|
gemeinde = Gemeinde(
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
label=gemeinde_name,
|
||||||
|
id_kanton=kanton_id,
|
||||||
|
kontextInformationen=[
|
||||||
|
Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfs_nummer}, ensure_ascii=False))
|
||||||
|
],
|
||||||
|
)
|
||||||
|
created = interface.createGemeinde(gemeinde)
|
||||||
|
if created and created.id:
|
||||||
|
gemeinden_created += 1
|
||||||
|
saved_gemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created)
|
||||||
|
else:
|
||||||
|
errors.append(f"Failed to create Gemeinde {gemeinde_name}")
|
||||||
|
gemeinden_skipped += 1
|
||||||
|
except Exception as ex:
|
||||||
|
errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}")
|
||||||
|
gemeinden_skipped += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gemeinden": saved_gemeinden,
|
||||||
|
"count": len(saved_gemeinden),
|
||||||
|
"stats": {
|
||||||
|
"gemeinden_created": gemeinden_created,
|
||||||
|
"gemeinden_skipped": gemeinden_skipped,
|
||||||
|
"kantone_created": kantone_created,
|
||||||
|
"error_count": len(errors),
|
||||||
|
"errors": errors[:10],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{instanceId}/gemeinden/fetch-bzo-documents", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("10/hour")
|
||||||
|
async def fetch_instance_bzo_documents(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Search for and download BZO documents for all Gemeinden of this instance (1 doc per Gemeinde, no duplicates)."""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getRealEstateInterface(
|
||||||
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
componentInterface = getComponentInterface(
|
||||||
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
from modules.features.realEstate.realEstateGemeindeService import fetch_bzo_for_gemeinde
|
||||||
|
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
||||||
|
stats = {"gemeinden_processed": 0, "documents_created": 0, "documents_skipped": 0, "errors": []}
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for gemeinde in gemeinden:
|
||||||
|
gr = {"gemeinde_id": gemeinde.id, "gemeinde_label": gemeinde.label, "status": None, "dokument_ids": [], "error": None}
|
||||||
|
try:
|
||||||
|
stats["gemeinden_processed"] += 1
|
||||||
|
fetched = await fetch_bzo_for_gemeinde(
|
||||||
|
interface, componentInterface, gemeinde, mandateId, instanceId
|
||||||
|
)
|
||||||
|
if fetched:
|
||||||
|
gr["status"] = "created"
|
||||||
|
stats["documents_created"] += 1
|
||||||
|
refreshed = interface.getGemeinde(gemeinde.id)
|
||||||
|
if refreshed and refreshed.dokumente:
|
||||||
|
for doc in refreshed.dokumente:
|
||||||
|
doc_id = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None)
|
||||||
|
if doc_id:
|
||||||
|
gr["dokument_ids"].append(doc_id)
|
||||||
|
else:
|
||||||
|
gr["status"] = "skipped"
|
||||||
|
stats["documents_skipped"] += 1
|
||||||
|
except Exception as ex:
|
||||||
|
gr["status"] = "error"
|
||||||
|
gr["error"] = str(ex)
|
||||||
|
stats["errors"].append(f"{gemeinde.label}: {str(ex)}")
|
||||||
|
results.append(gr)
|
||||||
|
|
||||||
|
return {"success": True, "stats": stats, "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/parcel-documents", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def get_parcel_documents(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
gemeinde: str = Query(..., description="Gemeinde name (e.g. Zürich)"),
|
||||||
|
bauzone: str = Query(..., description="Bauzone code (e.g. W5)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Ensure BZO document exists for Gemeinde, return documents for parcel info display.
|
||||||
|
Creates Gemeinde (Swiss Topo) and BZO (Tavily) if not in DB.
|
||||||
|
Returns documents for preview - does NOT run LangGraph.
|
||||||
|
"""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
interface = getRealEstateInterface(
|
||||||
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
componentInterface = getComponentInterface(
|
||||||
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
from modules.features.realEstate.realEstateGemeindeService import (
|
||||||
|
ensure_single_gemeinde,
|
||||||
|
fetch_bzo_for_gemeinde,
|
||||||
|
)
|
||||||
|
gemeinde_obj = None
|
||||||
|
by_label = interface.getGemeinden(recordFilter={"label": gemeinde, "mandateId": mandateId})
|
||||||
|
gemeinde_obj = by_label[0] if by_label else None
|
||||||
|
if not gemeinde_obj:
|
||||||
|
gemeinde_obj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeinde)
|
||||||
|
if not gemeinde_obj:
|
||||||
|
return {"documents": [], "error": f"Gemeinde '{gemeinde}' nicht gefunden"}
|
||||||
|
bzo_docs = []
|
||||||
|
if gemeinde_obj.dokumente:
|
||||||
|
for doc in gemeinde_obj.dokumente:
|
||||||
|
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
|
||||||
|
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] or str(typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision"]:
|
||||||
|
doc_id = doc.id if hasattr(doc, "id") else doc.get("id")
|
||||||
|
if doc_id:
|
||||||
|
full = interface.getDokument(doc_id)
|
||||||
|
if full and full.dokumentReferenz:
|
||||||
|
bzo_docs.append(full)
|
||||||
|
if not bzo_docs:
|
||||||
|
fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeinde_obj, mandateId, instanceId)
|
||||||
|
if fetched:
|
||||||
|
gemeinde_obj = interface.getGemeinde(gemeinde_obj.id)
|
||||||
|
if gemeinde_obj and gemeinde_obj.dokumente:
|
||||||
|
for doc in gemeinde_obj.dokumente:
|
||||||
|
typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None)
|
||||||
|
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]:
|
||||||
|
doc_id = doc.id if hasattr(doc, "id") else doc.get("id")
|
||||||
|
if doc_id:
|
||||||
|
full = interface.getDokument(doc_id)
|
||||||
|
if full and full.dokumentReferenz:
|
||||||
|
bzo_docs.append(full)
|
||||||
|
result = []
|
||||||
|
for d in bzo_docs:
|
||||||
|
result.append({
|
||||||
|
"id": d.id,
|
||||||
|
"label": d.label,
|
||||||
|
"fileId": d.dokumentReferenz,
|
||||||
|
"fileName": (d.label or "BZO") + ".pdf",
|
||||||
|
"mimeType": d.mimeType or "application/pdf",
|
||||||
|
})
|
||||||
|
return {"documents": result, "gemeinde": gemeinde, "bauzone": bauzone}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{instanceId}/bzo-information", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def get_instance_bzo_information(
|
||||||
|
request: Request,
|
||||||
|
instanceId: str = Path(..., description="Feature Instance ID"),
|
||||||
|
gemeinde: str = Query(..., description="Gemeinde name or ID"),
|
||||||
|
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
|
||||||
|
total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
|
||||||
|
parcel_ids: Optional[str] = Query(None, description="Comma-separated parcel IDs; total area computed from parcels"),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Extract BZO information for a Bauzone in a Gemeinde. Runs LangGraph workflow. With total_area_m2 or parcel_ids, includes Machbarkeitsstudie."""
|
||||||
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
parcels = None
|
||||||
|
if parcel_ids:
|
||||||
|
ids = [x.strip() for x in parcel_ids.split(",") if x.strip()]
|
||||||
|
if ids:
|
||||||
|
interface = getRealEstateInterface(
|
||||||
|
context.user, mandateId=mandateId, featureInstanceId=instanceId
|
||||||
|
)
|
||||||
|
parcels = []
|
||||||
|
for pid in ids:
|
||||||
|
p = interface.getParzelle(pid)
|
||||||
|
if p:
|
||||||
|
flat = dict(p) if hasattr(p, "keys") else (vars(p) if hasattr(p, "__dict__") else {})
|
||||||
|
parcels.append({"parcel": flat, "map_view": flat.get("map_view", {})})
|
||||||
|
return await extract_bzo_information(
|
||||||
|
currentUser=context.user,
|
||||||
|
gemeinde=gemeinde,
|
||||||
|
bauzone=bauzone,
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=instanceId,
|
||||||
|
total_area_m2=total_area_m2,
|
||||||
|
parcels=parcels,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# LEGACY / STATELESS ROUTES (unchanged)
|
# LEGACY / STATELESS ROUTES (unchanged)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -995,12 +1316,38 @@ async def create_table_record(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/parcel/wfs")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
def get_parcels_wfs(
|
||||||
|
request: Request,
|
||||||
|
bbox: str = Query(..., description="Bounding box as minx,miny,maxx,maxy in LV95 (EPSG:2056)"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Fetch parcel geometries from geodienste.ch OGC API (Swiss Liegenschaften) within bounding box.
|
||||||
|
Returns GeoJSON FeatureCollection in WGS84 for map display.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
connector = ZhWfsParcelsConnector()
|
||||||
|
geojson = connector.get_parcels_by_bbox(bbox)
|
||||||
|
return JSONResponse(content=geojson)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching WFS parcels: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Failed to fetch parcel data from WFS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/parcel/search", response_model=Dict[str, Any])
|
@router.get("/parcel/search", response_model=Dict[str, Any])
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def search_parcel(
|
async def search_parcel(
|
||||||
request: Request,
|
request: Request,
|
||||||
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
|
location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"),
|
||||||
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
|
include_adjacent: bool = Query(False, description="Include adjacent parcels information"),
|
||||||
|
include_bauzone: bool = Query(True, description="Include Bauzone from ÖREB WFS (zone information)"),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1010,12 +1357,14 @@ async def search_parcel(
|
||||||
- Parcel identification (number, EGRID, etc.)
|
- Parcel identification (number, EGRID, etc.)
|
||||||
- Precise boundary geometry for map display
|
- Precise boundary geometry for map display
|
||||||
- Administrative context (canton, municipality)
|
- Administrative context (canton, municipality)
|
||||||
|
- Bauzone (zone code from ÖREB WFS when include_bauzone=True)
|
||||||
- Link to official cadastral map
|
- Link to official cadastral map
|
||||||
- Optional: Adjacent parcels
|
- Optional: Adjacent parcels
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string
|
- location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string
|
||||||
- include_adjacent: If true, fetches information about adjacent parcels (slower)
|
- include_adjacent: If true, fetches information about adjacent parcels (slower)
|
||||||
|
- include_bauzone: If true, queries ÖREB WFS for zone info (Bauzone/Wohnzone)
|
||||||
|
|
||||||
Headers:
|
Headers:
|
||||||
- X-CSRF-Token: CSRF token (required for security)
|
- X-CSRF-Token: CSRF token (required for security)
|
||||||
|
|
@ -1073,6 +1422,9 @@ async def search_parcel(
|
||||||
"y": sum_y / len(points)
|
"y": sum_y / len(points)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Extract canton early (needed for bauzone query and municipality resolution)
|
||||||
|
canton = attributes.get("ak", "")
|
||||||
|
|
||||||
# Extract municipality name and address from Swiss Topo data
|
# Extract municipality name and address from Swiss Topo data
|
||||||
municipality_name = None
|
municipality_name = None
|
||||||
full_address = None
|
full_address = None
|
||||||
|
|
@ -1126,32 +1478,82 @@ async def search_parcel(
|
||||||
full_address = location
|
full_address = location
|
||||||
logger.debug(f"Using location as address: {full_address}")
|
logger.debug(f"Using location as address: {full_address}")
|
||||||
|
|
||||||
|
# Try to extract municipality name from address string (e.g. "Forchstrasse 6c, 8610 Uster")
|
||||||
|
if not municipality_name and full_address:
|
||||||
|
plz_municipality_match = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", full_address)
|
||||||
|
if plz_municipality_match:
|
||||||
|
extracted_municipality = plz_municipality_match.group(2).strip()
|
||||||
|
extracted_municipality = re.sub(r"[,;\.]+$", "", extracted_municipality).strip()
|
||||||
|
if extracted_municipality:
|
||||||
|
municipality_name = extracted_municipality
|
||||||
|
if not plz:
|
||||||
|
plz = plz_municipality_match.group(1)
|
||||||
|
logger.debug(f"Extracted municipality from address: {municipality_name}")
|
||||||
|
|
||||||
# Try to extract municipality name from BFSNR if not found
|
# Try to extract municipality name from BFSNR if not found
|
||||||
if not municipality_name:
|
bfsnr = attributes.get("bfsnr")
|
||||||
# Common Swiss municipalities lookup (you can expand this)
|
if not municipality_name and bfsnr and canton and context.mandateId:
|
||||||
bfsnr = attributes.get("bfsnr")
|
try:
|
||||||
canton = attributes.get("ak", "")
|
interface = getRealEstateInterface(
|
||||||
|
context.user, mandateId=str(context.mandateId), featureInstanceId=None
|
||||||
|
)
|
||||||
|
gemeinden = interface.getGemeinden(recordFilter={"mandateId": str(context.mandateId)})
|
||||||
|
for g in gemeinden:
|
||||||
|
for k in (g.kontextInformationen or []):
|
||||||
|
try:
|
||||||
|
data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt
|
||||||
|
if isinstance(data, dict):
|
||||||
|
bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code")
|
||||||
|
if str(bfs) == str(bfsnr):
|
||||||
|
municipality_name = g.label
|
||||||
|
logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipality_name}")
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
continue
|
||||||
|
if municipality_name:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error querying Gemeinde by BFS: {e}")
|
||||||
|
|
||||||
# Basic municipality lookup for common codes
|
# Swiss Topo geocoding to get municipality from coordinates
|
||||||
|
if not municipality_name and centroid and canton:
|
||||||
|
try:
|
||||||
|
geocode_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify"
|
||||||
|
params = {
|
||||||
|
"geometry": f"{centroid['x']},{centroid['y']}",
|
||||||
|
"geometryType": "esriGeometryPoint",
|
||||||
|
"layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill",
|
||||||
|
"tolerance": "0",
|
||||||
|
"returnGeometry": "false",
|
||||||
|
"sr": "2056",
|
||||||
|
"f": "json",
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(geocode_url, params=params) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.json()
|
||||||
|
results = data.get("results", [])
|
||||||
|
if results:
|
||||||
|
attrs = results[0].get("attributes", {})
|
||||||
|
geo_name = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label")
|
||||||
|
if geo_name:
|
||||||
|
municipality_name = connector._clean_municipality_name(str(geo_name))
|
||||||
|
logger.debug(f"Found municipality via Swiss Topo geocoding: {municipality_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error querying Swiss Topo geocoding: {e}")
|
||||||
|
|
||||||
|
# Expanded common municipalities fallback
|
||||||
|
if not municipality_name and bfsnr:
|
||||||
common_municipalities = {
|
common_municipalities = {
|
||||||
351: "Bern",
|
261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur",
|
||||||
261: "Zuerich",
|
351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne",
|
||||||
6621: "Geneve",
|
1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz",
|
||||||
2701: "Basel",
|
|
||||||
5586: "Lausanne",
|
|
||||||
1061: "Luzern",
|
|
||||||
3203: "Winterthur",
|
|
||||||
230: "St. Gallen",
|
|
||||||
5192: "Lugano",
|
|
||||||
1367: "Schwyz"
|
|
||||||
}
|
}
|
||||||
|
if bfsnr in common_municipalities:
|
||||||
if bfsnr and bfsnr in common_municipalities:
|
|
||||||
municipality_name = common_municipalities[bfsnr]
|
municipality_name = common_municipalities[bfsnr]
|
||||||
logger.debug(f"Looked up municipality: {municipality_name}")
|
logger.debug(f"Looked up municipality from common list: {municipality_name}")
|
||||||
else:
|
elif canton and bfsnr:
|
||||||
# Fallback: Use canton + code
|
municipality_name = f"{canton}-{bfsnr}"
|
||||||
municipality_name = f"{canton}-{bfsnr}" if canton and bfsnr else "Unknown"
|
|
||||||
logger.debug(f"Using fallback municipality: {municipality_name}")
|
logger.debug(f"Using fallback municipality: {municipality_name}")
|
||||||
|
|
||||||
# Final validation: Don't use EGRID as address
|
# Final validation: Don't use EGRID as address
|
||||||
|
|
@ -1160,6 +1562,29 @@ async def search_parcel(
|
||||||
full_address = None
|
full_address = None
|
||||||
logger.debug("Removed EGRID from address field")
|
logger.debug("Removed EGRID from address field")
|
||||||
|
|
||||||
|
# Query Bauzone (wohnzone) from ÖREB WFS when requested
|
||||||
|
bauzone = None
|
||||||
|
has_geometry = geometry and (geometry.get("rings") or geometry.get("coordinates"))
|
||||||
|
if include_bauzone and canton and has_geometry and centroid:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}")
|
||||||
|
oereb_connector = OerebWfsConnector()
|
||||||
|
zone_results = await oereb_connector.query_zone_layer(
|
||||||
|
egrid=attributes.get("egris_egrid", "") or "",
|
||||||
|
x=centroid["x"],
|
||||||
|
y=centroid["y"],
|
||||||
|
canton=canton,
|
||||||
|
geometry=geometry,
|
||||||
|
)
|
||||||
|
if zone_results and len(zone_results) > 0:
|
||||||
|
zone_attrs = zone_results[0].get("attributes", {})
|
||||||
|
typ_gde_abkuerzung = zone_attrs.get("typ_gde_abkuerzung")
|
||||||
|
if typ_gde_abkuerzung:
|
||||||
|
bauzone = typ_gde_abkuerzung
|
||||||
|
logger.debug(f"Found bauzone: {bauzone} for parcel {attributes.get('label')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error querying zone information: {e}", exc_info=True)
|
||||||
|
|
||||||
# Build parcel info
|
# Build parcel info
|
||||||
parcel_info = {
|
parcel_info = {
|
||||||
"id": attributes.get("label") or attributes.get("number"),
|
"id": attributes.get("label") or attributes.get("number"),
|
||||||
|
|
@ -1176,7 +1601,8 @@ async def search_parcel(
|
||||||
"area_m2": area_m2,
|
"area_m2": area_m2,
|
||||||
"centroid": centroid,
|
"centroid": centroid,
|
||||||
"geoportal_url": attributes.get("geoportal_url"),
|
"geoportal_url": attributes.get("geoportal_url"),
|
||||||
"realestate_type": attributes.get("realestate_type")
|
"realestate_type": attributes.get("realestate_type"),
|
||||||
|
"bauzone": bauzone,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build map view info
|
# Build map view info
|
||||||
|
|
@ -1310,6 +1736,172 @@ async def search_parcel(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parcel/selection-summary", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def parcel_selection_summary(
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(..., description="Parcel selection data"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Compute combined outline, total area, and Bauzone grouping for selected parcels.
|
||||||
|
Request body: { "parcels": [ { parcel, map_view, perimeter, geometry_geojson, ... } ] }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
|
if not csrf_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
)
|
||||||
|
parcels = body.get("parcels", [])
|
||||||
|
if not parcels:
|
||||||
|
return {
|
||||||
|
"combined_outline_geojson": {"type": "Polygon", "coordinates": []},
|
||||||
|
"total_area_m2": 0.0,
|
||||||
|
"bauzonen": [],
|
||||||
|
}
|
||||||
|
result = compute_selection_summary(parcels)
|
||||||
|
logger.info(f"Computed selection summary for {len(parcels)} parcels, total area {result['total_area_m2']} m²")
|
||||||
|
return result
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error computing selection summary: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error computing selection summary: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_geometry_geojson(extracted: Dict[str, Any], parcel_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Build geometry_geojson from extracted perimeter for add-adjacent response."""
|
||||||
|
coords = []
|
||||||
|
if extracted.get("perimeter", {}).get("punkte"):
|
||||||
|
coords = [[[p["x"], p["y"]] for p in extracted["perimeter"]["punkte"]]]
|
||||||
|
return {
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {"type": "Polygon", "coordinates": coords},
|
||||||
|
"properties": {"id": parcel_info["id"], "egrid": parcel_info["egrid"], "number": parcel_info["number"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/parcel/add-adjacent", response_model=Dict[str, Any])
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def add_adjacent_parcel(
|
||||||
|
request: Request,
|
||||||
|
body: Dict[str, Any] = Body(..., description="Location and selected parcels"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Add an adjacent parcel to the selection. Validates that the parcel at the given
|
||||||
|
location touches the current selection.
|
||||||
|
Request body: { "location": { "x": number, "y": number }, "selected_parcels": [...] }
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token")
|
||||||
|
if not csrf_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="CSRF token missing. Please include X-CSRF-Token header."
|
||||||
|
)
|
||||||
|
location = body.get("location")
|
||||||
|
selected_parcels = body.get("selected_parcels", [])
|
||||||
|
if not location or "x" not in location or "y" not in location:
|
||||||
|
raise HTTPException(status_code=400, detail="location with x,y required")
|
||||||
|
loc_str = f"{location['x']},{location['y']}"
|
||||||
|
connector = SwissTopoMapServerConnector()
|
||||||
|
parcel_data = await connector.search_parcel(loc_str)
|
||||||
|
if not parcel_data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No parcel found at this location"
|
||||||
|
)
|
||||||
|
extracted = connector.extract_parcel_attributes(parcel_data)
|
||||||
|
attributes = parcel_data.get("attributes", {})
|
||||||
|
geometry = parcel_data.get("geometry", {})
|
||||||
|
area_m2 = None
|
||||||
|
centroid = None
|
||||||
|
if extracted.get("perimeter"):
|
||||||
|
perimeter = extracted["perimeter"]
|
||||||
|
points = perimeter.get("punkte", [])
|
||||||
|
if len(points) >= 3:
|
||||||
|
area = 0
|
||||||
|
for i in range(len(points)):
|
||||||
|
j = (i + 1) % len(points)
|
||||||
|
area += points[i]["x"] * points[j]["y"]
|
||||||
|
area -= points[j]["x"] * points[i]["y"]
|
||||||
|
area_m2 = abs(area / 2)
|
||||||
|
sum_x = sum(p["x"] for p in points)
|
||||||
|
sum_y = sum(p["y"] for p in points)
|
||||||
|
centroid = {"x": sum_x / len(points), "y": sum_y / len(points)}
|
||||||
|
parcel_info = {
|
||||||
|
"id": attributes.get("label") or attributes.get("number"),
|
||||||
|
"egrid": attributes.get("egris_egrid"),
|
||||||
|
"number": attributes.get("number"),
|
||||||
|
"name": attributes.get("name"),
|
||||||
|
"identnd": attributes.get("identnd"),
|
||||||
|
"canton": attributes.get("ak"),
|
||||||
|
"municipality_code": attributes.get("bfsnr"),
|
||||||
|
"municipality_name": None,
|
||||||
|
"address": None,
|
||||||
|
"plz": None,
|
||||||
|
"perimeter": extracted.get("perimeter"),
|
||||||
|
"area_m2": area_m2,
|
||||||
|
"centroid": centroid,
|
||||||
|
"geoportal_url": attributes.get("geoportal_url"),
|
||||||
|
"realestate_type": attributes.get("realestate_type"),
|
||||||
|
"bauzone": None,
|
||||||
|
}
|
||||||
|
map_view = {
|
||||||
|
"center": centroid,
|
||||||
|
"zoom_bounds": parcel_data.get("bbox", []) and {
|
||||||
|
"min_x": parcel_data["bbox"][0],
|
||||||
|
"min_y": parcel_data["bbox"][1],
|
||||||
|
"max_x": parcel_data["bbox"][2],
|
||||||
|
"max_y": parcel_data["bbox"][3],
|
||||||
|
} or None,
|
||||||
|
"geometry_geojson": _build_geometry_geojson(extracted, parcel_info),
|
||||||
|
}
|
||||||
|
new_parcel_response = {"parcel": parcel_info, "map_view": map_view}
|
||||||
|
if not is_parcel_adjacent_to_selection(new_parcel_response, selected_parcels):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Nur angrenzende Parzellen können hinzugefügt werden"
|
||||||
|
)
|
||||||
|
bbox = parcel_data.get("bbox", [])
|
||||||
|
map_view["zoom_bounds"] = {
|
||||||
|
"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3]
|
||||||
|
} if len(bbox) >= 4 else None
|
||||||
|
geocoded_address = parcel_data.get("geocoded_address")
|
||||||
|
if geocoded_address:
|
||||||
|
parcel_info["municipality_name"] = geocoded_address.get("municipality")
|
||||||
|
parcel_info["address"] = geocoded_address.get("full_address")
|
||||||
|
parcel_info["plz"] = geocoded_address.get("plz")
|
||||||
|
if centroid and attributes.get("ak"):
|
||||||
|
try:
|
||||||
|
oereb = OerebWfsConnector()
|
||||||
|
zone_results = await oereb.query_zone_layer(
|
||||||
|
egrid=attributes.get("egris_egrid", "") or "",
|
||||||
|
x=centroid["x"], y=centroid["y"],
|
||||||
|
canton=attributes.get("ak"),
|
||||||
|
geometry=geometry,
|
||||||
|
)
|
||||||
|
if zone_results and len(zone_results) > 0:
|
||||||
|
parcel_info["bauzone"] = zone_results[0].get("attributes", {}).get("typ_gde_abkuerzung")
|
||||||
|
except Exception as oe:
|
||||||
|
logger.debug(f"ÖREB zone query failed: {oe}")
|
||||||
|
return new_parcel_response
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding adjacent parcel: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Error adding adjacent parcel: {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(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.datamodels.datamodelRealEstate import (
|
from .datamodelFeatureRealEstate import (
|
||||||
Parzelle,
|
Parzelle,
|
||||||
GeoPolylinie,
|
GeoPolylinie,
|
||||||
GeoPunkt,
|
GeoPunkt,
|
||||||
|
|
@ -23,7 +23,7 @@ from modules.datamodels.datamodelRealEstate import (
|
||||||
Gemeinde,
|
Gemeinde,
|
||||||
Kanton,
|
Kanton,
|
||||||
)
|
)
|
||||||
from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface
|
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
||||||
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
||||||
from modules.connectors.connectorOerebWfs import OerebWfsConnector
|
from modules.connectors.connectorOerebWfs import OerebWfsConnector
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1254,6 +1254,34 @@ class ComponentObjects:
|
||||||
logger.error(f"Error processing file data for {fileId}: {str(e)}")
|
logger.error(f"Error processing file data for {fileId}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def getFileDataForPublicDocument(self, fileId: str) -> Optional[bytes]:
|
||||||
|
"""
|
||||||
|
Returns binary data for public documents (e.g. BZO) WITHOUT RBAC filtering.
|
||||||
|
Use for official/mandate documents that must be accessible to all users.
|
||||||
|
Reads FileData directly from database.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fileDataEntries = self.db.getRecordset(FileData, recordFilter={"id": fileId})
|
||||||
|
if not fileDataEntries:
|
||||||
|
logger.warning(f"No file data found for public document ID {fileId}")
|
||||||
|
return None
|
||||||
|
fileDataEntry = fileDataEntries[0]
|
||||||
|
if "data" not in fileDataEntry:
|
||||||
|
logger.warning(f"No data field in file data for ID {fileId}")
|
||||||
|
return None
|
||||||
|
data = fileDataEntry["data"]
|
||||||
|
base64Encoded = fileDataEntry.get("base64Encoded", False)
|
||||||
|
if base64Encoded:
|
||||||
|
return base64.b64decode(data)
|
||||||
|
# PDF/binary stored as text: try base64 decode (common for binary files)
|
||||||
|
try:
|
||||||
|
return base64.b64decode(data)
|
||||||
|
except Exception:
|
||||||
|
return data.encode("utf-8") if isinstance(data, str) else data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving public document {fileId}: {str(e)}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
def getFileContent(self, fileId: str) -> Optional[FilePreview]:
|
def getFileContent(self, fileId: str) -> Optional[FilePreview]:
|
||||||
"""Returns the full file content if user has access."""
|
"""Returns the full file content if user has access."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2254,6 +2254,7 @@ async def get_bzo_information(
|
||||||
request: Request,
|
request: Request,
|
||||||
gemeinde: str = Query(..., description="Gemeinde name or ID"),
|
gemeinde: str = Query(..., description="Gemeinde name or ID"),
|
||||||
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
|
bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"),
|
||||||
|
total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -2348,7 +2349,8 @@ async def get_bzo_information(
|
||||||
result = await extract_bzo_information(
|
result = await extract_bzo_information(
|
||||||
currentUser=currentUser,
|
currentUser=currentUser,
|
||||||
gemeinde=gemeinde,
|
gemeinde=gemeinde,
|
||||||
bauzone=bauzone
|
bauzone=bauzone,
|
||||||
|
total_area_m2=total_area_m2,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
|
||||||
from modules.features.trustee.mainTrustee import UI_OBJECTS
|
from modules.features.trustee.mainTrustee import UI_OBJECTS
|
||||||
return UI_OBJECTS
|
return UI_OBJECTS
|
||||||
elif featureCode == "realestate":
|
elif featureCode == "realestate":
|
||||||
from modules.features.realestate.mainRealEstate import UI_OBJECTS
|
from modules.features.realEstate.mainRealEstate import UI_OBJECTS
|
||||||
return UI_OBJECTS
|
return UI_OBJECTS
|
||||||
elif featureCode == "chatplayground":
|
elif featureCode == "chatplayground":
|
||||||
from modules.features.chatplayground.mainChatplayground import UI_OBJECTS
|
from modules.features.chatplayground.mainChatplayground import UI_OBJECTS
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue