Skip to content

Commit 9a50add

Browse files
authored
Merge pull request #2643 from IFRCGo/feature/eap-share-functionality
2 parents ad133f5 + 609dc8e commit 9a50add

13 files changed

Lines changed: 263 additions & 2 deletions

File tree

assets

eap/dev_views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def get(self, request):
2020
"pending_pfa": "email/eap/pending_pfa.html",
2121
"approved_eap": "email/eap/approved.html",
2222
"reminder": "email/eap/reminder.html",
23+
"share_eap": "email/eap/share_eap.html",
2324
}
2425

2526
if type_param not in template_map:
@@ -113,6 +114,13 @@ def get(self, request):
113114
"national_society": "Test National Society",
114115
"disaster_type": "Flood",
115116
},
117+
"share_eap": {
118+
"registration_id": 1,
119+
"eap_type": "simplified",
120+
"country_name": "Test Country",
121+
"national_society": "Test National Society",
122+
"disaster_type": "Flood",
123+
},
116124
}
117125

118126
context = context_map.get(type_param)

eap/filter_set.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,11 @@ class FullEAPFilterSet(BaseEAPFilterSet):
9898
class Meta:
9999
model = FullEAP
100100
fields = ("eap_registration",)
101+
102+
103+
class EAPShareUserFilterSet(filters.FilterSet):
104+
id = filters.NumberFilter(field_name="id", lookup_expr="exact")
105+
106+
class Meta:
107+
model = EAPRegistration
108+
fields = ("id",)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Generated by Django 4.2.26 on 2026-01-28 11:09
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
10+
("eap", "0003_eapaction_eapcontact_eapfile_eapimpact_and_more"),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name="eapregistration",
16+
name="users",
17+
field=models.ManyToManyField(
18+
blank=True,
19+
related_name="user_eap",
20+
to=settings.AUTH_USER_MODEL,
21+
verbose_name="users",
22+
),
23+
),
24+
]

eap/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,14 @@ class EAPRegistration(EAPBaseModel):
628628
blank=True,
629629
)
630630

631+
# Users involved in the EAP
632+
users = models.ManyToManyField(
633+
settings.AUTH_USER_MODEL,
634+
blank=True,
635+
verbose_name=_("users"),
636+
related_name="user_eap",
637+
)
638+
631639
# Validated Budget file
632640
validated_budget_file = SecureFileField(
633641
upload_to="eap/files/validated_budgets/",

eap/serializers.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
generate_export_eap_pdf,
4343
send_approved_email,
4444
send_eap_resubmission_email,
45+
send_eap_share_email,
4546
send_feedback_email,
4647
send_feedback_email_for_resubmitted_eap,
4748
send_new_eap_registration_email,
@@ -95,6 +96,39 @@ def update(self, instance, validated_data: dict[str, typing.Any]):
9596
return super().update(instance, validated_data)
9697

9798

99+
class EAPShareUserSerializer(serializers.ModelSerializer):
100+
users = serializers.PrimaryKeyRelatedField(
101+
queryset=User.objects.all(),
102+
many=True,
103+
required=True,
104+
)
105+
106+
users_details = UserNameSerializer(source="users", many=True, read_only=True)
107+
108+
class Meta:
109+
model = EAPRegistration
110+
fields = (
111+
"users",
112+
"users_details",
113+
)
114+
read_only_fields = ("id",)
115+
116+
def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> EAPRegistration:
117+
existing_user_ids = set(instance.users.values_list("id", flat=True))
118+
new_users = [user for user in validated_data["users"] if user.id not in existing_user_ids]
119+
instance = super().update(instance, validated_data)
120+
121+
if new_users:
122+
transaction.on_commit(
123+
lambda: send_eap_share_email.delay(
124+
eap_registration_id=instance.id,
125+
recipient_emails=[user.email for user in new_users],
126+
)
127+
)
128+
129+
return instance
130+
131+
98132
class EAPFileInputSerializer(serializers.Serializer):
99133
file = serializers.ListField(child=serializers.FileField(required=True))
100134

eap/tasks.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,3 +614,23 @@ def send_deadline_reminder_email(eap_registration_id: int):
614614
instance.save(update_fields=["deadline_remainder_sent_at"])
615615

616616
return True
617+
618+
619+
@shared_task
620+
def send_eap_share_email(eap_registration_id: int, recipient_emails: list[str]):
621+
instance = EAPRegistration.objects.filter(id=eap_registration_id).first()
622+
if not instance or not recipient_emails:
623+
return None
624+
625+
email_context = get_eap_registration_email_context(instance)
626+
email_subject = f"EAP shared: {instance.country} {instance.disaster_type}"
627+
email_body = render_to_string("email/eap/share_eap.html", email_context)
628+
email_type = "Shared EAP"
629+
630+
send_notification(
631+
subject=email_subject,
632+
recipients=recipient_emails,
633+
html=email_body,
634+
mailtype=email_type,
635+
)
636+
return True

eap/test_views.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,80 @@ def test_active_eaps(self):
387387
},
388388
)
389389

