2026-04-06 12:41:49 +01:00
"""
Router endpoints for organisation module
Endpoints :
2026-05-28 14:23:36 +01:00
- [ GET ] ( / org / id ) : [ root user ] : Get details about an organisation ( id )
- [ POST ] ( / org / ) : [ oidc claim ] : Creates an organisation , adds the current user as a user and sets them to be the root user
- [ PATCH ] ( / org / questionnaire ) : [ root user ] : Updates the org ' s intake questionnaire and optionally be submitted for review
- [ PATCH ] ( / org / status ) : [ super admin ] : Allows a super admin to update an org ( id ) status ( Status enum )
- [ GET ] ( / org / users ) : [ root user ] : Gets a list of the org ( id ) users ( email )
- [ POST ] ( / org / users ) : [ root user ] : Adds a new user ( id ) to the org ( id )
- [ DELETE ] ( / org / ) : [ super admin ] : Deletes an organisation ( id )
- [ PATCH ] ( / org / root_user ) : [ super admin ] : Updates an org ( id ) root user ( id )
- [ GET ] ( / org / groups ) : [ root user ] : Gets a list of the org ( id ) groups ( name )
- [ DELETE ] ( / org / user ) : [ root user ] : Removes a user ( id ) from an org ( id )
- [ GET ] ( / org / contact ) : [ root user ] : Gets the ( contact_type ) contact for an org ( id )
- [ PATCH ] ( / org / contact ) : [ root user ] : Updates the ( contact_type ) contact for an org ( id ) . Any number of details can be changed .
2026-04-06 12:41:49 +01:00
"""
2026-05-29 09:52:34 +01:00
from typing import Annotated
2026-05-19 11:11:03 +01:00
2026-05-27 14:58:10 +01:00
from fastapi import APIRouter , status
2026-05-27 12:21:03 +01:00
from fastapi . params import Query
2026-05-27 16:26:34 +01:00
from psycopg . errors import UniqueViolation
from sqlalchemy . exc import IntegrityError
2026-04-06 12:41:49 +01:00
2026-05-29 09:52:34 +01:00
from src . auth . exceptions import UnauthorizedException
from src . contact . schemas import ContactModel
from src . exceptions import UnprocessableContentException , ConflictException
2026-05-27 15:42:53 +01:00
from src . contact . models import Contact
2026-05-25 15:15:50 +01:00
from src . contact . schemas import ContactAddress
2026-05-27 14:58:10 +01:00
from src . contact . exceptions import ContactNotFoundException
2026-04-06 12:41:49 +01:00
from src . database import db_dependency
2026-05-29 09:52:34 +01:00
from src . user . dependencies import user_model_body_dependency , user_model_claims_dependency
2026-05-27 15:42:53 +01:00
from src . auth . dependencies import super_admin_dependency , org_model_root_claim_query_dependency , org_model_root_claim_body_dependency
2026-04-06 12:41:49 +01:00
2026-05-27 15:45:31 +01:00
from src . organisation . dependencies import org_model_body_dependency
2026-04-06 12:41:49 +01:00
from src . organisation . constants import ContactType
2026-05-25 16:54:45 +01:00
from src . organisation . models import Organisation as Org
2026-05-27 16:51:46 +01:00
from src . organisation . schemas import OrgPostOrgRequest , OrgPatchQuestionnaireRequest , OrgPatchStatusRequest , \
OrgPatchContactRequest , \
OrgPostUserRequest , OrgGetUserResponse , OrgGetContactResponse , OrgGetOrgResponse , OrgPatchRootRequest , \
2026-05-29 09:44:24 +01:00
OrgGetGroupResponse , OrgDeleteUserRequest , OrgDeleteOrgRequest , OrgPostOrgResponse , OrgPatchQuestionnaireResponse , \
OrgPatchStatusResponse , OrgPostUserResponse , OrgPatchRootResponse
2026-05-27 15:59:12 +01:00
2026-04-06 12:41:49 +01:00
router = APIRouter (
prefix = " /org " ,
2026-05-28 16:46:44 +01:00
tags = [ " Organisation " ] ,
2026-04-06 12:41:49 +01:00
)
2026-06-02 16:23:29 +01:00
@router.get ( " " ,
2026-05-28 16:43:39 +01:00
summary = " Get org details by ID. " ,
response_model = OrgGetOrgResponse ,
status_code = status . HTTP_200_OK ,
responses = {
status . HTTP_200_OK : { " description " : " Successful retrieval from database " } ,
status . HTTP_404_NOT_FOUND : { " description " : " Organisation not found " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Missing or invalid org_id query parameter " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
} )
2026-05-27 15:42:53 +01:00
async def get_org_by_id ( org_model : org_model_root_claim_query_dependency ) :
2026-05-29 10:40:24 +01:00
"""
Returns organisation details including key member email addresses
"""
2026-04-06 12:41:49 +01:00
response = {
" name " : org_model . name ,
" status " : org_model . status ,
2026-05-25 15:15:50 +01:00
" owner_contact " : org_model . owner_contact_rel . email ,
" billing_contact " : org_model . billing_contact_rel . email ,
" security_contact " : org_model . security_contact_rel . email ,
2026-06-02 16:36:56 +01:00
" root_user " : org_model . root_user_email ,
" intake_questionnaire " : org_model . intake_questionnaire
2026-04-06 12:41:49 +01:00
}
return response
2026-06-02 16:23:29 +01:00
@router.post ( " " ,
2026-05-28 16:43:39 +01:00
summary = " Create new organisation. " ,
status_code = status . HTTP_201_CREATED ,
2026-05-29 09:44:24 +01:00
response_model = OrgPostOrgResponse ,
2026-05-28 16:43:39 +01:00
responses = {
status . HTTP_201_CREATED : { " description " : " Successfully created organisation. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " User must be logged in with OIDC to create organisation. " } ,
status . HTTP_409_CONFLICT : { " description " : " Organisation with this name already exists. " } ,
} )
2026-05-27 16:51:46 +01:00
async def create_org ( db : db_dependency , user_model : user_model_claims_dependency , request_model : OrgPostOrgRequest ) :
2026-05-29 10:40:24 +01:00
"""
Creates a new organisation with optional questionnaire ( to be completed or submitted ) .
ALl organisations are given the " partial " status on creation . See update_questionnaire ( ) for more details .
"""
2026-05-27 12:21:03 +01:00
if request_model . intake_questionnaire :
intake_questionnaire = request_model . intake_questionnaire . model_dump ( )
else :
intake_questionnaire = None
org_model = Org ( name = request_model . name , intake_questionnaire = intake_questionnaire )
2026-04-06 12:41:49 +01:00
2026-05-29 10:40:24 +01:00
org_model . status = " partial "
2026-04-06 12:41:49 +01:00
db . add ( org_model )
2026-05-27 16:26:34 +01:00
try :
db . flush ( )
except IntegrityError as e :
if isinstance ( e . orig , UniqueViolation ) :
2026-05-29 09:52:34 +01:00
raise ConflictException ( message = " Organisation with this name already exists " )
2026-05-25 16:54:45 +01:00
# Adds currently logged-in user to org users list and sets them as root_user
org_model . user_rel . append ( user_model )
org_model . root_user_rel = user_model
2026-05-25 15:15:50 +01:00
for contact_type in [ " billing_contact_id " , " security_contact_id " , " owner_contact_id " ] :
contact_model = Contact ( org_id = org_model . id )
db . add ( contact_model )
db . flush ( )
org_model . __setattr__ ( contact_type , contact_model . id )
2026-05-29 09:44:24 +01:00
response = OrgPostOrgResponse ( * * org_model . __dict__ )
2026-04-06 12:41:49 +01:00
db . commit ( )
2026-05-29 09:44:24 +01:00
return response
2026-04-06 12:41:49 +01:00
2026-05-28 16:43:39 +01:00
@router.patch ( " /questionnaire " ,
summary = " Update questionnaire. " ,
status_code = status . HTTP_200_OK ,
2026-05-29 09:44:24 +01:00
response_model = OrgPatchQuestionnaireResponse ,
2026-05-28 16:43:39 +01:00
responses = {
status . HTTP_200_OK : { " description " : " Successfully updated questionnaire. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
} )
2026-05-29 09:44:24 +01:00
async def update_questionnaire ( db : db_dependency , org_model : org_model_root_claim_body_dependency , request_model : OrgPatchQuestionnaireRequest ) :
2026-04-06 12:41:49 +01:00
"""
Route for updating questionnaire .
The partial bool allows for submission of partially completed questionnaire and / or
final " are you sure " check before setting the org to be in " submitted " status , awaiting admin approval .
"""
2026-05-27 12:21:03 +01:00
org_model . intake_questionnaire = request_model . intake_questionnaire . model_dump ( )
2026-04-06 12:41:49 +01:00
# Allows for partially completed questionnaires to be saved without being submitted for review
2026-05-27 12:21:03 +01:00
if not request_model . partial :
2026-04-06 12:41:49 +01:00
org_model . status = " submitted "
2026-05-29 09:44:24 +01:00
db . flush ( )
response = OrgPatchQuestionnaireResponse ( * * org_model . __dict__ )
2026-04-06 12:41:49 +01:00
db . commit ( )
2026-05-29 09:44:24 +01:00
return response
2026-04-06 12:41:49 +01:00
2026-05-28 16:43:39 +01:00
@router.patch ( " /status " ,
summary = " Update status of organisation. " ,
status_code = status . HTTP_200_OK ,
2026-05-29 09:44:24 +01:00
response_model = OrgPatchStatusResponse ,
2026-05-28 16:43:39 +01:00
responses = {
status . HTTP_200_OK : { " description " : " Successfully updated organisation status. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be super admin. " } ,
} )
2026-05-27 16:51:46 +01:00
async def update_status ( db : db_dependency , org_model : org_model_body_dependency , su : super_admin_dependency , request_model : OrgPatchStatusRequest ) :
2026-05-29 10:40:24 +01:00
"""
Sets an organisation ' s status. This is the endpoint for approving or denying an organisation after reviewing the questionnaire.
"""
2026-05-27 12:21:03 +01:00
org_model . status = request_model . status
2026-05-29 09:44:24 +01:00
db . flush ( )
response = OrgPatchStatusResponse ( * * org_model . __dict__ )
2026-04-06 12:41:49 +01:00
db . commit ( )
2026-05-29 09:44:24 +01:00
return response
2026-04-06 12:41:49 +01:00
2026-05-28 16:43:39 +01:00
@router.get ( " /users " ,
summary = " Get email addresses of users of the organisation. " ,
status_code = status . HTTP_200_OK ,
response_model = OrgGetUserResponse ,
responses = {
status . HTTP_200_OK : { " description " : " Successful retrieval of users. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Org ID missing or invalid. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
} )
2026-05-27 15:42:53 +01:00
async def get_users ( org_model : org_model_root_claim_query_dependency ) :
2026-05-29 10:40:24 +01:00
"""
Returns a list of the email addresses of all users of the organisation .
"""
return { " users " : [ user . email for user in org_model . user_rel ] }
2026-04-06 12:41:49 +01:00
2026-05-29 10:40:24 +01:00
@router.post ( " /user " ,
2026-05-29 09:44:24 +01:00
summary = " Add user to the organisation. " ,
2026-05-28 16:43:39 +01:00
status_code = status . HTTP_200_OK ,
2026-05-29 09:44:24 +01:00
response_model = OrgPostUserResponse ,
2026-05-28 16:43:39 +01:00
responses = {
status . HTTP_200_OK : { " description " : " Successfully added user to the organisation. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_409_CONFLICT : { " description " : " User is already a member of the organisation. " } ,
} )
2026-05-27 16:51:46 +01:00
async def add_user_to_org ( db : db_dependency , org_model : org_model_root_claim_body_dependency , user_model : user_model_body_dependency , request_model : OrgPostUserRequest ) :
2026-05-29 10:40:24 +01:00
"""
Adds a user to the organisation .
"""
2026-05-25 16:54:45 +01:00
if user_model in org_model . user_rel :
2026-05-29 09:52:34 +01:00
raise ConflictException ( message = " User already a part of this organisation " )
2026-05-25 16:54:45 +01:00
org_model . user_rel . append ( user_model )
2026-05-29 09:44:24 +01:00
db . flush ( )
response = { " users " : [ user . email for user in org_model . user_rel ] }
2026-04-06 12:41:49 +01:00
db . commit ( )
2026-05-29 09:44:24 +01:00
return response
2026-04-06 12:41:49 +01:00
2026-06-02 16:23:29 +01:00
@router.delete ( " " ,
2026-05-28 16:43:39 +01:00
summary = " Delete organisation from the hub. " ,
status_code = status . HTTP_204_NO_CONTENT ,
responses = {
status . HTTP_204_NO_CONTENT : { " description " : " Successfully deleted organisation. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be super admin. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Org ID missing or invalid. " } ,
} )
2026-05-27 15:42:53 +01:00
async def delete_organisation_by_id ( db : db_dependency , org_model : org_model_body_dependency , su : super_admin_dependency , request_model : OrgDeleteOrgRequest ) :
2026-05-29 10:40:24 +01:00
"""
Removes an organisation from the hub .
"""
2026-04-06 12:41:49 +01:00
db . delete ( org_model )
db . commit ( )
2026-05-28 16:43:39 +01:00
@router.patch ( " /root_user " ,
summary = " Update the root user of the organisation. " ,
status_code = status . HTTP_200_OK ,
2026-05-29 09:44:24 +01:00
response_model = OrgPatchRootResponse ,
2026-05-28 16:43:39 +01:00
responses = {
status . HTTP_200_OK : { " description " : " Successfully updated root user. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be super admin. " } ,
} )
2026-05-27 16:51:46 +01:00
async def update_root_user ( db : db_dependency , org_model : org_model_body_dependency , user_model : user_model_body_dependency , su : super_admin_dependency , request_model : OrgPatchRootRequest ) :
2026-05-29 10:40:24 +01:00
"""
Promotes an existing organisation user to the root user , giving them full control of the org .
"""
2026-05-29 09:52:34 +01:00
if user_model not in org_model . user_rel :
raise UnauthorizedException ( message = " This user does not belong to your organisation. " )
2026-05-27 15:59:12 +01:00
org_model . root_user_rel = user_model
2026-05-29 09:44:24 +01:00
db . flush ( )
2026-06-01 13:07:42 +01:00
response = OrgPatchRootResponse ( name = org_model . name , root_user_email = org_model . root_user_email )
2026-05-25 09:05:17 +01:00
db . commit ( )
2026-05-29 09:44:24 +01:00
return response
2026-05-25 09:05:17 +01:00
2026-05-28 16:43:39 +01:00
@router.get ( " /groups " ,
summary = " Get all organisation IAM groups. " ,
status_code = status . HTTP_200_OK ,
response_model = OrgGetGroupResponse ,
responses = {
status . HTTP_200_OK : { " description " : " Successful retrieval of IAM groups. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Org ID missing or invalid. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
} )
2026-05-27 15:42:53 +01:00
async def get_org_groups ( org_model : org_model_root_claim_query_dependency ) :
2026-05-29 10:40:24 +01:00
"""
Returns a list of the names of all IAM groups created by the organisation .
"""
2026-05-25 16:54:45 +01:00
return { " groups " : [ group . name for group in org_model . group_rel ] }
2026-05-25 09:05:17 +01:00
2026-05-28 16:43:39 +01:00
@router.delete ( " /user " ,
summary = " Remove user from organisation. " ,
status_code = status . HTTP_204_NO_CONTENT ,
responses = {
status . HTTP_204_NO_CONTENT : { " description " : " Successfully removed user. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
} )
2026-05-27 16:51:46 +01:00
async def remove_user_from_org ( db : db_dependency , org_model : org_model_root_claim_body_dependency , user_model : user_model_body_dependency , request_model : OrgDeleteUserRequest ) :
2026-05-29 10:40:24 +01:00
"""
Revokes a user ' s membership in an organisation.
"""
2026-05-27 15:59:12 +01:00
if user_model not in org_model . user_rel :
2026-05-27 14:58:10 +01:00
return
2026-05-25 15:15:50 +01:00
2026-05-27 15:59:12 +01:00
org_model . user_rel . remove ( user_model )
2026-05-25 16:54:45 +01:00
db . commit ( )
2026-05-25 15:15:50 +01:00
2026-05-27 12:21:03 +01:00
2026-05-28 16:43:39 +01:00
@router.get ( " /contact " ,
summary = " Get contact for organisation. " ,
status_code = status . HTTP_200_OK ,
response_model = OrgGetContactResponse ,
responses = {
status . HTTP_200_OK : { " description " : " Successful retrieval of contact. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
} )
2026-05-28 16:52:10 +01:00
async def get_contact ( org_model : org_model_root_claim_query_dependency , contact_type : Annotated [ ContactType , Query ( description = " Must be billing|security|owner " ) ] ) :
2026-05-29 10:40:24 +01:00
"""
Gets full details for a contact point at an organisation .
"""
2026-05-25 15:15:50 +01:00
match contact_type :
case " billing " :
2026-05-25 16:54:45 +01:00
contact_model = org_model . billing_contact_rel
2026-05-25 15:15:50 +01:00
case " security " :
2026-05-25 16:54:45 +01:00
contact_model = org_model . security_contact_rel
2026-05-25 15:15:50 +01:00
case " owner " :
2026-05-25 16:54:45 +01:00
contact_model = org_model . owner_contact_rel
2026-05-25 15:15:50 +01:00
case _ :
2026-05-29 09:52:34 +01:00
raise UnprocessableContentException ( " Invalid contact type " )
2026-05-25 15:15:50 +01:00
if contact_model is None :
2026-05-27 14:58:10 +01:00
raise ContactNotFoundException ( )
2026-05-25 15:15:50 +01:00
2026-05-27 16:51:46 +01:00
address = ContactAddress . model_validate ( contact_model )
contact_response = ContactModel . model_construct ( * * contact_model . __dict__ , address = address )
2026-05-25 15:15:50 +01:00
2026-05-27 16:51:46 +01:00
return { " contact " : contact_response }
2026-05-25 15:15:50 +01:00
2026-05-27 16:51:46 +01:00
2026-05-28 16:43:39 +01:00
@router.patch ( " /contact " ,
summary = " Update contact for organisation. " ,
status_code = status . HTTP_200_OK ,
response_model = OrgGetContactResponse ,
responses = {
status . HTTP_200_OK : { " description " : " Successfully updated contact. " } ,
status . HTTP_422_UNPROCESSABLE_CONTENT : { " description " : " Invalid data in request. " } ,
status . HTTP_401_UNAUTHORIZED : { " description " : " Not authorised. Must be org root user. " } ,
} )
2026-05-27 16:51:46 +01:00
async def update_contact ( db : db_dependency , org_model : org_model_root_claim_body_dependency , request_model : OrgPatchContactRequest ) :
2026-05-29 10:40:24 +01:00
"""
Updates details for a contact point at an organisation .
"""
2026-05-27 12:21:03 +01:00
match request_model . contact_type :
2026-05-25 15:15:50 +01:00
case " billing " :
2026-05-25 16:54:45 +01:00
contact_model = org_model . billing_contact_rel
2026-05-25 15:15:50 +01:00
case " security " :
2026-05-25 16:54:45 +01:00
contact_model = org_model . security_contact_rel
2026-05-25 15:15:50 +01:00
case " owner " :
2026-05-25 16:54:45 +01:00
contact_model = org_model . owner_contact_rel
2026-05-25 15:15:50 +01:00
case _ :
2026-05-29 09:52:34 +01:00
raise UnprocessableContentException ( " Invalid contact type " )
2026-05-25 15:15:50 +01:00
if contact_model is None :
2026-05-27 14:58:10 +01:00
raise ContactNotFoundException ( )
2026-05-25 15:15:50 +01:00
2026-05-27 12:21:03 +01:00
update_data = request_model . model_dump ( exclude_none = True )
2026-05-25 15:15:50 +01:00
for key , value in update_data . items ( ) :
if hasattr ( contact_model , key ) :
setattr ( contact_model , key , value )
else :
2026-06-01 14:27:50 +01:00
if key == " contact_type " or key == " organisation_id " :
continue
2026-05-29 09:52:34 +01:00
raise UnprocessableContentException ( " Invalid keys in update request " )
2026-05-25 16:54:45 +01:00
db . flush ( )
2026-05-27 16:51:46 +01:00
address = ContactAddress . model_validate ( contact_model )
contact_response = ContactModel . model_construct ( * * contact_model . __dict__ , address = address )
2026-05-25 16:54:45 +01:00
db . commit ( )
2026-05-27 16:51:46 +01:00
return { " contact " : contact_response }