From c477084d81b9a38b9ea4a6ea71b2ddc0969a6baa Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Sat, 29 Nov 2025 19:49:12 +0545 Subject: [PATCH 1/3] feat(notifications): add alert subscription api --- main/urls.py | 1 + notifications/admin.py | 27 +++++ notifications/drf_views.py | 30 ++++- notifications/enums.py | 3 + notifications/factories.py | 40 ++++++- notifications/filter_set.py | 19 ++++ .../0016_hazardtype_alertsubscription.py | 47 ++++++++ notifications/models.py | 94 ++++++++++++++++ notifications/serializers.py | 43 ++++++- notifications/tests.py | 105 +++++++++++++++++- 10 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 notifications/filter_set.py create mode 100644 notifications/migrations/0016_hazardtype_alertsubscription.py diff --git a/main/urls.py b/main/urls.py index f4a31cb42..7e568499a 100644 --- a/main/urls.py +++ b/main/urls.py @@ -153,6 +153,7 @@ router.register(r"situation_report_type", api_views.SituationReportTypeViewset, basename="situation_report_type") router.register(r"subscription", notification_views.SubscriptionViewset, basename="subscription") router.register(r"surge_alert", notification_views.SurgeAlertViewset, basename="surge_alert") +router.register(r"alert-subscription", notification_views.AlertSubscriptionViewSet, basename="alert_subscription") router.register(r"user", api_views.UserViewset, basename="user") router.register(r"flash-update", flash_views.FlashUpdateViewSet, basename="flash_update") router.register(r"flash-update-file", flash_views.FlashUpdateFileViewSet, basename="flash_update_file") diff --git a/notifications/admin.py b/notifications/admin.py index 71ad1d475..4b68e73ac 100644 --- a/notifications/admin.py +++ b/notifications/admin.py @@ -66,3 +66,30 @@ def has_delete_permission(self, request, obj=None): admin.site.register(models.NotificationGUID, NotificationGUIDAdmin) admin.site.register(models.Subscription, SubscriptionAdmin) admin.site.register(models.SurgeAlert, SurgeAlertAdmin) + + +@admin.register(models.HazardType) +class AlertTypeAdmin(admin.ModelAdmin): + list_display = ("type",) + search_fields = ("type",) + + +@admin.register(models.AlertSubscription) +class AlertSubscriptionAdmin(admin.ModelAdmin): + list_select_related = True + list_display = ("user", "created_at", "alert_per_day") + autocomplete_fields = ("user", "regions", "countries", "hazard_types") + + def get_queryset(self, request): + return ( + super() + .get_queryset(request) + .select_related( + "user", + ) + .prefetch_related( + "countries", + "regions", + "hazard_types", + ) + ) diff --git a/notifications/drf_views.py b/notifications/drf_views.py index f13e9c9b5..3cd2460d7 100644 --- a/notifications/drf_views.py +++ b/notifications/drf_views.py @@ -1,5 +1,6 @@ # from datetime import datetime, timedelta, timezone # from django.db.models import Q +from django.db.models.query import QuerySet from django_filters import rest_framework as filters from django_filters.widgets import CSVWidget from rest_framework import viewsets @@ -9,9 +10,11 @@ from deployments.models import MolnixTag from main.filters import CharInFilter from main.permissions import DenyGuestUserPermission +from notifications.filter_set import AlertSubscriptionFilterSet -from .models import Subscription, SurgeAlert +from .models import AlertSubscription, Subscription, SurgeAlert from .serializers import ( # UnauthenticatedSurgeAlertSerializer, + AlertSubscriptionSerialize, SubscriptionSerializer, SurgeAlertCsvSerializer, SurgeAlertSerializer, @@ -110,3 +113,28 @@ class SubscriptionViewset(viewsets.ModelViewSet): def get_queryset(self): return Subscription.objects.filter(user=self.request.user) + + +class AlertSubscriptionViewSet(viewsets.ModelViewSet): + queryset = AlertSubscription.objects.all() + serializer_class = AlertSubscriptionSerialize + filterset_class = AlertSubscriptionFilterSet + lookup_field = "id" + permission_classes = [ + IsAuthenticated, + DenyGuestUserPermission, + ] + + def get_queryset(self) -> QuerySet[AlertSubscription]: + return ( + super() + .get_queryset() + .select_related( + "user", + ) + .prefetch_related( + "countries", + "regions", + "hazard_types", + ) + ) diff --git a/notifications/enums.py b/notifications/enums.py index 1f9749f62..b05e144c2 100644 --- a/notifications/enums.py +++ b/notifications/enums.py @@ -2,4 +2,7 @@ enum_register = { "surge_alert_status": models.SurgeAlertStatus, + "alert_source": models.AlertSubscription.AlertSource, + "hazard_type": models.HazardType.Type, + "alert_per_day": models.AlertSubscription.AlertPerDay, } diff --git a/notifications/factories.py b/notifications/factories.py index bdf47df68..023bbb441 100644 --- a/notifications/factories.py +++ b/notifications/factories.py @@ -1,7 +1,9 @@ import factory from factory import fuzzy -from .models import SurgeAlert, SurgeAlertStatus +from deployments.factories.user import UserFactory + +from .models import AlertSubscription, HazardType, SurgeAlert, SurgeAlertStatus class SurgeAlertFactory(factory.django.DjangoModelFactory): @@ -21,3 +23,39 @@ def molnix_tags(self, create, extracted, **_): if extracted: for item in extracted: self.molnix_tags.add(item) + + +class AlertSubscriptionFactory(factory.django.DjangoModelFactory): + class Meta: + model = AlertSubscription + + user = factory.SubFactory(UserFactory) + + @factory.post_generation + def countries(self, create, extracted, **kwargs): + if not create: + return + if extracted: + for country in extracted: + self.countries.add(country) + + @factory.post_generation + def regions(self, create, extracted, **kwargs): + if not create: + return + if extracted: + for region in extracted: + self.regions.add(region) + + @factory.post_generation + def hazard_types(self, create, extracted, **kwargs): + if not create: + return + if extracted: + for alert_type in extracted: + self.hazard_types.add(alert_type) + + +class HazardTypeFactory(factory.django.DjangoModelFactory): + class Meta: + model = HazardType diff --git a/notifications/filter_set.py b/notifications/filter_set.py new file mode 100644 index 000000000..a40e760e7 --- /dev/null +++ b/notifications/filter_set.py @@ -0,0 +1,19 @@ +import django_filters as filters + +from api.models import Country, Region +from notifications.models import AlertSubscription + + +class AlertSubscriptionFilterSet(filters.FilterSet): + country = filters.ModelMultipleChoiceFilter(field_name="countries", queryset=Country.objects.all()) + region = filters.ModelMultipleChoiceFilter(field_name="regions", queryset=Region.objects.all()) + alert_source = filters.NumberFilter(field_name="alert_source", label="Alert Source") + hazard_type = filters.NumberFilter(field_name="hazard_types__type", label="Hazard Type") + alert_per_day = filters.ChoiceFilter(choices=AlertSubscription.AlertPerDay.choices, label="Alert Per Day") + + class Meta: + model = AlertSubscription + fields = { + "countries__iso3": ("exact",), + "alert_per_day": ("exact",), + } diff --git a/notifications/migrations/0016_hazardtype_alertsubscription.py b/notifications/migrations/0016_hazardtype_alertsubscription.py new file mode 100644 index 000000000..59a58553d --- /dev/null +++ b/notifications/migrations/0016_hazardtype_alertsubscription.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.19 on 2025-12-03 16:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0226_nsdinitiativescategory_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0015_rename_molnix_status_surgealert_molnix_status_old'), + ] + + operations = [ + migrations.CreateModel( + name='HazardType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.IntegerField(choices=[(100, 'Earthquake'), (200, 'Flood'), (300, 'Cyclone')], unique=True, verbose_name='Hazard Type')), + ], + options={ + 'verbose_name': 'Hazard Type', + 'verbose_name_plural': 'Hazard Types', + }, + ), + migrations.CreateModel( + name='AlertSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alert_source', models.IntegerField(choices=[(100, 'Montandon')], default=100, verbose_name='Alert Source')), + ('alert_per_day', models.IntegerField(choices=[(100, 'Five'), (200, 'Ten'), (300, 'Twenty'), (400, 'Fifty'), (500, 'Unlimited')], default=100, help_text='Maximum number of alerts sent to the user per day.', verbose_name='Alerts Per Day')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('countries', models.ManyToManyField(related_name='alert_subscriptions_countries', to='api.country', verbose_name='Countries')), + ('hazard_types', models.ManyToManyField(help_text='Types of hazards the user is subscribed to.', related_name='alert_subscriptions_hazard_types', to='notifications.hazardtype', verbose_name='Hazard Types')), + ('regions', models.ManyToManyField(blank=True, related_name='alert_subscriptions_regions', to='api.region', verbose_name='Regions')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alert_subscriptions_user', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Alert Subscription', + 'verbose_name_plural': 'Alert Subscriptions', + 'ordering': ['-id'], + }, + ), + ] diff --git a/notifications/models.py b/notifications/models.py index e717fada6..e425b44e9 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -320,3 +320,97 @@ class NotificationGUID(models.Model): ) email_type = models.CharField(max_length=600, null=True, blank=True) to_list = models.TextField(null=True, blank=True) + + +class HazardType(models.Model): + """Model representing a hazard category.""" + + class Type(models.IntegerChoices): + EARTHQUAKE = 100, _("Earthquake") + FLOOD = 200, _("Flood") + CYCLONE = 300, _("Cyclone") + + type = models.IntegerField( + choices=Type.choices, + unique=True, + verbose_name=_("Hazard Type"), + ) + + class Meta: + verbose_name = _("Hazard Type") + verbose_name_plural = _("Hazard Types") + + def __str__(self): + return self.get_type_display() + + +class AlertSubscription(models.Model): + class AlertSource(models.IntegerChoices): + MONTANDON = 100, _("Montandon") + """Alerts provided by the Montandon platform.""" + + class AlertPerDay(models.IntegerChoices): + """Enum representing the maximum number of alerts per day.""" + + FIVE = 100, _("Five") + """Receive up to 5 alerts per day.""" + + TEN = 200, _("Ten") + """Receive up to 10 alerts per day.""" + + TWENTY = 300, _("Twenty") + """Receive up to 20 alerts per day.""" + + FIFTY = 400, _("Fifty") + """Receive up to 50 alerts per day.""" + + UNLIMITED = 500, _("Unlimited") + """No daily alert limit.""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name=_("User"), + on_delete=models.CASCADE, + related_name="alert_subscriptions_user", + ) + countries = models.ManyToManyField( + Country, + related_name="alert_subscriptions_countries", + verbose_name=_("Countries"), + ) + regions = models.ManyToManyField( + Region, + related_name="alert_subscriptions_regions", + blank=True, + verbose_name=_("Regions"), + ) + alert_source = models.IntegerField( + choices=AlertSource.choices, + default=AlertSource.MONTANDON, + verbose_name=_("Alert Source"), + ) + + hazard_types = models.ManyToManyField( + HazardType, + related_name="alert_subscriptions_hazard_types", + verbose_name=_("Hazard Types"), + help_text="Types of hazards the user is subscribed to.", + ) + alert_per_day = models.IntegerField( + choices=AlertPerDay.choices, + default=AlertPerDay.FIVE, + verbose_name=_("Alerts Per Day"), + help_text="Maximum number of alerts sent to the user per day.", + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + # Typing + id: int + + class Meta: + ordering = ["-id"] + verbose_name = _("Alert Subscription") + verbose_name_plural = _("Alert Subscriptions") + + def __str__(self): + return f"Alert subscription for {self.user.get_full_name()}" diff --git a/notifications/serializers.py b/notifications/serializers.py index a414c341b..5889a1233 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -3,12 +3,14 @@ from api.serializers import ( MiniCountrySerializer, MiniEventSerializer, + MiniRegionSerialzier, SurgeEventSerializer, + UserNameSerializer, ) from deployments.serializers import MolnixTagSerializer from lang.serializers import ModelSerializer -from .models import Subscription, SurgeAlert +from .models import AlertSubscription, HazardType, Subscription, SurgeAlert class SurgeAlertSerializer(ModelSerializer): @@ -258,3 +260,42 @@ class Meta: "rtype", "rtype_display", ) + + +class HazardTypeSerializer(ModelSerializer): + + type_display = serializers.CharField(source="get_type_display", read_only=True) + + class Meta: + model = HazardType + fields = ( + "id", + "type", + "type_display", + ) + + +class AlertSubscriptionSerialize(ModelSerializer): + user_detail = UserNameSerializer(source="user", read_only=True) + countries_detail = MiniCountrySerializer(source="countries", many=True, read_only=True) + regions_detail = MiniRegionSerialzier(source="regions", many=True, read_only=True) + hazard_types_detail = HazardTypeSerializer(source="hazard_types", many=True, read_only=True) + alert_per_day_display = serializers.CharField(source="get_alert_per_day_display", read_only=True) + + class Meta: + model = AlertSubscription + fields = ( + "id", + "user", + "countries", + "regions", + "hazard_types", + "alert_per_day", + "user_detail", + "countries_detail", + "regions_detail", + "hazard_types_detail", + "alert_per_day_display", + "created_at", + "updated_at", + ) diff --git a/notifications/tests.py b/notifications/tests.py index c0a4c30be..d260b70be 100644 --- a/notifications/tests.py +++ b/notifications/tests.py @@ -6,12 +6,24 @@ from api.factories.country import CountryFactory from api.factories.region import RegionFactory +from api.models import RegionName from deployments.factories.molnix_tag import MolnixTagFactory +from deployments.factories.user import UserFactory from lang.serializers import TranslatedModelSerializerMixin from main.test_case import APITestCase -from notifications.factories import SurgeAlertFactory +from notifications.factories import ( + AlertSubscriptionFactory, + HazardTypeFactory, + SurgeAlertFactory, +) from notifications.management.commands.ingest_alerts import categories, timeformat -from notifications.models import SurgeAlert, SurgeAlertStatus, SurgeAlertType +from notifications.models import ( + AlertSubscription, + HazardType, + SurgeAlert, + SurgeAlertStatus, + SurgeAlertType, +) class NotificationTestCase(APITestCase): @@ -215,3 +227,92 @@ def _fetch(filters): response = _fetch(dict({"molnix_status": _to_csv(SurgeAlertStatus.STOOD_DOWN, SurgeAlertStatus.OPEN)})) self.assertEqual(response["count"], 2) self.assertEqual(response["results"][0]["molnix_status"], SurgeAlertStatus.STOOD_DOWN) + + +class AlertSubscriptionTestCase(APITestCase): + + def setUp(self): + self.user1 = UserFactory.create(email="testuser1@com") + self.user2 = UserFactory.create(email="testuser2@com") + + self.region = RegionFactory.create(name=RegionName.ASIA_PACIFIC) + self.regions = RegionFactory.create_batch(3) + self.countries = CountryFactory.create_batch(2) + + self.country = CountryFactory.create( + name="Nepal", + iso3="NLP", + iso="NP", + region=self.region, + ) + + self.country_1 = CountryFactory.create( + name="Philippines", + iso3="PHL", + iso="PH", + region=self.region, + ) + self.hazard_type1 = HazardTypeFactory.create(type=HazardType.Type.EARTHQUAKE) + self.hazard_type2 = HazardTypeFactory.create(type=HazardType.Type.FLOOD) + + self.alert_subscription = AlertSubscriptionFactory.create( + user=self.user1, + countries=self.countries, + regions=[self.region], + hazard_types=[self.hazard_type1, self.hazard_type2], + ) + + def test_list_retrieve_subscription(self): + url = "/api/v2/alert-subscription/" + + # Anonymous user cannot access list + response = self.client.get(url) + self.assert_401(response) + + # Authenticated user can list + self.authenticate(self.user1) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # Retrieve detail + url = f"/api/v2/alert-subscription/{self.alert_subscription.id}/" + response = self.client.get(url) + self.assert_200(response) + self.assertEqual(response.data["user"], self.user1.id) + + # Test Create Subscription + def test_create_subscription(self): + + data = { + "user": self.user1.id, + "countries": [self.country.id, self.country_1.id], + "regions": [self.region.id], + "hazard_types": [self.hazard_type1.id, self.hazard_type2.id], + "alert_per_day": AlertSubscription.AlertPerDay.TEN, + "source": AlertSubscription.AlertSource.MONTANDON, + } + url = "/api/v2/alert-subscription/" + self.authenticate(self.user1) + response = self.client.post(url, data=data, format="json") + self.assert_201(response) + subscription = AlertSubscription.objects.get(id=response.data["id"]) + self.assertEqual(subscription.user, self.user1) + self.assertEqual(subscription.countries.count(), 2) + self.assertEqual(subscription.regions.count(), 1) + self.assertEqual(subscription.hazard_types.count(), 2) + self.assertEqual(subscription.alert_per_day, AlertSubscription.AlertPerDay.TEN) + + # Test Update + def test_update_subscription(self): + + url = f"/api/v2/alert-subscription/{self.alert_subscription.id}/" + data = { + "countries": [self.country_1.id], + "alert_per_day": AlertSubscription.AlertPerDay.UNLIMITED, + } + self.authenticate(self.user1) + response = self.client.patch(url, data=data, format="json") + self.assert_200(response) + self.alert_subscription.refresh_from_db() + self.assertEqual(self.alert_subscription.countries.first().id, self.country_1.id) + self.assertEqual(self.alert_subscription.alert_per_day, AlertSubscription.AlertPerDay.UNLIMITED) From 332999c25dfc97265ccf4b5501d1827f11a11d86 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Thu, 11 Dec 2025 09:55:39 +0545 Subject: [PATCH 2/3] chore(assets): Update open-api schema --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index d6b617c5e..1a39645f8 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit d6b617c5efdd857d398ef5ab569509ae32e8fa18 +Subproject commit 1a39645f84a5e2c9184abb761d8b2e8e41c5f755 From b5abceca59227325a269b8f930f27a0055b260b7 Mon Sep 17 00:00:00 2001 From: sudip-khanal Date: Fri, 12 Dec 2025 09:49:43 +0545 Subject: [PATCH 3/3] chore(schema): Update schema reference --- assets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets b/assets index 1a39645f8..322ecec63 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 1a39645f84a5e2c9184abb761d8b2e8e41c5f755 +Subproject commit 322ecec63bf26068c4daac5376359d5c97e46d33