diff --git a/src/exceptions.py b/src/exceptions.py index 66507a4..83099e0 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,25 +1,21 @@ """ Global exceptions - -Exports: - - UnprocessableContentException - - ConflictException """ from typing import Optional from fastapi import HTTPException, status -class UnprocessableContentException(HTTPException): +class UnprocessableContent(HTTPException): def __init__(self, message: Optional[str] = None) -> None: - detail = "Unprocessable content" if not message else message + detail = "Not authorized" if not message else message super().__init__( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail, ) -class ConflictException(HTTPException): +class Conflict(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 f2f6a34..a6398a9 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 ConflictException +from src.exceptions import Conflict 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 ConflictException("Group with this name already exists") + raise Conflict("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 ConflictException("Group already has this permission") + raise Conflict("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 ConflictException("User already in group") + raise Conflict("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 ConflictException(message="Permission already exists") + raise Conflict(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 ec8805c..51ee569 100644 --- a/src/organisation/dependencies.py +++ b/src/organisation/dependencies.py @@ -6,7 +6,6 @@ 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 @@ -18,14 +17,12 @@ from src.organisation.exceptions import OrgNotFoundException, AwaitingApprovalEx from src.organisation.constants import Status as OrgStatus -def get_org_model(db: Session, request: Request, org_id: int): +def get_org_model(db, request: Request, org_id: int): org_model = db.get(Org, org_id) if org_model is None: raise OrgNotFoundException(org_id) - 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"] + pre_approval_endpoints = ["PATCH/org/status", "PATCH/org/questionnaire", "GET/org/id", "GET/org/contact", "PATCH/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 1d7f3d3..97f3e85 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 +from typing import Annotated, Optional 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 contact.schemas import ContactModel +from src.exceptions import UnprocessableContent, Conflict 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.user.models import User +from 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, OrgPostOrgResponse, OrgPatchQuestionnaireResponse, \ - OrgPatchStatusResponse, OrgPostUserResponse, OrgPatchRootResponse + OrgGetGroupResponse, OrgDeleteUserRequest, OrgDeleteOrgRequest + router = APIRouter( prefix="/org", @@ -58,9 +58,6 @@ 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, @@ -76,7 +73,6 @@ 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."}, @@ -84,24 +80,21 @@ 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): - """ - 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. - """ + # TODO: Response model 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" + 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") + raise Conflict(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 @@ -110,56 +103,46 @@ 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_body_dependency, request_model: OrgPatchQuestionnaireRequest): +async def update_questionnaire(db: db_dependency, org_model: org_model_root_claim_query_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): - """ - Sets an organisation's status. This is the endpoint for approving or denying an organisation after reviewing the questionnaire. - """ + # TODO: Response model org_model.status = request_model.status - db.flush() - response = OrgPatchStatusResponse(**org_model.__dict__) + db.commit() - return response @router.get("/users", @@ -172,16 +155,12 @@ 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): - """ - Returns a list of the email addresses of all users of the organisation. - """ - return {"users": [user.email for user in org_model.user_rel]} + return {"users": [user.email for user in org_model.user_rel]} -@router.post("/user", - summary="Add user to the organisation.", +@router.post("/users", + summary="All 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."}, @@ -189,16 +168,11 @@ 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): - """ - Adds a user to the organisation. - """ + # TODO: response model if user_model in org_model.user_rel: - raise ConflictException(message="User already a part of this organisation") + raise Conflict(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("/", @@ -210,9 +184,6 @@ 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() @@ -220,23 +191,15 @@ 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): - """ - 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.") + # TODO: response model org_model.root_user_rel = user_model - db.flush() - response = OrgPatchRootResponse(**org_model.__dict__) db.commit() - return response @router.get("/groups", @@ -249,9 +212,6 @@ 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]} @@ -264,9 +224,7 @@ 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): - """ - Revokes a user's membership in an organisation. - """ + # TODO: response model if user_model not in org_model.user_rel: return @@ -283,10 +241,7 @@ 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(description="Must be billing|security|owner")]): - """ - Gets full details for a contact point at an organisation. - """ +async def get_contact(org_model: org_model_root_claim_query_dependency, contact_type: Annotated[ContactType, Query()]): match contact_type: case "billing": contact_model = org_model.billing_contact_rel @@ -295,7 +250,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 UnprocessableContentException("Invalid contact type") + raise UnprocessableContent("Invalid contact type") if contact_model is None: raise ContactNotFoundException() @@ -316,9 +271,6 @@ 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 @@ -327,7 +279,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 UnprocessableContentException("Invalid contact type") + raise UnprocessableContent("Invalid contact type") if contact_model is None: raise ContactNotFoundException() @@ -337,7 +289,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 UnprocessableContentException("Invalid keys in update request") + raise UnprocessableContent("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 3620e38..0fb13c5 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: Optional[str] = None - question_two: Optional[str] = None - question_three: Optional[str] = None + question_one: str + question_two: str + question_three: str class OrgIDMixin(CustomBaseModel): organisation_id: int @@ -30,26 +30,13 @@ 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 @@ -69,19 +56,12 @@ 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 d1a6a41..6c0bd41 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 ConflictException +from src.exceptions import Conflict 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 ConflictException(message="Service with this name already exists") + raise Conflict(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 3d42574..f65e045 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 UnprocessableContentException +from src.exceptions import UnprocessableContent 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 UnprocessableContentException("Invalid or missing OIDC data") + raise UnprocessableContent("Invalid or missing OIDC data") db = next(get_db()) db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first()