1030 lines
40 KiB
Python
1030 lines
40 KiB
Python
# 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 modules.interfaces.interfaceDbTrustee import getInterface
|
|
from modules.interfaces.interfaceDbAppObjects import getRootInterface
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from modules.datamodels.datamodelTrustee 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)
|
|
|
|
|
|
# ===== 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"}
|