gateway/modules/features/redmine/interfaceFeatureRedmine.py
2026-04-21 18:14:21 +02:00

449 lines
16 KiB
Python

# Copyright (c) 2026 Patrick Motsch
# 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, Optional
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,
RedmineInstanceConfig,
RedmineRelationMirror,
RedmineTicketMirror,
)
from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG, decryptValue, encryptValue
from modules.shared.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]