platform-core/modules/features/realEstate/mainRealEstate.py
ValueOn AG ebc4b2a080
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 12s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
cp adapted to 2026 poweron 2
2026-06-09 09:58:05 +02:00

376 lines
14 KiB
Python

# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Real Estate feature main entry point.
Handles feature definition, RBAC registration, and lifecycle hooks.
Service logic is split into dedicated modules:
- serviceGeometry: Geometry utilities and project creation with parcel data
- serviceAiIntent: AI-based intent recognition and CRUD operations
- serviceBzo: BZO information extraction and filtering
"""
import logging
from modules.shared.i18nRegistry import t
FEATURE_CODE = "realestate"
FEATURE_LABEL = t("Immobilien", context="UI")
FEATURE_ICON = "mdi-home-city"
UI_OBJECTS = [
{
"objectKey": "ui.feature.realestate.dashboard",
"label": t("Karte", context="UI"),
"meta": {"area": "dashboard"}
},
]
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.realestate.project.create",
"label": t("Projekt erstellen", context="UI"),
"meta": {"endpoint": "/api/realestate/project", "method": "POST"}
},
{
"objectKey": "resource.feature.realestate.project.delete",
"label": t("Projekt löschen", context="UI"),
"meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"}
},
]
# Template roles for this feature with AccessRules
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [
{
"roleLabel": "realestate-viewer",
"description": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "realestate-user",
"description": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
],
},
{
"roleLabel": "realestate-admin",
"description": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
],
},
{
"roleLabel": "realestate-manager",
"description": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
],
},
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Feature Definition & RBAC Registration
# ---------------------------------------------------------------------------
def getFeatureDefinition():
"""Return the feature definition for registration."""
return {
"code": FEATURE_CODE,
"label": FEATURE_LABEL,
"icon": FEATURE_ICON
}
def getUiObjects():
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS
def getResourceObjects():
"""Return resource objects for RBAC catalog registration."""
return RESOURCE_OBJECTS
def getTemplateRoles():
"""Return template roles for this feature."""
return TEMPLATE_ROLES
def registerFeature(catalogService) -> bool:
"""Register this feature's RBAC objects in the catalog."""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta")
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta")
)
_syncTemplateRolesToDb()
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
# ---------------------------------------------------------------------------
# Internal RBAC Helpers
# ---------------------------------------------------------------------------
def _syncTemplateRolesToDb() -> int:
"""
Sync template roles and their AccessRules to the database.
Creates global template roles (mandateId=None) if they don't exist.
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelUtils import coerce_text_multilingual
rootInterface = getRootInterface()
db = rootInterface.db
existingRoles = db.getRecordset(
Role,
recordFilter={"featureCode": FEATURE_CODE, "mandateId": None}
)
existingRoleLabels = {r.get("roleLabel"): r.get("id") for r in existingRoles}
createdCount = 0
for roleTemplate in TEMPLATE_ROLES:
roleLabel = roleTemplate["roleLabel"]
if roleLabel in existingRoleLabels:
roleId = existingRoleLabels[roleLabel]
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
else:
newRole = Role(
roleLabel=roleLabel,
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,
isSystemRole=False
)
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
existingRoleLabels[roleLabel] = roleId
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
if createdCount > 0:
logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
_repairInstanceRolesAccessRules(rootInterface, existingRoleLabels)
return createdCount
except Exception as e:
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _repairInstanceRolesAccessRules(rootInterface, templateRoleLabels: dict) -> int:
"""Repair instance-specific roles by copying AccessRules from their template roles."""
from modules.datamodels.datamodelRbac import Role, AccessRule
repairedCount = 0
allRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
instanceRoles = [r for r in allRoles if r.mandateId is not None]
for instanceRole in instanceRoles:
roleLabel = instanceRole.roleLabel
instanceRoleId = str(instanceRole.id)
templateRoleId = templateRoleLabels.get(roleLabel)
if not templateRoleId:
continue
existingRules = rootInterface.getAccessRulesByRole(instanceRoleId)
if existingRules:
continue
templateRules = rootInterface.getAccessRulesByRole(templateRoleId)
if not templateRules:
continue
for rule in templateRules:
newRule = AccessRule(
roleId=instanceRoleId,
context=rule.context,
item=rule.item,
view=rule.view if rule.view else False,
read=rule.read,
create=rule.create,
update=rule.update,
delete=rule.delete,
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
repairedCount += 1
return repairedCount
def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: list) -> int:
"""Ensure AccessRules exist for a role based on templates."""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
existingRules = rootInterface.getAccessRulesByRole(roleId)
# IMPORTANT: Use .value for enum comparison, not str() which gives "AccessRuleContext.DATA" in Python 3.11+
existingSignatures = {(r.context.value if r.context else None, r.item) for r in existingRules}
createdCount = 0
for template in ruleTemplates or []:
context = template.get("context", "UI")
item = template.get("item")
if (context, item) in existingSignatures:
continue
if context == "UI":
contextEnum = AccessRuleContext.UI
elif context == "DATA":
contextEnum = AccessRuleContext.DATA
elif context == "RESOURCE":
contextEnum = AccessRuleContext.RESOURCE
else:
contextEnum = context
newRule = AccessRule(
roleId=roleId,
context=contextEnum,
item=item,
view=template.get("view", False),
read=template.get("read"),
create=template.get("create"),
update=template.get("update"),
delete=template.get("delete"),
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
existingSignatures.add((context, item))
return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all realEstate data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.realEstate.datamodelFeatureRealEstate import (
Dokument, Kontext, Land, Kanton, Gemeinde, Parzelle, Projekt,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_realestate",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
for ModelClass in [Dokument, Kontext, Land, Kanton, Gemeinde, Parzelle, Projekt]:
try:
records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or []
if not records:
records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
except Exception:
pass
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} realEstate record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete realEstate data for mandate {mandateId}: {e}")
def onUserDelete(userId: str, currentUser) -> dict:
"""Delete/anonymize user data from the realEstate database (GDPR)."""
from modules.system.gdprDeletion import deleteUserDataFromDatabase
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
dbName = "poweron_realestate"
try:
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=dbName,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
stats = deleteUserDataFromDatabase(db, userId, dbName)
db.close()
return stats
except Exception as e:
logger.warning(f"onUserDelete realEstate failed: {e}")
return {"database": dbName, "tablesProcessed": 0, "recordsDeleted": 0, "recordsAnonymized": 0, "errors": [str(e)]}
# ---------------------------------------------------------------------------
# Re-exports for backward compatibility
# ---------------------------------------------------------------------------
from .serviceGeometry import ( # noqa: F401, E402
geopolylinie_to_shapely_polygon,
shapely_polygon_to_geopolylinie,
combine_parcel_geometries,
filter_neighbor_parcels,
fetch_parcel_polygon_from_swisstopo,
create_project_with_parcel_data,
convert_geojson_to_geopolylinie,
)
from .serviceAiIntent import ( # noqa: F401, E402
executeDirectQuery,
_formatEntitySummary,
processNaturalLanguageCommand,
analyzeUserIntent,
executeIntentBasedOperation,
)
from .serviceBzo import ( # noqa: F401, E402
extract_bzo_information,
filter_rules_by_bauzone,
filter_zones_by_bauzone,
filter_articles_by_bauzone,
_filter_tables_by_bauzone,
generate_bauzone_ai_summary,
)