diff --git a/assets b/assets index d98c2b844..97a6901ca 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d98c2b844a4246b2bedd29f6b2c87f5f32a7018d +Subproject commit 97a6901ca498b5f79d48ba1f27e84eedf681fcf5 diff --git a/eap/dev_views.py b/eap/dev_views.py index aa7c9617c..fea626384 100644 --- a/eap/dev_views.py +++ b/eap/dev_views.py @@ -20,6 +20,7 @@ def get(self, request): "pending_pfa": "email/eap/pending_pfa.html", "approved_eap": "email/eap/approved.html", "reminder": "email/eap/reminder.html", + "share_eap": "email/eap/share_eap.html", } if type_param not in template_map: @@ -113,6 +114,13 @@ def get(self, request): "national_society": "Test National Society", "disaster_type": "Flood", }, + "share_eap": { + "registration_id": 1, + "eap_type": "simplified", + "country_name": "Test Country", + "national_society": "Test National Society", + "disaster_type": "Flood", + }, } context = context_map.get(type_param) diff --git a/eap/filter_set.py b/eap/filter_set.py index 5e3ba16ac..f2cf75fc1 100644 --- a/eap/filter_set.py +++ b/eap/filter_set.py @@ -98,3 +98,11 @@ class FullEAPFilterSet(BaseEAPFilterSet): class Meta: model = FullEAP fields = ("eap_registration",) + + +class EAPShareUserFilterSet(filters.FilterSet): + id = filters.NumberFilter(field_name="id", lookup_expr="exact") + + class Meta: + model = EAPRegistration + fields = ("id",) diff --git a/eap/migrations/0004_eapregistration_users.py b/eap/migrations/0004_eapregistration_users.py new file mode 100644 index 000000000..2e6d2441a --- /dev/null +++ b/eap/migrations/0004_eapregistration_users.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.26 on 2026-01-28 11:09 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("eap", "0003_eapaction_eapcontact_eapfile_eapimpact_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="eapregistration", + name="users", + field=models.ManyToManyField( + blank=True, + related_name="user_eap", + to=settings.AUTH_USER_MODEL, + verbose_name="users", + ), + ), + ] diff --git a/eap/models.py b/eap/models.py index 4656466cf..06f446b42 100644 --- a/eap/models.py +++ b/eap/models.py @@ -628,6 +628,14 @@ class EAPRegistration(EAPBaseModel): blank=True, ) + # Users involved in the EAP + users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + verbose_name=_("users"), + related_name="user_eap", + ) + # Validated Budget file validated_budget_file = SecureFileField( upload_to="eap/files/validated_budgets/", diff --git a/eap/serializers.py b/eap/serializers.py index 7d655c21b..3b7affde5 100644 --- a/eap/serializers.py +++ b/eap/serializers.py @@ -42,6 +42,7 @@ generate_export_eap_pdf, send_approved_email, send_eap_resubmission_email, + send_eap_share_email, send_feedback_email, send_feedback_email_for_resubmitted_eap, send_new_eap_registration_email, @@ -95,6 +96,39 @@ def update(self, instance, validated_data: dict[str, typing.Any]): return super().update(instance, validated_data) +class EAPShareUserSerializer(serializers.ModelSerializer): + users = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + many=True, + required=True, + ) + + users_details = UserNameSerializer(source="users", many=True, read_only=True) + + class Meta: + model = EAPRegistration + fields = ( + "users", + "users_details", + ) + read_only_fields = ("id",) + + def update(self, instance: EAPRegistration, validated_data: dict[str, typing.Any]) -> EAPRegistration: + existing_user_ids = set(instance.users.values_list("id", flat=True)) + new_users = [user for user in validated_data["users"] if user.id not in existing_user_ids] + instance = super().update(instance, validated_data) + + if new_users: + transaction.on_commit( + lambda: send_eap_share_email.delay( + eap_registration_id=instance.id, + recipient_emails=[user.email for user in new_users], + ) + ) + + return instance + + class EAPFileInputSerializer(serializers.Serializer): file = serializers.ListField(child=serializers.FileField(required=True)) diff --git a/eap/tasks.py b/eap/tasks.py index 21aff9b27..9b34eeb91 100644 --- a/eap/tasks.py +++ b/eap/tasks.py @@ -609,3 +609,23 @@ def send_deadline_reminder_email(eap_registration_id: int): instance.save(update_fields=["deadline_remainder_sent_at"]) return True + + +@shared_task +def send_eap_share_email(eap_registration_id: int, recipient_emails: list[str]): + instance = EAPRegistration.objects.filter(id=eap_registration_id).first() + if not instance or not recipient_emails: + return None + + email_context = get_eap_registration_email_context(instance) + email_subject = f"EAP shared: {instance.country} {instance.disaster_type}" + email_body = render_to_string("email/eap/share_eap.html", email_context) + email_type = "Shared EAP" + + send_notification( + subject=email_subject, + recipients=recipient_emails, + html=email_body, + mailtype=email_type, + ) + return True diff --git a/eap/test_views.py b/eap/test_views.py index e1d814de7..7f9212fb8 100644 --- a/eap/test_views.py +++ b/eap/test_views.py @@ -387,6 +387,80 @@ def test_active_eaps(self): }, ) + @mock.patch("eap.serializers.send_eap_share_email.delay") + def test_share_eap(self, send_eap_share_email): + eap_registration = EAPRegistrationFactory.create( + country=self.country, + eap_type=EAPType.SIMPLIFIED_EAP, + national_society=self.national_society, + disaster_type=self.disaster_type, + partners=[self.partner1.id, self.partner2.id], + created_by=self.country_admin, + modified_by=self.country_admin, + status=EAPStatus.UNDER_REVIEW, + ) + user1, user2, user3 = UserFactory.create_batch(3) + + url = f"/api/v2/eap-registration/{eap_registration.id}/share/" + data = { + "users": [ + user1.id, + user3.id, + ], + } + self.authenticate() + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + + # Check if notification email sent + send_eap_share_email.assert_called_with( + eap_registration_id=eap_registration.id, + recipient_emails=[user1.email, user3.email], + ) + + # Check if the users has been added + eap_registration.refresh_from_db() + self.assertEqual(eap_registration.users.count(), 2) + + # Test removing a user + data = { + "users": [ + user1.id, + user2.id, + ], + } + + with self.capture_on_commit_callbacks(execute=True): + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + eap_registration.refresh_from_db() + self.assertEqual(eap_registration.users.count(), 2, response.data) + + # Check notification email sent again with only updated user + send_eap_share_email.assert_called_with( + eap_registration_id=eap_registration.id, + recipient_emails=[user2.email], + ) + + # NOTE: test list of EAP Share Users + url = "/api/v2/eap-share-users/" + self.authenticate() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1, response.data) + returned_user_ids = [user["id"] for user in response.data["results"][0]["users_details"]] + # count should be 2 + self.assertEqual(len(returned_user_ids), 2) + + # NOTE: test with filter by EAP Registration Id + url = f"/api/v2/eap-share-users/?id={eap_registration.id}" + self.authenticate() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["count"], 1, response.data) + class EAPSimplifiedTestCase(APITestCase): def setUp(self): diff --git a/eap/utils.py b/eap/utils.py index 04f25d5fa..3befcca33 100644 --- a/eap/utils.py +++ b/eap/utils.py @@ -47,6 +47,29 @@ def get_file_url(file_obj): return file_obj.file.url +def get_share_eap_email_context(instance): + from eap.serializers import EAPRegistrationSerializer + + eap_registration_data = EAPRegistrationSerializer(instance).data + + # NOTE: Matching the FRONTEND URLs with the email reference links + if instance.get_eap_type_enum == EAPType.SIMPLIFIED_EAP: + eap_type = "simplified" + elif instance.get_eap_type_enum == EAPType.FULL_EAP: + eap_type = "full" + else: + eap_type = None + + return { + "registration_id": eap_registration_data["id"], + "country_name": eap_registration_data["country_details"]["name"], + "disaster_type": eap_registration_data["disaster_type_details"]["name"], + "eap_type": eap_type, + "eap_type_display": eap_registration_data.get("eap_type_display"), + "frontend_url": settings.GO_WEB_URL, + } + + def get_eap_registration_email_context(instance): from eap.serializers import EAPRegistrationSerializer diff --git a/eap/views.py b/eap/views.py index 96e13ba1a..a4ee97326 100644 --- a/eap/views.py +++ b/eap/views.py @@ -8,6 +8,7 @@ from eap.filter_set import ( EAPRegistrationFilterSet, + EAPShareUserFilterSet, FullEAPFilterSet, SimplifiedEAPFilterSet, ) @@ -32,6 +33,7 @@ EAPFileSerializer, EAPGlobalFilesSerializer, EAPRegistrationSerializer, + EAPShareUserSerializer, EAPStatusSerializer, EAPValidatedBudgetFileSerializer, FullEAPSerializer, @@ -169,6 +171,45 @@ def upload_validated_budget_file( serializer.save() return response.Response(serializer.data) + @extend_schema( + request=EAPShareUserSerializer, + responses={200: None}, + ) + @action( + detail=True, + url_path="share", + methods=["post"], + serializer_class=EAPShareUserSerializer, + permission_classes=[permissions.IsAuthenticated, DenyGuestUserPermission], + ) + def share( + self, + request, + id: int, + ): + eap_registration: EAPRegistration = self.get_object() + serializer = EAPShareUserSerializer( + eap_registration, + data=request.data, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(status=status.HTTP_200_OK) + + +class EAPShareUserViewSet( + viewsets.ReadOnlyModelViewSet, +): + queryset = EAPRegistration.objects.all() + lookup_field = "id" + serializer_class = EAPShareUserSerializer + permission_classes = [permissions.IsAuthenticated, DenyGuestUserPermission] + filterset_class = EAPShareUserFilterSet + + def get_queryset(self) -> QuerySet[EAPRegistration]: + return super().get_queryset().prefetch_related("users").order_by("-created_at").distinct() + class SimplifiedEAPViewSet(EAPModelViewSet): queryset = SimplifiedEAP.objects.all() diff --git a/main/urls.py b/main/urls.py index c4bc50060..b6b2f2ca7 100644 --- a/main/urls.py +++ b/main/urls.py @@ -201,6 +201,7 @@ router.register(r"full-eap", eap_views.FullEAPViewSet, basename="full_eap") router.register(r"eap-file", eap_views.EAPFileViewSet, basename="eap_file") router.register(r"eap/global-files", eap_views.EAPGlobalFilesViewSet, basename="eap_global_files") +router.register(r"eap-share-users", eap_views.EAPShareUserViewSet, basename="eap_share_users") admin.site.site_header = "IFRC Go administration" admin.site.site_title = "IFRC Go admin" diff --git a/notifications/templates/email/eap/registration.html b/notifications/templates/email/eap/registration.html index 21f9f9af8..d19720f39 100644 --- a/notifications/templates/email/eap/registration.html +++ b/notifications/templates/email/eap/registration.html @@ -40,7 +40,6 @@ -

You can check the progress of this EAP here.

Kind regards,
{{ ns_contact_name }}
diff --git a/notifications/templates/email/eap/share_eap.html b/notifications/templates/email/eap/share_eap.html new file mode 100644 index 000000000..c47b0b3e6 --- /dev/null +++ b/notifications/templates/email/eap/share_eap.html @@ -0,0 +1,21 @@ +{% include "design/head3.html" %} + + + + + +
+ + You have been added to an EAP Application: + + {% if eap_type %} + + {{ country_name }}: {{ disaster_type }} of type {{ eap_type_display }} + + {% else %} + {{ country_name }}: {{ disaster_type }} + {% endif %} + +
+ +{% include "design/foot1.html" %}