390+
@mock.patch("eap.serializers.send_eap_share_email.delay")
391+
def test_share_eap(self, send_eap_share_email):
392+
eap_registration = EAPRegistrationFactory.create(
393+
country=self.country,
394+
eap_type=EAPType.SIMPLIFIED_EAP,
395+
national_society=self.national_society,
396+
disaster_type=self.disaster_type,
397+
partners=[self.partner1.id, self.partner2.id],
398+
created_by=self.country_admin,
399+
modified_by=self.country_admin,
400+
status=EAPStatus.UNDER_REVIEW,
401+
)
402+
user1, user2, user3 = UserFactory.create_batch(3)
403+
404+
url = f"/api/v2/eap-registration/{eap_registration.id}/share/"
405+
data = {
406+
"users": [
407+
user1.id,
408+
user3.id,
409+
],
410+
}
411+
self.authenticate()
412+
413+
with self.capture_on_commit_callbacks(execute=True):
414+
response = self.client.post(url, data, format="json")
415+
self.assertEqual(response.status_code, 200)
416+
417+
# Check if notification email sent
418+
send_eap_share_email.assert_called_with(
419+
eap_registration_id=eap_registration.id,
420+
recipient_emails=[user1.email, user3.email],
421+
)
422+
423+
# Check if the users has been added
424+
eap_registration.refresh_from_db()
425+
self.assertEqual(eap_registration.users.count(), 2)
426+
427+
# Test removing a user
428+
data = {
429+
"users": [
430+
user1.id,
431+
user2.id,
432+
],
433+
}
434+
435+
with self.capture_on_commit_callbacks(execute=True):
436+
response = self.client.post(url, data, format="json")
437+
self.assertEqual(response.status_code, 200)
438+
eap_registration.refresh_from_db()
439+
self.assertEqual(eap_registration.users.count(), 2, response.data)
440+
441+
# Check notification email sent again with only updated user
442+
send_eap_share_email.assert_called_with(
443+
eap_registration_id=eap_registration.id,
444+
recipient_emails=[user2.email],
445+
)
446+
447+
# NOTE: test list of EAP Share Users
448+
url = "/api/v2/eap-share-users/"
449+
self.authenticate()
450+
response = self.client.get(url)
451+
self.assertEqual(response.status_code, 200)
452+
self.assertEqual(response.data["count"], 1, response.data)
453+
returned_user_ids = [user["id"] for user in response.data["results"][0]["users_details"]]
454+
# count should be 2
455+
self.assertEqual(len(returned_user_ids), 2)
456+
457+
# NOTE: test with filter by EAP Registration Id
458+
url = f"/api/v2/eap-share-users/?id={eap_registration.id}"
459+
self.authenticate()
460+
response = self.client.get(url)
461+
self.assertEqual(response.status_code, 200)
462+
self.assertEqual(response.data["count"], 1, response.data)
463+
390464

391465
class EAPSimplifiedTestCase(APITestCase):
392466
def setUp(self):

eap/utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,29 @@ def get_file_url(file_obj):
4747
return file_obj.file.url
4848

4949

50+
def get_share_eap_email_context(instance):
51+
from eap.serializers import EAPRegistrationSerializer
52+
53+
eap_registration_data = EAPRegistrationSerializer(instance).data
54+
55+
# NOTE: Matching the FRONTEND URLs with the email reference links
56+
if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP:
57+
eap_type = "simplified"
58+
elif instance.get_eap_type_enum == EAPType.FULL_EAP:
59+
eap_type = "full"
60+
else:
61+
eap_type = None
62+
63+
return {
64+
"registration_id": eap_registration_data["id"],
65+
"country_name": eap_registration_data["country_details"]["name"],
66+
"disaster_type": eap_registration_data["disaster_type_details"]["name"],
67+
"eap_type": eap_type,
68+
"eap_type_display": eap_registration_data.get("eap_type_display"),
69+
"frontend_url": settings.GO_WEB_URL,
70+
}
71+
72+
5073
def get_eap_registration_email_context(instance):
5174
from eap.serializers import EAPRegistrationSerializer
5275

eap/views.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from eap.filter_set import (
1010
EAPRegistrationFilterSet,
11+
EAPShareUserFilterSet,
1112
FullEAPFilterSet,
1213
SimplifiedEAPFilterSet,
1314
)
@@ -32,6 +33,7 @@
3233
EAPFileSerializer,
3334
EAPGlobalFilesSerializer,
3435
EAPRegistrationSerializer,
36+
EAPShareUserSerializer,
3537
EAPStatusSerializer,
3638
EAPValidatedBudgetFileSerializer,
3739
FullEAPSerializer,
@@ -169,6 +171,45 @@ def upload_validated_budget_file(
169171
serializer.save()
170172
return response.Response(serializer.data)
171173

174+
@extend_schema(
175+
request=EAPShareUserSerializer,
176+
responses={200: None},
177+
)
178+
@action(
179+
detail=True,
180+
url_path="share",
181+
methods=["post"],
182+
serializer_class=EAPShareUserSerializer,
183+
permission_classes=[permissions.IsAuthenticated, DenyGuestUserPermission],
184+
)
185+
def share(
186+
self,
187+
request,
188+
id: int,
189+
):
190+
eap_registration: EAPRegistration = self.get_object()
191+
serializer = EAPShareUserSerializer(
192+
eap_registration,
193+
data=request.data,
194+
context={"request": request},
195+
)
196+
serializer.is_valid(raise_exception=True)
197+
serializer.save()
198+
return response.Response(status=status.HTTP_200_OK)
199+
200+
201+
class EAPShareUserViewSet(
202+
viewsets.ReadOnlyModelViewSet,
203+
):
204+
queryset = EAPRegistration.objects.all()
205+
lookup_field = "id"
206+
serializer_class = EAPShareUserSerializer
207+
permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission]
208+
filterset_class = EAPShareUserFilterSet
209+
210+
def get_queryset(self) -> QuerySet[EAPRegistration]:
211+
return super().get_queryset().prefetch_related("users").order_by("-created_at").distinct()
212+
172213

173214
class SimplifiedEAPViewSet(EAPModelViewSet):
174215
queryset = SimplifiedEAP.objects.all()

0 commit comments

Comments
 (0)