diff --git a/readme.md b/readme.md index 842b82732fb6795b10be63d4985bdf9df5bb184c..ccf5b24c9408192a70bef30efb01cc3f952f3108 100644 --- a/readme.md +++ b/readme.md @@ -175,6 +175,16 @@ server { - extract static files and make them available via nginx: `./manage.py collectstatic` - you start application by starting gunicorn and nginx: `service gunicorn start`, `service nginx start` +## Cron jobs (weekly emails) + +If weekly emails are required then cron must be edited to fire periodically django function that send emails. + +``` +> crontab -e +SHELL=/bin/bash +*/30 * * * * source /var/www/scheduling-system/env/bin/activate && python /var/www/scheduling-system/smash/manage.py runcrons >> /var/log/django-cronjob.log 2>&1 +``` + ## Operations ### Public holidays @@ -191,4 +201,4 @@ example: ``` ./manage.py holidays 2017 2018 2019 -``` \ No newline at end of file +``` diff --git a/requirements.txt b/requirements.txt index 7c8c8a256eade03dd1b45e814f2edf0593fd446b..0dcc0503db9fadbba8cf9f7f203abd80d3926b62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ psycopg2==2.6.2 pytz==2016.10 lxml==3.7.3 python-docx==0.8.6 -django-cleanup==0.4.2 \ No newline at end of file +django-cleanup==0.4.2 +django_cron==0.5.0 diff --git a/smash/smash/settings.py b/smash/smash/settings.py index 48fbaf41986bb03af7835e85f076684e91bdbc90..c19c846a7b8e0e037efd8015b67e5c750ac8129c 100644 --- a/smash/smash/settings.py +++ b/smash/smash/settings.py @@ -22,13 +22,6 @@ ALLOWED_HOSTS = ['prc.parkinson.lu', 'localhost'] DEBUG = True -EMAIL_HOST = 'smtp.uni.lu' -EMAIL_HOST_USER = '' -EMAIL_HOST_PASSWORD = '' -EMAIL_PORT = 25 -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -DEFAULT_FROM_EMAIL = 'prc-scheduling-admin@uni.lu' - # Application definition INSTALLED_APPS = [ @@ -39,6 +32,7 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django_cleanup', + 'django_cron', 'debug_toolbar', 'web' ] @@ -72,6 +66,10 @@ TEMPLATES = [ }, ] +CRON_CLASSES = [ + 'web.views.kit.KitRequestEmailSendJob' +] + # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators diff --git a/smash/web/api_views/configuration.py b/smash/web/api_views/configuration.py index 305cbef01bc87a15ddec0ca735c18b63866a3b9f..c332d385438cb274b8d8f0509f975d218ddd9739 100644 --- a/smash/web/api_views/configuration.py +++ b/smash/web/api_views/configuration.py @@ -52,7 +52,13 @@ def update_configuration_item(request): }) item = items[0] item.value = value - item.save() - return JsonResponse({ - "status": "ok", - }) + if ConfigurationItem.is_valid(item): + item.save() + return JsonResponse({ + "status": "ok", + }) + else: + return JsonResponse({ + "status": "error", + "message": ConfigurationItem.validation_error(item) + }) diff --git a/smash/web/models/configuration_item.py b/smash/web/models/configuration_item.py index 2d982b06aa3f37b9cbd78310b39d3ee61bc3de9b..0129315ae6f6b1286471ba903db2b447035bcdd2 100644 --- a/smash/web/models/configuration_item.py +++ b/smash/web/models/configuration_item.py @@ -1,6 +1,11 @@ # coding=utf-8 +import re from django.db import models +from web.models.constants import CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE, \ + NO_SHOW_APPOINTMENT_COLOR_CONFIGURATION_TYPE, KIT_EMAIL_HOUR_CONFIGURATION_TYPE, \ + KIT_EMAIL_DAY_OF_WEEK_CONFIGURATION_TYPE + class ConfigurationItem(models.Model): type = models.CharField(max_length=50, @@ -21,3 +26,24 @@ class ConfigurationItem(models.Model): def __unicode__(self): return "%s %s" % (self.name, self.value) + + @staticmethod + def is_valid(item): + message = ConfigurationItem.validation_error(item) + return message == "" + + @staticmethod + def validation_error(item): + pattern = None + if item.type == CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE \ + or item.type == NO_SHOW_APPOINTMENT_COLOR_CONFIGURATION_TYPE: + pattern = "^#[0-9a-fA-F]+$" + if item.type == KIT_EMAIL_HOUR_CONFIGURATION_TYPE: + pattern = "^[0-9]{2}:[0-9]{2}$" + if item.type == KIT_EMAIL_DAY_OF_WEEK_CONFIGURATION_TYPE: + pattern = "^(MONDAY|TUESDAY|WEDNESDAY|THURSDAY|FRIDAY|SATURDAY|SUNDAY)$" + if pattern is not None: + if not re.compile(pattern).match(item.value): + return "Invalid value of param: " + item.name + ". It should match regex pattern: " + pattern + + return "" diff --git a/smash/web/models/constants.py b/smash/web/models/constants.py index fb01625467218e48e9111f775824a84e93372843..6d5fbeef623b083661c57842689254ac95f12fde 100644 --- a/smash/web/models/constants.py +++ b/smash/web/models/constants.py @@ -53,3 +53,11 @@ MAIL_TEMPLATE_CONTEXT_CHOICES = ( (MAIL_TEMPLATE_CONTEXT_VISIT, 'Visit'), ) LOCALE_CHOICES = [(value, value) for value in sorted(locale.windows_locale.values())] + +MONDAY_AS_DAY_OF_WEEK = 1 +TUESDAY_AS_DAY_OF_WEEK = 2 +WEDNESDAY_AS_DAY_OF_WEEK = 3 +THURSDAY_AS_DAY_OF_WEEK = 4 +FRIDAY_AS_DAY_OF_WEEK = 5 +SATURDAY_AS_DAY_OF_WEEK = 6 +SUNDAY_AS_DAY_OF_WEEK = 7 diff --git a/smash/web/templates/equipment_and_rooms/kit_requests.html b/smash/web/templates/equipment_and_rooms/kit_requests.html index 25ef9d78762b0488ff0ade18605068303ee22c6b..764d3b4bcc8a1e4b7d2decf037b3beaaa8988499 100644 --- a/smash/web/templates/equipment_and_rooms/kit_requests.html +++ b/smash/web/templates/equipment_and_rooms/kit_requests.html @@ -88,10 +88,10 @@ <div class="col-sm-12"> {% if end_date == None %} <a href="{% url 'web.views.kit_requests_send_mail' start_date|date:"Y-m-d" %}" - class="btn btn-block btn-default">Show email content</a> + class="btn btn-block btn-default">Send email</a> {% else %} <a href="{% url 'web.views.kit_requests_send_mail' start_date|date:"Y-m-d" end_date|date:"Y-m-d" %}" - class="btn btn-block btn-default">Show email content</a> + class="btn btn-block btn-default">Send email</a> {% endif %} </div> </div><!-- /.box-footer --> diff --git a/smash/web/tests/test_api_configuration_item.py b/smash/web/tests/test_api_configuration_item.py index a550ff27cb88b64d95630dc0c032ffaee2695d13..78ef0d1674706159f8faca41fd658ba441a65ef6 100644 --- a/smash/web/tests/test_api_configuration_item.py +++ b/smash/web/tests/test_api_configuration_item.py @@ -6,6 +6,9 @@ from django.urls import reverse from web.models import ConfigurationItem from web.tests.functions import create_configuration_item from . import LoggedInTestCase +from web.models.constants import CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE, \ + NO_SHOW_APPOINTMENT_COLOR_CONFIGURATION_TYPE, KIT_EMAIL_HOUR_CONFIGURATION_TYPE, \ + KIT_EMAIL_DAY_OF_WEEK_CONFIGURATION_TYPE class TestConfigurationItemApi(LoggedInTestCase): @@ -31,3 +34,21 @@ class TestConfigurationItemApi(LoggedInTestCase): self.assertEqual(response.status_code, 200) updated_item = ConfigurationItem.objects.get(id=item.id) self.assertEqual(new_val, updated_item.value) + + def test_configuration_modify_CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE_invalid_value(self): + item = ConfigurationItem.objects.get(type=CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE) + invalid_val = 'invalid color' + + response = self.client.get(reverse('web.api.update_configuration_item'), {'id': item.id, 'value': invalid_val}) + self.assertEqual(response.status_code, 200) + updated_item = ConfigurationItem.objects.get(type=CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE) + self.assertNotEqual(invalid_val, updated_item.value) + + def test_configuration_modify_CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE_valid_value(self): + item = ConfigurationItem.objects.get(type=CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE) + invalid_val = '#FFFFFF' + + response = self.client.get(reverse('web.api.update_configuration_item'), {'id': item.id, 'value': invalid_val}) + self.assertEqual(response.status_code, 200) + updated_item = ConfigurationItem.objects.get(type=CANCELLED_APPOINTMENT_COLOR_CONFIGURATION_TYPE) + self.assertEqual(invalid_val, updated_item.value) diff --git a/smash/web/tests/test_process_file.py b/smash/web/tests/test_process_file.py index 7d00165404acd575efa951f4b48f333ad7c2817a..f46bcac4266a002dcd993cf59d73cb0fb78c82c8 100644 --- a/smash/web/tests/test_process_file.py +++ b/smash/web/tests/test_process_file.py @@ -26,7 +26,7 @@ class TestDocxProcessor(TestCase): "##ADDRESS2##": "61-234, Poznan", "##COUNTRY##": "POLAND", "##CONTENT##": "1", - "##DATE##": datetime.datetime.now().date().strftime("%A %-d %B %Y"), + "##DATE##": datetime.datetime.now().date().strftime("%A %d %B %Y"), } process_file(template_path, output_path, changes) self.assertTrue(os.path.isfile(output_path)) diff --git a/smash/web/tests/test_view_kit_request.py b/smash/web/tests/test_view_kit_request.py index d2bbebcdd27ab0e22d1decf2fd4f32910ff61141..4a061d4a9c4d5ec17ae6eff7bba66656766fe59a 100644 --- a/smash/web/tests/test_view_kit_request.py +++ b/smash/web/tests/test_view_kit_request.py @@ -1,9 +1,11 @@ import datetime +from django.core import mail from django.urls import reverse -from functions import create_appointment_type, create_appointment +from functions import create_appointment_type, create_appointment, create_visit from web.models import Item, Appointment, AppointmentTypeLink +from web.views.kit import get_kit_requests from web.views.notifications import get_today_midnight_date from . import LoggedInTestCase @@ -25,7 +27,6 @@ class ViewFunctionsTests(LoggedInTestCase): appointment.save() AppointmentTypeLink.objects.create(appointment=appointment, appointment_type=appointment_type) - response = self.client.get(reverse('web.views.kit_requests')) self.assertEqual(response.status_code, 200) @@ -61,8 +62,57 @@ class ViewFunctionsTests(LoggedInTestCase): appointment.save() AppointmentTypeLink.objects.create(appointment=appointment, appointment_type=appointment_type) - response = self.client.get(reverse('web.views.kit_requests')) self.assertEqual(response.status_code, 200) self.assertTrue(item_name in response.content) + + def test_kit_requests_order(self): + item_name = "Test item to be ordered" + item = Item.objects.create(disposable=True, name=item_name) + appointment_type = create_appointment_type() + appointment_type.required_equipment.add(item) + appointment_type.save() + + visit = create_visit(); + + appointment1 = create_appointment(visit) + appointment1.datetime_when = get_today_midnight_date() + datetime.timedelta(days=3) + appointment1.save() + AppointmentTypeLink.objects.create(appointment=appointment1, appointment_type=appointment_type) + + appointment2 = create_appointment(visit) + appointment2.datetime_when = get_today_midnight_date() + datetime.timedelta(days=4) + appointment2.save() + AppointmentTypeLink.objects.create(appointment=appointment2, appointment_type=appointment_type) + + appointment3 = create_appointment(visit) + appointment3.datetime_when = get_today_midnight_date() + datetime.timedelta(days=2) + appointment3.save() + AppointmentTypeLink.objects.create(appointment=appointment3, appointment_type=appointment_type) + + result = get_kit_requests(self.user) + self.assertEqual(appointment3, result['appointments'][0]) + self.assertEqual(appointment1, result['appointments'][1]) + self.assertEqual(appointment2, result['appointments'][2]) + + +def test_kit_requests_send_email(self): + item_name = "Test item to be ordered" + item = Item.objects.create(disposable=True, name=item_name) + appointment_type = create_appointment_type() + appointment_type.required_equipment.add(item) + appointment_type.save() + + appointment = create_appointment() + appointment.datetime_when = get_today_midnight_date() + datetime.timedelta(days=2) + appointment.save() + AppointmentTypeLink.objects.create(appointment=appointment, appointment_type=appointment_type) + + response = self.client.get(reverse('web.views.kit_requests_send_mail', + kwargs={'start_date': str(get_today_midnight_date().strftime("%Y-%m-%d"))})) + self.assertEqual(response.status_code, 200) + + self.assertTrue(item_name in response.content) + + self.assertEqual(1, len(mail.outbox)) diff --git a/smash/web/views/doctor.py b/smash/web/views/doctor.py index 09031a36050d59d970c8c57942a188726f88021e..43a7465552e55183bf4104bc53c667b3f4e5876b 100644 --- a/smash/web/views/doctor.py +++ b/smash/web/views/doctor.py @@ -4,15 +4,8 @@ from django.shortcuts import redirect, get_object_or_404 from . import wrap_response from ..forms import WorkerAddForm, WorkerEditForm, WorkerDetailForm from ..models import Worker, Availability - -MONDAY_AS_DAY_OF_WEEK = 1 -TUESDAY_AS_DAY_OF_WEEK = 2 -WEDNESDAY_AS_DAY_OF_WEEK = 3 -THURSDAY_AS_DAY_OF_WEEK = 4 -FRIDAY_AS_DAY_OF_WEEK = 5 -SATURDAY_AS_DAY_OF_WEEK = 6 -SUNDAY_AS_DAY_OF_WEEK = 7 - +from ..models.constants import MONDAY_AS_DAY_OF_WEEK, TUESDAY_AS_DAY_OF_WEEK, WEDNESDAY_AS_DAY_OF_WEEK, \ + THURSDAY_AS_DAY_OF_WEEK, FRIDAY_AS_DAY_OF_WEEK, SATURDAY_AS_DAY_OF_WEEK, SUNDAY_AS_DAY_OF_WEEK def doctors(request): doctors_list = Worker.objects.order_by('-last_name') diff --git a/smash/web/views/kit.py b/smash/web/views/kit.py index f4a7b9e2e2065a6b1f1d6ff36f4ea5e7301f2547..98eb536f942b5737a3765ecd461c1ae09e7723cc 100644 --- a/smash/web/views/kit.py +++ b/smash/web/views/kit.py @@ -1,12 +1,27 @@ # coding=utf-8 + import datetime +import locale +import platform +import sys +import time +import traceback +import pytz +from django.contrib import messages from django.utils.dateparse import parse_datetime +from django_cron import CronJobBase, Schedule +from django_cron.models import CronJobLog from notifications import get_filter_locations, get_today_midnight_date +from web.models import ConfigurationItem, Language, Worker +from web.models.constants import KIT_EMAIL_HOUR_CONFIGURATION_TYPE, \ + KIT_EMAIL_DAY_OF_WEEK_CONFIGURATION_TYPE +from web.models.constants import KIT_RECIPIENT_EMAIL_CONFIGURATION_TYPE from . import wrap_response from ..forms import KitRequestForm from ..models import AppointmentType, Appointment +from ..smash_email import EmailSender def get_kit_requests(user, start_date=None, end_date=None): @@ -16,8 +31,12 @@ def get_kit_requests(user, start_date=None, end_date=None): else: if isinstance(start_date, str): start_date = parse_datetime(start_date) + if isinstance(start_date, unicode): + start_date = datetime.datetime.strptime(start_date, '%Y-%m-%d') if (end_date is not None) and (isinstance(end_date, str)): end_date = parse_datetime(end_date) + if (end_date is not None) and (isinstance(end_date, unicode)): + end_date = datetime.datetime.strptime(end_date, '%Y-%m-%d') appointment_types = AppointmentType.objects.filter(required_equipment__disposable=True) @@ -26,7 +45,8 @@ def get_kit_requests(user, start_date=None, end_date=None): datetime_when__gt=start_date, location__in=get_filter_locations(user), status=Appointment.APPOINTMENT_STATUS_SCHEDULED, - ) + ).order_by('datetime_when') + if end_date is not None: appointments = appointments.filter(datetime_when__lt=end_date) @@ -58,6 +78,98 @@ def kit_requests(request): return wrap_response(request, 'equipment_and_rooms/kit_requests.html', get_kit_requests_data(request)) +def send_mail(data): + end_date_str = " end of time" + if data["end_date"] is not None: + end_date_str = data["end_date"].strftime('%Y-%m-%d') + title = "Kits required between " + data["start_date"].strftime('%Y-%m-%d') + " and " + end_date_str + + cell_style = "padding: 8px; line-height: 1.42857143; vertical-align: top; " \ + "font-size: 14px; font-family: 'Source Sans Pro','Helvetica Neue',Helvetica,Arial,sans-serif;" + + email_body = "<h1>" + title + "</h1>" + email_body += '<table style="border: 1px solid #f4f4f4;border-spacing: 0;border-collapse: collapse;">' \ + '<thead><tr><th>Date</th><th>Kits</th><th>Location</th><th>Person responsible</th></tr></thead>' + email_body += "<tbody>" + + even = True + for appointment in data["appointments"]: + row_style = "" + even = not even + if even: + row_style = ' background-color: #f9f9f9;' + email_body += "<tr style='" + row_style + "'>" + email_body += "<td style='" + cell_style + "'>" + appointment.datetime_when.strftime('%Y-%m-%d %H:%M') + "</td>" + email_body += "<td style='" + cell_style + "'>" + for type in appointment.appointment_types.all(): + for item in type.required_equipment.all(): + if item.disposable: + email_body += item.name + ", " + email_body += "</td>" + email_body += "<td style='" + cell_style + "'>" + str(appointment.location) + "</td>" + email_body += "<td style='" + cell_style + "'>" + str(appointment.worker_assigned) + "</td>" + email_body += "</tr>" + email_body += "</tbody></table>" + recipients = ConfigurationItem.objects.get(type=KIT_RECIPIENT_EMAIL_CONFIGURATION_TYPE).value + cc_recipients = [] + EmailSender().send_email(title, email_body, recipients, cc_recipients) + + def kit_requests_send_mail(request, start_date, end_date=None): - return wrap_response(request, 'equipment_and_rooms/kit_requests_send_mail.html', - get_kit_requests_data(request, start_date, end_date)) + data = get_kit_requests_data(request, start_date, end_date) + try: + send_mail(data) + messages.add_message(request, messages.SUCCESS, 'Mail sent') + except: + messages.add_message(request, messages.ERROR, 'There was problem with sending email') + return wrap_response(request, 'equipment_and_rooms/kit_requests.html', get_kit_requests_data(request)) + + +class KitRequestEmailSendJob(CronJobBase): + RUN_EVERY_MINS = 1 + schedule = Schedule(run_every_mins=RUN_EVERY_MINS) + code = 'web.kit_request_weekly_email' # a unique code + + def do(self): + now = datetime.datetime.utcnow() + hour = int(ConfigurationItem.objects.get( + type=KIT_EMAIL_HOUR_CONFIGURATION_TYPE).value.split(":")[0]) + minute = int(ConfigurationItem.objects.get( + type=KIT_EMAIL_HOUR_CONFIGURATION_TYPE).value.split(":")[1]) + # check if we sent email this day already + date = now.replace(hour=hour, minute=minute) + # TODO it's a hack assuming that we are in CEST + date = pytz.utc.localize(date - datetime.timedelta(minutes=122)) + jobs = CronJobLog.objects.filter(code=KitRequestEmailSendJob.code, message="mail sent", start_time__gte=date) + + if jobs.count() == 0: + print date + print datetime.datetime.now() + if pytz.utc.localize(datetime.datetime.utcnow()) > date: + if self.match_day_of_week(): + data = get_kit_requests(Worker.objects.create()); + send_mail(data); + return "mail sent" + else: + return "day of week doesn't match" + else: + return "too early" + else: + return "mail already sent" + + def match_day_of_week(self): + user_day_of_week = ConfigurationItem.objects.get(type=KIT_EMAIL_DAY_OF_WEEK_CONFIGURATION_TYPE).value + language = Language.objects.get(name="English"); + locale_name = language.locale + if platform.system() == 'Windows': + locale_name = language.windows_locale_name + try: + locale.setlocale(locale.LC_TIME, locale_name) + except: + print locale_name + traceback.print_exc(file=sys.stdout) + + user_day_of_week_int = int(time.strptime(user_day_of_week, '%A').tm_wday) + 1 + current_day_of_week_int = int(datetime.datetime.now().strftime("%w")); + + return user_day_of_week_int == current_day_of_week_int