617 lines
24 KiB
Python
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]}
|