""" 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, )