gateway/modules/features/redmine/datamodelRedmine.py
2026-04-26 18:11:42 +02:00

559 lines
27 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", "labelField": "label"},
},
)
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", "labelField": "label"},
},
)
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", "labelField": "label"}},
)
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})
categoryId: Optional[int] = Field(default=None, json_schema_extra={"label": "Kategorie-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
categoryName: Optional[str] = Field(default=None, json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
closedOnTs: Optional[float] = Field(
default=None,
description="Best-effort UTC epoch when the ticket transitioned to a closed status. Approximated as updatedOnTs for closed tickets at sync time; used by Stats to render the open-vs-total snapshot chart.",
json_schema_extra={"label": "closedOn (epoch)", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True},
)
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": "timestamp", "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": "timestamp", "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", "labelField": "label"}},
)
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
categoryId: Optional[int] = None
categoryName: 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)
categories: List[RedmineFieldChoiceDto] = Field(
default_factory=list,
description="Per-project Redmine issue categories. Empty if the project has none defined or if the API key is not allowed to list them.",
)
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 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 RedmineThroughputBucket(BaseModel):
"""Per-bucket snapshot used by the Stats page.
``created`` / ``closed`` keep the per-bucket flow numbers (still useful
for callers that want raw deltas), while ``cumTotal`` / ``cumOpen``
expose the cumulative snapshot the UI actually plots:
- ``cumTotal`` = number of tickets that exist as of the END of this
bucket (= count of tickets created on or before bucket end).
- ``cumOpen`` = of those, how many are still open at bucket end (i.e.
not yet closed).
"""
bucketKey: str
label: str
created: int = 0
closed: int = 0
cumTotal: int = 0
cumOpen: 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)
categoryIds: List[int] = Field(default_factory=list)
statusFilter: str = "*"
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 = Field(json_schema_extra={"frontend_type": "timestamp"})
error: Optional[str] = None
class RedmineSyncStatusDto(BaseModel):
instanceId: str
lastSyncAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
lastFullSyncAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
lastSyncDurationMs: Optional[int] = None
lastSyncTicketCount: Optional[int] = None
lastSyncErrorAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
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] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
isActive: bool = True
lastConnectedAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
lastSyncAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
lastFullSyncAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
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