diff --git a/dav_auth/forms.py b/dav_auth/forms.py index d46ce6c..8c1faa4 100644 --- a/dav_auth/forms.py +++ b/dav_auth/forms.py @@ -62,7 +62,7 @@ class SetPasswordForm(forms.Form): return self.user -class ResetPasswordForm(forms.Form): +class CreateAndSendPasswordForm(forms.Form): username = auth_forms.UsernameField( max_length=254, label=_(u'E-Mail-Adresse'), diff --git a/dav_auth/templates/dav_auth/forms/login.html b/dav_auth/templates/dav_auth/forms/login.html index 3f592fb..f914d3f 100644 --- a/dav_auth/templates/dav_auth/forms/login.html +++ b/dav_auth/templates/dav_auth/forms/login.html @@ -15,7 +15,8 @@
{% csrf_token %} {% bootstrap_form form %} -
{% trans 'Passwort vergessen?' %}
+
{% trans 'Passwort vergessen?' %}
{% buttons %}   {% endif %} - {% if registration.answered %} - - {% endif %} {{ registration.get_full_name }} ({{ registration.email_address }}, {{ registration.phone_number }}) @@ -162,12 +161,15 @@ {% bootstrap_icon 'info-sign' %} - {% if registration.answered %} + {% if registration.apply_reduced_fee %} +   + + %{% bootstrap_icon 'piggy-bank' %} (reduzierte Gebühr) {% endif %} {% empty %} - {% trans 'Keine unbearbeiteten Anmeldungen vorhanden' %} + {% trans 'Keine Anmeldungen vorhanden' %} {% endfor %} @@ -179,21 +181,46 @@ {% with position=participant.position %}
{% endif %} +
+
+ {% trans 'Gebuchte Teilnahmegebühren' %}: {{ earnings|floatformat:2 }} € +
+
{% if participant_emails %}
@@ -280,6 +317,112 @@
{% endif %}
+ +
+ +

{% trans 'Papierkorb' %}

+
+ {% if registrations_support and registrations_answered %} +
+ +
+
+ {% for registration in registrations_answered %} +
+ +   + +   + + {{ registration.get_full_name }} + ({{ registration.email_address }}, + {{ registration.phone_number }}) +   + + {% bootstrap_icon 'time' %} + {{ registration.created_at|date:'d. F Y, G:i' }} + +   + + {% bootstrap_icon 'info-sign' %} + + +
+ {% endfor %} +
+
+
+ {% endif %} + {% if participants_trash %} +
+ +
+
+ {% for participant in participants_trash %} +
+ + {{ participant.get_full_name }} + ({{ participant.email_address }}, + {{ participant.phone_number }}) +   + {% if participant.dav_member %} + {{ participant.dav_number|default:'Fehler! heinzel Bescheid geben!' }} + {% else %} + {% trans 'Nicht Mitglied' %} + {% endif %} +   + + {% bootstrap_icon 'info-sign' %} + + {% if event.charge and participant.paid %} +   + %{% bootstrap_icon 'piggy-bank' %} + {% elif event.charge %} +   + %{% bootstrap_icon 'piggy-bank' %} + {% endif %} + +
+ {% endfor %} +
+
+
+ {% endif %} + {% if not registrations_answered and not participants_trash %} + {% trans 'Der Papierkorb ist leer.' %} + {% endif %} +
-{% endblock page-container-fluid %} \ No newline at end of file +{% endblock page-container-fluid %} diff --git a/dav_event_office/templates/dav_event_office/includes/participant_form.html b/dav_event_office/templates/dav_event_office/includes/participant_form.html index 4eb0e04..535d068 100644 --- a/dav_event_office/templates/dav_event_office/includes/participant_form.html +++ b/dav_event_office/templates/dav_event_office/includes/participant_form.html @@ -29,9 +29,20 @@
-
+
+ {% bootstrap_field form.year_of_birth %} +
+
+ {% bootstrap_field form.apply_reduced_fee %} +
+
+
+
{% bootstrap_field form.dav_number %}
+
+ {% bootstrap_field form.dav_member %} +
{% bootstrap_field form.emergency_contact %}
diff --git a/dav_event_office/templates/dav_event_office/participant_list.html b/dav_event_office/templates/dav_event_office/participant_list.html index 02fc370..30eabb5 100644 --- a/dav_event_office/templates/dav_event_office/participant_list.html +++ b/dav_event_office/templates/dav_event_office/participant_list.html @@ -24,7 +24,7 @@ {% trans 'Nachname' %} {% trans 'Vorname' %} {% trans 'Mitgliedsnummer' %} - {% trans 'Teilnehmergebühr' %} + {% trans 'Teilnahmegebühr' %} @@ -32,7 +32,7 @@ - + @@ -55,10 +55,14 @@ {{ participant.personal_names }} - {{ participant.dav_number }} + {% if participant.dav_member %} + {{ participant.dav_number|default:'Fehler! heinzel Bescheid geben!' }} + {% else %} + {% trans 'Nicht Mitglied' %} + {% endif %} + data-search="{% if not event.charge %}{% trans 'Grau' %}{% elif participant.paid %}{% trans 'Grün' %}{% else %}{% trans 'Rot' %}{% endif %} {% if participant.apply_reduced_fee %}%{% endif %} {{ event.charge|floatformat:'-2' }}">
{% bootstrap_icon 'envelope' %} {% csrf_token %} + {% if event.charge %} + +   + {% endif %} {% if event.charge and participant.paid %} - + %   - ({{ event.charge|floatformat:'-2' }} €) + ({{ event.charge|floatformat:'-2' }}{% if participant.apply_reduced_fee %} / 2{% endif %} €) {% elif event.charge %} - + {% if participant.apply_reduced_fee %}%{% else %}? {% endif %}   - ({{ event.charge|floatformat:'-2' }} €) + ({{ event.charge|floatformat:'-2' }}{% if participant.apply_reduced_fee %} / 2{% endif %} €) {% else %} - - {% bootstrap_icon 'piggy-bank' %} - + {% endif %} diff --git a/dav_event_office/views.py b/dav_event_office/views.py index c44a136..148d3b1 100644 --- a/dav_event_office/views.py +++ b/dav_event_office/views.py @@ -53,6 +53,11 @@ class ParticipantListView(generic.ListView): participant = get_object_or_404(Participant, pk=participant_id) participant.paid = False participant.save() + elif action == 'toggle_reduced_fee': + participant_id = request.POST.get('id') + participant = get_object_or_404(Participant, pk=participant_id) + participant.apply_reduced_fee = not participant.apply_reduced_fee + participant.save() else: messages.error(request, 'unsupported action: {}'.format(action)) return HttpResponseRedirect(reverse('dav_event_office:participant-list')) diff --git a/dav_events/admin.py b/dav_events/admin.py index 5d76ac6..8b3896d 100644 --- a/dav_events/admin.py +++ b/dav_events/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import EventStatus, EventFlag, Event, OneClickAction, Participant +from .models import EventStatus, EventFlag, Event, OneClickAction, Participant, TrashedParticipant @admin.register(EventStatus) @@ -31,3 +31,8 @@ class OneClickActionAdmin(admin.ModelAdmin): @admin.register(Participant) class ParticipantAdmin(admin.ModelAdmin): pass + + +@admin.register(TrashedParticipant) +class TrashedParticipantAdmin(admin.ModelAdmin): + pass diff --git a/dav_events/forms/__init__.py b/dav_events/forms/__init__.py index a13db64..c7f2392 100644 --- a/dav_events/forms/__init__.py +++ b/dav_events/forms/__init__.py @@ -1,3 +1,4 @@ from . import generic from . import events from . import participant +from . import registration diff --git a/dav_events/forms/participant.py b/dav_events/forms/participant.py index 92dd23c..e471173 100644 --- a/dav_events/forms/participant.py +++ b/dav_events/forms/participant.py @@ -7,7 +7,9 @@ from ..models import Participant class ParticipantForm(forms.ModelForm): class Meta: model = Participant - exclude = ['event', 'created_at', 'position', 'purge_at'] + exclude = ['event', 'created_at', 'position', + 'privacy_policy', 'privacy_policy_accepted', + 'paid', 'purge_at'] widgets = { 'emergency_contact': forms.Textarea(attrs={'rows': 4}), 'experience': forms.Textarea(attrs={'rows': 5}), diff --git a/dav_events/forms/registration.py b/dav_events/forms/registration.py new file mode 100644 index 0000000..2d28b2b --- /dev/null +++ b/dav_events/forms/registration.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +from django import forms +from django.utils.translation import ugettext_lazy as _ + + +class RegistrationResponseForm(forms.Form): + apply_reduced_fee = forms.BooleanField(required=False, + label=_(u'Reduzierte Teilnahmegebühr')) diff --git a/dav_events/migrations/0033_auto_20200925_1543.py b/dav_events/migrations/0033_auto_20200925_1543.py new file mode 100644 index 0000000..91e1ccb --- /dev/null +++ b/dav_events/migrations/0033_auto_20200925_1543.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-09-25 13:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0032_auto_20190605_1400'), + ] + + operations = [ + migrations.AlterField( + model_name='eventstatus', + name='bootstrap_context', + field=models.CharField(blank=True, choices=[('default', 'default'), ('primary', 'primary'), ('success', 'success'), ('info', 'info'), ('warning', 'warning'), ('danger', 'danger'), ('black', 'black')], max_length=20), + ), + ] diff --git a/dav_events/migrations/0034_auto_20201015_1738.py b/dav_events/migrations/0034_auto_20201015_1738.py new file mode 100644 index 0000000..b530157 --- /dev/null +++ b/dav_events/migrations/0034_auto_20201015_1738.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-10-15 15:38 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0033_auto_20200925_1543'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='dav_member', + field=models.BooleanField(default=True, help_text='In Ausnahmefällen nehmen wir auch Nichtmitglieder mit.', verbose_name='DAV Mitglied'), + ), + migrations.AlterField( + model_name='participant', + name='dav_number', + field=models.CharField(blank=True, max_length=62, validators=[django.core.validators.RegexValidator('^([0-9]{1,10}/[0-9]{2,10}/)?[0-9]{1,10}(\\*[0-9]{1,10})?(\\*[0-9]{4}\\*[0-9]{4})?([* ][0-9]{8})?$', 'Ungültiges Format.')], verbose_name='DAV Mitgliedsnummer'), + ), + ] diff --git a/dav_events/migrations/0034_eventchange.py b/dav_events/migrations/0034_eventchange.py new file mode 100644 index 0000000..a185f07 --- /dev/null +++ b/dav_events/migrations/0034_eventchange.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-09-29 20:15 +from __future__ import unicode_literals + +import dav_events.models.eventchange +import dav_events.roles +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dav_events', '0033_auto_20200925_1543'), + ] + + operations = [ + migrations.CreateModel( + name='EventChange', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('operation', models.CharField(choices=[('update', 'Update'), ('set_flag', 'Raise Flag'), ('unset_flag', 'Lower Flag')], max_length=20)), + ('content', models.TextField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='dav_events.Event')), + ('user', models.ForeignKey(default=dav_events.models.eventchange.get_system_user_id, on_delete=models.SET(dav_events.roles.get_ghost_user), related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['event', 'timestamp'], + }, + ), + ] diff --git a/dav_events/migrations/0035_merge_20201103_1112.py b/dav_events/migrations/0035_merge_20201103_1112.py new file mode 100644 index 0000000..b45c133 --- /dev/null +++ b/dav_events/migrations/0035_merge_20201103_1112.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-11-03 10:12 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0034_auto_20201015_1738'), + ('dav_events', '0034_eventchange'), + ] + + operations = [ + ] diff --git a/dav_events/migrations/0036_trashedparticipant.py b/dav_events/migrations/0036_trashedparticipant.py new file mode 100644 index 0000000..8753703 --- /dev/null +++ b/dav_events/migrations/0036_trashedparticipant.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-03 10:44 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0035_merge_20201103_1112'), + ] + + operations = [ + migrations.CreateModel( + name='TrashedParticipant', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('personal_names', models.CharField(max_length=1024, verbose_name='Vorname(n)')), + ('family_names', models.CharField(max_length=1024, verbose_name='Familienname')), + ('address', models.CharField(help_text='Straße, Hausnummer', max_length=1024, verbose_name='Anschrift')), + ('postal_code', models.CharField(max_length=254, verbose_name='Postleitzahl')), + ('city', models.CharField(max_length=1024, verbose_name='Ort')), + ('email_address', models.EmailField(max_length=254, verbose_name='E-Mail-Adresse')), + ('phone_number', models.CharField(max_length=254, verbose_name='Telefonnummer')), + ('dav_member', models.BooleanField(default=True, help_text='In Ausnahmefällen nehmen wir auch Nichtmitglieder mit.', verbose_name='DAV Mitglied')), + ('dav_number', models.CharField(blank=True, max_length=62, validators=[django.core.validators.RegexValidator('^([0-9]{1,10}/[0-9]{2,10}/)?[0-9]{1,10}(\\*[0-9]{1,10})?(\\*[0-9]{4}\\*[0-9]{4})?([* ][0-9]{8})?$', 'Ungültiges Format.')], verbose_name='DAV Mitgliedsnummer')), + ('emergency_contact', models.TextField(blank=True, help_text='Kann frei gelassen werden.', verbose_name='Notfall-Kontakt')), + ('experience', models.TextField(blank=True, help_text='Kann frei gelassen werden.', verbose_name='Erfahrung')), + ('note', models.TextField(blank=True, help_text='Kann frei gelassen werden.', verbose_name='Anmerkung')), + ('paid', models.BooleanField(default=False, verbose_name='Teilnehmerbeitrag bezahlt')), + ('purge_at', models.DateTimeField()), + ('created_at', models.DateTimeField()), + ('trashed_at', models.DateTimeField(auto_now_add=True)), + ('position', models.IntegerField(verbose_name='Listennummer')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trashed_participants', to='dav_events.Event')), + ], + options={ + 'verbose_name': 'Gelöschter Teilnehmer (Papierkorb)', + 'verbose_name_plural': 'Gelöschte Teilnehmer (Papierkorb)', + 'ordering': ['event', 'trashed_at'], + }, + ), + ] diff --git a/dav_events/migrations/0037_auto_20201209_1327.py b/dav_events/migrations/0037_auto_20201209_1327.py new file mode 100644 index 0000000..9e0b287 --- /dev/null +++ b/dav_events/migrations/0037_auto_20201209_1327.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-09 12:27 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0036_trashedparticipant'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='year_of_birth', + field=models.IntegerField(default=1870, verbose_name='Geburtsjahr'), + preserve_default=False, + ), + migrations.AddField( + model_name='trashedparticipant', + name='year_of_birth', + field=models.IntegerField(default=1870, verbose_name='Geburtsjahr'), + preserve_default=False, + ), + ] diff --git a/dav_events/migrations/0038_auto_20201209_1542.py b/dav_events/migrations/0038_auto_20201209_1542.py new file mode 100644 index 0000000..cc6e05f --- /dev/null +++ b/dav_events/migrations/0038_auto_20201209_1542.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-09 14:42 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0037_auto_20201209_1327'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='apply_reduced_fee', + field=models.BooleanField(default=False, verbose_name='Antrag auf reduzierte Teilnahmegebühr'), + ), + migrations.AddField( + model_name='trashedparticipant', + name='apply_reduced_fee', + field=models.BooleanField(default=False, verbose_name='Antrag auf reduzierte Teilnahmegebühr'), + ), + ] diff --git a/dav_events/migrations/0039_auto_20201215_1155.py b/dav_events/migrations/0039_auto_20201215_1155.py new file mode 100644 index 0000000..42d664c --- /dev/null +++ b/dav_events/migrations/0039_auto_20201215_1155.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-15 10:55 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0038_auto_20201209_1542'), + ] + + operations = [ + migrations.AlterField( + model_name='participant', + name='apply_reduced_fee', + field=models.BooleanField(default=False, help_text='Für Jugendliche und Junioren (bis zum vollendeten 25. Lebensjahr), sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch "Karlsruher Pass"), wird die Teilnahmegebühr auf 50% ermäßigt.', verbose_name='Antrag auf reduzierte Teilnahmegebühr'), + ), + migrations.AlterField( + model_name='participant', + name='year_of_birth', + field=models.IntegerField(help_text='Vierstellige Jahreszahl', verbose_name='Geburtsjahr'), + ), + migrations.AlterField( + model_name='trashedparticipant', + name='apply_reduced_fee', + field=models.BooleanField(default=False, help_text='Für Jugendliche und Junioren (bis zum vollendeten 25. Lebensjahr), sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch "Karlsruher Pass"), wird die Teilnahmegebühr auf 50% ermäßigt.', verbose_name='Antrag auf reduzierte Teilnahmegebühr'), + ), + migrations.AlterField( + model_name='trashedparticipant', + name='year_of_birth', + field=models.IntegerField(help_text='Vierstellige Jahreszahl', verbose_name='Geburtsjahr'), + ), + ] diff --git a/dav_events/migrations/0040_auto_20201216_1712.py b/dav_events/migrations/0040_auto_20201216_1712.py new file mode 100644 index 0000000..d844345 --- /dev/null +++ b/dav_events/migrations/0040_auto_20201216_1712.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-16 16:12 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_events', '0039_auto_20201215_1155'), + ] + + operations = [ + migrations.AddField( + model_name='participant', + name='privacy_policy', + field=models.TextField(blank=True, verbose_name='Erklärung zur Datenspeicherung'), + ), + migrations.AddField( + model_name='participant', + name='privacy_policy_accepted', + field=models.BooleanField(default=False, verbose_name='Einwilligung zur Datenspeicherung'), + ), + migrations.AddField( + model_name='trashedparticipant', + name='privacy_policy', + field=models.TextField(blank=True, verbose_name='Erklärung zur Datenspeicherung'), + ), + migrations.AddField( + model_name='trashedparticipant', + name='privacy_policy_accepted', + field=models.BooleanField(default=False, verbose_name='Einwilligung zur Datenspeicherung'), + ), + ] diff --git a/dav_events/models/__init__.py b/dav_events/models/__init__.py index dbddae6..b50a909 100644 --- a/dav_events/models/__init__.py +++ b/dav_events/models/__init__.py @@ -1,6 +1,8 @@ from ..roles import get_system_user, get_ghost_user from .event import Event +from .eventchange import EventChange from .eventflag import EventFlag from .eventstatus import EventStatus from .oneclickaction import OneClickAction from .participant import Participant +from .trash import TrashedParticipant diff --git a/dav_events/models/event.py b/dav_events/models/event.py index 22e6c15..bd0a147 100644 --- a/dav_events/models/event.py +++ b/dav_events/models/event.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import datetime import difflib +import json import logging import os import re @@ -14,14 +15,14 @@ from django.db import models from django.template.loader import get_template from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import get_language, ugettext_lazy as _ -from django_countries.fields import CountryField +from django_countries.fields import Country, CountryField -from . import get_ghost_user from .. import choices from .. import config from .. import signals from ..workflow import DefaultWorkflow - +from . import get_ghost_user +from .eventchange import EventChange logger = logging.getLogger(__name__) @@ -292,9 +293,8 @@ class Event(models.Model): logger.warning('Event is not created by its owner (Current user: %s, Owner: %s)!', self.editor, owner) self.owner = owner creating = True - elif not implicit_update: + else: original = Event.objects.get(id=self.id) - original_text = original.render_as_text(show_internal_fields=True) if not self.editor or not self.editor.is_authenticated: self.editor = self.owner @@ -305,13 +305,50 @@ class Event(models.Model): logger.info('Event created: %s', self) signals.event_created.send(sender=self.__class__, event=self) self.workflow.update_status('draft', self.editor) - elif not implicit_update: - modified_text = self.render_as_text(show_internal_fields=True) - o_lines = original_text.split('\n') - m_lines = modified_text.split('\n') - diff_lines = list(difflib.unified_diff(o_lines, m_lines, n=len(m_lines), lineterm='')) - logger.info('Event updated: %s', self) - signals.event_updated.send(sender=self.__class__, event=self, diff=diff_lines, user=self.editor) + else: + change = EventChange(event=self, user=self.editor, operation=EventChange.UPDATE, + content=self.diff(original)) + change.save() + if not implicit_update: + logger.info('Event updated: %s', self) + signals.event_updated.send(sender=self.__class__, event=self, user=self.editor, + diff=self.diff(original, fmt='human_readable')) + + def diff(self, event, fmt='json'): + if fmt == 'human_readable': + from_text = event.render_as_text(show_internal_fields=True) + to_text = self.render_as_text(show_internal_fields=True) + from_lines = from_text.split('\n') + to_lines = to_text.split('\n') + diff_lines = list(difflib.unified_diff(from_lines, to_lines, n=len(from_lines), lineterm='')) + diff_text = '\n'.join(diff_lines[3:]) + elif fmt == 'json': + fields = self._meta.get_fields() + changes = [] + for field in fields: + field_name = field.name + from_value = getattr(event, field_name) + try: + json.dumps(from_value) + except TypeError: + from_value = str(from_value) + to_value = getattr(self, field_name) + try: + json.dumps(to_value) + except TypeError: + to_value = str(to_value) + if from_value != to_value: + change = { + 'field': field_name, + 'refer': from_value, + 'current': to_value, + } + changes.append(change) + diff_text = json.dumps(changes) + else: + raise ValueError("Event.diff(): Unsupported format: {}".format(fmt)) + + return diff_text def is_deadline_expired(self): today = datetime.date.today() @@ -468,6 +505,7 @@ class Event(models.Model): 'course_goal_6': self.course_goal_6, 'planned_publication_date': self.planned_publication_date, 'internal_note': self.internal_note, + 'registration_closed': self.registration_closed, } if context is not None: r.update(context) diff --git a/dav_events/models/eventchange.py b/dav_events/models/eventchange.py new file mode 100644 index 0000000..a1f3084 --- /dev/null +++ b/dav_events/models/eventchange.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.encoding import python_2_unicode_compatible + +from . import get_ghost_user, get_system_user + + +def get_system_user_id(): + return get_system_user().id + + +@python_2_unicode_compatible +class EventChange(models.Model): + UPDATE = 'update' + RAISE_FLAG = 'set_flag' + LOWER_FLAG = 'unset_flag' + OPERATION_CHOICES = ( + (UPDATE, 'Update'), + (RAISE_FLAG, 'Raise Flag'), + (LOWER_FLAG, 'Lower Flag'), + ) + + event = models.ForeignKey('dav_events.Event', related_name='changes') + timestamp = models.DateTimeField(default=timezone.now) + user = models.ForeignKey(settings.AUTH_USER_MODEL, + default=get_system_user_id, + on_delete=models.SET(get_ghost_user), + related_name='+') + + operation = models.CharField(max_length=20, choices=OPERATION_CHOICES) + content = models.TextField() + + class Meta: + ordering = ['event', 'timestamp'] + + def __str__(self): + s = '{timestamp} - {user} - {operation}' + return s.format(operation=self.operation, timestamp=self.timestamp.strftime('%d.%m.%Y %H:%M:%S %Z'), + user=self.user) diff --git a/dav_events/models/participant.py b/dav_events/models/participant.py index 4171c2d..da0b692 100644 --- a/dav_events/models/participant.py +++ b/dav_events/models/participant.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals import datetime +from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible @@ -12,12 +13,7 @@ midnight = datetime.time(00, 00, 00) @python_2_unicode_compatible -class Participant(models.Model): - event = models.ForeignKey('Event', related_name='participants') - created_at = models.DateTimeField(auto_now_add=True) - - position = models.IntegerField(verbose_name='Listennummer') - +class AbstractParticipant(models.Model): personal_names = models.CharField(max_length=1024, verbose_name=_('Vorname(n)')) family_names = models.CharField(max_length=1024, @@ -32,8 +28,20 @@ class Participant(models.Model): email_address = models.EmailField(verbose_name=_('E-Mail-Adresse')) phone_number = models.CharField(max_length=254, verbose_name=_('Telefonnummer')) + year_of_birth = models.IntegerField(verbose_name=_('Geburtsjahr'), + help_text=_('Vierstellige Jahreszahl')) + apply_reduced_fee = models.BooleanField(default=False, + verbose_name=_('Antrag auf reduzierte Teilnahmegebühr'), + help_text=_('Für Jugendliche und Junioren' + ' (bis zum vollendeten 25. Lebensjahr),' + ' sowie Mitglieder mit geringen finanziellen Mitteln' + ' (Nachweis durch "Karlsruher Pass"),' + ' wird die Teilnahmegebühr auf 50% ermäßigt.')) + dav_member = models.BooleanField(default=True, + verbose_name=_('DAV Mitglied'), + help_text=_('In Ausnahmefällen nehmen wir auch Nichtmitglieder mit.')) dav_number = models.CharField(max_length=62, - validators=[DAVNumberValidator], + blank=True, validators=[DAVNumberValidator], verbose_name=_('DAV Mitgliedsnummer')) emergency_contact = models.TextField(blank=True, verbose_name=_('Notfall-Kontakt'), @@ -45,22 +53,25 @@ class Participant(models.Model): verbose_name=_('Anmerkung'), help_text=_('Kann frei gelassen werden.')) + privacy_policy = models.TextField(blank=True, + verbose_name=_('Erklärung zur Datenspeicherung')) + privacy_policy_accepted = models.BooleanField(default=False, + verbose_name=_('Einwilligung zur Datenspeicherung')) + paid = models.BooleanField('Teilnehmerbeitrag bezahlt', default=False) purge_at = models.DateTimeField() + def approx_age(self): + now = datetime.datetime.now() + year_now = now.year + return year_now - self.year_of_birth + class Meta: - unique_together = (('event', 'position'), ) - verbose_name = _('Teilnehmer') - verbose_name_plural = _('Teilnehmer') - ordering = ['event', 'position'] + abstract = True def __str__(self): - return '{eventnumber} - {position}. {name}'.format( - eventnumber=self.event.get_number(), - position=self.position, - name=self.get_full_name(), - ) + return self.get_full_name() def get_full_name(self): return '{} {}'.format(self.personal_names, self.family_names) @@ -69,26 +80,56 @@ class Participant(models.Model): text = """{fullname} {address}, {postal_code} {city} + DAV Mitglied: {dav_info} + Jahrgang: {year_of_birth} (ungefähres Alter: {approx_age}) + Antrag auf reduzierte Teilnehmergebühr: {apply_reduced_fee_yesno} + Notfallkontakt: {emergency_contact} Anmerkung: {note} """ + if not self.dav_member: + dav_info = _('Nein') + else: + dav_info = self.dav_number + + if self.apply_reduced_fee: + apply_reduced_fee_yesno = _('Ja') + else: + apply_reduced_fee_yesno = _('Nein') + return text.format( fullname=self.get_full_name(), address=self.address, postal_code=self.postal_code, city=self.city, + dav_info=dav_info, + year_of_birth=self.year_of_birth, + approx_age=self.approx_age(), + apply_reduced_fee_yesno=apply_reduced_fee_yesno, emergency_contact=self.emergency_contact, note=self.note, ) + def get_data_dict(self): + data = {} + for field in self._meta.fields: + if not field.primary_key: + data[field.name] = getattr(self, field.name) + return data + + def clean(self): + if self.dav_member and not self.dav_number: + raise ValidationError({'dav_number': _('Bei DAV Mitgliedern brauchen wir die Mitgliedsnummer.')}) + def save(self, **kwargs): if not self.purge_at and self.event: self.purge_at = self.__class__.calc_purge_at(self.event) - super(Participant, self).save(**kwargs) + self.full_clean() + super(AbstractParticipant, self).save(**kwargs) @staticmethod def calc_purge_at(event): @@ -115,3 +156,23 @@ class Participant(models.Model): purge_date = july_nextyear return timezone.make_aware(datetime.datetime.combine(purge_date, midnight)) + + +@python_2_unicode_compatible +class Participant(AbstractParticipant): + event = models.ForeignKey('Event', related_name='participants') + created_at = models.DateTimeField(auto_now_add=True) + position = models.IntegerField(verbose_name='Listennummer') + + class Meta: + verbose_name = _('Teilnehmer') + verbose_name_plural = _('Teilnehmer') + unique_together = (('event', 'position'), ) + ordering = ['event', 'position'] + + def __str__(self): + return '{eventnumber} - {position}. {name}'.format( + eventnumber=self.event.get_number(), + position=self.position, + name=self.get_full_name(), + ) diff --git a/dav_events/models/trash/__init__.py b/dav_events/models/trash/__init__.py new file mode 100644 index 0000000..6723f08 --- /dev/null +++ b/dav_events/models/trash/__init__.py @@ -0,0 +1 @@ +from .trashed_participant import TrashedParticipant \ No newline at end of file diff --git a/dav_events/models/trash/trashed_participant.py b/dav_events/models/trash/trashed_participant.py new file mode 100644 index 0000000..90609ac --- /dev/null +++ b/dav_events/models/trash/trashed_participant.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.db import models +from django.utils.encoding import python_2_unicode_compatible +from django.utils.translation import ugettext_lazy as _ + +from ..participant import AbstractParticipant + + +@python_2_unicode_compatible +class TrashedParticipant(AbstractParticipant): + event = models.ForeignKey('Event', related_name='trashed_participants') + created_at = models.DateTimeField() + trashed_at = models.DateTimeField(auto_now_add=True) + position = models.IntegerField(verbose_name='Listennummer') + + class Meta: + verbose_name = _('Gelöschter Teilnehmer (Papierkorb)') + verbose_name_plural = _('Gelöschte Teilnehmer (Papierkorb)') + ordering = ['event', 'trashed_at'] + + def __str__(self): + return '{eventnumber} - {name}'.format( + eventnumber=self.event.get_number(), + name=self.get_full_name(), + ) diff --git a/dav_events/templates/dav_events/event/default.txt b/dav_events/templates/dav_events/event/default.txt index b563404..c526db6 100644 --- a/dav_events/templates/dav_events/event/default.txt +++ b/dav_events/templates/dav_events/event/default.txt @@ -54,6 +54,7 @@ {% trans 'Schwierigkeitsnivau' %}: {{ event.get_level_display }} {% if event.sport == 'S' %}{% trans 'Skiliftbenutzung' %}: {% if event.ski_list %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %} {% endif %}{% trans 'Gelände' %}: {{ event.get_terrain_display }} +{% trans 'Anmeldung' %}: {% if registration_required %}{% if registration_closed %}{% trans 'Geschlossen' %}{% else %}{% trans 'Erforderlich' %}{% endif %}{% else %}{% trans 'Nicht erforderlich' %}{% endif %} {% trans 'Anreise des Kurs-/Tourenleiters am Vortag' %}: {% if event.arrival_previous_day %}{% trans 'Ja' %}{% else %}{% trans 'Nein' %}{% endif %} {% trans 'Veröffentlichung' %}: {% if planned_publication_date %}{{ planned_publication_date|date:'l, d. F Y' }}{% else %}{% trans 'sofort' %}{% endif %} {% if internal_note %} diff --git a/dav_events/templates/dav_events/event_create/SummaryForm.html b/dav_events/templates/dav_events/event_create/SummaryForm.html index 4c925a4..7bf5704 100644 --- a/dav_events/templates/dav_events/event_create/SummaryForm.html +++ b/dav_events/templates/dav_events/event_create/SummaryForm.html @@ -40,11 +40,9 @@ {% bootstrap_icon 'remove' %}  {% trans 'Abbrechen' %} - {% endbuttons %} {% endblock form-buttons %} diff --git a/dav_events/templates/dav_events/event_detail.html b/dav_events/templates/dav_events/event_detail.html index f4b95c9..21ef44e 100644 --- a/dav_events/templates/dav_events/event_detail.html +++ b/dav_events/templates/dav_events/event_detail.html @@ -1,6 +1,7 @@ {% extends 'dav_events/base.html' %} {% load bootstrap3 %} {% load i18n %} +{% load dav_events %} {% block head-title %}{{ event }} - {{ block.super }}{% endblock head-title %} @@ -188,35 +189,77 @@
{{ event.render_as_html }} -
-
-
-
-
Status-Log
- {% for flag in event.flags.all %} -
-
- {% bootstrap_icon 'check' %} - {{ flag.status.label }}: -
-
- {{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}
- {% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }} +
+
+
+
+ +
+
+ {% for flag in event.flags.all %} +
+
+ {% bootstrap_icon 'check' %} + {{ flag.status.label }}: +
+
+ {{ flag.timestamp|date:'l, d. F Y, H:i' }} {% trans 'Uhr' %}
+ {% trans 'von' %} {{ flag.user.get_full_name|default:flag.user }} +
+
+ {% endfor %}
- {% endfor %}
-
-
{% trans 'Veröffentlichung' %}
+
+ +
+
+ {% render_event_changelog event %} +
+
+
+
+
+
+ {% if event.internal_note %} +
+
+
{% trans 'Bearbeitungshinweis' %}
+
+
+
{{ event.internal_note|linebreaksbr }}
+
+
+ {% endif %} +
+
+
{% trans 'Veröffentlichung' %}
+
+
{% if event.planned_publication_date %} {{ event.planned_publication_date|date:'l, d. F Y' }} {% else %} {% trans 'Unverzüglich' %} {% endif %} - {% if event.internal_note %} -
{% trans 'Bearbeitungshinweis' %}
-
{{ event.internal_note|linebreaksbr }}
- {% endif %}
diff --git a/dav_events/templates/dav_events/event_registrations.html b/dav_events/templates/dav_events/event_registrations.html index fc63f20..531557e 100644 --- a/dav_events/templates/dav_events/event_registrations.html +++ b/dav_events/templates/dav_events/event_registrations.html @@ -222,12 +222,40 @@
+
+
+ {% bootstrap_icon 'question-sign' %} +

{% trans 'Teilnehmer' %}

{% if registrations_support %}
-
- {% for registration in registrations %} + {% for registration in registrations_pending %}
{% csrf_token %} {% if has_permission_update_participants %} + {% if registration.apply_reduced_fee %} + + {% bootstrap_icon 'plus-sign' %} + + {% else %} + {% endif %}     {% endif %} - {% if registration.answered %} - - {% endif %} {{ registration.get_full_name }} ({{ registration.email_address }}, {{ registration.phone_number }}) @@ -270,7 +302,10 @@ {% bootstrap_icon 'info-sign' %} - {% if registration.answered %} + {% if registration.apply_reduced_fee %} +   + + %{% bootstrap_icon 'piggy-bank' %} (reduzierte Gebühr) {% endif %} @@ -284,6 +319,14 @@ {% if has_permission_update_participants %}
+
+ {% bootstrap_icon 'question-sign' %} +
{% trans 'Es wurden noch keine Teilnehmer hinzugefügt.' %}
@@ -414,6 +475,159 @@
{% endif %}
+ +
+
+ {% bootstrap_icon 'question-sign' %} +
+

{% trans 'Papierkorb' %}

+
+ {% if registrations_support and registrations_answered %} +
+ +
+
+ {% for registration in registrations_answered %} +
+ +   + +   + + {{ registration.get_full_name }} + ({{ registration.email_address }}, + {{ registration.phone_number }}) +   + + {% bootstrap_icon 'time' %} + {{ registration.created_at|date:'d. F Y, G:i' }} + +   + + {% bootstrap_icon 'info-sign' %} + + + + {% if has_permission_update_registration %} +
+
+ {% csrf_token %} + + +
+
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %} + {% if participants_trash %} +
+ +
+
+ {% for participant in participants_trash %} +
+ +   + + {{ participant.get_full_name }} + ({{ participant.email_address }}, + {{ participant.phone_number }}) +   + + {% bootstrap_icon 'info-sign' %} + + {% if event.charge and participant.paid %} +   + %{% bootstrap_icon 'piggy-bank' %} + {% elif event.charge %} +   + %{% bootstrap_icon 'piggy-bank' %} + {% endif %} + + + {% if has_permission_update_participants %} +
+
+ {% csrf_token %} + + +
+
+ {% endif %} +
+ {% endfor %} +
+
+
+ {% endif %} + {% if not registrations_answered and not participants_trash %} + {% trans 'Der Papierkorb ist leer.' %} + {% endif %} +
-{% endblock page-container-fluid %} \ No newline at end of file +{% endblock page-container-fluid %} diff --git a/dav_events/templates/dav_events/includes/participant_form.html b/dav_events/templates/dav_events/includes/participant_form.html index 4eb0e04..535d068 100644 --- a/dav_events/templates/dav_events/includes/participant_form.html +++ b/dav_events/templates/dav_events/includes/participant_form.html @@ -29,9 +29,20 @@
-
+
+ {% bootstrap_field form.year_of_birth %} +
+
+ {% bootstrap_field form.apply_reduced_fee %} +
+
+
+
{% bootstrap_field form.dav_number %}
+
+ {% bootstrap_field form.dav_member %} +
{% bootstrap_field form.emergency_contact %}
diff --git a/dav_events/templates/dav_events/registration_response.html b/dav_events/templates/dav_events/registration_response.html new file mode 100644 index 0000000..6f7bc83 --- /dev/null +++ b/dav_events/templates/dav_events/registration_response.html @@ -0,0 +1,45 @@ +{% extends 'dav_events/base.html' %} +{% load bootstrap3 %} +{% load i18n %} + +{% block head-title %} + {% trans 'Anmeldung' %} {{ registration.get_full_name }} - {{ registration.event.number }} - {{ block.super }} +{% endblock head-title %} + +{% block page-container %} +
+

