fix:user invitations

This commit is contained in:
Ida Dittrich 2026-02-16 09:30:21 +01:00
parent 856b9f3c05
commit 2c1927663d
3 changed files with 162 additions and 54 deletions

View file

@ -7,8 +7,10 @@ import asyncio
import hashlib import hashlib
import json import json
import logging import logging
import re
import ssl import ssl
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
from urllib.parse import urljoin, urlparse
import aiohttp import aiohttp
@ -28,12 +30,26 @@ KANTON_NAMES = {
"VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich", "VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich",
} }
# Quartier/place names -> politische Gemeinde (Swiss Topo geocoding returns quarter names)
# Prevents wrong matches like "Enge" -> Martherenges instead of Zürich
QUARTIER_TO_GEMEINDE: Dict[str, str] = {
"enge": "Zürich", # Kreis 2 Enge (Zürich)
"aussersihl": "Zürich",
"wiedikon": "Zürich",
}
# Known direct BZO PDF URLs for municipalities (by normalized name, lowercase) # Known direct BZO PDF URLs for municipalities (by normalized name, lowercase)
# These are tried first to avoid SSL/HTML issues with Tavily search results # Used when Tavily returns no matching PDFs; avoids SSL/HTML issues with Tavily results
KNOWN_BZO_PDF_URLS: Dict[str, str] = { # Uster: _docn shows HTML "Erlass ausser Kraft" page; _rtr/dokument_xxx serves the actual PDF
"schlieren": "https://www.schlieren.ch/_docn/6239470/SKR_10.10_Bauordnung.pdf", KNOWN_BZO_PDF_URLS: Dict[str, List[str]] = {
"zürich": "https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf", "schlieren": ["https://www.schlieren.ch/_docn/6239470/SKR_10.10_Bauordnung.pdf"],
"zurich": "https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf", "uster": [
"https://www.uster.ch/_rtr/dokument_3619802", # Direct document (PDF)
"https://www.uster.ch/_docn/3619802/Bau-und-Zonenordnung-teilrevidiert-2021.pdf", # May return HTML first
],
"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"],
"zuerich": ["https://www.stadt-zuerich.ch/content/dam/stzh/portal/Deutsch/AmtlicheSammlung/Erlasse/700/100/700.100%20Bau-%20und%20Zonenordnung%20V2.pdf"],
} }
@ -119,9 +135,15 @@ async def ensure_single_gemeinde(
""" """
if not gemeinde_name or not gemeinde_name.strip(): if not gemeinde_name or not gemeinde_name.strip():
return None return None
# Resolve Quartier/place names to politische Gemeinde (e.g. Enge -> Zürich)
lookup_name = gemeinde_name.strip()
quartier_key = _normalize_gemeinde_for_match(lookup_name)
if quartier_key and quartier_key in QUARTIER_TO_GEMEINDE:
lookup_name = QUARTIER_TO_GEMEINDE[quartier_key]
logger.debug(f"Mapped Quartier '{gemeinde_name}' -> Gemeinde '{lookup_name}'")
try: try:
connector = SwissTopoMapServerConnector() connector = SwissTopoMapServerConnector()
gd = await connector.get_gemeinde_by_name(gemeinde_name) gd = await connector.get_gemeinde_by_name(lookup_name)
except Exception as e: except Exception as e:
logger.error(f"Error fetching Gemeinde '{gemeinde_name}' from Swiss Topo: {e}", exc_info=True) logger.error(f"Error fetching Gemeinde '{gemeinde_name}' from Swiss Topo: {e}", exc_info=True)
return None return None
@ -207,9 +229,11 @@ async def fetch_bzo_for_gemeinde(
Deduplication: re-fetches Gemeinde, skips if BZO exists, skips URLs we already have, 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. creates at most 1 new document per call to avoid duplicates from multiple Tavily URLs.
""" """
logger.info(f"fetch_bzo_for_gemeinde: starting for {gemeinde.label} (id={gemeinde.id})")
# Re-fetch Gemeinde to get latest dokumente (avoid race with concurrent requests) # Re-fetch Gemeinde to get latest dokumente (avoid race with concurrent requests)
fresh = interface.getGemeinde(gemeinde.id) fresh = interface.getGemeinde(gemeinde.id)
if not fresh: if not fresh:
logger.warning(f"fetch_bzo_for_gemeinde: Gemeinde {gemeinde.id} not found after refresh")
return False return False
gemeinde = fresh gemeinde = fresh
@ -223,12 +247,19 @@ async def fetch_bzo_for_gemeinde(
if q: if q:
existing_quellen.add(q) existing_quellen.add(q)
if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]: if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]:
existing_bzo = True doc_id = doc.id if hasattr(doc, "id") else doc.get("id")
break full = interface.getDokument(doc_id) if doc_id else None
if full and full.dokumentReferenz:
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")): 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 doc_id = doc.id if hasattr(doc, "id") else doc.get("id")
break full = interface.getDokument(doc_id) if doc_id else None
if full and full.dokumentReferenz:
existing_bzo = True
break
if existing_bzo: if existing_bzo:
logger.info(f"fetch_bzo_for_gemeinde: {gemeinde.label} already has BZO document(s), skipping")
return True return True
kanton_abk = None kanton_abk = None
@ -275,6 +306,15 @@ async def fetch_bzo_for_gemeinde(
if (r.url.lower().endswith(".pdf") or "/pdf" in r.url.lower()) if (r.url.lower().endswith(".pdf") or "/pdf" in r.url.lower())
and _is_valid_bzo_result(r.url, r.title or "") and _is_valid_bzo_result(r.url, r.title or "")
] ]
# If Tavily returned nothing useful, try known direct PDF URLs (Uster, Schlieren, etc.)
gemeinde_key = _normalize_gemeinde_for_match(gemeinde.label or "")
gemeinde_key_alt = gemeinde.label.strip().lower() if gemeinde.label else ""
if not pdf_urls and (gemeinde_key in KNOWN_BZO_PDF_URLS or gemeinde_key_alt in KNOWN_BZO_PDF_URLS):
key = gemeinde_key if gemeinde_key in KNOWN_BZO_PDF_URLS else gemeinde_key_alt
pdf_urls = list(KNOWN_BZO_PDF_URLS[key])
logger.info(f"Using known BZO PDF URL for {gemeinde.label} (no Tavily matches)")
if not pdf_urls: if not pdf_urls:
logger.warning( logger.warning(
f"No PDF URLs with matching Gemeinde name for {gemeinde.label} " f"No PDF URLs with matching Gemeinde name for {gemeinde.label} "
@ -282,12 +322,11 @@ async def fetch_bzo_for_gemeinde(
) )
return False return False
# Prepend known direct PDF URLs for this Gemeinde (avoids SSL/HTML issues with Tavily results) # Prepend known direct PDF URL when available (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: if gemeinde_key and gemeinde_key in KNOWN_BZO_PDF_URLS:
known_url = KNOWN_BZO_PDF_URLS[gemeinde_key] known_urls = KNOWN_BZO_PDF_URLS[gemeinde_key]
pdf_urls = [known_url] + [u for u in pdf_urls if u != known_url] pdf_urls = list(known_urls) + [u for u in pdf_urls if u not in known_urls]
logger.info(f"Using known BZO PDF URL for {gemeinde.label}") logger.info(f"Preferring known BZO PDF URL for {gemeinde.label}")
# Use ssl.CERT_NONE to avoid CERTIFICATE_VERIFY_FAILED on Windows/corporate environments # Use ssl.CERT_NONE to avoid CERTIFICATE_VERIFY_FAILED on Windows/corporate environments
# (same approach as routeRealEstate for external HTTP requests) # (same approach as routeRealEstate for external HTTP requests)
@ -295,10 +334,38 @@ async def fetch_bzo_for_gemeinde(
ssl_context.check_hostname = False ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE ssl_context.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_context) connector = aiohttp.TCPConnector(ssl=ssl_context)
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/pdf,*/*"} # Use Accept: application/pdf first to encourage direct PDF delivery (e.g. uster.ch)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/pdf,application/octet-stream,*/*",
}
timeout = aiohttp.ClientTimeout(total=30) timeout = aiohttp.ClientTimeout(total=30)
async def download_pdf(session: aiohttp.ClientSession, url: str) -> Optional[bytes]: def _extract_document_url_from_html(html_bytes: bytes, base_url: str) -> Optional[str]:
"""Extract document/PDF URL from HTML (e.g. uster.ch 'Weiter' page)."""
try:
text = html_bytes.decode("utf-8", errors="ignore")
# Swiss municipal doc systems: _rtr/dokument_xxx, .pdf links, or _docn redirect targets
for pat in (
r'href=["\']([^"\']*(?:/_rtr/dokument[_\w]*|dokument_\d+)[^"\']*)["\']',
r'href=["\']([^"\']+\.pdf(?:\?[^"\']*)?)["\']',
r'action=["\']([^"\']+\.pdf[^"\']*)["\']',
):
m = re.search(pat, text, re.I)
if m:
raw = m.group(1).strip()
if raw and not raw.startswith("#") and not raw.lower().startswith("javascript:"):
next_url = urljoin(base_url, raw)
parsed = urlparse(next_url)
if parsed.netloc and parsed.scheme:
return next_url
except Exception:
pass
return None
async def download_pdf(
session: aiohttp.ClientSession, url: str, _followed_from_html: bool = False
) -> Optional[bytes]:
for attempt in range(3): for attempt in range(3):
try: try:
async with session.get(url, allow_redirects=True) as resp: async with session.get(url, allow_redirects=True) as resp:
@ -306,7 +373,11 @@ async def fetch_bzo_for_gemeinde(
data = await resp.read() data = await resp.read()
if data and len(data) >= 100 and data.startswith(b"%PDF"): if data and len(data) >= 100 and data.startswith(b"%PDF"):
return data return data
if data.startswith(b"<") or data.startswith(b"<!DOCTYPE"): if (data.startswith(b"<") or data.startswith(b"<!DOCTYPE")) and not _followed_from_html:
fallback = _extract_document_url_from_html(data, url)
if fallback and fallback != url:
logger.debug(f"HTML from {url[:60]}..., following link to document")
return await download_pdf(session, fallback, _followed_from_html=True)
raise Exception("Server returned HTML instead of PDF") raise Exception("Server returned HTML instead of PDF")
elif resp.status == 406 and attempt < 2: elif resp.status == 406 and attempt < 2:
await asyncio.sleep(2) await asyncio.sleep(2)

View file

@ -703,8 +703,22 @@ async def get_parcel_documents(
by_label = interface.getGemeinden(recordFilter={"label": gemeinde, "mandateId": mandateId}) by_label = interface.getGemeinden(recordFilter={"label": gemeinde, "mandateId": mandateId})
gemeinde_obj = by_label[0] if by_label else None gemeinde_obj = by_label[0] if by_label else None
if not gemeinde_obj: if not gemeinde_obj:
# Fallback: match by normalized label (e.g. DB has "Stadt Uster", request has "Uster")
all_g = interface.getGemeinden(recordFilter={"mandateId": mandateId})
g_norm = gemeinde.strip().lower()
for g in all_g:
gl = (g.label or "").strip().lower()
if gl == g_norm or g_norm in gl or gl in g_norm:
gemeinde_obj = g
logger.debug(f"parcel-documents: Found Gemeinde by label match '{gemeinde}' -> '{g.label}'")
break
if gemeinde_obj:
logger.debug(f"parcel-documents: Gemeinde '{gemeinde}' resolved: {gemeinde_obj.id}")
if not gemeinde_obj:
logger.info(f"parcel-documents: No Gemeinde for label '{gemeinde}', ensuring via Swiss Topo...")
gemeinde_obj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeinde) gemeinde_obj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeinde)
if not gemeinde_obj: if not gemeinde_obj:
logger.warning(f"parcel-documents: Gemeinde '{gemeinde}' nicht gefunden (mandateId={mandateId[:8]}...)")
return {"documents": [], "error": f"Gemeinde '{gemeinde}' nicht gefunden"} return {"documents": [], "error": f"Gemeinde '{gemeinde}' nicht gefunden"}
bzo_docs = [] bzo_docs = []
if gemeinde_obj.dokumente: if gemeinde_obj.dokumente:
@ -717,6 +731,7 @@ async def get_parcel_documents(
if full and full.dokumentReferenz: if full and full.dokumentReferenz:
bzo_docs.append(full) bzo_docs.append(full)
if not bzo_docs: if not bzo_docs:
logger.info(f"parcel-documents: No BZO for {gemeinde}, fetching...")
fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeinde_obj, mandateId, instanceId) fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeinde_obj, mandateId, instanceId)
if fetched: if fetched:
gemeinde_obj = interface.getGemeinde(gemeinde_obj.id) gemeinde_obj = interface.getGemeinde(gemeinde_obj.id)

