""" Router endpoints for organisation module Endpoints: - [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. """ from typing import Annotated from fastapi import APIRouter, status from fastapi.params import Query from psycopg.errors import UniqueViolation from sqlalchemy.exc import IntegrityError from src.auth.exceptions import UnauthorizedException from src.contact.schemas import ContactModel from src.exceptions import UnprocessableContentException, ConflictException from src.contact.models import Contact from src.contact.schemas import ContactAddress from src.contact.exceptions import ContactNotFoundException from src.database import db_dependency from src.user.dependencies import user_model_body_dependency, user_model_claims_dependency from src.auth.dependencies import super_admin_dependency, org_model_root_claim_query_dependency, org_model_root_claim_body_dependency from src.organisation.dependencies import org_model_body_dependency from src.organisation.constants import ContactType from src.organisation.models import Organisation as Org from src.organisation.schemas import OrgPostOrgRequest, OrgPatchQuestionnaireRequest, OrgPatchStatusRequest, \ OrgPatchContactRequest, \ OrgPostUserRequest, OrgGetUserResponse, OrgGetContactResponse, OrgGetOrgResponse, OrgPatchRootRequest, \ OrgGetGroupResponse, OrgDeleteUserRequest, OrgDeleteOrgRequest, OrgPostOrgResponse, OrgPatchQuestionnaireResponse, \ OrgPatchStatusResponse, OrgPostUserResponse, OrgPatchRootResponse router = APIRouter( prefix="/org", tags=["Organisation"], ) @router.get("/id", 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."}, }) async def get_org_by_id(org_model: org_model_root_claim_query_dependency): response = { "name": org_model.name, "status": org_model.status, "owner_contact": org_model.owner_contact_rel.email, "billing_contact": org_model.billing_contact_rel.email, "security_contact": org_model.security_contact_rel.email, "root_user": org_model.root_user_email } return response @router.post("/", summary="Create new organisation.", status_code=status.HTTP_201_CREATED, response_model=OrgPostOrgResponse, 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."}, }) async def create_org(db: db_dependency, user_model: user_model_claims_dependency, request_model: OrgPostOrgRequest): 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) org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc db.add(org_model) try: db.flush() except IntegrityError as e: if isinstance(e.orig, UniqueViolation): raise ConflictException(message="Organisation with this name already exists") # 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 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) response = OrgPostOrgResponse(**org_model.__dict__) db.commit() return response @router.patch("/questionnaire", summary="Update questionnaire.", status_code=status.HTTP_200_OK, response_model=OrgPatchQuestionnaireResponse, 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."}, }) async def update_questionnaire(db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: OrgPatchQuestionnaireRequest): """ 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. """ org_model.intake_questionnaire = request_model.intake_questionnaire.model_dump() # Allows for partially completed questionnaires to be saved without being submitted for review if not request_model.partial: org_model.status = "submitted" db.flush() response = OrgPatchQuestionnaireResponse(**org_model.__dict__) db.commit() return response @router.patch("/status", summary="Update status of organisation.", status_code=status.HTTP_200_OK, response_model=OrgPatchStatusResponse, 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."}, }) async def update_status(db: db_dependency, org_model: org_model_body_dependency, su: super_admin_dependency, request_model: OrgPatchStatusRequest): org_model.status = request_model.status db.flush() response = OrgPatchStatusResponse(**org_model.__dict__) db.commit() return response @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."}, }) async def get_users(org_model: org_model_root_claim_query_dependency): return {"users": [user.email for user in org_model.user_rel]} @router.post("/users", summary="Add user to the organisation.", status_code=status.HTTP_200_OK, response_model=OrgPostUserResponse, 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."}, }) 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): if user_model in org_model.user_rel: raise ConflictException(message="User already a part of this organisation") org_model.user_rel.append(user_model) db.flush() response = {"users": [user.email for user in org_model.user_rel]} db.commit() return response @router.delete("/", 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."}, }) async def delete_organisation_by_id(db: db_dependency, org_model: org_model_body_dependency, su: super_admin_dependency, request_model: OrgDeleteOrgRequest): db.delete(org_model) db.commit() @router.patch("/root_user", summary="Update the root user of the organisation.", status_code=status.HTTP_200_OK, response_model=OrgPatchRootResponse, 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."}, }) 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): if user_model not in org_model.user_rel: raise UnauthorizedException(message="This user does not belong to your organisation.") org_model.root_user_rel = user_model db.flush() response = OrgPatchRootResponse(**org_model.__dict__) db.commit() return response @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."}, }) async def get_org_groups(org_model: org_model_root_claim_query_dependency): return {"groups": [group.name for group in org_model.group_rel]} @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."}, }) 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): if user_model not in org_model.user_rel: return org_model.user_rel.remove(user_model) db.commit() @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."}, }) async def get_contact(org_model: org_model_root_claim_query_dependency, contact_type: Annotated[ContactType, Query(description="Must be billing|security|owner")]): match contact_type: case "billing": contact_model = org_model.billing_contact_rel case "security": contact_model = org_model.security_contact_rel case "owner": contact_model = org_model.owner_contact_rel case _: raise UnprocessableContentException("Invalid contact type") if contact_model is None: raise ContactNotFoundException() address = ContactAddress.model_validate(contact_model) contact_response = ContactModel.model_construct(**contact_model.__dict__, address=address) return {"contact": contact_response} @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."}, }) async def update_contact(db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: OrgPatchContactRequest): match request_model.contact_type: case "billing": contact_model = org_model.billing_contact_rel case "security": contact_model = org_model.security_contact_rel case "owner": contact_model = org_model.owner_contact_rel case _: raise UnprocessableContentException("Invalid contact type") if contact_model is None: raise ContactNotFoundException() update_data = request_model.model_dump(exclude_none=True) for key, value in update_data.items(): if hasattr(contact_model, key): setattr(contact_model, key, value) else: raise UnprocessableContentException("Invalid keys in update request") db.flush() address = ContactAddress.model_validate(contact_model) contact_response = ContactModel.model_construct(**contact_model.__dict__, address=address) db.commit() return {"contact": contact_response}