+ Hallo {{ registration.event.trainer_firstname }}, +

+

+ du hast sicherlich schon gesehen, dass {{ registration.get_full_name }} angekreuzt hat, + die reduzierte Teilnahmegebühr zahlen zu wollen. +

+

+ Für Jugendliche und Junioren sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch Karlsruher Paß) + wird die Teilnahmegebühr auf 50% ermäßigt. +

+

+ Wenn ihr bereits darüber gesprochen habt und ihr übereingekommen seid, + dass {{ registration.personal_names }} doch den vollen Betrag zahlen soll, + dann kannst du unten den Haken entfernen. +

+
+
+
+ {{ registration.event.number }} - {{ registration.event.title }}
+ {% trans 'Anmeldung' %} {{ registration.get_full_name }} +
+
+ {% csrf_token %} + {% bootstrap_form form %} + + + {% bootstrap_icon 'remove' %} Zurück + +
+
+{% endblock page-container %} diff --git a/dav_events/templatetags/dav_events.py b/dav_events/templatetags/dav_events.py index f5731f2..cbeac1d 100644 --- a/dav_events/templatetags/dav_events.py +++ b/dav_events/templatetags/dav_events.py @@ -1,7 +1,12 @@ +# -*- coding: utf-8 -*- +import json from django import template from django.utils.html import format_html from django.utils.safestring import mark_safe +from django.utils import timezone +from django.utils.translation import ugettext as _ +from ..models.eventchange import EventChange from ..models.eventstatus import EventStatus, get_or_create_event_status register = template.Library() @@ -30,3 +35,86 @@ def render_event_status(event, show_void=True): context=context) return mark_safe(html) + + +@register.simple_tag +def render_event_changelog(event): + change_templ = u'
  • \n' \ + u'\t

    ' \ + u'' \ + u' {timestamp}' \ + u' - ' \ + u' {user}

    \n' \ + u'\t{content}\n' \ + u'
  • \n' + update_sub_templ = u'
  • \n' \ + u'\t{field}:{separator1}\n' \ + u'\t{refer}\n' \ + u'\t{separator2}\n' \ + u'\t{current}\n' \ + u'
  • \n' + raise_flag_templ = u'' \ + u' {label}' \ + u' ' + lower_flag_templ = u'' \ + u' {label}' \ + u' ' + + if event.changes.exists(): + html = u'
      \n' + + for change in event.changes.all(): + + username = change.user.get_full_name() + if not username: + username = change.user + + if change.operation == EventChange.UPDATE: + icon = u'pencil' + content_html = u'
        ' + subchanges = json.loads(change.content) + for subchange in subchanges: + field_label = event._meta.get_field(subchange['field']).verbose_name + try: + is_long_strings = (len(subchange['refer']) + len(subchange['current'])) > 20 + except TypeError: + is_long_strings = False + if is_long_strings: + separator1 = u'
        ' + separator2 = u'
        ' + else: + separator1 = u' ' + separator2 = u' -> ' + content_html += format_html(update_sub_templ, + field=field_label, + separator1=mark_safe(separator1), + refer=subchange['refer'], + separator2=mark_safe(separator2), + current=subchange['current']) + content_html += u'
      ' + elif change.operation == EventChange.RAISE_FLAG: + icon = u'flag' + status = get_or_create_event_status(change.content) + content_html = format_html(raise_flag_templ, + bcontext=status.bootstrap_context or u'default', + label=status.label) + elif change.operation == EventChange.LOWER_FLAG: + icon = u'flag' + status = get_or_create_event_status(change.content) + content_html = format_html(lower_flag_templ, + bcontext=status.bootstrap_context or u'default', + label=status.label) + else: + icon = u'question-sign' + content_html = format_html(u'{content}', content=change.content) + + html += format_html(change_templ, + icon=icon, + timestamp=timezone.localtime(change.timestamp).strftime('%Y-%m-%d %H:%M:%S %Z'), + user=username, + content=mark_safe(content_html)) + + html += u'
    \n' + else: + html = _(u'Keine Einträge') + return mark_safe(html) diff --git a/dav_events/tests/test_emails.py b/dav_events/tests/test_emails.py index 13b33f0..2e3c3e8 100644 --- a/dav_events/tests/test_emails.py +++ b/dav_events/tests/test_emails.py @@ -49,6 +49,7 @@ Link zur Veranstaltung: Veranstaltungsart: gemeinschaftliche Tour Schwierigkeitsnivau: Anfänger Gelände: Kletterhalle + Anmeldung: Nicht erforderlich Anreise des Kurs-/Tourenleiters am Vortag: Nein Veröffentlichung: sofort """ diff --git a/dav_events/tests/test_models.py b/dav_events/tests/test_models.py new file mode 100644 index 0000000..48c0c65 --- /dev/null +++ b/dav_events/tests/test_models.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import datetime +import json +from django.test import TestCase + +from ..models.eventchange import EventChange +from .generic import EventMixin + +TEST_EVENT_DATA = { + 'title': 'Täst', + 'description': 'Teßt', + 'mode': 'joint', + 'sport': 'W', + 'level': 'beginner', + 'first_day': datetime.date(2019, 3, 1), + 'country': 'DE', + 'trainer_firstname': 'Übungsleiter', + 'trainer_familyname': 'Weißalles', + 'trainer_email': 'trainer@localhost', +} + + +class EventsTestCase(EventMixin, TestCase): + def test_changelog(self): + data = TEST_EVENT_DATA + event = self.create_event_by_model(data) + + event.alt_first_day = event.first_day + datetime.timedelta(1) + event.sport = 'M' + event.ski_lift = True + event.save() + + event.country = 'FR' + event.save() + + event.trainer_familyname += '-Ömlaut' + event.max_participants = 8 + event.save() + + changes = event.changes + self.assertEqual(changes.count(), 4) + + change = changes.get(pk=1) + self.assertEqual(change.operation, EventChange.RAISE_FLAG) + self.assertEqual(change.content, 'draft') + + change = changes.get(pk=2) + self.assertEqual(change.operation, EventChange.UPDATE) + subchanges = json.loads(change.content) + self.assertEqual(len(subchanges), 3) + self.assertIn({'field': 'alt_first_day', 'refer': None, 'current': '2019-03-02'}, subchanges) + self.assertIn({'field': 'sport', 'refer': 'W', 'current': 'M'}, subchanges) + self.assertIn({'field': 'ski_lift', 'refer': False, 'current': True}, subchanges) + + change = changes.get(pk=3) + self.assertEqual(change.operation, EventChange.UPDATE) + subchanges = json.loads(change.content) + self.assertEqual(len(subchanges), 1) + self.assertIn({'field': 'country', 'refer': 'DE', 'current': 'FR'}, subchanges) + + change = changes.get(pk=4) + self.assertEqual(change.operation, EventChange.UPDATE) + subchanges = json.loads(change.content) + self.assertEqual(len(subchanges), 2) + self.assertIn({'field': 'trainer_familyname', 'refer': 'Weißalles', 'current': 'Weißalles-Ömlaut'}, subchanges) + self.assertIn({'field': 'max_participants', 'refer': 0, 'current': 8}, subchanges) diff --git a/dav_events/tests/test_oneclickactions.py b/dav_events/tests/test_oneclickactions.py index c0b1da7..beee8c2 100644 --- a/dav_events/tests/test_oneclickactions.py +++ b/dav_events/tests/test_oneclickactions.py @@ -121,7 +121,11 @@ class ActionTestCase(EmailTestMixin, RoleMixin, EventMixin, TestCase): 'user': user.get_full_name(), }) html = message.replace('\'', ''') - self.assertInHTML(html, content) + # Sometimes this test fail, and we cannot see the tested content, so we create our own Exception + try: + self.assertInHTML(html, content) + except AssertionError: + raise AssertionError('Not in HTML:\n{}\n-----\n{}\n'.format(html, content)) self.assertRegex(content, r'alert-success') def setUp(self): diff --git a/dav_events/tests/test_screenshots.py b/dav_events/tests/test_screenshots.py index f0246ab..a211883 100644 --- a/dav_events/tests/test_screenshots.py +++ b/dav_events/tests/test_screenshots.py @@ -14,7 +14,7 @@ from dav_auth.tests.generic import SeleniumAuthMixin from .generic import RoleMixin TEST_TRAINER_EMAIL = 'trainer@localhost' -TEST_PASSWORD = u'me||ön 2' +TEST_PASSWORD = u'me||ön 21ABll' TEST_EVENT_DATA_S = { 'mode': 'training', 'sport': 'S', diff --git a/dav_events/urls.py b/dav_events/urls.py index b348d56..40cb2d7 100644 --- a/dav_events/urls.py +++ b/dav_events/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ views.events.EventUpdateStatusView.as_view(), name='updatestatus'), url(r'^(?P\d+)/edit', views.events.EventUpdateView.as_view(), name='update'), url(r'^(?P\d+)/', views.events.EventDetailView.as_view(), name='detail'), + url(r'^registration/(?P\d+)/', views.events.RespondRegistrationView.as_view(), name='respond_registration'), url(r'^action/(?P[a-fA-F0-9]{8}-([a-fA-F0-9]{4}-){3}[a-fA-F0-9]{12})/', views.actions.OneClickActionRunView.as_view(), name='action_run'), ] diff --git a/dav_events/utils.py b/dav_events/utils.py index 85377a6..a1cebe4 100644 --- a/dav_events/utils.py +++ b/dav_events/utils.py @@ -1,7 +1,7 @@ import logging from django.utils import timezone -from .models import Participant +from .models import Participant, TrashedParticipant logger = logging.getLogger(__name__) @@ -11,3 +11,6 @@ def purge_participants(): for p in Participant.objects.filter(purge_at__lte=now): logger.info('Purge participant \'%s\'', p) p.delete() + for p in TrashedParticipant.objects.filter(purge_at__lte=now): + logger.info('Purge participant from trash \'%s\'', p) + p.delete() diff --git a/dav_events/views/events.py b/dav_events/views/events.py index 2fd0a44..8946fcb 100644 --- a/dav_events/views/events.py +++ b/dav_events/views/events.py @@ -2,6 +2,7 @@ import datetime import logging import os +from django.apps import apps from django.contrib import messages from django.contrib.auth import login from django.contrib.auth.decorators import login_required @@ -215,6 +216,18 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView): participants = event.participants.all() context['participants'] = participants + participants_trash = event.trashed_participants.all() + context['participants_trash'] = participants_trash + + earnings = 0 + if event.charge: + for participant in participants: + if participant.paid: + if participant.apply_reduced_fee: + earnings += event.charge / 2 + else: + earnings += event.charge + context['earnings'] = earnings if participants.count() > 1: email_list = [u'"{}" <{}>'.format(p.get_full_name(), p.email_address) for p in participants] @@ -230,9 +243,13 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView): registrations_support = hasattr(event, 'registrations') context['registrations_support'] = registrations_support if registrations_support: - registrations = event.registrations.filter(answered=False) - # registrations = event.registrations.all() - context['registrations'] = registrations + registrations_all = event.registrations.all() + registrations_pending = registrations_all.filter(~Q(status__answered=True)) + registrations_answered = registrations_all.filter(status__answered=True) + context['registrations_all'] = registrations_all + context['registrations_pending'] = registrations_pending + context['registrations_answered'] = registrations_answered + context['registrations'] = registrations_all return context @@ -265,34 +282,21 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView): messages.success(request, _(u'Der Anmeldeschluss wurde gelöscht')) def _accept_registration(self, request, registration): - event = registration.event + data = registration.get_data_dict() + del data['created_at'] + del data['answered_obsolete'] + data['position'] = registration.event.participants.count() + 1 - position = event.participants.count() + 1 - - data = { - 'event': event, - 'position': position, - 'personal_names': registration.personal_names, - 'family_names': registration.family_names, - 'address': registration.address, - 'postal_code': registration.postal_code, - 'city': registration.city, - 'email_address': registration.email_address, - 'phone_number': registration.phone_number, - 'dav_number': registration.dav_number, - 'emergency_contact': registration.emergency_contact, - 'experience': registration.experience, - 'note': registration.note, - 'purge_at': registration.purge_at, - } participant = models.Participant.objects.create(**data) - registration.answered = True - registration.save() + + registration.status.set_accepted() messages.success(request, _(u'Teilnehmer hinzugefügt: {}'.format(participant.get_full_name()))) def _reject_registration(self, registration): - registration.answered = True - registration.save() + registration.status.set_rejected() + + def _reset_registration(self, registration): + registration.status.reset() def _swap_participants_position(self, participant1, participant2): event = participant1.event @@ -342,6 +346,20 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView): self._reject_registration(registration) else: raise FieldDoesNotExist('Event has no registrations') + elif action == 'untrash_registration': + self.enforce_permission(event, permission='update-registration') + if hasattr(event, 'registrations'): + registration_id = request.POST.get('registration') + registration = event.registrations.get(id=registration_id) + self._reset_registration(registration) + else: + raise FieldDoesNotExist('Event has no registrations') + elif action == 'toggle_reduced_fee': + self.enforce_permission(event, permission='payment') + participant_id = request.POST.get('id') + participant = event.participants.get(id=participant_id) + participant.apply_reduced_fee = not participant.apply_reduced_fee + participant.save() elif action == 'confirm_payment': self.enforce_permission(event, permission='payment') participant_id = request.POST.get('id') @@ -354,18 +372,33 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView): participant = event.participants.get(id=participant_id) participant.paid = False participant.save() - elif action == 'remove_participant': + elif action == 'trash_participant': self.enforce_permission(event, permission='update-participants') participant_id = request.POST.get('id') participant = event.participants.get(id=participant_id) - full_name = participant.get_full_name() - position = participant.position + participants_below = event.participants.filter(position__gt=participant.position) + + data = participant.get_data_dict() + trashed = models.TrashedParticipant.objects.create(**data) participant.delete() - qs = event.participants.filter(position__gt=position) - for participant in qs: + + for participant in participants_below: participant.position -= 1 participant.save() - messages.success(request, _(u'Teilnehmer gelöscht: {}'.format(full_name))) + + messages.success(request, _(u'Teilnehmer in den Papierkorb verschoben: {}'.format(trashed.get_full_name()))) + elif action == 'untrash_participant': + self.enforce_permission(event, permission='update-participants') + trashed_id = request.POST.get('id') + trashed = event.trashed_participants.get(id=trashed_id) + trashed.position = event.participants.count() + 1 + + data = trashed.get_data_dict() + del data['trashed_at'] + participant = models.Participant.objects.create(**data) + trashed.delete() + + messages.success(request, _(u'Teilnehmer zurückgeholt: {}'.format(participant.get_full_name()))) elif action == 'moveup_participant': self.enforce_permission(event, permission='update-participants') participant_id = request.POST.get('id') @@ -422,6 +455,62 @@ class EventRegistrationsView(EventPermissionMixin, generic.DetailView): return super(EventRegistrationsView, self).dispatch(request, *args, **kwargs) +class RespondRegistrationView(EventPermissionMixin, generic.DetailView, generic.FormView): + permission = 'update-participants' + context_object_name = 'registration' + template_name = 'dav_events/registration_response.html' + form_class = forms.registration.RegistrationResponseForm + + def _accept_registration(self, request, registration): + data = registration.get_data_dict() + del data['created_at'] + del data['answered_obsolete'] + data['position'] = registration.event.participants.count() + 1 + + participant = models.Participant.objects.create(**data) + + registration.status.set_accepted() + messages.success(request, _(u'Teilnehmer hinzugefügt: {}'.format(participant.get_full_name()))) + + def has_permission(self, permission, obj): + user = self.request.user + return obj.event.workflow.has_permission(user, permission) + + def get_queryset(self): + model = apps.get_model(app_label='dav_registration', model_name='Registration') + return model.objects.all() + + def get_success_url(self): + return reverse('dav_events:registrations', args=[self.object.event.pk]) + + def get_initial(self): + return { + 'apply_reduced_fee': self.object.apply_reduced_fee, + } + + def form_valid(self, form): + registration = self.object + registration.apply_reduced_fee = form.cleaned_data['apply_reduced_fee'] + registration.save() + self._accept_registration(self.request, registration) + return HttpResponseRedirect(self.get_success_url()) + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + self.enforce_permission(self.object) + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + self.enforce_permission(self.object) + return super(RespondRegistrationView, self).post(request, *args, **kwargs) + + @method_decorator(login_required) + def dispatch(self, request, *args, **kwargs): + return super(RespondRegistrationView, self).dispatch(request, *args, **kwargs) + + class EventUpdateStatusView(EventPermissionMixin, generic.DetailView): model = models.Event @@ -454,6 +543,18 @@ class EventUpdateStatusView(EventPermissionMixin, generic.DetailView): messages.error(request, message) return HttpResponseRedirect(event.get_absolute_url()) + if not event.workflow.has_reached_status('publishing*') and not event.workflow.has_reached_status('published*'): + cur_pub_date = event.planned_publication_date + real_pub_date, real_pub_issue = event.workflow.plan_publication(event.first_day, event.deadline) + if cur_pub_date != real_pub_date: + if real_pub_date is None: + real_pub_str = _(u'Unverzüglich') + else: + real_pub_str = u'%s (%s)' % (real_pub_date.strftime('%d.%m.%Y'), real_pub_issue) + event.planned_publication_date = real_pub_date + event.save() + messages.warning(request, _(u'Veröffentlichungsdatum wurde angepasst: %s') % real_pub_str) + event.workflow.update_status(status, request.user) if status.startswith('submit'): @@ -513,7 +614,21 @@ class EventUpdateView(EventPermissionMixin, generic.UpdateView): def form_valid(self, form): form.instance.editor = self.request.user - self.object = form.save() + event = form.save() + self.object = event + + if not event.workflow.has_reached_status('publishing*') and not event.workflow.has_reached_status('published*'): + cur_pub_date = event.planned_publication_date + real_pub_date, real_pub_issue = event.workflow.plan_publication(event.first_day, event.deadline) + if cur_pub_date != real_pub_date: + if real_pub_date is None: + real_pub_str = _(u'Unverzüglich') + else: + real_pub_str = u'%s (%s)' % (real_pub_date.strftime('%d.%m.%Y'), real_pub_issue) + event.planned_publication_date = real_pub_date + event.save() + messages.warning(self.request, _(u'Veröffentlichungsdatum wurde angepasst: %s') % real_pub_str) + return HttpResponseRedirect(self.get_success_url()) @method_decorator(login_required) @@ -573,6 +688,31 @@ class EventCreateView(EventPermissionMixin, generic.FormView): return self.render_to_response(self.get_context_data(form=next_form, event=event)) else: event.editor = self.request.user + + # Check for double submission (seems to happens accidentally if smartphone user reload the submit page) + possible_doublets = models.Event.objects.filter(owner=event.owner, + title=event.title, + first_day=event.first_day) + if possible_doublets.exists(): + accident_period = datetime.datetime.now() - datetime.timedelta(hours=24) + possible_doublets = possible_doublets.filter(created_at__gt=accident_period) + if possible_doublets.exists(): + doublet = possible_doublets.first() + doublet_created_at = doublet.created_at.strftime('%d.%m.%Y %H:%M:%S') + error_msg = _(u'Du hast bereits eine Veranstaltung' + u' mit dem Titel "%(title)s"' + u' für das Datum %(day)s angelegt' + u' (am %(created_at)s).') % {'title': event.title, + 'day': event.first_day, + 'created_at': doublet_created_at} + warn_msg = _(u'Deine eingegebenen Daten sind noch da,' + u' wenn du jetzt auf "Neue Veranstaltung anlegen" klickst.' + u' Du musst aber entweder den Titel oder das Startdatum ändern,' + u' um die Veranstaltung anlegen zu können.') + messages.error(self.request, error_msg) + messages.warning(self.request, warn_msg) + return HttpResponseRedirect(reverse('dav_events:list')) + event.save() if 'submit' in form.data: event.workflow.update_status('submitted', event.owner) diff --git a/dav_events/workflow.py b/dav_events/workflow.py index 69195d5..499b09b 100644 --- a/dav_events/workflow.py +++ b/dav_events/workflow.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from . import emails from . import signals +from .models.eventchange import EventChange from .models.eventflag import EventFlag from .models.eventstatus import get_or_create_event_status from .roles import get_users_by_role, has_role @@ -50,6 +51,8 @@ class BasicWorkflow(object): kwargs['status'] = status flag = EventFlag(**kwargs) flag.save() + change = EventChange(event=event, user=flag.user, operation=EventChange.RAISE_FLAG, content=status.code) + change.save() logger.info('Flagging status \'%s\' for %s', status.code, event) return flag @@ -312,10 +315,9 @@ class BasicWorkflow(object): if not app_config.settings.enable_email_on_update: return - if len(diff) < 1: + if not diff: logger.debug('send_emails_on_update(): No diff data -> Skip sending mails.') return - diff_text = '\n'.join(diff[3:]) # Who should be informed about the update? recipients = [event.owner] @@ -329,7 +331,7 @@ class BasicWorkflow(object): for recipient in recipients: if recipient.email and recipient.email != updater.email: - email = emails.EventUpdatedMail(recipient=recipient, event=event, editor=updater, diff=diff_text) + email = emails.EventUpdatedMail(recipient=recipient, event=event, editor=updater, diff=diff) email.send() def send_emails_on_status_update(self, flag): @@ -487,9 +489,8 @@ class BasicWorkflow(object): # # Misc logic # - # TODO: is a class method a good idea? - @classmethod - def plan_publication(cls, first_day, deadline=None): + @staticmethod + def plan_publication(first_day, deadline=None): app_config = apps.get_containing_app_config(__package__) if deadline: diff --git a/dav_registration/admin.py b/dav_registration/admin.py index b7617c8..401f651 100644 --- a/dav_registration/admin.py +++ b/dav_registration/admin.py @@ -1,8 +1,12 @@ from django.contrib import admin -from .models import Registration +from .models import Registration, RegistrationStatus + + +class RegistrationStatusInline(admin.StackedInline): + model = RegistrationStatus @admin.register(Registration) class RegistrationAdmin(admin.ModelAdmin): - pass + inlines = [RegistrationStatusInline] diff --git a/dav_registration/forms.py b/dav_registration/forms.py index 504874b..2b10316 100644 --- a/dav_registration/forms.py +++ b/dav_registration/forms.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- +import datetime import logging from django import forms -from django.utils.translation import ugettext +from django.utils.translation import ugettext, ugettext_lazy as _ from .models import Registration @@ -9,14 +10,50 @@ logger = logging.getLogger(__name__) class RegistrationForm(forms.ModelForm): + not_dav_member = forms.BooleanField(required=False, + label=_(u'Ich bin noch kein DAV Mitglied.'), + help_text=u'%s
    \n%s' % ( + _(u'Wenn du noch kein DAV Mitglied bist,' + u' oder deine Aufnahme noch in Arbeit ist,' + u' kreuze dieses Feld hier an.'), + _(u'Spätestens zu Veranstaltungsbeginn muss' + u' jedoch eine Mitgliedschaft bestehen.') + )) + class Meta: model = Registration exclude = ['event', 'created_at', 'privacy_policy', 'purge_at', 'answered'] widgets = { + 'dav_member': forms.HiddenInput(), 'emergency_contact': forms.Textarea(attrs={'rows': 4}), 'experience': forms.Textarea(attrs={'rows': 5}), 'note': forms.Textarea(attrs={'rows': 5}), } + labels = { + 'apply_reduced_fee': _(u'Ich bin noch keine 25 Jahre alt oder besitze einen "Karlsruher Pass".'), + } + + def clean_year_of_birth(self): + now = datetime.datetime.now() + year_now = now.year + max_age = 100 + val = self.cleaned_data.get('year_of_birth') + if val > year_now: + raise forms.ValidationError( + ugettext(u'Dein Geburtsjahr liegt in der Zukunft?' + u' Das finden wir gut,' + u' aber bitte melde dich besser mal per E-Mail bei uns.'), + code='to_young', + ) + elif val < (year_now - max_age): + raise forms.ValidationError( + ugettext(u'Du bist schon über %(max_age)d Jahre alt?' + u' Das finden wir gut,' + u' aber bitte melde dich besser mal per E-Mail bei uns.'), + params={'max_age': max_age}, + code='to_old', + ) + return val def clean_experience(self): val = self.cleaned_data.get('experience') @@ -41,3 +78,13 @@ class RegistrationForm(forms.ModelForm): code='privacy_policy_not_accepted', ) return val + + def clean(self): + super(RegistrationForm, self).clean() + dav_member = self.cleaned_data.get('dav_member') + dav_number = self.cleaned_data.get('dav_number') + if dav_member and not dav_number: + error_msg = ugettext(u'Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.') + self.add_error('not_dav_member', error_msg) + raise forms.ValidationError(error_msg, code='dav_number_missing') + return self.cleaned_data diff --git a/dav_registration/migrations/0005_auto_20201015_1738.py b/dav_registration/migrations/0005_auto_20201015_1738.py new file mode 100644 index 0000000..3d69b2b --- /dev/null +++ b/dav_registration/migrations/0005_auto_20201015_1738.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-10-15 15:38 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_registration', '0004_auto_20190605_1400'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='dav_member', + field=models.BooleanField(default=True, verbose_name='DAV Mitglied'), + ), + migrations.AlterField( + model_name='registration', + name='dav_number', + field=models.CharField(blank=True, help_text='Deine Mitgliedsnummer findest du unter dem Strichcode auf deinem DAV Ausweis.
    Beispiel: 131/00/012345 (der Teil bis zum ersten * genügt)', max_length=62, validators=[django.core.validators.RegexValidator('^([0-9]{1,10}/[0-9]{2,10}/)?[0-9]{1,10}(\\*[0-9]{1,10})?(\\*[0-9]{4}\\*[0-9]{4})?([* ][0-9]{8})?$', 'Ungültiges Format.')], verbose_name='DAV Mitgliedsnummer'), + ), + ] diff --git a/dav_registration/migrations/0006_auto_20201203_1144.py b/dav_registration/migrations/0006_auto_20201203_1144.py new file mode 100644 index 0000000..a9f9bf4 --- /dev/null +++ b/dav_registration/migrations/0006_auto_20201203_1144.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-03 10:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_registration', '0005_auto_20201015_1738'), + ] + + operations = [ + migrations.CreateModel( + name='RegistrationStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('answered', models.BooleanField(default=False, verbose_name='Durch Tourleitung beantwortet')), + ('accepted', models.NullBooleanField(verbose_name='Zusage erteilt')), + ], + options={ + 'verbose_name': 'Anmeldungsstatus', + 'verbose_name_plural': 'Anmeldungsstati', + 'ordering': ['updated_at'], + }, + ), + migrations.AddField( + model_name='registrationstatus', + name='registration', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='status', to='dav_registration.Registration'), + ), + ] diff --git a/dav_registration/migrations/0007_auto_20201208_1853.py b/dav_registration/migrations/0007_auto_20201208_1853.py new file mode 100644 index 0000000..4485362 --- /dev/null +++ b/dav_registration/migrations/0007_auto_20201208_1853.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-08 17:53 +from __future__ import unicode_literals + +from django.db import migrations + + +def migrate_registration_status(apps, schema_editor): + Registration = apps.get_model('dav_registration', 'Registration') + RegistrationStatus = apps.get_model('dav_registration', 'RegistrationStatus') + db_alias = schema_editor.connection.alias + for r in Registration.objects.using(db_alias).all(): + s = RegistrationStatus(registration=r) + s.answered = r.answered + s.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_registration', '0006_auto_20201203_1144'), + ] + + operations = [ + migrations.RunPython(migrate_registration_status), + ] diff --git a/dav_registration/migrations/0008_auto_20201208_1906.py b/dav_registration/migrations/0008_auto_20201208_1906.py new file mode 100644 index 0000000..5495b04 --- /dev/null +++ b/dav_registration/migrations/0008_auto_20201208_1906.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-08 18:06 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_registration', '0007_auto_20201208_1853'), + ] + + operations = [ + migrations.RenameField( + model_name='registration', + old_name='answered', + new_name='answered_obsolete', + ), + ] diff --git a/dav_registration/migrations/0009_registration_year_of_birth.py b/dav_registration/migrations/0009_registration_year_of_birth.py new file mode 100644 index 0000000..455a614 --- /dev/null +++ b/dav_registration/migrations/0009_registration_year_of_birth.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-09 09:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_registration', '0008_auto_20201208_1906'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='year_of_birth', + field=models.IntegerField(default=1870, help_text='Manchmal müssen wir wissen, wie alt unsere Teilnehmer sind. Darum brauchen wir die vierstellige Jahreszahl, des Jahres in dem du geboren bist (zb. 1991).', verbose_name='Geburtsjahr'), + preserve_default=False, + ), + ] diff --git a/dav_registration/migrations/0010_registration_apply_reduced_fee.py b/dav_registration/migrations/0010_registration_apply_reduced_fee.py new file mode 100644 index 0000000..213f62f --- /dev/null +++ b/dav_registration/migrations/0010_registration_apply_reduced_fee.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-12-09 14:16 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dav_registration', '0009_registration_year_of_birth'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='apply_reduced_fee', + field=models.BooleanField(default=False, help_text='Für Jugendliche und Junioren (bis zum vollendeten 25. Lebensjahr), sowie Mitglieder mit geringen finanziellen Mitteln (Nachweis durch "Karlsruher Pass"), wird die Teilnahmegebühr auf 50% ermäßigt.', verbose_name='Antrag auf reduzierte Teilnahmegebühr'), + ), + ] diff --git a/dav_registration/models.py b/dav_registration/models.py index a1c7e64..d5cc281 100644 --- a/dav_registration/models.py +++ b/dav_registration/models.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import datetime import logging +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils import timezone @@ -39,8 +40,24 @@ class Registration(models.Model): phone_number = models.CharField(max_length=254, verbose_name=_('Telefonnummer'), help_text=_('Idealerweise eine Mobilfunk-Nummer')) + + year_of_birth = models.IntegerField(verbose_name=_('Geburtsjahr'), + help_text=_('Manchmal müssen wir wissen, wie alt unsere Teilnehmer sind.' + ' Darum brauchen wir die vierstellige Jahreszahl,' + ' des Jahres in dem du geboren bist (zb. 1991).')) + + apply_reduced_fee = models.BooleanField(default=False, + verbose_name=_('Antrag auf reduzierte Teilnahmegebühr'), + help_text=_('Für Jugendliche und Junioren' + ' (bis zum vollendeten 25. Lebensjahr),' + ' sowie Mitglieder mit geringen finanziellen Mitteln' + ' (Nachweis durch "Karlsruher Pass"),' + ' wird die Teilnahmegebühr auf 50% ermäßigt.')) + + dav_member = models.BooleanField(default=True, + verbose_name=_('DAV Mitglied')) dav_number = models.CharField(max_length=62, - validators=[DAVNumberValidator], + blank=True, validators=[DAVNumberValidator], verbose_name=_('DAV Mitgliedsnummer'), help_text='%s
    %s %s' % ( _('Deine Mitgliedsnummer findest du unter dem Strichcode' @@ -69,7 +86,12 @@ class Registration(models.Model): verbose_name=_('Einwilligung zur Datenspeicherung')) purge_at = models.DateTimeField(_('Zeitpunkt der Datenlöschung')) - answered = models.BooleanField(_('Durch Tourleitung beantwortet'), default=False) + answered_obsolete = models.BooleanField(default=False, verbose_name=_('Durch Tourleitung beantwortet')) + + def approx_age(self): + now = datetime.datetime.now() + year_now = now.year + return year_now - self.year_of_birth @staticmethod def pk2hexstr(pk): @@ -109,21 +131,50 @@ class Registration(models.Model): text = """{fullname} {address}, {postal_code} {city} +DAV Mitglied: {dav_info} +Jahrgang: {year_of_birth} (ungefähres Alter: {approx_age}) +Antrag auf reduzierte Teilnehmergebühr: {apply_reduced_fee_yesno} + Erfahrung: {experience} Anmerkung: {note} """ + if not self.dav_member: + dav_info = _('Nein') + else: + dav_info = self.dav_number + + if self.apply_reduced_fee: + apply_reduced_fee_yesno = _('Ja') + else: + apply_reduced_fee_yesno = _('Nein') + return text.format( fullname=self.get_full_name(), address=self.address, postal_code=self.postal_code, city=self.city, + dav_info=dav_info, + year_of_birth=self.year_of_birth, + approx_age=self.approx_age(), + apply_reduced_fee_yesno=apply_reduced_fee_yesno, experience=self.experience, note=self.note, ) + def get_data_dict(self): + data = {} + for field in self._meta.fields: + if not field.primary_key: + data[field.name] = getattr(self, field.name) + return data + + def clean(self): + if self.dav_member and not self.dav_number: + raise ValidationError({'dav_number': _('Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.')}) + def save(self, **kwargs): creating = False if not self.id: @@ -132,9 +183,12 @@ Anmerkung: if not self.purge_at and self.event: self.purge_at = self.__class__.calc_purge_at(self.event) + self.full_clean() super(Registration, self).save(**kwargs) if creating: + status = RegistrationStatus(registration=self) + status.save() logger.info('Registration stored: %s', self) signals.registration_created.send(sender=self.__class__, registration=self) @@ -163,3 +217,42 @@ Anmerkung: purge_date = july_nextyear return timezone.make_aware(datetime.datetime.combine(purge_date, midnight)) + + +@python_2_unicode_compatible +class RegistrationStatus(models.Model): + registration = models.OneToOneField(Registration, on_delete=models.CASCADE, related_name='status') + updated_at = models.DateTimeField(auto_now=True) + answered = models.BooleanField(_('Durch Tourleitung beantwortet'), default=False) + accepted = models.NullBooleanField(_('Zusage erteilt')) + + class Meta: + verbose_name = _('Anmeldungsstatus') + verbose_name_plural = _('Anmeldungsstati') + ordering = ['updated_at'] + + def __str__(self): + return '{} (Updated: {})'.format(self.registration, self.updated_at.strftime('%d.%m.%Y %H:%M:%S')) + + def clean(self): + if self.accepted is not None and self.answered is not True: + raise ValidationError({'answered': 'if accepted is not None, answered must be True'}) + + def save(self, **kwargs): + self.full_clean() + super(RegistrationStatus, self).save(**kwargs) + + def set_accepted(self): + self.accepted = True + self.answered = True + self.save() + + def set_rejected(self): + self.accepted = False + self.answered = True + self.save() + + def reset(self): + self.accepted = None + self.answered = False + self.save() diff --git a/dav_registration/templates/dav_registration/emails/inform_self.txt b/dav_registration/templates/dav_registration/emails/inform_self.txt index 5db5623..bd719a2 100644 --- a/dav_registration/templates/dav_registration/emails/inform_self.txt +++ b/dav_registration/templates/dav_registration/emails/inform_self.txt @@ -22,7 +22,9 @@ Personendaten {{ registration.postal_code }} {{ registration.city }} Telefon: {{ registration.phone_number }} E-Mail: {{ registration.email_address }} -DAV Mitgliedsnummer: {{ registration.dav_number }} +Jahrgang: {{ registration.year_of_birth }} +Antrag auf reduzierte Teilnahmegebühr: {% if registration.apply_reduced_fee %}Ja{% else %}Nein{% endif %} +{% if registration.dav_member %}DAV Mitgliedsnummer: {{ registration.dav_number }}{% else %}DAV Mitglied: Nein{% endif %} Notfall-Kontakt --------------- diff --git a/dav_registration/templates/dav_registration/emails/inform_trainer.txt b/dav_registration/templates/dav_registration/emails/inform_trainer.txt index d3e00ba..ddd8969 100644 --- a/dav_registration/templates/dav_registration/emails/inform_trainer.txt +++ b/dav_registration/templates/dav_registration/emails/inform_trainer.txt @@ -11,7 +11,8 @@ Teilnehmer*in: {{ registration.address }}, {{ registration.postal_code }} {{ registration.city }} {{ registration.phone_number }} {{ registration.email_address }} -{{ registration.dav_number }} +{% if registration.dav_member %}{{ registration.dav_number }}{% else %}Nicht DAV Mitglied{% endif %} +Antrag auf reduzierte Teilnahmegebühr: {% if registration.apply_reduced_fee %}Ja{% else %}Nein{% endif %} Notfall-Kontakt: {% if registration.emergency_contact %}{{ registration.emergency_contact }}{% else %}-{% endif %} @@ -19,6 +20,8 @@ Notfall-Kontakt: Erfahrung: {% if registration.experience %}{{ registration.experience }}{% else %}-{% endif %} +Jahrgang: {{ registration.year_of_birth }} (ungefähres Alter: {{ registration.approx_age }}) + Anmerkung: {% if registration.note %}{{ registration.note }}{% else %}-{% endif %} diff --git a/dav_registration/templates/dav_registration/event_list.html b/dav_registration/templates/dav_registration/event_list.html index 83c7f1c..9636609 100644 --- a/dav_registration/templates/dav_registration/event_list.html +++ b/dav_registration/templates/dav_registration/event_list.html @@ -17,7 +17,7 @@
    +

    {{ event.get_formated_date }} {% if event.get_alt_formated_date %} @@ -81,12 +85,12 @@

    +{% endblock head-additional %} + {% block page-container-fluid %}

    {% trans 'Anmeldung' %}

    @@ -40,10 +85,10 @@
    -
    +
    {% bootstrap_field form.postal_code %}
    -
    +
    {% bootstrap_field form.city %}
    @@ -56,9 +101,22 @@
    -
    +
    + {% bootstrap_field form.year_of_birth %} +
    +
    + Antrag auf reduzierte Teilnahmegebühr + {% bootstrap_field form.apply_reduced_fee %} +
    +
    +
    +
    {% bootstrap_field form.dav_number %}
    +
    + Nichtmitglieder + {% bootstrap_field form.not_dav_member %} +
    {% bootstrap_field form.emergency_contact %}
    diff --git a/dav_registration/tests/generic.py b/dav_registration/tests/generic.py index 30ebf6f..499023b 100644 --- a/dav_registration/tests/generic.py +++ b/dav_registration/tests/generic.py @@ -1,5 +1,8 @@ +from django.utils import timezone from ..models import Registration +THIS_YEAR = timezone.now().year + class RegistrationMixin(object): def create_registration(self, data): diff --git a/dav_registration/tests/test_emails.py b/dav_registration/tests/test_emails.py index 75012f3..1c6547b 100644 --- a/dav_registration/tests/test_emails.py +++ b/dav_registration/tests/test_emails.py @@ -10,7 +10,7 @@ from django.utils.translation import get_language from dav_base.tests.generic import EmailTestMixin from dav_events.tests.generic import EventMixin -from .generic import RegistrationMixin +from .generic import THIS_YEAR, RegistrationMixin MAIL_SELF_TEMPLATE = """Hallo {participant_full_name}, @@ -32,11 +32,13 @@ Vorgang: {registration_hexstr} (wird nur gebraucht, wenn irgendwas schief geht) Personendaten ------------- {participant_full_name} - - -Telefon: +Here +1 Karlsruhe +Telefon: 12 E-Mail: {participant_email} -DAV Mitgliedsnummer: +Jahrgang: {year_of_birth} +Antrag auf reduzierte Teilnahmegebühr: {apply_reduced_fee_yesno} +DAV Mitgliedsnummer: 0 Notfall-Kontakt --------------- @@ -66,10 +68,11 @@ Vorgang: {registration_hexstr} Teilnehmer*in: {participant_full_name} -, - +Here, 1 Karlsruhe +12 {participant_email} - +0 +Antrag auf reduzierte Teilnahmegebühr: {apply_reduced_fee_yesno} Notfall-Kontakt: - @@ -77,6 +80,8 @@ Notfall-Kontakt: Erfahrung: - +Jahrgang: {year_of_birth} (ungefähres Alter: {approx_age}) + Anmerkung: - @@ -107,7 +112,13 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): 'event': event, 'personal_names': 'Participant', 'family_names': 'One', + 'address': 'Here', + 'postal_code': '1', + 'city': 'Karlsruhe', + 'phone_number': '12', 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR - 10, + 'dav_number': '0', } registration = self.create_registration(registration_data) @@ -126,6 +137,8 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): body = MAIL_SELF_TEMPLATE.format( participant_full_name=registration.get_full_name(), participant_email=registration.email_address, + year_of_birth=registration.year_of_birth, + apply_reduced_fee_yesno='Nein', event_number=event.get_number(), event_title=event.title, event_formated_date=event.get_formated_date(), @@ -150,6 +163,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): 'postal_code': '76131', 'city': 'Karlsruhe', 'phone_number': '+49 721 1234567890 AB (Büro)', + 'year_of_birth': 1976, + 'apply_reduced_fee': True, + 'dav_member': False, 'dav_number': '131/00/007*12345', 'emergency_contact': 'Call 911!', 'experience': 'Yes, we can!', @@ -167,7 +183,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): search += '{} {}\n'.format(registration_data['postal_code'], registration_data['city']) search += 'Telefon: {}\n'.format(registration_data['phone_number']) search += 'E-Mail: {}\n'.format(registration_data['email_address']) - search += 'DAV Mitgliedsnummer: {}\n'.format(registration_data['dav_number']) + search += 'Jahrgang: {}\n'.format(registration_data['year_of_birth']) + search += 'Antrag auf reduzierte Teilnahmegebühr: Ja\n' + search += 'DAV Mitglied: Nein\n' self.assertIn(search, mail.body) search = '\n' @@ -204,7 +222,13 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): 'event': event, 'personal_names': 'Participant', 'family_names': 'One', + 'address': 'Here', + 'postal_code': '1', + 'city': 'Karlsruhe', + 'phone_number': '12', 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR - 86, + 'dav_number': '0', } registration = self.create_registration(registration_data) @@ -224,6 +248,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): body = MAIL_TRAINER_TEMPLATE.format( participant_full_name=registration.get_full_name(), participant_email=registration.email_address, + year_of_birth=registration.year_of_birth, + approx_age=registration.approx_age(), + apply_reduced_fee_yesno='Nein', event_number=event.get_number(), event_title=event.title, event_formated_date=event.get_formated_date(), @@ -247,6 +274,9 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): 'postal_code': '76131', 'city': 'Karlsruhe', 'phone_number': '+49 721 1234567890 AB (Büro)', + 'year_of_birth': THIS_YEAR, + 'apply_reduced_fee': True, + 'dav_member': False, 'dav_number': '131/00/007*12345', 'emergency_contact': 'Call 911!', 'experience': 'Yes, we can!', @@ -265,8 +295,8 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): search += '\n' search += registration_data['email_address'] search += '\n' - search += registration_data['dav_number'] - search += '\n' + search += 'Nicht DAV Mitglied\n' + search += 'Antrag auf reduzierte Teilnahmegebühr: Ja\n' self.assertIn(search, mail.body) search = '\n' @@ -281,6 +311,12 @@ class EmailsTestCase(EmailTestMixin, EventMixin, RegistrationMixin, TestCase): search += '\n' self.assertIn(search, mail.body) + search = '\n' + search += 'Jahrgang: ' + search += str(registration_data['year_of_birth']) + search += ' (ungefähres Alter: 0)\n' + self.assertIn(search, mail.body) + search = '\n' search += 'Anmerkung:\n' search += registration_data['note'] diff --git a/dav_registration/tests/test_models.py b/dav_registration/tests/test_models.py new file mode 100644 index 0000000..0644611 --- /dev/null +++ b/dav_registration/tests/test_models.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.apps import apps +from django.core.exceptions import ValidationError +from django.test import TestCase + +from dav_events.tests.generic import EventMixin + +from .generic import THIS_YEAR, RegistrationMixin + + +class RegistrationTestCase(EventMixin, RegistrationMixin, TestCase): + def setUp(self): + super(RegistrationTestCase, self).setUp() + + app_config = apps.get_app_config('dav_events') + app_config.settings.enable_email_on_status_update = False + + self.event = self.create_event_by_model() + self.submit_event(self.event) + self.accept_event(self.event) + self.confirm_publication_event(self.event) + + def test_create_members(self): + event = self.event + registration_data = { + 'event': event, + 'personal_names': 'Participant', + 'family_names': 'One', + 'address': 'Here', + 'postal_code': '1', + 'city': 'Karlsruhe', + 'phone_number': '12', + 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR, + } + dav_numbers = ['0', '12345', '131/00/12345'] + for n in dav_numbers: + d = registration_data + d['dav_number'] = n + self.create_registration(d) + + def test_create_members_without_number(self): + event = self.event + registration_data = { + 'event': event, + 'personal_names': 'Participant', + 'family_names': 'One', + 'address': 'Here', + 'postal_code': '1', + 'city': 'Karlsruhe', + 'phone_number': '12', + 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR, + } + with self.assertRaisesMessage(ValidationError, + 'Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.'): + self.create_registration(registration_data) + + registration_data['dav_number'] = '' + with self.assertRaisesMessage(ValidationError, + 'Wenn du DAV Mitglied bist, brauchen wir deine Mitgliedsnummer.'): + self.create_registration(registration_data) + + def test_create_members_with_invalid_numbers(self): + event = self.event + registration_data = { + 'event': event, + 'personal_names': 'Participant', + 'family_names': 'One', + 'address': 'Here', + 'postal_code': '1', + 'city': 'Karlsruhe', + 'phone_number': '12', + 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR, + } + dav_numbers = ['Nein', '-', '13100123456789'] + for n in dav_numbers: + d = registration_data + d['dav_number'] = n + + with self.assertRaises(ValidationError) as context: + self.create_registration(d) + + self.assertEqual(context.exception.messages, ['Ungültiges Format.']) + + def test_create_non_member(self): + event = self.event + registration_data = { + 'event': event, + 'personal_names': 'Participant', + 'family_names': 'One', + 'address': 'Here', + 'postal_code': '1', + 'city': 'Karlsruhe', + 'phone_number': '12', + 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR, + 'dav_member': False, + } + self.create_registration(registration_data) + diff --git a/dav_registration/tests/test_utils.py b/dav_registration/tests/test_utils.py index 81ff5d4..0dd3994 100644 --- a/dav_registration/tests/test_utils.py +++ b/dav_registration/tests/test_utils.py @@ -9,7 +9,7 @@ from dav_events.tests.generic import EventMixin from ..models import Registration from ..utils import purge_registrations -from .generic import RegistrationMixin +from .generic import THIS_YEAR, RegistrationMixin class UtilsTestCase(RegistrationMixin, EventMixin, TestCase): @@ -29,6 +29,17 @@ class UtilsTestCase(RegistrationMixin, EventMixin, TestCase): 'trainer_familyname': 'One', 'trainer_email': 'trainer@localhost', } + registration_data = { + 'personal_names': 'Participant', + 'family_names': 'P.', + 'address': 'Am Fächerbad 2', + 'postal_code': '76131', + 'city': 'Karlsruhe', + 'phone_number': '555 5555', + 'email_address': 'participant@localhost', + 'year_of_birth': THIS_YEAR - 44, + 'dav_number': '1', + } first_day = today - (one_day * 367) while first_day < today: @@ -41,7 +52,9 @@ class UtilsTestCase(RegistrationMixin, EventMixin, TestCase): self.accept_event(event) for i in range(0, registrations_per_event): - self.create_registration({'event': event}) + d = registration_data + d['event'] = event + self.create_registration(d) purge_registrations()