View file

@ -37,13 +37,14 @@ router = APIRouter(
class InvitationCreate(BaseModel): class InvitationCreate(BaseModel):
"""Request model for creating an invitation. """Request model for creating an invitation.
Invitations are feature-instance-level: the user selects a feature instance and Supports two modes:
instance-level roles. The mandateId is derived from the feature instance automatically. - Mandate-level: featureInstanceId omitted, roleIds are mandate-level roles (user, viewer, admin)
- Feature-instance-level: featureInstanceId required, roleIds are instance-level roles
""" """
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)") targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
email: Optional[str] = Field(None, description="Email address to send invitation link (optional)") email: Optional[str] = Field(None, description="Email address to send invitation link (optional)")
featureInstanceId: str = Field(..., description="Feature instance to grant access to") featureInstanceId: Optional[str] = Field(None, description="Feature instance to grant access to (optional for mandate-level invitations)")
roleIds: List[str] = Field(..., description="Instance-level role IDs to assign to the invited user") roleIds: List[str] = Field(..., description="Role IDs: mandate-level (user, viewer, admin) or instance-level")
frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)") frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)")
expiresInHours: int = Field( expiresInHours: int = Field(
72, 72,
@ -117,23 +118,62 @@ def create_invitation(
try: try:
rootInterface = getRootInterface() rootInterface = getRootInterface()
# Validate feature instance exists and get mandateId from it # Determine mandateId and validate
instance = rootInterface.getFeatureInstance(data.featureInstanceId) if data.featureInstanceId:
if not instance: # Feature-instance-level invitation
raise HTTPException( instance = rootInterface.getFeatureInstance(data.featureInstanceId)
status_code=status.HTTP_404_NOT_FOUND, if not instance:
detail=f"Feature instance '{data.featureInstanceId}' not found" raise HTTPException(
) status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{data.featureInstanceId}' not found"
)
mandateId = str(instance.mandateId)
# Validate roles belong to this feature instance
for roleId in data.roleIds:
role = rootInterface.getRole(roleId)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{roleId}' not found"
)
if str(role.featureInstanceId or "") != data.featureInstanceId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' does not belong to feature instance '{data.featureInstanceId}'"
)
else:
# Mandate-level invitation (user, viewer, admin roles)
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required for mandate-level invitations"
)
mandateId = str(context.mandateId)
# Validate roles are mandate-level (no featureInstanceId)
for roleId in data.roleIds:
role = rootInterface.getRole(roleId)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{roleId}' not found"
)
if role.featureInstanceId is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' is an instance-level role; use mandate-level roles (user, viewer, admin) for mandate invitations"
)
if str(role.mandateId or "") != mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' does not belong to mandate"
)
mandateId = str(instance.mandateId) # Check admin permission
# Check admin permission: SysAdmin can invite for any mandate,
# MandateAdmin can invite for their own mandate
if not context.hasSysAdminRole: if not context.hasSysAdminRole:
if str(context.mandateId) != mandateId: if str(context.mandateId) != mandateId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Feature instance belongs to a different mandate" detail="Access denied to this mandate"
) )
if not _hasMandateAdminRole(context): if not _hasMandateAdminRole(context):
raise HTTPException( raise HTTPException(
@ -141,32 +181,14 @@ def create_invitation(
detail="Mandate-Admin role required to create invitations" detail="Mandate-Admin role required to create invitations"
) )
# Note: targetUsername does NOT need to exist yet!
# The invitation can be for a user who will register later.
# Validate role IDs exist and belong to this feature instance
for roleId in data.roleIds:
role = rootInterface.getRole(roleId)
if not role:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{roleId}' not found"
)
# Role must belong to this feature instance
if str(role.featureInstanceId or "") != data.featureInstanceId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' does not belong to feature instance '{data.featureInstanceId}'"
)
# Calculate expiration time # Calculate expiration time
currentTime = getUtcTimestamp() currentTime = getUtcTimestamp()
expiresAt = currentTime + (data.expiresInHours * 3600) expiresAt = currentTime + (data.expiresInHours * 3600)
# Create invitation (mandateId derived from feature instance) # Create invitation
invitation = Invitation( invitation = Invitation(
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=data.featureInstanceId, featureInstanceId=data.featureInstanceId or None,
roleIds=data.roleIds, roleIds=data.roleIds,
targetUsername=data.targetUsername, targetUsername=data.targetUsername,
email=data.email, email=data.email,