Merge pull request #85 from valueonag/feat/real-estate

Feat/real estate
This commit is contained in:
Patrick Motsch 2026-01-11 15:12:16 +01:00 committed by GitHub
commit abbed64463
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 6167 additions and 33 deletions

3
app.py
View file

@ -415,6 +415,9 @@ app.include_router(workflowRouter)
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
app.include_router(chatPlaygroundRouter)
from modules.routes.routeRealEstate import router as realEstateRouter
app.include_router(realEstateRouter)
from modules.routes.routeSecurityLocal import router as localRouter
app.include_router(localRouter)

View file

@ -34,4 +34,11 @@ Web_Crawl_RETRY_DELAY = 2
# Web Research configuration
Web_Research_MAX_DEPTH = 2
Web_Research_MAX_LINKS_PER_DOMAIN = 4
Web_Research_CRAWL_TIMEOUT_MINUTES = 10
Web_Research_CRAWL_TIMEOUT_MINUTES = 10
# STAC API Connector configuration (Swiss Topo)
Connector_StacSwisstopo_BASE_URL = https://data.geo.admin.ch/api/stac/v1
Connector_StacSwisstopo_TIMEOUT = 30
Connector_StacSwisstopo_MAX_RETRIES = 3
Connector_StacSwisstopo_RETRY_DELAY = 1.0
Connector_StacSwisstopo_ENABLE_CACHE = True

View file

@ -29,6 +29,13 @@ DB_MANAGEMENT_USER=poweron_dev
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
DB_MANAGEMENT_PORT=5432
# PostgreSQL Storage (new)
DB_REALESTATE_HOST=localhost
DB_REALESTATE_DATABASE=poweron_realestate
DB_REALESTATE_USER=poweron_dev
DB_REALESTATE_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
DB_REALESTATE_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
APP_TOKEN_EXPIRY=300

View file

@ -67,7 +67,17 @@ def _get_model_fields(model_class) -> Dict[str, str]:
"messages",
"stats",
"tasks",
"perimeter", # GeoPolylinie objects
"baulinie", # GeoPolylinie objects
"kontextInformationen", # List of Kontext objects
"parzellenNachbarschaft", # List of dictionaries
"dokumente", # List of Dokument objects
"parzellen", # List of Parzelle objects (in Projekt)
]
# Check if field type is a Pydantic BaseModel (for nested models like GeoPolylinie)
or (hasattr(field_type, "__origin__") and get_origin(field_type) is Union
and any(hasattr(arg, "__bases__") and BaseModel in getattr(arg, "__bases__", ())
for arg in get_args(field_type)))
):
fields[field_name] = "JSONB"
# Simple type mapping

File diff suppressed because it is too large Load diff

View file

