# 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]