Compare commits

...

7 commits

Author SHA1 Message Date
53e01033c4 docs: org endpoint docstrings 2026-05-29 10:40:24 +01:00
8e8c00c34c feat: root user verification
New root users must already be members of the organisation.
2026-05-29 09:52:34 +01:00
da5099e172 minor: global exception names 2026-05-29 09:50:09 +01:00
4a97789c1a minor: global exception names 2026-05-29 09:47:37 +01:00
987a050b4b feat: org router response models 2026-05-29 09:44:24 +01:00
d404ab3ea3 fix: preapproval endpoints with new root path 2026-05-29 09:24:51 +01:00
90943c3d18 docs: org router query descriptions
Issue: #13
2026-05-28 16:52:10 +01:00
7 changed files with 117 additions and 42 deletions

View file

@ -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__(

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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]

View file

@ -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()

View file

@ -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()