gateway/modules/features/redmine/serviceRedmine.py
2026-04-21 21:30:11 +02:00

617 lines
24 KiB
Python

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