From dd0478d5e7f17e13b11a6cc73b318801521fcfb6 Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 11 Jun 2026 09:36:51 +0100 Subject: [PATCH 1/6] docs: iam router Issue: #13 --- src/iam/router.py | 166 ++++++++++++++++++++++++++++++++++++++-------- test/test_iam.py | 2 +- 2 files changed, 138 insertions(+), 30 deletions(-) diff --git a/src/iam/router.py b/src/iam/router.py index 55971f6..810c2aa 100644 --- a/src/iam/router.py +++ b/src/iam/router.py @@ -2,18 +2,20 @@ Router endpoints for IAM Endpoints: - - [POST](/iam/can_act_on_resource): [API key & user claim]: Service access point to verify user permissions - - [GET](/iam/group/permissions): [root user]: Gets list of perms(service, resource, action) the given group(id) has - - [DELETE](/iam/group/permissions): [root user]: Removes a given perm(id) from the given group(id) - - [GET](/iam/group/users): [root user]: Gets a list of users(id, name, email) that are assigned to the given group(id) - - [POST](/iam/group): [root user]: Creates a new group for the given org(id) - - [PUT](/iam/group/permission): [root user]: Assigns a perm(id) to the given group(id) - - [PUT](/iam/group/user): [root user]: Assigns a user(id) to a group(id) - - [DELETE](/iam/group/user): [root user]: Removes a user(id) from the given group(id) - - [GET](/iam/permissions): [root user]: Gets a list of all permissions - - [POST](/iam/permission): [super admin]: Creates a new permission - - [DELETE](/iam/permission): [super admin]: Removes a permission - - [GET](/iam/permissions/search): [root user]: Returns a list of permissions matching a filter(service|resource|action) +- [POST](/api/v1/iam/can_act_on_resource): [API Key & User JWT]: Used for services to check user access permission +- [GET](/api/v1/iam/group/permissions): [Root User]: Gets a list of permissions granted to a group +- [GET](/api/v1/iam/group/users): [Root User]: Gets a list of users assigned to a group +- [POST](/api/v1/iam/group): [Root User]: Creates a new group +- [PUT](/api/v1/iam/group/permission): [Root User]: Grants a permission to a group +- [PUT](/api/v1/iam/group/user): [Root User]: Directly adds a user to the group +- [DELETE](/api/v1/iam/group/permissions): [Root User]: Removes a permission from the group +- [DELETE](/api/v1/iam/group/user): [Root User]: Removes a user from the group +- [GET](/api/v1/iam/permissions): [Root User]: Returns a full list of permissions +- [POST](/api/v1/iam/permission): [Super Admin]: Creates a new permission for a service +- [DELETE](/api/v1/iam/permission): [Super Admin]: Deletes a permission +- [POST](/api/v1/iam/permissions/search): [Root User]: Search list of permissions +- [PUT](/api/v1/iam/group/user/invitation): [Root User]: Send an email invitation for non-org member to join a group +- [PUT](/api/v1/iam/group/user//invitation/accept): [User Claim & Invite JWT]: Accept email invitation to join an org's group """ from fastapi import APIRouter, status, BackgroundTasks @@ -281,7 +283,21 @@ async def add_group_permission( return response -@router.put("/group/user", response_model=IAMPutGroupUserResponse) +@router.put( + path="/group/user", + summary="Directly adds a user to the group", + status_code=status.HTTP_200_OK, + response_model=IAMPutGroupUserResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: { + "description": "Group not in org | User not authenticated | User does not have permission" + }, + status.HTTP_409_CONFLICT: {"description": "User is already in group"}, + status.HTTP_403_FORBIDDEN: { + "description": "Only existing org members can be added directly." + }, + }, +) async def add_group_user( db: db_dependency, group_model: group_model_body_dependency, @@ -289,6 +305,11 @@ async def add_group_user( org_model: org_model_root_claim_body_dependency, request_model: IAMPutGroupUserRequest, ): + """ + Directly adds an organisation member to a group.\n + To add a non-member, use an email invitation instead.\n + The user's email address must match the email on their OIDC profile. + """ if group_model.org_id != org_model.id: raise UnauthorizedException("Group does not belong to this organization") @@ -309,13 +330,26 @@ async def add_group_user( return response -@router.delete("/group/permissions") +@router.delete( + path="/group/permissions", + summary="Removes a permission from the group", + status_code=status.HTTP_200_OK, + response_model=IAMDeleteGroupPermissionResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: { + "description": "Group not in org | User not authenticated | User does not have permission" + }, + }, +) async def remove_group_permissions( db: db_dependency, group_model: group_model_query_dependency, perm_model: perm_model_query_dependency, org_model: org_model_root_claim_query_dependency, ): + """ + Removes a permission from the group. + """ if group_model.org_id != org_model.id: raise UnauthorizedException("Group does not belong to this organization") @@ -329,13 +363,26 @@ async def remove_group_permissions( return response -@router.delete("/group/user") +@router.delete( + path="/group/user", + summary="Removes a user from the group", + status_code=status.HTTP_200_OK, + response_model=IAMDeleteGroupUserResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: { + "description": "Group not in org | User not authenticated | User does not have permission" + }, + }, +) async def remove_group_user( db: db_dependency, group_model: group_model_query_dependency, user_model: user_model_query_dependency, org_model: org_model_root_claim_query_dependency, ): + """ + Removes a user from the group. + """ if group_model.org_id != org_model.id: raise UnauthorizedException("Group does not belong to this organization") @@ -349,21 +396,47 @@ async def remove_group_user( return response -@router.get("/permissions", response_model=IAMGetPermissionsResponse) +@router.get( + path="/permissions", + summary="Returns a full list of permissions", + status_code=status.HTTP_200_OK, + response_model=IAMGetPermissionsResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: { + "description": "User must be root user of an organisation." + }, + }, +) async def get_permissions( db: db_dependency, org_model: org_model_root_claim_query_dependency ): + """ + Returns a full list of permissions. + """ permission_models = db.query(Perm).all() return {"permissions": permission_models} -@router.post("/permission", response_model=IAMPostPermissionResponse) +@router.post( + path="/permission", + summary="Creates a new permission for a service", + status_code=status.HTTP_201_CREATED, + response_model=IAMPostPermissionResponse, + responses={ + status.HTTP_401_UNAUTHORIZED: {"description": "Must be super user."}, + status.HTTP_404_NOT_FOUND: {"description": "Service does not exist"}, + status.HTTP_409_CONFLICT: {"description": "Permission already exists"}, + }, +) async def create_new_permission( db: db_dependency, su: super_admin_dependency, request_model: IAMPostPermissionRequest, ): + """ + Allows a super admin to create a new IAM permission for a service. + """ service_model = db.get(Service, request_model.service_id) if service_model is None: raise ServiceNotFoundException(service_id=request_model.service_id) @@ -387,22 +460,40 @@ async def create_new_permission( return {"permission": response} -@router.delete("/permission", status_code=status.HTTP_204_NO_CONTENT) +@router.delete( + path="/permission", + summary="Deletes a permission", + status_code=status.HTTP_204_NO_CONTENT, + responses={}, +) async def delete_permission( db: db_dependency, su: super_admin_dependency, perm_model: perm_model_query_dependency, ): + """ + Allows a super admin to remove a permission. + """ db.delete(perm_model) db.commit() -@router.post("/permissions/search", response_model=IAMGetPermissionsSearchResponse) +@router.post( + path="/permissions/search", + summary="Search list of permissions", + status_code=status.HTTP_200_OK, + response_model=IAMGetPermissionsSearchResponse, + responses={}, +) async def post_permissions( db: db_dependency, org_model: org_model_root_claim_body_dependency, request_model: IAMGetPermissionsSearchRequest, ): + """ + Returns a list of permissions filtered by the queries provided.\n + If a query is null, it will be ignored. + """ permission_query = db.query(Perm) if request_model.service_id is not None: @@ -424,9 +515,10 @@ async def post_permissions( @router.put( - "/group/user/invitation", + path="/group/user/invitation", summary="Send an email invitation for non-org member to join a group", status_code=status.HTTP_200_OK, + responses={}, ) async def invitation( background_tasks: BackgroundTasks, @@ -434,11 +526,17 @@ async def invitation( group_model: group_model_body_dependency, request_model: IAMPutGroupInvitationRequest, ): - org_id = org_model.id - org_name = org_model.name + """ + Sends an email invitation to join a group.\n + This is intended for inviting no-members to a group, giving them permission to access org resources.\n + i.e. Allowing somebody in a partner organisation to view metrics.\n + Can also be used for inviting organisaion members if needed. + """ + org_id: int = org_model.id + org_name: str = org_model.name user_email = request_model.user_email - group_id = group_model.id - group_name = group_model.name + group_id: int = group_model.id + group_name: str = group_model.name background_tasks.add_task( send_user_group_invitation, @@ -453,32 +551,42 @@ async def invitation( @router.put( - "/group/user//invitation/accept", + path="/group/user//invitation/accept", summary="Accept email invitation to join an org's group", status_code=status.HTTP_200_OK, + responses={ + status.HTTP_404_NOT_FOUND: {"description": "User|Org|Group not found"}, + status.HTTP_403_FORBIDDEN: { + "description": "Group and organisation do not match" + }, + status.HTTP_409_CONFLICT: {"description": "User is already in the group"}, + }, ) async def accept_invitation( db: db_dependency, user_model: user_model_claims_dependency, request_model: IAMPutGroupInvitationAcceptRequest, ): + """ + Accepts an invitation to join an org's group + """ email_claims = await verify_email_token( token=request_model.jwt, user_model=user_model ) org_model = db.get(Org, email_claims["org_id"]) if org_model is None: - raise OrgNotFoundException() + raise OrgNotFoundException(email_claims["org_id"]) group_model = db.get(Group, email_claims["group_id"]) if group_model is None: - raise GroupNotFoundException() + raise GroupNotFoundException(email_claims["group_id"]) if group_model not in org_model.group_rel: - raise UnauthorizedException("Group and org do not match.") + raise ForbiddenException("Group and org do not match.") if user_model in group_model.user_rel: - raise ConflictException(message="User already in group.") + raise ConflictException("User already in group.") group_model.user_rel.append(user_model) db.commit() diff --git a/test/test_iam.py b/test/test_iam.py index 5364e87..ab3b0df 100644 --- a/test/test_iam.py +++ b/test/test_iam.py @@ -587,7 +587,7 @@ async def test_post_perm_success(default_client: AsyncClient, db_session): "/iam/permission", json={"service_id": 1, "resource": "test_resource", "action": "create"}, ) - assert resp.status_code == 200 + assert resp.status_code == 201 data = resp.json() From c2680973064279e26a0383b2f3157a4a05343a1a Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 11 Jun 2026 09:57:34 +0100 Subject: [PATCH 2/6] feat: helper for generating module docstrings --- test/conftest.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 05c8174..dbd63d7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -167,11 +167,25 @@ def get_testable_routes(): if method in {"HEAD", "OPTIONS"}: continue - routes.append((method, route.path, route.status_code, route.response_model)) + routes.append( + ( + method, + route.path, + route.status_code, + route.response_model, + route.summary, + ) + ) return routes # with open("endpoints.txt", "w") as f: # for ep in get_testable_routes(): -# f.write(f"[{ep[0]}]{ep[1]}({ep[2]}) -> {ep[2]}: {ep[3]}\n") +# f.write(f"[{ep[0]}]({ep[1]}) -> {ep[2]}: {ep[3]}\n") +# +# +### Docstring formatted output ### +# with open("endpoints.txt", "w") as f: +# for ep in get_testable_routes(): +# f.write(f"- [{ep[0]}]({ep[1]}): []: {ep[4]}\n") From 0a7f9092c70596723f681f0ce9f61073a95c6fa3 Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 11 Jun 2026 12:24:36 +0100 Subject: [PATCH 3/6] feat: questionnaire shape update --- src/organisation/router.py | 41 +++++++++++++++++----- src/organisation/schemas.py | 16 ++++++--- src/organisation/schemas_questionnaires.py | 13 +++++++ test/conftest.py | 5 ++- test/test_auth_general.py | 5 ++- test/test_organisation.py | 20 +++++++---- test/test_user.py | 9 +++-- 7 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 src/organisation/schemas_questionnaires.py diff --git a/src/organisation/router.py b/src/organisation/router.py index 5c862ea..10f3521 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -16,6 +16,7 @@ Endpoints: - [PATCH](/org/contact): [root user]: Updates the (contact_type) contact for an org(id). Any number of details can be changed. """ +from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, status @@ -29,6 +30,7 @@ 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.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0 from src.user.dependencies import ( user_model_body_dependency, user_model_claims_dependency, @@ -64,6 +66,7 @@ from src.organisation.schemas import ( OrgPatchRootResponse, Questionnaire, OrgPatchContactResponse, + QuestionnaireMetadata, ) router = APIRouter( @@ -88,7 +91,9 @@ router = APIRouter( }, }, ) -async def get_org_by_id(org_model: org_model_root_claim_query_dependency): +async def get_org_by_id( + db: db_dependency, org_model: org_model_root_claim_query_dependency +): """ Returns organisation details including key member email addresses """ @@ -143,10 +148,21 @@ async def create_org( 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() + questionnaire_questions = request_model.intake_questionnaire.model_dump() else: - intake_questionnaire = None - org_model = Org(name=request_model.name, intake_questionnaire=intake_questionnaire) + questionnaire_questions = QuestionnaireQuestionsVersion0().model_dump() + + questionnaire_metadata = QuestionnaireMetadata(version=0, submission_date=None) + + intake_questionnaire = Questionnaire( + metadata=questionnaire_metadata, + questions=questionnaire_questions, + ) + + org_model = Org( + name=request_model.name, + intake_questionnaire=intake_questionnaire.model_dump(mode="json"), + ) org_model.status = "partial" @@ -204,18 +220,27 @@ async def update_questionnaire( final "are you sure" check before setting the org to be in "submitted" status, awaiting admin approval. """ update_data = request_model.intake_questionnaire.model_dump(exclude_none=True) - questionnaire_model = Questionnaire(**org_model.intake_questionnaire) + questionnaire = org_model.intake_questionnaire + questions_model = QuestionnaireQuestionsVersion0(**questionnaire["questions"]) for key, value in update_data.items(): - if hasattr(questionnaire_model, key): - setattr(questionnaire_model, key, value) + if hasattr(questions_model, key): + setattr(questions_model, key, value) else: raise UnprocessableContentException("Invalid keys in update request") + metadata = QuestionnaireMetadata(version=questionnaire["metadata"]["version"]) + # Allows for partially completed questionnaires to be saved without being submitted for review if not request_model.partial: org_model.status = "submitted" + metadata.submission_date = datetime.now(timezone.utc) - org_model.intake_questionnaire = questionnaire_model.model_dump() + questionnaire_model = Questionnaire( + metadata=metadata, + questions=questions_model, + ) + + org_model.intake_questionnaire = questionnaire_model.model_dump(mode="json") db.flush() response = OrgPatchQuestionnaireResponse(**org_model.__dict__) db.commit() diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index b4e9f23..2ea91c1 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -7,6 +7,7 @@ Models follow the nomenclature of: """ from typing import Optional +from datetime import datetime from pydantic import EmailStr, ConfigDict @@ -21,12 +22,17 @@ from src.schemas import ( from src.contact.schemas import ContactModel from src.organisation.constants import Status, ContactType +from src.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0 + + +class QuestionnaireMetadata(CustomBaseModel): + version: int + submission_date: Optional[datetime] = None class Questionnaire(CustomBaseModel): - question_one: Optional[str] = None - question_two: Optional[str] = None - question_three: Optional[str] = None + metadata: QuestionnaireMetadata + questions: QuestionnaireQuestionsVersion0 class ContactSummary(CustomBaseModel): @@ -47,7 +53,7 @@ class OrgSchema(OrgIDMixin): class OrgPostOrgRequest(CustomBaseModel): name: str - intake_questionnaire: Optional[Questionnaire] = None + intake_questionnaire: Optional[QuestionnaireQuestionsVersion0] = None class OrgPostOrgResponse(CustomBaseModel): @@ -57,7 +63,7 @@ class OrgPostOrgResponse(CustomBaseModel): class OrgPatchQuestionnaireRequest(OrgIDMixin): - intake_questionnaire: Questionnaire + intake_questionnaire: QuestionnaireQuestionsVersion0 partial: bool diff --git a/src/organisation/schemas_questionnaires.py b/src/organisation/schemas_questionnaires.py new file mode 100644 index 0000000..e9f7cfb --- /dev/null +++ b/src/organisation/schemas_questionnaires.py @@ -0,0 +1,13 @@ +from typing import Optional + +from src.schemas import CustomBaseModel + + +class QuestionnaireQuestions(CustomBaseModel): + pass + + +class QuestionnaireQuestionsVersion0(QuestionnaireQuestions): + question_one: Optional[str] = None + question_two: Optional[str] = None + question_three: Optional[str] = None diff --git a/test/conftest.py b/test/conftest.py index dbd63d7..1db0ad5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -103,7 +103,10 @@ def _seed(db): owner_contact_id=2, security_contact_id=3, status="approved", - intake_questionnaire={"question_two": "answer two"}, + intake_questionnaire={ + "metadata": {"version": 0, "submission_date": None}, + "questions": {"question_two": "answer two"}, + }, ) ) db.add(Service(name="Test Service", api_key="123456789")) diff --git a/test/test_auth_general.py b/test/test_auth_general.py index 4cc9ba5..a7f73a6 100644 --- a/test/test_auth_general.py +++ b/test/test_auth_general.py @@ -32,7 +32,10 @@ async def test_get_org_auth_root_su(default_client: AsyncClient, db_session): owner_contact_id=2, security_contact_id=3, status="approved", - intake_questionnaire={}, + intake_questionnaire={ + "metadata": {"version": 0, "submission_date": None}, + "questions": {}, + }, ) ) db_session.flush() diff --git a/test/test_organisation.py b/test/test_organisation.py index 364c753..ca7183c 100644 --- a/test/test_organisation.py +++ b/test/test_organisation.py @@ -103,9 +103,13 @@ async def test_patch_org_questionnaire_partial_success( assert data["status"] == "partial" assert "intake_questionnaire" in data assert isinstance(data["intake_questionnaire"], dict) - assert data["intake_questionnaire"]["question_one"] == "new answer one" - assert data["intake_questionnaire"]["question_two"] == "answer two" - assert data["intake_questionnaire"]["question_three"] is None + metadata = data["intake_questionnaire"]["metadata"] + assert metadata["version"] == 0 + assert metadata["submission_date"] is None + questions = data["intake_questionnaire"]["questions"] + assert questions["question_one"] == "new answer one" + assert questions["question_two"] == "answer two" + assert questions["question_three"] is None @pytest.mark.parametrize( @@ -172,9 +176,13 @@ async def test_patch_org_questionnaire_submit_success( assert data["status"] == "submitted" assert "intake_questionnaire" in data assert isinstance(data["intake_questionnaire"], dict) - assert data["intake_questionnaire"]["question_one"] == "new answer one" - assert data["intake_questionnaire"]["question_two"] == "answer two" - assert data["intake_questionnaire"]["question_three"] is None + metadata = data["intake_questionnaire"]["metadata"] + assert metadata["version"] == 0 + assert metadata["submission_date"] is not None + questions = data["intake_questionnaire"]["questions"] + assert questions["question_one"] == "new answer one" + assert questions["question_two"] == "answer two" + assert questions["question_three"] is None @pytest.mark.parametrize( diff --git a/test/test_user.py b/test/test_user.py index ad924f7..148ecae 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -161,9 +161,12 @@ async def test_get_self_orgs_dynamic(default_client: AsyncClient): "security_contact": {"email": "security@test.org", "id": 3}, "billing_contact": {"email": "billing@test.org", "id": 1}, "intake_questionnaire": { - "question_one": None, - "question_three": None, - "question_two": "answer two", + "questions": { + "question_one": None, + "question_three": None, + "question_two": "answer two", + }, + "metadata": {"version": 0, "submission_date": None}, }, } ] From 38c26cca7be203ab0e32b7115540d18dbb845c54 Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 11 Jun 2026 13:07:48 +0100 Subject: [PATCH 4/6] docs: iam module meta doc --- src/main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main.py b/src/main.py index 26dc6e0..bf671db 100644 --- a/src/main.py +++ b/src/main.py @@ -34,13 +34,17 @@ tags_metadata = [ "name": "User", "description": "User related operations, includes getting information about the current user", }, + { + "name": "Organisation", + "description": "Organisation related operations, includes getting lists of users etc associated with orgs", + }, { "name": "Service", "description": "Services related operations, includes registering services and reissuing API keys", }, { - "name": "Organisation", - "description": "Organisation related operations, includes getting lists of users etc associated with orgs", + "name": "IAM", + "description": "Operations related to the role based identity and access management system. This includes management of groups, permissions, and related users.", }, ] From 8925280f963aefcc4eacb96e1bc85750f08d1026 Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 11 Jun 2026 13:38:14 +0100 Subject: [PATCH 5/6] feat: questions union Allows responses to include questionnaire questions of multiple versions --- src/organisation/schemas.py | 11 +++++++---- src/organisation/schemas_questionnaires.py | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 2ea91c1..4641118 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -22,7 +22,10 @@ from src.schemas import ( from src.contact.schemas import ContactModel from src.organisation.constants import Status, ContactType -from src.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0 +from src.organisation.schemas_questionnaires import ( + QuestionnaireQuestionsVersion0 as CurrentQuestions, + questionnaire_union, +) class QuestionnaireMetadata(CustomBaseModel): @@ -32,7 +35,7 @@ class QuestionnaireMetadata(CustomBaseModel): class Questionnaire(CustomBaseModel): metadata: QuestionnaireMetadata - questions: QuestionnaireQuestionsVersion0 + questions: questionnaire_union class ContactSummary(CustomBaseModel): @@ -53,7 +56,7 @@ class OrgSchema(OrgIDMixin): class OrgPostOrgRequest(CustomBaseModel): name: str - intake_questionnaire: Optional[QuestionnaireQuestionsVersion0] = None + intake_questionnaire: Optional[CurrentQuestions] = None class OrgPostOrgResponse(CustomBaseModel): @@ -63,7 +66,7 @@ class OrgPostOrgResponse(CustomBaseModel): class OrgPatchQuestionnaireRequest(OrgIDMixin): - intake_questionnaire: QuestionnaireQuestionsVersion0 + intake_questionnaire: CurrentQuestions partial: bool diff --git a/src/organisation/schemas_questionnaires.py b/src/organisation/schemas_questionnaires.py index e9f7cfb..491dfe5 100644 --- a/src/organisation/schemas_questionnaires.py +++ b/src/organisation/schemas_questionnaires.py @@ -11,3 +11,6 @@ class QuestionnaireQuestionsVersion0(QuestionnaireQuestions): question_one: Optional[str] = None question_two: Optional[str] = None question_three: Optional[str] = None + + +questionnaire_union = QuestionnaireQuestionsVersion0 # | QuestionnaireQuestionsVersion1 From bcdef91dd0beadbf56e10582ce62d0b78df9563f Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 11 Jun 2026 14:14:31 +0100 Subject: [PATCH 6/6] feat: user invite response models --- src/user/router.py | 20 ++++++++++++++++++-- src/user/schemas.py | 12 +++++++++++- test/test_user.py | 10 +++++++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/user/router.py b/src/user/router.py index 9a3b78a..fe9dbef 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -17,6 +17,8 @@ from src.user.schemas import ( UserPostInvitationRequest, UserPostInvitationAcceptRequest, UserGetSelfOrgsResponse, + UserPostInvitationResponse, + UserPostInvitationAcceptResponse, ) from src.user.dependencies import ( user_model_claims_dependency, @@ -153,6 +155,7 @@ async def get_user_orgs(user_model: user_model_claims_dependency): "/invitation", summary="Send an email invitation for a user to join an org", status_code=status.HTTP_200_OK, + response_model=UserPostInvitationResponse, ) async def invitation( background_tasks: BackgroundTasks, @@ -167,13 +170,19 @@ async def invitation( send_invitation, org_id=org_id, org_name=org_name, user_email=user_email ) - return "Invitation sent" + response = { + "organisation": org_model, + "invited_email": user_email, + } + + return response @router.post( "/invitation/accept", summary="Accept email invitation to join an org", status_code=status.HTTP_200_OK, + response_model=UserPostInvitationAcceptResponse, ) async def accept_invitation( db: db_dependency, @@ -189,6 +198,13 @@ async def accept_invitation( raise OrgNotFoundException() org_model.user_rel.append(user_model) + db.flush() + + response = { + "organisation": org_model, + "user": user_model, + } + db.commit() - return "Invitation accepted" + return response diff --git a/src/user/schemas.py b/src/user/schemas.py index ddff869..65ab530 100644 --- a/src/user/schemas.py +++ b/src/user/schemas.py @@ -6,7 +6,7 @@ from typing import Optional from pydantic import EmailStr from src.organisation.schemas import OrgSchema -from src.schemas import CustomBaseModel, OrgIDMixin +from src.schemas import CustomBaseModel, OrgIDMixin, OrgSummary, UserSummary class OIDCClaims(CustomBaseModel): @@ -60,3 +60,13 @@ class UserPostInvitationAcceptRequest(CustomBaseModel): class UserGetSelfOrgsResponse(CustomBaseModel): organisations: list[OrgSchema] + + +class UserPostInvitationResponse(CustomBaseModel): + organisation: OrgSummary + invited_email: EmailStr + + +class UserPostInvitationAcceptResponse(CustomBaseModel): + organisation: OrgSummary + user: UserSummary diff --git a/test/test_user.py b/test/test_user.py index 148ecae..ab87aef 100644 --- a/test/test_user.py +++ b/test/test_user.py @@ -70,7 +70,15 @@ async def test_post_user_invitation_success(default_client: AsyncClient): resp = await default_client.post("/user/invitation", json=body) assert resp.status_code == 200 - assert resp.json() == "Invitation sent" + data = resp.json() + assert "organisation" in data + assert isinstance(data["organisation"], dict) + assert data["organisation"]["id"] == 1 + assert data["organisation"]["name"] == "Test Org" + + assert "invited_email" in data + assert isinstance(data["invited_email"], str) + assert data["invited_email"] == "admin@test.com" @pytest.mark.parametrize(