Merge pull request #85 from valueonag/feat/real-estate
Feat/real estate
This commit is contained in:
commit
abbed64463
18 changed files with 6167 additions and 33 deletions
3
app.py
3
app.py
|
|
@ -415,6 +415,9 @@ app.include_router(workflowRouter)
|
||||||
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
|
from modules.routes.routeChatPlayground import router as chatPlaygroundRouter
|
||||||
app.include_router(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
|
from modules.routes.routeSecurityLocal import router as localRouter
|
||||||
app.include_router(localRouter)
|
app.include_router(localRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,10 @@ Web_Crawl_RETRY_DELAY = 2
|
||||||
Web_Research_MAX_DEPTH = 2
|
Web_Research_MAX_DEPTH = 2
|
||||||
Web_Research_MAX_LINKS_PER_DOMAIN = 4
|
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
|
||||||
|
|
@ -29,6 +29,13 @@ DB_MANAGEMENT_USER=poweron_dev
|
||||||
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
|
DB_MANAGEMENT_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEUldqSTVpUnFqdGhITDYzT3RScGlMYVdTMmZhOXdudDRCc3dhdllOd3l6MS1vWHY2MjVsTUF1Sk9saEJOSk9ONUlBZjQwb2c2T1gtWWJhcXFzVVVXd01xc0U0b0lJX0JyVDRxaDhNS01JcWs9
|
||||||
DB_MANAGEMENT_PORT=5432
|
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
|
# Security Configuration
|
||||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,17 @@ def _get_model_fields(model_class) -> Dict[str, str]:
|
||||||
"messages",
|
"messages",
|
||||||
"stats",
|
"stats",
|
||||||
"tasks",
|
"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"
|
fields[field_name] = "JSONB"
|
||||||
# Simple type mapping
|
# Simple type mapping
|
||||||
|
|
|
||||||
1206
modules/connectors/connectorSwissTopoMapServer.py
Normal file
1206
modules/connectors/connectorSwissTopoMapServer.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -80,3 +80,28 @@ class PaginatedResponse(BaseModel, Generic[T]):
|
||||||
|
|
||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
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
|
||||||
|
|
|
||||||
667
modules/datamodels/datamodelRealEstate.py
Normal file
667
modules/datamodels/datamodelRealEstate.py
Normal 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"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
4
modules/features/realEstate/__init__.py
Normal file
4
modules/features/realEstate/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
"""
|
||||||
|
Real Estate feature module.
|
||||||
|
"""
|
||||||
|
|
||||||
2049
modules/features/realEstate/mainRealEstate.py
Normal file
2049
modules/features/realEstate/mainRealEstate.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -494,6 +494,49 @@ class ComponentObjects:
|
||||||
"""Returns the initial ID for a table."""
|
"""Returns the initial ID for a table."""
|
||||||
return self.db.getInitialId(model_class)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Prompt methods
|
# Prompt methods
|
||||||
|
|
|
||||||
90
modules/interfaces/interfaceDbRealEstateAccess.py
Normal file
90
modules/interfaces/interfaceDbRealEstateAccess.py
Normal 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
|
||||||
|
|
||||||
744
modules/interfaces/interfaceDbRealEstateObjects.py
Normal file
744
modules/interfaces/interfaceDbRealEstateObjects.py
Normal 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]
|
||||||
|
|
||||||
|
|
@ -10,17 +10,20 @@ SECURITY NOTE:
|
||||||
- This prevents security vulnerabilities where admin users could see other users' connections
|
- 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 typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
from modules.auth import getCurrentUser, limiter
|
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.interfaces.interfaceDbAppObjects import getInterface
|
||||||
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
|
from modules.interfaces.interfaceDbComponentObjects import ComponentObjects
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -89,20 +92,44 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/", response_model=List[UserConnection])
|
@router.get("/", response_model=PaginatedResponse[UserConnection])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def get_connections(
|
async def get_connections(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(getCurrentUser)
|
||||||
) -> List[UserConnection]:
|
) -> PaginatedResponse[UserConnection]:
|
||||||
"""Get all connections for the current user
|
"""Get connections for the current user with optional pagination, sorting, and filtering.
|
||||||
|
|
||||||
SECURITY: This endpoint is secure - users can only see their own connections.
|
SECURITY: This endpoint is secure - users can only see their own connections.
|
||||||
Automatically refreshes expired OAuth tokens in the background.
|
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:
|
try:
|
||||||
interface = getInterface(currentUser)
|
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
|
# SECURITY FIX: All users (including admins) can only see their own connections
|
||||||
# This prevents admin from seeing other users' connections and causing confusion
|
# This prevents admin from seeing other users' connections and causing confusion
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
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)}")
|
logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}")
|
||||||
# Continue with original connections even if refresh fails
|
# Continue with original connections even if refresh fails
|
||||||
|
|
||||||
# Enhance each connection with token status information
|
# Enhance each connection with token status information and convert to dict
|
||||||
enhanced_connections = []
|
enhanced_connections_dict = []
|
||||||
for connection in connections:
|
for connection in connections:
|
||||||
# Get token status for this connection
|
# Get token status for this connection
|
||||||
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
||||||
|
|
||||||
# Create enhanced connection with token status
|
# Convert to dict for filtering/sorting
|
||||||
enhanced_connection = UserConnection(
|
connection_dict = {
|
||||||
id=connection.id,
|
"id": connection.id,
|
||||||
userId=connection.userId,
|
"userId": connection.userId,
|
||||||
authority=connection.authority,
|
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
||||||
externalId=connection.externalId,
|
"externalId": connection.externalId,
|
||||||
externalUsername=connection.externalUsername,
|
"externalUsername": connection.externalUsername or "",
|
||||||
externalEmail=connection.externalEmail,
|
"externalEmail": connection.externalEmail, # Keep None instead of converting to empty string
|
||||||
status=connection.status,
|
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
|
||||||
connectedAt=connection.connectedAt,
|
"connectedAt": connection.connectedAt,
|
||||||
lastChecked=connection.lastChecked,
|
"lastChecked": connection.lastChecked,
|
||||||
expiresAt=connection.expiresAt,
|
"expiresAt": connection.expiresAt,
|
||||||
tokenStatus=tokenStatus,
|
"tokenStatus": tokenStatus,
|
||||||
tokenExpiresAt=tokenExpiresAt
|
"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:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=f"Failed to get connections: {str(e)}"
|
detail=f"Failed to get connections: {str(e)}"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObj
|
||||||
from modules.datamodels.datamodelFiles import FileItem, FilePreview
|
from modules.datamodels.datamodelFiles import FileItem, FilePreview
|
||||||
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
from modules.shared.attributeUtils import getModelAttributeDefinitions
|
||||||
from modules.datamodels.datamodelUam import User
|
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
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -59,7 +59,10 @@ async def get_files(
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
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:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from modules.auth import limiter, getCurrentUser
|
||||||
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
import modules.interfaces.interfaceDbComponentObjects as interfaceDbComponentObjects
|
||||||
from modules.datamodels.datamodelUtils import Prompt
|
from modules.datamodels.datamodelUtils import Prompt
|
||||||
from modules.datamodels.datamodelUam import User
|
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
|
# Configure logger
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -48,7 +48,10 @@ async def get_prompts(
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
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:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
1153
modules/routes/routeRealEstate.py
Normal file
1153
modules/routes/routeRealEstate.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ from modules.datamodels.datamodelChat import (
|
||||||
)
|
)
|
||||||
from modules.shared.attributeUtils import getModelAttributeDefinitions, AttributeResponse
|
from modules.shared.attributeUtils import getModelAttributeDefinitions, AttributeResponse
|
||||||
from modules.datamodels.datamodelUam import User
|
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
|
# Configure logger
|
||||||
|
|
@ -71,7 +71,10 @@ async def get_workflows(
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
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:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -264,7 +267,10 @@ async def get_workflow_logs(
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
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:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -352,7 +358,10 @@ async def get_workflow_messages(
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
paginationDict = json.loads(pagination)
|
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:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
|
|
@ -103,3 +103,9 @@ xyzservices>=2021.09.1
|
||||||
# PostgreSQL connector dependencies
|
# PostgreSQL connector dependencies
|
||||||
psycopg2-binary==2.9.9
|
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
|
||||||
Loading…
Reference in a new issue