Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot) Refactor: gdprDeletion via onUserDelete lifecycle hooks Refactor: i18nBootSync accounting labels via app.py parameter injection Refactor: serviceHub moved to serviceCenter/serviceHub.py Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling Cleanup: remove obsolete methodTrustee, serviceExceptions shim Co-authored-by: Cursor <cursoragent@cursor.com>
374 lines
14 KiB
Python
374 lines
14 KiB
Python
"""
|
|
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,
|
|
)
|