# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Routes for Trustee feature data management. Implements CRUD operations for organisations, roles, access, contracts, documents, and positions. URL Structure: /api/trustee/{instanceId}/{entity} - instanceId is the FeatureInstance ID (required for all operations) - This ensures proper multi-tenant isolation at the URL level """ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response from fastapi.responses import StreamingResponse from typing import List, Dict, Any, Optional from fastapi import status import logging import json import io from modules.auth import limiter, getRequestContext, RequestContext from .interfaceFeatureTrustee import getInterface from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from .datamodelFeatureTrustee import ( TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition, TrusteePositionDocument, ) from modules.datamodels.datamodelPagination import ( PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict, ) logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/trustee", tags=["Trustee"], responses={404: {"description": "Not found"}} ) # ===== Helper Functions ===== def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: """Parse pagination parameter from JSON string.""" if not pagination: return None try: paginationDict = json.loads(pagination) if paginationDict: paginationDict = normalize_pagination_dict(paginationDict) return PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, detail=f"Invalid pagination parameter: {str(e)}" ) return None async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: """ Validate that the user has access to the feature instance. Returns the mandateId for the instance. Args: instanceId: The FeatureInstance ID from URL context: The request context with user info Returns: mandateId of the instance Raises: HTTPException 404 if instance not found HTTPException 403 if user doesn't have access """ rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) instance = featureInterface.getFeatureInstance(instanceId) if not instance: raise HTTPException( status_code=404, detail=f"Feature instance '{instanceId}' not found" ) # Verify it's a trustee instance if instance.featureCode != "trustee": raise HTTPException( status_code=400, detail=f"Instance '{instanceId}' is not a trustee instance" ) # Verify user has access to this instance if not context.isSysAdmin: # Check if user has FeatureAccess for this instance featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) hasAccess = any( str(fa.featureInstanceId) == instanceId and fa.enabled for fa in featureAccesses ) if not hasAccess: raise HTTPException( status_code=403, detail=f"Access denied to feature instance '{instanceId}'" ) return str(instance.mandateId) # ============================================================================ # OPTIONS ENDPOINTS (for dropdowns) # ============================================================================ @router.get("/{instanceId}/organisations/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") async def getOrganisationOptions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """Get organisation options for select dropdowns. Returns: [{ value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllOrganisations(None) items = result.items if hasattr(result, 'items') else result return [{"value": org.id, "label": org.label or org.id} for org in items] @router.get("/{instanceId}/roles/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") async def getRoleOptions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """Get role options for select dropdowns. Returns: [{ value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllRoles(None) items = result.items if hasattr(result, 'items') else result return [{"value": role.id, "label": role.desc or role.id} for role in items] @router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") async def getContractOptions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """Get contract options for select dropdowns. Returns: [{ value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllContracts(None) items = result.items if hasattr(result, 'items') else result return [{"value": c.id, "label": c.label or c.name or c.id} for c in items] @router.get("/{instanceId}/documents/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") async def getDocumentOptions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """Get document options for select dropdowns. Returns: [{ value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllDocuments(None) items = result.items if hasattr(result, 'items') else result return [{"value": d.id, "label": d.name or d.id} for d in items] @router.get("/{instanceId}/positions/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") async def getPositionOptions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: """Get position options for select dropdowns. Returns: [{ value, label }]""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllPositions(None) items = result.items if hasattr(result, 'items') else result return [{"value": p.id, "label": p.title or p.id} for p in items] # ============================================================================ # CRUD ENDPOINTS # ============================================================================ # ===== Organisation Routes ===== @router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation]) @limiter.limit("30/minute") async def getOrganisations( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeOrganisation]: """Get all organisations for a feature instance with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllOrganisations(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation) @limiter.limit("30/minute") async def getOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(..., description="Organisation ID"), context: RequestContext = Depends(getRequestContext) ) -> TrusteeOrganisation: """Get a single organisation by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) org = interface.getOrganisation(orgId) if not org: raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") return org @router.post("/{instanceId}/organisations", response_model=TrusteeOrganisation, status_code=201) @limiter.limit("10/minute") async def createOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeOrganisation = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeOrganisation: """Create a new organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createOrganisation(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create organisation") return result @router.put("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation) @limiter.limit("10/minute") async def updateOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(..., description="Organisation ID"), data: TrusteeOrganisation = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeOrganisation: """Update an organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getOrganisation(orgId) if not existing: raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update organisation") return result @router.delete("/{instanceId}/organisations/{orgId}") @limiter.limit("10/minute") async def deleteOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(..., description="Organisation ID"), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete an organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getOrganisation(orgId) if not existing: raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found") success = interface.deleteOrganisation(orgId) if not success: raise HTTPException(status_code=400, detail="Failed to delete organisation") return {"message": f"Organisation {orgId} deleted"} # ===== Role Routes ===== @router.get("/{instanceId}/roles", response_model=PaginatedResponse[TrusteeRole]) @limiter.limit("30/minute") async def getRoles( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeRole]: """Get all roles with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllRoles(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/roles/{roleId}", response_model=TrusteeRole) @limiter.limit("30/minute") async def getRole( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), context: RequestContext = Depends(getRequestContext) ) -> TrusteeRole: """Get a single role by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) role = interface.getRole(roleId) if not role: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") return role @router.post("/{instanceId}/roles", response_model=TrusteeRole, status_code=201) @limiter.limit("10/minute") async def createRole( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeRole = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeRole: """Create a new role (sysadmin only).""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createRole(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create role") return result @router.put("/{instanceId}/roles/{roleId}", response_model=TrusteeRole) @limiter.limit("10/minute") async def updateRole( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(...), data: TrusteeRole = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeRole: """Update a role (sysadmin only).""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getRole(roleId) if not existing: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") result = interface.updateRole(roleId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update role") return result @router.delete("/{instanceId}/roles/{roleId}") @limiter.limit("10/minute") async def deleteRole( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a role (sysadmin only, fails if in use).""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getRole(roleId) if not existing: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") success = interface.deleteRole(roleId) if not success: raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)") return {"message": f"Role {roleId} deleted"} # ===== Access Routes ===== @router.get("/{instanceId}/access", response_model=PaginatedResponse[TrusteeAccess]) @limiter.limit("30/minute") async def getAllAccess( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeAccess]: """Get all access records with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllAccess(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/access/{accessId}", response_model=TrusteeAccess) @limiter.limit("30/minute") async def getAccess( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), accessId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeAccess: """Get a single access record by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) access = interface.getAccess(accessId) if not access: raise HTTPException(status_code=404, detail=f"Access {accessId} not found") return access @router.get("/{instanceId}/access/organisation/{orgId}", response_model=List[TrusteeAccess]) @limiter.limit("30/minute") async def getAccessByOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeAccess]: """Get all access records for an organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getAccessByOrganisation(orgId) @router.get("/{instanceId}/access/user/{userId}", response_model=List[TrusteeAccess]) @limiter.limit("30/minute") async def getAccessByUser( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), userId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeAccess]: """Get all access records for a user.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getAccessByUser(userId) @router.post("/{instanceId}/access", response_model=TrusteeAccess, status_code=201) @limiter.limit("10/minute") async def createAccess( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeAccess = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeAccess: """Create a new access record.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createAccess(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create access") return result @router.put("/{instanceId}/access/{accessId}", response_model=TrusteeAccess) @limiter.limit("10/minute") async def updateAccess( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), accessId: str = Path(...), data: TrusteeAccess = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeAccess: """Update an access record.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getAccess(accessId) if not existing: raise HTTPException(status_code=404, detail=f"Access {accessId} not found") result = interface.updateAccess(accessId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update access") return result @router.delete("/{instanceId}/access/{accessId}") @limiter.limit("10/minute") async def deleteAccess( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), accessId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete an access record.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getAccess(accessId) if not existing: raise HTTPException(status_code=404, detail=f"Access {accessId} not found") success = interface.deleteAccess(accessId) if not success: raise HTTPException(status_code=400, detail="Failed to delete access") return {"message": f"Access {accessId} deleted"} # ===== Contract Routes ===== @router.get("/{instanceId}/contracts", response_model=PaginatedResponse[TrusteeContract]) @limiter.limit("30/minute") async def getContracts( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeContract]: """Get all contracts with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllContracts(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract) @limiter.limit("30/minute") async def getContract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeContract: """Get a single contract by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) contract = interface.getContract(contractId) if not contract: raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") return contract @router.get("/{instanceId}/contracts/organisation/{orgId}", response_model=List[TrusteeContract]) @limiter.limit("30/minute") async def getContractsByOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeContract]: """Get all contracts for an organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getContractsByOrganisation(orgId) @router.post("/{instanceId}/contracts", response_model=TrusteeContract, status_code=201) @limiter.limit("10/minute") async def createContract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeContract = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeContract: """Create a new contract.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createContract(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create contract") return result @router.put("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract) @limiter.limit("10/minute") async def updateContract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), data: TrusteeContract = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeContract: """Update a contract (organisationId is immutable).""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getContract(contractId) if not existing: raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") result = interface.updateContract(contractId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)") return result @router.delete("/{instanceId}/contracts/{contractId}") @limiter.limit("10/minute") async def deleteContract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a contract.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getContract(contractId) if not existing: raise HTTPException(status_code=404, detail=f"Contract {contractId} not found") success = interface.deleteContract(contractId) if not success: raise HTTPException(status_code=400, detail="Failed to delete contract") return {"message": f"Contract {contractId} deleted"} # ===== Document Routes ===== @router.get("/{instanceId}/documents", response_model=PaginatedResponse[TrusteeDocument]) @limiter.limit("30/minute") async def getDocuments( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteeDocument]: """Get all documents (metadata only) with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllDocuments(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument) @limiter.limit("30/minute") async def getDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: """Get document metadata by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) doc = interface.getDocument(documentId) if not doc: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") return doc @router.get("/{instanceId}/documents/{documentId}/data") @limiter.limit("10/minute") async def getDocumentData( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), context: RequestContext = Depends(getRequestContext) ): """Download document binary data.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) doc = interface.getDocument(documentId) if not doc: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") data = interface.getDocumentData(documentId) if not data: raise HTTPException(status_code=404, detail="Document data not found") return StreamingResponse( io.BytesIO(data), media_type=doc.documentMimeType or "application/octet-stream", headers={"Content-Disposition": f"attachment; filename={doc.documentName or 'document'}"} ) @router.get("/{instanceId}/documents/contract/{contractId}", response_model=List[TrusteeDocument]) @limiter.limit("30/minute") async def getDocumentsByContract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteeDocument]: """Get all documents for a contract.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getDocumentsByContract(contractId) @router.post("/{instanceId}/documents", response_model=TrusteeDocument, status_code=201) @limiter.limit("10/minute") async def createDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeDocument = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: """Create a new document.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createDocument(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create document") return result @router.put("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument) @limiter.limit("10/minute") async def updateDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), data: TrusteeDocument = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: """Update document metadata.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getDocument(documentId) if not existing: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") result = interface.updateDocument(documentId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update document") return result @router.delete("/{instanceId}/documents/{documentId}") @limiter.limit("10/minute") async def deleteDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a document.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getDocument(documentId) if not existing: raise HTTPException(status_code=404, detail=f"Document {documentId} not found") success = interface.deleteDocument(documentId) if not success: raise HTTPException(status_code=400, detail="Failed to delete document") return {"message": f"Document {documentId} deleted"} # ===== Position Routes ===== @router.get("/{instanceId}/positions", response_model=PaginatedResponse[TrusteePosition]) @limiter.limit("30/minute") async def getPositions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteePosition]: """Get all positions with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllPositions(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition) @limiter.limit("30/minute") async def getPosition( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteePosition: """Get a single position by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) position = interface.getPosition(positionId) if not position: raise HTTPException(status_code=404, detail=f"Position {positionId} not found") return position @router.get("/{instanceId}/positions/contract/{contractId}", response_model=List[TrusteePosition]) @limiter.limit("30/minute") async def getPositionsByContract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePosition]: """Get all positions for a contract.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getPositionsByContract(contractId) @router.get("/{instanceId}/positions/organisation/{orgId}", response_model=List[TrusteePosition]) @limiter.limit("30/minute") async def getPositionsByOrganisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePosition]: """Get all positions for an organisation.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getPositionsByOrganisation(orgId) @router.post("/{instanceId}/positions", response_model=TrusteePosition, status_code=201) @limiter.limit("10/minute") async def createPosition( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteePosition = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteePosition: """Create a new position.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createPosition(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create position") return result @router.put("/{instanceId}/positions/{positionId}", response_model=TrusteePosition) @limiter.limit("10/minute") async def updatePosition( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), data: TrusteePosition = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteePosition: """Update a position.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getPosition(positionId) if not existing: raise HTTPException(status_code=404, detail=f"Position {positionId} not found") result = interface.updatePosition(positionId, data.model_dump(exclude={"id"})) if not result: raise HTTPException(status_code=400, detail="Failed to update position") return result @router.delete("/{instanceId}/positions/{positionId}") @limiter.limit("10/minute") async def deletePosition( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a position.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getPosition(positionId) if not existing: raise HTTPException(status_code=404, detail=f"Position {positionId} not found") success = interface.deletePosition(positionId) if not success: raise HTTPException(status_code=400, detail="Failed to delete position") return {"message": f"Position {positionId} deleted"} # ===== Position-Document Link Routes ===== @router.get("/{instanceId}/position-documents", response_model=PaginatedResponse[TrusteePositionDocument]) @limiter.limit("30/minute") async def getPositionDocuments( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[TrusteePositionDocument]: """Get all position-document links with optional pagination.""" mandateId = await _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllPositionDocuments(paginationParams) if paginationParams: return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort if paginationParams else [], filters=paginationParams.filters if paginationParams else None ) ) return PaginatedResponse(items=result.items, pagination=None) @router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument) @limiter.limit("30/minute") async def getPositionDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), linkId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteePositionDocument: """Get a single position-document link by ID.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) link = interface.getPositionDocument(linkId) if not link: raise HTTPException(status_code=404, detail=f"Link {linkId} not found") return link @router.get("/{instanceId}/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument]) @limiter.limit("30/minute") async def getDocumentsForPosition( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePositionDocument]: """Get all document links for a position.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getDocumentsForPosition(positionId) @router.get("/{instanceId}/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument]) @limiter.limit("30/minute") async def getPositionsForDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> List[TrusteePositionDocument]: """Get all position links for a document.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) return interface.getPositionsForDocument(documentId) @router.post("/{instanceId}/position-documents", response_model=TrusteePositionDocument, status_code=201) @limiter.limit("10/minute") async def createPositionDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteePositionDocument = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteePositionDocument: """Create a new position-document link.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createPositionDocument(data.model_dump()) if not result: raise HTTPException(status_code=400, detail="Failed to create link") return result @router.delete("/{instanceId}/position-documents/{linkId}") @limiter.limit("10/minute") async def deletePositionDocument( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), linkId: str = Path(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """Delete a position-document link.""" mandateId = await _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) existing = interface.getPositionDocument(linkId) if not existing: raise HTTPException(status_code=404, detail=f"Link {linkId} not found") success = interface.deletePositionDocument(linkId) if not success: raise HTTPException(status_code=400, detail="Failed to delete link") return {"message": f"Link {linkId} deleted"}