RealEstate: routeRealEstate, mainRealEstate, routeFeatureRealEstate, routeSystem, requirements

This commit is contained in:
Stephan Schellworth 2026-02-03 08:40:22 +01:00
parent 32f7251b95
commit 5ddb857c4b
5 changed files with 2104 additions and 5 deletions

View file

@ -15,6 +15,11 @@ FEATURE_ICON = "mdi-home-city"
# UI Objects for RBAC catalog # UI Objects for RBAC catalog
UI_OBJECTS = [ UI_OBJECTS = [
{
"objectKey": "ui.feature.realestate.dashboard",
"label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"},
"meta": {"area": "dashboard"}
},
{ {
"objectKey": "ui.feature.realestate.projects", "objectKey": "ui.feature.realestate.projects",
"label": {"en": "Projects", "de": "Projekte", "fr": "Projets"}, "label": {"en": "Projects", "de": "Projekte", "fr": "Projets"},
@ -70,6 +75,7 @@ TEMPLATE_ROLES = [
}, },
"accessRules": [ "accessRules": [
# UI access to main views - vollqualifizierte ObjectKeys # UI access to main views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True}, {"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True}, {"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
# Group-level DATA access # Group-level DATA access
@ -87,6 +93,7 @@ TEMPLATE_ROLES = [
}, },
"accessRules": [ "accessRules": [
# UI access to view-only views - vollqualifizierte ObjectKeys # UI access to view-only views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.realestate.projects", "view": True}, {"context": "UI", "item": "ui.feature.realestate.projects", "view": True},
{"context": "UI", "item": "ui.feature.realestate.parcels", "view": True}, {"context": "UI", "item": "ui.feature.realestate.parcels", "view": True},
# Read-only DATA access (my records) # Read-only DATA access (my records)
@ -139,10 +146,139 @@ def registerFeature(catalogService) -> bool:
meta=resObj.get("meta") meta=resObj.get("meta")
) )
# Sync template roles to database (with AccessRules)
_syncTemplateRolesToDb()
return True return True
except Exception as e: except Exception as e:
logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}") logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False return False
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
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(db, roleId, roleTemplate.get("accessRules", []))
else:
newRole = Role(
roleLabel=roleLabel,
description=roleTemplate.get("description", {}),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,
isSystemRole=False
)
createdRole = db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
existingRoleLabels[roleLabel] = roleId
_ensureAccessRulesForRole(db, roleId, roleTemplate.get("accessRules", []))
logging.getLogger(__name__).info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
if createdCount > 0:
logging.getLogger(__name__).info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles")
_repairInstanceRolesAccessRules(db, existingRoleLabels)
return createdCount
except Exception as e:
logging.getLogger(__name__).error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _repairInstanceRolesAccessRules(db, templateRoleLabels: dict) -> int:
"""Repair instance-specific roles by copying AccessRules from their template roles."""
from modules.datamodels.datamodelRbac import Role, AccessRule
repairedCount = 0
allRoles = db.getRecordset(Role, recordFilter={"featureCode": FEATURE_CODE})
instanceRoles = [r for r in allRoles if r.get("mandateId") is not None]
for instanceRole in instanceRoles:
roleLabel = instanceRole.get("roleLabel")
instanceRoleId = instanceRole.get("id")
templateRoleId = templateRoleLabels.get(roleLabel)
if not templateRoleId:
continue
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": instanceRoleId})
if existingRules:
continue
templateRules = db.getRecordset(AccessRule, recordFilter={"roleId": templateRoleId})
if not templateRules:
continue
for rule in templateRules:
newRule = AccessRule(
roleId=instanceRoleId,
context=rule.get("context"),
item=rule.get("item"),
view=rule.get("view", False),
read=rule.get("read"),
create=rule.get("create"),
update=rule.get("update"),
delete=rule.get("delete"),
)
db.recordCreate(AccessRule, newRule.model_dump())
repairedCount += 1
return repairedCount
def _ensureAccessRulesForRole(db, roleId: str, ruleTemplates: list) -> int:
"""Ensure AccessRules exist for a role based on templates."""
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
existingSignatures = {(r.get("context"), r.get("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"),
)
db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
existingSignatures.add((context, item))
return createdCount
import json import json
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from fastapi import HTTPException, status from fastapi import HTTPException, status

View file

@ -13,7 +13,14 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Pat
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
# Import models # Import models
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata from modules.datamodels.datamodelPagination import (
PaginationParams,
PaginatedResponse,
PaginationMetadata,
normalize_pagination_dict,
)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from .datamodelFeatureRealEstate import ( from .datamodelFeatureRealEstate import (
Projekt, Projekt,
Parzelle, Parzelle,
@ -57,6 +64,403 @@ router = APIRouter(
) )
# ===== Helper Functions (instanceId-based routes, backend-driven like Trustee) =====
def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
"""Parse pagination parameter from JSON string."""
if not pagination:
return None
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
return PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
return None
async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""
Validate that the user has access to the feature instance.
Returns the mandateId for the instance.
"""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=404,
detail=f"Feature instance '{instanceId}' not found"
)
if instance.featureCode != "realestate":
raise HTTPException(
status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance"
)
if not context.isSysAdmin:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled
for fa in featureAccesses
)
if not hasAccess:
raise HTTPException(
status_code=403,
detail=f"Access denied to feature instance '{instanceId}'"
)
return str(instance.mandateId)
# Mapping of entity names to Pydantic model classes (for attributes endpoint)
_REALESTATE_ENTITY_MODELS = {
"Projekt": Projekt,
"Parzelle": Parzelle,
"Dokument": Dokument,
"Gemeinde": Gemeinde,
"Kanton": Kanton,
"Land": Land,
}
# ============================================================================
# INSTANCE-ID ROUTES (backend-driven, analog to Trustee)
# ============================================================================
@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def get_entity_attributes(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
entityType: str = Path(..., description="Entity type (e.g., Projekt, Parzelle)"),
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""Get attribute definitions for a Real Estate entity. Used by FormGeneratorTable."""
await _validateInstanceAccess(instanceId, context)
if entityType not in _REALESTATE_ENTITY_MODELS:
raise HTTPException(
status_code=404,
detail=f"Unknown entity type: {entityType}. Valid types: {list(_REALESTATE_ENTITY_MODELS.keys())}"
)
modelClass = _REALESTATE_ENTITY_MODELS[entityType]
try:
attrDefs = getModelAttributeDefinitions(modelClass)
visibleAttrs = [
attr for attr in attrDefs.get("attributes", [])
if isinstance(attr, dict) and attr.get("visible", True)
]
return {"attributes": visibleAttrs}
except Exception as e:
logger.error(f"Error getting attributes for {entityType}: {e}")
raise HTTPException(
status_code=500,
detail=f"Error getting attributes for {entityType}: {str(e)}"
)
@router.get("/{instanceId}/projects/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def get_project_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""Get project options for select dropdowns. Returns: [{ value, label }]"""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
items = interface.getProjekte(recordFilter={"featureInstanceId": instanceId})
return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
@router.get("/{instanceId}/parcels/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def get_parcel_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""Get parcel options for select dropdowns. Returns: [{ value, label }]"""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
items = interface.getParzellen(recordFilter={"featureInstanceId": instanceId})
return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items]
# ----- Projects CRUD -----
@router.get("/{instanceId}/projects", response_model=PaginatedResponse[Projekt])
@limiter.limit("30/minute")
async def get_projects(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Projekt]:
"""Get all projects for a feature instance with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
recordFilter = {"featureInstanceId": instanceId}
items = interface.getProjekte(recordFilter=recordFilter)
paginationParams = _parsePagination(pagination)
if paginationParams:
if paginationParams.sort:
for sort_field in reversed(paginationParams.sort):
field_name = sort_field.field
direction = sort_field.direction.lower()
items.sort(
key=lambda x: getattr(x, field_name, None),
reverse=(direction == "desc")
)
total_items = len(items)
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
start_idx = (paginationParams.page - 1) * paginationParams.pageSize
end_idx = start_idx + paginationParams.pageSize
paginated_items = items[start_idx:end_idx]
return PaginatedResponse(
items=paginated_items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=total_items,
totalPages=total_pages,
sort=paginationParams.sort or [],
filters=paginationParams.filters
)
)
return PaginatedResponse(items=items, pagination=None)
@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt)
@limiter.limit("30/minute")
async def get_project_by_id(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
projectId: str = Path(..., description="Project ID"),
context: RequestContext = Depends(getRequestContext)
) -> Projekt:
"""Get a single project by ID."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
projekt = interface.getProjekt(projectId)
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
return projekt
@router.post("/{instanceId}/projects", response_model=Projekt)
@limiter.limit("30/minute")
async def create_project(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Projekt:
"""Create a new project."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
if "mandateId" not in data:
data["mandateId"] = mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = instanceId
try:
projekt = Projekt(**data)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
return interface.createProjekt(projekt)
@router.put("/{instanceId}/projects/{projectId}", response_model=Projekt)
@limiter.limit("30/minute")
async def update_project(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
projectId: str = Path(..., description="Project ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Projekt:
"""Update a project."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
projekt = interface.getProjekt(projectId)
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
updated = interface.updateProjekt(projectId, data)
if not updated:
raise HTTPException(status_code=500, detail="Update failed")
return updated
@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
async def delete_project(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
projectId: str = Path(..., description="Project ID"),
context: RequestContext = Depends(getRequestContext)
) -> None:
"""Delete a project."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
projekt = interface.getProjekt(projectId)
if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found")
if not interface.deleteProjekt(projectId):
raise HTTPException(status_code=500, detail="Delete failed")
# ----- Parcels CRUD -----
@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle])
@limiter.limit("30/minute")
async def get_parcels(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Parzelle]:
"""Get all parcels for a feature instance with optional pagination."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
recordFilter = {"featureInstanceId": instanceId}
items = interface.getParzellen(recordFilter=recordFilter)
paginationParams = _parsePagination(pagination)
if paginationParams:
if paginationParams.sort:
for sort_field in reversed(paginationParams.sort):
field_name = sort_field.field
direction = sort_field.direction.lower()
items.sort(
key=lambda x: getattr(x, field_name, None),
reverse=(direction == "desc")
)
total_items = len(items)
total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize
start_idx = (paginationParams.page - 1) * paginationParams.pageSize
end_idx = start_idx + paginationParams.pageSize
paginated_items = items[start_idx:end_idx]
return PaginatedResponse(
items=paginated_items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=total_items,
totalPages=total_pages,
sort=paginationParams.sort or [],
filters=paginationParams.filters
)
)
return PaginatedResponse(items=items, pagination=None)
@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
@limiter.limit("30/minute")
async def get_parcel_by_id(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
parcelId: str = Path(..., description="Parcel ID"),
context: RequestContext = Depends(getRequestContext)
) -> Parzelle:
"""Get a single parcel by ID."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parzelle = interface.getParzelle(parcelId)
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
return parzelle
@router.post("/{instanceId}/parcels", response_model=Parzelle)
@limiter.limit("30/minute")
async def create_parcel(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Parzelle:
"""Create a new parcel."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
if "mandateId" not in data:
data["mandateId"] = mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = instanceId
try:
parzelle = Parzelle(**data)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}")
return interface.createParzelle(parzelle)
@router.put("/{instanceId}/parcels/{parcelId}", response_model=Parzelle)
@limiter.limit("30/minute")
async def update_parcel(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
parcelId: str = Path(..., description="Parcel ID"),
data: Dict[str, Any] = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> Parzelle:
"""Update a parcel."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parzelle = interface.getParzelle(parcelId)
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
updated = interface.updateParzelle(parcelId, data)
if not updated:
raise HTTPException(status_code=500, detail="Update failed")
return updated
@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
async def delete_parcel(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
parcelId: str = Path(..., description="Parcel ID"),
context: RequestContext = Depends(getRequestContext)
) -> None:
"""Delete a parcel."""
mandateId = await _validateInstanceAccess(instanceId, context)
interface = getRealEstateInterface(
context.user, mandateId=mandateId, featureInstanceId=instanceId
)
parzelle = interface.getParzelle(parcelId)
if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId:
raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found")
if not interface.deleteParzelle(parcelId):
raise HTTPException(status_code=500, detail="Delete failed")
# ============================================================================
# LEGACY / STATELESS ROUTES (unchanged)
# ============================================================================
@router.post("/command", response_model=Dict[str, Any]) @router.post("/command", response_model=Dict[str, Any])
@limiter.limit("120/minute") @limiter.limit("120/minute")
async def process_command( async def process_command(

File diff suppressed because it is too large Load diff

View file

@ -106,7 +106,7 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
from modules.features.trustee.mainTrustee import UI_OBJECTS from modules.features.trustee.mainTrustee import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
elif featureCode == "realestate": elif featureCode == "realestate":
from modules.features.realestate.mainRealEstate import UI_OBJECTS from modules.features.realEstate.mainRealEstate import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
else: else:
logger.warning(f"Unknown feature code: {featureCode}") logger.warning(f"Unknown feature code: {featureCode}")

View file

@ -71,9 +71,6 @@ google-cloud-texttospeech==2.16.3
## MSFT Integration ## MSFT Integration
msal==1.24.1 msal==1.24.1
## Azure Integration
azure-communication-email>=1.0.0 # Azure Communication Services Email
## Testing Dependencies ## Testing Dependencies
pytest>=8.0.0 pytest>=8.0.0
pytest-asyncio>=0.21.0 pytest-asyncio>=0.21.0