# Copyright (c) 2026 Patrick Motsch # All rights reserved. """Redmine service layer. Sits between the FastAPI route and the connector. Responsibilities: - Resolve the connector for an authenticated user / feature instance. - Cache project meta (trackers, statuses, priorities, custom fields, users) on the instance config. - Resolve the configured ``rootTrackerName`` against the live tracker list. No heuristic / no auto-detect. - **Reads come from the local mirror** (``RedmineTicketMirror`` / ``RedmineRelationMirror`` in ``poweron_redmine``). The mirror is populated by ``serviceRedmineSync`` (button or scheduler). - **Writes go to Redmine, then immediately upsert the affected ticket into the mirror** so the UI is consistent without waiting for a sync. - Invalidate ``serviceRedmineStatsCache`` after every successful write. All AI-tool-friendly entry points are pure async functions taking the authenticated ``User`` plus the explicit ``featureInstanceId`` and ``mandateId`` so the same service can be called from REST and from the workflow engine without context-magic. """ from __future__ import annotations import logging import time from typing import Any, Dict, List, Optional, Tuple from modules.connectors.connectorTicketsRedmine import ( ConnectorTicketsRedmine, RedmineApiError, ) from modules.datamodels.datamodelUam import User from modules.features.redmine.datamodelRedmine import ( RedmineCustomFieldSchemaDto, RedmineCustomFieldValueDto, RedmineFieldChoiceDto, RedmineFieldSchemaDto, RedmineRelationCreateRequest, RedmineRelationDto, RedmineTicketCreateRequest, RedmineTicketDto, RedmineTicketUpdateRequest, ) from modules.features.redmine.interfaceFeatureRedmine import ( RedmineObjects, getInterface, ) from modules.features.redmine.serviceRedmineStatsCache import _getStatsCache logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Resolution helpers # --------------------------------------------------------------------------- class RedmineNotConfiguredError(RuntimeError): """The given feature instance has no usable Redmine config.""" def _resolveContext( currentUser: User, mandateId: Optional[str], featureInstanceId: str ) -> Tuple[RedmineObjects, ConnectorTicketsRedmine]: 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" ) return iface, connector # --------------------------------------------------------------------------- # Project meta -- with TTL cache stored on the config record # --------------------------------------------------------------------------- async def getProjectMeta( currentUser: User, mandateId: Optional[str], featureInstanceId: str, *, forceRefresh: bool = False, ) -> RedmineFieldSchemaDto: iface, connector = _resolveContext(currentUser, mandateId, featureInstanceId) 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, ) 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), ) # --------------------------------------------------------------------------- # Mirror -> RedmineTicketDto # --------------------------------------------------------------------------- def _mirroredRowToDto( row: Dict[str, Any], relations: List[Dict[str, Any]], includeRaw: bool = False ) -> RedmineTicketDto: return RedmineTicketDto( id=int(row.get("redmineId")), subject=str(row.get("subject") or ""), description=str(row.get("description") or ""), trackerId=row.get("trackerId"), trackerName=row.get("trackerName"), statusId=row.get("statusId"), statusName=row.get("statusName"), isClosed=bool(row.get("isClosed")), priorityId=row.get("priorityId"), priorityName=row.get("priorityName"), assignedToId=row.get("assignedToId"), assignedToName=row.get("assignedToName"), authorId=row.get("authorId"), authorName=row.get("authorName"), parentId=row.get("parentId"), fixedVersionId=row.get("fixedVersionId"), fixedVersionName=row.get("fixedVersionName"), categoryId=row.get("categoryId"), categoryName=row.get("categoryName"), createdOn=row.get("createdOn"), updatedOn=row.get("updatedOn"), customFields=[ RedmineCustomFieldValueDto( id=int(cf.get("id")), name=str(cf.get("name") or ""), value=cf.get("value"), ) for cf in (row.get("customFields") or []) if cf.get("id") is not None ], relations=[ RedmineRelationDto( id=int(r.get("redmineRelationId") or r.get("id")), issueId=int(r.get("issueId")), issueToId=int(r.get("issueToId")), relationType=str(r.get("relationType") or "relates"), delay=r.get("delay"), ) for r in relations if (r.get("redmineRelationId") or r.get("id")) is not None ], raw=row.get("raw") if includeRaw else None, ) def _isoToEpoch(value: Optional[str]) -> Optional[float]: if not value: return None try: from datetime import datetime return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() except Exception: return None # --------------------------------------------------------------------------- # Read API -- from mirror # --------------------------------------------------------------------------- def listTickets( currentUser: User, mandateId: Optional[str], featureInstanceId: str, *, trackerIds: Optional[List[int]] = None, statusFilter: str = "*", updatedOnFrom: Optional[str] = None, updatedOnTo: Optional[str] = None, assignedToId: Optional[int] = None, ) -> List[RedmineTicketDto]: """List tickets from the local mirror. ``statusFilter`` accepts ``"open"``, ``"closed"`` or ``"*"`` (any), matching the Redmine ``status_id`` semantics. """ iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) rows = iface.listMirroredTickets( featureInstanceId, trackerIds=trackerIds, assigneeId=assignedToId, updatedFromTs=_isoToEpoch(updatedOnFrom), updatedToTs=_isoToEpoch(updatedOnTo), ) if statusFilter and statusFilter != "*": want_closed = statusFilter == "closed" rows = [r for r in rows if bool(r.get("isClosed")) == want_closed] relations_all = iface.listMirroredRelations(featureInstanceId) relations_by_issue: Dict[int, List[Dict[str, Any]]] = {} ids = {int(r.get("redmineId")) for r in rows} for r in relations_all: a = int(r.get("issueId") or 0) b = int(r.get("issueToId") or 0) for k in (a, b): if k in ids: relations_by_issue.setdefault(k, []).append(r) return [ _mirroredRowToDto(row, relations_by_issue.get(int(row.get("redmineId")), [])) for row in rows ] def getTicket( currentUser: User, mandateId: Optional[str], featureInstanceId: str, issueId: int, *, includeRaw: bool = True, ) -> Optional[RedmineTicketDto]: """Read a single ticket from the mirror. Returns ``None`` when not present.""" iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) rows = iface.listMirroredTickets(featureInstanceId) target = next((r for r in rows if int(r.get("redmineId") or 0) == int(issueId)), None) if target is None: return None relations_all = iface.listMirroredRelations(featureInstanceId) rel = [ r for r in relations_all if int(r.get("issueId") or 0) == int(issueId) or int(r.get("issueToId") or 0) == int(issueId) ] return _mirroredRowToDto(target, rel, includeRaw=includeRaw) # --------------------------------------------------------------------------- # Write API -- idempotent + cache invalidation + mirror upsert # --------------------------------------------------------------------------- def _invalidateCache(featureInstanceId: str) -> None: try: _getStatsCache().invalidateInstance(featureInstanceId) except Exception as e: logger.warning(f"Failed to invalidate stats cache for {featureInstanceId}: {e}") def _diffPayload( current: RedmineTicketDto, update: RedmineTicketUpdateRequest ) -> Dict[str, Any]: """Return the Redmine ``issue`` payload containing only changed fields.""" payload: Dict[str, Any] = {} if update.subject is not None and update.subject != current.subject: payload["subject"] = update.subject if update.description is not None and update.description != current.description: payload["description"] = update.description if update.trackerId is not None and update.trackerId != current.trackerId: payload["tracker_id"] = int(update.trackerId) if update.statusId is not None and update.statusId != current.statusId: payload["status_id"] = int(update.statusId) if update.priorityId is not None and update.priorityId != current.priorityId: payload["priority_id"] = int(update.priorityId) if update.assignedToId is not None and update.assignedToId != current.assignedToId: payload["assigned_to_id"] = int(update.assignedToId) if update.parentIssueId is not None and update.parentIssueId != current.parentId: payload["parent_issue_id"] = int(update.parentIssueId) if update.fixedVersionId is not None and update.fixedVersionId != current.fixedVersionId: payload["fixed_version_id"] = int(update.fixedVersionId) if update.customFields: current_by_id = {cf.id: cf.value for cf in current.customFields} cf_payload: List[Dict[str, Any]] = [] for cf_id, value in update.customFields.items(): try: cf_id_int = int(cf_id) except Exception: continue if current_by_id.get(cf_id_int) != value: cf_payload.append({"id": cf_id_int, "value": value}) if cf_payload: payload["custom_fields"] = cf_payload return payload async def _refreshMirroredTicket( currentUser: User, mandateId: Optional[str], featureInstanceId: str, issueId: int, ) -> None: from modules.features.redmine.serviceRedmineSync import upsertSingleTicket try: await upsertSingleTicket(currentUser, mandateId, featureInstanceId, int(issueId)) except Exception as e: logger.warning(f"Mirror upsert for issue {issueId} failed: {e}") async def updateTicket( currentUser: User, mandateId: Optional[str], featureInstanceId: str, issueId: int, update: RedmineTicketUpdateRequest, ) -> RedmineTicketDto: """Idempotent: fetch the issue from Redmine (live, for diff accuracy), only PUT if non-empty, then upsert the mirror.""" _, connector = _resolveContext(currentUser, mandateId, featureInstanceId) schema = await getProjectMeta(currentUser, mandateId, featureInstanceId) current_live = await connector.getIssue(int(issueId), includeRelations=False) current = _liveIssueToDto(current_live, schema) payload = _diffPayload(current, update) if not payload and not update.notes: return current await connector.updateIssue(int(issueId), payload, notes=update.notes) await _refreshMirroredTicket(currentUser, mandateId, featureInstanceId, int(issueId)) _invalidateCache(featureInstanceId) refreshed = getTicket(currentUser, mandateId, featureInstanceId, int(issueId), includeRaw=True) return refreshed or current async def createTicket( currentUser: User, mandateId: Optional[str], featureInstanceId: str, payload: RedmineTicketCreateRequest, ) -> RedmineTicketDto: _, connector = _resolveContext(currentUser, mandateId, featureInstanceId) schema = await getProjectMeta(currentUser, mandateId, featureInstanceId) fields: Dict[str, Any] = { "subject": payload.subject, "tracker_id": int(payload.trackerId), "description": payload.description or "", } if payload.statusId is not None: fields["status_id"] = int(payload.statusId) if payload.priorityId is not None: fields["priority_id"] = int(payload.priorityId) if payload.assignedToId is not None: fields["assigned_to_id"] = int(payload.assignedToId) if payload.parentIssueId is not None: fields["parent_issue_id"] = int(payload.parentIssueId) if payload.fixedVersionId is not None: fields["fixed_version_id"] = int(payload.fixedVersionId) if payload.customFields: fields["custom_fields"] = [ {"id": int(k), "value": v} for k, v in payload.customFields.items() ] created = await connector.createIssue(fields) if created.get("id"): await _refreshMirroredTicket(currentUser, mandateId, featureInstanceId, int(created["id"])) _invalidateCache(featureInstanceId) if not created.get("id"): return _liveIssueToDto(created, schema, includeRaw=True) fresh = getTicket(currentUser, mandateId, featureInstanceId, int(created["id"]), includeRaw=True) return fresh or _liveIssueToDto(created, schema, includeRaw=True) async def deleteTicket( currentUser: User, mandateId: Optional[str], featureInstanceId: str, issueId: int, *, fallbackStatusId: Optional[int] = None, ) -> Dict[str, Any]: """Try DELETE; on Redmine's 403/401 silently fall back to a closed status if ``fallbackStatusId`` is provided. Returns ``{deleted: bool, archived: bool, statusId: int|None}``. """ iface, connector = _resolveContext(currentUser, mandateId, featureInstanceId) deleted = await connector.deleteIssue(int(issueId)) if deleted: from modules.features.redmine.serviceRedmineSync import deleteMirroredTicket deleteMirroredTicket(currentUser, mandateId, featureInstanceId, int(issueId)) _invalidateCache(featureInstanceId) return {"deleted": True, "archived": False, "statusId": None} if fallbackStatusId is not None: await connector.updateIssue( int(issueId), {"status_id": int(fallbackStatusId)}, notes="Archived via Porta -- delete forbidden by Redmine", ) await _refreshMirroredTicket(currentUser, mandateId, featureInstanceId, int(issueId)) _invalidateCache(featureInstanceId) return {"deleted": False, "archived": True, "statusId": int(fallbackStatusId)} return {"deleted": False, "archived": False, "statusId": None} async def addRelation( currentUser: User, mandateId: Optional[str], featureInstanceId: str, issueId: int, payload: RedmineRelationCreateRequest, ) -> Dict[str, Any]: _, connector = _resolveContext(currentUser, mandateId, featureInstanceId) rel = await connector.addRelation( int(issueId), int(payload.issueToId), relationType=payload.relationType, delay=payload.delay, ) await _refreshMirroredTicket(currentUser, mandateId, featureInstanceId, int(issueId)) await _refreshMirroredTicket(currentUser, mandateId, featureInstanceId, int(payload.issueToId)) _invalidateCache(featureInstanceId) return rel async def deleteRelation( currentUser: User, mandateId: Optional[str], featureInstanceId: str, relationId: int, ) -> bool: iface, connector = _resolveContext(currentUser, mandateId, featureInstanceId) ok = await connector.deleteRelation(int(relationId)) if ok: iface.deleteMirroredRelationByRedmineId(featureInstanceId, int(relationId)) _invalidateCache(featureInstanceId) return ok # --------------------------------------------------------------------------- # Live (Redmine) -> RedmineTicketDto -- only used by the write paths to # compute idempotent diffs against the current Redmine state. # --------------------------------------------------------------------------- def _statusIsClosedFromSchema(statusId: Optional[int], schema: Optional[RedmineFieldSchemaDto]) -> bool: if statusId is None or schema is None: return False for s in schema.statuses: if s.id == statusId: return bool(s.isClosed) return False def _liveIssueToDto( issue: Dict[str, Any], schema: Optional[RedmineFieldSchemaDto] = None, *, includeRaw: bool = False ) -> RedmineTicketDto: tracker = issue.get("tracker") or {} status = issue.get("status") or {} priority = issue.get("priority") or {} assigned = issue.get("assigned_to") or {} author = issue.get("author") or {} fixed_version = issue.get("fixed_version") or {} category = issue.get("category") or {} status_id = status.get("id") return RedmineTicketDto( id=int(issue.get("id")), subject=str(issue.get("subject") or ""), description=str(issue.get("description") or ""), trackerId=tracker.get("id"), trackerName=tracker.get("name"), statusId=status_id, statusName=status.get("name"), isClosed=_statusIsClosedFromSchema(status_id, schema), priorityId=priority.get("id"), priorityName=priority.get("name"), assignedToId=assigned.get("id"), assignedToName=assigned.get("name"), authorId=author.get("id"), authorName=author.get("name"), parentId=(issue.get("parent") or {}).get("id"), fixedVersionId=fixed_version.get("id"), fixedVersionName=fixed_version.get("name"), categoryId=category.get("id"), categoryName=category.get("name"), createdOn=issue.get("created_on"), updatedOn=issue.get("updated_on"), customFields=[ RedmineCustomFieldValueDto( id=int(cf.get("id")), name=str(cf.get("name") or ""), value=cf.get("value"), ) for cf in issue.get("custom_fields") or [] if cf.get("id") is not None ], relations=[ RedmineRelationDto( id=int(r.get("id")), issueId=int(r.get("issue_id")), issueToId=int(r.get("issue_to_id")), relationType=str(r.get("relation_type") or "relates"), delay=r.get("delay"), ) for r in issue.get("relations") or [] if r.get("id") is not None ], raw=issue if includeRaw else None, ) # --------------------------------------------------------------------------- # Connection self-test (used by the Settings page button) # --------------------------------------------------------------------------- async def testConnection( currentUser: User, mandateId: Optional[str], featureInstanceId: str, ) -> Dict[str, Any]: """Calls ``whoAmI`` and a minimal project fetch. Updates the ``lastConnectedAt`` timestamp on success. Never raises -- returns a structured dict for the UI.""" iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) connector = iface.resolveConnector(featureInstanceId) if not connector: return {"ok": False, "reason": "notConfigured", "message": "Keine gueltige Redmine-Konfiguration."} try: user = await connector.whoAmI() project = await connector.getProjectInfo() iface.markConfigConnected(featureInstanceId) return { "ok": True, "user": {"id": user.get("id"), "name": (user.get("firstname") or "") + " " + (user.get("lastname") or "")}, "project": {"id": project.get("id"), "name": project.get("name")}, } except RedmineApiError as e: return {"ok": False, "reason": "httpError", "status": e.status, "message": (e.body or "")[:300]} except Exception as e: return {"ok": False, "reason": "exception", "message": str(e)[:300]}