# Copyright (c) 2026 Patrick Motsch # All rights reserved. """Redmine Feature Container -- Main Module. Defines the feature metadata and registers RBAC objects + template roles in the catalog. Loaded automatically by ``modules.system.registry``. """ from __future__ import annotations import logging from typing import Any, Dict, List from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) FEATURE_CODE = "redmine" FEATURE_LABEL = t("Redmine", context="UI") FEATURE_ICON = "mdi-bug-outline" # Wrapping labels in t() at import time registers the keys with the i18n # catalog immediately, so the AI translator picks them up on the next sweep. # Without this, brand-new labels like "Ticket-Browser" stay untranslated and # render as ``[Ticket-Browser]`` in non-de UIs. UI_OBJECTS: List[Dict[str, Any]] = [ {"objectKey": "ui.feature.redmine.stats", "label": t("Statistik", context="UI"), "meta": {"area": "stats", "isDefault": True}}, {"objectKey": "ui.feature.redmine.browser", "label": t("Ticket-Browser", context="UI"), "meta": {"area": "browser"}}, {"objectKey": "ui.feature.redmine.settings", "label": t("Einstellungen", context="UI"), "meta": {"area": "settings", "admin_only": True}}, ] DATA_OBJECTS: List[Dict[str, Any]] = [ {"objectKey": "data.feature.redmine.config", "label": "Konfiguration", "meta": {"isGroup": True}}, { "objectKey": "data.feature.redmine.RedmineInstanceConfig", "label": "Redmine-Verbindung", "meta": { "table": "RedmineInstanceConfig", "group": "data.feature.redmine.config", "fields": ["id", "baseUrl", "projectId", "rootTrackerName", "isActive", "lastConnectedAt", "lastSyncAt"], }, }, { "objectKey": "data.feature.redmine.RedmineTicketMirror", "label": "Redmine-Tickets (Mirror)", "meta": { "table": "RedmineTicketMirror", "group": "data.feature.redmine.config", "fields": ["redmineId", "subject", "trackerName", "statusName", "assignedToName", "updatedOn"], }, }, { "objectKey": "data.feature.redmine.RedmineRelationMirror", "label": "Redmine-Beziehungen (Mirror)", "meta": { "table": "RedmineRelationMirror", "group": "data.feature.redmine.config", "fields": ["redmineRelationId", "issueId", "issueToId", "relationType"], }, }, { "objectKey": "data.feature.redmine.*", "label": "Alle Redmine-Daten", "meta": {"wildcard": True, "description": "Wildcard for all redmine data tables"}, }, ] RESOURCE_OBJECTS: List[Dict[str, Any]] = [ { "objectKey": "resource.feature.redmine.tickets.read", "label": "Tickets lesen", "meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.tickets.create", "label": "Tickets erstellen", "meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "POST"}, }, { "objectKey": "resource.feature.redmine.tickets.update", "label": "Tickets bearbeiten", "meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "PUT"}, }, { "objectKey": "resource.feature.redmine.tickets.delete", "label": "Tickets loeschen / archivieren", "meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "DELETE"}, }, { "objectKey": "resource.feature.redmine.relations.manage", "label": "Beziehungen verwalten", "meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}/relations", "method": "ALL"}, }, { "objectKey": "resource.feature.redmine.stats.read", "label": "Statistik einsehen", "meta": {"endpoint": "/api/redmine/{instanceId}/stats", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.config.manage", "label": "Verbindung verwalten", "meta": {"endpoint": "/api/redmine/{instanceId}/config", "method": "ALL", "admin_only": True}, }, { "objectKey": "resource.feature.redmine.config.test", "label": "Verbindung testen", "meta": {"endpoint": "/api/redmine/{instanceId}/config/test", "method": "POST", "admin_only": True}, }, { "objectKey": "resource.feature.redmine.sync.run", "label": "Mirror synchronisieren", "meta": {"endpoint": "/api/redmine/{instanceId}/sync", "method": "POST", "admin_only": True}, }, { "objectKey": "resource.feature.redmine.sync.status", "label": "Sync-Status lesen", "meta": {"endpoint": "/api/redmine/{instanceId}/sync/status", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.workflows.view", "label": "Workflows einsehen", "meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"}, }, { "objectKey": "resource.feature.redmine.workflows.execute", "label": "Workflows ausfuehren", "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"}, }, ] TEMPLATE_ROLES: List[Dict[str, Any]] = [ { "roleLabel": "redmine-viewer", "description": "Redmine-Betrachter -- Tickets und Statistik lesen", "accessRules": [ {"context": "UI", "item": "ui.feature.redmine.stats", "view": True}, {"context": "UI", "item": "ui.feature.redmine.browser", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.read", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.stats.read", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.sync.status", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, ], }, { "roleLabel": "redmine-editor", "description": "Redmine-Bearbeiter -- Tickets erstellen, bearbeiten, Beziehungen pflegen", "accessRules": [ {"context": "UI", "item": "ui.feature.redmine.stats", "view": True}, {"context": "UI", "item": "ui.feature.redmine.browser", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.read", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.create", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.update", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.delete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.relations.manage", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.stats.read", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.sync.status", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.workflows.view", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.workflows.execute", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "n"}, ], }, { "roleLabel": "redmine-admin", "description": "Redmine-Administrator -- Vollzugriff inkl. Einstellungen und Verbindung", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "RESOURCE", "item": "resource.feature.redmine.config.manage", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.config.test", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.create", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.update", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.tickets.delete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.relations.manage", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.workflows.view", "view": True}, {"context": "RESOURCE", "item": "resource.feature.redmine.workflows.execute", "view": True}, ], }, ] # --------------------------------------------------------------------------- # Public discovery API (called by registry.py) # --------------------------------------------------------------------------- def getFeatureDefinition() -> Dict[str, Any]: return {"code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON} def getUiObjects() -> List[Dict[str, Any]]: return UI_OBJECTS def getResourceObjects() -> List[Dict[str, Any]]: return RESOURCE_OBJECTS def getDataObjects() -> List[Dict[str, Any]]: return DATA_OBJECTS def getTemplateRoles() -> List[Dict[str, Any]]: return TEMPLATE_ROLES def getTemplateWorkflows() -> List[Dict[str, Any]]: return [] def registerFeature(catalogService) -> bool: """Register UI / Resource / Data objects and sync template roles.""" try: for uiObj in UI_OBJECTS: catalogService.registerUiObject( featureCode=FEATURE_CODE, objectKey=uiObj["objectKey"], label=uiObj["label"], meta=uiObj.get("meta"), ) for resObj in RESOURCE_OBJECTS: catalogService.registerResourceObject( featureCode=FEATURE_CODE, objectKey=resObj["objectKey"], label=resObj["label"], meta=resObj.get("meta"), ) for dataObj in DATA_OBJECTS: catalogService.registerDataObject( featureCode=FEATURE_CODE, objectKey=dataObj["objectKey"], label=dataObj["label"], meta=dataObj.get("meta"), ) _syncTemplateRolesToDb() logger.info( f"Feature '{FEATURE_CODE}' registered " f"{len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects" ) return True except Exception as e: logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") return False # --------------------------------------------------------------------------- # Template-role sync (mirrors the trustee implementation) # --------------------------------------------------------------------------- def _syncTemplateRolesToDb() -> int: try: from modules.datamodels.datamodelRbac import ( AccessRule, AccessRuleContext, Role, ) from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.interfaces.interfaceDbApp import getRootInterface rootInterface = getRootInterface() existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) templateRoles = [r for r in existingRoles if r.mandateId is None] existingByLabel: Dict[str, str] = {r.roleLabel: str(r.id) for r in templateRoles} createdCount = 0 for roleTemplate in TEMPLATE_ROLES: roleLabel = roleTemplate["roleLabel"] if roleLabel in existingByLabel: _ensureAccessRulesForRole( rootInterface, existingByLabel[roleLabel], roleTemplate.get("accessRules", []), ) continue newRole = Role( roleLabel=roleLabel, description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, isSystemRole=False, ) createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump()) roleId = createdRole.get("id") _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) logger.info(f"Created template role '{roleLabel}' with ID {roleId}") createdCount += 1 return createdCount except Exception as e: logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") return 0 def _ensureAccessRulesForRole( rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]] ) -> int: from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext existingRules = rootInterface.getAccessRulesByRole(roleId) existingSignatures: set[Any] = set() for rule in existingRules: sig = (rule.context.value if rule.context else None, rule.item) existingSignatures.add(sig) createdCount = 0 for template in ruleTemplates: context = template.get("context", "UI") item = template.get("item") if (context, item) in existingSignatures: continue if context == "UI": contextEnum = AccessRuleContext.UI elif context == "DATA": contextEnum = AccessRuleContext.DATA elif context == "RESOURCE": contextEnum = AccessRuleContext.RESOURCE else: contextEnum = context newRule = AccessRule( roleId=roleId, context=contextEnum, item=item, view=template.get("view", False), read=template.get("read"), create=template.get("create"), update=template.get("update"), delete=template.get("delete"), ) rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) createdCount += 1 return createdCount