Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/teams/graphql/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/teams/graphql/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from .inputs import (
TeamCreateInput,
TeamMemberBulkCreateInput,
TeamMemberCreateInput,
TeamMemberUpdateInput,
TeamUpdateInput,
Expand Down Expand Up @@ -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)
9 changes: 9 additions & 0 deletions apps/teams/graphql/queries.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"),
)
137 changes: 137 additions & 0 deletions apps/teams/tests/mutations_test.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
14 changes: 14 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ type AppEnumCollectionUserRole {
label: String!
}

union BulkCreateTeamMembersPayload = TeamMemberTypeListMutationResponseType | OperationInfo

"""A Capacity & Resources entry that groups multiple ExternalDashboards."""
input CapacityAndResourceCreateInput {
title: String!
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!
}

Expand Down Expand Up @@ -1158,6 +1162,10 @@ input TeamFilter {
search: String
}

input TeamMemberBulkCreateInput {
members: [TeamMemberCreateInput!]!
}

input TeamMemberCreateInput {
team: ID!
name: String!
Expand Down Expand Up @@ -1215,6 +1223,12 @@ type TeamMemberType {
woreda: ID
}

type TeamMemberTypeListMutationResponseType {
ok: Boolean!
errors: CustomErrorType
result: [TeamMemberType!]
}

type TeamMemberTypeMutationResponseType {
ok: Boolean!
errors: CustomErrorType
Expand Down
Binary file added static/files/create-team-member-template.xlsx
Binary file not shown.
39 changes: 39 additions & 0 deletions utils/graphql/mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down