diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0b370c7d..6ea02128 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,6 +31,6 @@ repos: - id: flake8 - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.389 + rev: v1.1.390 hooks: - id: pyright diff --git a/apps/cap_feed/management/commands/create_fake_alerts.py b/apps/cap_feed/management/commands/create_fake_alerts.py new file mode 100644 index 00000000..132dd22a --- /dev/null +++ b/apps/cap_feed/management/commands/create_fake_alerts.py @@ -0,0 +1,128 @@ +import factory +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.cap_feed.factories import ( + Admin1Factory, + AlertFactory, + AlertInfoFactory, + CountryFactory, + FeedFactory, + RegionFactory, +) +from apps.cap_feed.models import Admin1, AlertInfo, Country, Feed, Region +from apps.subscription.factories import UserAlertSubscriptionFactory +from apps.subscription.models import UserAlertSubscription +from apps.subscription.tasks import process_pending_subscription_alerts +from apps.user.models import User + +SUBSCRIPTION_FILTERSET = [ + ( + "Sub-1", + dict( + filter_alert_urgencies=[AlertInfo.Urgency.IMMEDIATE], + filter_alert_severities=[], + filter_alert_certainties=[], + filter_alert_categories=[], + ), + ), + ( + "Sub-1", + dict( + filter_alert_urgencies=[AlertInfo.Urgency.IMMEDIATE, AlertInfo.Urgency.EXPECTED], + filter_alert_severities=[AlertInfo.Severity.MODERATE], + filter_alert_certainties=[], + filter_alert_categories=[], + ), + ), + ( + "Sub-3", + dict( + filter_alert_urgencies=[AlertInfo.Urgency.EXPECTED], + filter_alert_severities=[AlertInfo.Severity.MODERATE], + filter_alert_certainties=[AlertInfo.Certainty.LIKELY], + filter_alert_categories=[], + ), + ), + # Everything + ( + "All", + dict( + filter_alert_urgencies=[], + filter_alert_severities=[], + filter_alert_certainties=[], + filter_alert_categories=[], + ), + ), +] + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument('--user-email', dest='user_email', required=True) + + def handle(self, *_, **kwargs): + if not settings.ALLOW_FAKE_DATA: + self.stdout.write( + self.style.WARNING( + "Add ALLOW_FAKE_DATA=true and DJANGO_DEBUG=true to environment variable to allow fake data generation" + ) + ) + return + + user_email = kwargs['user_email'] + user = User.objects.get(email=user_email) + + FAKE_REGION_NAME = "[Fake] Asia" + FAKE_COUNTRY_NAME = "[Fake] Nepal" + FAKE_ADMIN1_NAME = "[Fake] Bagmati" + FAKE_FEED_URL = "https://fake-feed.com/123" + + if (r_asia := Region.objects.filter(name=FAKE_REGION_NAME).first()) is None: + r_asia = RegionFactory.create(name=FAKE_REGION_NAME) + if (c_nepal := Country.objects.filter(name=FAKE_COUNTRY_NAME).first()) is None: + c_nepal = CountryFactory.create(name=FAKE_COUNTRY_NAME, region=r_asia) + if (ad1_bagmati := Admin1.objects.filter(name=FAKE_ADMIN1_NAME).first()) is None: + ad1_bagmati = Admin1Factory.create(name=FAKE_ADMIN1_NAME, country=c_nepal) + + if (feed1 := Feed.objects.filter(url=FAKE_FEED_URL).first()) is None: + feed1 = FeedFactory.create(url=FAKE_FEED_URL, country=c_nepal, polling_interval=False) + + random_id = timezone.now().isoformat() + alert_list = AlertFactory.create_batch( + 50, + url=factory.Sequence(lambda n: f"https://alert-{random_id}-{n}.com/test"), + feed=feed1, + country=c_nepal, + admin1s=[ad1_bagmati], + sent=timezone.now().date(), + ) + + alert_info_iterator = dict( + category=factory.Iterator(AlertInfo.Category.choices, getter=lambda c: c[0]), + urgency=factory.Iterator(AlertInfo.Urgency.choices, getter=lambda c: c[0]), + severity=factory.Iterator(AlertInfo.Severity.choices, getter=lambda c: c[0]), + certainty=factory.Iterator(AlertInfo.Certainty.choices, getter=lambda c: c[0]), + ) + for alert in alert_list: + AlertInfoFactory.create_batch( + 2, + event=factory.Sequence(lambda n: f"Event-{n}"), + alert=alert, + **alert_info_iterator, + ) + + # Delete all existing fake subscriptions for this user + UserAlertSubscription.objects.filter(user=user, name__startswith="[Fake]").delete() + for name, filters in SUBSCRIPTION_FILTERSET: + UserAlertSubscriptionFactory.create( + name=f"[Fake] {name}", + email_frequency=UserAlertSubscription.EmailFrequency.DAILY, + user=user, + filter_alert_country=c_nepal, + filter_alert_admin1s=[ad1_bagmati.pk], + **filters, + ) + # Tag new alerts to subscriptions + process_pending_subscription_alerts() diff --git a/apps/subscription/emails.py b/apps/subscription/emails.py index ce71941e..b9b66d58 100644 --- a/apps/subscription/emails.py +++ b/apps/subscription/emails.py @@ -1,5 +1,7 @@ +import logging from datetime import timedelta +from django.conf import settings from django.db import models from django.utils import timezone from django.utils.encoding import force_bytes @@ -9,8 +11,11 @@ from apps.user.models import EmailNotificationType, User from main.permalinks import Permalink from main.tokens import TokenManager +from utils.common import logger_log_extra from utils.emails import send_email +logger = logging.getLogger(__name__) + def generate_unsubscribe_user_alert_subscription_url(subscription: UserAlertSubscription) -> str: uid = urlsafe_base64_encode(force_bytes(subscription.pk)) @@ -22,7 +27,7 @@ def generate_unsubscribe_user_alert_subscription_url(subscription: UserAlertSubs def generate_user_alert_subscription_email_context( user: User, email_frequency: UserAlertSubscription.EmailFrequency, -) -> tuple[dict, models.QuerySet[UserAlertSubscription]]: +) -> tuple[bool, dict, models.QuerySet[UserAlertSubscription]]: # NOTE: Number of subscription is static and less than UserAlertSubscription.LIMIT_PER_USER subscription_qs = UserAlertSubscription.objects.filter(user=user, email_frequency=email_frequency) @@ -31,62 +36,90 @@ def generate_user_alert_subscription_email_context( elif email_frequency == UserAlertSubscription.EmailFrequency.WEEKLY: from_datetime_threshold = timezone.now() - timedelta(days=7) elif email_frequency == UserAlertSubscription.EmailFrequency.MONTHLY: - # TODO: Calculate days instead of using 30 days + # TODO: Calculate month days instead of using 30 days from_datetime_threshold = timezone.now() - timedelta(days=30) - subscription_data = [ - { - 'subscription': subscription, - 'unsubscribe_url': generate_unsubscribe_user_alert_subscription_url(subscription), - 'latest_alerts': [ - subscription_alert.alert - # NOTE: N+1 query, but N < 10 for now - # TODO: Index/partition alert__sent column? - for subscription_alert in ( - SubscriptionAlert.objects.select_related('alert') - .filter( - subscription=subscription, - alert__sent__gte=from_datetime_threshold, - ) - .order_by('-alert__sent')[:5] - ) - ], + def _alert_data(alert): + # TODO: Fix N+1 for alert.infos.first() and alert.admin1s + info = alert.infos.first() + return { + "url": Permalink.alert_detail(alert.pk), + "name": info and info.event or f"Alert #{alert.pk}", + "urgency": info and info.urgency or '-', + "severity": info and info.severity or '-', + "certainty": info and info.certainty or '-', + "admins": ",".join(list(alert.admin1s.values_list("name", flat=True))) or '-', } - for subscription in subscription_qs - ] + + subscription_data = [] + for subscription in subscription_qs.iterator(): + latest_alerts = [ + _alert_data(subscription_alert.alert) + # NOTE: N+1 query, but N < 10 for now + # TODO: Index/partition alert__sent column? + for subscription_alert in ( + SubscriptionAlert.objects.select_related('alert') + .filter( + subscription=subscription, + alert__sent__gte=from_datetime_threshold, + ) + .order_by('-alert__sent')[:5] + ) + ] + if latest_alerts: + subscription_data.append( + { + 'subscription': subscription, + 'url': Permalink.subscription_detail(subscription.pk), + 'unsubscribe_url': generate_unsubscribe_user_alert_subscription_url(subscription), + 'latest_alerts': latest_alerts, + } + ) context = { - 'subscriptions': subscription_data, + 'subscriptions_data': subscription_data, } - return context, subscription_qs + return len(context["subscriptions_data"]) > 0, context, subscription_qs def send_user_alert_subscription_email(user: User, email_frequency: UserAlertSubscription.EmailFrequency): - context, subscription_qs = generate_user_alert_subscription_email_context(user, email_frequency) + have_data, context, subscription_qs = generate_user_alert_subscription_email_context(user, email_frequency) sent_at = timezone.now() - send_email( - user=user, - email_type=EmailNotificationType.ALERT_SUBSCRIPTIONS, - subject="Daily Alerts", # TODO: Is this fine? - email_html_template='emails/subscription/body.html', - email_text_template='emails/subscription/body.txt', - context=context, - ) + if have_data: + send_email( + user=user, + email_type=EmailNotificationType.ALERT_SUBSCRIPTIONS, + subject=f"{settings.EMAIL_SUBJECT_PREFIX} {email_frequency.label}", + email_html_template='emails/subscription/body.html', + email_text_template='emails/subscription/body.txt', + context=context, + ) # Post action subscription_qs.update(email_last_sent_at=sent_at) def send_user_alert_subscriptions_email(email_frequency: UserAlertSubscription.EmailFrequency): - # TODO: Send in parallel if email service supports it + # TODO: Send in parallel if email service supports it? users_qs = User.objects.filter( id__in=UserAlertSubscription.objects.filter(email_frequency=email_frequency).values('user'), ) - # TODO: Handle failure for user in users_qs.iterator(): - # TODO: Trigger this as cronjob - # TODO: Pass timezone.now for ref time - send_user_alert_subscription_email(user, email_frequency) + # TODO: Trigger this as cronjob? + # TODO: Pass timezone.now for ref time? + try: + send_user_alert_subscription_email(user, email_frequency) + except Exception: + logger.error( + "Subscription: Failed to send email to user", + exc_info=True, + extra=logger_log_extra( + { + 'user_id': user.pk, + 'email_frequency': email_frequency, + } + ), + ) diff --git a/apps/subscription/views.py b/apps/subscription/views.py deleted file mode 100644 index 163873c9..00000000 --- a/apps/subscription/views.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.contrib.auth.decorators import login_required -from django.http import HttpResponse -from django.template import loader - -from .emails import generate_user_alert_subscription_email_context -from .models import UserAlertSubscription - -USER_ALERT_SUBSCRIPTION_EMAIL_PREVIEW_MESSAGE = """ - To use email_frequency in GET params, Please specify integer values. Default is Daily - Use this for reference - """ + ''.join( - [f"{frequency.label}: {frequency.value}" for frequency in UserAlertSubscription.EmailFrequency] -) - - -@login_required -def user_alert_subscription_email_preview(request): - try: - email_frequency = int( - request.GET.get( - "email_frequency", - UserAlertSubscription.EmailFrequency.DAILY, - ) - ) - if email_frequency not in UserAlertSubscription.EmailFrequency: - return HttpResponse(USER_ALERT_SUBSCRIPTION_EMAIL_PREVIEW_MESSAGE) - email_frequency = UserAlertSubscription.EmailFrequency(email_frequency) - except ValueError: - return HttpResponse(USER_ALERT_SUBSCRIPTION_EMAIL_PREVIEW_MESSAGE) - - context, _ = generate_user_alert_subscription_email_context( - request.user, - email_frequency, - ) - template = loader.get_template("emails/subscription/body.html") - return HttpResponse(template.render(context, request)) diff --git a/apps/user/emails.py b/apps/user/emails.py index e01b1f03..f6750f1a 100644 --- a/apps/user/emails.py +++ b/apps/user/emails.py @@ -1,4 +1,4 @@ -from django.utils import timezone +from django.conf import settings from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode @@ -17,14 +17,14 @@ def send_account_activation(user: User): uid = urlsafe_base64_encode(force_bytes(user.pk)) token = TokenManager.account_activation_token_generator.make_token(user) context = { - 'activation_url': Permalink.user_activation(uid, token), + "activation_url": Permalink.user_activation(uid, token), } send_email( user=user, email_type=EmailNotificationType.ACCOUNT_ACTIVATION, - subject="Account Activation", - email_html_template='emails/user/activation/body.html', - email_text_template='emails/user/activation/body.txt', + subject=f"{settings.EMAIL_SUBJECT_PREFIX} Account Activation", + email_html_template="emails/user/activation/body.html", + email_text_template="emails/user/activation/body.txt", context=context, ) @@ -41,17 +41,16 @@ def send_password_reset( uid = urlsafe_base64_encode(force_bytes(user.pk)) token = TokenManager.password_reset_token_generator.make_token(user) context = { - 'time': timezone.now(), - 'location': client_ip, - 'device': device_type, - 'password_reset_url': Permalink.user_password_reset(uid, token), + "location": client_ip, + "device": device_type, + "password_reset_url": Permalink.user_password_reset(uid, token), } send_email( user=user, email_type=EmailNotificationType.PASSWORD_RESET, - subject="Alert Hub: Password Reset", - email_html_template='emails/user/password_reset/body.html', - email_text_template='emails/user/password_reset/body.txt', + subject=f"{settings.EMAIL_SUBJECT_PREFIX} Password Reset", + email_html_template="emails/user/password_reset/body.html", + email_text_template="emails/user/password_reset/body.txt", context=context, ) return uid, token @@ -59,16 +58,15 @@ def send_password_reset( def send_password_changed_notification(user, client_ip, device_type): context = { - 'time': timezone.now(), - 'location': client_ip, - 'device': device_type, - 'frontend_forgot_password': Permalink.FORGOT_PASSWORD, + "location": client_ip, + "device": device_type, + "frontend_forgot_password": Permalink.FORGOT_PASSWORD, } send_email( user=user, email_type=EmailNotificationType.PASSWORD_CHANGED, - subject='Alert Hub: Password Changed', - email_html_template='emails/user/password_changed/body.html', - email_text_template='emails/user/password_changed/body.txt', + subject=f"{settings.EMAIL_SUBJECT_PREFIX} Password Changed", + email_html_template="emails/user/password_changed/body.html", + email_text_template="emails/user/password_changed/body.txt", context=context, ) diff --git a/apps/user/templates/email_reset.html b/apps/user/templates/email_reset.html deleted file mode 100644 index b238c5c7..00000000 --- a/apps/user/templates/email_reset.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
--Here is your verification code to change your email: -
- - - --If you did not request this email, you can ignore it. -
- -
-Best,
-IFRC Alert hub
-
-Welcome to IFRC Alert Hub. Use this verification code to complete sign up. -
- -- Verification Code: {{ verification_token }} -
- - - diff --git a/apps/user/templates/new_email_verify.html b/apps/user/templates/new_email_verify.html deleted file mode 100644 index f96e0c04..00000000 --- a/apps/user/templates/new_email_verify.html +++ /dev/null @@ -1,24 +0,0 @@ - - - --Here is your verification code to verify your new email address: -
- - - --If you did not request this email, you can ignore it. -
- -
-Best,
-IFRC Alert hub
-
-You requested a password reset. Here is your verification code to set a new password: -
- - - --If you did not request this, please ignore this email. -
- -
-Best,
-IFRC Alert hub
-
- {% block title %}{% endblock %} - | -- - | + +
---|
+
|
Thank you for using Alert Hub
-The Alert Hub team
-
+
+ Disclaimer!
+
+
+ The IFRC Alert Hub publishes only official alerts issued by recognised government alerting
+ agencies. Alerts are not issued or published by the IFRC or its member Red Cross and Red
+ Crescent National Societies. The IFRC takes no responsibility for the information contained
+ in the alerts, which is solely the responsibility of the issuing agency. The IFRC makes
+ every effort to identify broken government alert feeds and to highlight this information for
+ users of the Alert Hub. However, the IFRC does not maintain 24/7 monitoring of the status of
+ the government feeds and it is the responsibility of Alert Hub users to identify redundant
+ sources of hazard alert information.
+
+ |
+
+
+ If you need any help, don’t hesitate to reach out to us at
+ im@ifrc.org |
+
-{% for subscription in subscriptions %}
-Subscription: {{ subscription.name }}
-
-Latest alerts:
- {% for alert in subscription.latest_alerts %}
- - {{ alert.url }} -- {{ alert.sent }}
-
+{% extends "emails/base.html" %}
+{% load custom_tags %}
+{% block title %}
+You got new
Alerts!
+{% endblock %}
+
+{% block title_description %}
+
We have some new alerts that you are subscribed to.
+{% endblock %} + +{% block body %} + +{% for subscription_data in subscriptions_data %} + +
+
+ Alert Name
+
+ |
+
+
+ Urgency
+
+ |
+
+
+ Severity
+
+ |
+
+
+ Certainty
+
+ |
+
+
+ Admin1
+
+ |
+
+ + {{ alert_data.name }} + + | +
+
+ {{ alert_data.urgency }}
+
+ |
+
+
+ {{ alert_data.severity }}
+
+ |
+
+
+ {{ alert_data.certainty }}
+
+ |
+
+
+ {{ alert_data.admins|truncatechars:40 }}
+
+ |
+
-
- - +{% endblock %} diff --git a/templates/emails/subscription/body.txt b/templates/emails/subscription/body.txt index e69de29b..dee71a81 100644 --- a/templates/emails/subscription/body.txt +++ b/templates/emails/subscription/body.txt @@ -0,0 +1,15 @@ +{% extends "emails/base.txt" %} +{% block title %}You got new Alerts!{% endblock %} +{% block title_description %}We have some new alerts that you are subscribed to. {% endblock %} +{% block body %}{% for subscription_data in subscriptions_data %} +Subscription: {{ subscription_data.subscription.name }} | {{ subscription_data.subscription.filter_alert_country.name }} +URL: {{ subscription_data.url }} +Unsubscribe: {{ subscription_data.unsubscribe_url }} +Alerts: {% for alert_data in subscription_data.latest_alerts %} + Alert Name: {{ alert_data.name }} + Alert Url: {{ alert_data.url }} + Alert Urgency: {{ alert_data.urgency }} + Alert Severity: {{ alert_data.severity }} + Alert certainty: {{ alert_data.certainty }} + Alert Admins: {{ alert_data.admins|truncatechars:40}} +{% endfor %}{% endfor %}{% endblock %} diff --git a/templates/emails/user/activation/body.html b/templates/emails/user/activation/body.html index 27ce8f0c..0b02ed5e 100644 --- a/templates/emails/user/activation/body.html +++ b/templates/emails/user/activation/body.html @@ -1,15 +1,40 @@ {% extends "emails/base.html" %} -{% block body %} -- Please click on the link below to activate your account: -
- - Activate your account - -Please open the link below to activate your account:
+{% endblock %} + +{% block body %} + +{% endblock %} \ No newline at end of file diff --git a/templates/emails/user/activation/body.txt b/templates/emails/user/activation/body.txt index 0b03d557..6aee60b1 100644 --- a/templates/emails/user/activation/body.txt +++ b/templates/emails/user/activation/body.txt @@ -1,8 +1,5 @@ {% extends "emails/base.txt" %} -{% block body %} - -Please open the link below to activate your account: -"{{ activation_url }}" - -{% endblock %} +{% block title %}IFRC Alert Hub account activation{% endblock %} +{% block body %}Please open the link below to activate your account: +Activate your account: {{ activation_url }}{% endblock%} diff --git a/templates/emails/user/password_changed/body.html b/templates/emails/user/password_changed/body.html index c3a6b120..5a6d31a5 100644 --- a/templates/emails/user/password_changed/body.html +++ b/templates/emails/user/password_changed/body.html @@ -1,64 +1,58 @@ {% extends "emails/base.html" %} {% block head %} - + .actions-container { + margin: 23px; + } + {% endblock %} {% block title %} - Your password has been changed successfully. +Hi there {{ user.first_name|default:"User" }}, +Your password has been changed successfully. {% endblock %} {% block body %} -
- Hi there {{ user.first_name }},
-
- Your password has been changed successfully.
+{% if location or device %}
+
Location | +{{location}} | +
Device | +{{device}} | +
+ If you are aware of this change, you can disregard this email. + If this was not triggered by you, please reset your password.
-Time | -{{time}} | -
---|---|
Location | -{{location}} | -
Device | -{{device}} | -
- If you are aware of this change, you can disregard this email. - If this was not triggered by you, please reset your password. -
- - Reset Password - -It seems you've forgotten your password.
+{% endblock %} + + {% block body %} -- It seems you've forgotten your password. +
+ If you didn't request to reset your password, you may simply ignore this email.
-- If you didn't request to reset your password, you may simply ignore this email. -
- - Reset your password - -More detail on password reset trigger
-Time | -{{time}} | -||||||
---|---|---|---|---|---|---|---|
Location | -{{location}} | +
Location | +{{location}} |
Device | -{{device}} | +
---|---|
Device | +{{device}} |