Skip to content

Commit b1e8a61

Browse files
Feature/subscription improvements (#105)
* Update subscriptions email template - Add helper script to create fake alerts - Remove legacy templates - Add helper dev views to update templates * Fix email templates * Fix email raw text templates - Truncate alert admins to 40 chars * Add error catch - Update TODOs - Upgrade pyright --------- Co-authored-by: thenav56 <ayernavin@gmail.com>
1 parent d46701d commit b1e8a61

File tree

26 files changed

+735
-374
lines changed

26 files changed

+735
-374
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ repos:
3131
- id: flake8
3232

3333
- repo: https://github.com/RobertCraigie/pyright-python
34-
rev: v1.1.389
34+
rev: v1.1.390
3535
hooks:
3636
- id: pyright
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import factory
2+
from django.conf import settings
3+
from django.core.management.base import BaseCommand
4+
from django.utils import timezone
5+
6+
from apps.cap_feed.factories import (
7+
Admin1Factory,
8+
AlertFactory,
9+
AlertInfoFactory,
10+
CountryFactory,
11+
FeedFactory,
12+
RegionFactory,
13+
)
14+
from apps.cap_feed.models import Admin1, AlertInfo, Country, Feed, Region
15+
from apps.subscription.factories import UserAlertSubscriptionFactory
16+
from apps.subscription.models import UserAlertSubscription
17+
from apps.subscription.tasks import process_pending_subscription_alerts
18+
from apps.user.models import User
19+
20+
SUBSCRIPTION_FILTERSET = [
21+
(
22+
"Sub-1",
23+
dict(
24+
filter_alert_urgencies=[AlertInfo.Urgency.IMMEDIATE],
25+
filter_alert_severities=[],
26+
filter_alert_certainties=[],
27+
filter_alert_categories=[],
28+
),
29+
),
30+
(
31+
"Sub-1",
32+
dict(
33+
filter_alert_urgencies=[AlertInfo.Urgency.IMMEDIATE, AlertInfo.Urgency.EXPECTED],
34+
filter_alert_severities=[AlertInfo.Severity.MODERATE],
35+
filter_alert_certainties=[],
36+
filter_alert_categories=[],
37+
),
38+
),
39+
(
40+
"Sub-3",
41+
dict(
42+
filter_alert_urgencies=[AlertInfo.Urgency.EXPECTED],
43+
filter_alert_severities=[AlertInfo.Severity.MODERATE],
44+
filter_alert_certainties=[AlertInfo.Certainty.LIKELY],
45+
filter_alert_categories=[],
46+
),
47+
),
48+
# Everything
49+
(
50+
"All",
51+
dict(
52+
filter_alert_urgencies=[],
53+
filter_alert_severities=[],
54+
filter_alert_certainties=[],
55+
filter_alert_categories=[],
56+
),
57+
),
58+
]
59+
60+
61+
class Command(BaseCommand):
62+
def add_arguments(self, parser):
63+
parser.add_argument('--user-email', dest='user_email', required=True)
64+
65+
def handle(self, *_, **kwargs):
66+
if not settings.ALLOW_FAKE_DATA:
67+
self.stdout.write(
68+
self.style.WARNING(
69+
"Add ALLOW_FAKE_DATA=true and DJANGO_DEBUG=true to environment variable to allow fake data generation"
70+
)
71+
)
72+
return
73+
74+
user_email = kwargs['user_email']
75+
user = User.objects.get(email=user_email)
76+
77+
FAKE_REGION_NAME = "[Fake] Asia"
78+
FAKE_COUNTRY_NAME = "[Fake] Nepal"
79+
FAKE_ADMIN1_NAME = "[Fake] Bagmati"
80+
FAKE_FEED_URL = "https://fake-feed.com/123"
81+
82+
if (r_asia := Region.objects.filter(name=FAKE_REGION_NAME).first()) is None:
83+
r_asia = RegionFactory.create(name=FAKE_REGION_NAME)
84+
if (c_nepal := Country.objects.filter(name=FAKE_COUNTRY_NAME).first()) is None:
85+
c_nepal = CountryFactory.create(name=FAKE_COUNTRY_NAME, region=r_asia)
86+
if (ad1_bagmati := Admin1.objects.filter(name=FAKE_ADMIN1_NAME).first()) is None:
87+
ad1_bagmati = Admin1Factory.create(name=FAKE_ADMIN1_NAME, country=c_nepal)
88+
89+
if (feed1 := Feed.objects.filter(url=FAKE_FEED_URL).first()) is None:
90+
feed1 = FeedFactory.create(url=FAKE_FEED_URL, country=c_nepal, polling_interval=False)
91+
92+
random_id = timezone.now().isoformat()
93+
alert_list = AlertFactory.create_batch(
94+
50,
95+
url=factory.Sequence(lambda n: f"https://alert-{random_id}-{n}.com/test"),
96+
feed=feed1,
97+
country=c_nepal,
98+
admin1s=[ad1_bagmati],
99+
sent=timezone.now().date(),
100+
)
101+
102+
alert_info_iterator = dict(
103+
category=factory.Iterator(AlertInfo.Category.choices, getter=lambda c: c[0]),
104+
urgency=factory.Iterator(AlertInfo.Urgency.choices, getter=lambda c: c[0]),
105+
severity=factory.Iterator(AlertInfo.Severity.choices, getter=lambda c: c[0]),
106+
certainty=factory.Iterator(AlertInfo.Certainty.choices, getter=lambda c: c[0]),
107+
)
108+
for alert in alert_list:
109+
AlertInfoFactory.create_batch(
110+
2,
111+
event=factory.Sequence(lambda n: f"Event-{n}"),
112+
alert=alert,
113+
**alert_info_iterator,
114+
)
115+
116+
# Delete all existing fake subscriptions for this user
117+
UserAlertSubscription.objects.filter(user=user, name__startswith="[Fake]").delete()
118+
for name, filters in SUBSCRIPTION_FILTERSET:
119+
UserAlertSubscriptionFactory.create(
120+
name=f"[Fake] {name}",
121+
email_frequency=UserAlertSubscription.EmailFrequency.DAILY,
122+
user=user,
123+
filter_alert_country=c_nepal,
124+
filter_alert_admin1s=[ad1_bagmati.pk],
125+
**filters,
126+
)
127+
# Tag new alerts to subscriptions
128+
process_pending_subscription_alerts()

apps/subscription/emails.py

Lines changed: 70 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import logging
12
from datetime import timedelta
23

4+
from django.conf import settings
35
from django.db import models
46
from django.utils import timezone
57
from django.utils.encoding import force_bytes
@@ -9,8 +11,11 @@
911
from apps.user.models import EmailNotificationType, User
1012
from main.permalinks import Permalink
1113
from main.tokens import TokenManager
14+
from utils.common import logger_log_extra
1215
from utils.emails import send_email
1316

17+
logger = logging.getLogger(__name__)
18+
1419

1520
def generate_unsubscribe_user_alert_subscription_url(subscription: UserAlertSubscription) -> str:
1621
uid = urlsafe_base64_encode(force_bytes(subscription.pk))
@@ -22,7 +27,7 @@ def generate_unsubscribe_user_alert_subscription_url(subscription: UserAlertSubs
2227
def generate_user_alert_subscription_email_context(
2328
user: User,
2429
email_frequency: UserAlertSubscription.EmailFrequency,
25-
) -> tuple[dict, models.QuerySet[UserAlertSubscription]]:
30+
) -> tuple[bool, dict, models.QuerySet[UserAlertSubscription]]:
2631
# NOTE: Number of subscription is static and less than UserAlertSubscription.LIMIT_PER_USER
2732
subscription_qs = UserAlertSubscription.objects.filter(user=user, email_frequency=email_frequency)
2833

@@ -31,62 +36,90 @@ def generate_user_alert_subscription_email_context(
3136
elif email_frequency == UserAlertSubscription.EmailFrequency.WEEKLY:
3237
from_datetime_threshold = timezone.now() - timedelta(days=7)
3338
elif email_frequency == UserAlertSubscription.EmailFrequency.MONTHLY:
34-
# TODO: Calculate days instead of using 30 days
39+
# TODO: Calculate month days instead of using 30 days
3540
from_datetime_threshold = timezone.now() - timedelta(days=30)
3641

37-
subscription_data = [
38-
{
39-
'subscription': subscription,
40-
'unsubscribe_url': generate_unsubscribe_user_alert_subscription_url(subscription),
41-
'latest_alerts': [
42-
subscription_alert.alert
43-
# NOTE: N+1 query, but N < 10 for now
44-
# TODO: Index/partition alert__sent column?
45-
for subscription_alert in (
46-
SubscriptionAlert.objects.select_related('alert')
47-
.filter(
48-
subscription=subscription,
49-
alert__sent__gte=from_datetime_threshold,
50-
)
51-
.order_by('-alert__sent')[:5]
52-
)
53-
],
42+
def _alert_data(alert):
43+
# TODO: Fix N+1 for alert.infos.first() and alert.admin1s
44+
info = alert.infos.first()
45+
return {
46+
"url": Permalink.alert_detail(alert.pk),
47+
"name": info and info.event or f"Alert #{alert.pk}",
48+
"urgency": info and info.urgency or '-',
49+
"severity": info and info.severity or '-',
50+
"certainty": info and info.certainty or '-',
51+
"admins": ",".join(list(alert.admin1s.values_list("name", flat=True))) or '-',
5452
}
55-
for subscription in subscription_qs
56-
]
53+
54+
subscription_data = []
55+
for subscription in subscription_qs.iterator():
56+
latest_alerts = [
57+
_alert_data(subscription_alert.alert)
58+
# NOTE: N+1 query, but N < 10 for now
59+
# TODO: Index/partition alert__sent column?
60+
for subscription_alert in (
61+
SubscriptionAlert.objects.select_related('alert')
62+
.filter(
63+
subscription=subscription,
64+
alert__sent__gte=from_datetime_threshold,
65+
)
66+
.order_by('-alert__sent')[:5]
67+
)
68+
]
69+
if latest_alerts:
70+
subscription_data.append(
71+
{
72+
'subscription': subscription,
73+
'url': Permalink.subscription_detail(subscription.pk),
74+
'unsubscribe_url': generate_unsubscribe_user_alert_subscription_url(subscription),
75+
'latest_alerts': latest_alerts,
76+
}
77+
)
5778

5879
context = {
59-
'subscriptions': subscription_data,
80+
'subscriptions_data': subscription_data,
6081
}
6182

62-
return context, subscription_qs
83+
return len(context["subscriptions_data"]) > 0, context, subscription_qs
6384

6485

6586
def send_user_alert_subscription_email(user: User, email_frequency: UserAlertSubscription.EmailFrequency):
66-
context, subscription_qs = generate_user_alert_subscription_email_context(user, email_frequency)
87+
have_data, context, subscription_qs = generate_user_alert_subscription_email_context(user, email_frequency)
6788
sent_at = timezone.now()
6889

69-
send_email(
70-
user=user,
71-
email_type=EmailNotificationType.ALERT_SUBSCRIPTIONS,
72-
subject="Daily Alerts", # TODO: Is this fine?
73-
email_html_template='emails/subscription/body.html',
74-
email_text_template='emails/subscription/body.txt',
75-
context=context,
76-
)
90+
if have_data:
91+
send_email(
92+
user=user,
93+
email_type=EmailNotificationType.ALERT_SUBSCRIPTIONS,
94+
subject=f"{settings.EMAIL_SUBJECT_PREFIX} {email_frequency.label}",
95+
email_html_template='emails/subscription/body.html',
96+
email_text_template='emails/subscription/body.txt',
97+
context=context,
98+
)
7799

78100
# Post action
79101
subscription_qs.update(email_last_sent_at=sent_at)
80102

81103

82104
def send_user_alert_subscriptions_email(email_frequency: UserAlertSubscription.EmailFrequency):
83-
# TODO: Send in parallel if email service supports it
105+
# TODO: Send in parallel if email service supports it?
84106
users_qs = User.objects.filter(
85107
id__in=UserAlertSubscription.objects.filter(email_frequency=email_frequency).values('user'),
86108
)
87109

88-
# TODO: Handle failure
89110
for user in users_qs.iterator():
90-
# TODO: Trigger this as cronjob
91-
# TODO: Pass timezone.now for ref time
92-
send_user_alert_subscription_email(user, email_frequency)
111+
# TODO: Trigger this as cronjob?
112+
# TODO: Pass timezone.now for ref time?
113+
try:
114+
send_user_alert_subscription_email(user, email_frequency)
115+
except Exception:
116+
logger.error(
117+
"Subscription: Failed to send email to user",
118+
exc_info=True,
119+
extra=logger_log_extra(
120+
{
121+
'user_id': user.pk,
122+
'email_frequency': email_frequency,
123+
}
124+
),
125+
)

apps/subscription/views.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

0 commit comments

Comments
 (0)