From ed8d8b043437e8d47aaa6faa1e6dfb8cb5f0d20c Mon Sep 17 00:00:00 2001 From: Christian Merten Date: Tue, 14 Mar 2023 01:09:55 +0100 Subject: [PATCH] waiting list: add waiting status confirmation mechanism and automation with celery beat --- .gitignore | 3 + jdav_web/jdav_web/__init__.py | 3 + jdav_web/jdav_web/celery.py | 2 +- jdav_web/jdav_web/settings.py | 14 ++++ jdav_web/mailer/mailutils.py | 5 ++ jdav_web/members/admin.py | 13 ++- jdav_web/members/models.py | 81 +++++++++++++++++-- jdav_web/members/tasks.py | 10 +++ .../members/waiting_confirmation_invalid.html | 21 +++++ .../members/waiting_confirmation_success.html | 23 ++++++ jdav_web/members/urls.py | 1 + jdav_web/members/views.py | 35 ++++++++ runserver | 2 +- 13 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 jdav_web/members/tasks.py create mode 100644 jdav_web/members/templates/members/waiting_confirmation_invalid.html create mode 100644 jdav_web/members/templates/members/waiting_confirmation_success.html diff --git a/.gitignore b/.gitignore index fb9e97f..1ea8302 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,6 @@ jdav_web/static/jet/css/themes/*/.sass-cache/* # test config config + +# celerybeat schedule database +jdav_web/celerybeat-schedule.db diff --git a/jdav_web/jdav_web/__init__.py b/jdav_web/jdav_web/__init__.py index e69de29..fb989c4 100644 --- a/jdav_web/jdav_web/__init__.py +++ b/jdav_web/jdav_web/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/jdav_web/jdav_web/celery.py b/jdav_web/jdav_web/celery.py index 9d87f39..6cff3ef 100644 --- a/jdav_web/jdav_web/celery.py +++ b/jdav_web/jdav_web/celery.py @@ -8,7 +8,7 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'jdav_web.settings') app = Celery() app.config_from_object('django.conf:settings') -app.autodiscover_tasks(settings.INSTALLED_APPS) +app.autodiscover_tasks() if __name__ == '__main__': app.start() diff --git a/jdav_web/jdav_web/settings.py b/jdav_web/jdav_web/settings.py index 5218abc..7689c3d 100644 --- a/jdav_web/jdav_web/settings.py +++ b/jdav_web/jdav_web/settings.py @@ -58,6 +58,7 @@ INSTALLED_APPS = [ 'ludwigsburgalpin.apps.LudwigsburgalpinConfig', #'easy_select2', 'djcelery_email', + 'django_celery_beat', 'jet', 'django.contrib.admin', 'django.contrib.auth', @@ -203,6 +204,13 @@ JET_SIDE_MENU_ITEMS = [ {'name': 'group', 'permissions': ['auth.group'] }, {'name': 'user', 'permissions': ['auth.user']}, ]}, + {'app_label': 'django_celery_beat', 'permissions': ['django_celery_beat'], 'items': [ + {'name': 'crontabschedule'}, + {'name': 'clockedschedule'}, + {'name': 'intervalschedule'}, + {'name': 'periodictask'}, + {'name': 'solarschedule'}, + ]}, {'app_label': 'ludwigsburgalpin', 'permissions': ['ludwigsburgalpin'], 'items': [ {'name': 'termin'}, ]}, @@ -229,3 +237,9 @@ JET_SIDE_MENU_ITEMS = [ { 'label': 'Packlisten und Co.', 'url': 'https://cloud.jdav-ludwigsburg.de/index.php/s/qxQCTR8JqYSXXCQ'} ]}, ] + +# Waiting list configuration parameters, all numbers are in days + + +GRACE_PERIOD_WAITING_CONFIRMATION = 30 +WAITING_CONFIRMATION_FREQUENCY = 90 diff --git a/jdav_web/mailer/mailutils.py b/jdav_web/mailer/mailutils.py index 943c02e..90219fd 100644 --- a/jdav_web/mailer/mailutils.py +++ b/jdav_web/mailer/mailutils.py @@ -77,6 +77,11 @@ def get_registration_link(waiter): return prepend_base_url("/members/registration?key={}".format(key)) +def get_wait_confirmation_link(waiter): + key = waiter.generate_wait_confirmation_key() + return prepend_base_url("/members/waitinglist/confirm?key={}".format(key)) + + def get_mail_confirmation_link(key): return prepend_base_url("/members/mail/confirm?key={}".format(key)) diff --git a/jdav_web/members/admin.py b/jdav_web/members/admin.py index d3dd577..49a8f33 100644 --- a/jdav_web/members/admin.py +++ b/jdav_web/members/admin.py @@ -248,15 +248,24 @@ class WaiterInviteForm(forms.Form): class MemberWaitingListAdmin(admin.ModelAdmin): fields = ['prename', 'lastname', 'email', 'email_parents', 'birth_date', 'comments', 'invited_for_group'] - list_display = ('name', 'birth_date', 'age', 'confirmed_mail', 'confirmed_mail_parents') + list_display = ('name', 'birth_date', 'age', 'confirmed_mail', 'confirmed_mail_parents', + 'waiting_confirmed') search_fields = ('prename', 'lastname', 'email') list_filter = ('confirmed_mail', 'confirmed_mail_parents') - actions = ['request_mail_confirmation', 'ask_for_registration'] + actions = ['ask_for_registration', 'ask_for_wait_confirmation'] readonly_fields= ('invited_for_group',) def has_add_permission(self, request, obj=None): return False + def ask_for_wait_confirmation(self, request, queryset): + """Asks the waiting person to confirm their waiting status.""" + for waiter in queryset: + waiter.ask_for_wait_confirmation() + messages.success(request, + _("Successfully asked %(name)s to confirm their waiting status.") % {'name': waiter.name}) + ask_for_wait_confirmation.short_description = _('Ask the waiter to confirm their waiting status') + def ask_for_registration(self, request, queryset): """Asks the waiting person to register with all required data.""" if "apply" in request.POST: diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 22b0899..54b633f 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -11,8 +11,9 @@ from django.contrib.contenttypes.models import ContentType from utils import RestrictedFileField import os from mailer.mailutils import send as send_mail, mail_root, get_mail_confirmation_link,\ - prepend_base_url, get_registration_link + prepend_base_url, get_registration_link, get_wait_confirmation_link from django.contrib.auth.models import User +from django.conf import settings from dateutil.relativedelta import relativedelta @@ -51,7 +52,6 @@ class Group(models.Model): year_to = models.IntegerField(verbose_name=_('highest year'), default=2011) leiters = models.ManyToManyField('members.Member', verbose_name=_('youth leaders'), related_name='leited_groups', blank=True) - secret = models.CharField(max_length=32, default=generate_random_key) def __str__(self): """String representation""" @@ -138,6 +138,13 @@ class Person(models.Model): self.save() return (email, parents) + def send_mail(self, subject, content): + send_mail(subject, + content, + mail_root, + [self.email, self.email_parents] if self.email_parents and self.cc_email_parents + else self.email) + class Member(Person): """ @@ -318,9 +325,14 @@ class MemberUnconfirmedProxy(Member): class MemberWaitingList(Person): """A participant on the waiting list""" + WAITING_CONFIRMATION_SUCCESS = 0 + WAITING_CONFIRMATION_INVALID = 1 + WAITING_CONFIRMATION_EXPIRED = 1 + WAITING_CONFIRMED = 2 + last_wait_confirmation = models.DateField(auto_now=True, verbose_name=_('Last wait confirmation')) wait_confirmation_key = models.CharField(max_length=32, default="") - wait_confirmation_key_expiry = models.DateTimeField(default=timezone.now) + wait_confirmation_key_expire = models.DateTimeField(default=timezone.now) registration_key = models.CharField(max_length=32, default="") registration_expire = models.DateTimeField(default=timezone.now) @@ -341,12 +353,50 @@ class MemberWaitingList(Person): def waiting_confirmation_needed(self): """Returns if person should be asked to confirm waiting status.""" return wait_confirmation_key is None \ - and last_wait_confirmation < timezone.now - timezone.timedelta(days=90) + and last_wait_confirmation < timezone.now() - timezone.timedelta(days=90) - @property def waiting_confirmed(self): - """Returns if person is still confirmed to be waiting.""" - return last_wait_confirmation < timezone.now() - timezone.timedelta(days=100) + """Returns if the persons waiting status is considered to be confirmed.""" + cutoff = timezone.now() \ + - timezone.timedelta(days= settings.GRACE_PERIOD_WAITING_CONFIRMATION \ + + settings.WAITING_CONFIRMATION_FREQUENCY) + return self.last_wait_confirmation > cutoff.date() + waiting_confirmed.admin_order_field = 'last_wait_confirmation' + waiting_confirmed.boolean = True + waiting_confirmed.short_description = _('Waiting status confirmed') + + def ask_for_wait_confirmation(self): + """Sends an email to the person asking them to confirm their intention to wait.""" + self.send_mail(_('Waiting confirmation needed'), + WAIT_CONFIRMATION_TEXT.format(name=self.prename, + link=get_wait_confirmation_link(self))) + + def confirm_waiting(self, key): + # if a wrong key is supplied, we return invalid + if not self.wait_confirmation_key == key: + return self.WAITING_CONFIRMATION_INVALID + + # if the current wait confirmation key is not expired, return sucess + if timezone.now() < self.wait_confirmation_key_expire: + self.last_wait_confirmation = timezone.now() + self.wait_confirmation_key_expire = timezone.now() + self.save() + return self.WAITING_CONFIRMATION_SUCCESS + + # if the waiting is already confirmed, return success + # this might happen if both parents and member mail are used for communication + if self.waiting_confirmed(): + return self.WAITING_CONFIRMED + + # otherwise the link is too old and the person was not confirmed in time + return self.WAITING_CONFIRMATION_EXPIRED + + def generate_wait_confirmation_key(self): + self.wait_confirmation_key = uuid.uuid4().hex + self.wait_confirmation_key_expire = timezone.now() \ + + timezone.timedelta(days=settings.GRACE_PERIOD_WAITING_CONFIRMATION) + self.save() + return self.wait_confirmation_key def generate_registration_key(self): self.registration_key = uuid.uuid4().hex @@ -710,3 +760,20 @@ Bei Fragen, wende dich gerne an jugendreferent@jdav-ludwigsburg.de. Viele Grüße Deine JDAV Ludwigsburg""" + + +WAIT_CONFIRMATION_TEXT = """Hallo {name}, + +leider können wir dir zur Zeit noch keinen Platz in einer Jugendgruppe anbieten. Da wir +sehr viele Interessenten haben und wir möglichst vielen die Möglichkeit bieten möchten, an +einer Jugendgruppe teilhaben zu können, fragen wir regelmäßig alle Personen auf der +Warteliste ab, ob sie noch Interesse haben. + +Wenn du weiterhin auf der Warteliste bleiben möchtest, klicke auf den folgenden Link: + +{link} + +Falls du nicht mehr auf der Warteliste bleiben möchtest, musst du nichts machen. Du wirst automatisch entfernt. + +Viele Grüße +Deine JDAV Ludwigsburg""" diff --git a/jdav_web/members/tasks.py b/jdav_web/members/tasks.py new file mode 100644 index 0000000..197e84a --- /dev/null +++ b/jdav_web/members/tasks.py @@ -0,0 +1,10 @@ +from celery import shared_task +from django.utils import timezone +from django.conf import settings +from .models import MemberWaitingList + +@shared_task +def ask_for_waiting_confirmation(): + cutoff = timezone.now() - timezone.timedelta(days=settings.WAITING_CONFIRMATION_FREQUENCY) + for waiter in MemberWaitingList.objects.filter(last_wait_confirmation__lte=cutoff): + waiter.ask_for_wait_confirmation() diff --git a/jdav_web/members/templates/members/waiting_confirmation_invalid.html b/jdav_web/members/templates/members/waiting_confirmation_invalid.html new file mode 100644 index 0000000..8d5bcd6 --- /dev/null +++ b/jdav_web/members/templates/members/waiting_confirmation_invalid.html @@ -0,0 +1,21 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Waiting confirmation failed" %} +{% endblock %} + +{% block content %} + +

{% trans "Waiting confirmation failed" %}

+ +{% if expired %} +{% url 'members:register_waiting_list' as reg_wait_link %} +

{% blocktrans %}Unfortunately, you did not confirm your intention to stay on the waiting list in time. You lost your spot on the list. You can rejoin the waiting list.{% endblocktrans %} +

+{% else %} +

{% trans "The supplied link is invalid." %}

+{% endif %} + +{% endblock %} diff --git a/jdav_web/members/templates/members/waiting_confirmation_success.html b/jdav_web/members/templates/members/waiting_confirmation_success.html new file mode 100644 index 0000000..34b0f3b --- /dev/null +++ b/jdav_web/members/templates/members/waiting_confirmation_success.html @@ -0,0 +1,23 @@ +{% extends "members/base.html" %} +{% load i18n %} +{% load static %} + +{% block title %} +{% trans "Waiting confirmed" %} +{% endblock %} + +{% block content %} + +

{% trans "Waiting confirmed" %}

+ +{% if already_confirmed %} +

{% blocktrans %}Thank you {{prename}} for your interest in staying on the waiting list. +Your spot was already confirmed.{% endblocktrans %} +

+{% else %} +

{% blocktrans %}Thank you {{prename}} for your interest in staying on the waiting list. +Your spot has been confirmed.{% endblocktrans %} +

+{% endif %} + +{% endblock %} diff --git a/jdav_web/members/urls.py b/jdav_web/members/urls.py index f128c6a..b0c28bd 100644 --- a/jdav_web/members/urls.py +++ b/jdav_web/members/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ re_path(r'^echo', views.echo , name='echo'), re_path(r'^registration', views.invited_registration , name='registration'), re_path(r'^register', views.register , name='register'), + re_path(r'^waitinglist/confirm', views.confirm_waiting , name='confirm_waiting'), re_path(r'^waitinglist', views.register_waiting_list , name='register_waiting_list'), re_path(r'^mail/confirm', views.confirm_mail , name='confirm_mail'), ] diff --git a/jdav_web/members/views.py b/jdav_web/members/views.py index 73e55bf..6bdb49b 100644 --- a/jdav_web/members/views.py +++ b/jdav_web/members/views.py @@ -278,3 +278,38 @@ def render_invited_registration_failed(request, reason=""): if reason: context['reason'] = reason return render(request, 'members/invited_registration_failed.html', context) + + +def confirm_waiting(request): + if request.method == 'GET' and 'key' in request.GET: + key = request.GET['key'] + try: + waiter = MemberWaitingList.objects.get(wait_confirmation_key=key) + except MemberWaitingList.DoesNotExist: + return render_waiting_confirmation_invalid(request) + status = waiter.confirm_waiting(key) + if status == MemberWaitingList.WAITING_CONFIRMATION_SUCCESS: + return render_waiting_confirmation_success(request, + waiter.prename, + already_confirmed=False) + elif status == MemberWaitingList.WAITING_CONFIRMED: + return render_waiting_confirmation_success(request, + waiter.prename, + already_confirmed=True) + elif status == MemberWaitingList.WAITING_CONFIRMATION_EXPIRED: + return render_waiting_confirmation_invalid(request, prename=waiter.prename, expired=True) + else: + # invalid + return render_waiting_confirmation_invalid(request) + return HttpResponseRedirect(reverse('startpage:index')) + + +def render_waiting_confirmation_invalid(request, prename=None, expired=False): + return render(request, + 'members/waiting_confirmation_invalid.html', + {'expired': expired, 'prename': prename}) + + +def render_waiting_confirmation_success(request, prename, already_confirmed): + return render(request, 'members/waiting_confirmation_success.html', + {'prename': prename, 'already_confirmed': already_confirmed}) diff --git a/runserver b/runserver index 9bef637..85f3993 100755 --- a/runserver +++ b/runserver @@ -5,6 +5,6 @@ source venv/bin/activate cd jdav_web -celery -A jdav_web worker -l debug & +celery -A jdav_web worker -B --scheduler django_celery_beat.schedulers:DatabaseScheduler -l info & python3 manage.py runserver 8008