redmine integration

This commit is contained in:
ValueOn AG 2026-04-21 18:14:21 +02:00
parent be43876461
commit dc0346904f
22 changed files with 4154 additions and 7 deletions

8
app.py
View file

@ -519,14 +519,18 @@ from modules.auth import (
ProactiveTokenRefreshMiddleware, ProactiveTokenRefreshMiddleware,
) )
# i18n language detection middleware (sets per-request language from Accept-Language header) # Per-request context middleware: language (Accept-Language) + user timezone (X-User-Timezone).
# Both are written into ContextVars and consumed by t() / resolveText() and getRequestNow()
# without having to thread them through every call site.
from modules.shared.i18nRegistry import _setLanguage, normalizePrimaryLanguageTag from modules.shared.i18nRegistry import _setLanguage, normalizePrimaryLanguageTag
from modules.shared.timeUtils import _setRequestTimezone
@app.middleware("http") @app.middleware("http")
async def _i18nMiddleware(request: Request, call_next): async def _requestContextMiddleware(request: Request, call_next):
acceptLang = request.headers.get("Accept-Language", "") acceptLang = request.headers.get("Accept-Language", "")
lang = normalizePrimaryLanguageTag(acceptLang, "de") lang = normalizePrimaryLanguageTag(acceptLang, "de")
_setLanguage(lang) _setLanguage(lang)
_setRequestTimezone(request.headers.get("X-User-Timezone", ""))
return await call_next(request) return await call_next(request)
app.add_middleware(CSRFMiddleware) app.add_middleware(CSRFMiddleware)

View file

@ -0,0 +1,410 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Redmine REST connector.
Async / aiohttp port of the SSS pilot client
(``pamocreate/projects/valueon/sss/project_mars/redmine-sync/code/_redmineClient.py``)
plus the read-side helpers required by ``serviceRedmine`` and
``serviceRedmineStats``.
Auth: ``X-Redmine-API-Key`` header. The key is *never* logged.
Idempotency / safety:
- ``DELETE /issues/{id}`` is often forbidden in Redmine (HTTP 403).
``deleteIssue`` returns ``False`` instead of raising in that case so
the higher layer can fall back to status-based archival.
- A small ``_throttleSeconds`` delay (default 150 ms) is awaited after
every write call to keep the SSS server happy.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlencode
import aiohttp
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
logger = logging.getLogger(__name__)
class RedmineApiError(RuntimeError):
"""Raised when the Redmine API returns a non-success status."""
def __init__(self, status: int, body: str, method: str, path: str):
self.status = status
self.body = body
self.method = method
self.path = path
super().__init__(f"Redmine {method} {path} failed: HTTP {status} {body[:300]}")
class ConnectorTicketsRedmine(TicketBase):
"""Async Redmine connector. One instance per (baseUrl, apiKey, projectId)."""
def __init__(
self,
*,
baseUrl: str,
apiKey: str,
projectId: str,
throttleSeconds: float = 0.15,
timeoutSeconds: float = 30.0,
) -> None:
if not baseUrl:
raise ValueError("Redmine baseUrl is required")
if not apiKey:
raise ValueError("Redmine apiKey is required")
self._baseUrl = baseUrl.rstrip("/")
self._apiKey = apiKey
self._projectId = str(projectId) if projectId is not None else ""
self._throttleSeconds = max(0.0, float(throttleSeconds))
self._timeoutSeconds = float(timeoutSeconds)
# ------------------------------------------------------------------
# Low-level
# ------------------------------------------------------------------
def _headers(self) -> Dict[str, str]:
return {
"X-Redmine-API-Key": self._apiKey,
"Content-Type": "application/json",
"Accept": "application/json",
}
async def _call(
self,
method: str,
path: str,
*,
payload: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None,
) -> Tuple[int, Optional[Dict[str, Any]], str]:
"""Single REST call. Returns ``(status, json_or_none, raw_body)``.
Does *not* raise -- the caller decides whether a non-2xx is fatal
(e.g. 403 on DELETE is expected and handled).
"""
url = f"{self._baseUrl}{path}"
if params:
url = f"{url}?{urlencode(params)}"
timeout = aiohttp.ClientTimeout(total=self._timeoutSeconds)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(method, url, headers=self._headers(), json=payload) as resp:
raw = await resp.text()
parsed: Optional[Dict[str, Any]] = None
if raw:
try:
parsed = await resp.json(content_type=None)
except Exception:
parsed = None
return resp.status, parsed, raw
except aiohttp.ClientError as e:
logger.warning(f"Redmine {method} {path} client error: {e}")
return -1, None, f"ClientError: {e}"
except asyncio.TimeoutError:
logger.warning(f"Redmine {method} {path} timeout after {self._timeoutSeconds}s")
return -1, None, "Timeout"
@staticmethod
def _isOk(status: int) -> bool:
return 200 <= status < 300
async def _gentle(self) -> None:
if self._throttleSeconds > 0:
await asyncio.sleep(self._throttleSeconds)
# ------------------------------------------------------------------
# Identity / health
# ------------------------------------------------------------------
async def whoAmI(self) -> Dict[str, Any]:
status, body, raw = await self._call("GET", "/users/current.json")
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", "/users/current.json")
return body.get("user", {})
# ------------------------------------------------------------------
# Project meta -- trackers, statuses, priorities, custom fields, users
# ------------------------------------------------------------------
async def getTrackers(self) -> List[Dict[str, Any]]:
status, body, raw = await self._call("GET", "/trackers.json")
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", "/trackers.json")
return body.get("trackers", []) or []
async def getStatuses(self) -> List[Dict[str, Any]]:
status, body, raw = await self._call("GET", "/issue_statuses.json")
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", "/issue_statuses.json")
return body.get("issue_statuses", []) or []
async def getPriorities(self) -> List[Dict[str, Any]]:
status, body, raw = await self._call(
"GET", "/enumerations/issue_priorities.json"
)
if not self._isOk(status) or not body:
return []
return body.get("issue_priorities", []) or []
async def getCustomFields(self) -> List[Dict[str, Any]]:
"""Requires admin privileges in Redmine. Returns ``[]`` if forbidden."""
status, body, raw = await self._call("GET", "/custom_fields.json")
if status == 403 or status == 401:
logger.info("Redmine /custom_fields.json forbidden -- using per-issue field discovery")
return []
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", "/custom_fields.json")
return body.get("custom_fields", []) or []
async def getProjectUsers(self) -> List[Dict[str, Any]]:
status, body, raw = await self._call(
"GET", f"/projects/{self._projectId}/memberships.json", params={"limit": 100}
)
if not self._isOk(status) or not body:
return []
members = body.get("memberships", []) or []
users: List[Dict[str, Any]] = []
seen: set[int] = set()
for m in members:
user = m.get("user")
if not user:
continue
uid = user.get("id")
if uid in seen:
continue
seen.add(uid)
users.append(user)
return users
async def getProjectInfo(self) -> Dict[str, Any]:
status, body, raw = await self._call("GET", f"/projects/{self._projectId}.json")
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", f"/projects/{self._projectId}.json")
return body.get("project", {})
# ------------------------------------------------------------------
# Issues -- read
# ------------------------------------------------------------------
async def getIssue(
self, issueId: int, *, includeRelations: bool = True, includeChildren: bool = False
) -> Dict[str, Any]:
includes = ["custom_fields", "journals"]
if includeRelations:
includes.append("relations")
if includeChildren:
includes.append("children")
params = {"include": ",".join(includes)}
status, body, raw = await self._call("GET", f"/issues/{issueId}.json", params=params)
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", f"/issues/{issueId}.json")
return body.get("issue", {})
async def listIssues(
self,
*,
trackerId: Optional[int] = None,
statusId: Optional[str] = "*",
updatedOnFrom: Optional[str] = None,
updatedOnTo: Optional[str] = None,
createdOnFrom: Optional[str] = None,
createdOnTo: Optional[str] = None,
assignedToId: Optional[int] = None,
subjectContains: Optional[str] = None,
limit: int = 100,
offset: int = 0,
include: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Single-page list. Returns the raw envelope ``{issues, total_count, offset, limit}``."""
params: Dict[str, Any] = {
"project_id": self._projectId,
"limit": str(limit),
"offset": str(offset),
}
if statusId is not None:
params["status_id"] = str(statusId)
if trackerId is not None:
params["tracker_id"] = str(trackerId)
if assignedToId is not None:
params["assigned_to_id"] = str(assignedToId)
if subjectContains:
params["subject"] = f"~{subjectContains}"
if updatedOnFrom and updatedOnTo:
params["updated_on"] = f"><{updatedOnFrom}|{updatedOnTo}"
elif updatedOnFrom:
params["updated_on"] = f">={updatedOnFrom}"
elif updatedOnTo:
params["updated_on"] = f"<={updatedOnTo}"
if createdOnFrom and createdOnTo:
params["created_on"] = f"><{createdOnFrom}|{createdOnTo}"
elif createdOnFrom:
params["created_on"] = f">={createdOnFrom}"
elif createdOnTo:
params["created_on"] = f"<={createdOnTo}"
if include:
params["include"] = ",".join(include)
status, body, raw = await self._call("GET", "/issues.json", params=params)
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "GET", "/issues.json")
return body
async def listAllIssues(
self,
*,
trackerId: Optional[int] = None,
statusId: Optional[str] = "*",
updatedOnFrom: Optional[str] = None,
updatedOnTo: Optional[str] = None,
createdOnFrom: Optional[str] = None,
createdOnTo: Optional[str] = None,
assignedToId: Optional[int] = None,
pageSize: int = 100,
maxPages: int = 50,
include: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Paginate ``listIssues`` and return all matching raw issues."""
all_issues: List[Dict[str, Any]] = []
offset = 0
for _page in range(maxPages):
envelope = await self.listIssues(
trackerId=trackerId,
statusId=statusId,
updatedOnFrom=updatedOnFrom,
updatedOnTo=updatedOnTo,
createdOnFrom=createdOnFrom,
createdOnTo=createdOnTo,
assignedToId=assignedToId,
limit=pageSize,
offset=offset,
include=include,
)
page_issues = envelope.get("issues", []) or []
all_issues.extend(page_issues)
total = int(envelope.get("total_count") or 0)
offset += len(page_issues)
if not page_issues or offset >= total:
break
return all_issues
async def listRelations(self, issueId: int) -> List[Dict[str, Any]]:
issue = await self.getIssue(issueId, includeRelations=True)
return issue.get("relations", []) or []
# ------------------------------------------------------------------
# Issues -- write
# ------------------------------------------------------------------
async def createIssue(self, fields: Dict[str, Any]) -> Dict[str, Any]:
body_in = {"issue": dict(fields)}
body_in["issue"].setdefault("project_id", self._projectId)
status, body, raw = await self._call("POST", "/issues.json", payload=body_in)
await self._gentle()
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "POST", "/issues.json")
return body.get("issue", {})
async def updateIssue(
self, issueId: int, fields: Dict[str, Any], *, notes: Optional[str] = None
) -> bool:
body_in: Dict[str, Any] = {"issue": dict(fields)}
if notes:
body_in["issue"]["notes"] = notes
status, body, raw = await self._call("PUT", f"/issues/{issueId}.json", payload=body_in)
await self._gentle()
if status == 204:
return True
if not self._isOk(status):
raise RedmineApiError(status, raw, "PUT", f"/issues/{issueId}.json")
return True
async def deleteIssue(self, issueId: int) -> bool:
"""Returns ``False`` if Redmine forbids deletion (HTTP 403/401)."""
status, body, raw = await self._call("DELETE", f"/issues/{issueId}.json")
await self._gentle()
if status in (200, 204):
return True
if status in (401, 403):
logger.info(f"Redmine DELETE issue {issueId} forbidden ({status}) -- caller should fall back")
return False
raise RedmineApiError(status, raw, "DELETE", f"/issues/{issueId}.json")
# ------------------------------------------------------------------
# Relations -- write
# ------------------------------------------------------------------
async def addRelation(
self, fromId: int, toId: int, *, relationType: str = "relates", delay: Optional[int] = None
) -> Dict[str, Any]:
rel: Dict[str, Any] = {"issue_to_id": toId, "relation_type": relationType}
if delay is not None:
rel["delay"] = int(delay)
status, body, raw = await self._call(
"POST", f"/issues/{fromId}/relations.json", payload={"relation": rel}
)
await self._gentle()
if not self._isOk(status) or not body:
raise RedmineApiError(status, raw, "POST", f"/issues/{fromId}/relations.json")
return body.get("relation", {})
async def deleteRelation(self, relationId: int) -> bool:
status, body, raw = await self._call("DELETE", f"/relations/{relationId}.json")
await self._gentle()
if status in (200, 204):
return True
if status in (401, 403):
return False
raise RedmineApiError(status, raw, "DELETE", f"/relations/{relationId}.json")
# ------------------------------------------------------------------
# TicketBase compliance (used by AI-tool path)
# ------------------------------------------------------------------
async def readAttributes(self) -> List[TicketFieldAttribute]:
"""Static base attributes + project custom fields (best-effort)."""
attrs: List[TicketFieldAttribute] = [
TicketFieldAttribute(fieldName="Subject", field="subject"),
TicketFieldAttribute(fieldName="Description", field="description"),
TicketFieldAttribute(fieldName="Tracker", field="tracker_id"),
TicketFieldAttribute(fieldName="Status", field="status_id"),
TicketFieldAttribute(fieldName="Priority", field="priority_id"),
TicketFieldAttribute(fieldName="Assignee", field="assigned_to_id"),
TicketFieldAttribute(fieldName="Parent", field="parent_issue_id"),
TicketFieldAttribute(fieldName="Target Version", field="fixed_version_id"),
]
try:
cfs = await self.getCustomFields()
except Exception:
cfs = []
for cf in cfs:
try:
attrs.append(
TicketFieldAttribute(
fieldName=str(cf.get("name", f"cf_{cf.get('id')}")),
field=f"cf_{cf.get('id')}",
)
)
except Exception:
continue
return attrs
async def readTasks(self, *, limit: int = 0) -> List[Dict[str, Any]]:
if limit and limit > 0:
envelope = await self.listIssues(limit=limit)
return envelope.get("issues", []) or []
return await self.listAllIssues()
async def writeTasks(self, tasklist: List[Dict[str, Any]]) -> None:
for task in tasklist:
issue_id = task.get("id")
fields = {k: v for k, v in task.items() if k != "id"}
if issue_id:
await self.updateIssue(int(issue_id), fields)
else:
await self.createIssue(fields)

