449 lines
16 KiB
Python
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]
|