335 lines
14 KiB
Python
335 lines
14 KiB
Python
# 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
|