View file

@ -0,0 +1,3 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Redmine feature container -- ticket browser, statistics, AI tools."""

View file

@ -0,0 +1,530 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Redmine feature data models.
Two layers:
1. **Persisted** (``PowerOnModel``, auto-DDL into ``poweron_redmine``):
- ``RedmineInstanceConfig``: per-feature-instance connection + sync state.
- ``RedmineTicketMirror``: local mirror of a Redmine issue.
- ``RedmineRelationMirror``: local mirror of an issue relation.
2. **Transport** (plain Pydantic): ``Redmine*Dto`` returned over the
REST API and shared with the AI tools. The frontend (``RedmineStatsPage``)
maps the raw ``RedmineStatsDto`` buckets onto ``ReportSection`` for
``FormGeneratorReport``.
Scale: the mirror tables let us aggregate stats and render the ticket tree
for projects with 20k+ tickets without round-tripping the Redmine REST API
on every request.
"""
import uuid
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, model_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
def _coerceNoneToDefaults(cls, values):
"""Replace None values with each field's declared default.
Reason: Postgres rows written before we added a column return NULL for
that column, which Pydantic v2 rejects for non-Optional fields even if
a default is declared. We only apply the default when the incoming
value is explicitly None AND the field has a default (not a
default_factory that would generate a new value).
"""
if not isinstance(values, dict):
return values
for name, field in cls.model_fields.items():
if name in values and values[name] is None and field.default is not None:
values[name] = field.default
return values
# ---------------------------------------------------------------------------
# Persisted: per feature-instance Redmine connection config + sync state
# ---------------------------------------------------------------------------
@i18nModel("Redmine-Verbindung")
class RedmineInstanceConfig(PowerOnModel):
"""Per feature-instance Redmine connection config.
The API key is stored encrypted (``encryptValue`` keyed
``"redmineApiKey"``). It is never returned to the frontend in plain
text -- the route returns a boolean ``hasApiKey`` flag instead.
"""
@model_validator(mode="before")
@classmethod
def _applyDefaults(cls, values):
return _coerceNoneToDefaults(cls, values)
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
featureInstanceId: str = Field(
description="FK -> FeatureInstance.id (1:1 per instance)",
json_schema_extra={
"label": "Feature-Instanz",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID (auto-set from feature instance)",
json_schema_extra={
"label": "Mandant",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_app", "table": "Mandate"},
},
)
baseUrl: str = Field(
default="",
description="Redmine base URL, e.g. https://redmine.logobject.ch",
json_schema_extra={"label": "Basis-URL", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
projectId: str = Field(
default="",
description="Redmine numeric project id or identifier (slug)",
json_schema_extra={"label": "Projekt-ID", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
encryptedApiKey: str = Field(
default="",
description="Encrypted Redmine API key (X-Redmine-API-Key)",
json_schema_extra={"label": "API-Key (verschluesselt)", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True},
)
rootTrackerName: str = Field(
default="Userstory",
description="Name of the tracker used as the tree root in the browser. Set explicitly in config; resolved against the live tracker list at runtime.",
json_schema_extra={"label": "Wurzel-Tracker (Name)", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
)
defaultPeriodValue: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional snapshot of a frontend ``PeriodValue`` ({preset, fromDate, toDate}) used as default period when the user opens the feature.",
json_schema_extra={"label": "Standard-Zeitraum", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False},
)
schemaCache: Optional[Dict[str, Any]] = Field(
default=None,
description="Cached project meta: {trackers:[{id,name}], statuses:[{id,name,isClosed}], customFields:[{id,name,fieldFormat,possibleValues}], priorities:[...], users:[{id,name}]}",
json_schema_extra={"label": "Schema-Cache", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True},
)
schemaCachedAt: Optional[float] = Field(
default=None,
description="UTC timestamp when schemaCache was last refreshed",
json_schema_extra={"label": "Schema-Cache-Zeit", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
schemaCacheTtlSeconds: Optional[int] = Field(
default=24 * 60 * 60,
description="Schema cache TTL in seconds (default 24h). Optional to tolerate NULL rows from auto-DDL upgrades.",
json_schema_extra={"label": "Schema-Cache-TTL (s)", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False},
)
isActive: Optional[bool] = Field(
default=True,
description="Whether this connection is active",
json_schema_extra={"label": "Aktiv", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
lastConnectedAt: Optional[float] = Field(
default=None,
description="Timestamp of the last successful whoAmI() call",
json_schema_extra={"label": "Letzte Verbindung", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
# ---- Sync state (incremental ticket mirror) ---------------------------
lastSyncAt: Optional[float] = Field(
default=None,
description="UTC timestamp of the last successful (incremental) mirror sync",
json_schema_extra={"label": "Letzter Sync", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
lastFullSyncAt: Optional[float] = Field(
default=None,
description="UTC timestamp of the last full mirror sync (force=true)",
json_schema_extra={"label": "Letzter Full-Sync", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
lastSyncDurationMs: Optional[int] = Field(
default=None,
description="Duration of the last sync in milliseconds",
json_schema_extra={"label": "Letzte Sync-Dauer (ms)", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
)
lastSyncTicketCount: Optional[int] = Field(
default=None,
description="Number of tickets upserted in the last sync",
json_schema_extra={"label": "Tickets im letzten Sync", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
)
lastSyncErrorAt: Optional[float] = Field(
default=None,
description="UTC timestamp of the last failed sync",
json_schema_extra={"label": "Letzter Sync-Fehler", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
lastSyncErrorMessage: Optional[str] = Field(
default=None,
description="Error message of the last failed sync",
json_schema_extra={"label": "Letzter Fehler", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
@i18nModel("Redmine-Ticket (Mirror)")
class RedmineTicketMirror(PowerOnModel):
"""Local mirror of a Redmine issue.
Composite uniqueness: ``(featureInstanceId, redmineId)``. We do not
enforce it via a DB constraint -- the sync logic looks up by these
two columns and does an upsert.
"""
@model_validator(mode="before")
@classmethod
def _applyDefaults(cls, values):
return _coerceNoneToDefaults(cls, values)
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
featureInstanceId: str = Field(
description="FK -> FeatureInstance.id",
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
)
mandateId: Optional[str] = Field(
default=None,
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
redmineId: int = Field(
description="Redmine issue id (unique per feature instance)",
json_schema_extra={"label": "Redmine-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": True},
)
subject: str = Field(default="", json_schema_extra={"label": "Titel", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: str = Field(default="", json_schema_extra={"label": "Beschreibung", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
trackerId: Optional[int] = Field(default=None, json_schema_extra={"label": "Tracker-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
trackerName: Optional[str] = Field(default=None, json_schema_extra={"label": "Tracker", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
statusId: Optional[int] = Field(default=None, json_schema_extra={"label": "Status-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
statusName: Optional[str] = Field(default=None, json_schema_extra={"label": "Status", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
isClosed: bool = Field(default=False, json_schema_extra={"label": "Geschlossen", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False})
priorityId: Optional[int] = Field(default=None, json_schema_extra={"label": "Prio-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
priorityName: Optional[str] = Field(default=None, json_schema_extra={"label": "Prioritaet", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
assignedToId: Optional[int] = Field(default=None, json_schema_extra={"label": "Zuweisung-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
assignedToName: Optional[str] = Field(default=None, json_schema_extra={"label": "Zuweisung", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
authorId: Optional[int] = Field(default=None, json_schema_extra={"label": "Autor-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
authorName: Optional[str] = Field(default=None, json_schema_extra={"label": "Autor", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
parentId: Optional[int] = Field(default=None, json_schema_extra={"label": "Parent-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
fixedVersionId: Optional[int] = Field(default=None, json_schema_extra={"label": "Zielversion-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
fixedVersionName: Optional[str] = Field(default=None, json_schema_extra={"label": "Zielversion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
createdOn: Optional[str] = Field(default=None, json_schema_extra={"label": "Erstellt am (Redmine)", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
updatedOn: Optional[str] = Field(default=None, json_schema_extra={"label": "Geaendert am (Redmine)", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
createdOnTs: Optional[float] = Field(default=None, description="UTC epoch parsed from createdOn (for SQL filtering)",
json_schema_extra={"label": "createdOn (epoch)", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True})
updatedOnTs: Optional[float] = Field(default=None, description="UTC epoch parsed from updatedOn (for SQL filtering)",
json_schema_extra={"label": "updatedOn (epoch)", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True})
customFields: Optional[List[Dict[str, Any]]] = Field(
default=None,
description="List of {id,name,value} as returned by Redmine; stored as JSON",
json_schema_extra={"label": "Custom Fields", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
)
raw: Optional[Dict[str, Any]] = Field(
default=None,
description="Original Redmine issue payload (full)",
json_schema_extra={"label": "Roh-Payload", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True},
)
syncedAt: Optional[float] = Field(
default=None,
description="UTC epoch when this row was last upserted from Redmine",
json_schema_extra={"label": "Synced At", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
@i18nModel("Redmine-Beziehung (Mirror)")
class RedmineRelationMirror(PowerOnModel):
"""Local mirror of a Redmine issue relation.
Composite uniqueness: ``(featureInstanceId, redmineRelationId)``.
"""
@model_validator(mode="before")
@classmethod
def _applyDefaults(cls, values):
return _coerceNoneToDefaults(cls, values)
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
featureInstanceId: str = Field(
description="FK -> FeatureInstance.id",
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
)
redmineRelationId: int = Field(
description="Redmine relation id (unique per feature instance)",
json_schema_extra={"label": "Relation-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": True},
)
issueId: int = Field(
description="Source issue id (issue.id from Redmine)",
json_schema_extra={"label": "Source-Issue-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": True},
)
issueToId: int = Field(
description="Target issue id (issue_to_id from Redmine)",
json_schema_extra={"label": "Target-Issue-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": True},
)
relationType: str = Field(
default="relates",
json_schema_extra={"label": "Beziehungstyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
delay: Optional[int] = Field(
default=None,
json_schema_extra={"label": "Verzoegerung (Tage)", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False},
)
syncedAt: Optional[float] = Field(
default=None,
json_schema_extra={"label": "Synced At", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False},
)
# ---------------------------------------------------------------------------
# Transport DTOs (not persisted)
# ---------------------------------------------------------------------------
class RedmineRelationDto(BaseModel):
id: int = Field(description="Relation id")
issueId: int = Field(description="Source issue id (issue.id from Redmine)")
issueToId: int = Field(description="Target issue id (issue_to_id from Redmine)")
relationType: str = Field(description="relates | precedes | follows | blocks | blocked | duplicates | duplicated | copied_to | copied_from | parent")
delay: Optional[int] = Field(default=None, description="Delay in days (precedes/follows only)")
class RedmineCustomFieldValueDto(BaseModel):
id: int
name: str
value: Any = None
class RedmineTicketDto(BaseModel):
"""Normalised Redmine issue used by the UI and the AI tools."""
id: int = Field(description="Redmine issue id")
subject: str = Field(default="")
description: str = Field(default="")
trackerId: Optional[int] = None
trackerName: Optional[str] = None
statusId: Optional[int] = None
statusName: Optional[str] = None
isClosed: bool = False
priorityId: Optional[int] = None
priorityName: Optional[str] = None
assignedToId: Optional[int] = None
assignedToName: Optional[str] = None
authorId: Optional[int] = None
authorName: Optional[str] = None
parentId: Optional[int] = None
fixedVersionId: Optional[int] = None
fixedVersionName: Optional[str] = None
createdOn: Optional[str] = None
updatedOn: Optional[str] = None
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
relations: List[RedmineRelationDto] = Field(default_factory=list)
raw: Optional[Dict[str, Any]] = None
class RedmineFieldChoiceDto(BaseModel):
id: int
name: str
isClosed: Optional[bool] = Field(default=None, description="Status only: closed-state flag")
class RedmineCustomFieldSchemaDto(BaseModel):
id: int
name: str
fieldFormat: str = Field(default="string")
isRequired: bool = False
possibleValues: List[str] = Field(default_factory=list)
multiple: bool = False
defaultValue: Optional[str] = None
class RedmineFieldSchemaDto(BaseModel):
"""Project meta returned by ``getProjectMeta``."""
projectId: str
projectName: str = ""
trackers: List[RedmineFieldChoiceDto] = Field(default_factory=list)
statuses: List[RedmineFieldChoiceDto] = Field(default_factory=list)
priorities: List[RedmineFieldChoiceDto] = Field(default_factory=list)
users: List[RedmineFieldChoiceDto] = Field(default_factory=list)
customFields: List[RedmineCustomFieldSchemaDto] = Field(default_factory=list)
rootTrackerName: str = "Userstory"
rootTrackerId: Optional[int] = Field(default=None, description="Resolved id of the configured rootTrackerName, or None if no matching tracker exists")
# ---------------------------------------------------------------------------
# Stats DTO -- raw buckets, mapped to ReportSection in the frontend
# ---------------------------------------------------------------------------
class RedmineStatsKpis(BaseModel):
total: int = 0
open: int = 0
closed: int = 0
closedInPeriod: int = 0
createdInPeriod: int = 0
orphans: int = 0
class RedmineStatusByTrackerEntry(BaseModel):
trackerId: Optional[int] = None
trackerName: str = ""
countsByStatus: Dict[str, int] = Field(default_factory=dict)
total: int = 0
class RedmineThroughputBucket(BaseModel):
bucketKey: str
label: str
created: int = 0
closed: int = 0
class RedmineAssigneeBucket(BaseModel):
assignedToId: Optional[int] = None
name: str = "(nicht zugewiesen)"
open: int = 0
class RedmineRelationDistributionEntry(BaseModel):
relationType: str
count: int = 0
class RedmineAgingBucket(BaseModel):
bucketKey: str
label: str
minDays: int
maxDays: Optional[int] = None
count: int = 0
class RedmineStatsDto(BaseModel):
"""All sections needed by the Statistics page in one round-trip."""
instanceId: str
dateFrom: Optional[str] = None
dateTo: Optional[str] = None
bucket: str = "week"
trackerIds: List[int] = Field(default_factory=list)
kpis: RedmineStatsKpis = Field(default_factory=RedmineStatsKpis)
statusByTracker: List[RedmineStatusByTrackerEntry] = Field(default_factory=list)
throughput: List[RedmineThroughputBucket] = Field(default_factory=list)
topAssignees: List[RedmineAssigneeBucket] = Field(default_factory=list)
relationDistribution: List[RedmineRelationDistributionEntry] = Field(default_factory=list)
backlogAging: List[RedmineAgingBucket] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Sync DTO
# ---------------------------------------------------------------------------
class RedmineSyncResultDto(BaseModel):
instanceId: str
full: bool = Field(description="True if a full sync was performed (no incremental cursor)")
ticketsUpserted: int = 0
relationsUpserted: int = 0
durationMs: int = 0
lastSyncAt: float
error: Optional[str] = None
class RedmineSyncStatusDto(BaseModel):
instanceId: str
lastSyncAt: Optional[float] = None
lastFullSyncAt: Optional[float] = None
lastSyncDurationMs: Optional[int] = None
lastSyncTicketCount: Optional[int] = None
lastSyncErrorAt: Optional[float] = None
lastSyncErrorMessage: Optional[str] = None
mirroredTicketCount: int = 0
mirroredRelationCount: int = 0
# ---------------------------------------------------------------------------
# Request bodies
# ---------------------------------------------------------------------------
class RedmineConfigUpdateRequest(BaseModel):
"""PUT body for the config endpoint. Fields are all optional -- only
provided ones are updated. ``apiKey`` is encrypted before persistence."""
baseUrl: Optional[str] = None
projectId: Optional[str] = None
apiKey: Optional[str] = Field(default=None, description="Plain api key; will be encrypted server-side")
rootTrackerName: Optional[str] = None
defaultPeriodValue: Optional[Dict[str, Any]] = None
schemaCacheTtlSeconds: Optional[int] = None
isActive: Optional[bool] = None
class RedmineConfigDto(BaseModel):
"""Frontend-safe view of the config (no plain api key)."""
id: Optional[str] = None
featureInstanceId: str
mandateId: Optional[str] = None
baseUrl: str = ""
projectId: str = ""
hasApiKey: bool = False
rootTrackerName: str = "Userstory"
defaultPeriodValue: Optional[Dict[str, Any]] = None
schemaCacheTtlSeconds: int = 24 * 60 * 60
schemaCachedAt: Optional[float] = None
isActive: bool = True
lastConnectedAt: Optional[float] = None
lastSyncAt: Optional[float] = None
lastFullSyncAt: Optional[float] = None
lastSyncTicketCount: Optional[int] = None
lastSyncErrorMessage: Optional[str] = None
class RedmineTicketUpdateRequest(BaseModel):
"""Body for ``PUT /tickets/{id}``."""
subject: Optional[str] = None
description: Optional[str] = None
trackerId: Optional[int] = None
statusId: Optional[int] = None
priorityId: Optional[int] = None
assignedToId: Optional[int] = None
parentIssueId: Optional[int] = None
fixedVersionId: Optional[int] = None
notes: Optional[str] = None
customFields: Optional[Dict[int, Any]] = None
class RedmineTicketCreateRequest(BaseModel):
"""Body for ``POST /tickets``."""
subject: str
trackerId: int
description: Optional[str] = ""
statusId: Optional[int] = None
priorityId: Optional[int] = None
assignedToId: Optional[int] = None
parentIssueId: Optional[int] = None
fixedVersionId: Optional[int] = None
customFields: Optional[Dict[int, Any]] = None
class RedmineRelationCreateRequest(BaseModel):
"""Body for ``POST /tickets/{id}/relations``."""
issueToId: int
relationType: str = Field(default="relates")
delay: Optional[int] = None

View file

@ -0,0 +1,449 @@
# 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]

View file

@ -0,0 +1,329 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Redmine Feature Container -- Main Module.
Defines the feature metadata and registers RBAC objects + template roles
in the catalog. Loaded automatically by ``modules.system.registry``.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
FEATURE_CODE = "redmine"
FEATURE_LABEL = "Redmine"
FEATURE_ICON = "mdi-bug-outline"
UI_OBJECTS: List[Dict[str, Any]] = [
{"objectKey": "ui.feature.redmine.stats", "label": "Statistik", "meta": {"area": "stats", "isDefault": True}},
{"objectKey": "ui.feature.redmine.browser", "label": "Ticket-Browser", "meta": {"area": "browser"}},
{"objectKey": "ui.feature.redmine.settings", "label": "Einstellungen", "meta": {"area": "settings", "admin_only": True}},
]
DATA_OBJECTS: List[Dict[str, Any]] = [
{"objectKey": "data.feature.redmine.config", "label": "Konfiguration", "meta": {"isGroup": True}},
{
"objectKey": "data.feature.redmine.RedmineInstanceConfig",
"label": "Redmine-Verbindung",
"meta": {
"table": "RedmineInstanceConfig",
"group": "data.feature.redmine.config",
"fields": ["id", "baseUrl", "projectId", "rootTrackerName", "isActive", "lastConnectedAt", "lastSyncAt"],
},
},
{
"objectKey": "data.feature.redmine.RedmineTicketMirror",
"label": "Redmine-Tickets (Mirror)",
"meta": {
"table": "RedmineTicketMirror",
"group": "data.feature.redmine.config",
"fields": ["redmineId", "subject", "trackerName", "statusName", "assignedToName", "updatedOn"],
},
},
{
"objectKey": "data.feature.redmine.RedmineRelationMirror",
"label": "Redmine-Beziehungen (Mirror)",
"meta": {
"table": "RedmineRelationMirror",
"group": "data.feature.redmine.config",
"fields": ["redmineRelationId", "issueId", "issueToId", "relationType"],
},
},
{
"objectKey": "data.feature.redmine.*",
"label": "Alle Redmine-Daten",
"meta": {"wildcard": True, "description": "Wildcard for all redmine data tables"},
},
]
RESOURCE_OBJECTS: List[Dict[str, Any]] = [
{
"objectKey": "resource.feature.redmine.tickets.read",
"label": "Tickets lesen",
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.tickets.create",
"label": "Tickets erstellen",
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets", "method": "POST"},
},
{
"objectKey": "resource.feature.redmine.tickets.update",
"label": "Tickets bearbeiten",
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "PUT"},
},
{
"objectKey": "resource.feature.redmine.tickets.delete",
"label": "Tickets loeschen / archivieren",
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}", "method": "DELETE"},
},
{
"objectKey": "resource.feature.redmine.relations.manage",
"label": "Beziehungen verwalten",
"meta": {"endpoint": "/api/redmine/{instanceId}/tickets/{issueId}/relations", "method": "ALL"},
},
{
"objectKey": "resource.feature.redmine.stats.read",
"label": "Statistik einsehen",
"meta": {"endpoint": "/api/redmine/{instanceId}/stats", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.config.manage",
"label": "Verbindung verwalten",
"meta": {"endpoint": "/api/redmine/{instanceId}/config", "method": "ALL", "admin_only": True},
},
{
"objectKey": "resource.feature.redmine.config.test",
"label": "Verbindung testen",
"meta": {"endpoint": "/api/redmine/{instanceId}/config/test", "method": "POST", "admin_only": True},
},
{
"objectKey": "resource.feature.redmine.sync.run",
"label": "Mirror synchronisieren",
"meta": {"endpoint": "/api/redmine/{instanceId}/sync", "method": "POST", "admin_only": True},
},
{
"objectKey": "resource.feature.redmine.sync.status",
"label": "Sync-Status lesen",
"meta": {"endpoint": "/api/redmine/{instanceId}/sync/status", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.workflows.view",
"label": "Workflows einsehen",
"meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"},
},
{
"objectKey": "resource.feature.redmine.workflows.execute",
"label": "Workflows ausfuehren",
"meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"},
},
]
TEMPLATE_ROLES: List[Dict[str, Any]] = [
{
"roleLabel": "redmine-viewer",
"description": "Redmine-Betrachter -- Tickets und Statistik lesen",
"accessRules": [
{"context": "UI", "item": "ui.feature.redmine.stats", "view": True},
{"context": "UI", "item": "ui.feature.redmine.browser", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.read", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.stats.read", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.sync.status", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "redmine-editor",
"description": "Redmine-Bearbeiter -- Tickets erstellen, bearbeiten, Beziehungen pflegen",
"accessRules": [
{"context": "UI", "item": "ui.feature.redmine.stats", "view": True},
{"context": "UI", "item": "ui.feature.redmine.browser", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.read", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.update", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.delete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.relations.manage", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.stats.read", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.sync.status", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.workflows.view", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.workflows.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "n"},
],
},
{
"roleLabel": "redmine-admin",
"description": "Redmine-Administrator -- Vollzugriff inkl. Einstellungen und Verbindung",
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
{"context": "RESOURCE", "item": "resource.feature.redmine.config.manage", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.config.test", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.update", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.tickets.delete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.relations.manage", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.workflows.view", "view": True},
{"context": "RESOURCE", "item": "resource.feature.redmine.workflows.execute", "view": True},
],
},
]
# ---------------------------------------------------------------------------
# Public discovery API (called by registry.py)
# ---------------------------------------------------------------------------
def getFeatureDefinition() -> Dict[str, Any]:
return {"code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON}
def getUiObjects() -> List[Dict[str, Any]]:
return UI_OBJECTS
def getResourceObjects() -> List[Dict[str, Any]]:
return RESOURCE_OBJECTS
def getDataObjects() -> List[Dict[str, Any]]:
return DATA_OBJECTS
def getTemplateRoles() -> List[Dict[str, Any]]:
return TEMPLATE_ROLES
def getTemplateWorkflows() -> List[Dict[str, Any]]:
return []
def registerFeature(catalogService) -> bool:
"""Register UI / Resource / Data objects and sync template roles."""
try:
for uiObj in UI_OBJECTS:
catalogService.registerUiObject(
featureCode=FEATURE_CODE,
objectKey=uiObj["objectKey"],
label=uiObj["label"],
meta=uiObj.get("meta"),
)
for resObj in RESOURCE_OBJECTS:
catalogService.registerResourceObject(
featureCode=FEATURE_CODE,
objectKey=resObj["objectKey"],
label=resObj["label"],
meta=resObj.get("meta"),
)
for dataObj in DATA_OBJECTS:
catalogService.registerDataObject(
featureCode=FEATURE_CODE,
objectKey=dataObj["objectKey"],
label=dataObj["label"],
meta=dataObj.get("meta"),
)
_syncTemplateRolesToDb()
logger.info(
f"Feature '{FEATURE_CODE}' registered "
f"{len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects"
)
return True
except Exception as e:
logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}")
return False
# ---------------------------------------------------------------------------
# Template-role sync (mirrors the trustee implementation)
# ---------------------------------------------------------------------------
def _syncTemplateRolesToDb() -> int:
try:
from modules.datamodels.datamodelRbac import (
AccessRule,
AccessRuleContext,
Role,
)
from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE)
templateRoles = [r for r in existingRoles if r.mandateId is None]
existingByLabel: Dict[str, str] = {r.roleLabel: str(r.id) for r in templateRoles}
createdCount = 0
for roleTemplate in TEMPLATE_ROLES:
roleLabel = roleTemplate["roleLabel"]
if roleLabel in existingByLabel:
_ensureAccessRulesForRole(
rootInterface,
existingByLabel[roleLabel],
roleTemplate.get("accessRules", []),
)
continue
newRole = Role(
roleLabel=roleLabel,
description=coerce_text_multilingual(roleTemplate.get("description", {})),
featureCode=FEATURE_CODE,
mandateId=None,
featureInstanceId=None,
isSystemRole=False,
)
createdRole = rootInterface.db.recordCreate(Role, newRole.model_dump())
roleId = createdRole.get("id")
_ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", []))
logger.info(f"Created template role '{roleLabel}' with ID {roleId}")
createdCount += 1
return createdCount
except Exception as e:
logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}")
return 0
def _ensureAccessRulesForRole(
rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]
) -> int:
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
existingRules = rootInterface.getAccessRulesByRole(roleId)
existingSignatures: set[Any] = set()
for rule in existingRules:
sig = (rule.context.value if rule.context else None, rule.item)
existingSignatures.add(sig)
createdCount = 0
for template in ruleTemplates:
context = template.get("context", "UI")
item = template.get("item")
if (context, item) in existingSignatures:
continue
if context == "UI":
contextEnum = AccessRuleContext.UI
elif context == "DATA":
contextEnum = AccessRuleContext.DATA
elif context == "RESOURCE":
contextEnum = AccessRuleContext.RESOURCE
else:
contextEnum = context
newRule = AccessRule(
roleId=roleId,
context=contextEnum,
item=item,
view=template.get("view", False),
read=template.get("read"),
create=template.get("create"),
update=template.get("update"),
delete=template.get("delete"),
)
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
return createdCount

View file

@ -0,0 +1,478 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""FastAPI routes for the Redmine feature.
URL pattern: ``/api/redmine/{instanceId}/...`` -- mirrors the Trustee /
CommCoach pattern. Every endpoint validates that the feature instance
exists and resolves its ``mandateId``. Audit log is written for every
write call.
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
from modules.auth import RequestContext, getRequestContext, limiter
from modules.features.redmine import interfaceFeatureRedmine as interfaceDb
from modules.features.redmine import (
serviceRedmine,
serviceRedmineStats,
serviceRedmineSync,
)
from modules.features.redmine.datamodelRedmine import (
RedmineConfigDto,
RedmineConfigUpdateRequest,
RedmineFieldSchemaDto,
RedmineRelationCreateRequest,
RedmineStatsDto,
RedmineSyncResultDto,
RedmineSyncStatusDto,
RedmineTicketCreateRequest,
RedmineTicketDto,
RedmineTicketUpdateRequest,
)
from modules.features.redmine.serviceRedmine import RedmineNotConfiguredError
from modules.connectors.connectorTicketsRedmine import RedmineApiError
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeFeatureRedmine")
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/redmine",
tags=["Redmine"],
responses={404: {"description": "Not found"}},
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _audit(
context: RequestContext,
action: str,
resourceType: Optional[str] = None,
resourceId: Optional[str] = None,
details: str = "",
success: bool = True,
errorMessage: Optional[str] = None,
) -> None:
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logEvent(
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=getattr(context, "featureInstanceId", None),
category="redmine",
action=action,
resourceType=resourceType,
resourceId=resourceId,
details=details,
success=success,
errorMessage=errorMessage,
)
except Exception as e:
logger.debug(f"Redmine audit log failed: {e}")
def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
"""Returns the resolved ``mandateId`` for the instance."""
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
instance = featureInterface.getFeatureInstance(instanceId)
if not instance:
raise HTTPException(
status_code=404,
detail=routeApiMsg(f"Feature instance '{instanceId}' not found"),
)
mandateId = (
instance.get("mandateId")
if isinstance(instance, dict)
else getattr(instance, "mandateId", None)
)
if not mandateId:
raise HTTPException(
status_code=500,
detail=routeApiMsg("Feature instance has no mandateId"),
)
return str(mandateId)
def _toHttpStatus(e: RedmineApiError) -> int:
if e.status in (400, 401, 403, 404, 409, 422):
return e.status
return 502
def _handleRedmineError(e: RedmineApiError) -> HTTPException:
return HTTPException(status_code=_toHttpStatus(e), detail=f"Redmine: {e}")
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
@router.get("/{instanceId}/config", response_model=RedmineConfigDto)
@limiter.limit("60/minute")
async def getConfig(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
) -> RedmineConfigDto:
mandateId = _validateInstanceAccess(instanceId, context)
iface = interfaceDb.getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
return iface.getConfigDto(instanceId)
@router.put("/{instanceId}/config", response_model=RedmineConfigDto)
@limiter.limit("20/minute")
async def updateConfig(
request: Request,
instanceId: str,
body: RedmineConfigUpdateRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> RedmineConfigDto:
mandateId = _validateInstanceAccess(instanceId, context)
iface = interfaceDb.getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
dto = iface.upsertConfig(instanceId, body)
_audit(
context,
"redmine.config.updated",
"RedmineInstanceConfig",
instanceId,
details=f"baseUrl={dto.baseUrl} projectId={dto.projectId} hasApiKey={dto.hasApiKey}",
)
return dto
@router.delete("/{instanceId}/config")
@limiter.limit("20/minute")
async def deleteConfig(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
mandateId = _validateInstanceAccess(instanceId, context)
iface = interfaceDb.getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
deleted = iface.deleteConfig(instanceId)
_audit(context, "redmine.config.deleted", "RedmineInstanceConfig", instanceId, success=deleted)
return {"deleted": deleted}
@router.post("/{instanceId}/config/test")
@limiter.limit("20/minute")
async def testConfig(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
mandateId = _validateInstanceAccess(instanceId, context)
result = await serviceRedmine.testConnection(context.user, mandateId, instanceId)
_audit(
context,
"redmine.config.test",
"RedmineInstanceConfig",
instanceId,
success=bool(result.get("ok")),
errorMessage=str(result.get("message")) if not result.get("ok") else None,
)
return result
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
@router.get("/{instanceId}/schema", response_model=RedmineFieldSchemaDto)
@limiter.limit("60/minute")
async def getSchema(
request: Request,
instanceId: str,
forceRefresh: bool = Query(False),
context: RequestContext = Depends(getRequestContext),
) -> RedmineFieldSchemaDto:
mandateId = _validateInstanceAccess(instanceId, context)
try:
return await serviceRedmine.getProjectMeta(
context.user, mandateId, instanceId, forceRefresh=forceRefresh
)
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
raise _handleRedmineError(e)
# ---------------------------------------------------------------------------
# Sync (mirror)
# ---------------------------------------------------------------------------
@router.post("/{instanceId}/sync", response_model=RedmineSyncResultDto)
@limiter.limit("6/minute")
async def runSync(
request: Request,
instanceId: str,
force: bool = Query(default=False, description="True -> ignore lastSyncAt and pull every issue."),
context: RequestContext = Depends(getRequestContext),
) -> RedmineSyncResultDto:
mandateId = _validateInstanceAccess(instanceId, context)
try:
result = await serviceRedmineSync.runSync(
context.user, mandateId, instanceId, force=force
)
_audit(
context,
"redmine.sync.completed",
"RedmineInstanceConfig",
instanceId,
details=f"full={result.full} tickets={result.ticketsUpserted} relations={result.relationsUpserted} {result.durationMs}ms",
)
return result
except RedmineApiError as e:
_audit(
context,
"redmine.sync.completed",
"RedmineInstanceConfig",
instanceId,
success=False,
errorMessage=str(e),
)
raise _handleRedmineError(e)
except Exception as e:
_audit(
context,
"redmine.sync.completed",
"RedmineInstanceConfig",
instanceId,
success=False,
errorMessage=str(e),
)
raise HTTPException(status_code=500, detail=f"Sync failed: {e}")
@router.get("/{instanceId}/sync/status", response_model=RedmineSyncStatusDto)
@limiter.limit("60/minute")
async def getSyncStatus(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
) -> RedmineSyncStatusDto:
mandateId = _validateInstanceAccess(instanceId, context)
return serviceRedmineSync.getSyncStatus(context.user, mandateId, instanceId)
# ---------------------------------------------------------------------------
# Tickets
# ---------------------------------------------------------------------------
@router.get("/{instanceId}/tickets", response_model=List[RedmineTicketDto])
@limiter.limit("60/minute")
async def listTickets(
request: Request,
instanceId: str,
trackerIds: Optional[List[int]] = Query(default=None),
status: str = Query(default="*"),
dateFrom: Optional[str] = Query(default=None, description="ISO date (YYYY-MM-DD) -- updated_on >= dateFrom"),
dateTo: Optional[str] = Query(default=None, description="ISO date (YYYY-MM-DD) -- updated_on <= dateTo"),
assignedToId: Optional[int] = Query(default=None),
context: RequestContext = Depends(getRequestContext),
) -> List[RedmineTicketDto]:
"""Reads from the local mirror. Trigger a sync via ``POST /sync`` first."""
mandateId = _validateInstanceAccess(instanceId, context)
return serviceRedmine.listTickets(
context.user,
mandateId,
instanceId,
trackerIds=trackerIds,
statusFilter=status,
updatedOnFrom=dateFrom,
updatedOnTo=dateTo,
assignedToId=assignedToId,
)
@router.get("/{instanceId}/tickets/{issueId}", response_model=RedmineTicketDto)
@limiter.limit("120/minute")
async def getTicket(
request: Request,
instanceId: str,
issueId: int,
context: RequestContext = Depends(getRequestContext),
) -> RedmineTicketDto:
mandateId = _validateInstanceAccess(instanceId, context)
ticket = serviceRedmine.getTicket(context.user, mandateId, instanceId, issueId)
if ticket is None:
raise HTTPException(status_code=404, detail=f"Ticket {issueId} not in mirror; run a sync first.")
return ticket
@router.post("/{instanceId}/tickets", response_model=RedmineTicketDto)
@limiter.limit("30/minute")
async def createTicket(
request: Request,
instanceId: str,
body: RedmineTicketCreateRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> RedmineTicketDto:
mandateId = _validateInstanceAccess(instanceId, context)
try:
ticket = await serviceRedmine.createTicket(context.user, mandateId, instanceId, body)
_audit(context, "redmine.ticket.created", "RedmineTicket", str(ticket.id), details=f"trackerId={body.trackerId}")
return ticket
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
_audit(context, "redmine.ticket.created", "RedmineTicket", "?", success=False, errorMessage=str(e))
raise _handleRedmineError(e)
@router.put("/{instanceId}/tickets/{issueId}", response_model=RedmineTicketDto)
@limiter.limit("60/minute")
async def updateTicket(
request: Request,
instanceId: str,
issueId: int,
body: RedmineTicketUpdateRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> RedmineTicketDto:
mandateId = _validateInstanceAccess(instanceId, context)
try:
ticket = await serviceRedmine.updateTicket(context.user, mandateId, instanceId, issueId, body)
_audit(context, "redmine.ticket.updated", "RedmineTicket", str(issueId))
return ticket
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
_audit(context, "redmine.ticket.updated", "RedmineTicket", str(issueId), success=False, errorMessage=str(e))
raise _handleRedmineError(e)
@router.delete("/{instanceId}/tickets/{issueId}")
@limiter.limit("30/minute")
async def deleteTicket(
request: Request,
instanceId: str,
issueId: int,
fallbackStatusId: Optional[int] = Query(default=None, description="If Redmine forbids DELETE, set this status instead"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
mandateId = _validateInstanceAccess(instanceId, context)
try:
result = await serviceRedmine.deleteTicket(
context.user, mandateId, instanceId, issueId, fallbackStatusId=fallbackStatusId
)
_audit(
context,
"redmine.ticket.deleted",
"RedmineTicket",
str(issueId),
success=bool(result.get("deleted") or result.get("archived")),
details=f"deleted={result.get('deleted')} archived={result.get('archived')}",
)
return result
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
_audit(context, "redmine.ticket.deleted", "RedmineTicket", str(issueId), success=False, errorMessage=str(e))
raise _handleRedmineError(e)
# ---------------------------------------------------------------------------
# Relations
# ---------------------------------------------------------------------------
@router.post("/{instanceId}/tickets/{issueId}/relations")
@limiter.limit("30/minute")
async def addRelation(
request: Request,
instanceId: str,
issueId: int,
body: RedmineRelationCreateRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
mandateId = _validateInstanceAccess(instanceId, context)
try:
rel = await serviceRedmine.addRelation(context.user, mandateId, instanceId, issueId, body)
_audit(
context,
"redmine.relation.created",
"RedmineRelation",
str(rel.get("id")),
details=f"{issueId} -[{body.relationType}]-> {body.issueToId}",
)
return {"relation": rel}
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
_audit(
context,
"redmine.relation.created",
"RedmineRelation",
f"{issueId}->{body.issueToId}",
success=False,
errorMessage=str(e),
)
raise _handleRedmineError(e)
@router.delete("/{instanceId}/relations/{relationId}")
@limiter.limit("30/minute")
async def deleteRelation(
request: Request,
instanceId: str,
relationId: int,
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
mandateId = _validateInstanceAccess(instanceId, context)
try:
ok = await serviceRedmine.deleteRelation(context.user, mandateId, instanceId, relationId)
_audit(context, "redmine.relation.deleted", "RedmineRelation", str(relationId), success=ok)
return {"deleted": ok}
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
_audit(
context,
"redmine.relation.deleted",
"RedmineRelation",
str(relationId),
success=False,
errorMessage=str(e),
)
raise _handleRedmineError(e)
# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------
@router.get("/{instanceId}/stats", response_model=RedmineStatsDto)
@limiter.limit("60/minute")
async def getStats(
request: Request,
instanceId: str,
dateFrom: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"),
dateTo: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"),
bucket: str = Query(default="week", regex="^(day|week|month)$"),
trackerIds: Optional[List[int]] = Query(default=None),
context: RequestContext = Depends(getRequestContext),
) -> RedmineStatsDto:
mandateId = _validateInstanceAccess(instanceId, context)
try:
return await serviceRedmineStats.getStats(
context.user,
mandateId,
instanceId,
dateFrom=dateFrom,
dateTo=dateTo,
bucket=bucket,
trackerIds=trackerIds,
)
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
except RedmineApiError as e:
raise _handleRedmineError(e)

View file

@ -0,0 +1,609 @@
# 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()
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],
"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 []],
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"),
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 {}
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"),
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]}

View file

@ -0,0 +1,403 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Redmine statistics aggregator.
Returns raw buckets in :class:`RedmineStatsDto`. The frontend
(``RedmineStatsPage.tsx``) maps these onto ``ReportSection`` for
``FormGeneratorReport``. Decision 2026-04-21.
Sections produced:
- KPIs: total / open / closed / closedInPeriod / createdInPeriod / orphans
- statusByTracker (stacked bar)
- throughput (line chart, created vs closed per bucket)
- topAssignees (top-10 horizontal bar)
- relationDistribution (pie)
- backlogAging (open issues by age since last update)
The whole result is cached in :mod:`serviceRedmineStatsCache` keyed by
``(instanceId, dateFrom, dateTo, bucket, trackerIds)`` with a 90 s TTL.
"""
from __future__ import annotations
import datetime as _dt
import logging
from collections import Counter, defaultdict
from typing import Any, Dict, Iterable, List, Optional, Tuple
from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
RedmineAgingBucket,
RedmineAssigneeBucket,
RedmineFieldSchemaDto,
RedmineRelationDistributionEntry,
RedmineStatsDto,
RedmineStatsKpis,
RedmineStatusByTrackerEntry,
RedmineThroughputBucket,
RedmineTicketDto,
)
from modules.features.redmine.serviceRedmineStatsCache import _getStatsCache
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public entry
# ---------------------------------------------------------------------------
async def getStats(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
*,
dateFrom: Optional[str] = None,
dateTo: Optional[str] = None,
bucket: str = "week",
trackerIds: Optional[List[int]] = None,
) -> RedmineStatsDto:
"""Compute (or fetch from cache) the full statistics payload."""
bucket_norm = (bucket or "week").lower()
if bucket_norm not in {"day", "week", "month"}:
bucket_norm = "week"
tracker_ids_norm: List[int] = sorted({int(t) for t in trackerIds or []})
cache = _getStatsCache()
cache_key = cache.buildKey(featureInstanceId, dateFrom, dateTo, bucket_norm, tracker_ids_norm)
cached = cache.get(cache_key)
if cached is not None:
return cached
# Lazy import: keeps the pure aggregation helpers below importable
# without dragging in aiohttp / DB connector at module load.
from modules.features.redmine.serviceRedmine import (
getProjectMeta,
listTickets,
)
schema = await getProjectMeta(currentUser, mandateId, featureInstanceId)
root_tracker_id = schema.rootTrackerId
tickets = listTickets(
currentUser,
mandateId,
featureInstanceId,
trackerIds=tracker_ids_norm or None,
statusFilter="*",
)
stats = _aggregate(
tickets,
schema=schema,
rootTrackerId=root_tracker_id,
dateFrom=dateFrom,
dateTo=dateTo,
bucket=bucket_norm,
trackerIdsFilter=tracker_ids_norm,
instanceId=featureInstanceId,
)
cache.set(cache_key, stats)
return stats
# ---------------------------------------------------------------------------
# Pure aggregation (testable without I/O)
# ---------------------------------------------------------------------------
def _aggregate(
tickets: List[RedmineTicketDto],
*,
schema: Optional[RedmineFieldSchemaDto],
rootTrackerId: Optional[int],
dateFrom: Optional[str],
dateTo: Optional[str],
bucket: str,
trackerIdsFilter: List[int],
instanceId: str,
) -> RedmineStatsDto:
period_from = _parseIsoDate(dateFrom)
period_to = _parseIsoDate(dateTo)
kpis = _kpis(tickets, rootTrackerId, period_from, period_to)
status_by_tracker = _statusByTracker(tickets, schema)
throughput = _throughput(tickets, period_from, period_to, bucket)
top_assignees = _topAssignees(tickets, limit=10)
relation_distribution = _relationDistribution(tickets)
backlog_aging = _backlogAging(tickets, now=_utcNow())
return RedmineStatsDto(
instanceId=instanceId,
dateFrom=dateFrom,
dateTo=dateTo,
bucket=bucket,
trackerIds=trackerIdsFilter,
kpis=kpis,
statusByTracker=status_by_tracker,
throughput=throughput,
topAssignees=top_assignees,
relationDistribution=relation_distribution,
backlogAging=backlog_aging,
)
# ---------------------------------------------------------------------------
# Section builders
# ---------------------------------------------------------------------------
def _kpis(
tickets: List[RedmineTicketDto],
rootTrackerId: Optional[int],
periodFrom: Optional[_dt.datetime],
periodTo: Optional[_dt.datetime],
) -> RedmineStatsKpis:
total = len(tickets)
open_count = sum(1 for t in tickets if not t.isClosed)
closed_count = sum(1 for t in tickets if t.isClosed)
closed_in_period = 0
created_in_period = 0
for t in tickets:
created = _parseIsoDate(t.createdOn)
updated = _parseIsoDate(t.updatedOn)
if created and _inPeriod(created, periodFrom, periodTo):
created_in_period += 1
if t.isClosed and updated and _inPeriod(updated, periodFrom, periodTo):
closed_in_period += 1
orphans = _countOrphans(tickets, rootTrackerId)
return RedmineStatsKpis(
total=total,
open=open_count,
closed=closed_count,
closedInPeriod=closed_in_period,
createdInPeriod=created_in_period,
orphans=orphans,
)
def _countOrphans(
tickets: List[RedmineTicketDto], rootTrackerId: Optional[int]
) -> int:
"""A ticket is an orphan if it is not a root user-story AND not
reachable (via parent or any relation, in either direction) to any
root user-story within the same loaded set."""
if not tickets:
return 0
by_id: Dict[int, RedmineTicketDto] = {t.id: t for t in tickets}
roots: set[int] = {
t.id for t in tickets if rootTrackerId and t.trackerId == rootTrackerId
}
if not roots:
return sum(1 for t in tickets if not (rootTrackerId and t.trackerId == rootTrackerId))
adjacency: Dict[int, set[int]] = defaultdict(set)
for t in tickets:
if t.parentId is not None and t.parentId in by_id:
adjacency[t.id].add(t.parentId)
adjacency[t.parentId].add(t.id)
for r in t.relations:
for a, b in ((r.issueId, r.issueToId), (r.issueToId, r.issueId)):
if a in by_id and b in by_id and a != b:
adjacency[a].add(b)
reached: set[int] = set(roots)
frontier: List[int] = list(roots)
while frontier:
nxt: List[int] = []
for tid in frontier:
for neighbour in adjacency.get(tid, ()): # type: ignore[arg-type]
if neighbour not in reached:
reached.add(neighbour)
nxt.append(neighbour)
frontier = nxt
return sum(1 for t in tickets if t.id not in reached)
def _statusByTracker(
tickets: List[RedmineTicketDto], schema: Optional[RedmineFieldSchemaDto]
) -> List[RedmineStatusByTrackerEntry]:
by_tracker: Dict[Tuple[Optional[int], str], Counter] = defaultdict(Counter)
for t in tickets:
key = (t.trackerId, t.trackerName or "(unbekannt)")
by_tracker[key][t.statusName or "(unbekannt)"] += 1
out: List[RedmineStatusByTrackerEntry] = []
for (tid, tname), ctr in by_tracker.items():
out.append(
RedmineStatusByTrackerEntry(
trackerId=tid,
trackerName=tname,
countsByStatus=dict(ctr),
total=sum(ctr.values()),
)
)
out.sort(key=lambda e: e.total, reverse=True)
return out
def _throughput(
tickets: List[RedmineTicketDto],
periodFrom: Optional[_dt.datetime],
periodTo: Optional[_dt.datetime],
bucket: str,
) -> List[RedmineThroughputBucket]:
if not tickets:
return []
if periodFrom is None or periodTo is None:
all_dates: List[_dt.datetime] = []
for t in tickets:
for s in (t.createdOn, t.updatedOn):
d = _parseIsoDate(s)
if d:
all_dates.append(d)
if not all_dates:
return []
periodFrom = periodFrom or min(all_dates)
periodTo = periodTo or max(all_dates)
created_counter: Counter = Counter()
closed_counter: Counter = Counter()
for t in tickets:
c = _parseIsoDate(t.createdOn)
if c and _inPeriod(c, periodFrom, periodTo):
created_counter[_bucketKey(c, bucket)] += 1
if t.isClosed:
u = _parseIsoDate(t.updatedOn)
if u and _inPeriod(u, periodFrom, periodTo):
closed_counter[_bucketKey(u, bucket)] += 1
keys: List[str] = sorted(set(created_counter) | set(closed_counter))
if not keys:
return []
out: List[RedmineThroughputBucket] = []
for key in keys:
out.append(
RedmineThroughputBucket(
bucketKey=key,
label=_bucketLabel(key, bucket),
created=int(created_counter.get(key, 0)),
closed=int(closed_counter.get(key, 0)),
)
)
return out
def _topAssignees(
tickets: List[RedmineTicketDto], *, limit: int = 10
) -> List[RedmineAssigneeBucket]:
by_assignee: Dict[Tuple[Optional[int], str], int] = defaultdict(int)
for t in tickets:
if t.isClosed:
continue
key = (t.assignedToId, t.assignedToName or "(nicht zugewiesen)")
by_assignee[key] += 1
sorted_items = sorted(by_assignee.items(), key=lambda kv: kv[1], reverse=True)[:limit]
return [
RedmineAssigneeBucket(assignedToId=k[0], name=k[1], open=v)
for k, v in sorted_items
]
def _relationDistribution(
tickets: List[RedmineTicketDto],
) -> List[RedmineRelationDistributionEntry]:
seen: set[int] = set()
counter: Counter = Counter()
for t in tickets:
for r in t.relations:
if r.id in seen:
continue
seen.add(r.id)
counter[r.relationType or "relates"] += 1
return [
RedmineRelationDistributionEntry(relationType=k, count=v)
for k, v in sorted(counter.items(), key=lambda kv: kv[1], reverse=True)
]
def _backlogAging(
tickets: List[RedmineTicketDto], *, now: Optional[_dt.datetime] = None
) -> List[RedmineAgingBucket]:
if now is None:
now = _utcNow()
buckets = [
RedmineAgingBucket(bucketKey="lt7", label="< 7 Tage", minDays=0, maxDays=7),
RedmineAgingBucket(bucketKey="7-30", label="7-30 Tage", minDays=7, maxDays=30),
RedmineAgingBucket(bucketKey="30-90", label="30-90 Tage", minDays=30, maxDays=90),
RedmineAgingBucket(bucketKey="90-180", label="90-180 Tage", minDays=90, maxDays=180),
RedmineAgingBucket(bucketKey="gt180", label="> 180 Tage", minDays=180, maxDays=None),
]
for t in tickets:
if t.isClosed:
continue
ref = _parseIsoDate(t.updatedOn) or _parseIsoDate(t.createdOn)
if ref is None:
continue
age_days = max(0, (now - ref).days)
for b in buckets:
if (b.maxDays is None and age_days >= b.minDays) or (
b.maxDays is not None and b.minDays <= age_days < b.maxDays
):
b.count += 1
break
return buckets
# ---------------------------------------------------------------------------
# Date helpers (no external deps)
# ---------------------------------------------------------------------------
def _utcNow() -> _dt.datetime:
"""Naive UTC ``datetime`` -- the rest of the helpers compare naive
objects, so we strip tz info on purpose."""
return _dt.datetime.now(_dt.timezone.utc).replace(tzinfo=None)
def _parseIsoDate(value: Optional[str]) -> Optional[_dt.datetime]:
if not value:
return None
try:
s = value.replace("Z", "+00:00") if isinstance(value, str) else value
if isinstance(s, str) and "T" not in s and len(s) == 10:
return _dt.datetime.strptime(s, "%Y-%m-%d")
return _dt.datetime.fromisoformat(s).replace(tzinfo=None)
except Exception:
try:
return _dt.datetime.strptime(str(value)[:10], "%Y-%m-%d")
except Exception:
return None
def _inPeriod(
when: _dt.datetime,
fromDate: Optional[_dt.datetime],
toDate: Optional[_dt.datetime],
) -> bool:
if fromDate and when < fromDate:
return False
if toDate and when > toDate + _dt.timedelta(days=1):
return False
return True
def _bucketKey(when: _dt.datetime, bucket: str) -> str:
if bucket == "day":
return when.strftime("%Y-%m-%d")
if bucket == "month":
return when.strftime("%Y-%m")
iso_year, iso_week, _ = when.isocalendar()
return f"{iso_year}-W{iso_week:02d}"
def _bucketLabel(key: str, bucket: str) -> str:
if bucket == "day":
return key
if bucket == "month":
try:
d = _dt.datetime.strptime(key, "%Y-%m")
return d.strftime("%b %Y")
except Exception:
return key
return key

View file

@ -0,0 +1,105 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""TTL-based in-memory cache for ``serviceRedmineStats`` results.
The cache key is ``(featureInstanceId, dateFrom, dateTo, bucket, sorted(trackerIds))``.
Any write through ``serviceRedmine`` (createIssue, updateIssue, deleteIssue,
addRelation, deleteRelation) MUST call :func:`invalidateInstance` to drop
all cached entries for that feature instance.
Default TTL: 90 seconds. Override at construction or via ``setTtl``.
"""
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Any, Dict, Iterable, Optional, Tuple
_DEFAULT_TTL_SECONDS = 90.0
@dataclass
class _CacheEntry:
value: Any
expiresAt: float
CacheKey = Tuple[str, Optional[str], Optional[str], str, Tuple[int, ...]]
class RedmineStatsCache:
"""Thread-safe TTL cache."""
def __init__(self, ttlSeconds: float = _DEFAULT_TTL_SECONDS) -> None:
self._ttlSeconds = float(ttlSeconds)
self._store: Dict[CacheKey, _CacheEntry] = {}
self._lock = threading.Lock()
def setTtl(self, ttlSeconds: float) -> None:
self._ttlSeconds = float(ttlSeconds)
@staticmethod
def buildKey(
featureInstanceId: str,
dateFrom: Optional[str],
dateTo: Optional[str],
bucket: str,
trackerIds: Iterable[int],
) -> CacheKey:
return (
str(featureInstanceId),
dateFrom or None,
dateTo or None,
(bucket or "week").lower(),
tuple(sorted(int(t) for t in trackerIds or [])),
)
def get(self, key: CacheKey) -> Optional[Any]:
now = time.monotonic()
with self._lock:
entry = self._store.get(key)
if not entry:
return None
if entry.expiresAt < now:
self._store.pop(key, None)
return None
return entry.value
def set(self, key: CacheKey, value: Any, *, ttlSeconds: Optional[float] = None) -> None:
ttl = float(ttlSeconds) if ttlSeconds is not None else self._ttlSeconds
with self._lock:
self._store[key] = _CacheEntry(value=value, expiresAt=time.monotonic() + ttl)
def invalidateInstance(self, featureInstanceId: str) -> int:
"""Drop every entry whose key starts with ``featureInstanceId``.
Returns the number of entries dropped.
"""
target = str(featureInstanceId)
with self._lock:
to_drop = [k for k in self._store.keys() if k[0] == target]
for k in to_drop:
self._store.pop(k, None)
return len(to_drop)
def clear(self) -> None:
with self._lock:
self._store.clear()
def size(self) -> int:
with self._lock:
return len(self._store)
_globalCache: Optional[RedmineStatsCache] = None
def _getStatsCache() -> RedmineStatsCache:
"""Process-wide singleton."""
global _globalCache
if _globalCache is None:
_globalCache = RedmineStatsCache()
return _globalCache

View file

@ -0,0 +1,315 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Incremental Redmine -> ``poweron_redmine`` mirror sync.
Strategy:
- **Full sync** when ``RedmineInstanceConfig.lastSyncAt`` is None or
``force=True`` is requested. Pulls every issue with ``status_id=*``
(open + closed) for the configured project, paginated.
- **Incremental sync** otherwise. Pulls only issues whose ``updated_on``
is greater than ``lastSyncAt - overlapSeconds`` (default 1h overlap to
catch clock skew and missed updates).
- Each issue is upserted into ``RedmineTicketMirror`` (looked up by
``(featureInstanceId, redmineId)``).
- The full set of relations attached to each issue replaces any existing
relation rows for that issue in ``RedmineRelationMirror``.
Concurrency: a per-instance ``asyncio.Lock`` prevents two concurrent
syncs for the same feature instance.
After every successful sync the in-memory stats cache is invalidated for
the instance.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional
from modules.connectors.connectorTicketsRedmine import RedmineApiError
from modules.datamodels.datamodelUam import User
from modules.features.redmine.datamodelRedmine import (
RedmineInstanceConfig,
RedmineRelationMirror,
RedmineSyncResultDto,
RedmineSyncStatusDto,
RedmineTicketMirror,
)
from modules.features.redmine.interfaceFeatureRedmine import getInterface
from modules.features.redmine.serviceRedmineStatsCache import _getStatsCache
logger = logging.getLogger(__name__)
_INCREMENTAL_OVERLAP_SECONDS = 60 * 60 # 1h overlap on incremental syncs
_DEFAULT_PAGE_SIZE = 100
_MAX_PAGES_SAFETY = 5000 # 500k tickets safety cap
_locks: Dict[str, asyncio.Lock] = {}
def _lockFor(featureInstanceId: str) -> asyncio.Lock:
if featureInstanceId not in _locks:
_locks[featureInstanceId] = asyncio.Lock()
return _locks[featureInstanceId]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def runSync(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
*,
force: bool = False,
pageSize: int = _DEFAULT_PAGE_SIZE,
) -> RedmineSyncResultDto:
"""Run a (full or incremental) sync for the given feature instance."""
iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
connector = iface.resolveConnector(featureInstanceId)
cfg = iface.getConfig(featureInstanceId)
if not connector or not cfg:
raise RuntimeError(
f"Redmine instance {featureInstanceId} is not configured or inactive"
)
async with _lockFor(featureInstanceId):
started = time.monotonic()
full = force or cfg.lastSyncAt is None
updated_from_iso: Optional[str] = None
if not full and cfg.lastSyncAt is not None:
cursor_epoch = max(0.0, cfg.lastSyncAt - _INCREMENTAL_OVERLAP_SECONDS)
updated_from_iso = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(cursor_epoch)
)
try:
issues = await connector.listAllIssues(
statusId="*",
updatedOnFrom=updated_from_iso,
pageSize=pageSize,
maxPages=_MAX_PAGES_SAFETY,
include=["relations"],
)
except RedmineApiError as e:
iface.recordSyncFailure(featureInstanceId, str(e))
raise
tickets_upserted = 0
relations_upserted = 0
now_epoch = time.time()
for issue in issues:
tickets_upserted += _upsertTicket(iface, featureInstanceId, mandateId, issue, now_epoch)
relations_upserted += _replaceRelations(iface, featureInstanceId, issue, now_epoch)
duration_ms = int((time.monotonic() - started) * 1000)
iface.recordSyncSuccess(
featureInstanceId,
full=full,
ticketsUpserted=tickets_upserted,
durationMs=duration_ms,
lastSyncAt=now_epoch,
)
_getStatsCache().invalidateInstance(featureInstanceId)
return RedmineSyncResultDto(
instanceId=featureInstanceId,
full=full,
ticketsUpserted=tickets_upserted,
relationsUpserted=relations_upserted,
durationMs=duration_ms,
lastSyncAt=now_epoch,
)
def getSyncStatus(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
) -> RedmineSyncStatusDto:
iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
cfg = iface.getConfig(featureInstanceId)
ticket_count = iface.countMirroredTickets(featureInstanceId)
relation_count = iface.countMirroredRelations(featureInstanceId)
return RedmineSyncStatusDto(
instanceId=featureInstanceId,
lastSyncAt=cfg.lastSyncAt if cfg else None,
lastFullSyncAt=cfg.lastFullSyncAt if cfg else None,
lastSyncDurationMs=cfg.lastSyncDurationMs if cfg else None,
lastSyncTicketCount=cfg.lastSyncTicketCount if cfg else None,
lastSyncErrorAt=cfg.lastSyncErrorAt if cfg else None,
lastSyncErrorMessage=cfg.lastSyncErrorMessage if cfg else None,
mirroredTicketCount=ticket_count,
mirroredRelationCount=relation_count,
)
async def upsertSingleTicket(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
issueId: int,
) -> int:
"""Re-fetch one issue from Redmine and upsert it into the mirror.
Used by the write paths in ``serviceRedmine`` so the mirror stays
consistent after every create / update without a full sync.
Returns the number of relation rows replaced.
"""
iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
connector = iface.resolveConnector(featureInstanceId)
if not connector:
raise RuntimeError("Redmine instance not configured")
issue = await connector.getIssue(int(issueId), includeRelations=True)
now_epoch = time.time()
_upsertTicket(iface, featureInstanceId, mandateId, issue, now_epoch)
relations_upserted = _replaceRelations(iface, featureInstanceId, issue, now_epoch)
_getStatsCache().invalidateInstance(featureInstanceId)
return relations_upserted
def deleteMirroredTicket(
currentUser: User,
mandateId: Optional[str],
featureInstanceId: str,
issueId: int,
) -> bool:
"""Drop a ticket and its relations from the mirror after a successful Redmine DELETE."""
iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
deleted = iface.deleteMirroredTicket(featureInstanceId, int(issueId))
iface.deleteMirroredRelationsForIssue(featureInstanceId, int(issueId))
_getStatsCache().invalidateInstance(featureInstanceId)
return deleted
# ---------------------------------------------------------------------------
# Per-issue upsert helpers (sync, run inside the per-instance lock)
# ---------------------------------------------------------------------------
def _upsertTicket(
iface,
featureInstanceId: str,
mandateId: Optional[str],
issue: Dict[str, Any],
nowEpoch: float,
) -> int:
redmine_id = issue.get("id")
if redmine_id is None:
return 0
statuses_lookup = (iface.getConfig(featureInstanceId).schemaCache or {}).get("statuses") or []
is_closed = _statusIsClosed(issue.get("status") or {}, statuses_lookup)
record = _ticketRecordFromIssue(issue, featureInstanceId, mandateId, is_closed, nowEpoch)
iface.upsertMirroredTicket(featureInstanceId, int(redmine_id), record)
return 1
def _replaceRelations(
iface,
featureInstanceId: str,
issue: Dict[str, Any],
nowEpoch: float,
) -> int:
issue_id = issue.get("id")
relations = issue.get("relations") or []
if issue_id is None:
return 0
iface.deleteMirroredRelationsForIssue(featureInstanceId, int(issue_id))
inserted = 0
for r in relations:
rid = r.get("id")
if rid is None:
continue
iface.insertMirroredRelation(
featureInstanceId,
{
"featureInstanceId": featureInstanceId,
"redmineRelationId": int(rid),
"issueId": int(r.get("issue_id") or 0),
"issueToId": int(r.get("issue_to_id") or 0),
"relationType": str(r.get("relation_type") or "relates"),
"delay": r.get("delay"),
"syncedAt": nowEpoch,
},
)
inserted += 1
return inserted
# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------
def _statusIsClosed(status: Dict[str, Any], statusesLookup: List[Dict[str, Any]]) -> bool:
"""Best-effort: prefer the schemaCache; fall back to inspecting the
raw issue (Redmine sets ``is_closed`` on the status object only when
explicitly requested)."""
sid = status.get("id")
if sid is None:
return False
for s in statusesLookup:
if s.get("id") == sid:
return bool(s.get("isClosed"))
return bool(status.get("is_closed"))
def _parseRedmineDateToEpoch(value: Optional[str]) -> Optional[float]:
if not value:
return None
try:
from datetime import datetime
s = value.replace("Z", "+00:00")
return datetime.fromisoformat(s).timestamp()
except Exception:
return None
def _ticketRecordFromIssue(
issue: Dict[str, Any],
featureInstanceId: str,
mandateId: Optional[str],
isClosed: bool,
nowEpoch: float,
) -> Dict[str, Any]:
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 {}
parent = issue.get("parent") or {}
fixed_version = issue.get("fixed_version") or {}
created_on = issue.get("created_on")
updated_on = issue.get("updated_on")
return {
"featureInstanceId": featureInstanceId,
"mandateId": mandateId,
"redmineId": 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.get("id"),
"statusName": status.get("name"),
"isClosed": bool(isClosed),
"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": parent.get("id"),
"fixedVersionId": fixed_version.get("id"),
"fixedVersionName": fixed_version.get("name"),
"createdOn": created_on,
"updatedOn": updated_on,
"createdOnTs": _parseRedmineDateToEpoch(created_on),
"updatedOnTs": _parseRedmineDateToEpoch(updated_on),
"customFields": list(issue.get("custom_fields") or []),
"raw": issue,
"syncedAt": nowEpoch,
}

View file

@ -123,6 +123,9 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]:
elif featureCode == "workspace": elif featureCode == "workspace":
from modules.features.workspace.mainWorkspace import UI_OBJECTS from modules.features.workspace.mainWorkspace import UI_OBJECTS
return UI_OBJECTS return UI_OBJECTS
elif featureCode == "redmine":
from modules.features.redmine.mainRedmine import UI_OBJECTS
return UI_OBJECTS
else: else:
logger.debug(f"Skipping removed feature code: {featureCode}") logger.debug(f"Skipping removed feature code: {featureCode}")
return [] return []

View file

@ -7,6 +7,7 @@ import logging
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition
from modules.shared.timeUtils import getRequestNow, getRequestTimezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -322,6 +323,27 @@ def _buildSummaryPrompt(
return prompt return prompt
def _buildTemporalContext() -> str:
"""Inject current date/time (in the user's browser timezone) into the system prompt.
LLMs have no innate access to "now" and otherwise hallucinate from their
training cutoff. The browser timezone is propagated via the
``X-User-Timezone`` request header (see ``api.ts`` axios interceptor and the
``_requestContextMiddleware`` in ``app.py``). When called outside of an HTTP
context, ``getRequestNow()`` falls back to UTC.
"""
tz = getRequestTimezone()
now = getRequestNow()
return (
"## Current Date & Time\n"
f"- Today: {now.strftime('%Y-%m-%d (%A)')}\n"
f"- Now: {now.strftime('%H:%M')} ({tz})\n"
"- Use this for any relative time references such as \"today\", "
"\"yesterday\", \"last week\", \"this month\", \"Q1\", etc.\n"
"- Do NOT rely on your training cutoff for the current date.\n\n"
)
def buildSystemPrompt( def buildSystemPrompt(
tools: List[ToolDefinition], tools: List[ToolDefinition],
toolsFormatted: str = None, toolsFormatted: str = None,
@ -342,8 +364,9 @@ def buildSystemPrompt(
) )
prompt = ( prompt = (
f"{langInstruction}" _buildTemporalContext()
"You are an AI agent with access to tools. " + f"{langInstruction}"
+ "You are an AI agent with access to tools. "
"Use the provided tools to accomplish the user's task. " "Use the provided tools to accomplish the user's task. "
"Think step by step. Call tools when you need information or need to perform actions. " "Think step by step. Call tools when you need information or need to perform actions. "
"When you have enough information to answer, respond directly without calling tools.\n\n" "When you have enough information to answer, respond directly without calling tools.\n\n"

View file

@ -22,6 +22,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
from modules.shared.i18nRegistry import resolveText from modules.shared.i18nRegistry import resolveText
from modules.shared.timeUtils import getRequestNow, getRequestTimezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -321,9 +322,21 @@ def _buildSchemaContext(
header += f' (instance: "{instanceLabel}")' header += f' (instance: "{instanceLabel}")'
header += "." header += "."
tz = getRequestTimezone()
now = getRequestNow()
temporalLines = [
"CURRENT DATE & TIME (use this for relative time references in filters):",
f" Today: {now.strftime('%Y-%m-%d (%A)')}",
f" Now: {now.strftime('%H:%M')} ({tz})",
" Resolve phrases like 'today', 'last month', 'Q1', 'this year' against THIS date.",
" Do NOT use your training cutoff for date filters.",
]
parts = [ parts = [
header, header,
"", "",
*temporalLines,
"",
"AVAILABLE TABLES (use EXACTLY these names as tableName parameter):", "AVAILABLE TABLES (use EXACTLY these names as tableName parameter):",
*tableBlocks, *tableBlocks,
"", "",

View file

@ -5,14 +5,85 @@ Timezone utilities for consistent timestamp handling across the gateway.
Ensures all timestamps are properly handled as UTC. Ensures all timestamps are properly handled as UTC.
""" """
from contextvars import ContextVar
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional, Any from typing import Optional, Any
import time import time
import logging import logging
# Configure logger try:
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
except ImportError:
ZoneInfo = None
ZoneInfoNotFoundError = Exception
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Per-request user timezone (set by middleware from X-User-Timezone header)
#
# Mirrors the i18n language ContextVar pattern in modules.shared.i18nRegistry:
# the browser knows its IANA timezone (Intl.DateTimeFormat().resolvedOptions().timeZone),
# the frontend axios interceptor sends it as X-User-Timezone, and the gateway
# middleware writes it into _CURRENT_TIMEZONE for any handler/agent to read.
#
# Storage stays UTC everywhere (getUtcTimestamp / getIsoTimestamp). Only
# user-visible "what is now?" decisions (AI-agent prompts, formatted display
# strings) should consult getRequestTimezone() / getRequestNow().
# ---------------------------------------------------------------------------
_DEFAULT_REQUEST_TZ = "UTC"
_CURRENT_TIMEZONE: ContextVar[str] = ContextVar("user_tz", default=_DEFAULT_REQUEST_TZ)
def _setRequestTimezone(tzName: str) -> None:
"""Set the current request's user timezone (called by gateway middleware).
Validates against zoneinfo; falls back to UTC for unknown/invalid names so
a malicious or stale header cannot break downstream code.
"""
if not tzName or not isinstance(tzName, str):
_CURRENT_TIMEZONE.set(_DEFAULT_REQUEST_TZ)
return
if ZoneInfo is None:
_CURRENT_TIMEZONE.set(_DEFAULT_REQUEST_TZ)
return
try:
ZoneInfo(tzName)
except (ZoneInfoNotFoundError, ValueError, OSError) as e:
logger.warning(
"Invalid timezone in X-User-Timezone header: %r (%s); falling back to %s",
tzName, type(e).__name__, _DEFAULT_REQUEST_TZ,
)
_CURRENT_TIMEZONE.set(_DEFAULT_REQUEST_TZ)
return
_CURRENT_TIMEZONE.set(tzName)
def getRequestTimezone() -> str:
"""Return the IANA timezone name for the current request (browser-supplied).
Defaults to ``UTC`` outside of an HTTP request context (e.g. scheduler) or
when the frontend did not send the header.
"""
return _CURRENT_TIMEZONE.get()
def getRequestNow() -> datetime:
"""Return current time as a timezone-aware datetime in the request's user TZ.
Use this for **user-visible** time values (agent prompts, formatted strings).
Use ``getUtcNow()`` / ``getUtcTimestamp()`` for storage and DB writes.
"""
tzName = getRequestTimezone()
if ZoneInfo is None:
return datetime.now(timezone.utc)
try:
return datetime.now(ZoneInfo(tzName))
except (ZoneInfoNotFoundError, ValueError, OSError):
return datetime.now(timezone.utc)
def getUtcNow() -> datetime: def getUtcNow() -> datetime:
""" """
Get current time in UTC with timezone info. Get current time in UTC with timezone info.

View file

@ -8,9 +8,13 @@ log_file = logs/test_logs.log
log_file_level = INFO log_file_level = INFO
log_file_format = %(asctime)s %(levelname)s %(message)s log_file_format = %(asctime)s %(levelname)s %(message)s
log_file_date_format = %Y-%m-%d %H:%M:%S log_file_date_format = %Y-%m-%d %H:%M:%S
# Only run non-expensive tests by default, verbose log, short traceback # Only run non-expensive and non-live tests by default, verbose log, short traceback
# Use 'pytest -m ""' to run ALL tests. # Use 'pytest -m ""' to run ALL tests.
addopts = -v --tb=short -m 'not expensive' addopts = -v --tb=short -m 'not expensive and not live'
markers =
expensive: tests that take longer than a few seconds (e.g. heavy DB or AI)
live: integration tests that hit a live external service (e.g. Redmine SSS sandbox)
# Suppress deprecation warnings from third-party libraries # Suppress deprecation warnings from third-party libraries
filterwarnings = filterwarnings =

0
tests/fixtures/__init__.py vendored Normal file
View file

73
tests/fixtures/loadRedmineSnapshot.py vendored Normal file
View file

@ -0,0 +1,73 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Load ``redmineSnapshot.json`` into in-memory ``RedmineTicketDto`` objects.
Used by all stats / orphan unit tests so they do not require any DB,
HTTP or live Redmine access.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import List, Optional, Tuple
from modules.features.redmine.datamodelRedmine import (
RedmineFieldChoiceDto,
RedmineFieldSchemaDto,
RedmineRelationDto,
RedmineTicketDto,
)
_SNAPSHOT_PATH = Path(__file__).parent / "redmineSnapshot.json"
def loadSnapshot() -> Tuple[RedmineFieldSchemaDto, List[RedmineTicketDto]]:
"""Return ``(schema, tickets)`` parsed from the JSON fixture."""
with _SNAPSHOT_PATH.open("r", encoding="utf-8") as f:
raw = json.load(f)
schema_raw = raw.get("schema") or {}
trackers_raw = schema_raw.get("trackers") or []
schema = RedmineFieldSchemaDto(
projectId=str(schema_raw.get("projectId") or ""),
projectName=str(schema_raw.get("projectName") or ""),
trackers=[RedmineFieldChoiceDto(**t) for t in trackers_raw],
statuses=[RedmineFieldChoiceDto(**s) for s in schema_raw.get("statuses") or []],
priorities=[RedmineFieldChoiceDto(**p) for p in schema_raw.get("priorities") or []],
users=[RedmineFieldChoiceDto(**u) for u in schema_raw.get("users") or []],
customFields=[],
rootTrackerName="Userstory",
rootTrackerId=_findRootTrackerId(trackers_raw),
)
tickets: List[RedmineTicketDto] = []
for issue in raw.get("issues") or []:
tickets.append(
RedmineTicketDto(
id=int(issue["id"]),
subject=str(issue.get("subject") or ""),
trackerId=issue.get("trackerId"),
trackerName=issue.get("trackerName"),
statusId=issue.get("statusId"),
statusName=issue.get("statusName"),
isClosed=bool(issue.get("isClosed")),
priorityId=issue.get("priorityId"),
priorityName=issue.get("priorityName"),
assignedToId=issue.get("assignedToId"),
assignedToName=issue.get("assignedToName"),
parentId=issue.get("parentId"),
createdOn=issue.get("createdOn"),
updatedOn=issue.get("updatedOn"),
relations=[RedmineRelationDto(**r) for r in issue.get("relations") or []],
)
)
return schema, tickets
def _findRootTrackerId(trackers) -> Optional[int]:
for t in trackers:
name = str(t.get("name") or "").strip().lower()
if name in ("userstory", "user story", "user-story"):
return int(t.get("id"))
return None

98
tests/fixtures/redmineSnapshot.json vendored Normal file
View file

@ -0,0 +1,98 @@
{
"_doc": "Synthetic Redmine snapshot for unit tests. Replace with real data via captureRedmineSnapshot.py against the SSS sandbox once the live tests are green.",
"schema": {
"projectId": "demo-project",
"projectName": "Demo Project",
"trackers": [
{"id": 1, "name": "Userstory"},
{"id": 2, "name": "Feature"},
{"id": 3, "name": "Acc.Crit"},
{"id": 4, "name": "Bug"},
{"id": 5, "name": "Task"}
],
"statuses": [
{"id": 1, "name": "Neu", "isClosed": false},
{"id": 2, "name": "In Bearbeitung", "isClosed": false},
{"id": 3, "name": "Review", "isClosed": false},
{"id": 4, "name": "Erledigt", "isClosed": true},
{"id": 5, "name": "Geschlossen", "isClosed": true}
],
"priorities": [
{"id": 1, "name": "Niedrig"},
{"id": 2, "name": "Normal"},
{"id": 3, "name": "Hoch"}
],
"users": [
{"id": 11, "name": "Anna Beispiel"},
{"id": 12, "name": "Bruno Test"}
],
"customFields": []
},
"issues": [
{
"id": 1001,
"subject": "Mandanten-Setup automatisieren",
"trackerId": 1, "trackerName": "Userstory",
"statusId": 2, "statusName": "In Bearbeitung", "isClosed": false,
"priorityId": 2, "priorityName": "Normal",
"assignedToId": 11, "assignedToName": "Anna Beispiel",
"createdOn": "2026-02-01T10:00:00Z", "updatedOn": "2026-04-10T09:00:00Z",
"relations": []
},
{
"id": 2001,
"subject": "Onboarding-Wizard UX",
"trackerId": 2, "trackerName": "Feature",
"statusId": 1, "statusName": "Neu", "isClosed": false,
"priorityId": 2, "priorityName": "Normal",
"assignedToId": 12, "assignedToName": "Bruno Test",
"createdOn": "2026-02-05T12:00:00Z", "updatedOn": "2026-03-01T08:00:00Z",
"relations": [
{"id": 901, "issueId": 2001, "issueToId": 1001, "relationType": "relates", "delay": null}
]
},
{
"id": 3001,
"subject": "AC: Wizard-Schritt 1 muss Mandant erkennen",
"trackerId": 3, "trackerName": "Acc.Crit",
"statusId": 4, "statusName": "Erledigt", "isClosed": true,
"priorityId": 2, "priorityName": "Normal",
"assignedToId": 12, "assignedToName": "Bruno Test",
"parentId": 2001,
"createdOn": "2026-02-10T08:00:00Z", "updatedOn": "2026-04-08T15:30:00Z",
"relations": []
},
{
"id": 4001,
"subject": "Bug: Wizard friert ein bei leerem Mandanten",
"trackerId": 4, "trackerName": "Bug",
"statusId": 5, "statusName": "Geschlossen", "isClosed": true,
"priorityId": 3, "priorityName": "Hoch",
"assignedToId": 11, "assignedToName": "Anna Beispiel",
"createdOn": "2026-03-15T12:00:00Z", "updatedOn": "2026-04-12T11:00:00Z",
"relations": [
{"id": 902, "issueId": 4001, "issueToId": 2001, "relationType": "blocks", "delay": null}
]
},
{
"id": 5001,
"subject": "Orphan: Refactor altes Logging-Modul",
"trackerId": 5, "trackerName": "Task",
"statusId": 1, "statusName": "Neu", "isClosed": false,
"priorityId": 1, "priorityName": "Niedrig",
"assignedToId": null, "assignedToName": null,
"createdOn": "2025-09-15T08:00:00Z", "updatedOn": "2025-10-01T10:00:00Z",
"relations": []
},
{
"id": 5002,
"subject": "Orphan: Doku Schemamigration",
"trackerId": 5, "trackerName": "Task",
"statusId": 2, "statusName": "In Bearbeitung", "isClosed": false,
"priorityId": 2, "priorityName": "Normal",
"assignedToId": 11, "assignedToName": "Anna Beispiel",
"createdOn": "2026-01-10T08:00:00Z", "updatedOn": "2026-02-15T10:00:00Z",
"relations": []
}
]
}

View file

@ -0,0 +1,48 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Pure-Python unit tests for the orphan detection in
``serviceRedmineStats._countOrphans``.
Snapshot in ``tests/fixtures/redmineSnapshot.json`` contains:
- 1x Userstory (1001) -- root
- 1x Feature (2001) related to 1001 -> reachable
- 1x Acc.Crit (3001) parent=2001 -> reachable
- 1x Bug (4001) blocks 2001 -> reachable via relation
- 2x Task (5001, 5002) -> orphan (no link to any User Story)
"""
from __future__ import annotations
from modules.features.redmine.serviceRedmineStats import _countOrphans
from tests.fixtures.loadRedmineSnapshot import loadSnapshot
class TestCountOrphans:
def test_orphansFromSnapshot(self) -> None:
schema, tickets = loadSnapshot()
orphans = _countOrphans(tickets, schema.rootTrackerId)
assert orphans == 2, "Two unrelated Tasks should be orphans"
def test_emptyListReturnsZero(self) -> None:
assert _countOrphans([], 1) == 0
def test_noRootTrackerCountsAllAsOrphan(self) -> None:
schema, tickets = loadSnapshot()
# Pretend there is no User Story tracker at all -- every ticket is orphan.
assert _countOrphans(tickets, None) == len(tickets)
def test_relationDirectionAgnostic(self) -> None:
"""A ticket reachable via the *target* side of a relation must not
be counted as orphan -- _countOrphans walks both directions."""
_, tickets = loadSnapshot()
bug = next(t for t in tickets if t.id == 4001)
# Bug 4001 -[blocks]-> 2001; it is the source. Reverse it: 2001 -[blocks]-> 4001
bug.relations = []
# Attach the relation on 2001 instead.
feature = next(t for t in tickets if t.id == 2001)
from modules.features.redmine.datamodelRedmine import RedmineRelationDto
feature.relations.append(
RedmineRelationDto(id=999, issueId=2001, issueToId=4001, relationType="blocks", delay=None)
)
orphans = _countOrphans(tickets, 1)
assert orphans == 2 # Tasks remain orphans, Bug is still reachable

View file

@ -0,0 +1,122 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Unit tests for the pure aggregation in ``serviceRedmineStats._aggregate``.
These tests run the whole bucket / KPI / aging logic against the static
fixture, with no I/O and no service / connector / DB.
"""
from __future__ import annotations
import datetime as _dt
from modules.features.redmine.serviceRedmineStats import (
_aggregate,
_backlogAging,
_bucketKey,
_kpis,
_relationDistribution,
_statusByTracker,
_throughput,
_topAssignees,
)
from tests.fixtures.loadRedmineSnapshot import loadSnapshot
class TestKpis:
def test_kpisCountTotalsCorrectly(self) -> None:
schema, tickets = loadSnapshot()
kpis = _kpis(tickets, schema.rootTrackerId, periodFrom=None, periodTo=None)
assert kpis.total == 6
assert kpis.open == 4
assert kpis.closed == 2
assert kpis.orphans == 2
def test_periodFiltersClosedAndCreated(self) -> None:
schema, tickets = loadSnapshot()
period_from = _dt.datetime(2026, 4, 1)
period_to = _dt.datetime(2026, 4, 30)
kpis = _kpis(tickets, schema.rootTrackerId, period_from, period_to)
assert kpis.closedInPeriod == 2 # 3001 + 4001 closed in April
assert kpis.createdInPeriod == 0 # nothing was created in April
class TestStatusByTracker:
def test_buildsOneEntryPerTracker(self) -> None:
schema, tickets = loadSnapshot()
rows = _statusByTracker(tickets, schema)
names = {r.trackerName for r in rows}
assert names == {"Userstory", "Feature", "Acc.Crit", "Bug", "Task"}
task_row = next(r for r in rows if r.trackerName == "Task")
assert task_row.total == 2
assert sum(task_row.countsByStatus.values()) == 2
class TestThroughput:
def test_bucketByMonthCountsClosed(self) -> None:
_schema, tickets = loadSnapshot()
period_from = _dt.datetime(2026, 4, 1)
period_to = _dt.datetime(2026, 4, 30)
out = _throughput(tickets, period_from, period_to, "month")
keys = [b.bucketKey for b in out]
assert "2026-04" in keys
april = next(b for b in out if b.bucketKey == "2026-04")
assert april.closed == 2
assert april.created == 0
def test_bucketByWeekIsoFormat(self) -> None:
when = _dt.datetime(2026, 4, 15)
key = _bucketKey(when, "week")
assert key.startswith("2026-W")
class TestTopAssignees:
def test_excludesClosedTickets(self) -> None:
_schema, tickets = loadSnapshot()
rows = _topAssignees(tickets, limit=10)
names = {r.name for r in rows}
# Anna has 1 open (1001), Bruno has 1 open (2001), unassigned has 1 (5001).
assert "Anna Beispiel" in names
assert "Bruno Test" in names
assert "(nicht zugewiesen)" in names
class TestRelationDistribution:
def test_dedupesByRelationId(self) -> None:
_schema, tickets = loadSnapshot()
rows = _relationDistribution(tickets)
types = {r.relationType for r in rows}
assert "relates" in types
assert "blocks" in types
for r in rows:
assert r.count >= 1
class TestBacklogAging:
def test_oldOrphansLandInOlderBuckets(self) -> None:
_schema, tickets = loadSnapshot()
now = _dt.datetime(2026, 5, 1)
buckets = _backlogAging(tickets, now=now)
gt180 = next(b for b in buckets if b.bucketKey == "gt180")
assert gt180.count >= 1
class TestAggregateEndToEnd:
def test_aggregateProducesAllSections(self) -> None:
schema, tickets = loadSnapshot()
dto = _aggregate(
tickets,
schema=schema,
rootTrackerId=schema.rootTrackerId,
dateFrom="2026-04-01",
dateTo="2026-04-30",
bucket="month",
trackerIdsFilter=[],
instanceId="test-instance",
)
assert dto.instanceId == "test-instance"
assert dto.kpis.total == 6
assert dto.kpis.orphans == 2
assert len(dto.statusByTracker) == 5
assert any(b.bucketKey == "2026-04" for b in dto.throughput)
assert dto.backlogAging[-1].bucketKey == "gt180"

View file

@ -0,0 +1,57 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Unit tests for ``RedmineStatsCache``.
Verifies TTL expiry, key composition, instance invalidation and process-wide
singleton behaviour.
"""
from __future__ import annotations
import time
from modules.features.redmine.serviceRedmineStatsCache import (
RedmineStatsCache,
_getStatsCache,
)
class TestRedmineStatsCache:
def test_getReturnsNoneOnMiss(self) -> None:
c = RedmineStatsCache(ttlSeconds=60)
key = c.buildKey("inst-a", "2026-01-01", "2026-01-31", "week", [1, 2])
assert c.get(key) is None
def test_setAndGetRoundTrip(self) -> None:
c = RedmineStatsCache(ttlSeconds=60)
key = c.buildKey("inst-a", None, None, "week", [])
c.set(key, {"answer": 42})
assert c.get(key) == {"answer": 42}
def test_keyIsOrderInsensitiveForTrackerIds(self) -> None:
c = RedmineStatsCache()
k1 = c.buildKey("inst-a", None, None, "week", [3, 1, 2])
k2 = c.buildKey("inst-a", None, None, "week", [1, 2, 3])
assert k1 == k2
def test_ttlExpiry(self) -> None:
c = RedmineStatsCache(ttlSeconds=0.05)
key = c.buildKey("inst-a", None, None, "week", [])
c.set(key, "value")
time.sleep(0.06)
assert c.get(key) is None
def test_invalidateInstanceDropsAllKeysForThatInstance(self) -> None:
c = RedmineStatsCache(ttlSeconds=60)
c.set(c.buildKey("inst-a", None, None, "week", []), "v1")
c.set(c.buildKey("inst-a", "2026-01-01", "2026-01-31", "month", [1]), "v2")
c.set(c.buildKey("inst-b", None, None, "week", []), "v3")
dropped = c.invalidateInstance("inst-a")
assert dropped == 2
assert c.get(c.buildKey("inst-a", None, None, "week", [])) is None
assert c.get(c.buildKey("inst-b", None, None, "week", [])) == "v3"
def test_singletonIsStable(self) -> None:
a = _getStatsCache()
b = _getStatsCache()
assert a is b