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
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,3 +35,10 @@ Web_Crawl_RETRY_DELAY = 2
|
|||
Web_Research_MAX_DEPTH = 2
|
||||
Web_Research_MAX_LINKS_PER_DOMAIN = 4
|
||||
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_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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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)
|
||||
|
||||
|
||||
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."""
|
||||
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
|
||||
|
|
|
|||
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
|
||||
"""
|
||||
|
||||
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)}"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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.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,
|
||||
|
|
|
|||
|
|
@ -103,3 +103,9 @@ xyzservices>=2021.09.1
|
|||
# PostgreSQL connector dependencies
|
||||
psycopg2-binary==2.9.9
|
||||
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