# 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)