Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot) Refactor: gdprDeletion via onUserDelete lifecycle hooks Refactor: i18nBootSync accounting labels via app.py parameter injection Refactor: serviceHub moved to serviceCenter/serviceHub.py Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling Cleanup: remove obsolete methodTrustee, serviceExceptions shim Co-authored-by: Cursor <cursoragent@cursor.com>
949 lines
39 KiB
Python
949 lines
39 KiB
Python
"""
|
|
Handler functions for Real Estate feature routes.
|
|
Contains extracted business logic from route handlers.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import aiohttp
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
from modules.datamodels.datamodelPagination import (
|
|
PaginationParams,
|
|
PaginatedResponse,
|
|
PaginationMetadata,
|
|
)
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from .datamodelFeatureRealEstate import (
|
|
Projekt,
|
|
Parzelle,
|
|
Dokument,
|
|
DokumentTyp,
|
|
Gemeinde,
|
|
Kanton,
|
|
Land,
|
|
Kontext,
|
|
StatusProzess,
|
|
)
|
|
from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface
|
|
from .mainRealEstate import (
|
|
create_project_with_parcel_data,
|
|
extract_bzo_information,
|
|
)
|
|
from .parcelSelectionService import is_parcel_adjacent_to_selection
|
|
|
|
from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector
|
|
from modules.connectors.connectorOerebWfs import OerebWfsConnector
|
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
|
|
routeApiMsg = apiRouteContext("routeFeatureRealEstate")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# GEMEINDE / BZO HANDLERS
|
|
# ============================================================================
|
|
|
|
async def processGemeindenSync(interface, instanceId: str, mandateId: str, onlyCurrent: bool = True) -> Dict[str, Any]:
|
|
"""
|
|
Fetch all Gemeinden from Swiss Topo and save to DB for an instance.
|
|
Creates Kantone as needed.
|
|
"""
|
|
try:
|
|
oerebConnector = OerebWfsConnector()
|
|
connector = SwissTopoMapServerConnector(oereb_connector=oerebConnector)
|
|
gemeindenData = await connector.get_all_gemeinden(only_current=onlyCurrent)
|
|
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)}")
|
|
|
|
gemeindenCreated = 0
|
|
gemeindenSkipped = 0
|
|
kantoneCreated = 0
|
|
errors: List[str] = []
|
|
kantonCache: Dict[str, str] = {}
|
|
|
|
def _findGemeindeByBfsNummer(bfsNummer: 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(bfsNummer):
|
|
return g
|
|
except (json.JSONDecodeError, AttributeError):
|
|
continue
|
|
except Exception as ex:
|
|
logger.error(f"Error finding Gemeinde by BFS {bfsNummer}: {ex}", exc_info=True)
|
|
return None
|
|
|
|
def _getOrCreateKanton(kantonAbk: str) -> Optional[str]:
|
|
nonlocal kantoneCreated, errors
|
|
if not kantonAbk:
|
|
return None
|
|
if kantonAbk in kantonCache:
|
|
return kantonCache[kantonAbk]
|
|
kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kantonAbk})
|
|
if kantone:
|
|
kantonCache[kantonAbk] = kantone[0].id
|
|
return kantone[0].id
|
|
kantonNames = {
|
|
"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:
|
|
kantonLabel = kantonNames.get(kantonAbk, kantonAbk)
|
|
kanton = Kanton(
|
|
mandateId=mandateId,
|
|
featureInstanceId=instanceId,
|
|
label=kantonLabel,
|
|
abk=kantonAbk,
|
|
)
|
|
created = interface.createKanton(kanton)
|
|
if created and created.id:
|
|
kantonCache[kantonAbk] = created.id
|
|
kantoneCreated += 1
|
|
return created.id
|
|
except Exception as ex:
|
|
errors.append(f"Error creating Kanton {kantonAbk}: {ex}")
|
|
return None
|
|
|
|
savedGemeinden: List[Dict[str, Any]] = []
|
|
for gd in gemeindenData:
|
|
try:
|
|
gemeindeName = gd.get("name")
|
|
bfsNummer = gd.get("bfs_nummer")
|
|
kantonAbk = gd.get("kanton")
|
|
if not gemeindeName or bfsNummer is None:
|
|
gemeindenSkipped += 1
|
|
continue
|
|
existing = _findGemeindeByBfsNummer(str(bfsNummer))
|
|
if existing:
|
|
gemeindenSkipped += 1
|
|
savedGemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing)
|
|
continue
|
|
kantonId = _getOrCreateKanton(kantonAbk) if kantonAbk else None
|
|
gemeinde = Gemeinde(
|
|
mandateId=mandateId,
|
|
featureInstanceId=instanceId,
|
|
label=gemeindeName,
|
|
id_kanton=kantonId,
|
|
kontextInformationen=[
|
|
Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfsNummer}, ensure_ascii=False))
|
|
],
|
|
)
|
|
created = interface.createGemeinde(gemeinde)
|
|
if created and created.id:
|
|
gemeindenCreated += 1
|
|
savedGemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created)
|
|
else:
|
|
errors.append(f"Failed to create Gemeinde {gemeindeName}")
|
|
gemeindenSkipped += 1
|
|
except Exception as ex:
|
|
errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}")
|
|
gemeindenSkipped += 1
|
|
|
|
return {
|
|
"gemeinden": savedGemeinden,
|
|
"count": len(savedGemeinden),
|
|
"stats": {
|
|
"gemeinden_created": gemeindenCreated,
|
|
"gemeinden_skipped": gemeindenSkipped,
|
|
"kantone_created": kantoneCreated,
|
|
"error_count": len(errors),
|
|
"errors": errors[:10],
|
|
},
|
|
}
|
|
|
|
|
|
async def processBzoDocumentsFetch(interface, componentInterface, mandateId: str, instanceId: str) -> Dict[str, Any]:
|
|
"""Search for and download BZO documents for all Gemeinden of an instance."""
|
|
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:
|
|
docId = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None)
|
|
if docId:
|
|
gr["dokument_ids"].append(docId)
|
|
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}
|
|
|
|
|
|
async def processParcelDocuments(interface, componentInterface, gemeindeName: str, bauzone: str, mandateId: str, instanceId: str) -> 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.
|
|
"""
|
|
from modules.features.realEstate.realEstateGemeindeService import (
|
|
ensure_single_gemeinde,
|
|
fetch_bzo_for_gemeinde,
|
|
)
|
|
|
|
gemeindeObj = None
|
|
byLabel = interface.getGemeinden(recordFilter={"label": gemeindeName, "mandateId": mandateId})
|
|
gemeindeObj = byLabel[0] if byLabel else None
|
|
|
|
if not gemeindeObj:
|
|
allG = interface.getGemeinden(recordFilter={"mandateId": mandateId})
|
|
gNorm = gemeindeName.strip().lower()
|
|
for g in allG:
|
|
gl = (g.label or "").strip().lower()
|
|
if gl == gNorm or gNorm in gl or gl in gNorm:
|
|
gemeindeObj = g
|
|
logger.debug(f"parcel-documents: Found Gemeinde by label match '{gemeindeName}' -> '{g.label}'")
|
|
break
|
|
|
|
if gemeindeObj:
|
|
logger.debug(f"parcel-documents: Gemeinde '{gemeindeName}' resolved: {gemeindeObj.id}")
|
|
|
|
if not gemeindeObj:
|
|
logger.info(f"parcel-documents: No Gemeinde for label '{gemeindeName}', ensuring via Swiss Topo...")
|
|
gemeindeObj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeindeName)
|
|
|
|
if not gemeindeObj:
|
|
logger.warning(f"parcel-documents: Gemeinde '{gemeindeName}' nicht gefunden (mandateId={mandateId[:8]}...)")
|
|
return {"documents": [], "error": f"Gemeinde '{gemeindeName}' nicht gefunden"}
|
|
|
|
bzoDocs = []
|
|
if gemeindeObj.dokumente:
|
|
for doc in gemeindeObj.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"]:
|
|
docId = doc.id if hasattr(doc, "id") else doc.get("id")
|
|
if docId:
|
|
full = interface.getDokument(docId)
|
|
if full and full.dokumentReferenz:
|
|
bzoDocs.append(full)
|
|
|
|
if not bzoDocs:
|
|
logger.info(f"parcel-documents: No BZO for {gemeindeName}, fetching...")
|
|
fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeindeObj, mandateId, instanceId)
|
|
if fetched:
|
|
gemeindeObj = interface.getGemeinde(gemeindeObj.id)
|
|
if gemeindeObj and gemeindeObj.dokumente:
|
|
for doc in gemeindeObj.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]:
|
|
docId = doc.id if hasattr(doc, "id") else doc.get("id")
|
|
if docId:
|
|
full = interface.getDokument(docId)
|
|
if full and full.dokumentReferenz:
|
|
bzoDocs.append(full)
|
|
|
|
result = []
|
|
for d in bzoDocs:
|
|
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": gemeindeName, "bauzone": bauzone}
|
|
|
|
|
|
# ============================================================================
|
|
# LEGACY TABLE HANDLERS
|
|
# ============================================================================
|
|
|
|
def processTableData(user, mandateId: Optional[str], table: str, pagination: Optional[str]) -> PaginatedResponse:
|
|
"""Fetch and paginate table data for a real estate entity table."""
|
|
tableMapping = {
|
|
"Projekt": (Projekt, "getProjekte"),
|
|
"Parzelle": (Parzelle, "getParzellen"),
|
|
"Dokument": (Dokument, "getDokumente"),
|
|
"Gemeinde": (Gemeinde, "getGemeinden"),
|
|
"Kanton": (Kanton, "getKantone"),
|
|
"Land": (Land, "getLaender"),
|
|
}
|
|
|
|
if table not in tableMapping:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}"
|
|
)
|
|
|
|
realEstateInterface = getRealEstateInterface(user, mandateId=mandateId)
|
|
modelClass, methodName = tableMapping[table]
|
|
getterMethod = getattr(realEstateInterface, methodName)
|
|
items = getterMethod(recordFilter=None)
|
|
|
|
paginationParams = None
|
|
if pagination:
|
|
try:
|
|
paginationDict = json.loads(pagination)
|
|
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid pagination parameter: {str(e)}"
|
|
)
|
|
|
|
if paginationParams:
|
|
if paginationParams.sort:
|
|
for sortField in reversed(paginationParams.sort):
|
|
fieldName = sortField.field
|
|
direction = sortField.direction.lower()
|
|
|
|
def _sortKey(item, _fieldName=fieldName):
|
|
value = getattr(item, _fieldName, None)
|
|
if value is None:
|
|
return (1, None)
|
|
return (0, value)
|
|
|
|
items.sort(key=_sortKey, reverse=(direction == "desc"))
|
|
|
|
totalItems = len(items)
|
|
totalPages = (totalItems + paginationParams.pageSize - 1) // paginationParams.pageSize
|
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
|
endIdx = startIdx + paginationParams.pageSize
|
|
paginatedItems = items[startIdx:endIdx]
|
|
|
|
return PaginatedResponse(
|
|
items=paginatedItems,
|
|
pagination=PaginationMetadata(
|
|
currentPage=paginationParams.page,
|
|
pageSize=paginationParams.pageSize,
|
|
totalItems=totalItems,
|
|
totalPages=totalPages,
|
|
sort=paginationParams.sort,
|
|
filters=paginationParams.filters
|
|
)
|
|
)
|
|
|
|
return PaginatedResponse(items=items, pagination=None)
|
|
|
|
|
|
async def processCreateTableRecord(user, mandateId: Optional[str], table: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Create a record in a real estate table, with special handling for Projekt+parcel."""
|
|
if table == "Projekt" and ("parzelle" in data or "parzellen" in data):
|
|
logger.info(f"Creating Projekt with parcel data for user {user.id}")
|
|
|
|
label = data.get("label")
|
|
if not label:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("label is required")
|
|
)
|
|
|
|
statusProzess = data.get("statusProzess", "Eingang")
|
|
|
|
parzellenData = []
|
|
if "parzellen" in data:
|
|
parzellenData = data.get("parzellen", [])
|
|
if not isinstance(parzellenData, list):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("parzellen must be an array")
|
|
)
|
|
elif "parzelle" in data:
|
|
parzelleData = data.get("parzelle")
|
|
if parzelleData:
|
|
parzellenData = [parzelleData]
|
|
|
|
if not parzellenData:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("parzelle or parzellen data is required")
|
|
)
|
|
|
|
result = await create_project_with_parcel_data(
|
|
currentUser=user,
|
|
mandateId=mandateId,
|
|
projekt_label=label,
|
|
parzellen_data=parzellenData,
|
|
status_prozess=statusProzess,
|
|
)
|
|
return result.get("projekt", {})
|
|
|
|
tableMapping = {
|
|
"Projekt": (Projekt, "createProjekt"),
|
|
"Parzelle": (Parzelle, "createParzelle"),
|
|
"Dokument": (Dokument, "createDokument"),
|
|
"Gemeinde": (Gemeinde, "createGemeinde"),
|
|
"Kanton": (Kanton, "createKanton"),
|
|
"Land": (Land, "createLand"),
|
|
}
|
|
|
|
if table not in tableMapping:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}"
|
|
)
|
|
|
|
realEstateInterface = getRealEstateInterface(user, mandateId=mandateId)
|
|
modelClass, methodName = tableMapping[table]
|
|
createMethod = getattr(realEstateInterface, methodName)
|
|
|
|
if "mandateId" not in data:
|
|
data["mandateId"] = mandateId
|
|
|
|
try:
|
|
modelInstance = modelClass(**data)
|
|
except Exception as e:
|
|
logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid data for {table}: {str(e)}"
|
|
)
|
|
|
|
createdRecord = createMethod(modelInstance)
|
|
if hasattr(createdRecord, 'model_dump'):
|
|
return createdRecord.model_dump()
|
|
return createdRecord
|
|
|
|
|
|
# ============================================================================
|
|
# PARCEL SEARCH HANDLER
|
|
# ============================================================================
|
|
|
|
async def processParcelSearch(user, mandateId: Optional[str], location: str, includeBauzone: bool, includeAdjacent: bool) -> Dict[str, Any]:
|
|
"""
|
|
Search for parcel information by address or coordinates.
|
|
Resolves address, calculates geometry, optionally fetches adjacent parcels and bauzone.
|
|
"""
|
|
connector = SwissTopoMapServerConnector()
|
|
parcelData = await connector.search_parcel(location)
|
|
|
|
if not parcelData:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"No parcel found for location: {location}"
|
|
)
|
|
|
|
extractedAttributes = connector.extract_parcel_attributes(parcelData)
|
|
attributes = parcelData.get("attributes", {})
|
|
geometry = parcelData.get("geometry", {})
|
|
|
|
areaM2 = None
|
|
centroid = None
|
|
if extractedAttributes.get("perimeter"):
|
|
perimeter = extractedAttributes["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"]
|
|
areaM2 = abs(area / 2)
|
|
sumX = sum(p["x"] for p in points)
|
|
sumY = sum(p["y"] for p in points)
|
|
centroid = {"x": sumX / len(points), "y": sumY / len(points)}
|
|
|
|
canton = attributes.get("ak", "")
|
|
municipalityName = None
|
|
fullAddress = None
|
|
plz = None
|
|
|
|
geocodedAddress = parcelData.get('geocoded_address')
|
|
if geocodedAddress:
|
|
fullAddress = geocodedAddress.get('full_address')
|
|
plz = geocodedAddress.get('plz')
|
|
municipalityName = geocodedAddress.get('municipality')
|
|
logger.debug(f"Using geocoded address: {fullAddress}")
|
|
|
|
queryCoords = parcelData.get('query_coordinates')
|
|
addressQueryCoords = queryCoords if queryCoords else centroid
|
|
|
|
if not fullAddress and addressQueryCoords:
|
|
queryX = addressQueryCoords['x']
|
|
queryY = addressQueryCoords['y']
|
|
logger.debug(f"Querying address layer at query coordinates: ({queryX}, {queryY})")
|
|
|
|
isCoordinateSearch = ',' in location and not any(c.isalpha() for c in location.split(',')[0])
|
|
buildingTolerance = 1 if isCoordinateSearch else 10
|
|
buildingResult = await connector._query_building_layer(queryX, queryY, tolerance=buildingTolerance, buffer=25)
|
|
|
|
if buildingResult:
|
|
addrAttrs = buildingResult.get("attributes", {})
|
|
logger.debug(f"Address layer attributes: {addrAttrs}")
|
|
addressInfo = connector._extract_address_from_building_attrs(addrAttrs)
|
|
fullAddress = addressInfo.get('full_address')
|
|
plz = addressInfo.get('plz')
|
|
municipalityName = addressInfo.get('municipality')
|
|
if fullAddress:
|
|
logger.debug(f"Constructed address: {fullAddress}")
|
|
|
|
if not fullAddress:
|
|
if location and any(c.isalpha() for c in location) and "CH" not in location:
|
|
fullAddress = location
|
|
logger.debug(f"Using location as address: {fullAddress}")
|
|
|
|
if not municipalityName and fullAddress:
|
|
plzMunicipalityMatch = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", fullAddress)
|
|
if plzMunicipalityMatch:
|
|
extractedMunicipality = plzMunicipalityMatch.group(2).strip()
|
|
extractedMunicipality = re.sub(r"[,;\.]+$", "", extractedMunicipality).strip()
|
|
if extractedMunicipality:
|
|
municipalityName = extractedMunicipality
|
|
if not plz:
|
|
plz = plzMunicipalityMatch.group(1)
|
|
logger.debug(f"Extracted municipality from address: {municipalityName}")
|
|
|
|
bfsnr = attributes.get("bfsnr")
|
|
if not municipalityName and bfsnr and canton and mandateId:
|
|
try:
|
|
interface = getRealEstateInterface(user, mandateId=mandateId, featureInstanceId=None)
|
|
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):
|
|
bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code")
|
|
if str(bfs) == str(bfsnr):
|
|
municipalityName = g.label
|
|
logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipalityName}")
|
|
break
|
|
except (json.JSONDecodeError, AttributeError):
|
|
continue
|
|
if municipalityName:
|
|
break
|
|
except Exception as e:
|
|
logger.debug(f"Error querying Gemeinde by BFS: {e}")
|
|
|
|
if not municipalityName and centroid and canton:
|
|
try:
|
|
geocodeUrl = "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(geocodeUrl, params=params) as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json()
|
|
results = data.get("results", [])
|
|
if results:
|
|
attrs = results[0].get("attributes", {})
|
|
geoName = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label")
|
|
if geoName:
|
|
municipalityName = connector._clean_municipality_name(str(geoName))
|
|
logger.debug(f"Found municipality via Swiss Topo geocoding: {municipalityName}")
|
|
except Exception as e:
|
|
logger.debug(f"Error querying Swiss Topo geocoding: {e}")
|
|
|
|
if not municipalityName and bfsnr:
|
|
commonMunicipalities = {
|
|
261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur",
|
|
351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne",
|
|
1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz",
|
|
}
|
|
if bfsnr in commonMunicipalities:
|
|
municipalityName = commonMunicipalities[bfsnr]
|
|
logger.debug(f"Looked up municipality from common list: {municipalityName}")
|
|
elif canton and bfsnr:
|
|
municipalityName = f"{canton}-{bfsnr}"
|
|
logger.debug(f"Using fallback municipality: {municipalityName}")
|
|
|
|
if fullAddress and fullAddress.startswith("CH") and len(fullAddress) == 14 and fullAddress[2:].isdigit():
|
|
fullAddress = None
|
|
logger.debug("Removed EGRID from address field")
|
|
|
|
bauzone = None
|
|
hasGeometry = geometry and (geometry.get("rings") or geometry.get("coordinates"))
|
|
if includeBauzone and canton and hasGeometry and centroid:
|
|
try:
|
|
logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}")
|
|
oerebConnector = OerebWfsConnector()
|
|
zoneResults = await oerebConnector.query_zone_layer(
|
|
egrid=attributes.get("egris_egrid", "") or "",
|
|
x=centroid["x"],
|
|
y=centroid["y"],
|
|
canton=canton,
|
|
geometry=geometry,
|
|
)
|
|
if zoneResults and len(zoneResults) > 0:
|
|
zoneAttrs = zoneResults[0].get("attributes", {})
|
|
typGdeAbkuerzung = zoneAttrs.get("typ_gde_abkuerzung")
|
|
if typGdeAbkuerzung:
|
|
bauzone = typGdeAbkuerzung
|
|
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)
|
|
|
|
parcelInfo = {
|
|
"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": municipalityName,
|
|
"address": fullAddress,
|
|
"plz": plz,
|
|
"perimeter": extractedAttributes.get("perimeter"),
|
|
"area_m2": areaM2,
|
|
"centroid": centroid,
|
|
"geoportal_url": attributes.get("geoportal_url"),
|
|
"realestate_type": attributes.get("realestate_type"),
|
|
"bauzone": bauzone,
|
|
}
|
|
|
|
bbox = parcelData.get("bbox", [])
|
|
mapView = {
|
|
"center": centroid,
|
|
"zoom_bounds": {
|
|
"min_x": bbox[0] if len(bbox) >= 4 else None,
|
|
"min_y": bbox[1] if len(bbox) >= 4 else None,
|
|
"max_x": bbox[2] if len(bbox) >= 4 else None,
|
|
"max_y": bbox[3] if len(bbox) >= 4 else None
|
|
},
|
|
"geometry_geojson": {
|
|
"type": "Feature",
|
|
"geometry": {
|
|
"type": "Polygon",
|
|
"coordinates": [
|
|
[[p["x"], p["y"]] for p in extractedAttributes["perimeter"]["punkte"]]
|
|
] if extractedAttributes.get("perimeter") else []
|
|
},
|
|
"properties": {
|
|
"id": parcelInfo["id"],
|
|
"egrid": parcelInfo["egrid"],
|
|
"number": parcelInfo["number"]
|
|
}
|
|
}
|
|
}
|
|
|
|
responseData = {
|
|
"parcel": parcelInfo,
|
|
"map_view": mapView
|
|
}
|
|
|
|
if includeAdjacent and parcelData and parcelData.get("geometry"):
|
|
try:
|
|
selectedParcelId = parcelInfo["id"]
|
|
adjacentParcelsRaw = await connector.find_neighboring_parcels(
|
|
parcel_data=parcelData,
|
|
selected_parcel_id=selectedParcelId,
|
|
sample_distance=20.0,
|
|
max_sample_points=30,
|
|
max_neighbors=15,
|
|
max_concurrent=50,
|
|
)
|
|
adjacentParcels = [_convertParcelGeometry(adjParcel) for adjParcel in adjacentParcelsRaw]
|
|
responseData["adjacent_parcels"] = adjacentParcels
|
|
logger.info(f"Found {len(adjacentParcels)} neighboring parcels for parcel {selectedParcelId}")
|
|
except Exception as e:
|
|
logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True)
|
|
responseData["adjacent_parcels"] = []
|
|
|
|
return responseData
|
|
|
|
|
|
def _convertParcelGeometry(adjParcel: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Convert an adjacent parcel to include GeoJSON geometry."""
|
|
adjParcelWithGeo = {
|
|
"id": adjParcel["id"],
|
|
"egrid": adjParcel.get("egrid"),
|
|
"number": adjParcel.get("number"),
|
|
"perimeter": adjParcel.get("perimeter")
|
|
}
|
|
|
|
adjGeometry = adjParcel.get("geometry")
|
|
adjPerimeter = adjParcel.get("perimeter")
|
|
|
|
if adjGeometry:
|
|
if "rings" in adjGeometry and adjGeometry["rings"]:
|
|
ring = adjGeometry["rings"][0]
|
|
coordinates = [[[p[0], p[1]] for p in ring]]
|
|
adjParcelWithGeo["geometry_geojson"] = {
|
|
"type": "Feature",
|
|
"geometry": {"type": "Polygon", "coordinates": coordinates},
|
|
"properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")}
|
|
}
|
|
elif adjGeometry.get("type") == "Polygon":
|
|
adjParcelWithGeo["geometry_geojson"] = {
|
|
"type": "Feature",
|
|
"geometry": adjGeometry,
|
|
"properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")}
|
|
}
|
|
|
|
if "geometry_geojson" not in adjParcelWithGeo and adjPerimeter and adjPerimeter.get("punkte"):
|
|
punkte = adjPerimeter["punkte"]
|
|
coordinates = [[[p["x"], p["y"]] for p in punkte]]
|
|
adjParcelWithGeo["geometry_geojson"] = {
|
|
"type": "Feature",
|
|
"geometry": {"type": "Polygon", "coordinates": coordinates},
|
|
"properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")}
|
|
}
|
|
|
|
return adjParcelWithGeo
|
|
|
|
|
|
# ============================================================================
|
|
# ADD ADJACENT PARCEL HANDLER
|
|
# ============================================================================
|
|
|
|
async def processAddAdjacentParcel(location: Dict[str, Any], selectedParcels: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""
|
|
Add an adjacent parcel to the selection. Validates adjacency.
|
|
Returns parcel response with geometry.
|
|
"""
|
|
locStr = f"{location['x']},{location['y']}"
|
|
connector = SwissTopoMapServerConnector()
|
|
parcelData = await connector.search_parcel(locStr)
|
|
|
|
if not parcelData:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=routeApiMsg("No parcel found at this location")
|
|
)
|
|
|
|
extracted = connector.extract_parcel_attributes(parcelData)
|
|
attributes = parcelData.get("attributes", {})
|
|
geometry = parcelData.get("geometry", {})
|
|
|
|
areaM2 = 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"]
|
|
areaM2 = abs(area / 2)
|
|
sumX = sum(p["x"] for p in points)
|
|
sumY = sum(p["y"] for p in points)
|
|
centroid = {"x": sumX / len(points), "y": sumY / len(points)}
|
|
|
|
parcelInfo = {
|
|
"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": areaM2,
|
|
"centroid": centroid,
|
|
"geoportal_url": attributes.get("geoportal_url"),
|
|
"realestate_type": attributes.get("realestate_type"),
|
|
"bauzone": None,
|
|
}
|
|
|
|
mapView = {
|
|
"center": centroid,
|
|
"zoom_bounds": parcelData.get("bbox", []) and {
|
|
"min_x": parcelData["bbox"][0],
|
|
"min_y": parcelData["bbox"][1],
|
|
"max_x": parcelData["bbox"][2],
|
|
"max_y": parcelData["bbox"][3],
|
|
} or None,
|
|
"geometry_geojson": _buildGeometryGeojson(extracted, parcelInfo),
|
|
}
|
|
|
|
newParcelResponse = {"parcel": parcelInfo, "map_view": mapView}
|
|
|
|
if not is_parcel_adjacent_to_selection(newParcelResponse, selectedParcels):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden")
|
|
)
|
|
|
|
bbox = parcelData.get("bbox", [])
|
|
mapView["zoom_bounds"] = {
|
|
"min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3]
|
|
} if len(bbox) >= 4 else None
|
|
|
|
geocodedAddress = parcelData.get("geocoded_address")
|
|
if geocodedAddress:
|
|
parcelInfo["municipality_name"] = geocodedAddress.get("municipality")
|
|
parcelInfo["address"] = geocodedAddress.get("full_address")
|
|
parcelInfo["plz"] = geocodedAddress.get("plz")
|
|
|
|
if centroid and attributes.get("ak"):
|
|
try:
|
|
oereb = OerebWfsConnector()
|
|
zoneResults = await oereb.query_zone_layer(
|
|
egrid=attributes.get("egris_egrid", "") or "",
|
|
x=centroid["x"], y=centroid["y"],
|
|
canton=attributes.get("ak"),
|
|
geometry=geometry,
|
|
)
|
|
if zoneResults and len(zoneResults) > 0:
|
|
parcelInfo["bauzone"] = zoneResults[0].get("attributes", {}).get("typ_gde_abkuerzung")
|
|
except Exception as oe:
|
|
logger.debug(f"ÖREB zone query failed: {oe}")
|
|
|
|
return newParcelResponse
|
|
|
|
|
|
def _buildGeometryGeojson(extracted: Dict[str, Any], parcelInfo: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Build geometry_geojson from extracted perimeter."""
|
|
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": parcelInfo["id"], "egrid": parcelInfo["egrid"], "number": parcelInfo["number"]},
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# ADD PARCEL TO PROJECT HANDLER
|
|
# ============================================================================
|
|
|
|
async def processAddParcelToProject(user, mandateId: Optional[str], projektId: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Add a parcel to an existing project.
|
|
Supports linking existing, creating from location, or creating from custom data.
|
|
"""
|
|
realEstateInterface = getRealEstateInterface(user, mandateId=mandateId)
|
|
|
|
recordFilter = {"id": projektId}
|
|
if mandateId:
|
|
recordFilter["mandateId"] = mandateId
|
|
projekte = realEstateInterface.getProjekte(recordFilter=recordFilter)
|
|
if not projekte:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Projekt {projektId} not found"
|
|
)
|
|
projekt = projekte[0]
|
|
|
|
parcelId = body.get("parcelId")
|
|
location = body.get("location")
|
|
parcelDataDict = body.get("parcelData")
|
|
parzelle = None
|
|
|
|
if parcelId:
|
|
logger.info(f"Linking existing parcel {parcelId}")
|
|
parcelFilter = {"id": parcelId}
|
|
if mandateId:
|
|
parcelFilter["mandateId"] = mandateId
|
|
parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter)
|
|
if not parcels:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Parzelle {parcelId} not found"
|
|
)
|
|
parzelle = parcels[0]
|
|
|
|
elif location:
|
|
logger.info(f"Creating parcel from location: {location}")
|
|
connector = SwissTopoMapServerConnector()
|
|
parcelData = await connector.search_parcel(location)
|
|
if not parcelData:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"No parcel found at location: {location}"
|
|
)
|
|
extractedAttributes = connector.extract_parcel_attributes(parcelData)
|
|
attributes = parcelData.get("attributes", {})
|
|
|
|
parzelleCreateData = {
|
|
"mandateId": mandateId,
|
|
"label": extractedAttributes.get("label") or attributes.get("number") or "Unknown",
|
|
"parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [],
|
|
"eigentuemerschaft": None,
|
|
"strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None,
|
|
"plz": None,
|
|
"perimeter": extractedAttributes.get("perimeter"),
|
|
"baulinie": None,
|
|
"kontextGemeinde": None,
|
|
"bauzone": None,
|
|
"az": None,
|
|
"bz": None,
|
|
"vollgeschossZahl": None,
|
|
"anrechenbarDachgeschoss": None,
|
|
"anrechenbarUntergeschoss": None,
|
|
"gebaeudehoeheMax": None,
|
|
"regelnGrenzabstand": [],
|
|
"regelnMehrlaengenzuschlag": [],
|
|
"regelnMehrhoehenzuschlag": [],
|
|
"parzelleBebaut": None,
|
|
"parzelleErschlossen": None,
|
|
"parzelleHanglage": None,
|
|
"laermschutzzone": None,
|
|
"hochwasserschutzzone": None,
|
|
"grundwasserschutzzone": None,
|
|
"parzellenNachbarschaft": [],
|
|
"dokumente": [],
|
|
"kontextInformationen": [
|
|
Kontext(
|
|
thema="Swiss Topo Data",
|
|
inhalt=json.dumps({
|
|
"egrid": attributes.get("egris_egrid"),
|
|
"identnd": attributes.get("identnd"),
|
|
"canton": attributes.get("ak"),
|
|
"municipality_code": attributes.get("bfsnr"),
|
|
"geoportal_url": attributes.get("geoportal_url")
|
|
}, ensure_ascii=False)
|
|
)
|
|
]
|
|
}
|
|
parzelleInstance = Parzelle(**parzelleCreateData)
|
|
parzelle = realEstateInterface.createParzelle(parzelleInstance)
|
|
|
|
elif parcelDataDict:
|
|
logger.info(f"Creating parcel from custom data")
|
|
parcelDataDict["mandateId"] = mandateId
|
|
parzelleInstance = Parzelle(**parcelDataDict)
|
|
parzelle = realEstateInterface.createParzelle(parzelleInstance)
|
|
|
|
else:
|
|
raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required")
|
|
|
|
if parzelle not in projekt.parzellen:
|
|
projekt.parzellen.append(parzelle)
|
|
|
|
if not projekt.perimeter and parzelle.perimeter:
|
|
projekt.perimeter = parzelle.perimeter
|
|
|
|
updatedProjekt = realEstateInterface.updateProjekt(projekt)
|
|
logger.info(f"Added Parzelle {parzelle.id} to Projekt {projektId}")
|
|
|
|
return {
|
|
"projekt": updatedProjekt.model_dump(),
|
|
"parzelle": parzelle.model_dump()
|
|
}
|