-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Send license expiration notification emails #107
Changes from 10 commits
3b816dd
0304798
3f52f34
e229582
0170a54
0c81bbc
9955564
b6ead90
64ef257
b7018a5
149d7a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
# For local dev, copy this file as '.env' and paste the mandrill api key as the password | ||
EMAIL_HOST_PASSWORD = 'super-secret' | ||
EMAIL_HOST = 'smtp.mandrillapp.com' | ||
EMAIL_PORT = 587 | ||
EMAIL_HOST_USER = '[email protected]' | ||
|
||
BASE_URL = 'localhost:8000' |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ | ||
import os | ||
import socket | ||
|
||
# SECURITY WARNING: keep the secret key used in production secret! | ||
|
@@ -52,3 +53,17 @@ | |
'ADMIN_URL': 'http://solr:8983/solr/admin/cores' | ||
}, | ||
} | ||
|
||
BASE_URL = os.getenv('BASE_URL', 'localhost:8000') | ||
|
||
try: | ||
EMAIL_HOST_PASSWORD = os.environ['EMAIL_HOST_PASSWORD'] | ||
except KeyError: | ||
print('Email password not found, email sending not turned on.') | ||
else: | ||
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.mandrillapp.com') | ||
EMAIL_PORT = os.getenv('EMAIL_PORT', 587) | ||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '[email protected]') | ||
EMAIL_USE_TLS = True | ||
|
||
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
from docsearch.models import License, NotificationSubscription | ||
from docsearch.settings import BASE_URL | ||
from django.core.management.base import BaseCommand | ||
from django.core.mail import EmailMessage | ||
from django.template.loader import render_to_string | ||
|
||
import datetime | ||
from dateutil.relativedelta import relativedelta | ||
from csv import DictWriter | ||
from io import StringIO | ||
|
||
|
||
class Command(BaseCommand): | ||
help = ( | ||
"Send notification emails for nearly expired licenses." | ||
) | ||
|
||
def generate_csv(self, licenses): | ||
''' | ||
Create a file in memory with details of licenses, intended to be written later | ||
''' | ||
file = StringIO() | ||
field_names = ["license_number", "end_date", "url"] | ||
header = ["License Number", "End Date", "Link"] | ||
writer = DictWriter(file, fieldnames=field_names) | ||
|
||
writer.writer.writerow(header) | ||
for l in licenses: | ||
writer.writerow(l) | ||
|
||
return file | ||
|
||
def get_near_expired_licenses(self, present, future_limit): | ||
''' | ||
Returns all licenses expiring between now and a future date | ||
''' | ||
dates_to_exclude = ['continuous', 'indefinite', 'perpetual', 'cancelled', 'TBD'] | ||
licenses = License.objects.exclude(end_date=None).exclude(end_date__in=dates_to_exclude) | ||
|
||
result = [] | ||
for l in licenses: | ||
# The format is YYYY-MM-DD | ||
year, month, day = [int(time) for time in l.end_date.split("-")] | ||
|
||
end_date = datetime.date(year, month, day) | ||
if present <= end_date and end_date <= future_limit: | ||
obj = { | ||
"url": BASE_URL + l.get_absolute_url(), | ||
"license_number": l.license_number, | ||
"end_date": end_date | ||
} | ||
|
||
result.append(obj) | ||
|
||
return result | ||
|
||
def handle(self, *args, **options): | ||
today = datetime.date.today() | ||
|
||
if NotificationSubscription.objects.filter(notification_date=today).exists(): | ||
self.stdout.write("Checking for licenses expiring soon...") | ||
|
||
one_year_from_now = datetime.date.today() + relativedelta(years=1) | ||
near_expired = self.get_near_expired_licenses(today, one_year_from_now) | ||
subscribers = NotificationSubscription.objects.filter(notification_date=today) | ||
|
||
# Prepare email | ||
recipients = [] | ||
for sub in subscribers: | ||
recipients.append(sub.user.email) | ||
|
||
body = render_to_string( | ||
'emails/license_expiration.html', | ||
{ | ||
"n_licenses": str(len(near_expired)), | ||
"date_range_start": today.strftime("%m/%d/%Y"), | ||
"date_range_end": one_year_from_now.strftime("%m/%d/%Y"), | ||
}, | ||
) | ||
|
||
email = EmailMessage( | ||
subject="Licenses expiring in the next 12 months", | ||
body=body, | ||
to=recipients, | ||
) | ||
|
||
self.stdout.write(f"{len(near_expired)} license(s) found") | ||
if len(near_expired) > 0: | ||
attachment = self.generate_csv(near_expired) | ||
email.attach('expiring_licenses_{}.csv'.format(str(today.year)), attachment.getvalue()) | ||
|
||
self.stdout.write("Sending emails...") | ||
email.content_subtype = 'html' | ||
email.send() | ||
self.stdout.write(self.style.SUCCESS("Emails sent!")) | ||
|
||
# Update each users' notification date after sending emails | ||
for sub in subscribers: | ||
sub.notification_date = one_year_from_now | ||
sub.save() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think of this line? Is it worth leaving this out of the earlier The hope was that users wouldn't get their notification dates updated if the command fails midway. That way if this command were to run again, they could still have a chance to be included. Lmk if that makes zero sense haha. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that's a good thing to consider. this works fine, though it might be more idiomatic django to wrap it in a transaction: https://medium.com/@shivanikakrecha/transaction-atomic-in-django-87b787ead793 but that would be a lot of logic in the transaction — if you did that, then you could encapsulate more of the email sending code into functions that are called within the transaction. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,248 @@ | ||
# Generated by Django 2.2.13 on 2023-08-29 14:39 | ||
|
||
from django.conf import settings | ||
import django.contrib.postgres.fields | ||
import django.core.validators | ||
from django.db import migrations, models | ||
import django.db.models.deletion | ||
import docsearch.models | ||
import docsearch.validators | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
migrations.swappable_dependency(settings.AUTH_USER_MODEL), | ||
('docsearch', '0014_flatdrawing_building_id'), | ||
] | ||
|
||
operations = [ | ||
migrations.AlterField( | ||
model_name='book', | ||
name='range', | ||
field=docsearch.models.InclusiveIntegerRangeField(blank=True, help_text='Specify the lower and upper bounds for the range of values represented by this field. If this field only has one value, set it as both the lower and upper bounds.', max_length=255, null=True, validators=[docsearch.validators.validate_int_range]), | ||
), | ||
migrations.AlterField( | ||
model_name='book', | ||
name='section', | ||
field=docsearch.models.InclusiveIntegerRangeField(blank=True, help_text='Specify the lower and upper bounds for the range of values represented by this field. If this field only has one value, set it as both the lower and upper bounds.', max_length=255, null=True, validators=[docsearch.validators.validate_int_range]), | ||
), | ||
migrations.AlterField( | ||
model_name='book', | ||
name='source_file', | ||
field=models.FileField(upload_to='BOOKS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='controlmonumentmap', | ||
name='part_of_section', | ||
field=models.CharField(blank=True, choices=[('E1/2', 'E1/2'), ('W1/2', 'W1/2')], max_length=4, null=True), | ||
), | ||
migrations.AlterField( | ||
model_name='controlmonumentmap', | ||
name='range', | ||
field=models.PositiveIntegerField(null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='controlmonumentmap', | ||
name='section', | ||
field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(null=True), help_text='Set multiple values for this field by separating them with commas. E.g. to save the values 1, 2, and 3, record them as 1,2,3.', size=None, validators=[docsearch.validators.validate_int_array]), | ||
), | ||
migrations.AlterField( | ||
model_name='controlmonumentmap', | ||
name='source_file', | ||
field=models.FileField(upload_to='CONTROL_MONUMENT_MAPS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='deeptunnel', | ||
name='source_file', | ||
field=models.FileField(upload_to='DEEP_PARCEL_SURPLUS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='dossier', | ||
name='document_number', | ||
field=models.CharField(max_length=3, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='dossier', | ||
name='file_number', | ||
field=models.CharField(max_length=255, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='dossier', | ||
name='source_file', | ||
field=models.FileField(upload_to='DOSSIER_FILES', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='easement', | ||
name='easement_number', | ||
field=models.CharField(blank=True, max_length=255, null=True, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='easement', | ||
name='source_file', | ||
field=models.FileField(upload_to='EASEMENTS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='area', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='cad_file', | ||
field=models.FileField(blank=True, null=True, upload_to='', validators=[django.core.validators.FileExtensionValidator(['dwg', 'dxf', 'dgn', 'stl'])], verbose_name='CAD file'), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='cross_ref_area', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='cross_ref_section', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='date', | ||
field=models.CharField(blank=True, help_text='Enter the date as "YYYY-MM-DD"', max_length=255, null=True, validators=[docsearch.validators.validate_date]), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='number_of_sheets', | ||
field=models.CharField(blank=True, max_length=255, null=True, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='flatdrawing', | ||
name='source_file', | ||
field=models.FileField(upload_to='FLAT_DRAWINGS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='indexcard', | ||
name='source_file', | ||
field=models.FileField(upload_to='INDEX_CARDS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='diameter', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='end_date', | ||
field=models.CharField(blank=True, help_text='Enter the date as "YYYY-MM-DD"', max_length=255, null=True, validators=[docsearch.validators.validate_date]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='license_number', | ||
field=models.CharField(blank=True, help_text='Enter as a hyphenated string starting with "O" and ending with an integer (i.e. O-100)', max_length=255, null=True, validators=[docsearch.validators.validate_license_num]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='range', | ||
field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(null=True), default=list, help_text='Set multiple values for this field by separating them with commas. E.g. to save the values 1, 2, and 3, record them as 1,2,3.', size=None, validators=[docsearch.validators.validate_int_array]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='section', | ||
field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(null=True), default=list, help_text='Set multiple values for this field by separating them with commas. E.g. to save the values 1, 2, and 3, record them as 1,2,3.', size=None, validators=[docsearch.validators.validate_int_array]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='source_file', | ||
field=models.FileField(upload_to='LICENSES', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='status', | ||
field=models.CharField(blank=True, choices=[('TBD', 'TBD'), ('active', 'Active'), ('cancelled', 'Cancelled'), ('continuous', 'Continuous'), ('expired', 'Expired'), ('indefinite', 'Indefinite'), ('perpetual', 'Perpetual')], max_length=255, null=True), | ||
), | ||
migrations.AlterField( | ||
model_name='license', | ||
name='type', | ||
field=models.CharField(blank=True, choices=[('combined_sewer', 'Combined Sewer'), ('electric', 'Electric'), ('gas', 'Gas'), ('pipeline', 'Pipeline'), ('sanitary_sewer', 'Sanitary Sewer'), ('storm sewer', 'Storm Sewer'), ('telecom', 'Telecom'), ('water main', 'Water Main'), ('other', 'Other')], max_length=255, null=True), | ||
), | ||
migrations.AlterField( | ||
model_name='projectfile', | ||
name='area', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='projectfile', | ||
name='cabinet_number', | ||
field=models.CharField(blank=True, max_length=255, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='projectfile', | ||
name='section', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='projectfile', | ||
name='source_file', | ||
field=models.FileField(upload_to='PROJECT_FILES', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='rightofway', | ||
name='source_file', | ||
field=models.FileField(upload_to='RIGHT_OF_WAY', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='surplusparcel', | ||
name='source_file', | ||
field=models.FileField(upload_to='DEEP_PARCEL_SURPLUS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='cross_ref_area', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='cross_ref_section', | ||
field=models.PositiveIntegerField(blank=True, null=True, validators=[docsearch.validators.validate_int_btwn]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='date', | ||
field=models.CharField(blank=True, help_text='Enter the date as "YYYY-MM-DD"', max_length=255, null=True, validators=[docsearch.validators.validate_date]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='number_of_sheets', | ||
field=models.CharField(blank=True, max_length=255, null=True, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='range', | ||
field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(blank=True, null=True), help_text='Set multiple values for this field by separating them with commas. E.g. to save the values 1, 2, and 3, record them as 1,2,3.', size=None, validators=[docsearch.validators.validate_int_array]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='section', | ||
field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(blank=True, null=True), help_text='Set multiple values for this field by separating them with commas. E.g. to save the values 1, 2, and 3, record them as 1,2,3.', size=None, validators=[docsearch.validators.validate_int_array]), | ||
), | ||
migrations.AlterField( | ||
model_name='survey', | ||
name='source_file', | ||
field=models.FileField(upload_to='SURVEYS', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.AlterField( | ||
model_name='title', | ||
name='control_number', | ||
field=models.CharField(max_length=255, validators=[docsearch.validators.validate_positive_int]), | ||
), | ||
migrations.AlterField( | ||
model_name='title', | ||
name='source_file', | ||
field=models.FileField(upload_to='TITLES', validators=[django.core.validators.FileExtensionValidator(['pdf'])]), | ||
), | ||
migrations.CreateModel( | ||
name='NotificationSubscription', | ||
fields=[ | ||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||
('notification_date', models.DateField()), | ||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), | ||
], | ||
), | ||
] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you could move this to line 65 and skip the rest of the logic if
len(near_expired) == 0
. idk if we want to send an email if there are 0 or not?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i can definitely do that! i figured having a "hey don't worry about it, everything's okay" notification could be useful, but not sending the email in the first place would make it so those people would have one less thing to deal with, which could also be a nice QOL thing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
gotcha — this works as it is then!