# 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, 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, )