# 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