""" 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 .datamodelFeatureRealEstate 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 from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelUam import AccessLevel from modules.interfaces.interfaceRbac import getRecordsetWithRBAC 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. """ # Feature code for RBAC objectKey construction # Used to build: data.feature.realestate.{TableName} FEATURE_CODE = "realestate" def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): """Initializes the Real Estate Interface. Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header) featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header) """ self.currentUser = currentUser self.userId = currentUser.id if currentUser else None # Use mandateId from parameter (Request-Context), not from user object self.mandateId = mandateId self.featureInstanceId = featureInstanceId self.rbac = None # RBAC interface # Initialize database self._initializeDatabase() # Set user context if provided if currentUser: self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) def _initializeDatabase(self): """Initialize PostgreSQL database connection.""" try: # Get database configuration from environment dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") dbDatabase = "poweron_realestate" dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPort = int(APP_CONFIG.get("DB_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, ) # 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, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): """Sets the user context for the interface. Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header) featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header) """ self.currentUser = currentUser self.userId = currentUser.id # Use mandateId from parameter (Request-Context), not from user object self.mandateId = mandateId self.featureInstanceId = featureInstanceId if not self.userId: raise ValueError("Invalid user context: id is required") # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. # Users are NOT assigned to mandates by design - they get mandate context from the request. # sysAdmin users can additionally perform cross-mandate operations. # Without mandateId, operations will be filtered to accessible mandates via RBAC. # Initialize RBAC interface if not self.currentUser: raise ValueError("User context is required for RBAC") # Get DbApp connection for RBAC AccessRule queries from modules.security.rootAccess import getRootDbAppConnector dbApp = getRootDbAppConnector() self.rbac = RbacClass(self.db, dbApp=dbApp) # Update database context self.db.updateContext(self.userId) # ===== Projekt Methods ===== def createProjekt(self, projekt: Projekt) -> Projekt: """Create a new project.""" # Check RBAC permission if not self.checkRbacPermission(Projekt, "create"): raise PermissionError(f"User {self.userId} cannot create projects") # Ensure mandateId and featureInstanceId are set for proper data isolation if not projekt.mandateId: projekt.mandateId = self.mandateId if not projekt.featureInstanceId: projekt.featureInstanceId = self.featureInstanceId # 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 = getRecordsetWithRBAC( self.db, Projekt, self.currentUser, recordFilter={"id": projektId}, featureCode=self.FEATURE_CODE ) if not records: return None return Projekt(**records[0]) def getProjekte(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Projekt]: """Get all projects matching the filter.""" records = getRecordsetWithRBAC( self.db, Projekt, self.currentUser, recordFilter=recordFilter or {}, featureCode=self.FEATURE_CODE ) return [Projekt(**r) for r in records] 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 RBAC permission if not self.checkRbacPermission(Projekt, "update", 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 RBAC permission if not self.checkRbacPermission(Projekt, "delete", 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.""" # Check RBAC permission if not self.checkRbacPermission(Parzelle, "create"): raise PermissionError(f"User {self.userId} cannot create plots") # Ensure mandateId and featureInstanceId are set for proper data isolation if not parzelle.mandateId: parzelle.mandateId = self.mandateId if not parzelle.featureInstanceId: parzelle.featureInstanceId = self.featureInstanceId # 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 = getRecordsetWithRBAC( self.db, Parzelle, self.currentUser, recordFilter={"id": parzelleId}, featureCode=self.FEATURE_CODE ) if not records: return None return Parzelle(**records[0]) def getParzellen(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Parzelle]: """Get all plots matching the filter.""" # Resolve location names to IDs if needed if recordFilter: recordFilter = self._resolveLocationFilters(recordFilter) records = getRecordsetWithRBAC( self.db, Parzelle, self.currentUser, recordFilter=recordFilter or {} ) return [Parzelle(**r) for r in records] 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.checkRbacPermission(Parzelle, "update", 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.checkRbacPermission(Parzelle, "delete", 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.""" # Check RBAC permission if not self.checkRbacPermission(Dokument, "create"): raise PermissionError(f"User {self.userId} cannot create documents") # Ensure mandateId and featureInstanceId are set for proper data isolation if not dokument.mandateId: dokument.mandateId = self.mandateId if not dokument.featureInstanceId: dokument.featureInstanceId = self.featureInstanceId self.db.recordCreate(Dokument, dokument.model_dump()) return dokument def getDokument(self, dokumentId: str) -> Optional[Dokument]: """Get a document by ID.""" records = getRecordsetWithRBAC( self.db, Dokument, self.currentUser, recordFilter={"id": dokumentId}, featureCode=self.FEATURE_CODE ) if not records: return None return Dokument(**records[0]) def getDokumente(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Dokument]: """Get all documents matching the filter.""" records = getRecordsetWithRBAC( self.db, Dokument, self.currentUser, recordFilter=recordFilter or {}, featureCode=self.FEATURE_CODE ) return [Dokument(**r) for r in records] 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.checkRbacPermission(Dokument, "update", 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.checkRbacPermission(Dokument, "delete", 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.""" # Check RBAC permission if not self.checkRbacPermission(Gemeinde, "create"): raise PermissionError(f"User {self.userId} cannot create municipalities") # Ensure mandateId and featureInstanceId are set for proper data isolation if not gemeinde.mandateId: gemeinde.mandateId = self.mandateId if not gemeinde.featureInstanceId: gemeinde.featureInstanceId = self.featureInstanceId self.db.recordCreate(Gemeinde, gemeinde.model_dump()) return gemeinde def getGemeinde(self, gemeindeId: str) -> Optional[Gemeinde]: """Get a municipality by ID.""" records = getRecordsetWithRBAC( self.db, Gemeinde, self.currentUser, recordFilter={"id": gemeindeId}, featureCode=self.FEATURE_CODE ) if not records: return None return Gemeinde(**records[0]) def getGemeinden(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Gemeinde]: """Get all municipalities matching the filter.""" records = getRecordsetWithRBAC( self.db, Gemeinde, self.currentUser, recordFilter=recordFilter or {}, featureCode=self.FEATURE_CODE ) return [Gemeinde(**r) for r in records] 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.checkRbacPermission(Gemeinde, "update", 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.checkRbacPermission(Gemeinde, "delete", 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.""" # Check RBAC permission if not self.checkRbacPermission(Kanton, "create"): raise PermissionError(f"User {self.userId} cannot create cantons") # Ensure mandateId and featureInstanceId are set for proper data isolation if not kanton.mandateId: kanton.mandateId = self.mandateId if not kanton.featureInstanceId: kanton.featureInstanceId = self.featureInstanceId self.db.recordCreate(Kanton, kanton.model_dump()) return kanton def getKanton(self, kantonId: str) -> Optional[Kanton]: """Get a canton by ID.""" records = getRecordsetWithRBAC( self.db, Kanton, self.currentUser, recordFilter={"id": kantonId}, featureCode=self.FEATURE_CODE ) if not records: return None return Kanton(**records[0]) def getKantone(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Kanton]: """Get all cantons matching the filter.""" records = getRecordsetWithRBAC( self.db, Kanton, self.currentUser, recordFilter=recordFilter or {}, featureCode=self.FEATURE_CODE ) return [Kanton(**r) for r in records] 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.checkRbacPermission(Kanton, "update", 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.checkRbacPermission(Kanton, "delete", 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.""" # Check RBAC permission if not self.checkRbacPermission(Land, "create"): raise PermissionError(f"User {self.userId} cannot create countries") # Ensure mandateId and featureInstanceId are set for proper data isolation if not land.mandateId: land.mandateId = self.mandateId if not land.featureInstanceId: land.featureInstanceId = self.featureInstanceId self.db.recordCreate(Land, land.model_dump()) return land def getLand(self, landId: str) -> Optional[Land]: """Get a country by ID.""" records = getRecordsetWithRBAC( self.db, Land, self.currentUser, recordFilter={"id": landId}, featureCode=self.FEATURE_CODE ) if not records: return None return Land(**records[0]) def getLaender(self, recordFilter: Optional[Dict[str, Any]] = None) -> List[Land]: """Get all countries matching the filter.""" records = getRecordsetWithRBAC( self.db, Land, self.currentUser, recordFilter=recordFilter or {}, featureCode=self.FEATURE_CODE ) return [Land(**r) for r in records] 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.checkRbacPermission(Land, "update", 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.checkRbacPermission(Land, "delete", landId): raise PermissionError(f"User {self.userId} cannot delete country {landId}") return self.db.recordDelete(Land, landId) # ===== RBAC Permission Checks ===== def checkRbacPermission( self, modelClass: type, operation: str, recordId: Optional[str] = None ) -> bool: """ Check RBAC permission for a specific operation on a table. Args: modelClass: Pydantic model class for the table operation: Operation to check ('create', 'update', 'delete', 'read') recordId: Optional record ID for specific record check Returns: Boolean indicating permission """ if not self.rbac or not self.currentUser: return False tableName = modelClass.__name__ from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None) permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId ) if operation == "create": return permissions.create != AccessLevel.NONE elif operation == "update": return permissions.update != AccessLevel.NONE elif operation == "delete": return permissions.delete != AccessLevel.NONE elif operation == "read": return permissions.read != AccessLevel.NONE else: return False # ===== 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, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> RealEstateObjects: """ Factory function to get or create a Real Estate interface instance for a user. Uses singleton pattern per user. Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header). """ effectiveMandateId = str(mandateId) if mandateId else None effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None # Include featureInstanceId in key for proper isolation userKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}" if userKey not in _realEstateInterfaces: _realEstateInterfaces[userKey] = RealEstateObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) else: # Update user context if needed _realEstateInterfaces[userKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) return _realEstateInterfaces[userKey]