feat: org router refactor

- All TODOs done.
- org_model_dependency used for all applicable routes
- ORM relationships used to reduce number of queries being made and simplify endpoint code.
- Missing request and response models added.
- Small bug fixes
This commit is contained in:
Chris Milne 2026-05-25 16:54:45 +01:00
parent 2b6d923ae1
commit b3689c8af6
2 changed files with 81 additions and 84 deletions

View file

@ -12,25 +12,26 @@ Endpoints:
- [delete]/{org_id} - Deletes an organisation by ID - [delete]/{org_id} - Deletes an organisation by ID
- [get]/{org_id}/contact/{contact_type} - Retrieves the contact of a specific type (owner, billing, security) for an organisation - [get]/{org_id}/contact/{contact_type} - Retrieves the contact of a specific type (owner, billing, security) for an organisation
""" """
from typing import Annotated from typing import Annotated, Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from fastapi.params import Path, Query from fastapi.params import Path, Query
from sqlalchemy.sql import exists
from src.contact.schemas import ContactAddress from src.contact.schemas import ContactAddress
from src.database import db_dependency from src.database import db_dependency
from src.contact.models import Contact from src.contact.models import Contact
from src.iam.models import Group from src.user.models import User
from src.auth.service import root_user_dependency from src.user.exceptions import UserNotFoundException
from src.auth.service import root_user_dependency, claims_dependency
from src.organisation.dependencies import org_model_dependency from src.organisation.dependencies import org_model_dependency
from src.organisation.constants import ContactType from src.organisation.constants import ContactType
from src.organisation.models import Organisation as Org, OrgUsers from src.organisation.models import Organisation as Org
from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \ from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \
OrgContactPatchRequest, \ OrgContactPatchRequest, \
OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse, OrgRootPatchRequest, \
OrgGroupGetResponse, OrgUserDeleteRequest
router = APIRouter( router = APIRouter(
prefix="/org", prefix="/org",
@ -39,7 +40,7 @@ router = APIRouter(
@router.get("/id/{org_id}", response_model=OrgOrgGetResponse) @router.get("/id/{org_id}", response_model=OrgOrgGetResponse)
async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): async def get_org_by_id(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]):
response = { response = {
"name": org_model.name, "name": org_model.name,
"status": org_model.status, "status": org_model.status,
@ -53,14 +54,21 @@ async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_
@router.post("/") @router.post("/")
async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): async def create_org(db: db_dependency, user: claims_dependency, org_request: OrgOrgPostRequest):
# TODO: Root user from current user db_id: Optional[int] = user.get("db_id", None)
org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire) if db_id is None:
raise UserNotFoundException()
org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire.model_dump())
org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc
db.add(org_model) db.add(org_model)
db.flush() db.flush()
# Adds currently logged-in user to org users list and sets them as root_user
user_model = db.get(User, db_id)
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"]: for contact_type in ["billing_contact_id", "security_contact_id", "owner_contact_id"]:
contact_model = Contact(org_id=org_model.id) contact_model = Contact(org_id=org_model.id)
db.add(contact_model) db.add(contact_model)
@ -70,139 +78,111 @@ async def create_org(db: db_dependency, org_request: OrgOrgPostRequest):
@router.patch("/{org_id}/questionnaire") @router.patch("/{org_id}/questionnaire")
async def update_questionnaire(db: db_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]): async def update_questionnaire(db: db_dependency, org_model: org_model_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]):
""" """
Route for updating questionnaire. Route for updating questionnaire.
The partial bool allows for submission of partially completed questionnaire and/or 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. final "are you sure" check before setting the org to be in "submitted" status, awaiting admin approval.
""" """
org_model = db.query(Org).filter(Org.id == org_id).first()
if org_model is None:
raise HTTPException(status_code=404, detail="Organisation not found")
org_model.intake_questionnaire = q_request.intake_questionnaire.model_dump() org_model.intake_questionnaire = q_request.intake_questionnaire.model_dump()
# Allows for partially completed questionnaires to be saved without being submitted for review # Allows for partially completed questionnaires to be saved without being submitted for review
if not q_request.partial: if not q_request.partial:
org_model.status = "submitted" org_model.status = "submitted"
db.add(org_model)
db.commit() db.commit()
@router.patch("/{org_id}/status") @router.patch("/{org_id}/status")
async def update_status(db: db_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]): async def update_status(db: db_dependency, org_model: org_model_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]):
org_model = db.query(Org).filter(Org.id == org_id).first()
if org_model is None:
raise HTTPException(status_code=404, detail="Organisation not found")
org_model.status = status_request.status org_model.status = status_request.status
db.add(org_model)
db.commit() db.commit()
@router.get("/{org_id}/users", response_model=list[OrgUserGetResponse]) @router.get("/{org_id}/users", response_model=OrgUserGetResponse)
async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): async def get_users(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]):
org_exists = db.query(exists().where(Org.id == org_id)).scalar() return {"users": [user.email for user in org_model.user_rel]}
if not org_exists:
raise HTTPException(status_code=404, detail="Organisation not found")
org_user_models = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).all()
return org_user_models
@router.post("/{org_id}/users") @router.post("/{org_id}/users")
async def add_user_to_org(db: db_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): async def add_user_to_org(db: db_dependency, org_model: org_model_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]):
org_model = (db.query(Org).filter(Org.id == org_id).first()) user_model = db.get(User, user_request.user_id)
if org_model is None: if user_model in org_model.user_rel:
raise HTTPException(status_code=404, detail="Organisation not found") return
org_model.user_rel.append(user_model)
org_user_model = OrgUsers(**user_request.model_dump(), org_id=org_id)
db.add(org_user_model)
db.commit() db.commit()
@router.delete("/{org_id}") @router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_organisation_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): async def delete_organisation_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]):
org_model = (db.query(Org).filter(Org.id == org_id).first())
if org_model is None:
raise HTTPException(status_code=404, detail="Organisation not found")
db.delete(org_model) db.delete(org_model)
db.commit() db.commit()
@router.patch("/{org_id}/root_user") @router.patch("/{org_id}/root_user", status_code=status.HTTP_204_NO_CONTENT)
async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_request: OrgRootPatchRequest):
# TODO: Request model, ditch query root_user_model = db.get(User, user_request.user_id)
# TODO: Verify root_user exists, possibly with a user_model_dependency if root_user_model is None:
org_model.root_user_id = root_user raise UserNotFoundException(user_id=user_request.user_id)
db.add(org_model)
org_model.root_user_rel = root_user_model
db.commit() db.commit()
# TODO: Response model
@router.get("/{org_id}/groups") @router.get("/{org_id}/groups", response_model=OrgGroupGetResponse)
async def get_org_groups(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): async def get_org_groups(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]):
org_group_models = db.query(Group).filter(Group.org_id == org_id).all() return {"groups": [group.name for group in org_model.group_rel]}
# TODO: Response model
return org_group_models
@router.delete("/{org_id}/user") @router.delete("/{org_id}/user", status_code=status.HTTP_204_NO_CONTENT)
async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_id: Annotated[int, Query(gt=0)]): async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_request: OrgUserDeleteRequest):
orguser_model = db.query(OrgUsers).filter(OrgUsers.org_id == org_id, OrgUsers.user_id == user_id).first() user_id = user_request.user_id
user = db.get(User, user_id)
if orguser_model is None: if user is None:
raise HTTPException(status_code=status.HTTP_204_NO_CONTENT) raise UserNotFoundException(user_id=user_id)
db.delete(orguser_model) if user not in org_model.user_rel:
raise HTTPException(status_code=status.HTTP_204_NOT_FOUND)
org_model.user_rel.remove(user)
db.commit() db.commit()
pass
@router.get("/{org_id}/contact", response_model=OrgContactGetResponse) @router.get("/{org_id}/contact", response_model=OrgContactGetResponse)
async def get_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): async def get_contact(org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]):
match contact_type: match contact_type:
case "billing": case "billing":
contact_id = org_model.billing_contact_id contact_model = org_model.billing_contact_rel
case "security": case "security":
contact_id = org_model.security_contact_id contact_model = org_model.security_contact_rel
case "owner": case "owner":
contact_id = org_model.owner_contact_id contact_model = org_model.owner_contact_rel
case _: case _:
raise HTTPException(status_code=422, detail="Invalid contact type") raise HTTPException(status_code=422, detail="Invalid contact type")
contact_model = (db.query(Contact).filter(Contact.id == contact_id).first())
if contact_model is None: if contact_model is None:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
address = ContactAddress.model_validate(contact_model) return OrgContactGetResponse.model_construct(
response = OrgContactGetResponse.model_construct(
**contact_model.__dict__, **contact_model.__dict__,
address=address address=ContactAddress.model_validate(contact_model)
) )
return response
@router.patch("/{org_id}/contact", response_model=OrgContactGetResponse)
@router.patch("/{org_id}/contact")
async def update_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): async def update_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]):
match contact_type: match contact_type:
case "billing": case "billing":
contact_id = org_model.billing_contact_id contact_model = org_model.billing_contact_rel
case "security": case "security":
contact_id = org_model.security_contact_id contact_model = org_model.security_contact_rel
case "owner": case "owner":
contact_id = org_model.owner_contact_id contact_model = org_model.owner_contact_rel
case _: case _:
raise HTTPException(status_code=422, detail="Invalid contact type") raise HTTPException(status_code=422, detail="Invalid contact type")
contact_model = (db.query(Contact).filter(Contact.id == contact_id).first())
if contact_model is None: if contact_model is None:
raise HTTPException(status_code=404, detail="Contact not found") raise HTTPException(status_code=404, detail="Contact not found")
@ -212,5 +192,13 @@ async def update_contact(db: db_dependency, org_model: org_model_dependency, con
setattr(contact_model, key, value) setattr(contact_model, key, value)
else: else:
raise HTTPException(status_code=422, detail="Invalid keys in update request") raise HTTPException(status_code=422, detail="Invalid keys in update request")
db.add(org_model) db.flush()
db.commit()
response = OrgContactGetResponse.model_construct(
**contact_model.__dict__,
address=ContactAddress.model_validate(contact_model)
)
db.commit()
return response

View file

@ -47,9 +47,18 @@ class OrgContactPatchRequest(CustomBaseModel):
class OrgUserPostRequest(CustomBaseModel): class OrgUserPostRequest(CustomBaseModel):
user_id: int user_id: int
class OrgUserGetResponse(CustomBaseModel): class OrgUserDeleteRequest(CustomBaseModel):
user_id: int user_id: int
class OrgRootPatchRequest(CustomBaseModel):
user_id: int
class OrgUserGetResponse(CustomBaseModel):
users: list[str]
class OrgGroupGetResponse(CustomBaseModel):
groups: list[str]
class OrgContactGetResponse(CustomBaseModel): class OrgContactGetResponse(CustomBaseModel):
model_config = ConfigDict(from_attributes=True, extra="ignore") model_config = ConfigDict(from_attributes=True, extra="ignore")