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/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/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/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 0000000..68ae26f Binary files /dev/null and b/static/files/create-team-member-template.xlsx differ 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,