530 lines
24 KiB
Python
530 lines
24 KiB
Python
# 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
|