@ -80,3 +80,28 @@ class PaginatedResponse(BaseModel, Generic[T]):
model_config = ConfigDict(arbitrary_types_allowed=True)
def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize pagination dictionary to handle frontend variations.
Moves top-level "search" field into filters if present.
Args:
pagination_dict: Raw pagination dictionary from frontend
Returns:
Normalized pagination dictionary ready for PaginationParams parsing
"""
if not pagination_dict:
return pagination_dict
# Create a copy to avoid modifying the original
normalized = dict(pagination_dict)
# Move top-level "search" into filters if present
if "search" in normalized:
if "filters" not in normalized or normalized["filters"] is None:
normalized["filters"] = {}
normalized["filters"]["search"] = normalized.pop("search")
return normalized

View file

@ -0,0 +1,667 @@
"""
Real Estate data models for Architektur-Planungs-App.
Implements a general Swiss architecture planning data model.
(PEK is one example implementation, but the model is general-purpose)
"""
from typing import List, Dict, Any, Optional, ForwardRef
from enum import Enum
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid
# ===== Enums =====
class StatusProzess(str, Enum):
"""Project process status"""
EINGANG = "Eingang"
ANALYSE = "Analyse"
STUDIE = "Studie"
PLANUNG = "Planung"
BAURECHTSVERFAHREN = "Baurechtsverfahren"
UMSETZUNG = "Umsetzung"
ARCHIV = "Archiv"
class DokumentTyp(str, Enum):
"""Document type for categorization"""
KANTON_BAUREGLEMENT_AKTUELL = "kantonBaureglementAktuell"
KANTON_BAUREGLEMENT_REVISION = "kantonBaureglementRevision"
KANTON_BAUVERORDNUNG_AKTUELL = "kantonBauverordnungAktuell"
KANTON_BAUVERORDNUNG_REVISION = "kantonBauverordnungRevision"
GEMEINDE_BZO_AKTUELL = "gemeindeBzoAktuell"
GEMEINDE_BZO_REVISION = "gemeindeBzoRevision"
class JaNein(str, Enum):
"""Three-valued state for optional yes/no questions"""
UNBEKANNT = "" # Empty string for unknown/not captured
JA = "Ja"
NEIN = "Nein"
class GeoTag(str, Enum):
"""Geopoint categories"""
K1 = "K1" # Fixpunkt höchster Genauigkeit
K2 = "K2" # Fixpunkt mittlerer Genauigkeit
K3 = "K3" # Fixpunkt niedriger Genauigkeit
GEOMETER = "Geometer" # Vom Geometer vermessener Punkt
# ===== Helper Models (must be defined before main models) =====
class GeoPunkt(BaseModel):
"""Represents a 3D point with reference."""
koordinatensystem: str = Field(
description="Coordinate system (e.g. 'LV95', 'EPSG:2056')",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
x: float = Field(
description="East value (E) [m], typically 2'480'000 - 2'840'000",
frontend_type="number",
frontend_readonly=False,
frontend_required=True,
)
y: float = Field(
description="North value (N) [m], typically 1'070'000 - 1'300'000",
frontend_type="number",
frontend_readonly=False,
frontend_required=True,
)
z: Optional[float] = Field(
None,
description="Height above sea level [m]",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
referenz: Optional[GeoTag] = Field(
None,
description="Point categorization",
frontend_type="select",
frontend_readonly=False,
frontend_required=False,
)
class GeoPolylinie(BaseModel):
"""Represents a line or polygon from multiple GeoPunkte."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
)
closed: bool = Field(
description="Is the GeoPolylinie closed (polygon)?",
frontend_type="boolean",
frontend_readonly=False,
frontend_required=True,
)
punkte: List[GeoPunkt] = Field(
default_factory=list,
description="List of GeoPunkte forming the GeoPolylinie",
frontend_type="json",
frontend_readonly=False,
frontend_required=True,
)
class Dokument(BaseModel):
"""Supporting data object for file and URL management with versioning."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
mandateId: str = Field(
description="ID of the mandate this document belongs to",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Document label",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
versionsbezeichnung: Optional[str] = Field(
None,
description="Version number or designation (e.g. 'v1.0', 'Rev. A')",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
dokumentTyp: Optional[DokumentTyp] = Field(
None,
description="Document type",
frontend_type="select",
frontend_readonly=False,
frontend_required=False,
)
dokumentReferenz: str = Field(
description="File path or URL",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
quelle: Optional[str] = Field(
None,
description="Source of the document",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
mimeType: Optional[str] = Field(
None,
description="MIME type of the document (e.g. 'application/pdf', 'image/png')",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
kategorienTags: List[str] = Field(
default_factory=list,
description="Document categorization tags",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
class Kontext(BaseModel):
"""Supporting data object for flexible additional information."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
)
thema: str = Field(
description="Theme designation",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
inhalt: str = Field(
description="Detailed information (text)",
frontend_type="textarea",
frontend_readonly=False,
frontend_required=True,
)
class Land(BaseModel):
"""National level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
mandateId: str = Field(
description="ID of the mandate",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Country name (e.g. 'Schweiz')",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
abk: Optional[str] = Field(
None,
description="Abbreviation (e.g. 'CH')",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
dokumente: List[Dokument] = Field(
default_factory=list,
description="National laws/documents",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
kontextInformationen: List[Kontext] = Field(
default_factory=list,
description="National context information",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
class Kanton(BaseModel):
"""Cantonal level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
mandateId: str = Field(
description="ID of the mandate",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Canton name (e.g. 'Zürich')",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
id_land: Optional[str] = Field(
None,
description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
abk: Optional[str] = Field(
None,
description="Abbreviation (e.g. 'ZH')",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
dokumente: List[Dokument] = Field(
default_factory=list,
description="Cantonal documents",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
kontextInformationen: List[Kontext] = Field(
default_factory=list,
description="Canton-specific context information",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
class Gemeinde(BaseModel):
"""Municipal level administrative entity."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
mandateId: str = Field(
description="ID of the mandate",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Municipality name (e.g. 'Zürich')",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
id_kanton: Optional[str] = Field(
None,
description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
plz: Optional[str] = Field(
None,
description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
dokumente: List[Dokument] = Field(
default_factory=list,
description="Municipal documents",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
kontextInformationen: List[Kontext] = Field(
default_factory=list,
description="Municipality-specific context information",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
# ===== Main Models (use ForwardRef for circular references) =====
# Forward references for circular dependencies
ParzelleRef = ForwardRef('Parzelle')
class Parzelle(BaseModel):
"""Represents a plot with all building law properties."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
mandateId: str = Field(
description="ID of the mandate",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
# Grunddaten
label: str = Field(
description="Plot designation",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
parzellenAliasTags: List[str] = Field(
default_factory=list,
description="Additional plot names or field names",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
eigentuemerschaft: Optional[str] = Field(
None,
description="Owner of the plot",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
strasseNr: Optional[str] = Field(
None,
description="Street and house number",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
plz: Optional[str] = Field(
None,
description="Postal code of the plot",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
# Geografischer Kontext
perimeter: Optional[GeoPolylinie] = Field(
None,
description="Plot boundary as closed GeoPolylinie",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
baulinie: Optional[GeoPolylinie] = Field(
None,
description="Building line of the plot",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
kontextGemeinde: Optional[str] = Field(
None,
description="Municipality ID (Foreign Key)",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
# Bebauungsparameter
bauzone: Optional[str] = Field(
None,
description="Building zone designation (e.g. W3, WG2, etc.)",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
az: Optional[float] = Field(
None,
description="Ausnützungsziffer",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
bz: Optional[float] = Field(
None,
description="Bebauungsziffer",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
vollgeschossZahl: Optional[int] = Field(
None,
description="Number of allowed full floors",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
anrechenbarDachgeschoss: Optional[float] = Field(
None,
description="Accountable portion of attic (0.0 - 1.0)",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
anrechenbarUntergeschoss: Optional[float] = Field(
None,
description="Accountable portion of basement (0.0 - 1.0)",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
gebaeudehoeheMax: Optional[float] = Field(
None,
description="Maximum building height in meters",
frontend_type="number",
frontend_readonly=False,
frontend_required=False,
)
# Abstandsregelungen
regelnGrenzabstand: List[str] = Field(
default_factory=list,
description="Regulations for boundary distance",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
regelnMehrlaengenzuschlag: List[str] = Field(
default_factory=list,
description="Regulations for additional length surcharge",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
regelnMehrhoehenzuschlag: List[str] = Field(
default_factory=list,
description="Regulations for additional height surcharge",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
# Eigenschaften (Ja/Nein)
parzelleBebaut: Optional[JaNein] = Field(
None,
description="Is the plot built?",
frontend_type="select",
frontend_readonly=False,
frontend_required=False,
)
parzelleErschlossen: Optional[JaNein] = Field(
None,
description="Is the plot developed?",
frontend_type="select",
frontend_readonly=False,
frontend_required=False,
)
parzelleHanglage: Optional[JaNein] = Field(
None,
description="Is the plot on a slope?",
frontend_type="select",
frontend_readonly=False,
frontend_required=False,
)
# Schutzzonen
laermschutzzone: Optional[str] = Field(
None,
description="Noise protection zone (e.g. 'II')",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
hochwasserschutzzone: Optional[str] = Field(
None,
description="Flood protection zone (e.g. 'tief')",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
grundwasserschutzzone: Optional[str] = Field(
None,
description="Groundwater protection zone",
frontend_type="text",
frontend_readonly=False,
frontend_required=False,
)
# Beziehungen (stored as JSONB in database)
parzellenNachbarschaft: List[Dict[str, Any]] = Field(
default_factory=list,
description="Neighboring plots (stored as list of Parzelle IDs or full objects)",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
dokumente: List[Dokument] = Field(
default_factory=list,
description="Plot-specific documents",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
kontextInformationen: List[Kontext] = Field(
default_factory=list,
description="Plot-specific context information",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
class Projekt(BaseModel):
"""Core object representing a construction project."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
mandateId: str = Field(
description="ID of the mandate",
frontend_type="text",
frontend_readonly=True,
frontend_required=False,
)
label: str = Field(
description="Project designation",
frontend_type="text",
frontend_readonly=False,
frontend_required=True,
)
statusProzess: Optional[StatusProzess] = Field(
None,
description="Project status",
frontend_type="select",
frontend_readonly=False,
frontend_required=False,
)
perimeter: Optional[GeoPolylinie] = Field(
None,
description="Envelope of all plots in the project",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
baulinie: Optional[GeoPolylinie] = Field(
None,
description="Building line of the project",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
parzellen: List[Parzelle] = Field(
default_factory=list,
description="All plots of the project",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
dokumente: List[Dokument] = Field(
default_factory=list,
description="Project-specific documents",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
kontextInformationen: List[Kontext] = Field(
default_factory=list,
description="Project-specific context information",
frontend_type="json",
frontend_readonly=False,
frontend_required=False,
)
# Resolve forward references
Parzelle.model_rebuild()
Projekt.model_rebuild()
# Register labels for frontend
registerModelLabels(
"Projekt",
{"en": "Project", "fr": "Projet", "de": "Projekt"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
},
)
registerModelLabels(
"Parzelle",
{"en": "Plot", "fr": "Parcelle", "de": "Parzelle"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"},
},
)
registerModelLabels(
"Dokument",
{"en": "Document", "fr": "Document", "de": "Dokument"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
},
)

View file

@ -0,0 +1,4 @@
"""
Real Estate feature module.
"""

File diff suppressed because it is too large Load diff

View file

@ -493,6 +493,49 @@ class ComponentObjects:
def getInitialId(self, model_class: type) -> Optional[str]:
"""Returns the initial ID for a table."""
return self.db.getInitialId(model_class)
def _parse_size_string(self, size_str: str) -> Optional[int]:
"""
Parse a formatted size string (e.g., "2.13 MB", "1.5 GB") to bytes.
Args:
size_str: Formatted size string like "2.13 MB", "1.5 GB", "500 KB"
Returns:
Size in bytes, or None if parsing fails
"""
try:
size_str = size_str.strip().upper()
# Remove common separators and spaces
size_str = size_str.replace(",", "").replace(" ", "")
# Extract number and unit - handle both "MB" and "M" formats
import re
# Match: number (with optional decimal) followed by optional unit (K/M/G/T with optional B)
match = re.match(r"^([\d.]+)([KMGT]?B?)$", size_str)
if not match:
return None
number = float(match.group(1))
unit = match.group(2) or "B"
# Normalize unit (handle "M" as "MB", "K" as "KB", etc.)
if len(unit) == 1 and unit in "KMGT":
unit = unit + "B"
# Convert to bytes
multipliers = {
"B": 1,
"KB": 1024,
"MB": 1024 * 1024,
"GB": 1024 * 1024 * 1024,
"TB": 1024 * 1024 * 1024 * 1024,
}
multiplier = multipliers.get(unit, 1)
return int(number * multiplier)
except Exception:
return None

View file

@ -0,0 +1,90 @@
"""
Access control for Real Estate interface.
Handles user access management and permission checks.
"""
import logging
from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User
logger = logging.getLogger(__name__)
class RealEstateAccess:
"""
Access control class for Real Estate interface.
Handles user access management and permission checks.
"""
def __init__(self, currentUser: User, db):
"""Initialize with user context."""
self.currentUser = currentUser
self.mandateId = currentUser.mandateId
self.userId = currentUser.id
self.roleLabels = currentUser.roleLabels or []
if not self.mandateId or not self.userId:
raise ValueError("Invalid user context: mandateId and userId are required")
self.db = db
def uam(self, model_class: type, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Unified user access management function that filters data based on user privileges.
Args:
model_class: Pydantic model class for the table
recordset: Recordset to filter based on access rules
Returns:
Filtered recordset with access control attributes
"""
filtered_records = []
# System admins see all records
if "sysadmin" in self.roleLabels:
filtered_records = recordset
# Admins see records in their mandate
elif "admin" in self.roleLabels:
filtered_records = [r for r in recordset if r.get("mandateId", "-") == self.mandateId]
# Regular users see only their records
else:
filtered_records = [
r for r in recordset
if r.get("mandateId", "-") == self.mandateId and r.get("_createdBy") == self.userId
]
# Add access control attributes
for record in filtered_records:
record["_hideView"] = False
record["_hideEdit"] = not self.canModify(model_class, record.get("id"))
record["_hideDelete"] = not self.canModify(model_class, record.get("id"))
return filtered_records
def canModify(self, model_class: type, recordId: Optional[str] = None) -> bool:
"""Checks if the current user can modify records."""
# System admins can modify all records
if "sysadmin" in self.roleLabels:
return True
if recordId is not None:
records = self.db.getRecordset(model_class, recordFilter={"id": recordId})
if not records:
return False
record = records[0]
# Admins can modify records in their mandate
if "admin" in self.roleLabels and record.get("mandateId", "-") == self.mandateId:
return True
# Regular users can modify their own records
if (record.get("mandateId", "-") == self.mandateId and
record.get("_createdBy") == self.userId):
return True
return False
else:
return True # Regular users can create records

View file

@ -0,0 +1,744 @@
"""
Interface to Real Estate database objects.
Uses PostgreSQL connector for data access with user/mandate filtering.
Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.).
"""
import logging
from typing import Dict, Any, List, Optional, Union
from modules.datamodels.datamodelRealEstate import (
Projekt,
Parzelle,
Dokument,
Kanton,
Gemeinde,
Land,
GeoPolylinie,
GeoPunkt,
Kontext,
StatusProzess,
)
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
# Import Access-Klasse aus separater Datei
from modules.interfaces.interfaceDbRealEstateAccess import RealEstateAccess
logger = logging.getLogger(__name__)
# Singleton factory for Real Estate interfaces
_realEstateInterfaces = {}
class RealEstateObjects:
"""
Interface to Real Estate database objects.
Uses PostgreSQL connector for data access with user/mandate filtering.
Handles CRUD operations on Real Estate entities.
"""
def __init__(self, currentUser: Optional[User] = None):
"""Initializes the Real Estate Interface."""
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = currentUser.mandateId if currentUser else None
self.access = None
# Initialize database
self._initializeDatabase()
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
def _initializeDatabase(self):
"""Initialize PostgreSQL database connection."""
try:
# Get database configuration from environment
dbHost = APP_CONFIG.get("DB_REALESTATE_HOST", "localhost")
dbDatabase = APP_CONFIG.get("DB_REALESTATE_DATABASE", "poweron_realestate")
dbUser = APP_CONFIG.get("DB_REALESTATE_USER")
dbPassword = APP_CONFIG.get("DB_REALESTATE_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_REALESTATE_PORT", 5432))
# Initialize database connector
self.db = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=self.userId if self.userId else None,
)
# Initialize database system (creates database and system table if needed)
# Note: This is also called in DatabaseConnector.__init__, but we call it explicitly
# for consistency with other interfaces and to ensure proper initialization
self.db.initDbSystem()
# Ensure all supporting tables are created (Land, Kanton, Gemeinde, Dokument)
# These tables are needed for foreign key relationships
self._ensureSupportingTablesExist()
logger.info(f"Real Estate database connector initialized for database: {dbDatabase}")
except Exception as e:
logger.error(f"Error initializing Real Estate database: {e}")
raise
def _ensureSupportingTablesExist(self):
"""Ensure all supporting tables (Land, Kanton, Gemeinde, Dokument) are created."""
try:
# These tables are created on-demand when first accessed, but we ensure they exist here
# to avoid errors when resolving location names to IDs
self.db._ensureTableExists(Land)
self.db._ensureTableExists(Kanton)
self.db._ensureTableExists(Gemeinde)
self.db._ensureTableExists(Dokument)
logger.debug("Supporting tables (Land, Kanton, Gemeinde, Dokument) verified/created")
except Exception as e:
logger.warning(f"Error ensuring supporting tables exist: {e}")
# Don't raise - tables will be created on-demand anyway
def setUserContext(self, currentUser: User):
"""Sets the user context for the interface."""
self.currentUser = currentUser
self.userId = currentUser.id
self.mandateId = currentUser.mandateId
if not self.userId or not self.mandateId:
raise ValueError("Invalid user context: id and mandateId are required")
# Initialize access control
self.access = RealEstateAccess(self.currentUser, self.db)
# Update database context
self.db.updateContext(self.userId)
# ===== Projekt Methods =====
def createProjekt(self, projekt: Projekt) -> Projekt:
"""Create a new project."""
# Ensure mandateId is set
if not projekt.mandateId:
projekt.mandateId = self.mandateId
# Apply access control
self.access.uam(Projekt, [])
# Save to database - use mode='json' to ensure nested Pydantic models are serialized
self.db.recordCreate(Projekt, projekt.model_dump(mode='json'))
return projekt
def getProjekt(self, projektId: str) -> Optional[Projekt]:
"""Get a project by ID."""
records = self.db.getRecordset(
Projekt,
recordFilter={"id": projektId}
)
if not records:
return None
# Apply access control
filtered = self.access.uam(Projekt, records)
if not filtered:
return None
return Projekt(**filtered[0])
def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Projekt]:
"""Get all projects matching the filter."""
records = self.db.getRecordset(Projekt, recordFilter=recordFilter or {})
# Apply access control
filtered = self.access.uam(Projekt, records)
return [Projekt(**r) for r in filtered]
def updateProjekt(self, projektId_or_projekt: Union[str, Projekt], updateData: Optional[Dict[str, Any]] = None) -> Optional[Projekt]:
"""Update a project.
Args:
projektId_or_projekt: Either a project ID (str) or a Projekt object
updateData: Optional dict of fields to update (only used when projektId_or_projekt is a string)
"""
# Handle both Projekt object and projektId string
if isinstance(projektId_or_projekt, Projekt):
projekt = projektId_or_projekt
projektId = projekt.id
else:
projektId = projektId_or_projekt
projekt = self.getProjekt(projektId)
if not projekt:
return None
# Update fields from updateData if provided
if updateData:
for key, value in updateData.items():
if hasattr(projekt, key):
setattr(projekt, key, value)
# Check if user can modify
if not self.access.canModify(Projekt, projektId):
raise PermissionError(f"User {self.userId} cannot modify project {projektId}")
# Save to database
self.db.recordModify(Projekt, projektId, projekt.model_dump())
return projekt
def deleteProjekt(self, projektId: str) -> bool:
"""Delete a project."""
projekt = self.getProjekt(projektId)
if not projekt:
return False
# Check if user can modify
if not self.access.canModify(Projekt, projektId):
raise PermissionError(f"User {self.userId} cannot delete project {projektId}")
return self.db.recordDelete(Projekt, projektId)
# ===== Parzelle Methods =====
def createParzelle(self, parzelle: Parzelle) -> Parzelle:
"""Create a new plot."""
if not parzelle.mandateId:
parzelle.mandateId = self.mandateId
self.access.uam(Parzelle, [])
# Use mode='json' to ensure nested Pydantic models (like GeoPolylinie) are serialized
self.db.recordCreate(Parzelle, parzelle.model_dump(mode='json'))
return parzelle
def getParzelle(self, parzelleId: str) -> Optional[Parzelle]:
"""Get a plot by ID."""
records = self.db.getRecordset(
Parzelle,
recordFilter={"id": parzelleId}
)
if not records:
return None
filtered = self.access.uam(Parzelle, records)
if not filtered:
return None
return Parzelle(**filtered[0])
def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]:
"""Get all plots matching the filter."""
original_gemeinde_value = None
# Resolve location names to IDs if needed
if recordFilter:
# Save original value before resolution for fallback search
if "kontextGemeinde" in recordFilter:
original_gemeinde_value = recordFilter["kontextGemeinde"]
recordFilter = self._resolveLocationFilters(recordFilter)
records = self.db.getRecordset(Parzelle, recordFilter=recordFilter or {})
# Fallback: If no records found and we resolved a Gemeinde name,
# try searching with the original name for backwards compatibility
# (handles case where data has string names instead of UUIDs)
if not records and original_gemeinde_value and recordFilter and "kontextGemeinde" in recordFilter:
if recordFilter["kontextGemeinde"] != original_gemeinde_value:
logger.info(f"No results with resolved UUID, trying with original name '{original_gemeinde_value}'")
fallback_filter = recordFilter.copy()
fallback_filter["kontextGemeinde"] = original_gemeinde_value
records = self.db.getRecordset(Parzelle, recordFilter=fallback_filter)
if records:
logger.info(f"Found {len(records)} records using original name (legacy data format)")
# Apply access control
filtered = self.access.uam(Parzelle, records)
return [Parzelle(**r) for r in filtered]
def _resolveLocationFilters(self, recordFilter: Dict[str, Any]) -> Dict[str, Any]:
"""
Resolve location names to IDs for foreign key fields.
Only handles kontextGemeinde (Parzelle Gemeinde).
Note: Parzelle does NOT have direct links to Kanton or Land.
The relationship is: Parzelle Gemeinde Kanton Land
"""
resolvedFilter = recordFilter.copy()
# Resolve Gemeinde name to ID
# This is the only direct location link on Parzelle
if "kontextGemeinde" in resolvedFilter:
gemeindeValue = resolvedFilter["kontextGemeinde"]
# Check if it's a name (not a UUID-like string)
if not self._isUUID(gemeindeValue):
gemeindeId = self._resolveGemeindeByName(gemeindeValue)
if gemeindeId:
resolvedFilter["kontextGemeinde"] = gemeindeId
logger.debug(f"Resolved Gemeinde name '{gemeindeValue}' to ID '{gemeindeId}'")
else:
logger.warning(f"Gemeinde '{gemeindeValue}' not found, filter may return no results")
# Keep the original value - query will return empty if not found
# Note: kontextKanton and kontextLand are NOT fields on Parzelle
# If they appear in the filter, they will be filtered out by the validation in mainRealEstate.py
return resolvedFilter
def _isUUID(self, value: str) -> bool:
"""Check if a string looks like a UUID."""
import re
uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
return bool(uuid_pattern.match(value))
def _resolveGemeindeByName(self, name: str) -> Optional[str]:
"""Resolve Gemeinde name to ID by looking up in Gemeinde table."""
try:
# First try exact match
gemeinden = self.db.getRecordset(
Gemeinde,
recordFilter={"label": name}
)
if gemeinden:
gemeindeId = gemeinden[0].get("id")
logger.debug(f"Found Gemeinde '{name}' with ID '{gemeindeId}'")
return gemeindeId
# If no exact match, try case-insensitive search via SQL query
# This handles cases where the name might have different casing
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
'SELECT "id" FROM "Gemeinde" WHERE LOWER("label") = LOWER(%s) LIMIT 1',
(name,)
)
result = cursor.fetchone()
if result:
# psycopg2 returns tuples, so result[0] is the id
gemeindeId = result[0]
logger.debug(f"Found Gemeinde '{name}' (case-insensitive) with ID '{gemeindeId}'")
return gemeindeId
logger.warning(f"Gemeinde '{name}' not found in database")
return None
except Exception as e:
logger.error(f"Error resolving Gemeinde by name '{name}': {e}", exc_info=True)
return None
def _resolveKantonByName(self, name: str) -> Optional[str]:
"""Resolve Kanton name to ID by looking up in Kanton table."""
try:
# First try exact match
kantone = self.db.getRecordset(
Kanton,
recordFilter={"label": name}
)
if kantone:
kantonId = kantone[0].get("id")
logger.debug(f"Found Kanton '{name}' with ID '{kantonId}'")
return kantonId
# Try case-insensitive search
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
'SELECT "id" FROM "Kanton" WHERE LOWER("label") = LOWER(%s) LIMIT 1',
(name,)
)
result = cursor.fetchone()
if result:
# psycopg2 returns tuples, so result[0] is the id
kantonId = result[0]
logger.debug(f"Found Kanton '{name}' (case-insensitive) with ID '{kantonId}'")
return kantonId
logger.warning(f"Kanton '{name}' not found in database")
return None
except Exception as e:
logger.error(f"Error resolving Kanton by name '{name}': {e}", exc_info=True)
return None
def _resolveLandByName(self, name: str) -> Optional[str]:
"""Resolve Land name to ID by looking up in Land table."""
try:
# First try exact match
laender = self.db.getRecordset(
Land,
recordFilter={"label": name}
)
if laender:
landId = laender[0].get("id")
logger.debug(f"Found Land '{name}' with ID '{landId}'")
return landId
# Try case-insensitive search
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
cursor.execute(
'SELECT "id" FROM "Land" WHERE LOWER("label") = LOWER(%s) LIMIT 1',
(name,)
)
result = cursor.fetchone()
if result:
# psycopg2 returns tuples, so result[0] is the id
landId = result[0]
logger.debug(f"Found Land '{name}' (case-insensitive) with ID '{landId}'")
return landId
logger.warning(f"Land '{name}' not found in database")
return None
except Exception as e:
logger.error(f"Error resolving Land by name '{name}': {e}", exc_info=True)
return None
def updateParzelle(self, parzelleId: str, updateData: Dict[str, Any]) -> Optional[Parzelle]:
"""Update a plot."""
parzelle = self.getParzelle(parzelleId)
if not parzelle:
return None
if not self.access.canModify(Parzelle, parzelleId):
raise PermissionError(f"User {self.userId} cannot modify plot {parzelleId}")
for key, value in updateData.items():
if hasattr(parzelle, key):
setattr(parzelle, key, value)
self.db.recordModify(Parzelle, parzelleId, parzelle.model_dump())
return parzelle
def deleteParzelle(self, parzelleId: str) -> bool:
"""Delete a plot."""
parzelle = self.getParzelle(parzelleId)
if not parzelle:
return False
if not self.access.canModify(Parzelle, parzelleId):
raise PermissionError(f"User {self.userId} cannot delete plot {parzelleId}")
return self.db.recordDelete(Parzelle, parzelleId)
# ===== Dokument Methods =====
def createDokument(self, dokument: Dokument) -> Dokument:
"""Create a new document."""
if not dokument.mandateId:
dokument.mandateId = self.mandateId
self.access.uam(Dokument, [])
self.db.recordCreate(Dokument, dokument.model_dump())
return dokument
def getDokument(self, dokumentId: str) -> Optional[Dokument]:
"""Get a document by ID."""
records = self.db.getRecordset(
Dokument,
recordFilter={"id": dokumentId}
)
if not records:
return None
filtered = self.access.uam(Dokument, records)
if not filtered:
return None
return Dokument(**filtered[0])
def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Dokument]:
"""Get all documents matching the filter."""
records = self.db.getRecordset(Dokument, recordFilter=recordFilter or {})
filtered = self.access.uam(Dokument, records)
return [Dokument(**r) for r in filtered]
def updateDokument(self, dokumentId: str, updateData: Dict[str, Any]) -> Optional[Dokument]:
"""Update a document."""
dokument = self.getDokument(dokumentId)
if not dokument:
return None
if not self.access.canModify(Dokument, dokumentId):
raise PermissionError(f"User {self.userId} cannot modify document {dokumentId}")
for key, value in updateData.items():
if hasattr(dokument, key):
setattr(dokument, key, value)
self.db.recordModify(Dokument, dokumentId, dokument.model_dump())
return dokument
def deleteDokument(self, dokumentId: str) -> bool:
"""Delete a document."""
dokument = self.getDokument(dokumentId)
if not dokument:
return False
if not self.access.canModify(Dokument, dokumentId):
raise PermissionError(f"User {self.userId} cannot delete document {dokumentId}")
return self.db.recordDelete(Dokument, dokumentId)
# ===== Gemeinde Methods =====
def createGemeinde(self, gemeinde: Gemeinde) -> Gemeinde:
"""Create a new municipality."""
if not gemeinde.mandateId:
gemeinde.mandateId = self.mandateId
self.access.uam(Gemeinde, [])
self.db.recordCreate(Gemeinde, gemeinde.model_dump())
return gemeinde
def getGemeinde(self, gemeindeId: str) -> Optional[Gemeinde]:
"""Get a municipality by ID."""
records = self.db.getRecordset(
Gemeinde,
recordFilter={"id": gemeindeId}
)
if not records:
return None
filtered = self.access.uam(Gemeinde, records)
if not filtered:
return None
return Gemeinde(**filtered[0])
def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Gemeinde]:
"""Get all municipalities matching the filter."""
records = self.db.getRecordset(Gemeinde, recordFilter=recordFilter or {})
filtered = self.access.uam(Gemeinde, records)
return [Gemeinde(**r) for r in filtered]
def updateGemeinde(self, gemeindeId: str, updateData: Dict[str, Any]) -> Optional[Gemeinde]:
"""Update a municipality."""
gemeinde = self.getGemeinde(gemeindeId)
if not gemeinde:
return None
if not self.access.canModify(Gemeinde, gemeindeId):
raise PermissionError(f"User {self.userId} cannot modify municipality {gemeindeId}")
for key, value in updateData.items():
if hasattr(gemeinde, key):
setattr(gemeinde, key, value)
self.db.recordModify(Gemeinde, gemeindeId, gemeinde.model_dump())
return gemeinde
def deleteGemeinde(self, gemeindeId: str) -> bool:
"""Delete a municipality."""
gemeinde = self.getGemeinde(gemeindeId)
if not gemeinde:
return False
if not self.access.canModify(Gemeinde, gemeindeId):
raise PermissionError(f"User {self.userId} cannot delete municipality {gemeindeId}")
return self.db.recordDelete(Gemeinde, gemeindeId)
# ===== Kanton Methods =====
def createKanton(self, kanton: Kanton) -> Kanton:
"""Create a new canton."""
if not kanton.mandateId:
kanton.mandateId = self.mandateId
self.access.uam(Kanton, [])
self.db.recordCreate(Kanton, kanton.model_dump())
return kanton
def getKanton(self, kantonId: str) -> Optional[Kanton]:
"""Get a canton by ID."""
records = self.db.getRecordset(
Kanton,
recordFilter={"id": kantonId}
)
if not records:
return None
filtered = self.access.uam(Kanton, records)
if not filtered:
return None
return Kanton(**filtered[0])
def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Kanton]:
"""Get all cantons matching the filter."""
records = self.db.getRecordset(Kanton, recordFilter=recordFilter or {})
filtered = self.access.uam(Kanton, records)
return [Kanton(**r) for r in filtered]
def updateKanton(self, kantonId: str, updateData: Dict[str, Any]) -> Optional[Kanton]:
"""Update a canton."""
kanton = self.getKanton(kantonId)
if not kanton:
return None
if not self.access.canModify(Kanton, kantonId):
raise PermissionError(f"User {self.userId} cannot modify canton {kantonId}")
for key, value in updateData.items():
if hasattr(kanton, key):
setattr(kanton, key, value)
self.db.recordModify(Kanton, kantonId, kanton.model_dump())
return kanton
def deleteKanton(self, kantonId: str) -> bool:
"""Delete a canton."""
kanton = self.getKanton(kantonId)
if not kanton:
return False
if not self.access.canModify(Kanton, kantonId):
raise PermissionError(f"User {self.userId} cannot delete canton {kantonId}")
return self.db.recordDelete(Kanton, kantonId)
# ===== Land Methods =====
def createLand(self, land: Land) -> Land:
"""Create a new country."""
if not land.mandateId:
land.mandateId = self.mandateId
self.access.uam(Land, [])
self.db.recordCreate(Land, land.model_dump())
return land
def getLand(self, landId: str) -> Optional[Land]:
"""Get a country by ID."""
records = self.db.getRecordset(
Land,
recordFilter={"id": landId}
)
if not records:
return None
filtered = self.access.uam(Land, records)
if not filtered:
return None
return Land(**filtered[0])
def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Land]:
"""Get all countries matching the filter."""
records = self.db.getRecordset(Land, recordFilter=recordFilter or {})
filtered = self.access.uam(Land, records)
return [Land(**r) for r in filtered]
def updateLand(self, landId: str, updateData: Dict[str, Any]) -> Optional[Land]:
"""Update a country."""
land = self.getLand(landId)
if not land:
return None
if not self.access.canModify(Land, landId):
raise PermissionError(f"User {self.userId} cannot modify country {landId}")
for key, value in updateData.items():
if hasattr(land, key):
setattr(land, key, value)
self.db.recordModify(Land, landId, land.model_dump())
return land
def deleteLand(self, landId: str) -> bool:
"""Delete a country."""
land = self.getLand(landId)
if not land:
return False
if not self.access.canModify(Land, landId):
raise PermissionError(f"User {self.userId} cannot delete country {landId}")
return self.db.recordDelete(Land, landId)
# ===== Direct Query Execution (stateless) =====
def executeQuery(self, queryText: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Execute a SQL query directly on the database (stateless).
WARNING: This method executes raw SQL. Ensure proper validation and sanitization
before calling this method. Consider implementing query whitelisting or
only allowing SELECT statements for production use.
Args:
queryText: SQL query string (preferably SELECT only)
parameters: Optional parameters for parameterized queries
Returns:
Dictionary with 'rows' (list of dicts), 'columns' (list of column names),
'rowCount' (int), and 'executionTime' (float)
"""
import time
try:
start_time = time.time()
# Ensure connection is alive
self.db._ensure_connection()
with self.db.connection.cursor() as cursor:
# Execute query
if parameters:
# Use parameterized query for safety
cursor.execute(queryText, parameters)
else:
cursor.execute(queryText)
# Fetch results
rows = cursor.fetchall()
# Convert to list of dictionaries
result_rows = []
if rows:
columns = [desc[0] for desc in cursor.description] if cursor.description else []
result_rows = [dict(zip(columns, row)) for row in rows]
else:
columns = []
execution_time = time.time() - start_time
return {
"rows": result_rows,
"columns": columns,
"rowCount": len(result_rows),
"executionTime": execution_time,
}
except Exception as e:
logger.error(f"Error executing query: {e}", exc_info=True)
raise
def getInterface(currentUser: User) -> RealEstateObjects:
"""
Factory function to get or create a Real Estate interface instance for a user.
Uses singleton pattern per user.
"""
userKey = f"{currentUser.id}_{currentUser.mandateId}"
if userKey not in _realEstateInterfaces:
_realEstateInterfaces[userKey] = RealEstateObjects(currentUser)
return _realEstateInterfaces[userKey]

View file

@ -10,17 +10,20 @@ SECURITY NOTE:
- This prevents security vulnerabilities where admin users could see other users' connections
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
import json
import math
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbAppObjects import getInterface
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects
# Configure logger
logger = logging.getLogger(__name__)
@ -89,20 +92,44 @@ router = APIRouter(
responses={404: {"description": "Not found"}}
)
@router.get("/", response_model=List[UserConnection])
@router.get("/", response_model=PaginatedResponse[UserConnection])
@limiter.limit("30/minute")
async def get_connections(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
) -> List[UserConnection]:
"""Get all connections for the current user
) -> PaginatedResponse[UserConnection]:
"""Get connections for the current user with optional pagination, sorting, and filtering.
SECURITY: This endpoint is secure - users can only see their own connections.
Automatically refreshes expired OAuth tokens in the background.
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/connections/ (no pagination - returns all items)
- GET /api/connections/?pagination={"page":1,"pageSize":10,"sort":[]}
- GET /api/connections/?pagination={"page":1,"pageSize":10,"filters":{"status":"active"}}
"""
try:
interface = getInterface(currentUser)
# Parse pagination parameter
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
# Normalize pagination dict (handles top-level "search" field)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
# SECURITY FIX: All users (including admins) can only see their own connections
# This prevents admin from seeing other users' connections and causing confusion
connections = interface.getUserConnections(currentUser.id)
@ -119,33 +146,111 @@ async def get_connections(
logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}")
# Continue with original connections even if refresh fails
# Enhance each connection with token status information
enhanced_connections = []
# Enhance each connection with token status information and convert to dict
enhanced_connections_dict = []
for connection in connections:
# Get token status for this connection
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
# Create enhanced connection with token status
enhanced_connection = UserConnection(
id=connection.id,
userId=connection.userId,
authority=connection.authority,
externalId=connection.externalId,
externalUsername=connection.externalUsername,
externalEmail=connection.externalEmail,
status=connection.status,
connectedAt=connection.connectedAt,
lastChecked=connection.lastChecked,
expiresAt=connection.expiresAt,
tokenStatus=tokenStatus,
tokenExpiresAt=tokenExpiresAt
# Convert to dict for filtering/sorting
connection_dict = {
"id": connection.id,
"userId": connection.userId,
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
"externalId": connection.externalId,
"externalUsername": connection.externalUsername or "",
"externalEmail": connection.externalEmail, # Keep None instead of converting to empty string
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
"connectedAt": connection.connectedAt,
"lastChecked": connection.lastChecked,
"expiresAt": connection.expiresAt,
"tokenStatus": tokenStatus,
"tokenExpiresAt": tokenExpiresAt
}
enhanced_connections_dict.append(connection_dict)
# If no pagination requested, return all items
if paginationParams is None:
# Convert back to UserConnection objects (enum strings are already in dict)
items = []
for conn_dict in enhanced_connections_dict:
conn_dict_copy = dict(conn_dict)
if "authority" in conn_dict_copy and isinstance(conn_dict_copy["authority"], str):
try:
conn_dict_copy["authority"] = AuthAuthority(conn_dict_copy["authority"])
except ValueError:
pass
if "status" in conn_dict_copy and isinstance(conn_dict_copy["status"], str):
try:
conn_dict_copy["status"] = ConnectionStatus(conn_dict_copy["status"])
except ValueError:
pass
items.append(UserConnection(**conn_dict_copy))
return PaginatedResponse(
items=items,
pagination=None
)
enhanced_connections.append(enhanced_connection)
return enhanced_connections
# Apply filtering if provided
if paginationParams.filters:
component_interface = ComponentObjects()
component_interface.setUserContext(currentUser)
enhanced_connections_dict = component_interface._applyFilters(
enhanced_connections_dict,
paginationParams.filters
)
# Apply sorting if provided
if paginationParams.sort:
component_interface = ComponentObjects()
component_interface.setUserContext(currentUser)
enhanced_connections_dict = component_interface._applySorting(
enhanced_connections_dict,
paginationParams.sort
)
# Count total items after filters
totalItems = len(enhanced_connections_dict)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
# Apply pagination (skip/limit)
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
paged_connections = enhanced_connections_dict[startIdx:endIdx]
# Convert back to UserConnection objects (convert enum strings back to enums)
items = []
for conn_dict in paged_connections:
# Convert enum strings back to enum objects
conn_dict_copy = dict(conn_dict)
if "authority" in conn_dict_copy and isinstance(conn_dict_copy["authority"], str):
try:
conn_dict_copy["authority"] = AuthAuthority(conn_dict_copy["authority"])
except ValueError:
pass # Keep as string if invalid
if "status" in conn_dict_copy and isinstance(conn_dict_copy["status"], str):
try:
conn_dict_copy["status"] = ConnectionStatus(conn_dict_copy["status"])
except ValueError:
pass # Keep as string if invalid
items.append(UserConnection(**conn_dict_copy))
return PaginatedResponse(
items=items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting connections: {str(e)}")
logger.error(f"Error getting connections: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get connections: {str(e)}"

View file

@ -14,7 +14,7 @@ import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObj
from modules.datamodels.datamodelFiles import FileItem, FilePreview
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger
logger = logging.getLogger(__name__)
@ -59,7 +59,10 @@ async def get_files(
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
if paginationDict:
# Normalize pagination dict (handles top-level "search" field)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,

View file

@ -13,7 +13,7 @@ from modules.auth import limiter, getCurrentUser
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
from modules.datamodels.datamodelUtils import Prompt
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger
logger = logging.getLogger(__name__)
@ -48,7 +48,10 @@ async def get_prompts(
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
if paginationDict:
# Normalize pagination dict (handles top-level "search" field)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ from modules.datamodels.datamodelChat import (
)
from modules.shared.attributeUtils import getModelAttributeDefinitions, AttributeResponse
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger
@ -71,7 +71,10 @@ async def get_workflows(
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
if paginationDict:
# Normalize pagination dict (handles top-level "search" field)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
@ -264,7 +267,10 @@ async def get_workflow_logs(
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
if paginationDict:
# Normalize pagination dict (handles top-level "search" field)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
@ -352,7 +358,10 @@ async def get_workflow_messages(
if pagination:
try:
paginationDict = json.loads(pagination)
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
if paginationDict:
# Normalize pagination dict (handles top-level "search" field)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,

View file

@ -102,4 +102,10 @@ xyzservices>=2021.09.1
# PostgreSQL connector dependencies
psycopg2-binary==2.9.9
asyncpg==0.30.0
asyncpg==0.30.0
## Geospatial libraries for STAC connector
pyproj>=3.6.0 # For coordinate transformations (EPSG:2056 <-> EPSG:4326)
shapely>=2.0.0 # For geometric operations (intersections, area calculations)
geopandas>=0.14.0 # For reading and querying GeoPackage files
fiona>=1.9.0 # Required by geopandas for reading GeoPackage files