584 lines
22 KiB
Python
584 lines
22 KiB
Python
# Copyright (c) 2026 PowerOn AG
|
|
# 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, List, Optional, Tuple
|
|
|
|
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,
|
|
RedmineCustomFieldSchemaDto,
|
|
RedmineFieldChoiceDto,
|
|
RedmineFieldSchemaDto,
|
|
RedmineInstanceConfig,
|
|
RedmineRelationMirror,
|
|
RedmineTicketMirror,
|
|
)
|
|
from modules.security.rbac import RbacClass
|
|
from modules.shared.configuration import APP_CONFIG, decryptValue, encryptValue
|
|
from modules.dbHelpers.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]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Project meta -- with TTL cache stored on the config record
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class RedmineNotConfiguredError(RuntimeError):
|
|
"""The given feature instance has no usable Redmine config."""
|
|
|
|
|
|
def _resolveRootTrackerId(
|
|
rootTrackerName: str, trackers: List[Dict[str, Any]]
|
|
) -> Optional[int]:
|
|
"""Resolve the configured root tracker name to a tracker id.
|
|
|
|
Strict: case-insensitive exact match. Returns ``None`` if not found
|
|
(the UI must surface this as a config error).
|
|
"""
|
|
target = (rootTrackerName or "").strip().lower()
|
|
if not target:
|
|
return None
|
|
for t in trackers:
|
|
if str(t.get("name") or "").strip().lower() == target:
|
|
tid = t.get("id")
|
|
return int(tid) if tid is not None else None
|
|
return None
|
|
|
|
|
|
def _schemaFromCache(
|
|
projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str
|
|
) -> Optional[RedmineFieldSchemaDto]:
|
|
if not cache:
|
|
return None
|
|
trackers = cache.get("trackers") or []
|
|
return RedmineFieldSchemaDto(
|
|
projectId=projectId,
|
|
projectName=str(cache.get("projectName") or ""),
|
|
trackers=[RedmineFieldChoiceDto(**t) for t in trackers],
|
|
statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
|
|
priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
|
|
users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
|
|
categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
|
|
customFields=[
|
|
RedmineCustomFieldSchemaDto(
|
|
id=cf.get("id"),
|
|
name=cf.get("name", ""),
|
|
fieldFormat=cf.get("fieldFormat", "string"),
|
|
isRequired=bool(cf.get("isRequired")),
|
|
possibleValues=list(cf.get("possibleValues") or []),
|
|
multiple=bool(cf.get("multiple")),
|
|
defaultValue=cf.get("defaultValue"),
|
|
)
|
|
for cf in cache.get("customFields") or []
|
|
if cf.get("id") is not None
|
|
],
|
|
rootTrackerName=rootTrackerName,
|
|
rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers),
|
|
)
|
|
|
|
|
|
async def getProjectMeta(
|
|
currentUser: User,
|
|
mandateId: Optional[str],
|
|
featureInstanceId: str,
|
|
*,
|
|
forceRefresh: bool = False,
|
|
) -> RedmineFieldSchemaDto:
|
|
"""Fetch (or return cached) project metadata: trackers, statuses, priorities, etc."""
|
|
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"
|
|
)
|
|
cfg = iface.getConfig(featureInstanceId)
|
|
if cfg is None:
|
|
raise RedmineNotConfiguredError("Config row vanished after connector resolve")
|
|
|
|
ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60
|
|
fresh_enough = (
|
|
cfg.schemaCache
|
|
and cfg.schemaCachedAt
|
|
and (time.time() - cfg.schemaCachedAt) < ttl
|
|
)
|
|
if fresh_enough and not forceRefresh:
|
|
schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName)
|
|
if schema is not None:
|
|
return schema
|
|
|
|
project_info = await connector.getProjectInfo()
|
|
trackers_raw = await connector.getTrackers()
|
|
statuses_raw = await connector.getStatuses()
|
|
priorities_raw = await connector.getPriorities()
|
|
custom_fields_raw = await connector.getCustomFields()
|
|
users_raw = await connector.getProjectUsers()
|
|
categories_raw = await connector.getIssueCategories()
|
|
|
|
schema_cache: Dict[str, Any] = {
|
|
"projectName": project_info.get("name", ""),
|
|
"trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw],
|
|
"statuses": [
|
|
{
|
|
"id": s.get("id"),
|
|
"name": s.get("name"),
|
|
"isClosed": bool(s.get("is_closed")),
|
|
}
|
|
for s in statuses_raw
|
|
],
|
|
"priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
|
|
"users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
|
|
"categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
|
|
"customFields": [
|
|
{
|
|
"id": cf.get("id"),
|
|
"name": cf.get("name"),
|
|
"fieldFormat": cf.get("field_format", "string"),
|
|
"isRequired": bool(cf.get("is_required")),
|
|
"possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None],
|
|
"multiple": bool(cf.get("multiple")),
|
|
"defaultValue": cf.get("default_value"),
|
|
}
|
|
for cf in custom_fields_raw
|
|
],
|
|
}
|
|
iface.updateSchemaCache(featureInstanceId, schema_cache)
|
|
iface.markConfigConnected(featureInstanceId)
|
|
|
|
return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto(
|
|
projectId=cfg.projectId,
|
|
projectName=schema_cache["projectName"],
|
|
rootTrackerName=cfg.rootTrackerName,
|
|
)
|