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