platform-core/modules/features/redmine/interfaceFeatureRedmine.py
ValueOn AG 4a60086c80
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 15s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
cp adapted to 2026 poweron
2026-06-09 09:53:31 +02:00

584 lines
22 KiB
Python

# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Interface for the Redmine feature.
Owns:
- Database connection to ``poweron_redmine``
- CRUD on ``RedmineInstanceConfig`` (one row per FeatureInstance)
- Encryption of the API key (``encryptValue`` keyed ``"redmineApiKey"``)
- Resolution of the active config to a ``ConnectorTicketsRedmine`` instance
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.connectors.connectorTicketsRedmine import ConnectorTicketsRedmine
from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
RedmineConfigDto,
RedmineConfigUpdateRequest,
RedmineCustomFieldSchemaDto,
RedmineFieldChoiceDto,
RedmineFieldSchemaDto,
RedmineInstanceConfig,
RedmineRelationMirror,
RedmineTicketMirror,
)
from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG, decryptValue, encryptValue
from modules.dbHelpers.dbRegistry import registerDatabase
logger = logging.getLogger(__name__)
redmineDatabase = "poweron_redmine"
registerDatabase(redmineDatabase)
_redmineInterfaces: Dict[str, "RedmineObjects"] = {}
class RedmineObjects:
"""Per-user, per-instance Redmine interface."""
def __init__(
self,
currentUser: User,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> None:
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self._initializeDatabase()
from modules.security.rootAccess import getRootDbAppConnector
dbApp = getRootDbAppConnector()
self.rbac = RbacClass(self.db, dbApp=dbApp)
self.db.updateContext(self.userId)
# ------------------------------------------------------------------
# DB bootstrap
# ------------------------------------------------------------------
def _initializeDatabase(self) -> None:
self.db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "_no_config_default_data"),
dbDatabase=redmineDatabase,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=self.userId,
)
logger.debug(f"Redmine database initialized for user {self.userId}")
def setUserContext(
self,
currentUser: User,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> None:
self.currentUser = currentUser
self.userId = currentUser.id if currentUser else None
self.mandateId = mandateId
self.featureInstanceId = featureInstanceId
self.db.updateContext(self.userId)
# ------------------------------------------------------------------
# Config CRUD
# ------------------------------------------------------------------
def _findConfigRecord(self, featureInstanceId: str) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset(
RedmineInstanceConfig,
recordFilter={"featureInstanceId": featureInstanceId},
)
if not records:
return None
return dict(records[0])
def getConfig(self, featureInstanceId: str) -> Optional[RedmineInstanceConfig]:
record = self._findConfigRecord(featureInstanceId)
if not record:
return None
return RedmineInstanceConfig(**{k: v for k, v in record.items() if not k.startswith("_")})
def getConfigDto(self, featureInstanceId: str) -> RedmineConfigDto:
cfg = self.getConfig(featureInstanceId)
if not cfg:
return RedmineConfigDto(
featureInstanceId=featureInstanceId,
mandateId=self.mandateId,
)
return RedmineConfigDto(
id=cfg.id,
featureInstanceId=cfg.featureInstanceId,
mandateId=cfg.mandateId,
baseUrl=cfg.baseUrl or "",
projectId=cfg.projectId or "",
hasApiKey=bool(cfg.encryptedApiKey),
rootTrackerName=cfg.rootTrackerName or "Userstory",
defaultPeriodValue=cfg.defaultPeriodValue,
schemaCacheTtlSeconds=cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60,
schemaCachedAt=cfg.schemaCachedAt,
isActive=cfg.isActive if cfg.isActive is not None else True,
lastConnectedAt=cfg.lastConnectedAt,
lastSyncAt=cfg.lastSyncAt,
lastFullSyncAt=cfg.lastFullSyncAt,
lastSyncTicketCount=cfg.lastSyncTicketCount,
lastSyncErrorMessage=cfg.lastSyncErrorMessage,
)
def upsertConfig(
self,
featureInstanceId: str,
update: RedmineConfigUpdateRequest,
) -> RedmineConfigDto:
existing = self._findConfigRecord(featureInstanceId)
data: Dict[str, Any] = {}
if update.baseUrl is not None:
data["baseUrl"] = update.baseUrl.strip().rstrip("/")
if update.projectId is not None:
data["projectId"] = update.projectId.strip()
if update.rootTrackerName is not None:
cleaned = update.rootTrackerName.strip()
if not cleaned:
raise ValueError("rootTrackerName must not be empty")
data["rootTrackerName"] = cleaned
if update.defaultPeriodValue is not None:
data["defaultPeriodValue"] = update.defaultPeriodValue
if update.schemaCacheTtlSeconds is not None:
data["schemaCacheTtlSeconds"] = int(update.schemaCacheTtlSeconds)
if update.isActive is not None:
data["isActive"] = bool(update.isActive)
if update.apiKey is not None:
apiKey = update.apiKey.strip()
if apiKey == "":
data["encryptedApiKey"] = ""
else:
data["encryptedApiKey"] = encryptValue(
apiKey,
userId=self.userId or "system",
keyName="redmineApiKey",
)
if existing:
self.db.recordModify(RedmineInstanceConfig, existing["id"], data)
else:
seed = RedmineInstanceConfig(
featureInstanceId=featureInstanceId,
mandateId=self.mandateId,
).model_dump()
seed.update(data)
self.db.recordCreate(RedmineInstanceConfig, seed)
return self.getConfigDto(featureInstanceId)
def markConfigInvalid(self, featureInstanceId: str, reason: str = "") -> None:
existing = self._findConfigRecord(featureInstanceId)
if not existing:
return
self.db.recordModify(
RedmineInstanceConfig,
existing["id"],
{"lastConnectedAt": None},
)
if reason:
logger.warning(f"Redmine config {featureInstanceId} invalidated: {reason}")
def markConfigConnected(self, featureInstanceId: str) -> None:
existing = self._findConfigRecord(featureInstanceId)
if not existing:
return
self.db.recordModify(
RedmineInstanceConfig,
existing["id"],
{"lastConnectedAt": time.time()},
)
def updateSchemaCache(self, featureInstanceId: str, schema: Dict[str, Any]) -> None:
existing = self._findConfigRecord(featureInstanceId)
if not existing:
return
self.db.recordModify(
RedmineInstanceConfig,
existing["id"],
{"schemaCache": schema, "schemaCachedAt": time.time()},
)
# ------------------------------------------------------------------
# Connector resolution
# ------------------------------------------------------------------
def _decryptApiKey(self, encryptedApiKey: str) -> str:
if not encryptedApiKey:
return ""
try:
return decryptValue(
encryptedApiKey,
userId=self.userId or "system",
keyName="redmineApiKey",
)
except Exception as e:
logger.error(f"Failed to decrypt Redmine api key: {e}")
return ""
def resolveConnector(
self, featureInstanceId: str
) -> Optional[ConnectorTicketsRedmine]:
cfg = self.getConfig(featureInstanceId)
if not cfg or not cfg.isActive:
return None
if not cfg.baseUrl or not cfg.projectId or not cfg.encryptedApiKey:
return None
apiKey = self._decryptApiKey(cfg.encryptedApiKey)
if not apiKey:
return None
return ConnectorTicketsRedmine(
baseUrl=cfg.baseUrl,
apiKey=apiKey,
projectId=cfg.projectId,
)
def deleteConfig(self, featureInstanceId: str) -> bool:
existing = self._findConfigRecord(featureInstanceId)
if not existing:
return False
self.db.recordDelete(RedmineInstanceConfig, existing["id"])
return True
# ------------------------------------------------------------------
# Sync state
# ------------------------------------------------------------------
def recordSyncSuccess(
self,
featureInstanceId: str,
*,
full: bool,
ticketsUpserted: int,
durationMs: int,
lastSyncAt: float,
) -> None:
existing = self._findConfigRecord(featureInstanceId)
if not existing:
return
update: Dict[str, Any] = {
"lastSyncAt": float(lastSyncAt),
"lastSyncDurationMs": int(durationMs),
"lastSyncTicketCount": int(ticketsUpserted),
"lastSyncErrorAt": None,
"lastSyncErrorMessage": None,
}
if full:
update["lastFullSyncAt"] = float(lastSyncAt)
self.db.recordModify(RedmineInstanceConfig, existing["id"], update)
def recordSyncFailure(self, featureInstanceId: str, message: str) -> None:
existing = self._findConfigRecord(featureInstanceId)
if not existing:
return
self.db.recordModify(
RedmineInstanceConfig,
existing["id"],
{
"lastSyncErrorAt": time.time(),
"lastSyncErrorMessage": message[:1000] if message else "unknown error",
},
)
# ------------------------------------------------------------------
# Ticket mirror CRUD
# ------------------------------------------------------------------
def _findMirroredTicket(
self, featureInstanceId: str, redmineId: int
) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset(
RedmineTicketMirror,
recordFilter={"featureInstanceId": featureInstanceId, "redmineId": int(redmineId)},
)
if not records:
return None
return dict(records[0])
def upsertMirroredTicket(
self,
featureInstanceId: str,
redmineId: int,
record: Dict[str, Any],
) -> str:
existing = self._findMirroredTicket(featureInstanceId, redmineId)
if existing:
update = {k: v for k, v in record.items() if k not in {"id"}}
self.db.recordModify(RedmineTicketMirror, existing["id"], update)
return existing["id"]
else:
new = self.db.recordCreate(RedmineTicketMirror, record)
return new.get("id") if isinstance(new, dict) else record.get("id")
def deleteMirroredTicket(self, featureInstanceId: str, redmineId: int) -> bool:
existing = self._findMirroredTicket(featureInstanceId, redmineId)
if not existing:
return False
self.db.recordDelete(RedmineTicketMirror, existing["id"])
return True
def listMirroredTickets(
self,
featureInstanceId: str,
*,
trackerIds: Optional[list] = None,
statusIds: Optional[list] = None,
assigneeId: Optional[int] = None,
updatedFromTs: Optional[float] = None,
updatedToTs: Optional[float] = None,
) -> list:
recordFilter: Dict[str, Any] = {"featureInstanceId": featureInstanceId}
records = self.db.getRecordset(RedmineTicketMirror, recordFilter=recordFilter)
out = []
for r in records or []:
d = dict(r)
if trackerIds and d.get("trackerId") not in trackerIds:
continue
if statusIds and d.get("statusId") not in statusIds:
continue
if assigneeId is not None and d.get("assignedToId") != assigneeId:
continue
uts = d.get("updatedOnTs")
if updatedFromTs is not None and (uts is None or uts < updatedFromTs):
continue
if updatedToTs is not None and (uts is None or uts > updatedToTs):
continue
out.append(d)
return out
def countMirroredTickets(self, featureInstanceId: str) -> int:
records = self.db.getRecordset(
RedmineTicketMirror,
recordFilter={"featureInstanceId": featureInstanceId},
)
return len(records or [])
# ------------------------------------------------------------------
# Relation mirror CRUD
# ------------------------------------------------------------------
def insertMirroredRelation(self, featureInstanceId: str, record: Dict[str, Any]) -> None:
self.db.recordCreate(RedmineRelationMirror, record)
def deleteMirroredRelationsForIssue(self, featureInstanceId: str, issueId: int) -> int:
records_a = self.db.getRecordset(
RedmineRelationMirror,
recordFilter={"featureInstanceId": featureInstanceId, "issueId": int(issueId)},
) or []
records_b = self.db.getRecordset(
RedmineRelationMirror,
recordFilter={"featureInstanceId": featureInstanceId, "issueToId": int(issueId)},
) or []
deleted = 0
seen = set()
for r in list(records_a) + list(records_b):
rid = r.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
self.db.recordDelete(RedmineRelationMirror, rid)
deleted += 1
return deleted
def listMirroredRelations(self, featureInstanceId: str) -> list:
records = self.db.getRecordset(
RedmineRelationMirror,
recordFilter={"featureInstanceId": featureInstanceId},
)
return [dict(r) for r in (records or [])]
def countMirroredRelations(self, featureInstanceId: str) -> int:
return len(self.db.getRecordset(
RedmineRelationMirror,
recordFilter={"featureInstanceId": featureInstanceId},
) or [])
def deleteMirroredRelationByRedmineId(
self, featureInstanceId: str, redmineRelationId: int
) -> bool:
records = self.db.getRecordset(
RedmineRelationMirror,
recordFilter={"featureInstanceId": featureInstanceId, "redmineRelationId": int(redmineRelationId)},
)
if not records:
return False
self.db.recordDelete(RedmineRelationMirror, records[0]["id"])
return True
def getInterface(
currentUser: Optional[User] = None,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> RedmineObjects:
if not currentUser:
raise ValueError("Invalid user context: user is required")
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
contextKey = (
f"redmine_{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}"
)
if contextKey not in _redmineInterfaces:
_redmineInterfaces[contextKey] = RedmineObjects(
currentUser,
mandateId=effectiveMandateId,
featureInstanceId=effectiveFeatureInstanceId,
)
else:
_redmineInterfaces[contextKey].setUserContext(
currentUser,
mandateId=effectiveMandateId,
featureInstanceId=effectiveFeatureInstanceId,
)
return _redmineInterfaces[contextKey]
# ---------------------------------------------------------------------------
# Project meta -- with TTL cache stored on the config record
# ---------------------------------------------------------------------------
class RedmineNotConfiguredError(RuntimeError):
"""The given feature instance has no usable Redmine config."""
def _resolveRootTrackerId(
rootTrackerName: str, trackers: List[Dict[str, Any]]
) -> Optional[int]:
"""Resolve the configured root tracker name to a tracker id.
Strict: case-insensitive exact match. Returns ``None`` if not found
(the UI must surface this as a config error).
"""
target = (rootTrackerName or "").strip().lower()
if not target:
return None
for t in trackers:
if str(t.get("name") or "").strip().lower() == target:
tid = t.get("id")
return int(tid) if tid is not None else None
return None
def _schemaFromCache(
projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str
) -> Optional[RedmineFieldSchemaDto]:
if not cache:
return None
trackers = cache.get("trackers") or []
return RedmineFieldSchemaDto(
projectId=projectId,
projectName=str(cache.get("projectName") or ""),
trackers=[RedmineFieldChoiceDto(**t) for t in trackers],
statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
customFields=[
RedmineCustomFieldSchemaDto(
id=cf.get("id"),
name=cf.get("name", ""),
fieldFormat=cf.get("fieldFormat", "string"),
isRequired=bool(cf.get("isRequired")),
possibleValues=list(cf.get("possibleValues") or []),
multiple=bool(cf.get("multiple")),
defaultValue=cf.get("defaultValue"),
)
for cf in cache.get("customFields") or []
if cf.get("id") is not None
],
rootTrackerName=rootTrackerName,
rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers),
)
async def getProjectMeta(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
*,
forceRefresh: bool = False,
) -> RedmineFieldSchemaDto:
"""Fetch (or return cached) project metadata: trackers, statuses, priorities, etc."""
iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
connector = iface.resolveConnector(featureInstanceId)
if not connector:
raise RedmineNotConfiguredError(
f"Redmine instance {featureInstanceId} is not configured or inactive"
)
cfg = iface.getConfig(featureInstanceId)
if cfg is None:
raise RedmineNotConfiguredError("Config row vanished after connector resolve")
ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60
fresh_enough = (
cfg.schemaCache
and cfg.schemaCachedAt
and (time.time() - cfg.schemaCachedAt) < ttl
)
if fresh_enough and not forceRefresh:
schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName)
if schema is not None:
return schema
project_info = await connector.getProjectInfo()
trackers_raw = await connector.getTrackers()
statuses_raw = await connector.getStatuses()
priorities_raw = await connector.getPriorities()
custom_fields_raw = await connector.getCustomFields()
users_raw = await connector.getProjectUsers()
categories_raw = await connector.getIssueCategories()
schema_cache: Dict[str, Any] = {
"projectName": project_info.get("name", ""),
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw],
"statuses": [
{
"id": s.get("id"),
"name": s.get("name"),
"isClosed": bool(s.get("is_closed")),
}
for s in statuses_raw
],
"priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
"users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
"categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
"customFields": [
{
"id": cf.get("id"),
"name": cf.get("name"),
"fieldFormat": cf.get("field_format", "string"),
"isRequired": bool(cf.get("is_required")),
"possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None],
"multiple": bool(cf.get("multiple")),
"defaultValue": cf.get("default_value"),
}
for cf in custom_fields_raw
],
}
iface.updateSchemaCache(featureInstanceId, schema_cache)
iface.markConfigConnected(featureInstanceId)
return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto(
projectId=cfg.projectId,
projectName=schema_cache["projectName"],
rootTrackerName=cfg.rootTrackerName,
)