From 7bda03f8677286ef56557c60d614eed6711aed41 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 10 Jul 2026 14:45:39 +0545 Subject: [PATCH 1/2] feat(team): add team member bulk create mutation --- apps/teams/graphql/inputs.py | 5 ++ apps/teams/graphql/mutations.py | 9 ++ apps/teams/tests/mutations_test.py | 137 +++++++++++++++++++++++++++++ utils/graphql/mutations.py | 39 ++++++++ 4 files changed, 190 insertions(+) diff --git a/apps/teams/graphql/inputs.py b/apps/teams/graphql/inputs.py index 5b96e5b..e077d1a 100644 --- a/apps/teams/graphql/inputs.py +++ b/apps/teams/graphql/inputs.py @@ -30,6 +30,11 @@ class TeamMemberCreateInput: order: int = 0 +@strawberry.input +class TeamMemberBulkCreateInput: + members: list[TeamMemberCreateInput] + + @strawberry.input class TeamMemberUpdateInput: team: strawberry.ID | None = strawberry.UNSET diff --git a/apps/teams/graphql/mutations.py b/apps/teams/graphql/mutations.py index 2b5d48f..9edb3f1 100644 --- a/apps/teams/graphql/mutations.py +++ b/apps/teams/graphql/mutations.py @@ -10,6 +10,7 @@ from .inputs import ( TeamCreateInput, + TeamMemberBulkCreateInput, TeamMemberCreateInput, TeamMemberUpdateInput, TeamUpdateInput, @@ -83,3 +84,11 @@ async def delete_team( instance = await Team.objects.aget(id=id) await instance.adelete() return MutationResponseType(ok=True) + + @strawberry_django.mutation(permission_classes=[IsStaffOrAbove]) + async def bulk_create_team_members( + self, + info: Info, + data: TeamMemberBulkCreateInput, + ) -> MutationResponseType[list[TeamMemberType]]: + return await ModelMutation(TeamMemberSerializer).handle_bulk_create_mutation(data.members, info) diff --git a/apps/teams/tests/mutations_test.py b/apps/teams/tests/mutations_test.py index 9997c36..4c8c14a 100644 --- a/apps/teams/tests/mutations_test.py +++ b/apps/teams/tests/mutations_test.py @@ -1,5 +1,8 @@ import typing +from apps.geo.factories import RegionFactory +from apps.teams.factories import TeamFactory +from apps.teams.models import TeamMember from apps.users.factories import UserFactory from apps.users.models import User from main.tests import TestCase @@ -39,6 +42,31 @@ class Mutation: } """ + BULK_CREATE_TEAM_MEMBERS = """ + mutation BulkCreateTeamMembers($data: TeamMemberBulkCreateInput!) { + bulkCreateTeamMembers(data: $data) { + ... on TeamMemberTypeListMutationResponseType { + ok + errors + result { + id + teamId + name + position + email + phoneNumber + sex + region + woreda + training + fieldOfStudy + order + } + } + } + } + """ + @typing.override @classmethod def setUpClass(cls): @@ -82,3 +110,112 @@ def test_update_team(self): resp = content["data"]["updateTeam"] assert resp["ok"] is True assert resp["result"]["name"] == "New Name" + + def test_bulk_create_team_members_all_fields(self): + self.force_login(self.staff) + team = TeamFactory.create() + region = RegionFactory.create() + members = [ + { + "team": self.gID(team.pk), + "name": f"Member {i}", + "position": "Volunteer", + "email": f"member{i}@example.com", + "phoneNumber": f"+25190000000{i}", + "sex": self.genum(TeamMember.Sex.FEMALE), + "region": self.gID(region.pk), + "training": "First Aid", + "fieldOfStudy": "Public Health", + "order": i, + } + for i in range(3) + ] + content = self.query_check( + self.Mutation.BULK_CREATE_TEAM_MEMBERS, + variables={"data": {"members": members}}, + ) + resp = content["data"]["bulkCreateTeamMembers"] + assert resp["ok"] is True + assert resp["errors"] is None + assert len(resp["result"]) == 3 + assert TeamMember.objects.count() == 3 + first = resp["result"][0] + assert first["name"] == "Member 0" + assert first["teamId"] == self.gID(team.pk) + assert first["region"] == self.gID(region.pk) + assert first["sex"] == self.genum(TeamMember.Sex.FEMALE) + + def test_bulk_create_team_members_minimal_required_fields(self): + self.force_login(self.staff) + team = TeamFactory.create() + members = [ + {"team": self.gID(team.pk), "name": "Only Required", "position": "Lead"}, + ] + content = self.query_check( + self.Mutation.BULK_CREATE_TEAM_MEMBERS, + variables={"data": {"members": members}}, + ) + resp = content["data"]["bulkCreateTeamMembers"] + assert resp["ok"] is True + assert len(resp["result"]) == 1 + assert TeamMember.objects.count() == 1 + + def test_bulk_create_empty_list(self): + self.force_login(self.staff) + content = self.query_check( + self.Mutation.BULK_CREATE_TEAM_MEMBERS, + variables={"data": {"members": []}}, + ) + resp = content["data"]["bulkCreateTeamMembers"] + assert resp["ok"] is True + assert resp["result"] == [] + assert TeamMember.objects.count() == 0 + + def test_bulk_create_is_atomic_when_a_row_is_invalid(self): + self.force_login(self.staff) + team = TeamFactory.create() + members = [ + {"team": self.gID(team.pk), "name": "Valid", "position": "Lead"}, + # schema-valid but serializer-invalid (bad email) -> whole batch must fail + {"team": self.gID(team.pk), "name": "Invalid", "position": "Lead", "email": "not-an-email"}, + ] + content = self.query_check( + self.Mutation.BULK_CREATE_TEAM_MEMBERS, + variables={"data": {"members": members}}, + ) + resp = content["data"]["bulkCreateTeamMembers"] + assert resp["ok"] is False + assert resp["errors"] is not None + assert resp["result"] is None + # atomicity: the valid row must NOT have been persisted + assert TeamMember.objects.count() == 0 + + def test_bulk_create_with_invalid_team_fails(self): + self.force_login(self.staff) + members = [ + { + "team": "00000000-0000-0000-0000-000000000000", + "name": "Orphan", + "position": "Lead", + }, + ] + content = self.query_check( + self.Mutation.BULK_CREATE_TEAM_MEMBERS, + variables={"data": {"members": members}}, + ) + resp = content["data"]["bulkCreateTeamMembers"] + assert resp["ok"] is False + assert resp["errors"] is not None + assert TeamMember.objects.count() == 0 + + def test_viewer_cannot_bulk_create_team_members(self): + self.force_login(self.viewer) + team = TeamFactory.create() + members = [{"team": self.gID(team.pk), "name": "Blocked", "position": "Lead"}] + content = self.query_check( + self.Mutation.BULK_CREATE_TEAM_MEMBERS, + assert_errors=True, + variables={"data": {"members": members}}, + ) + assert "errors" in content + assert TeamMember.objects.count() == 0 diff --git a/utils/graphql/mutations.py b/utils/graphql/mutations.py index 69050d1..b1697f7 100644 --- a/utils/graphql/mutations.py +++ b/utils/graphql/mutations.py @@ -69,6 +69,45 @@ async def handle_create_mutation( return MutationResponseType(ok=False, errors=errors) return MutationResponseType(result=saved_instance) + @staticmethod + @sync_to_async + def handle_bulk_mutation( + serializer_class: type[serializers.Serializer], + data_list: list[typing.Any], + info: Info, + extra_context: dict[typing.Any, typing.Any] | None, + ) -> tuple[CustomErrorType | None, list[models.Model] | None]: + context = _get_serializer_context(info, extra_context=extra_context) + validated_serializers = [] + for data in data_list: + serializer = serializer_class(data=data, context=context) + if errors := mutation_is_not_valid(serializer): + return errors, None + validated_serializers.append(serializer) + try: + with transaction.atomic(): + instances = [serializer.save() for serializer in validated_serializers] + except Exception: + logger.error("Failed to handle bulk mutation", exc_info=True) + return MutationCustomErrorType.generate_message(), None + return None, instances + + async def handle_bulk_create_mutation( + self, + data: typing.Any, + info: Info, + extra_context: dict[typing.Any, typing.Any] | None = None, + ) -> MutationResponseType: # type: ignore[reportMissingTypeArgument] + errors, saved_instances = await self.handle_bulk_mutation( + self.serializer_class, + parse_input_data(data), + info, + extra_context, + ) + if errors: + return MutationResponseType(ok=False, errors=errors) + return MutationResponseType(result=saved_instances) + async def handle_update_mutation( self, data: typing.Any, From 62c4e94f95c8115bba5f31537c3a57fc8488edb3 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 10 Jul 2026 15:36:14 +0545 Subject: [PATCH 2/2] feat(team): add import team member template - Add a query to download import template --- apps/teams/graphql/queries.py | 9 +++++++++ main/settings.py | 2 ++ schema.graphql | 14 ++++++++++++++ static/files/create-team-member-template.xlsx | Bin 0 -> 112073 bytes 4 files changed, 25 insertions(+) create mode 100644 static/files/create-team-member-template.xlsx diff --git a/apps/teams/graphql/queries.py b/apps/teams/graphql/queries.py index 6fb3b3f..150f7df 100644 --- a/apps/teams/graphql/queries.py +++ b/apps/teams/graphql/queries.py @@ -1,5 +1,6 @@ import strawberry import strawberry_django +from django.contrib.staticfiles.storage import staticfiles_storage from strawberry_django.pagination import OffsetPaginated from main.graphql.permissions import IsAuthenticated @@ -30,3 +31,11 @@ class Query: team_member: TeamMemberType = strawberry_django.field( permission_classes=[IsAuthenticated], ) + + @strawberry.field + def create_team_member_template(self, info: strawberry.Info) -> str: + request = info.context.request + + return request.build_absolute_uri( + staticfiles_storage.url("files/create-team-member-template.xlsx"), + ) diff --git a/main/settings.py b/main/settings.py index db8410b..9c4ccb7 100644 --- a/main/settings.py +++ b/main/settings.py @@ -181,6 +181,8 @@ def urlparse(value) -> ParseResult: MEDIA_URL = env("MEDIA_URL") STATIC_URL = env("STATIC_URL") +STATICFILES_DIRS = [BASE_DIR / "static"] + if env("AWS_S3_ENABLED"): AWS_S3_CONFIG_OPTIONS = { diff --git a/schema.graphql b/schema.graphql index fe0c569..6521e66 100644 --- a/schema.graphql +++ b/schema.graphql @@ -117,6 +117,8 @@ type AppEnumCollectionUserRole { label: String! } +union BulkCreateTeamMembersPayload = TeamMemberTypeListMutationResponseType | OperationInfo + """A Capacity & Resources entry that groups multiple ExternalDashboards.""" input CapacityAndResourceCreateInput { title: String! @@ -729,6 +731,7 @@ type Mutation { updateTeamMember(id: ID!, data: TeamMemberUpdateInput!): UpdateTeamMemberPayload! deleteTeamMember(id: ID!): DeleteTeamMemberPayload! deleteTeam(id: ID!): DeleteTeamPayload! + bulkCreateTeamMembers(data: TeamMemberBulkCreateInput!): BulkCreateTeamMembersPayload! } input NewsPostCreateInput { @@ -917,6 +920,7 @@ type Query { team(id: ID!): TeamType! teamMembers(pagination: OffsetPaginationInput, filters: TeamMemberFilter, order: TeamMemberOrder): TeamMemberTypeOffsetPaginated! teamMember(id: ID!): TeamMemberType! + createTeamMemberTemplate: String! enums: AppEnumCollection! } @@ -1158,6 +1162,10 @@ input TeamFilter { search: String } +input TeamMemberBulkCreateInput { + members: [TeamMemberCreateInput!]! +} + input TeamMemberCreateInput { team: ID! name: String! @@ -1215,6 +1223,12 @@ type TeamMemberType { woreda: ID } +type TeamMemberTypeListMutationResponseType { + ok: Boolean! + errors: CustomErrorType + result: [TeamMemberType!] +} + type TeamMemberTypeMutationResponseType { ok: Boolean! errors: CustomErrorType diff --git a/static/files/create-team-member-template.xlsx b/static/files/create-team-member-template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..68ae26f8d129f7ffb26c6bac7db801d8f3830d69 GIT binary patch literal 112073 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 Crh+2? literal 0 HcmV?d00001