diff --git a/src/exceptions.py b/src/exceptions.py index 83099e0..66507a4 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,21 +1,25 @@ """ Global exceptions + +Exports: + - UnprocessableContentException + - ConflictException """ from typing import Optional from fastapi import HTTPException, status -class UnprocessableContent(HTTPException): +class UnprocessableContentException(HTTPException): def __init__(self, message: Optional[str] = None) -> None: - detail = "Not authorized" if not message else message + detail = "Unprocessable content" if not message else message super().__init__( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail, ) -class Conflict(HTTPException): +class ConflictException(HTTPException): def __init__(self, message: Optional[str] = None) -> None: detail = "Conflict" if not message else message super().__init__( diff --git a/src/iam/router.py b/src/iam/router.py index a6398a9..f2f6a34 100644 --- a/src/iam/router.py +++ b/src/iam/router.py @@ -19,7 +19,7 @@ from fastapi import APIRouter, status from sqlalchemy.exc import IntegrityError from psycopg import errors -from src.exceptions import Conflict +from src.exceptions import ConflictException from src.database import db_dependency from src.schemas import ResourceName from src.auth.exceptions import UnauthorizedException @@ -100,7 +100,7 @@ async def create_group(db: db_dependency, org_model: org_model_root_claim_body_d db.flush() except IntegrityError as e: if isinstance(e.orig, errors.UniqueViolation): - raise Conflict("Group with this name already exists") + raise ConflictException("Group with this name already exists") response = GroupSchema(**group_model.__dict__) db.commit() return {"group": response} @@ -112,7 +112,7 @@ async def add_group_permission(db: db_dependency, group_model: group_model_body_ raise UnauthorizedException() if perm_model in group_model.permission_rel: - raise Conflict("Group already has this permission") + raise ConflictException("Group already has this permission") group_model.permission_rel.append(perm_model) @@ -128,7 +128,7 @@ async def add_group_user(db: db_dependency, group_model: group_model_body_depend raise UnauthorizedException() if user_model in group_model.user_rel: - raise Conflict("User already in group") + raise ConflictException("User already in group") group_model.user_rel.append(user_model) db.flush() @@ -177,7 +177,7 @@ async def create_new_permission(db: db_dependency, su: super_admin_dependency, r db.add(perm_model) except IntegrityError as e: if isinstance(e.orig, errors.UniqueViolation): - raise Conflict(message="Permission already exists") + raise ConflictException(message="Permission already exists") db.flush() response = IAMPostPermissionResponse(permission=PermissionSchema(**perm_model.__dict__)) db.commit() diff --git a/src/organisation/dependencies.py b/src/organisation/dependencies.py index 51ee569..ec8805c 100644 --- a/src/organisation/dependencies.py +++ b/src/organisation/dependencies.py @@ -6,6 +6,7 @@ Exports: - org_model_body_dependency: org_model: Gets org model from db, if it exists. Uses org_id from request body. Also verifies if the org has been approved. """ from typing import Annotated, Optional +from sqlalchemy.orm import Session from fastapi import Depends, Query, Request @@ -17,12 +18,14 @@ from src.organisation.exceptions import OrgNotFoundException, AwaitingApprovalEx from src.organisation.constants import Status as OrgStatus -def get_org_model(db, request: Request, org_id: int): +def get_org_model(db: Session, request: Request, org_id: int): org_model = db.get(Org, org_id) if org_model is None: raise OrgNotFoundException(org_id) - pre_approval_endpoints = ["PATCH/org/status", "PATCH/org/questionnaire", "GET/org/id", "GET/org/contact", "PATCH/org/contact"] + root = "/api/v1" + + pre_approval_endpoints = [f"PATCH{root}/org/status", f"PATCH{root}/org/questionnaire", f"GET{root}/org/id", f"GET{root}/org/contact", f"PATCH{root}/org/contact"] current_request = f"{request.method}{request.url.path}" if current_request not in pre_approval_endpoints and org_model.status != OrgStatus.APPROVED: raise AwaitingApprovalException(org_id) diff --git a/src/organisation/router.py b/src/organisation/router.py index 97f3e85..1d7f3d3 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -15,21 +15,21 @@ Endpoints: - [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, Optional +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 contact.schemas import ContactModel -from src.exceptions import UnprocessableContent, Conflict +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.models import User -from user.dependencies import user_model_body_dependency, user_model_claims_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 @@ -38,8 +38,8 @@ 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 - + OrgGetGroupResponse, OrgDeleteUserRequest, OrgDeleteOrgRequest, OrgPostOrgResponse, OrgPatchQuestionnaireResponse, \ + OrgPatchStatusResponse, OrgPostUserResponse, OrgPatchRootResponse router = APIRouter( prefix="/org", @@ -58,6 +58,9 @@ router = APIRouter( 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): + """ + Returns organisation details including key member email addresses + """ response = { "name": org_model.name, "status": org_model.status, @@ -73,6 +76,7 @@ async def get_org_by_id(org_model: org_model_root_claim_query_dependency): @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."}, @@ -80,21 +84,24 @@ async def get_org_by_id(org_model: org_model_root_claim_query_dependency): 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): - # TODO: Response model + """ + 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. + """ 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 + org_model.status = "partial" db.add(org_model) try: db.flush() except IntegrityError as e: if isinstance(e.orig, UniqueViolation): - raise Conflict(message="Organisation with this name already exists") + 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 @@ -103,46 +110,56 @@ async def create_org(db: db_dependency, user_model: user_model_claims_dependency 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_query_dependency, request_model: OrgPatchQuestionnaireRequest): +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. """ - # TODO: Response model 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): - # TODO: Response model + """ + Sets an organisation's status. This is the endpoint for approving or denying an organisation after reviewing the questionnaire. + """ org_model.status = request_model.status - + db.flush() + response = OrgPatchStatusResponse(**org_model.__dict__) db.commit() + return response @router.get("/users", @@ -155,12 +172,16 @@ async def update_status(db: db_dependency, org_model: org_model_body_dependency, 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]} + """ + Returns a list of the email addresses of all users of the organisation. + """ + return {"users": [user.email for user in org_model.user_rel]} -@router.post("/users", - summary="All user to the organisation.", +@router.post("/user", + 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."}, @@ -168,11 +189,16 @@ async def get_users(org_model: org_model_root_claim_query_dependency): 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): - # TODO: response model + """ + Adds a user to the organisation. + """ if user_model in org_model.user_rel: - raise Conflict(message="User already a part of this organisation") + 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("/", @@ -184,6 +210,9 @@ async def add_user_to_org(db: db_dependency, org_model: org_model_root_claim_bod 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): + """ + Removes an organisation from the hub. + """ db.delete(org_model) db.commit() @@ -191,15 +220,23 @@ async def delete_organisation_by_id(db: db_dependency, org_model: org_model_body @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): - # TODO: response model + """ + Promotes an existing organisation user to the root user, giving them full control of the org. + """ + 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", @@ -212,6 +249,9 @@ async def update_root_user(db: db_dependency, org_model: org_model_body_dependen 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): + """ + Returns a list of the names of all IAM groups created by the organisation. + """ return {"groups": [group.name for group in org_model.group_rel]} @@ -224,7 +264,9 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency): 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): - # TODO: response model + """ + Revokes a user's membership in an organisation. + """ if user_model not in org_model.user_rel: return @@ -241,7 +283,10 @@ async def remove_user_from_org(db: db_dependency, org_model: org_model_root_clai 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()]): +async def get_contact(org_model: org_model_root_claim_query_dependency, contact_type: Annotated[ContactType, Query(description="Must be billing|security|owner")]): + """ + Gets full details for a contact point at an organisation. + """ match contact_type: case "billing": contact_model = org_model.billing_contact_rel @@ -250,7 +295,7 @@ async def get_contact(org_model: org_model_root_claim_query_dependency, contact_ case "owner": contact_model = org_model.owner_contact_rel case _: - raise UnprocessableContent("Invalid contact type") + raise UnprocessableContentException("Invalid contact type") if contact_model is None: raise ContactNotFoundException() @@ -271,6 +316,9 @@ async def get_contact(org_model: org_model_root_claim_query_dependency, contact_ 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): + """ + Updates details for a contact point at an organisation. + """ match request_model.contact_type: case "billing": contact_model = org_model.billing_contact_rel @@ -279,7 +327,7 @@ async def update_contact(db: db_dependency, org_model: org_model_root_claim_body case "owner": contact_model = org_model.owner_contact_rel case _: - raise UnprocessableContent("Invalid contact type") + raise UnprocessableContentException("Invalid contact type") if contact_model is None: raise ContactNotFoundException() @@ -289,7 +337,7 @@ async def update_contact(db: db_dependency, org_model: org_model_root_claim_body if hasattr(contact_model, key): setattr(contact_model, key, value) else: - raise UnprocessableContent("Invalid keys in update request") + raise UnprocessableContentException("Invalid keys in update request") db.flush() address = ContactAddress.model_validate(contact_model) diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 0fb13c5..3620e38 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -18,9 +18,9 @@ from src.organisation.constants import Status, ContactType class Questionnaire(CustomBaseModel): - question_one: str - question_two: str - question_three: str + question_one: Optional[str] = None + question_two: Optional[str] = None + question_three: Optional[str] = None class OrgIDMixin(CustomBaseModel): organisation_id: int @@ -30,13 +30,26 @@ class OrgPostOrgRequest(CustomBaseModel): name: str intake_questionnaire: Optional[Questionnaire] = None +class OrgPostOrgResponse(CustomBaseModel): + name: str + status: Status + class OrgPatchQuestionnaireRequest(OrgIDMixin): intake_questionnaire: Questionnaire partial: bool +class OrgPatchQuestionnaireResponse(CustomBaseModel): + name: str + intake_questionnaire: Questionnaire + status: Status + class OrgPatchStatusRequest(OrgIDMixin): status: Status +class OrgPatchStatusResponse(CustomBaseModel): + name: str + status: Status + class OrgPatchContactRequest(OrgIDMixin): contact_type: ContactType @@ -56,12 +69,19 @@ class OrgPatchContactRequest(OrgIDMixin): class OrgPostUserRequest(OrgIDMixin, UserIDMixin): pass +class OrgPostUserResponse(CustomBaseModel): + users: list[str] + class OrgDeleteUserRequest(OrgIDMixin, UserIDMixin): pass class OrgPatchRootRequest(OrgIDMixin, UserIDMixin): pass +class OrgPatchRootResponse(CustomBaseModel): + name: str + root_user_email: str + class OrgGetUserResponse(CustomBaseModel): users: list[str] diff --git a/src/service/router.py b/src/service/router.py index 6c0bd41..d1a6a41 100644 --- a/src/service/router.py +++ b/src/service/router.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, status from psycopg.errors import UniqueViolation from sqlalchemy.exc import IntegrityError -from src.exceptions import Conflict +from src.exceptions import ConflictException from src.database import db_dependency from src.auth.dependencies import super_admin_dependency, org_model_root_claim_query_dependency @@ -65,7 +65,7 @@ async def register_service(db: db_dependency, su: super_admin_dependency, reques db.flush() except IntegrityError as e: if isinstance(e.orig, UniqueViolation): - raise Conflict(message="Service with this name already exists") + raise ConflictException(message="Service with this name already exists") db.commit() response = ServiceWithKeySchema(**service_model.__dict__) db.commit() diff --git a/src/user/service.py b/src/user/service.py index f65e045..3d42574 100644 --- a/src/user/service.py +++ b/src/user/service.py @@ -7,7 +7,7 @@ Exports: from typing import Any from src.database import get_db -from src.exceptions import UnprocessableContent +from src.exceptions import UnprocessableContentException from src.user.schemas import OIDCUser from src.user.models import User @@ -18,7 +18,7 @@ async def add_user_to_db(user_claims: dict[str, Any]) -> int: valid_user = OIDCUser(first_name=user_claims["given_name"], last_name=user_claims["family_name"], email=user_claims["email"], oidc_id=user_claims["sub"]) except Exception as e: print(e) - raise UnprocessableContent("Invalid or missing OIDC data") + raise UnprocessableContentException("Invalid or missing OIDC data") db = next(get_db()) db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first()