Skip to content
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

Merged
merged 11 commits into from
Sep 6, 2023
Merged
7 changes: 7 additions & 0 deletions .env.example
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'
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ services:
# Docker automatically recognizes your changes.
- .:/app
- ${PWD}/docsearch/local_settings.dev.py:/app/docsearch/local_settings.py
env_file:
- .env
command: python manage.py runserver 0.0.0.0:8000

migration:
Expand Down
4 changes: 4 additions & 0 deletions docsearch/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@

admin.site.site_header = 'Forest Preserve of Cook County Document Search Admin'
admin.site.site_title = 'Document Search'

class NotificationSubscriptionAdmin(admin.ModelAdmin):
list_display = ["user"]
admin.site.register(models.NotificationSubscription, NotificationSubscriptionAdmin)
15 changes: 15 additions & 0 deletions docsearch/local_settings.example.py
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!
Expand Down Expand Up @@ -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
100 changes: 100 additions & 0 deletions docsearch/management/commands/send_expiration_email.py
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:
Copy link
Contributor

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?

Copy link
Collaborator Author

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

Copy link
Contributor

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!

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()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 for sub in subscribers loop on line 69?

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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

248 changes: 248 additions & 0 deletions docsearch/migrations/0015_auto_20230829_0939.py
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)),
],
),
]
Loading