This commit is contained in:
ValueOn AG 2026-01-24 18:01:28 +01:00
parent 4de962d7d6
commit df4c60fc99
30 changed files with 705 additions and 381 deletions

10
app.py
View file

@ -335,6 +335,15 @@ async def lifespan(app: FastAPI):
logger.info("Application has been shut down")
# Custom function to generate readable operation IDs for Swagger UI
# Uses snake_case function names directly instead of auto-generated IDs
def _generateOperationId(route) -> str:
"""Generate operation ID from route function name (snake_case)."""
if hasattr(route, "endpoint") and hasattr(route.endpoint, "__name__"):
return route.endpoint.__name__
return route.name if route.name else "unknown"
# START APP
app = FastAPI(
title="PowerOn | Data Platform API",
@ -343,6 +352,7 @@ app = FastAPI(
swagger_ui_init_oauth={
"usePkceWithAuthorizationCodeGrant": True,
},
generate_unique_id_function=_generateOperationId,
)
# Configure OpenAPI security scheme for Swagger UI

View file

@ -166,15 +166,38 @@ class User(BaseModel):
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
language: str = Field(
default="en",
description="Preferred language of the user",
default="de",
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [
{"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "fr": "Italien"}},
{"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}},
{"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}},
{"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}},
{"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}},
]}
)
@field_validator('language', mode='before')
@classmethod
def _normalizeLanguage(cls, v):
"""Normalize language to valid ISO 639-1 code."""
if v is None:
return "de"
# Map common variations to standard codes
langMap = {
'english': 'en', 'englisch': 'en',
'german': 'de', 'deutsch': 'de',
'french': 'fr', 'französisch': 'fr', 'francais': 'fr',
'italian': 'it', 'italienisch': 'it', 'italiano': 'it',
}
normalized = str(v).lower().strip()
if normalized in langMap:
return langMap[normalized]
# If already a valid code, return as-is
if normalized in ['de', 'en', 'fr', 'it']:
return normalized
# Default fallback
return "de"
enabled: bool = Field(
default=True,
description="Indicates whether the user is enabled",

View file

@ -282,9 +282,10 @@ class ChatObjects:
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
# 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.
# Add language settings
self.userLanguage = currentUser.language # Default user language

View file

@ -126,9 +126,10 @@ class RealEstateObjects:
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
# 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:

View file

@ -279,7 +279,12 @@ registerModelLabels(
class TrusteeDocument(BaseModel):
"""Contains document references and receipts for bookings."""
"""Contains document references and receipts for bookings.
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique document ID",
@ -289,25 +294,6 @@ class TrusteeDocument(BaseModel):
"frontend_required": False
}
)
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
}
)
contractId: str = Field(
description="Reference to TrusteeContract.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
"frontend_depends_on": "organisationId"
}
)
documentData: Optional[bytes] = Field(
default=None,
description="The file content (binary)",
@ -332,30 +318,27 @@ class TrusteeDocument(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": [
{"value": "application/pdf", "label": {"en": "PDF", "fr": "PDF", "de": "PDF"}},
{"value": "image/jpeg", "label": {"en": "JPEG", "fr": "JPEG", "de": "JPEG"}},
{"value": "image/png", "label": {"en": "PNG", "fr": "PNG", "de": "PNG"}},
{"value": "application/octet-stream", "label": {"en": "Other", "fr": "Autre", "de": "Andere"}},
]
"frontend_options": "/api/trustee/mime-types/options"
}
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID",
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
"frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
"frontend_required": False,
"frontend_hidden": True
}
)
# System attributes are automatically set by DatabaseConnector
@ -366,8 +349,6 @@ registerModelLabels(
{"en": "Document", "fr": "Document", "de": "Dokument"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"documentData": {"en": "Document Data", "fr": "Données du document", "de": "Dokumentdaten"},
"documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
"documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"},
@ -378,7 +359,12 @@ registerModelLabels(
class TrusteePosition(BaseModel):
"""Contains booking positions (expense entries)."""
"""Contains booking positions (expense entries).
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique position ID",
@ -388,25 +374,6 @@ class TrusteePosition(BaseModel):
"frontend_required": False
}
)
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
}
)
contractId: str = Field(
description="Reference to TrusteeContract.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
"frontend_depends_on": "organisationId"
}
)
valuta: Optional[str] = Field(
default=None,
description="Value date (ISO format: YYYY-MM-DD)",
@ -520,20 +487,22 @@ class TrusteePosition(BaseModel):
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID",
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
"frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
"frontend_required": False,
"frontend_hidden": True
}
)
# System attributes are automatically set by DatabaseConnector
@ -544,8 +513,6 @@ registerModelLabels(
{"en": "Position", "fr": "Position", "de": "Position"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"},
"transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
"company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
@ -564,7 +531,12 @@ registerModelLabels(
class TrusteePositionDocument(BaseModel):
"""Cross-reference table linking positions to documents (many-to-many)."""
"""Cross-reference table linking positions to documents (many-to-many).
Note: organisationId and contractId removed as per architecture decision:
- The feature instance IS the organisation
- Contracts are eliminated from the model
"""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique link ID",
@ -574,33 +546,13 @@ class TrusteePositionDocument(BaseModel):
"frontend_required": False
}
)
organisationId: str = Field(
description="Reference to TrusteeOrganisation.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
}
)
contractId: str = Field(
description="Reference to TrusteeContract.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
"frontend_depends_on": "organisationId"
}
)
documentId: str = Field(
description="Reference to TrusteeDocument.id",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/documents/options",
"frontend_depends_on": "contractId"
"frontend_options": "/api/trustee/{instanceId}/documents/options"
}
)
positionId: str = Field(
@ -609,26 +561,27 @@ class TrusteePositionDocument(BaseModel):
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": True,
"frontend_options": "/api/trustee/{instanceId}/positions/options",
"frontend_depends_on": "contractId"
"frontend_options": "/api/trustee/{instanceId}/positions/options"
}
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID",
description="Mandate ID (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
"frontend_required": False,
"frontend_hidden": True
}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="Feature Instance ID for instance-level isolation",
description="Feature Instance ID for instance-level isolation (auto-set from context)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False
"frontend_required": False,
"frontend_hidden": True
}
)
# System attributes are automatically set by DatabaseConnector
@ -639,8 +592,6 @@ registerModelLabels(
{"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"},
{
"id": {"en": "ID", "fr": "ID", "de": "ID"},
"organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
"contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
"documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
"positionId": {"en": "Position", "fr": "Position", "de": "Position"},
"mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},

View file

@ -110,9 +110,10 @@ class TrusteeObjects:
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
# 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.
self.userLanguage = currentUser.language
@ -734,18 +735,19 @@ class TrusteeObjects:
# ===== Document CRUD =====
def createDocument(self, data: Dict[str, Any]) -> Optional[TrusteeDocument]:
"""Create a new document."""
organisationId = data.get("organisationId")
contractId = data.get("contractId")
"""Create a new document.
# Check combined permission (system RBAC + feature-level)
if not self.checkCombinedPermission(TrusteeDocument, "create", organisationId, contractId):
logger.warning(f"User {self.userId} lacks permission to create document in org {organisationId}")
Note: organisationId and contractId removed - feature instance IS the organisation.
Permission is checked via system RBAC (feature-level access).
"""
# Check system RBAC permission
if not self.checkCombinedPermission(TrusteeDocument, "create"):
logger.warning(f"User {self.userId} lacks permission to create document")
return None
# Auto-set context fields
data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
data["featureInstanceId"] = self.featureInstanceId
import uuid
documentId = data.get("id") or str(uuid.uuid4())
@ -790,20 +792,22 @@ class TrusteeObjects:
# This applies userreport filtering (only own records)
records = self.filterRecordsByTrusteeAccess(records, TrusteeDocument)
# Remove binary data from responses
# Convert dicts to Pydantic objects (remove binary data and internal fields)
pydanticItems = []
for record in records:
record.pop("documentData", None)
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") and k != "documentData"}
pydanticItems.append(TrusteeDocument(**cleanedRecord))
totalItems = len(records)
totalItems = len(pydanticItems)
if params:
pageSize = params.pageSize or 20
page = params.page or 1
startIdx = (page - 1) * pageSize
endIdx = startIdx + pageSize
items = records[startIdx:endIdx]
items = pydanticItems[startIdx:endIdx]
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
else:
items = records
items = pydanticItems
totalPages = 1
page = 1
pageSize = totalItems
@ -835,8 +839,11 @@ class TrusteeObjects:
return result
def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[TrusteeDocument]:
"""Update a document."""
# Get existing document to check organisation and creator
"""Update a document.
Note: organisationId and contractId removed - feature instance IS the organisation.
"""
# Get existing document to check creator
existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
existing = existingRecords[0] if existingRecords else None
@ -844,14 +851,11 @@ class TrusteeObjects:
logger.warning(f"Document {documentId} not found")
return None
organisationId = existing.get("organisationId")
contractId = existing.get("contractId")
createdBy = existing.get("_createdBy")
# Check combined permission (system RBAC + feature-level)
# For userreport, this checks if they created the record
if not self.checkCombinedPermission(TrusteeDocument, "update", organisationId, contractId, createdBy):
logger.warning(f"User {self.userId} lacks permission to update document in org {organisationId}")
# Check system RBAC permission (userreport can only edit their own records)
if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to update document")
return None
data["id"] = documentId
@ -862,8 +866,11 @@ class TrusteeObjects:
return TrusteeDocument(**cleanedRecord)
def deleteDocument(self, documentId: str) -> bool:
"""Delete a document."""
# Get existing document to check organisation and creator
"""Delete a document.
Note: organisationId and contractId removed - feature instance IS the organisation.
"""
# Get existing document to check creator
existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId})
existing = existingRecords[0] if existingRecords else None
@ -871,14 +878,11 @@ class TrusteeObjects:
logger.warning(f"Document {documentId} not found")
return False
organisationId = existing.get("organisationId")
contractId = existing.get("contractId")
createdBy = existing.get("_createdBy")
# Check combined permission (system RBAC + feature-level)
# For userreport, this checks if they created the record
if not self.checkCombinedPermission(TrusteeDocument, "delete", organisationId, contractId, createdBy):
logger.warning(f"User {self.userId} lacks permission to delete document in org {organisationId}")
# Check system RBAC permission (userreport can only delete their own records)
if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete document")
return False
return self.db.recordDelete(TrusteeDocument, documentId)
@ -886,18 +890,19 @@ class TrusteeObjects:
# ===== Position CRUD =====
def createPosition(self, data: Dict[str, Any]) -> Optional[TrusteePosition]:
"""Create a new position."""
organisationId = data.get("organisationId")
contractId = data.get("contractId")
"""Create a new position.
# Check combined permission (system RBAC + feature-level)
if not self.checkCombinedPermission(TrusteePosition, "create", organisationId, contractId):
logger.warning(f"User {self.userId} lacks permission to create position in org {organisationId}")
Note: organisationId and contractId removed - feature instance IS the organisation.
Permission is checked via system RBAC (feature-level access).
"""
# Check system RBAC permission
if not self.checkCombinedPermission(TrusteePosition, "create"):
logger.warning(f"User {self.userId} lacks permission to create position")
return None
# Auto-set context fields
data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
data["featureInstanceId"] = self.featureInstanceId
# Calculate VAT amount if not provided
if "vatAmount" not in data or data.get("vatAmount") == 0:
@ -936,16 +941,22 @@ class TrusteeObjects:
# This applies userreport filtering (only own records)
records = self.filterRecordsByTrusteeAccess(records, TrusteePosition)
totalItems = len(records)
# Convert dicts to Pydantic objects (remove internal fields)
pydanticItems = []
for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")}
pydanticItems.append(TrusteePosition(**cleanedRecord))
totalItems = len(pydanticItems)
if params:
pageSize = params.pageSize or 20
page = params.page or 1
startIdx = (page - 1) * pageSize
endIdx = startIdx + pageSize
items = records[startIdx:endIdx]
items = pydanticItems[startIdx:endIdx]
totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
else:
items = records
items = pydanticItems
totalPages = 1
page = 1
pageSize = totalItems
@ -987,8 +998,11 @@ class TrusteeObjects:
return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[TrusteePosition]:
"""Update a position."""
# Get existing position to check organisation and creator
"""Update a position.
Note: organisationId and contractId removed - feature instance IS the organisation.
"""
# Get existing position to check creator
existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
existing = existingRecords[0] if existingRecords else None
@ -996,14 +1010,11 @@ class TrusteeObjects:
logger.warning(f"Position {positionId} not found")
return None
organisationId = existing.get("organisationId")
contractId = existing.get("contractId")
createdBy = existing.get("_createdBy")
# Check combined permission (system RBAC + feature-level)
# For userreport, this checks if they created the record
if not self.checkCombinedPermission(TrusteePosition, "update", organisationId, contractId, createdBy):
logger.warning(f"User {self.userId} lacks permission to update position in org {organisationId}")
# Check system RBAC permission (userreport can only edit their own records)
if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to update position")
return None
data["id"] = positionId
@ -1013,8 +1024,11 @@ class TrusteeObjects:
return TrusteePosition(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")})
def deletePosition(self, positionId: str) -> bool:
"""Delete a position."""
# Get existing position to check organisation and creator
"""Delete a position.
Note: organisationId and contractId removed - feature instance IS the organisation.
"""
# Get existing position to check creator
existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId})
existing = existingRecords[0] if existingRecords else None
@ -1022,14 +1036,11 @@ class TrusteeObjects:
logger.warning(f"Position {positionId} not found")
return False
organisationId = existing.get("organisationId")
contractId = existing.get("contractId")
createdBy = existing.get("_createdBy")
# Check combined permission (system RBAC + feature-level)
# For userreport, this checks if they created the record
if not self.checkCombinedPermission(TrusteePosition, "delete", organisationId, contractId, createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position in org {organisationId}")
# Check system RBAC permission (userreport can only delete their own records)
if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position")
return False
return self.db.recordDelete(TrusteePosition, positionId)
@ -1037,18 +1048,19 @@ class TrusteeObjects:
# ===== Position-Document Link CRUD =====
def createPositionDocument(self, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]:
"""Create a new position-document link."""
organisationId = data.get("organisationId")
contractId = data.get("contractId")
"""Create a new position-document link.
# Check combined permission (system RBAC + feature-level)
if not self.checkCombinedPermission(TrusteePositionDocument, "create", organisationId, contractId):
logger.warning(f"User {self.userId} lacks permission to create position-document link in org {organisationId}")
Note: organisationId and contractId removed - feature instance IS the organisation.
Permission is checked via system RBAC (feature-level access).
"""
# Check system RBAC permission
if not self.checkCombinedPermission(TrusteePositionDocument, "create"):
logger.warning(f"User {self.userId} lacks permission to create position-document link")
return None
# Auto-set context fields
data["mandateId"] = self.mandateId
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
data["featureInstanceId"] = self.featureInstanceId
import uuid
linkId = data.get("id") or str(uuid.uuid4())
@ -1132,8 +1144,11 @@ class TrusteeObjects:
return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered]
def deletePositionDocument(self, linkId: str) -> bool:
"""Delete a position-document link."""
# Get existing link to check organisation and creator
"""Delete a position-document link.
Note: organisationId and contractId removed - feature instance IS the organisation.
"""
# Get existing link to check creator
existingRecords = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId})
existing = existingRecords[0] if existingRecords else None
@ -1141,14 +1156,11 @@ class TrusteeObjects:
logger.warning(f"Position-document link {linkId} not found")
return False
organisationId = existing.get("organisationId")
contractId = existing.get("contractId")
createdBy = existing.get("_createdBy")
# Check combined permission (system RBAC + feature-level)
# For userreport, this checks if they created the record
if not self.checkCombinedPermission(TrusteePositionDocument, "delete", organisationId, contractId, createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position-document link in org {organisationId}")
# Check system RBAC permission (userreport can only delete their own records)
if not self.checkCombinedPermission(TrusteePositionDocument, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position-document link")
return False
return self.db.recordDelete(TrusteePositionDocument, linkId)
@ -1317,6 +1329,15 @@ class TrusteeObjects:
if accessLevel == AccessLevel.ALL:
return records
# NEW: Feature-instance based access (new system)
# If featureInstanceId is set, user has access via FeatureAccess system.
# Data is already filtered by featureInstanceId in getRecordsetWithRBAC.
# The old TrusteeAccess system (organisation-based) is not used for
# feature-instance scoped data.
if self.featureInstanceId:
return records # User already has access to this feature instance
# LEGACY: TrusteeAccess based filtering (for backwards compatibility)
# Get all user's access records
userAccess = self.getAllUserAccess(self.userId)

View file

@ -9,13 +9,14 @@ URL Structure: /api/trustee/{instanceId}/{entity}
- This ensures proper multi-tenant isolation at the URL level
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response, UploadFile, File, Form
from fastapi.responses import StreamingResponse
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
import json
import io
import base64
from modules.auth import limiter, getRequestContext, RequestContext
from .interfaceFeatureTrustee import getInterface
@ -133,7 +134,7 @@ _TRUSTEE_ENTITY_MODELS = {
@router.get("/{instanceId}/attributes/{entityType}")
@limiter.limit("30/minute")
async def getEntityAttributes(
async def get_entity_attributes(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
entityType: str = Path(..., description="Entity type (e.g., TrusteeDocument)"),
@ -179,9 +180,44 @@ async def getEntityAttributes(
# OPTIONS ENDPOINTS (for dropdowns)
# ============================================================================
@router.get("/mime-types/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def get_mime_type_options(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
"""Get supported MIME types from the document extraction service.
Returns: [{ value: "mime/type", label: "Description" }]
"""
from modules.services.serviceExtraction.subRegistry import ExtractorRegistry
registry = ExtractorRegistry()
formats = registry.getSupportedFormats()
# Collect all unique MIME types
allMimeTypes = set()
for mimeList in formats.get("mime_types", {}).values():
allMimeTypes.update(mimeList)
# Sort and create options with labels
result = []
for mimeType in sorted(allMimeTypes):
# Create readable label from mime type
parts = mimeType.split("/")
if len(parts) == 2:
mainType, subType = parts
# Clean up subtype for label
label = subType.replace("vnd.", "").replace("x-", "").replace("-", " ").title()
result.append({"value": mimeType, "label": f"{label} ({mimeType})"})
else:
result.append({"value": mimeType, "label": mimeType})
return result
@router.get("/{instanceId}/organisations/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getOrganisationOptions(
async def get_organisation_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
@ -196,7 +232,7 @@ async def getOrganisationOptions(
@router.get("/{instanceId}/roles/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getRoleOptions(
async def get_role_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
@ -211,7 +247,7 @@ async def getRoleOptions(
@router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getContractOptions(
async def get_contract_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
organisationId: Optional[str] = Query(None, description="Optional: Filter by organisation ID"),
@ -241,7 +277,7 @@ async def getContractOptions(
@router.get("/{instanceId}/documents/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getDocumentOptions(
async def get_document_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
@ -256,7 +292,7 @@ async def getDocumentOptions(
@router.get("/{instanceId}/positions/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getPositionOptions(
async def get_position_options(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
@ -266,8 +302,8 @@ async def getPositionOptions(
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.getAllPositions(None)
items = result.items if hasattr(result, 'items') else result
# Erstelle Label aus Datum, Firma und Beschreibung
def _makePositionLabel(p):
def _makePositionLabel(p: TrusteePosition) -> str:
parts = []
if p.valuta:
parts.append(str(p.valuta)[:10]) # Datum ohne Zeit
@ -276,6 +312,7 @@ async def getPositionOptions(
if p.desc:
parts.append(p.desc[:30])
return " - ".join(parts) if parts else p.id
return [{"value": p.id, "label": _makePositionLabel(p)} for p in items]
@ -287,7 +324,7 @@ async def getPositionOptions(
@router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
@limiter.limit("30/minute")
async def getOrganisations(
async def get_organisations(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
@ -317,7 +354,7 @@ async def getOrganisations(
@router.get("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
@limiter.limit("30/minute")
async def getOrganisation(
async def get_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(..., description="Organisation ID"),
@ -335,7 +372,7 @@ async def getOrganisation(
@router.post("/{instanceId}/organisations", response_model=TrusteeOrganisation, status_code=201)
@limiter.limit("10/minute")
async def createOrganisation(
async def create_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeOrganisation = Body(...),
@ -353,7 +390,7 @@ async def createOrganisation(
@router.put("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation)
@limiter.limit("10/minute")
async def updateOrganisation(
async def update_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(..., description="Organisation ID"),
@ -376,7 +413,7 @@ async def updateOrganisation(
@router.delete("/{instanceId}/organisations/{orgId}")
@limiter.limit("10/minute")
async def deleteOrganisation(
async def delete_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(..., description="Organisation ID"),
@ -400,7 +437,7 @@ async def deleteOrganisation(
@router.get("/{instanceId}/roles", response_model=PaginatedResponse[TrusteeRole])
@limiter.limit("30/minute")
async def getRoles(
async def get_roles(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None),
@ -430,7 +467,7 @@ async def getRoles(
@router.get("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
@limiter.limit("30/minute")
async def getRole(
async def get_role(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"),
@ -448,7 +485,7 @@ async def getRole(
@router.post("/{instanceId}/roles", response_model=TrusteeRole, status_code=201)
@limiter.limit("10/minute")
async def createRole(
async def create_role(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeRole = Body(...),
@ -466,7 +503,7 @@ async def createRole(
@router.put("/{instanceId}/roles/{roleId}", response_model=TrusteeRole)
@limiter.limit("10/minute")
async def updateRole(
async def update_role(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(...),
@ -489,7 +526,7 @@ async def updateRole(
@router.delete("/{instanceId}/roles/{roleId}")
@limiter.limit("10/minute")
async def deleteRole(
async def delete_role(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(...),
@ -513,7 +550,7 @@ async def deleteRole(
@router.get("/{instanceId}/access", response_model=PaginatedResponse[TrusteeAccess])
@limiter.limit("30/minute")
async def getAllAccess(
async def get_all_access(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None),
@ -543,7 +580,7 @@ async def getAllAccess(
@router.get("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
@limiter.limit("30/minute")
async def getAccess(
async def get_access(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
accessId: str = Path(...),
@ -561,7 +598,7 @@ async def getAccess(
@router.get("/{instanceId}/access/organisation/{orgId}", response_model=List[TrusteeAccess])
@limiter.limit("30/minute")
async def getAccessByOrganisation(
async def get_access_by_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(...),
@ -576,7 +613,7 @@ async def getAccessByOrganisation(
@router.get("/{instanceId}/access/user/{userId}", response_model=List[TrusteeAccess])
@limiter.limit("30/minute")
async def getAccessByUser(
async def get_access_by_user(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
userId: str = Path(...),
@ -591,7 +628,7 @@ async def getAccessByUser(
@router.post("/{instanceId}/access", response_model=TrusteeAccess, status_code=201)
@limiter.limit("10/minute")
async def createAccess(
async def create_access(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeAccess = Body(...),
@ -609,7 +646,7 @@ async def createAccess(
@router.put("/{instanceId}/access/{accessId}", response_model=TrusteeAccess)
@limiter.limit("10/minute")
async def updateAccess(
async def update_access(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
accessId: str = Path(...),
@ -632,7 +669,7 @@ async def updateAccess(
@router.delete("/{instanceId}/access/{accessId}")
@limiter.limit("10/minute")
async def deleteAccess(
async def delete_access(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
accessId: str = Path(...),
@ -656,7 +693,7 @@ async def deleteAccess(
@router.get("/{instanceId}/contracts", response_model=PaginatedResponse[TrusteeContract])
@limiter.limit("30/minute")
async def getContracts(
async def get_contracts(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None),
@ -686,7 +723,7 @@ async def getContracts(
@router.get("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
@limiter.limit("30/minute")
async def getContract(
async def get_contract(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...),
@ -704,7 +741,7 @@ async def getContract(
@router.get("/{instanceId}/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
@limiter.limit("30/minute")
async def getContractsByOrganisation(
async def get_contracts_by_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(...),
@ -719,7 +756,7 @@ async def getContractsByOrganisation(
@router.post("/{instanceId}/contracts", response_model=TrusteeContract, status_code=201)
@limiter.limit("10/minute")
async def createContract(
async def create_contract(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeContract = Body(...),
@ -737,7 +774,7 @@ async def createContract(
@router.put("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract)
@limiter.limit("10/minute")
async def updateContract(
async def update_contract(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...),
@ -760,7 +797,7 @@ async def updateContract(
@router.delete("/{instanceId}/contracts/{contractId}")
@limiter.limit("10/minute")
async def deleteContract(
async def delete_contract(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...),
@ -784,7 +821,7 @@ async def deleteContract(
@router.get("/{instanceId}/documents", response_model=PaginatedResponse[TrusteeDocument])
@limiter.limit("30/minute")
async def getDocuments(
async def get_documents(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None),
@ -814,7 +851,7 @@ async def getDocuments(
@router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
@limiter.limit("30/minute")
async def getDocument(
async def get_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...),
@ -832,7 +869,7 @@ async def getDocument(
@router.get("/{instanceId}/documents/{documentId}/data")
@limiter.limit("10/minute")
async def getDocumentData(
async def get_document_data(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...),
@ -859,7 +896,7 @@ async def getDocumentData(
@router.get("/{instanceId}/documents/contract/{contractId}", response_model=List[TrusteeDocument])
@limiter.limit("30/minute")
async def getDocumentsByContract(
async def get_documents_by_contract(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...),
@ -874,17 +911,66 @@ async def getDocumentsByContract(
@router.post("/{instanceId}/documents", response_model=TrusteeDocument, status_code=201)
@limiter.limit("10/minute")
async def createDocument(
async def create_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteeDocument = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument:
"""Create a new document."""
"""Create a new document. Accepts JSON body with optional base64-encoded documentData."""
mandateId = await _validateInstanceAccess(instanceId, context)
# Parse JSON body
body = await request.json()
# Handle documentData: convert base64 string to bytes if present
if "documentData" in body and body["documentData"]:
dataValue = body["documentData"]
if isinstance(dataValue, str):
# Base64-encoded data from frontend
try:
body["documentData"] = base64.b64decode(dataValue)
except Exception as e:
logger.warning(f"Failed to decode base64 documentData: {e}")
body["documentData"] = None
elif isinstance(dataValue, bytes):
# Already bytes
pass
else:
# Unknown format (e.g., File object serialized wrong)
body["documentData"] = None
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(data.model_dump())
result = interface.createDocument(body)
if not result:
raise HTTPException(status_code=400, detail="Failed to create document")
return result
@router.post("/{instanceId}/documents/upload", response_model=TrusteeDocument, status_code=201)
@limiter.limit("10/minute")
async def upload_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
file: UploadFile = File(..., description="Document file"),
documentName: str = Form(..., description="Document name"),
documentMimeType: str = Form(default="application/octet-stream", description="MIME type"),
context: RequestContext = Depends(getRequestContext)
) -> TrusteeDocument:
"""Upload a document with multipart/form-data."""
mandateId = await _validateInstanceAccess(instanceId, context)
# Read file content
fileContent = await file.read()
# Build document data
docData = {
"documentName": documentName,
"documentMimeType": documentMimeType or file.content_type or "application/octet-stream",
"documentData": fileContent
}
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
result = interface.createDocument(docData)
if not result:
raise HTTPException(status_code=400, detail="Failed to create document")
return result
@ -892,7 +978,7 @@ async def createDocument(
@router.put("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument)
@limiter.limit("10/minute")
async def updateDocument(
async def update_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...),
@ -915,7 +1001,7 @@ async def updateDocument(
@router.delete("/{instanceId}/documents/{documentId}")
@limiter.limit("10/minute")
async def deleteDocument(
async def delete_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...),
@ -939,7 +1025,7 @@ async def deleteDocument(
@router.get("/{instanceId}/positions", response_model=PaginatedResponse[TrusteePosition])
@limiter.limit("30/minute")
async def getPositions(
async def get_positions(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None),
@ -969,7 +1055,7 @@ async def getPositions(
@router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
@limiter.limit("30/minute")
async def getPosition(
async def get_position(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...),
@ -987,7 +1073,7 @@ async def getPosition(
@router.get("/{instanceId}/positions/contract/{contractId}", response_model=List[TrusteePosition])
@limiter.limit("30/minute")
async def getPositionsByContract(
async def get_positions_by_contract(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
contractId: str = Path(...),
@ -1002,7 +1088,7 @@ async def getPositionsByContract(
@router.get("/{instanceId}/positions/organisation/{orgId}", response_model=List[TrusteePosition])
@limiter.limit("30/minute")
async def getPositionsByOrganisation(
async def get_positions_by_organisation(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
orgId: str = Path(...),
@ -1017,7 +1103,7 @@ async def getPositionsByOrganisation(
@router.post("/{instanceId}/positions", response_model=TrusteePosition, status_code=201)
@limiter.limit("10/minute")
async def createPosition(
async def create_position(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteePosition = Body(...),
@ -1035,7 +1121,7 @@ async def createPosition(
@router.put("/{instanceId}/positions/{positionId}", response_model=TrusteePosition)
@limiter.limit("10/minute")
async def updatePosition(
async def update_position(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...),
@ -1058,7 +1144,7 @@ async def updatePosition(
@router.delete("/{instanceId}/positions/{positionId}")
@limiter.limit("10/minute")
async def deletePosition(
async def delete_position(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...),
@ -1082,7 +1168,7 @@ async def deletePosition(
@router.get("/{instanceId}/position-documents", response_model=PaginatedResponse[TrusteePositionDocument])
@limiter.limit("30/minute")
async def getPositionDocuments(
async def get_position_documents(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
pagination: Optional[str] = Query(None),
@ -1112,7 +1198,7 @@ async def getPositionDocuments(
@router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument)
@limiter.limit("30/minute")
async def getPositionDocument(
async def get_position_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
linkId: str = Path(...),
@ -1130,7 +1216,7 @@ async def getPositionDocument(
@router.get("/{instanceId}/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
@limiter.limit("30/minute")
async def getDocumentsForPosition(
async def get_documents_for_position(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
positionId: str = Path(...),
@ -1145,7 +1231,7 @@ async def getDocumentsForPosition(
@router.get("/{instanceId}/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
@limiter.limit("30/minute")
async def getPositionsForDocument(
async def get_positions_for_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
documentId: str = Path(...),
@ -1160,7 +1246,7 @@ async def getPositionsForDocument(
@router.post("/{instanceId}/position-documents", response_model=TrusteePositionDocument, status_code=201)
@limiter.limit("10/minute")
async def createPositionDocument(
async def create_position_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
data: TrusteePositionDocument = Body(...),
@ -1178,7 +1264,7 @@ async def createPositionDocument(
@router.delete("/{instanceId}/position-documents/{linkId}")
@limiter.limit("10/minute")
async def deletePositionDocument(
async def delete_position_document(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
linkId: str = Path(...),
@ -1240,7 +1326,7 @@ async def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> st
@router.get("/{instanceId}/instance-roles", response_model=PaginatedResponse)
@limiter.limit("30/minute")
async def getInstanceRoles(
async def get_instance_roles(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
context: RequestContext = Depends(getRequestContext)
@ -1270,7 +1356,7 @@ async def getInstanceRoles(
@router.get("/{instanceId}/instance-roles/{roleId}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def getInstanceRole(
async def get_instance_role(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"),
@ -1296,7 +1382,7 @@ async def getInstanceRole(
@router.get("/{instanceId}/instance-roles/{roleId}/rules", response_model=PaginatedResponse)
@limiter.limit("30/minute")
async def getInstanceRoleRules(
async def get_instance_role_rules(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"),
@ -1329,7 +1415,7 @@ async def getInstanceRoleRules(
@router.post("/{instanceId}/instance-roles/{roleId}/rules", response_model=Dict[str, Any], status_code=201)
@limiter.limit("10/minute")
async def createInstanceRoleRule(
async def create_instance_role_rule(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"),
@ -1378,7 +1464,7 @@ async def createInstanceRoleRule(
@router.put("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def updateInstanceRoleRule(
async def update_instance_role_rule(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"),
@ -1431,7 +1517,7 @@ async def updateInstanceRoleRule(
@router.delete("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}")
@limiter.limit("10/minute")
async def deleteInstanceRoleRule(
async def delete_instance_role_rule(
request: Request,
instanceId: str = Path(..., description="Feature Instance ID"),
roleId: str = Path(..., description="Role ID"),

View file

@ -107,11 +107,9 @@ class AppObjects:
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId is optional for isSysAdmin users doing system-level operations
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
if not self.mandateId and not isSysAdmin:
# Non-sysadmin users MUST have a mandateId for tenant-scoped operations
logger.warning(f"User {self.userId} has no mandateId context")
# 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.
# Add language settings
self.userLanguage = currentUser.language # Default user language
@ -599,27 +597,52 @@ class AppObjects:
logger.error(f"Error getting user by ID: {str(e)}")
return None
def _getUserForAuthentication(self, username: str) -> Optional[Dict[str, Any]]:
"""
Get user record by username for authentication purposes.
SECURITY NOTE: This method bypasses RBAC intentionally because:
1. Users are NOT mandate-bound (Multi-Tenant Design)
2. Authentication must work regardless of mandate context
3. RBAC filtering for User table requires mandate context which doesn't exist at login time
This method should ONLY be used for authentication flows.
For all other user queries, use getUserByUsername() which applies RBAC.
Returns:
Full UserInDB record as dict, or None if not found
"""
try:
users = self.db.getRecordset(UserInDB, recordFilter={"username": username})
if not users:
return None
return users[0]
except Exception as e:
logger.error(f"Error getting user for authentication: {str(e)}")
return None
def authenticateLocalUser(self, username: str, password: str) -> Optional[User]:
"""Authenticates a user by username and password using local authentication."""
# Clear the users table from cache and reload it
"""
Authenticates a user by username and password using local authentication.
# Get user by username
user = self.getUserByUsername(username)
SECURITY NOTE: Uses _getUserForAuthentication() which bypasses RBAC.
This is intentional because users are mandate-independent.
"""
# Get full user record directly (bypasses RBAC - see _getUserForAuthentication docstring)
userRecord = self._getUserForAuthentication(username)
if not user:
if not userRecord:
raise ValueError("User not found")
# Check if the user is enabled
if not user.enabled:
if not userRecord.get("enabled", True):
raise ValueError("User is disabled")
# Verify that the user has local authentication enabled
if user.authenticationAuthority != AuthAuthority.LOCAL:
authAuthority = userRecord.get("authenticationAuthority", AuthAuthority.LOCAL)
if authAuthority != AuthAuthority.LOCAL and authAuthority != AuthAuthority.LOCAL.value:
raise ValueError("User does not have local authentication enabled")
# Get the full user record with password hash for verification
userRecord = self.db.getRecordset(UserInDB, recordFilter={"id": user.id})[0]
# Check if user has a reset token set (password reset required)
if userRecord.get("resetToken"):
raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.")
@ -630,7 +653,12 @@ class AppObjects:
if not self._verifyPassword(password, userRecord["hashedPassword"]):
raise ValueError("Invalid password")
return user
# Return clean User object (without password hash and internal fields)
cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"}
# Ensure roleLabels is always a list
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
def createUser(
self,

View file

@ -282,9 +282,10 @@ class ChatObjects:
if not self.userId:
raise ValueError("Invalid user context: id is required")
# mandateId can be None for sysadmins performing cross-mandate operations
if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False):
raise ValueError("Invalid user context: mandateId is required for non-sysadmin users")
# 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.
# Add language settings
self.userLanguage = currentUser.language # Default user language

View file

@ -156,6 +156,7 @@ class FeatureInterface:
featureCode: str,
mandateId: str,
label: str,
enabled: bool = True,
copyTemplateRoles: bool = True
) -> FeatureInstance:
"""
@ -171,6 +172,7 @@ class FeatureInterface:
featureCode: Feature code (e.g., "trustee")
mandateId: Mandate ID
label: Instance label (e.g., "Buchhaltung 2025")
enabled: Whether the instance is enabled
copyTemplateRoles: Whether to copy template roles
Returns:
@ -182,7 +184,7 @@ class FeatureInterface:
featureCode=featureCode,
mandateId=mandateId,
label=label,
enabled=True
enabled=enabled
)
createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump())
@ -382,6 +384,41 @@ class FeatureInterface:
logger.error(f"Error syncing roles from template: {e}")
raise ValueError(f"Failed to sync roles: {e}")
def updateFeatureInstance(self, instanceId: str, updateData: Dict[str, Any]) -> Optional[FeatureInstance]:
"""
Update a feature instance.
Only label and enabled fields can be updated.
featureCode and mandateId are immutable.
Args:
instanceId: FeatureInstance ID
updateData: Dictionary with fields to update (label, enabled)
Returns:
Updated FeatureInstance object or None if not found
"""
try:
instance = self.getFeatureInstance(instanceId)
if not instance:
return None
# Only allow updating specific fields
allowedFields = {"label", "enabled"}
filteredData = {k: v for k, v in updateData.items() if k in allowedFields}
if not filteredData:
return instance
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
if updated:
cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")}
return FeatureInstance(**cleanedRecord)
return None
except Exception as e:
logger.error(f"Error updating feature instance {instanceId}: {e}")
raise ValueError(f"Failed to update feature instance: {e}")
def deleteFeatureInstance(self, instanceId: str) -> bool:
"""
Delete a feature instance.

View file

@ -239,23 +239,40 @@ def buildRbacWhereClause(
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
return {"condition": "1 = 0", "values": []}
# For UserInDB, filter by mandateId directly
# For UserInDB: Filter via UserMandate junction table
# Multi-Tenant Design: Users do NOT have mandateId - they are linked via UserMandate
if table == "UserInDB":
return {
"condition": '"mandateId" = %s',
"values": [effectiveMandateId]
}
# For UserConnection, need to join with UserInDB or filter by mandateId in user
elif table == "UserConnection":
# Get all user IDs in the same mandate using direct SQL query
try:
with connector.connection.cursor() as cursor:
# Get all user IDs that are members of the current mandate
cursor.execute(
'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s',
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
(effectiveMandateId,)
)
users = cursor.fetchall()
userIds = [u["id"] for u in users]
userMandates = cursor.fetchall()
userIds = [um["userId"] for um in userMandates]
if not userIds:
return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds))
return {
"condition": f'"id" IN ({placeholders})',
"values": userIds
}
except Exception as e:
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
return {"condition": "1 = 0", "values": []}
# For UserConnection: Filter via UserMandate junction table
elif table == "UserConnection":
try:
with connector.connection.cursor() as cursor:
# Get all user IDs that are members of the current mandate
cursor.execute(
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
(effectiveMandateId,)
)
userMandates = cursor.fetchall()
userIds = [um["userId"] for um in userMandates]
if not userIds:
return {"condition": "1 = 0", "values": []}
placeholders = ",".join(["%s"] * len(userIds))
@ -266,7 +283,8 @@ def buildRbacWhereClause(
except Exception as e:
logger.error(f"Error building GROUP filter for UserConnection: {e}")
return {"condition": "1 = 0", "values": []}
# For other tables, filter by mandateId
# For other tables, filter by mandateId field
else:
return {
"condition": '"mandateId" = %s',

View file

@ -39,6 +39,7 @@ class FeatureInstanceCreate(BaseModel):
"""Request model for creating a feature instance"""
featureCode: str = Field(..., description="Feature code (e.g., 'trustee', 'chatbot')")
label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')")
enabled: bool = Field(True, description="Whether this feature instance is enabled")
copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation")
@ -64,7 +65,7 @@ class SyncRolesResult(BaseModel):
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listFeatures(
async def list_features(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
@ -101,7 +102,7 @@ class FeaturesMyResponse(BaseModel):
@router.get("/my", response_model=FeaturesMyResponse)
@limiter.limit("60/minute")
async def getMyFeatureInstances(
async def get_my_feature_instances(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> FeaturesMyResponse:
@ -239,11 +240,12 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
permissions = {
"tables": {},
"views": {},
"fields": {}
"fields": {},
"isAdmin": False # Flag if user has admin role
}
try:
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
# Get FeatureAccess for this user and instance
@ -272,6 +274,15 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
logger.debug(f"_getInstancePermissions: No roles found for FeatureAccess {featureAccessId}")
return permissions
# Check if user has admin role
for roleId in roleIds:
roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId})
if roles:
roleLabel = roles[0].get("roleLabel", "").lower()
if "admin" in roleLabel:
permissions["isAdmin"] = True
break
# Get permissions (AccessRules) for all roles
for roleId in roleIds:
accessRules = rootInterface.db.getRecordset(
@ -323,6 +334,10 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
# item=None means all views - set a wildcard flag
permissions["views"]["_all"] = True
# Derive view permissions from table permissions
# This allows UI navigation to be controlled by data access rights
_deriveViewPermissions(permissions)
return permissions
except Exception as e:
@ -330,6 +345,51 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict
return permissions
def _deriveViewPermissions(permissions: Dict[str, Any]) -> None:
"""
Derive UI view permissions from table/data permissions.
Mapping:
- trustee-dashboard: always visible (basic access)
- trustee-positions: visible if READ on TrusteePosition
- trustee-documents: visible if READ on TrusteeDocument
- trustee-position-documents: visible if READ on TrusteePositionDocument
- trustee-instance-roles: visible only for admin roles
This function modifies permissions["views"] in place.
"""
tables = permissions.get("tables", {})
views = permissions.get("views", {})
isAdmin = permissions.get("isAdmin", False)
# If user has _all views permission, skip derivation
if views.get("_all"):
return
# Dashboard is always visible for users with any access
if "trustee-dashboard" not in views:
views["trustee-dashboard"] = True
# Positions view: requires READ on TrusteePosition
if "trustee-positions" not in views:
positionPerms = tables.get("TrusteePosition", {})
views["trustee-positions"] = positionPerms.get("read", "n") != "n"
# Documents view: requires READ on TrusteeDocument
if "trustee-documents" not in views:
documentPerms = tables.get("TrusteeDocument", {})
views["trustee-documents"] = documentPerms.get("read", "n") != "n"
# Position-Documents view: requires READ on TrusteePositionDocument
if "trustee-position-documents" not in views:
linkPerms = tables.get("TrusteePositionDocument", {})
views["trustee-position-documents"] = linkPerms.get("read", "n") != "n"
# Instance-roles (admin) view: requires admin role
if "trustee-instance-roles" not in views:
views["trustee-instance-roles"] = isAdmin
def _mergeAccessLevel(current: str, new: str) -> str:
"""Merge two access levels, returning the highest."""
levels = {"n": 0, "m": 1, "g": 2, "a": 3}
@ -343,7 +403,7 @@ def _mergeAccessLevel(current: str, new: str) -> str:
@router.post("/", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createFeature(
async def create_feature(
request: Request,
code: str = Query(..., description="Unique feature code"),
label: Dict[str, str] = None,
@ -397,7 +457,7 @@ async def createFeature(
@router.get("/instances", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listFeatureInstances(
async def list_feature_instances(
request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
context: RequestContext = Depends(getRequestContext)
@ -439,7 +499,7 @@ async def listFeatureInstances(
@router.get("/instances/{instanceId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeatureInstance(
async def get_feature_instance(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
@ -483,7 +543,7 @@ async def getFeatureInstance(
@router.post("/instances", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createFeatureInstance(
async def create_feature_instance(
request: Request,
data: FeatureInstanceCreate,
context: RequestContext = Depends(getRequestContext)
@ -525,6 +585,7 @@ async def createFeatureInstance(
featureCode=data.featureCode,
mandateId=str(context.mandateId),
label=data.label,
enabled=data.enabled,
copyTemplateRoles=data.copyTemplateRoles
)
@ -547,7 +608,7 @@ async def createFeatureInstance(
@router.delete("/instances/{instanceId}", response_model=Dict[str, str])
@limiter.limit("10/minute")
async def deleteFeatureInstance(
async def delete_feature_instance(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
@ -603,9 +664,90 @@ async def deleteFeatureInstance(
)
class FeatureInstanceUpdate(BaseModel):
"""Request model for updating a feature instance."""
label: Optional[str] = Field(None, description="New label for the instance")
enabled: Optional[bool] = Field(None, description="Enable/disable the instance")
@router.put("/instances/{instanceId}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateFeatureInstance(
request: Request,
instanceId: str,
data: FeatureInstanceUpdate,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Update a feature instance (label, enabled).
Requires Mandate-Admin role.
Args:
instanceId: FeatureInstance ID
data: Fields to update (label, enabled)
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
# Verify instance exists
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{instanceId}' not found"
)
# Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to update feature instances"
)
# Build update data (only non-None values)
updateData = {}
if data.label is not None:
updateData["label"] = data.label
if data.enabled is not None:
updateData["enabled"] = data.enabled
if not updateData:
return instance.model_dump()
updated = featureInterface.updateFeatureInstance(instanceId, updateData)
if not updated:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update feature instance"
)
logger.info(f"User {context.user.id} updated feature instance {instanceId}: {updateData}")
return updated.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating feature instance {instanceId}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update feature instance: {str(e)}"
)
@router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult)
@limiter.limit("10/minute")
async def syncInstanceRoles(
async def sync_instance_roles(
request: Request,
instanceId: str,
addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"),
@ -672,7 +814,7 @@ async def syncInstanceRoles(
@router.get("/templates/roles", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listTemplateRoles(
async def list_template_roles(
request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
sysAdmin: User = Depends(requireSysAdmin)
@ -702,7 +844,7 @@ async def listTemplateRoles(
@router.post("/templates/roles", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createTemplateRole(
async def create_template_role(
request: Request,
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
featureCode: str = Query(..., description="Feature code this role belongs to"),
@ -780,7 +922,7 @@ class FeatureInstanceUserResponse(BaseModel):
@router.get("/instances/{instanceId}/users", response_model=List[FeatureInstanceUserResponse])
@limiter.limit("60/minute")
async def listFeatureInstanceUsers(
async def list_feature_instance_users(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
@ -872,7 +1014,7 @@ async def listFeatureInstanceUsers(
@router.post("/instances/{instanceId}/users", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def addUserToFeatureInstance(
async def add_user_to_feature_instance(
request: Request,
instanceId: str,
data: FeatureInstanceUserCreate,
@ -976,7 +1118,7 @@ async def addUserToFeatureInstance(
@router.delete("/instances/{instanceId}/users/{userId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def removeUserFromFeatureInstance(
async def remove_user_from_feature_instance(
request: Request,
instanceId: str,
userId: str,
@ -1057,7 +1199,7 @@ async def removeUserFromFeatureInstance(
@router.put("/instances/{instanceId}/users/{userId}/roles", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateFeatureInstanceUserRoles(
async def update_feature_instance_user_roles(
request: Request,
instanceId: str,
userId: str,
@ -1154,7 +1296,7 @@ async def updateFeatureInstanceUserRoles(
@router.get("/instances/{instanceId}/available-roles", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getFeatureInstanceAvailableRoles(
async def get_feature_instance_available_roles(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext)
@ -1222,7 +1364,7 @@ async def getFeatureInstanceAvailableRoles(
@router.get("/{featureCode}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeature(
async def get_feature(
request: Request,
featureCode: str,
context: RequestContext = Depends(getRequestContext)

View file

@ -72,7 +72,7 @@ class RbacImportResult(BaseModel):
@router.get("/export/global", response_model=RbacExportData)
@limiter.limit("10/minute")
async def exportGlobalRbac(
async def export_global_rbac(
request: Request,
sysAdmin: User = Depends(requireSysAdmin)
) -> RbacExportData:
@ -138,7 +138,7 @@ async def exportGlobalRbac(
@router.post("/import/global", response_model=RbacImportResult)
@limiter.limit("5/minute")
async def importGlobalRbac(
async def import_global_rbac(
request: Request,
file: UploadFile = File(..., description="JSON file with RBAC export data"),
updateExisting: bool = False,
@ -285,7 +285,7 @@ async def importGlobalRbac(
@router.get("/export/mandate", response_model=RbacExportData)
@limiter.limit("10/minute")
async def exportMandateRbac(
async def export_mandate_rbac(
request: Request,
includeFeatureInstances: bool = True,
context: RequestContext = Depends(getRequestContext)
@ -380,7 +380,7 @@ async def exportMandateRbac(
@router.post("/import/mandate", response_model=RbacImportResult)
@limiter.limit("5/minute")
async def importMandateRbac(
async def import_mandate_rbac(
request: Request,
file: UploadFile = File(..., description="JSON file with RBAC export data"),
updateExisting: bool = False,

View file

@ -76,7 +76,7 @@ router = APIRouter(
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listRoles(
async def list_roles(
request: Request,
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
@ -121,7 +121,7 @@ async def listRoles(
@router.get("/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getRoleOptions(
async def get_role_options(
request: Request,
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
@ -162,7 +162,7 @@ async def getRoleOptions(
@router.post("/", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def createRole(
async def create_role(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin)
@ -206,7 +206,7 @@ async def createRole(
@router.get("/{roleId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getRole(
async def get_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
@ -250,7 +250,7 @@ async def getRole(
@router.put("/{roleId}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateRole(
async def update_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
@ -298,7 +298,7 @@ async def updateRole(
@router.delete("/{roleId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def deleteRole(
async def delete_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
@ -342,7 +342,7 @@ async def deleteRole(
@router.get("/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listUsersWithRoles(
async def list_users_with_roles(
request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
@ -412,7 +412,7 @@ async def listUsersWithRoles(
@router.get("/users/{userId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getUserRoles(
async def get_user_roles(
request: Request,
userId: str = Path(..., description="User ID"),
currentUser: User = Depends(requireSysAdmin)
@ -462,7 +462,7 @@ async def getUserRoles(
@router.put("/users/{userId}/roles", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateUserRoles(
async def update_user_roles(
request: Request,
userId: str = Path(..., description="User ID"),
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
@ -559,7 +559,7 @@ async def updateUserRoles(
@router.post("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def addUserRole(
async def add_user_role(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"),
@ -641,7 +641,7 @@ async def addUserRole(
@router.delete("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def removeUserRole(
async def remove_user_role(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"),
@ -721,7 +721,7 @@ async def removeUserRole(
@router.get("/roles/{roleLabel}/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getUsersWithRole(
async def get_users_with_role(
request: Request,
roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),

View file

@ -34,7 +34,7 @@ router = APIRouter(
@router.get("/permissions", response_model=UserPermissions)
@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
async def getPermissions(
async def get_permissions(
request: Request,
context: str = Query(..., description="Context type: DATA, UI, or RESOURCE"),
item: Optional[str] = Query(None, description="Item identifier (table name, UI path, or resource path)"),
@ -98,7 +98,7 @@ async def getPermissions(
@router.get("/permissions/all", response_model=Dict[str, Any])
@limiter.limit("120/minute") # Raised from 30 - optimized endpoint for bulk permission fetch
async def getAllPermissions(
async def get_all_permissions(
request: Request,
context: Optional[str] = Query(None, description="Context type: UI or RESOURCE (if not provided, returns both)"),
reqContext: RequestContext = Depends(getRequestContext)
@ -224,7 +224,7 @@ async def getAllPermissions(
@router.get("/rules", response_model=PaginatedResponse)
@limiter.limit("30/minute")
async def getAccessRules(
async def get_access_rules(
request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
@ -313,7 +313,7 @@ async def getAccessRules(
@router.get("/rules/by-role/{roleId}", response_model=PaginatedResponse)
@limiter.limit("30/minute")
async def getAccessRulesByRole(
async def get_access_rules_by_role(
request: Request,
roleId: str = Path(..., description="Role ID to get rules for"),
currentUser: User = Depends(requireSysAdmin)
@ -357,7 +357,7 @@ async def getAccessRulesByRole(
@router.get("/rules/{ruleId}", response_model=dict)
@limiter.limit("30/minute")
async def getAccessRule(
async def get_access_rule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(requireSysAdmin)
@ -399,7 +399,7 @@ async def getAccessRule(
@router.post("/rules", response_model=dict)
@limiter.limit("30/minute")
async def createAccessRule(
async def create_access_rule(
request: Request,
accessRuleData: dict = Body(..., description="Access rule data"),
currentUser: User = Depends(requireSysAdmin)
@ -465,7 +465,7 @@ async def createAccessRule(
@router.put("/rules/{ruleId}", response_model=dict)
@limiter.limit("30/minute")
async def updateAccessRule(
async def update_access_rule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
accessRuleData: dict = Body(..., description="Updated access rule data"),
@ -548,7 +548,7 @@ async def updateAccessRule(
@router.delete("/rules/{ruleId}")
@limiter.limit("30/minute")
async def deleteAccessRule(
async def delete_access_rule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(requireSysAdmin)
@ -606,7 +606,7 @@ async def deleteAccessRule(
@router.get("/roles", response_model=PaginatedResponse)
@limiter.limit("60/minute")
async def listRoles(
async def list_roles(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
includeTemplates: bool = Query(False, description="Include feature template roles"),
@ -775,7 +775,7 @@ async def listRoles(
@router.get("/roles/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getRoleOptions(
async def get_role_options(
request: Request,
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
@ -816,7 +816,7 @@ async def getRoleOptions(
@router.post("/roles", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def createRole(
async def create_role(
request: Request,
role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin)
@ -865,7 +865,7 @@ async def createRole(
@router.get("/roles/{roleId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getRole(
async def get_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)
@ -912,7 +912,7 @@ async def getRole(
@router.put("/roles/{roleId}", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def updateRole(
async def update_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
@ -965,7 +965,7 @@ async def updateRole(
@router.delete("/roles/{roleId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def deleteRole(
async def delete_role(
request: Request,
roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin)

View file

@ -100,7 +100,7 @@ router = APIRouter(
@router.get("/statuses/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getConnectionStatusOptions(
async def get_connection_status_options(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:
@ -116,7 +116,7 @@ async def getConnectionStatusOptions(
@router.get("/authorities/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getAuthAuthorityOptions(
async def get_auth_authority_options(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> List[Dict[str, Any]]:

View file

@ -324,7 +324,7 @@ async def delete_mandate(
@router.get("/{targetMandateId}/users")
@limiter.limit("60/minute")
async def listMandateUsers(
async def list_mandate_users(
request: Request,
targetMandateId: str = Path(..., description="ID of the mandate"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -486,7 +486,7 @@ async def listMandateUsers(
@router.post("/{targetMandateId}/users", response_model=UserMandateResponse)
@limiter.limit("30/minute")
async def addUserToMandate(
async def add_user_to_mandate(
request: Request,
targetMandateId: str = Path(..., description="ID of the mandate"),
data: UserMandateCreate = Body(...),
@ -602,7 +602,7 @@ async def addUserToMandate(
@router.delete("/{targetMandateId}/users/{targetUserId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def removeUserFromMandate(
async def remove_user_from_mandate(
request: Request,
targetMandateId: str = Path(..., description="ID of the mandate"),
targetUserId: str = Path(..., description="ID of the user to remove"),
@ -680,7 +680,7 @@ async def removeUserFromMandate(
@router.put("/{targetMandateId}/users/{targetUserId}/roles", response_model=UserMandateResponse)
@limiter.limit("30/minute")
async def updateUserRolesInMandate(
async def update_user_roles_in_mandate(
request: Request,
targetMandateId: str = Path(..., description="ID of the mandate"),
targetUserId: str = Path(..., description="ID of the user"),

View file

@ -152,7 +152,7 @@ router = APIRouter(
@router.get("/options", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def getUserOptions(
async def get_user_options(
request: Request,
context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]:
@ -631,7 +631,7 @@ async def change_password(
@router.post("/{userId}/send-password-link")
@limiter.limit("10/minute")
async def sendPasswordLink(
async def send_password_link(
request: Request,
userId: str = Path(..., description="ID of the user to send password setup link"),
frontendUrl: str = Body(..., embed=True),

View file

@ -73,7 +73,7 @@ class DeletionResult(BaseModel):
@router.get("/data-export", response_model=DataExportResponse)
@limiter.limit("5/minute")
async def exportUserData(
async def export_user_data(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> DataExportResponse:
@ -238,7 +238,7 @@ async def exportUserData(
@router.get("/data-portability")
@limiter.limit("5/minute")
async def exportPortableData(
async def export_portable_data(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> JSONResponse:
@ -333,7 +333,7 @@ async def exportPortableData(
@router.delete("/", response_model=DeletionResult)
@limiter.limit("1/hour")
async def deleteAccount(
async def delete_account(
request: Request,
confirmDeletion: bool = False,
currentUser: User = Depends(getCurrentUser)
@ -465,7 +465,7 @@ async def deleteAccount(
@router.get("/consent-info", response_model=Dict[str, Any])
@limiter.limit("30/minute")
async def getConsentInfo(
async def get_consent_info(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:

View file

@ -108,7 +108,7 @@ class RegisterAndAcceptResponse(BaseModel):
@router.post("/", response_model=InvitationResponse)
@limiter.limit("30/minute")
async def createInvitation(
async def create_invitation(
request: Request,
data: InvitationCreate,
context: RequestContext = Depends(getRequestContext)
@ -234,7 +234,7 @@ async def createInvitation(
@router.get("/", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute")
async def listInvitations(
async def list_invitations(
request: Request,
includeUsed: bool = Query(False, description="Include already used invitations"),
includeExpired: bool = Query(False, description="Include expired invitations"),
@ -313,7 +313,7 @@ async def listInvitations(
@router.delete("/{invitationId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
async def revokeInvitation(
async def revoke_invitation(
request: Request,
invitationId: str,
context: RequestContext = Depends(getRequestContext)
@ -397,7 +397,7 @@ async def revokeInvitation(
@router.get("/validate/{token}", response_model=InvitationValidation)
@limiter.limit("30/minute")
async def validateInvitation(
async def validate_invitation(
request: Request,
token: str
) -> InvitationValidation:
@ -482,7 +482,7 @@ async def validateInvitation(
@router.post("/accept/{token}", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def acceptInvitation(
async def accept_invitation(
request: Request,
token: str,
currentUser: User = Depends(getCurrentUser)
@ -614,7 +614,7 @@ async def acceptInvitation(
@router.post("/register-and-accept", response_model=RegisterAndAcceptResponse)
@limiter.limit("10/minute") # Stricter rate limit for registration
async def registerAndAcceptInvitation(
async def register_and_accept_invitation(
request: Request,
data: RegisterAndAcceptRequest
) -> RegisterAndAcceptResponse:

View file

@ -38,7 +38,7 @@ router = APIRouter(
@router.get("/subscriptions", response_model=PaginatedResponse[MessagingSubscription])
@limiter.limit("60/minute")
async def getSubscriptions(
async def get_subscriptions(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
@ -79,7 +79,7 @@ async def getSubscriptions(
@router.post("/subscriptions", response_model=MessagingSubscription)
@limiter.limit("60/minute")
async def createSubscription(
async def create_subscription(
request: Request,
subscription: MessagingSubscription,
currentUser: User = Depends(getCurrentUser)
@ -95,7 +95,7 @@ async def createSubscription(
@router.get("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
@limiter.limit("60/minute")
async def getSubscription(
async def get_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
currentUser: User = Depends(getCurrentUser)
@ -115,7 +115,7 @@ async def getSubscription(
@router.put("/subscriptions/{subscriptionId}", response_model=MessagingSubscription)
@limiter.limit("60/minute")
async def updateSubscription(
async def update_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to update"),
subscriptionData: MessagingSubscription = Body(...),
@ -145,7 +145,7 @@ async def updateSubscription(
@router.delete("/subscriptions/{subscriptionId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def deleteSubscription(
async def delete_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to delete"),
currentUser: User = Depends(getCurrentUser)
@ -174,7 +174,7 @@ async def deleteSubscription(
@router.get("/subscriptions/{subscriptionId}/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
@limiter.limit("60/minute")
async def getSubscriptionRegistrations(
async def get_subscription_registrations(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -219,7 +219,7 @@ async def getSubscriptionRegistrations(
@router.post("/subscriptions/{subscriptionId}/subscribe", response_model=MessagingSubscriptionRegistration)
@limiter.limit("60/minute")
async def subscribeUser(
async def subscribe_user(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
channel: MessagingChannel = Body(..., embed=True),
@ -241,7 +241,7 @@ async def subscribeUser(
@router.delete("/subscriptions/{subscriptionId}/unsubscribe", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def unsubscribeUser(
async def unsubscribe_user(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription"),
channel: MessagingChannel = Body(..., embed=True),
@ -267,7 +267,7 @@ async def unsubscribeUser(
@router.get("/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration])
@limiter.limit("60/minute")
async def getMyRegistrations(
async def get_my_registrations(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
@ -311,7 +311,7 @@ async def getMyRegistrations(
@router.put("/registrations/{registrationId}", response_model=MessagingSubscriptionRegistration)
@limiter.limit("60/minute")
async def updateRegistration(
async def update_registration(
request: Request,
registrationId: str = Path(..., description="ID of the registration to update"),
registrationData: MessagingSubscriptionRegistration = Body(...),
@ -341,7 +341,7 @@ async def updateRegistration(
@router.delete("/registrations/{registrationId}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def deleteRegistration(
async def delete_registration(
request: Request,
registrationId: str = Path(..., description="ID of the registration to delete"),
currentUser: User = Depends(getCurrentUser)
@ -376,7 +376,7 @@ def _getTriggerKey(request: Request) -> str:
@router.post("/trigger/{subscriptionId}", response_model=MessagingSubscriptionExecutionResult)
@limiter.limit("60/minute", key_func=_getTriggerKey)
async def triggerSubscription(
async def trigger_subscription(
request: Request,
subscriptionId: str = Path(..., description="ID of the subscription to trigger"),
eventParameters: Dict[str, Any] = Body(...),
@ -440,7 +440,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool:
@router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery])
@limiter.limit("60/minute")
async def getDeliveries(
async def get_deliveries(
request: Request,
subscriptionId: Optional[str] = Query(None, description="Filter by subscription ID"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -486,7 +486,7 @@ async def getDeliveries(
@router.get("/deliveries/{deliveryId}", response_model=MessagingDelivery)
@limiter.limit("60/minute")
async def getDelivery(
async def get_delivery(
request: Request,
deliveryId: str = Path(..., description="ID of the delivery"),
currentUser: User = Depends(getCurrentUser)

View file

@ -106,18 +106,9 @@ async def login(
# Get gateway interface with root privileges for authentication
rootInterface = getRootInterface()
# Get default mandate ID
defaultMandateId = rootInterface.getInitialId(Mandate)
if not defaultMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No default mandate found"
)
# Set the mandate ID on the interface
rootInterface.mandateId = defaultMandateId
# Authenticate user
# Note: authenticateLocalUser uses _getUserForAuthentication which bypasses RBAC
# This is correct because users are mandate-independent (Multi-Tenant Design)
user = rootInterface.authenticateLocalUser(
username=formData.username,
password=formData.password
@ -265,16 +256,9 @@ async def register_user(
# Get gateway interface with root privileges since this is a public endpoint
appInterface = getRootInterface()
# Get default mandate ID
defaultMandateId = appInterface.getInitialId(Mandate)
if not defaultMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No default mandate found"
)
# Set the mandate ID on the interface
appInterface.mandateId = defaultMandateId
# Note: User registration does NOT require mandateId context
# Users are mandate-independent (Multi-Tenant Design)
# Mandate assignment happens via createUserMandate() after registration
# Frontend URL is required - no fallback
baseUrl = frontendUrl.rstrip("/")
@ -548,7 +532,7 @@ async def check_username_availability(
@router.post("/password-reset-request")
@limiter.limit("5/minute")
async def passwordResetRequest(
async def password_reset_request(
request: Request,
username: str = Body(..., embed=True),
frontendUrl: str = Body(..., embed=True)
@ -628,7 +612,7 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor
@router.post("/password-reset")
@limiter.limit("10/minute")
async def passwordReset(
async def password_reset(
request: Request,
token: str = Body(..., embed=True),
password: str = Body(..., embed=True)

View file

@ -39,7 +39,7 @@ class ConnectionManager:
del activeConnections[connectionId]
logger.info(f"WebSocket disconnected: {connectionId}")
async def sendPersonalMessage(self, message: dict, websocket: WebSocket):
async def send_personal_message(self, message: dict, websocket: WebSocket):
try:
await websocket.send_text(json.dumps(message))
except Exception as e:

View file

@ -28,8 +28,11 @@ class MethodAiOperationsTester:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
self.services = None
self.methodAi = None
@ -119,7 +122,7 @@ class MethodAiOperationsTester:
currentAction=0,
totalTasks=0,
totalActions=0,
mandateId=self.testUser.mandateId,
mandateId=self.testMandateId,
messageIds=[],
workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC,
maxSteps=5
@ -277,7 +280,7 @@ class MethodAiOperationsTester:
currentAction=0,
totalTasks=0,
totalActions=0,
mandateId=self.testUser.mandateId,
mandateId=self.testMandateId,
messageIds=[],
workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC,
maxSteps=5

View file

@ -28,8 +28,11 @@ class AIBehaviorTester:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
# Initialize services using the existing system
self.services = getServices(self.testUser, None) # Test user, no workflow
@ -60,7 +63,7 @@ class AIBehaviorTester:
currentAction=0,
totalTasks=0,
totalActions=0,
mandateId=self.testUser.mandateId,
mandateId=self.testMandateId,
messageIds=[],
workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC,
maxSteps=5

View file

@ -30,8 +30,11 @@ class WorkflowWithDocumentsTester:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
# Initialize services using the existing system
self.services = getServices(self.testUser, None) # Test user, no workflow
@ -45,7 +48,7 @@ class WorkflowWithDocumentsTester:
logging.getLogger().setLevel(logging.INFO)
print(f"Initialized test with user: {self.testUser.id}")
print(f"Mandate ID: {self.testUser.mandateId}")
print(f"Test Mandate ID: {self.testMandateId}")
def createCsvTemplate(self) -> str:
"""Create a CSV template file for prime numbers."""

View file

@ -32,8 +32,11 @@ class WorkflowPromptVariationsTester:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
# Initialize services using the existing system
self.services = getServices(self.testUser, None) # Test user, no workflow
@ -46,7 +49,7 @@ class WorkflowPromptVariationsTester:
logging.getLogger().setLevel(logging.INFO)
print(f"Initialized test with user: {self.testUser.id}")
print(f"Mandate ID: {self.testUser.mandateId}")
print(f"Test Mandate ID: {self.testMandateId}")
def _createFile(self, fileName: str, mimeType: str, content: str) -> str:
"""Helper method to create a file and return its ID."""

View file

@ -31,8 +31,11 @@ class DocumentGenerationFormatsTester:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
# Initialize services using the existing system
self.services = getServices(self.testUser, None) # Test user, no workflow
@ -52,7 +55,7 @@ class DocumentGenerationFormatsTester:
logging.getLogger().setLevel(logging.INFO)
print(f"Initialized test with user: {self.testUser.id}")
print(f"Mandate ID: {self.testUser.mandateId}")
print(f"Test Mandate ID: {self.testMandateId}")
print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}")
# Upload PDF file for testing

View file

@ -31,8 +31,11 @@ class DocumentGenerationFormatsTester10:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
# Initialize services using the existing system
self.services = getServices(self.testUser, None) # Test user, no workflow
@ -52,7 +55,7 @@ class DocumentGenerationFormatsTester10:
logging.getLogger().setLevel(logging.INFO)
print(f"Initialized test with user: {self.testUser.id}")
print(f"Mandate ID: {self.testUser.mandateId}")
print(f"Test Mandate ID: {self.testMandateId}")
print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}")
# Upload PDF file for testing

View file

@ -33,8 +33,11 @@ class CodeGenerationFormatsTester11:
def __init__(self):
# Use root user for testing (has full access to everything)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelUam import Mandate
rootInterface = getRootInterface()
self.testUser = rootInterface.currentUser
# Get initial mandate ID for testing (User has no mandateId - use initial mandate)
self.testMandateId = rootInterface.getInitialId(Mandate)
# Initialize services using the existing system
self.services = getServices(self.testUser, None) # Test user, no workflow
@ -53,7 +56,7 @@ class CodeGenerationFormatsTester11:
logging.getLogger().setLevel(logging.INFO)
print(f"Initialized test with user: {self.testUser.id}")
print(f"Mandate ID: {self.testUser.mandateId}")
print(f"Test Mandate ID: {self.testMandateId}")
print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}")
def createTestPrompt(self, format: str) -> str: