feat: org dependencies

Org endpoints use query/body model dependencies to perform initial db lookups.

Issue #6

Org ID path params have been replaced with either query params (get endpoints) or body values.

Resolves #10

Endpoints in other modules that rely on an org model lookup have also been updated.
This commit is contained in:
Chris Milne 2026-05-27 12:21:03 +01:00
parent c6a2b301dc
commit 657f91d73d
9 changed files with 106 additions and 74 deletions

View file

@ -22,7 +22,7 @@ from src.user.service import add_user_to_db
from src.organisation.models import OrgUsers, Organisation as Org from src.organisation.models import OrgUsers, Organisation as Org
from src.user.models import User from src.user.models import User
from src.database import db_dependency from src.database import db_dependency
from src.organisation.dependencies import org_model_dependency from src.organisation.dependencies import org_model_query_dependency
oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG) oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG)
@ -54,7 +54,7 @@ async def get_current_user(oidc_auth_string: oidc_dependency) -> dict[str, Any]:
try: try:
claims_requests.validate(token.claims) claims_requests.validate(token.claims)
except ExpiredTokenError as e: except ExpiredTokenError:
raise HTTPException(status_code=401, detail="Token expired") raise HTTPException(status_code=401, detail="Token expired")
db_id = await add_user_to_db(token.claims) db_id = await add_user_to_db(token.claims)
@ -93,7 +93,7 @@ async def is_org_user(claims: claims_dependency, db: db_dependency, org_id: int
org_user_dependency = Annotated[dict[str, Any], Depends(is_org_user)] org_user_dependency = Annotated[dict[str, Any], Depends(is_org_user)]
async def is_org_root(claims: claims_dependency, db: db_dependency, org_model: org_model_dependency, org_id: int = Path(gt=0)): async def is_org_root_query(claims: claims_dependency, db: db_dependency, org_model: org_model_query_dependency):
db_id = claims.get("db_id", None) db_id = claims.get("db_id", None)
if db_id is None: if db_id is None:
raise HTTPException(status_code=404, detail="User not found in db") raise HTTPException(status_code=404, detail="User not found in db")
@ -104,7 +104,7 @@ async def is_org_root(claims: claims_dependency, db: db_dependency, org_model: o
raise HTTPException(status_code=401, detail="Not authorised") raise HTTPException(status_code=401, detail="Not authorised")
root_user_dependency = Annotated[dict[str, Any], Depends(is_org_root)] root_user_query_dependency = Annotated[dict[str, Any], Depends(is_org_root_query)]
async def is_super_admin(claims: claims_dependency): async def is_super_admin(claims: claims_dependency):

View file

@ -5,9 +5,7 @@ Endpoints:
- List: Description - List: Description
- Endpoints: Description - Endpoints: Description
""" """
from typing import Annotated from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Query, HTTPException, status
from src.database import db_dependency from src.database import db_dependency
from src.iam.schemas import IAMGetGroupPermissionsResponse, IAMGetGroupUsersResponse, IAMPostGroupRequest, \ from src.iam.schemas import IAMGetGroupPermissionsResponse, IAMGetGroupUsersResponse, IAMPostGroupRequest, \
@ -21,7 +19,7 @@ from src.user.exceptions import UserNotFoundException
from src.user.models import User from src.user.models import User
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.service.models import Service from src.service.models import Service
from src.organisation.dependencies import org_model_dependency from src.organisation.dependencies import org_model_body_dependency
from src.iam.service import service_key_dependency from src.iam.service import service_key_dependency
from src.iam.models import Permission as Perm, GroupPermissions as GPerms, Group, UserGroups from src.iam.models import Permission as Perm, GroupPermissions as GPerms, Group, UserGroups
@ -66,22 +64,22 @@ async def can_act_on_resource(valid_key: service_key_dependency, db: db_dependen
@router.get("/group/permissions", response_model=IAMGetGroupPermissionsResponse) @router.get("/group/permissions", response_model=IAMGetGroupPermissionsResponse)
async def get_group_permissions(db: db_dependency, group_model: group_model_query_dependency): async def get_group_permissions(group_model: group_model_query_dependency):
# TODO: root_user_dependency # TODO: root_user_dependency
return {"permissions": group_model.permission_rel} return {"permissions": group_model.permission_rel}
@router.get("/group/users", response_model=IAMGetGroupUsersResponse) @router.get("/group/users", response_model=IAMGetGroupUsersResponse)
async def get_group_users(db: db_dependency, group_model: group_model_query_dependency): async def get_group_users(group_model: group_model_query_dependency):
# TODO: root_user_dependency # TODO: root_user_dependency
return {"users": group_model.user_rel} return {"users": group_model.user_rel}
@router.post("/group", response_model=IAMPostGroupResponse) @router.post("/group", response_model=IAMPostGroupResponse)
async def create_group(db: db_dependency, group_request: IAMPostGroupRequest, org_model: org_model_dependency, org_id: Annotated[int, Query(gt=0)]): async def create_group(db: db_dependency, request_model: IAMPostGroupRequest, org_model: org_model_body_dependency):
# TODO: root_user_dependency # TODO: root_user_dependency
# TODO: get org ID from dependency instead of query (needs updated dep first) # TODO: get org ID from dependency instead of query (needs updated dep first)
group_model = Group(name=group_request.name, org_id=org_id) group_model = Group(name=request_model.name, org_id=org_model.id)
db.add(group_model) db.add(group_model)
db.flush() db.flush()
@ -172,7 +170,7 @@ async def delete_permission(db: db_dependency, perm_model: perm_model_body_depen
@router.get("/permissions/search", response_model=IAMGetPermissionsSearchResponse) @router.get("/permissions/search", response_model=IAMGetPermissionsSearchResponse)
async def get_permissions(db: db_dependency, search: IAMGetPermissionsSearchRequest): async def get_permissions(db: db_dependency, search: IAMGetPermissionsSearchRequest):
# TODO: super_admin_dependency # TODO: root_user_dependency
permission_query = db.query(Perm) permission_query = db.query(Perm)
if search.service_id is not None: if search.service_id is not None:

View file

@ -9,9 +9,8 @@ from typing import Optional
from pydantic import EmailStr, ConfigDict from pydantic import EmailStr, ConfigDict
from src.organisation.schemas import OrgIDMixin
from src.schemas import CustomBaseModel from src.schemas import CustomBaseModel
from src.organisation.constants import Status, ContactType
from src.contact.schemas import ContactAddress
class UserResponse(CustomBaseModel): class UserResponse(CustomBaseModel):
id: int id: int
@ -42,7 +41,7 @@ class IAMGetGroupPermissionsResponse(CustomBaseModel):
class IAMGetGroupUsersResponse(CustomBaseModel): class IAMGetGroupUsersResponse(CustomBaseModel):
users : list[UserResponse] users : list[UserResponse]
class IAMPostGroupRequest(CustomBaseModel): class IAMPostGroupRequest(OrgIDMixin):
name: str name: str
class IAMPostGroupResponse(CustomBaseModel): class IAMPostGroupResponse(CustomBaseModel):

View file

@ -11,18 +11,33 @@ Functions:
""" """
from typing import Annotated from typing import Annotated
from fastapi import HTTPException, Depends from fastapi import Depends, Query
from src.database import db_dependency from src.database import db_dependency
from src.organisation.schemas import OrgIDMixin
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.organisation.exceptions import OrgNotFoundException
def get_org_model(db: db_dependency, org_id: int) -> type[Org]: def get_org_model_query(db: db_dependency, org_id: Annotated[int, Query(gt=0)]) -> type[Org]:
org_model = db.query(Org).filter(Org.id == org_id).first() org_model = db.get(Org, org_id)
if org_model is None: if org_model is None:
raise HTTPException(status_code=404, detail="Organisation not found") raise OrgNotFoundException(org_id)
return org_model return org_model
org_model_dependency = Annotated[type[Org], Depends(get_org_model)] org_model_query_dependency = Annotated[type[Org], Depends(get_org_model_query)]
def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> type[Org]:
org_id = getattr(request_model, "organisation_id", None)
if org_id is None:
raise OrgNotFoundException
org_model = db.get(Org, org_id)
if org_model is None:
raise OrgNotFoundException(org_id)
return org_model
org_model_body_dependency = Annotated[type[Org], Depends(get_org_model_body)]

View file

@ -5,3 +5,15 @@ Exceptions:
- List: Description - List: Description
- Exceptions: Description - Exceptions: Description
""" """
from typing import Optional
from fastapi import HTTPException, status
class OrgNotFoundException(HTTPException):
def __init__(self, org_id: Optional[int] = None) -> None:
detail = "Organisation not found" if org_id is None else f"User with ID '{org_id}' was not found."
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=detail,
)

View file

@ -15,23 +15,22 @@ Endpoints:
from typing import Annotated, Optional 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 Query
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.user.models import User from src.user.models import User
from src.user.exceptions import UserNotFoundException from src.user.exceptions import UserNotFoundException
from src.auth.service import root_user_dependency, claims_dependency from src.auth.service import root_user_query_dependency, claims_dependency
from src.organisation.dependencies import org_model_dependency from src.organisation.dependencies import org_model_query_dependency, org_model_body_dependency
from src.organisation.constants import ContactType from src.organisation.constants import ContactType
from src.organisation.models import Organisation as Org 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, OrgRootPatchRequest, \ OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse, OrgRootPatchRequest, \
OrgGroupGetResponse, OrgUserDeleteRequest OrgGroupGetResponse, OrgUserDeleteRequest, OrgDeleteOrgRequest
router = APIRouter( router = APIRouter(
prefix="/org", prefix="/org",
@ -39,8 +38,8 @@ router = APIRouter(
) )
@router.get("/id/{org_id}", response_model=OrgOrgGetResponse) @router.get("/id", response_model=OrgOrgGetResponse)
async def get_org_by_id(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): async def get_org_by_id(org_model: org_model_query_dependency):
response = { response = {
"name": org_model.name, "name": org_model.name,
"status": org_model.status, "status": org_model.status,
@ -54,12 +53,16 @@ async def get_org_by_id(org_model: org_model_dependency, org_id: Annotated[int,
@router.post("/") @router.post("/")
async def create_org(db: db_dependency, user: claims_dependency, org_request: OrgOrgPostRequest): async def create_org(db: db_dependency, user: claims_dependency, request_model: OrgOrgPostRequest):
db_id: Optional[int] = user.get("db_id", None) db_id: Optional[int] = user.get("db_id", None)
if db_id is None: if db_id is None:
raise UserNotFoundException() raise UserNotFoundException()
org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire.model_dump()) 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" # Status is always set to partial at first, see update_questionnaire() doc
@ -77,67 +80,70 @@ async def create_org(db: db_dependency, user: claims_dependency, org_request: Or
db.commit() db.commit()
@router.patch("/{org_id}/questionnaire") @router.patch("/questionnaire")
async def update_questionnaire(db: db_dependency, org_model: org_model_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]): async def update_questionnaire(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgQuestionnairePatchRequest):
""" """
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.intake_questionnaire = q_request.intake_questionnaire.model_dump() org_model.intake_questionnaire = request_model.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 request_model.partial:
org_model.status = "submitted" org_model.status = "submitted"
db.commit() db.commit()
@router.patch("/{org_id}/status") @router.patch("/status")
async def update_status(db: db_dependency, org_model: org_model_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]): async def update_status(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgStatusPatchRequest):
org_model.status = status_request.status org_model.status = request_model.status
db.commit() db.commit()
@router.get("/{org_id}/users", response_model=OrgUserGetResponse) @router.get("/users", response_model=OrgUserGetResponse)
async def get_users(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): async def get_users(org_model: org_model_query_dependency):
return {"users": [user.email for user in org_model.user_rel]} return {"users": [user.email for user in org_model.user_rel]}
@router.post("/{org_id}/users") @router.post("/users")
async def add_user_to_org(db: db_dependency, org_model: org_model_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): async def add_user_to_org(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgUserPostRequest):
user_model = db.get(User, user_request.user_id) # TODO: user_model_body_dependency
user_model = db.get(User, request_model.user_id)
if user_model in org_model.user_rel: if user_model in org_model.user_rel:
return return
org_model.user_rel.append(user_model) org_model.user_rel.append(user_model)
db.commit() db.commit()
@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
async def delete_organisation_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): async def delete_organisation_by_id(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgDeleteOrgRequest):
db.delete(org_model) db.delete(org_model)
db.commit() db.commit()
@router.patch("/{org_id}/root_user", status_code=status.HTTP_204_NO_CONTENT) @router.patch("/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)], user_request: OrgRootPatchRequest): async def update_root_user(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgRootPatchRequest):
root_user_model = db.get(User, user_request.user_id) # TODO: user_model_body_dependency
root_user_model = db.get(User, request_model.user_id)
if root_user_model is None: if root_user_model is None:
raise UserNotFoundException(user_id=user_request.user_id) raise UserNotFoundException(user_id=request_model.user_id)
org_model.root_user_rel = root_user_model org_model.root_user_rel = root_user_model
db.commit() db.commit()
@router.get("/{org_id}/groups", response_model=OrgGroupGetResponse) @router.get("/groups", response_model=OrgGroupGetResponse)
async def get_org_groups(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): async def get_org_groups(org_model: org_model_query_dependency):
return {"groups": [group.name for group in org_model.group_rel]} return {"groups": [group.name for group in org_model.group_rel]}
@router.delete("/{org_id}/user", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/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_request: OrgUserDeleteRequest): async def remove_user_from_org(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgUserDeleteRequest):
user_id = user_request.user_id # TODO: user_model_body_dependency
user_id = request_model.user_id
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None: if user is None:
@ -149,8 +155,9 @@ async def remove_user_from_org(db: db_dependency, org_model: org_model_dependenc
org_model.user_rel.remove(user) org_model.user_rel.remove(user)
db.commit() db.commit()
@router.get("/{org_id}/contact", response_model=OrgContactGetResponse)
async def get_contact(org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): @router.get("/contact", response_model=OrgContactGetResponse)
async def get_contact(org_model: org_model_query_dependency, contact_type: Annotated[ContactType, Query()]):
match contact_type: match contact_type:
case "billing": case "billing":
contact_model = org_model.billing_contact_rel contact_model = org_model.billing_contact_rel
@ -170,10 +177,9 @@ async def get_contact(org_model: org_model_dependency, contact_type: Annotated[C
) )
@router.patch("/contact", response_model=OrgContactGetResponse)
@router.patch("/{org_id}/contact", response_model=OrgContactGetResponse) async def update_contact(db: db_dependency, org_model: org_model_body_dependency, request_model: OrgContactPatchRequest):
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 request_model.contact_type:
match contact_type:
case "billing": case "billing":
contact_model = org_model.billing_contact_rel contact_model = org_model.billing_contact_rel
case "security": case "security":
@ -186,7 +192,7 @@ async def update_contact(db: db_dependency, org_model: org_model_dependency, con
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")
update_data = contact_request.model_dump(exclude_none=True) update_data = request_model.model_dump(exclude_none=True)
for key, value in update_data.items(): for key, value in update_data.items():
if hasattr(contact_model, key): if hasattr(contact_model, key):
setattr(contact_model, key, value) setattr(contact_model, key, value)

View file

@ -18,19 +18,23 @@ class OrgQuestionnaire(CustomBaseModel):
question_two: str question_two: str
question_three: str question_three: str
class OrgIDMixin(CustomBaseModel):
organisation_id: int
class OrgOrgPostRequest(CustomBaseModel): class OrgOrgPostRequest(CustomBaseModel):
name: str name: str
intake_questionnaire: Optional[OrgQuestionnaire] = None intake_questionnaire: Optional[OrgQuestionnaire] = None
class OrgQuestionnairePatchRequest(CustomBaseModel): class OrgQuestionnairePatchRequest(OrgIDMixin):
intake_questionnaire: OrgQuestionnaire intake_questionnaire: OrgQuestionnaire
partial: bool partial: bool
class OrgStatusPatchRequest(CustomBaseModel): class OrgStatusPatchRequest(OrgIDMixin):
status: Status status: Status
class OrgContactPatchRequest(CustomBaseModel): class OrgContactPatchRequest(OrgIDMixin):
contact_type: ContactType
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
@ -44,13 +48,13 @@ class OrgContactPatchRequest(CustomBaseModel):
country_code: Optional[str] = None country_code: Optional[str] = None
postal_code: Optional[str] = None postal_code: Optional[str] = None
class OrgUserPostRequest(CustomBaseModel): class OrgUserPostRequest(OrgIDMixin):
user_id: int user_id: int
class OrgUserDeleteRequest(CustomBaseModel): class OrgUserDeleteRequest(OrgIDMixin):
user_id: int user_id: int
class OrgRootPatchRequest(CustomBaseModel): class OrgRootPatchRequest(OrgIDMixin):
user_id: int user_id: int
class OrgUserGetResponse(CustomBaseModel): class OrgUserGetResponse(CustomBaseModel):
@ -77,3 +81,6 @@ class OrgOrgGetResponse(CustomBaseModel):
owner_contact: Optional[str] = None owner_contact: Optional[str] = None
billing_contact: Optional[str] = None billing_contact: Optional[str] = None
security_contact: Optional[str] = None security_contact: Optional[str] = None
class OrgDeleteOrgRequest(OrgIDMixin):
pass

View file

@ -5,13 +5,9 @@ Models:
- List: Description - List: Description
- Models: Description - Models: Description
""" """
from typing import Optional from pydantic import ConfigDict
from pydantic import EmailStr, ConfigDict
from src.schemas import CustomBaseModel from src.schemas import CustomBaseModel
from src.organisation.constants import Status, ContactType
from src.contact.schemas import ContactAddress
class ServiceResponse(CustomBaseModel): class ServiceResponse(CustomBaseModel):
model_config = ConfigDict(from_attributes=True, extra="ignore") model_config = ConfigDict(from_attributes=True, extra="ignore")

View file

@ -10,7 +10,6 @@ from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database import Base from src.database import Base
from src.iam.models import Group
class User(Base): class User(Base):