diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..71f5d49 --- /dev/null +++ b/.env.example @@ -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 = 'testing@datamade.us' + +BASE_URL = 'localhost:8000' \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7d95fd7..8d12512 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docsearch/admin.py b/docsearch/admin.py index ff86188..11eb6db 100644 --- a/docsearch/admin.py +++ b/docsearch/admin.py @@ -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) diff --git a/docsearch/local_settings.example.py b/docsearch/local_settings.example.py index 1afed30..26b8584 100644 --- a/docsearch/local_settings.example.py +++ b/docsearch/local_settings.example.py @@ -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', 'testing@datamade.us') + EMAIL_USE_TLS = True + + DEFAULT_FROM_EMAIL = EMAIL_HOST_USER diff --git a/docsearch/management/commands/send_expiration_email.py b/docsearch/management/commands/send_expiration_email.py new file mode 100644 index 0000000..bb38f27 --- /dev/null +++ b/docsearch/management/commands/send_expiration_email.py @@ -0,0 +1,119 @@ +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 +from django.db import transaction + +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 attach_csv(self, email, near_expired, date): + ''' + If any expiring licenses exist, attach a csv report to the email + ''' + if len(near_expired) > 0: + attachment = self.generate_csv(near_expired) + email.attach('expiring_licenses_{}.csv'.format(str(date.year)), attachment.getvalue()) + + 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 prep_email(self, subscribers, near_expired, date_range_start, date_range_end, subject): + ''' + Assign recipients and build the contents of the email + ''' + recipients = [] + for sub in subscribers: + recipients.append(sub.user.email) + sub.notification_date = date_range_end + sub.save() + + body = render_to_string( + 'emails/license_expiration.html', + { + "n_licenses": str(len(near_expired)), + "date_range_start": date_range_start.strftime("%m/%d/%Y"), + "date_range_end": date_range_end.strftime("%m/%d/%Y"), + }, + ) + + email = EmailMessage( + subject=subject, + body=body, + to=recipients, + ) + email.content_subtype = 'html' + + self.attach_csv(email, near_expired, date_range_start) + + return email + + @transaction.atomic + 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) + self.stdout.write(f"{len(near_expired)} license(s) found") + + subscribers = NotificationSubscription.objects.filter(notification_date=today) + subject = "Licenses expiring in the next 12 months" + email = self.prep_email( + subscribers=subscribers, + near_expired=near_expired, + date_range_start=today, + date_range_end=one_year_from_now, + subject=subject + ) + + self.stdout.write("Sending emails...") + email.send() + self.stdout.write(self.style.SUCCESS("Emails sent!")) diff --git a/docsearch/migrations/0015_auto_20230829_0939.py b/docsearch/migrations/0015_auto_20230829_0939.py new file mode 100644 index 0000000..acaac35 --- /dev/null +++ b/docsearch/migrations/0015_auto_20230829_0939.py @@ -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)), + ], + ), + ] diff --git a/docsearch/models.py b/docsearch/models.py index 171f40b..f94ca0f 100644 --- a/docsearch/models.py +++ b/docsearch/models.py @@ -395,3 +395,8 @@ class Survey(BaseDocumentModel): class Title(BaseDocumentModel): control_number = models.CharField(max_length=255, validators=[validate_positive_int]) source_file = models.FileField(upload_to='TITLES', validators=[FileExtensionValidator(['pdf'])]) + + +class NotificationSubscription(models.Model): + notification_date = models.DateField() + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) diff --git a/docsearch/templates/emails/license_expiration.html b/docsearch/templates/emails/license_expiration.html new file mode 100644 index 0000000..4ca7676 --- /dev/null +++ b/docsearch/templates/emails/license_expiration.html @@ -0,0 +1,15 @@ +
Hello,
+ ++ There are + {% if n_licenses != '0' %} + {{ n_licenses }} + {% else %} + no + {% endif %} + license(s) that will expire between {{ date_range_start }} and {{ date_range_end }}. + + {% if n_licenses != '0' %} + Please see attached csv file for more details. + {% endif %} +
diff --git a/docsearch/validators.py b/docsearch/validators.py index f991b62..8bc5108 100644 --- a/docsearch/validators.py +++ b/docsearch/validators.py @@ -1,4 +1,5 @@ from django.core.exceptions import ValidationError +from functools import wraps import re def validate_positive_int(value): @@ -14,6 +15,7 @@ def validate_positive_int(value): def validate_int_range(min, max): # Check that a 2 integer range is between the min and max + @wraps(validate_int_range) def validator(value): # Change the NumericRange obj to an indexable array of ints value_list = value.__str__().strip('][').split(', ') @@ -38,6 +40,7 @@ def validator(value): def validate_int_btwn(min, max): # Check that a single integer is between the min and max + @wraps(validate_int_btwn) def validator(value): if int(value) < min or int(value) > max: raise ValidationError( @@ -55,6 +58,7 @@ def validator(value): def validate_int_array(min, max): # Check that a list/array of integers is between the min and max + @wraps(validate_int_array) def validator(value): valid = True invalid_ints = [] diff --git a/scripts/after_install.sh b/scripts/after_install.sh index a828bcf..f0e569b 100644 --- a/scripts/after_install.sh +++ b/scripts/after_install.sh @@ -79,3 +79,9 @@ echo "DEPLOYMENT_ID='$DEPLOYMENT_ID'" > $PROJECT_DIR/docsearch/deployment.py # Make sure Solr is running (docker ps | grep document-search-solr) || (cd $PROJECT_DIR && docker-compose up -d solr) + +# Move the crontab from the scripts directory to `/etc/cron.d` +mv $PROJECT_DIR/scripts/document-search-cronjobs /etc/cron.d/document-search-cronjobs + +# Adjust the permissions, so that the Cron service can effectively interact with the file +chmod 644 /etc/cron.d/document-search-cronjobs diff --git a/scripts/document-search-cronjobs b/scripts/document-search-cronjobs new file mode 100644 index 0000000..f3b4529 --- /dev/null +++ b/scripts/document-search-cronjobs @@ -0,0 +1,3 @@ +# /etc/cron.d/document-search-cronjobs +0 9 * * * datamade python manage.py send_expiration_email +# You need a newline at the end of your crontab, or cron ignores the file. \ No newline at end of file