From 63822bc45f644235a0c687e1906e588122f85d5b Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Wed, 24 May 2017 17:52:02 +0300 Subject: [PATCH 01/87] Database model changes & migrations while preparing for member registry --- serviceform/serviceform/admin.py | 21 ++-- serviceform/serviceform/forms.py | 32 +++--- .../locale/en/LC_MESSAGES/django.po | 4 +- .../locale/fi/LC_MESSAGES/django.po | 4 +- ...2_rename_responsibilityperson_to_member.py | 16 +++ .../migrations/0003_auto_20170524_1501.py | 45 ++++++++ ...004_rename_participant_to_participation.py | 20 ++++ .../migrations/0005_auto_20170524_1517.py | 41 +++++++ .../migrations/0006_organization.py | 34 ++++++ .../migrations/0007_member_organization.py | 46 ++++++++ ...0008_add_member_and_organization_fields.py | 98 +++++++++++++++++ ...ove_participation_contact_detail_fields.py | 51 +++++++++ serviceform/serviceform/models/__init__.py | 2 +- serviceform/serviceform/models/mixins.py | 4 +- .../serviceform/models/participation.py | 14 +-- serviceform/serviceform/models/people.py | 102 +++++++++++++++--- serviceform/serviceform/models/serviceform.py | 45 ++++---- serviceform/serviceform/tasks.py | 8 +- .../templatetags/serviceform_tags.py | 12 +-- serviceform/serviceform/utils.py | 28 ++--- serviceform/serviceform/views/decorators.py | 6 +- serviceform/serviceform/views/login_views.py | 2 +- .../serviceform/views/participation_views.py | 42 ++++---- .../serviceform/views/reports_views.py | 28 ++--- tests/test_views.py | 28 ++--- 25 files changed, 577 insertions(+), 156 deletions(-) create mode 100644 serviceform/serviceform/migrations/0002_rename_responsibilityperson_to_member.py create mode 100644 serviceform/serviceform/migrations/0003_auto_20170524_1501.py create mode 100644 serviceform/serviceform/migrations/0004_rename_participant_to_participation.py create mode 100644 serviceform/serviceform/migrations/0005_auto_20170524_1517.py create mode 100644 serviceform/serviceform/migrations/0006_organization.py create mode 100644 serviceform/serviceform/migrations/0007_member_organization.py create mode 100644 serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py create mode 100644 serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py diff --git a/serviceform/serviceform/admin.py b/serviceform/serviceform/admin.py index 03523e6..e47712c 100644 --- a/serviceform/serviceform/admin.py +++ b/serviceform/serviceform/admin.py @@ -190,13 +190,13 @@ class RevisionInline(NestedStackedInline): extra = 0 -class ResponsibilityPersonInline(NestedStackedInline): - model = models.ResponsibilityPerson - extra = 0 - fields = (('forenames', 'surname'), ('email', 'phone_number'), 'street_address', - ('postal_code', 'city'), 'send_email_notifications', 'hide_contact_details', - 'show_full_report', 'personal_link') - readonly_fields = ('personal_link',) +#class ResponsibilityPersonInline(NestedStackedInline): +# model = models.Member +# extra = 0 +# fields = (('forenames', 'surname'), ('email', 'phone_number'), 'street_address', +# ('postal_code', 'city'), 'send_email_notifications', 'hide_contact_details', +# 'show_full_report', 'personal_link') +# readonly_fields = ('personal_link',) @admin.register(models.ServiceForm) @@ -205,8 +205,7 @@ class ServiceFormAdmin(OwnerSaveMixin, ExtendedLogMixin, NestedModelAdminMixin, class Media: css = {'all': ('serviceform/serviceform_admin.css',)} - inlines = [RevisionInline, EmailTemplateInline, ResponsibilityPersonInline, - Level1CategoryInline, QuestionInline] + inlines = [RevisionInline, EmailTemplateInline, Level1CategoryInline, QuestionInline] superuser_actions = ['bulk_email_former_participants', 'bulk_email_responsibles'] if settings.DEBUG: @@ -311,7 +310,7 @@ def get_queryset(self, request: HttpRequest): def get_form(self, request: HttpRequest, obj: models.ServiceForm=None, **kwargs): form = super().get_form(request, obj, **kwargs) if obj: - request._responsibles = responsibles = models.ResponsibilityPerson.objects.filter( + request._responsibles = responsibles = models.Member.objects.filter( form=obj) form.base_fields['responsible'].queryset = responsibles form.base_fields['current_revision'].queryset = models.FormRevision.objects.filter( @@ -353,7 +352,7 @@ class EmailMessageAdmin(ExtendedLogMixin, admin.ModelAdmin): 'content_display',) -@admin.register(models.Participant) +@admin.register(models.Participation) class ParticipantAdmin(ExtendedLogMixin, admin.ModelAdmin): list_display = ( 'id', '__str__', 'form_display', 'form_revision', 'status', 'activities_display', diff --git a/serviceform/serviceform/forms.py b/serviceform/serviceform/forms.py index 1e3207f..441eaa3 100644 --- a/serviceform/serviceform/forms.py +++ b/serviceform/serviceform/forms.py @@ -122,7 +122,7 @@ def __init__(self, service_form, request, *args, **kwargs): def clean_email(self): email = self.cleaned_data['email'] if email and 'email' in self.changed_data: - participant = models.Participant.objects.filter( + participant = models.Participation.objects.filter( email=email, form_revision__form=self.instance).first() if not participant: @@ -131,9 +131,9 @@ def clean_email(self): return email def save(self): - participant = models.Participant.objects.filter(email=self.cleaned_data['email'], - form_revision__form=self.instance).first() - success = participant.send_participant_email(models.Participant.EmailIds.RESEND) + participant = models.Participation.objects.filter(email=self.cleaned_data['email'], + form_revision__form=self.instance).first() + success = participant.send_participant_email(models.Participation.EmailIds.RESEND) if success: messages.info(self.request, _('Access link sent to email address {}').format(participant.email)) @@ -158,16 +158,16 @@ def __init__(self, service_form: models.ServiceForm, request: HttpRequest, def clean_email(self) -> str: email = self.cleaned_data['email'] if email and 'email' in self.changed_data: - responsible = models.ResponsibilityPerson.objects.filter(email=email, - form=self.instance).first() + responsible = models.Member.objects.filter(email=email, + form=self.instance).first() if not responsible: raise ValidationError( _('There were no responsible with email address {}').format(email)) return email def save(self) -> Optional[models.EmailMessage]: - responsible = models.ResponsibilityPerson.objects.filter(email=self.cleaned_data['email'], - form=self.instance).first() + responsible = models.Member.objects.filter(email=self.cleaned_data['email'], + form=self.instance).first() success = responsible.resend_auth_link() if success: messages.info(self.request, @@ -180,9 +180,9 @@ def save(self) -> Optional[models.EmailMessage]: class ContactForm(ModelForm): class Meta: - model = models.Participant + model = models.Member fields = ('forenames', 'surname', 'year_of_birth', 'street_address', - 'postal_code', 'city', 'email', 'phone_number', 'send_email_allowed') + 'postal_code', 'city', 'email', 'phone_number', 'send_email_notifications') def __init__(self, *args, user: 'AbstractUser'=None, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -249,8 +249,8 @@ def clean(self): def clean_email(self): email = self.cleaned_data['email'] if email and 'email' in self.changed_data and \ - models.Participant.objects.filter(email=email, - form_revision__form=self.service_form) \ + models.Participation.objects.filter(email=email, + form_revision__form=self.service_form) \ .exclude(pk=self.participant.pk): logger.info('User tried to enter same email address %s again.', email) email_link = '{}'.format(reverse('send_auth_link', args=(email,)), @@ -268,7 +268,7 @@ def save(self, commit: bool=True): class ResponsibleForm(ModelForm): class Meta: - model = models.ResponsibilityPerson + model = models.Member fields = ('forenames', 'surname', 'street_address', 'postal_code', 'city', 'email', 'phone_number', 'send_email_notifications') @@ -285,7 +285,7 @@ class Meta: model = models.ParticipantLog fields = ('message',) - def __init__(self, participant: models.Participant, user: 'AbstractUser', + def __init__(self, participant: models.Participation, user: 'AbstractUser', *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.instance.participant = participant @@ -308,7 +308,7 @@ class ParticipationForm: Not any standard Django form. """ - def __init__(self, request: HttpRequest, participant: models.Participant, + def __init__(self, request: HttpRequest, participant: models.Participation, category: models.Level1Category=None, post_data: 'QueryDict'=None, service_form: models.ServiceForm=None) -> None: self.instance = participant @@ -454,7 +454,7 @@ class QuestionForm: Not any standard Django form. """ - def __init__(self, request: HttpRequest, participant: models.Participant, + def __init__(self, request: HttpRequest, participant: models.Participation, post_data: 'QueryDict'=None) -> None: self.instance = participant self.request = request diff --git a/serviceform/serviceform/locale/en/LC_MESSAGES/django.po b/serviceform/serviceform/locale/en/LC_MESSAGES/django.po index ba480b1..85c8e01 100644 --- a/serviceform/serviceform/locale/en/LC_MESSAGES/django.po +++ b/serviceform/serviceform/locale/en/LC_MESSAGES/django.po @@ -975,7 +975,7 @@ msgid "Answer required?" msgstr "" #: models.py:973 -msgid "Participant" +msgid "Participation" msgstr "" #: models.py:974 urls.py:90 @@ -1024,7 +1024,7 @@ msgid "" msgstr "" #: models.py:1041 -msgid "Participant created in system" +msgid "Participation created in system" msgstr "" #: models.py:1044 diff --git a/serviceform/serviceform/locale/fi/LC_MESSAGES/django.po b/serviceform/serviceform/locale/fi/LC_MESSAGES/django.po index b29ba30..18fd0a6 100644 --- a/serviceform/serviceform/locale/fi/LC_MESSAGES/django.po +++ b/serviceform/serviceform/locale/fi/LC_MESSAGES/django.po @@ -1135,7 +1135,7 @@ msgid "Answer required?" msgstr "Vaaditaanko vastaus?" #: models.py:973 -msgid "Participant" +msgid "Participation" msgstr "Osallistuja" #: models.py:974 urls.py:90 @@ -1189,7 +1189,7 @@ msgstr "" "lainkaan. Voit myös myöhemmin muuttaa tätä asetusta." #: models.py:1041 -msgid "Participant created in system" +msgid "Participation created in system" msgstr "Osallistuja talletettu ensimmäisen kerran järjestelmään" #: models.py:1044 diff --git a/serviceform/serviceform/migrations/0002_rename_responsibilityperson_to_member.py b/serviceform/serviceform/migrations/0002_rename_responsibilityperson_to_member.py new file mode 100644 index 0000000..cceb172 --- /dev/null +++ b/serviceform/serviceform/migrations/0002_rename_responsibilityperson_to_member.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 11:51 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0001_initial'), + ] + + operations = [ + migrations.RenameModel('ResponsibilityPerson', 'Member') + ] diff --git a/serviceform/serviceform/migrations/0003_auto_20170524_1501.py b/serviceform/serviceform/migrations/0003_auto_20170524_1501.py new file mode 100644 index 0000000..2ff2e76 --- /dev/null +++ b/serviceform/serviceform/migrations/0003_auto_20170524_1501.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 12:01 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0002_rename_responsibilityperson_to_member'), + ] + + operations = [ + migrations.AlterModelOptions( + name='member', + options={}, + ), + migrations.AddField( + model_name='member', + name='membership_type', + field=models.CharField(choices=[('external', 'external'), ('normal', 'normal'), ('staff', 'staff')], default='external', max_length=8, verbose_name='Is this person a member of this organization?'), + ), + migrations.AlterField( + model_name='member', + name='city', + field=models.CharField(max_length=32, verbose_name='City'), + ), + migrations.AlterField( + model_name='member', + name='phone_number', + field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator(message="Phone number must be entered in the format: '050123123' or '+35850123123'. Up to 15 digits allowed.", regex='^\\+?1?\\d{9,15}$')], verbose_name='Phone number'), + ), + migrations.AlterField( + model_name='member', + name='postal_code', + field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator(code='invalid', message='Enter a valid postal code.', regex='^\\d{5}$')], verbose_name='Zip/Postal code'), + ), + migrations.AlterField( + model_name='member', + name='street_address', + field=models.CharField(max_length=128, verbose_name='Street address'), + ), + ] diff --git a/serviceform/serviceform/migrations/0004_rename_participant_to_participation.py b/serviceform/serviceform/migrations/0004_rename_participant_to_participation.py new file mode 100644 index 0000000..63915d7 --- /dev/null +++ b/serviceform/serviceform/migrations/0004_rename_participant_to_participation.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 12:02 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0003_auto_20170524_1501'), + ] + + operations = [ + migrations.RenameModel('Participant', 'Participation'), + migrations.AlterModelOptions( + name='participation', + options={'verbose_name': 'Participation', 'verbose_name_plural': 'Participants'}, + ), + ] diff --git a/serviceform/serviceform/migrations/0005_auto_20170524_1517.py b/serviceform/serviceform/migrations/0005_auto_20170524_1517.py new file mode 100644 index 0000000..f7859ba --- /dev/null +++ b/serviceform/serviceform/migrations/0005_auto_20170524_1517.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 12:17 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0004_rename_participant_to_participation'), + ] + + operations = [ + migrations.AlterField( + model_name='member', + name='city', + field=models.CharField(blank=True, max_length=32, verbose_name='City'), + ), + migrations.AlterField( + model_name='member', + name='email', + field=models.EmailField(blank=True, db_index=True, max_length=254, verbose_name='Email'), + ), + migrations.AlterField( + model_name='member', + name='phone_number', + field=models.CharField(blank=True, max_length=32, validators=[django.core.validators.RegexValidator(message="Phone number must be entered in the format: '050123123' or '+35850123123'. Up to 15 digits allowed.", regex='^\\+?1?\\d{9,15}$')], verbose_name='Phone number'), + ), + migrations.AlterField( + model_name='member', + name='postal_code', + field=models.CharField(blank=True, max_length=32, validators=[django.core.validators.RegexValidator(code='invalid', message='Enter a valid postal code.', regex='^\\d{5}$')], verbose_name='Zip/Postal code'), + ), + migrations.AlterField( + model_name='member', + name='street_address', + field=models.CharField(blank=True, max_length=128, verbose_name='Street address'), + ), + ] diff --git a/serviceform/serviceform/migrations/0006_organization.py b/serviceform/serviceform/migrations/0006_organization.py new file mode 100644 index 0000000..10a845f --- /dev/null +++ b/serviceform/serviceform/migrations/0006_organization.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 12:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def create_organizations(apps, schema_editor): + ServiceForm = apps.get_model('serviceform', 'ServiceForm') + Organization = apps.get_model('serviceform', 'Organization') + + for s in ServiceForm.objects.all(): + Organization.objects.create(name=s.slug) + + +def null(apps, schema_editor): + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0005_auto_20170524_1517'), + ] + + operations = [ + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=64, verbose_name='Organization name')), + ], + ), + migrations.RunPython(create_organizations, null), + ] diff --git a/serviceform/serviceform/migrations/0007_member_organization.py b/serviceform/serviceform/migrations/0007_member_organization.py new file mode 100644 index 0000000..b98ba70 --- /dev/null +++ b/serviceform/serviceform/migrations/0007_member_organization.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 12:28 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +def assign_organizations_to_members(apps, schema_editor): + Organization = apps.get_model('serviceform', 'Organization') + Member = apps.get_model('serviceform', 'Member') + + for m in Member.objects.all(): + m.organization = Organization.objects.get(name=m.form.slug) + m.save() + + +def null(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0006_organization'), + ] + + operations = [ + migrations.AddField( + model_name='member', + name='organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='serviceform.Organization'), + ), + migrations.RunPython(assign_organizations_to_members, null), + + migrations.AlterField( + model_name='member', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + to='serviceform.Organization'), + ), + migrations.RemoveField( + model_name='member', + name='form', + ), + ] diff --git a/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py b/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py new file mode 100644 index 0000000..6b07a3d --- /dev/null +++ b/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 12:33 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +def null(*args, **kwargs): + pass + + +def fill_serviceform_organization(apps, schema_editor): + ServiceForm = apps.get_model('serviceform', 'ServiceForm') + Organization = apps.get_model('serviceform', 'Organization') + + for s in ServiceForm.objects.all(): + s.organization = Organization.objects.get(name=s.slug) + s.save() + + +def fill_participation_member(apps, schema_editor): + Participation = apps.get_model('serviceform', 'Participation') + Member = apps.get_model('serviceform', 'Member') + + for p in Participation.objects.all(): + m = Member.objects.filter(email=p.email).first() + if not m: + m = Member() + m.forenames = p.forenames + m.surname = p.surname + m.street_address = p.street_address + m.postal_code = p.postal_code + m.city = p.city + m.email = p.email + m.phone_number = p.phone_number + + m.membership_type = 'external' + m.organization = p.form_revision.form.organization + m.send_email_notifications = p.send_email_allowed + + m.email_verified = p.email_verified + if p.year_of_birth: + m.year_of_birth = p.year_of_birth + + m.save() + + p.member = m + p.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0007_member_organization'), + ] + + operations = [ + + migrations.AddField( + model_name='participation', + name='member', + field=models.ForeignKey(blank=True, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='serviceform.Member'), + ), + migrations.AddField( + model_name='serviceform', + name='organization', + field=models.ForeignKey(default=None, null=True, + on_delete=django.db.models.deletion.CASCADE, + to='serviceform.Organization'), + ), + migrations.AddField( + model_name='member', + name='email_verified', + field=models.BooleanField(default=False, verbose_name='Email verified'), + ), + migrations.AddField( + model_name='member', + name='year_of_birth', + field=models.SmallIntegerField(blank=True, null=True, verbose_name='Year of birth'), + ), + migrations.RunPython(fill_serviceform_organization, null), + migrations.RunPython(fill_participation_member, null), + migrations.AlterField( + model_name='participation', + name='member', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + to='serviceform.Member'), + ), + migrations.AlterField( + model_name='serviceform', + name='organization', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + to='serviceform.Organization'), + ), + ] diff --git a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py new file mode 100644 index 0000000..36d18dc --- /dev/null +++ b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-24 14:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0008_add_member_and_organization_fields'), + ] + + operations = [ + migrations.RemoveField( + model_name='participation', + name='city', + ), + migrations.RemoveField( + model_name='participation', + name='email', + ), + migrations.RemoveField( + model_name='participation', + name='email_verified', + ), + migrations.RemoveField( + model_name='participation', + name='forenames', + ), + migrations.RemoveField( + model_name='participation', + name='phone_number', + ), + migrations.RemoveField( + model_name='participation', + name='postal_code', + ), + migrations.RemoveField( + model_name='participation', + name='street_address', + ), + migrations.RemoveField( + model_name='participation', + name='surname', + ), + migrations.RemoveField( + model_name='participation', + name='year_of_birth', + ), + ] diff --git a/serviceform/serviceform/models/__init__.py b/serviceform/serviceform/models/__init__.py index af1d350..b0a9db9 100644 --- a/serviceform/serviceform/models/__init__.py +++ b/serviceform/serviceform/models/__init__.py @@ -19,7 +19,7 @@ from .email import EmailMessage, EmailTemplate from .participation import (ParticipationActivity, ParticipationActivityChoice, ParticipantLog, QuestionAnswer) -from .people import Participant, ResponsibilityPerson +from .people import Participation, Organization, Member from .serviceform import (ServiceForm, FormRevision, Activity, ActivityChoice, Level1Category, Level2Category, Question, ColorField) diff --git a/serviceform/serviceform/models/mixins.py b/serviceform/serviceform/models/mixins.py index c19112a..8ae8a61 100644 --- a/serviceform/serviceform/models/mixins.py +++ b/serviceform/serviceform/models/mixins.py @@ -32,7 +32,7 @@ from django.utils.translation import ugettext_lazy as _ if TYPE_CHECKING: - from .people import ResponsibilityPerson + from .people import Member from .. import utils @@ -131,7 +131,7 @@ def __init__(self, *args, **kwargs): def sub_items(self): return getattr(self, self.subitem_name + '_set').all() - def has_responsible(self, r: 'ResponsibilityPerson') -> bool: + def has_responsible(self, r: 'Member') -> bool: return r in self._responsibles diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index fad8acb..8ab2bbf 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -25,11 +25,11 @@ from .. import utils if TYPE_CHECKING: - from .people import Participant + from .people import Participation class ParticipantLog(models.Model): created_at = models.DateTimeField(auto_now_add=True) - participant = models.ForeignKey('serviceform.Participant', on_delete=models.CASCADE) + participant = models.ForeignKey('serviceform.Participation', on_delete=models.CASCADE) writer_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) writer_id = models.PositiveIntegerField() # Can be either responsible or django user @@ -43,13 +43,13 @@ class Meta: ordering = ( 'activity__category__category__order', 'activity__category__order', 'activity__order',) - participant = models.ForeignKey('serviceform.Participant', on_delete=models.CASCADE) + participant = models.ForeignKey('serviceform.Participation', on_delete=models.CASCADE) activity = models.ForeignKey('serviceform.Activity', on_delete=models.CASCADE) additional_info = models.CharField(max_length=1024, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True, null=True) @cached_property - def cached_participant(self) -> 'Participant': + def cached_participant(self) -> 'Participation': return utils.get_participant(self.participant_id) def __str__(self): @@ -76,7 +76,7 @@ class Meta: created_at = models.DateTimeField(auto_now_add=True, null=True) @cached_property - def cached_participant(self) -> 'Participant': + def cached_participant(self) -> 'Participation': return utils.get_participant(self.activity.participant_id) def __str__(self): @@ -88,7 +88,7 @@ def additional_info_display(self) -> str: class QuestionAnswer(models.Model): - participant = models.ForeignKey('serviceform.Participant', on_delete=models.CASCADE) + participant = models.ForeignKey('serviceform.Participation', on_delete=models.CASCADE) question = models.ForeignKey('serviceform.Question', on_delete=models.CASCADE) answer = models.TextField() created_at = models.DateTimeField(auto_now_add=True, null=True) @@ -97,7 +97,7 @@ class Meta: ordering = ('question__order',) @cached_property - def cached_participant(self) -> 'Participant': + def cached_participant(self) -> 'Participation': return utils.get_participant(self.participant_id) def __str__(self): diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index ba14f2b..9be426c 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -30,7 +30,7 @@ from django.utils.translation import ugettext_lazy as _ from .. import utils -from .mixins import CopyMixin, PasswordMixin, ContactDetailsMixinEmail, ContactDetailsMixin +from .mixins import PasswordMixin, ContactDetailsMixin, postalcode_regex, phone_regex from .email import EmailMessage if TYPE_CHECKING: @@ -38,15 +38,51 @@ from .serviceform import ServiceForm -class ResponsibilityPerson(CopyMixin, PasswordMixin, ContactDetailsMixinEmail, models.Model): - class Meta: - verbose_name = _('Responsibility person') - verbose_name_plural = _('Responsibility persons') - ordering = ('surname',) +class Organization(models.Model): + name = models.CharField(_('Organization name'), max_length=64) + + +class Member(PasswordMixin, models.Model): + + MEMBER_EXTERNAL = 'external' + MEMBER_NORMAL = 'normal' + MEMBER_STAFF = 'staff' + MEMBERSHIP_CHOICES = ( + (MEMBER_EXTERNAL, _('external')), + (MEMBER_NORMAL, _('normal')), + (MEMBER_STAFF, _('staff')) + ) + + + forenames = models.CharField(max_length=64, verbose_name=_('Forename(s)')) + surname = models.CharField(max_length=64, verbose_name=_('Surname')) + street_address = models.CharField(max_length=128, blank=True, + verbose_name=_('Street address')) + postal_code = models.CharField(max_length=32, blank=True, + verbose_name=_('Zip/Postal code'), + validators=[postalcode_regex]) + city = models.CharField(max_length=32, blank=True, verbose_name=_('City')) + + year_of_birth = models.SmallIntegerField(_('Year of birth'), null=True, blank=True) + + # TODO: set unique constraint + email = models.EmailField(blank=True, verbose_name=_('Email'), db_index=True) + email_verified = models.BooleanField(_('Email verified'), default=False) + + phone_number = models.CharField(max_length=32, validators=[phone_regex], blank=True, + verbose_name=_('Phone number')) + + membership_type = models.CharField(_('Is this person a member of this organization?'), + max_length=8, + choices=MEMBERSHIP_CHOICES, + default=MEMBER_EXTERNAL) + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + + # TODO: this might not be appropriate there any more AUTH_VIEW = 'authenticate_responsible_new' - form = models.ForeignKey('serviceform.ServiceForm', null=True) + # TODO: rename allow_send_email ? send_email_notifications = models.BooleanField( default=True, verbose_name=_('Send email notifications'), @@ -55,9 +91,36 @@ class Meta: 'registered. Email contains also has a link that allows accessing raport of ' 'administered activities.')) + # TODO: rename: allow_showing_contact_details_in_forms hide_contact_details = models.BooleanField(_('Hide contact details in form'), default=False) show_full_report = models.BooleanField(_('Grant access to full reports'), default=False) + def __str__(self): + if self.forenames or self.surname: + return '%s %s' % (self.forenames.title(), self.surname.title()) + else: + return self.email + + @property + def address(self): + return ('%s\n%s %s' % ( + self.street_address.title(), self.postal_code, self.city.title())).strip() + + + @property + def contact_details(self): + yield _('Name'), '%s %s' % (self.forenames.title(), self.surname.title()) + if self.email: + yield _('Email'), self.email + if self.phone_number: + yield _('Phone number'), self.phone_number + if self.address: + yield _('Address'), '\n' + self.address + + @property + def contact_display(self): + return '\n'.join('%s: %s' % (k, v) for k, v in self.contact_details) + @cached_property def item_count(self) -> int: return utils.count_for_responsible(self) @@ -73,12 +136,14 @@ def personal_link(self) -> str: def secret_id(self) -> str: return utils.encode(self.id) + # TODO: common unsubscribe @property def list_unsubscribe_link(self) -> str: return settings.SERVER_URL + reverse('unsubscribe_responsible', args=(self.secret_id,)) def resend_auth_link(self) -> 'EmailMessage': - + # TODO + raise NotImplementedError context = {'responsible': str(self), 'form': str(self.form), 'url': self.make_new_auth_url(), @@ -87,7 +152,9 @@ def resend_auth_link(self) -> 'EmailMessage': } return EmailMessage.make(self.form.email_to_responsible_auth_link, context, self.email) - def send_responsibility_email(self, participant: 'Participant') -> None: + def send_responsibility_email(self, participant: 'Participation') -> None: + # TODO + raise NotImplementedError if self.send_email_notifications: context = {'responsible': str(self), 'participant': str(participant), @@ -100,6 +167,8 @@ def send_responsibility_email(self, participant: 'Participant') -> None: EmailMessage.make(self.form.email_to_responsibles, context, self.email) def send_bulk_mail(self) -> 'Optional[EmailMessage]': + #TODO + raise NotImplementedError if self.send_email_notifications: context = {'responsible': str(self), 'form': str(self.form), @@ -110,11 +179,10 @@ def send_bulk_mail(self) -> 'Optional[EmailMessage]': return EmailMessage.make(self.form.bulk_email_to_responsibles, context, self.email) -class Participant(ContactDetailsMixin, PasswordMixin, models.Model): - email: str +class Participation(PasswordMixin, models.Model): class Meta: - verbose_name = _('Participant') + verbose_name = _('Participation') verbose_name_plural = _('Participants') # Current view is set by view decorator require_authenticated_participant @@ -148,7 +216,8 @@ class EmailIds(Enum): (STATUS_FINISHED, _('finished'))) STATUS_DICT = dict(STATUS_CHOICES) - year_of_birth = models.SmallIntegerField(_('Year of birth'), null=True, blank=True) + member = models.ForeignKey(Member, on_delete=models.CASCADE) + #year_of_birth = models.SmallIntegerField(_('Year of birth'), null=True, blank=True) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ONGOING) last_finished_view = models.CharField(max_length=32, default='') created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) @@ -156,9 +225,10 @@ class EmailIds(Enum): last_finished = models.DateTimeField(_('Last finished'), null=True) # Last form revision - form_revision = models.ForeignKey('serviceform.FormRevision', null=True, on_delete=models.CASCADE) + form_revision = models.ForeignKey('serviceform.FormRevision', null=True, + on_delete=models.CASCADE) - email_verified = models.BooleanField(_('Email verified'), default=False) + #email_verified = models.BooleanField(_('Email verified'), default=False) send_email_allowed = models.BooleanField(_('Sending email allowed'), default=True, help_text=_( 'You will receive email that contains a link that allows later modification of the form. ' @@ -182,7 +252,7 @@ def contact_details(self) -> Iterator[str]: @property def additional_data(self) -> Iterator[Tuple[str, str]]: - yield _('Participant created in system'), self.created_at + yield _('Participation created in system'), self.created_at yield _('Last finished'), self.last_finished yield _('Last modified'), self.last_modified yield _('Email address verified'), (_('No'), _('Yes'))[self.email_verified] diff --git a/serviceform/serviceform/models/serviceform.py b/serviceform/serviceform/models/serviceform.py index f65d834..075578b 100644 --- a/serviceform/serviceform/models/serviceform.py +++ b/serviceform/serviceform/models/serviceform.py @@ -41,7 +41,7 @@ from ..utils import ColorStr from .mixins import SubitemMixin, NameDescriptionMixin, CopyMixin -from .people import Participant, ResponsibilityPerson +from .people import Participation, Member, Organization from .email import EmailTemplate from .participation import QuestionAnswer @@ -118,7 +118,8 @@ def __str__(self): on_delete=models.SET_NULL) # Ownership - responsible = models.ForeignKey(ResponsibilityPerson, null=True, blank=True, + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + responsible = models.ForeignKey(Member, null=True, blank=True, verbose_name=_('Responsible'), on_delete=models.SET_NULL) # Email settings @@ -155,7 +156,7 @@ def __str__(self): help_text=_('Email that is sent to responsible when he requests auth link'), on_delete=models.SET_NULL) - # Participant emails: + # Participation emails: # on_finish email_to_participant = models.ForeignKey( @@ -275,7 +276,7 @@ def create_initial_data(self) -> None: self.create_email_templates() self.current_revision = FormRevision.objects.create(name='%s' % timezone.now().year, form=self) - self.responsible = ResponsibilityPerson.objects.create( + self.responsible = Member.objects.create( forenames=_('Default'), surname=_('Responsible'), email=_('defaultresponsible@email.com'), @@ -354,19 +355,19 @@ def invite_user(self, email: str, old_participants: bool=False) -> InviteUserRes """ logger.info('Invite user %s %s', self, email) - participant = Participant.objects.filter(email=email, form_revision__form=self).first() + participant = Participation.objects.filter(email=email, form_revision__form=self).first() if participant: if old_participants and participant.form_revision != self.current_revision: - rv = participant.send_participant_email(Participant.EmailIds.INVITE) + rv = participant.send_participant_email(Participation.EmailIds.INVITE) return (self.InviteUserResponse.EMAIL_SENT if rv else self.InviteUserResponse.USER_DENIED_EMAIL) else: return self.InviteUserResponse.USER_EXISTS else: - participant = Participant.objects.create(email=email, - form_revision=self.current_revision, - status=Participant.STATUS_INVITED) - participant.send_participant_email(Participant.EmailIds.INVITE) + participant = Participation.objects.create(email=email, + form_revision=self.current_revision, + status=Participation.STATUS_INVITED) + participant.send_participant_email(Participation.EmailIds.INVITE) return self.InviteUserResponse.EMAIL_SENT @cached_property @@ -426,9 +427,9 @@ def participation_count(self) -> str: if self.current_revision: old_time = timezone.now() - datetime.timedelta(minutes=20) ready = self.current_revision.participant_set.filter( - status__in=Participant.READY_STATUSES) + status__in=Participation.READY_STATUSES) recent_ongoing = self.current_revision.participant_set.filter( - status__in=[Participant.STATUS_ONGOING], + status__in=[Participation.STATUS_ONGOING], last_modified__gt=old_time) return '%s + %s' % (ready.count(), recent_ongoing.count()) @@ -445,11 +446,11 @@ def bulk_email_responsibles(self) -> None: def bulk_email_former_participants(self) -> None: logger.info('Bulk email former participants %s', self) - for p in Participant.objects.filter(send_email_allowed=True, - form_revision__send_bulk_email_to_participants=True, - form_revision__form=self, - form_revision__valid_to__lt=timezone.now()).distinct(): - p.send_participant_email(Participant.EmailIds.NEW_FORM_REVISION) + for p in Participation.objects.filter(send_email_allowed=True, + form_revision__send_bulk_email_to_participants=True, + form_revision__form=self, + form_revision__valid_to__lt=timezone.now()).distinct(): + p.send_participant_email(Participation.EmailIds.NEW_FORM_REVISION) def reschedule_bulk_email(self) -> None: now = timezone.now() @@ -469,7 +470,7 @@ def reschedule_bulk_email(self) -> None: class AbstractServiceFormItem(models.Model): - _responsibles: Set[ResponsibilityPerson] + _responsibles: Set[Member] sub_items: 'Iterable[AbstractServiceFormItem]' class Meta: @@ -478,7 +479,7 @@ class Meta: order = models.PositiveIntegerField(default=0, blank=False, null=False, db_index=True, verbose_name=_('Order')) - responsibles = select2_fields.ManyToManyField(ResponsibilityPerson, blank=True, + responsibles = select2_fields.ManyToManyField(Member, blank=True, verbose_name=_('Responsible persons'), related_name='%(class)s_related', overlay=_('Choose responsibles'), @@ -558,7 +559,7 @@ def participation_items(self, revision_name: str) -> 'Sequence[ParticipationActi current_revision = self.category.category.form.current_revision qs = self.participationactivity_set.filter( - participant__status__in=Participant.READY_STATUSES) + participant__status__in=Participation.READY_STATUSES) if revision_name == utils.RevisionOptions.ALL: qs = qs.order_by('participant__form_revision') @@ -601,7 +602,7 @@ def participation_items(self, revision_name: str) -> 'Sequence[ParticipationActi current_revision = self.activity.category.category.form.current_revision qs = self.participationactivitychoice_set.filter( - activity__participant__status__in=Participant.READY_STATUSES) + activity__participant__status__in=Participation.READY_STATUSES) if revision_name == utils.RevisionOptions.ALL: qs = qs.order_by('activity__participant__form_revision') @@ -648,7 +649,7 @@ def render(self) -> str: def questionanswers(self, revision_name: str) -> 'Sequence[QuestionAnswer]': qs = QuestionAnswer.objects.filter(question=self, - participant__status__in=Participant.READY_STATUSES) + participant__status__in=Participation.READY_STATUSES) current_revision = self.form.current_revision diff --git a/serviceform/serviceform/tasks.py b/serviceform/serviceform/tasks.py index e2478e0..00f51b5 100644 --- a/serviceform/serviceform/tasks.py +++ b/serviceform/serviceform/tasks.py @@ -32,15 +32,15 @@ @shared_task def cleanup_abandoned_participations(): logger.info('Deleting abandoned participations') - models.Participant.objects.filter(last_modified__lt=timezone.now() - timedelta(days=1), - status=models.Participant.STATUS_ONGOING).delete() + models.Participation.objects.filter(last_modified__lt=timezone.now() - timedelta(days=1), + status=models.Participation.STATUS_ONGOING).delete() @shared_task def finish_abandoned_updating_participations(): - for p in models.Participant.objects.filter( + for p in models.Participation.objects.filter( last_modified__lt=timezone.now() - timedelta(days=1), - status=models.Participant.STATUS_UPDATING): + status=models.Participation.STATUS_UPDATING): logger.info('Finishing abandoned updating participations %s', p) p.finish(email_participant=False) diff --git a/serviceform/serviceform/templatetags/serviceform_tags.py b/serviceform/serviceform/templatetags/serviceform_tags.py index 33959cf..6b3b2bf 100644 --- a/serviceform/serviceform/templatetags/serviceform_tags.py +++ b/serviceform/serviceform/templatetags/serviceform_tags.py @@ -7,7 +7,7 @@ from django.utils.safestring import mark_safe, SafeString from django.utils.translation import gettext_lazy as _ -from ..models import Participant +from ..models import Participation from ..utils import safe_join, ColorStr from .. import utils from ..urls import participant_flow_urls, menu_urls, Requires @@ -15,7 +15,7 @@ register = template.Library() if TYPE_CHECKING: - from ..models import (AbstractServiceFormItem, ResponsibilityPerson, SubitemMixin, Activity, + from ..models import (AbstractServiceFormItem, Member, SubitemMixin, Activity, ActivityChoice, ParticipationActivity, ParticipationActivityChoice, Question, QuestionAnswer) @@ -60,7 +60,7 @@ def responsible_link(context: Context, item: 'AbstractServiceFormItem') -> SafeS @register.assignment_tag -def has_responsible(item: 'SubitemMixin', responsible: 'ResponsibilityPerson') -> bool: +def has_responsible(item: 'SubitemMixin', responsible: 'Member') -> bool: return item.has_responsible(responsible) @@ -84,12 +84,12 @@ def all_revisions(context: Context) -> bool: @register.assignment_tag(takes_context=True) -def participants(context: Context) -> 'Sequence[Participant]': +def participants(context: Context) -> 'Sequence[Participation]': revision_name = utils.get_report_settings(context['request'], 'revision') service_form = context.get('service_form') - qs = Participant.objects.filter(form_revision__form=service_form, - status__in=Participant.READY_STATUSES).order_by('surname') + qs = Participation.objects.filter(form_revision__form=service_form, + status__in=Participation.READY_STATUSES).order_by('surname') if revision_name == utils.RevisionOptions.CURRENT: qs = qs.filter(form_revision__id=service_form.current_revision_id) elif revision_name == utils.RevisionOptions.ALL: diff --git a/serviceform/serviceform/utils.py b/serviceform/serviceform/utils.py index 12f3ae4..4653cb8 100644 --- a/serviceform/serviceform/utils.py +++ b/serviceform/serviceform/utils.py @@ -27,7 +27,7 @@ from typing import Match, Optional, TYPE_CHECKING, Iterable, Union if TYPE_CHECKING: - from .models import ServiceForm, Participant, ResponsibilityPerson, AbstractServiceFormItem + from .models import ServiceForm, Participation, Member, AbstractServiceFormItem from colorful.forms import RGB_REGEX from django.contrib import messages @@ -116,20 +116,20 @@ def user_has_serviceform_permission(user: settings.AUTH_USER_MODEL, service_form _participants = {} -def get_participant(_id: int) -> 'Participant': +def get_participant(_id: int) -> 'Participation': p = _participants.get(_id) if p is None: - logger.error('Participant %d was not in cache!', _id) + logger.error('Participation %d was not in cache!', _id) return p def fetch_participants(service_form: 'ServiceForm', revision_name: str) -> None: global _participants - from .models import Participant + from .models import Participation is_all_revisions = revision_name == RevisionOptions.ALL is_current_revision = revision_name == RevisionOptions.CURRENT - qs = Participant.objects.prefetch_related('participantlog_set__written_by') + qs = Participation.objects.prefetch_related('participantlog_set__written_by') if is_all_revisions: qs = qs.select_related('form_revision') participants = qs.filter(form_revision__form=service_form).distinct() @@ -177,7 +177,7 @@ def init_serviceform_counters(service_form: 'ServiceForm', all_responsibles: boo cat1_counter = 0 _responsible_counts.clear() - def _add_responsible(responsibles: 'Iterable[ResponsibilityPerson]', + def _add_responsible(responsibles: 'Iterable[Member]', *targets: 'AbstractServiceFormItem', resp_count: bool=False) -> None: if resp_count: @@ -222,12 +222,12 @@ def _add_responsible(responsibles: 'Iterable[ResponsibilityPerson]', def shuffle_person_data(service_form: 'ServiceForm') -> None: - from .models import Participant, ResponsibilityPerson, Question + from .models import Participation, Member, Question letters = len(string.ascii_letters) forenames = set() surnames = set() - participants = Participant.objects.filter(form_revision__form=service_form) - responsibles = ResponsibilityPerson.objects.filter(form=service_form) + participants = Participation.objects.filter(form_revision__form=service_form) + responsibles = Member.objects.filter(form=service_form) for p in chain(participants, responsibles): for n in p.forenames.split(' '): if n: @@ -288,7 +288,7 @@ def shuffle_contact_details(p): shuffle_question(q) -def count_for_responsible(resp: 'ResponsibilityPerson') -> int: +def count_for_responsible(resp: 'Member') -> int: return _responsible_counts[resp.pk] @@ -346,9 +346,9 @@ def clean_session(request: HttpRequest): def get_responsible(request: HttpRequest): - from .models import ResponsibilityPerson + from .models import Member responsible_pk = request.session.get('authenticated_responsibility') - return ResponsibilityPerson.objects.filter(pk=responsible_pk).first() + return Member.objects.filter(pk=responsible_pk).first() def safe_join(sep: str, args_generator: Iterable[str]): @@ -362,12 +362,12 @@ def safe_join(sep: str, args_generator: Iterable[str]): return result -def expire_auth_link(request: HttpRequest, obj: 'Union[Participant, ResponsibilityPerson]') \ +def expire_auth_link(request: HttpRequest, obj: 'Union[Participation, Member]') \ -> HttpResponse: """ :param request: WSGI request - :param obj: either Participant or ResponsibilityPerson + :param obj: either Participation or Member :return: HttpResponse """ obj.resend_auth_link() diff --git a/serviceform/serviceform/views/decorators.py b/serviceform/serviceform/views/decorators.py index d4d0ebb..6d7857e 100644 --- a/serviceform/serviceform/views/decorators.py +++ b/serviceform/serviceform/views/decorators.py @@ -74,9 +74,9 @@ def wrapper(request: HttpRequest, *args, title: str='', **kwargs) -> HttpRespons participant_pk = request.session.get('authenticated_participant') if participant_pk: request.participant = participant = get_object_or_404( - models.Participant.objects.all(), + models.Participation.objects.all(), pk=participant_pk, - status__in=models.Participant.EDIT_STATUSES) + status__in=models.Participation.EDIT_STATUSES) if check_flow: # Check flow status participant._current_view = current_view @@ -103,7 +103,7 @@ def wrapper(request: HttpRequest, *args, title: str='', **kwargs) -> HttpRespons def require_published_form(func): @wraps(func) - def wrapper(request: HttpRequest, participant: models.Participant, + def wrapper(request: HttpRequest, participant: models.Participation, *args, **kwargs) -> HttpResponse: if not participant.form.is_published: raise PermissionDenied diff --git a/serviceform/serviceform/views/login_views.py b/serviceform/serviceform/views/login_views.py index 2f6faa6..cc03d81 100644 --- a/serviceform/serviceform/views/login_views.py +++ b/serviceform/serviceform/views/login_views.py @@ -33,7 +33,7 @@ def password_login(request: HttpRequest, service_form: models.ServiceForm) -> Ht if request.method == 'POST': password_form = forms.PasswordForm(service_form, request.POST) if password_form.is_valid(): - participant = models.Participant.objects.create( + participant = models.Participation.objects.create( form_revision=service_form.current_revision) request.session['authenticated_participant'] = participant.pk return HttpResponseRedirect(reverse('contact_details')) diff --git a/serviceform/serviceform/views/participation_views.py b/serviceform/serviceform/views/participation_views.py index e5c9037..a85e9cd 100644 --- a/serviceform/serviceform/views/participation_views.py +++ b/serviceform/serviceform/views/participation_views.py @@ -34,8 +34,8 @@ @require_authenticated_participant -def contact_details(request: HttpRequest, participant: models.Participant) -> HttpResponse: - if participant and participant.status == models.Participant.STATUS_FINISHED: +def contact_details(request: HttpRequest, participant: models.Participation) -> HttpResponse: + if participant and participant.status == models.Participation.STATUS_FINISHED: return HttpResponseRedirect(reverse('submitted')) form = forms.ContactForm(instance=participant, user=request.user) @@ -47,7 +47,7 @@ def contact_details(request: HttpRequest, participant: models.Participant) -> Ht if participant.form.is_published: return participant.redirect_next(request) else: - participant.status = models.Participant.STATUS_FINISHED + participant.status = models.Participation.STATUS_FINISHED participant.save(update_fields=['status']) return HttpResponseRedirect(reverse('submitted')) @@ -60,10 +60,10 @@ def contact_details(request: HttpRequest, participant: models.Participant) -> Ht @require_authenticated_participant @require_published_form -def email_verification(request: HttpRequest, participant: models.Participant) -> HttpResponse: +def email_verification(request: HttpRequest, participant: models.Participation) -> HttpResponse: service_form = participant.form if request.session.get('verification_sent', '') != participant.email: - participant.send_participant_email(models.Participant.EmailIds.EMAIL_VERIFICATION) + participant.send_participant_email(models.Participation.EmailIds.EMAIL_VERIFICATION) request.session['verification_sent'] = participant.email else: messages.warning(request, @@ -77,7 +77,7 @@ def email_verification(request: HttpRequest, participant: models.Participant) -> @require_authenticated_participant @require_published_form -def participation(request: HttpRequest, participant: models.Participant, +def participation(request: HttpRequest, participant: models.Participation, cat_num: int) -> HttpResponse: cat_num = int(cat_num) service_form = participant.form @@ -116,7 +116,7 @@ def participation(request: HttpRequest, participant: models.Participant, @require_authenticated_participant @require_published_form -def questions(request: HttpRequest, participant: models.Participant) -> HttpResponse: +def questions(request: HttpRequest, participant: models.Participation) -> HttpResponse: if not participant.form.questions: return participant.redirect_next(request) @@ -134,7 +134,7 @@ def questions(request: HttpRequest, participant: models.Participant) -> HttpResp @require_authenticated_participant @require_published_form -def preview(request: HttpRequest, participant: models.Participant) -> HttpResponse: +def preview(request: HttpRequest, participant: models.Participation) -> HttpResponse: if request.method == 'POST' and 'submit' in request.POST: return participant.redirect_next(request, message=False) else: @@ -143,7 +143,7 @@ def preview(request: HttpRequest, participant: models.Participant) -> HttpRespon @require_authenticated_participant -def submitted(request: HttpRequest, participant: models.Participant) -> HttpResponse: +def submitted(request: HttpRequest, participant: models.Participation) -> HttpResponse: participant.finish() clean_session(request) return render(request, 'serviceform/participation/submitted_view.html', @@ -151,28 +151,28 @@ def submitted(request: HttpRequest, participant: models.Participant) -> HttpResp @require_authenticated_participant(check_flow=False) -def send_auth_link(request: HttpRequest, participant: models.Participant, +def send_auth_link(request: HttpRequest, participant: models.Participation, email: str) -> HttpResponse: if not email: raise Http404 - p = get_object_or_404(models.Participant, email=email, form_revision__form=participant.form) + p = get_object_or_404(models.Participation, email=email, form_revision__form=participant.form) p.send_participant_email(p.EmailIds.RESEND) messages.add_message(request, messages.INFO, _('Authentication link was sent to email address {}.').format(email)) return HttpResponseRedirect(reverse('contact_details')) -def auth_participant_common(request: HttpRequest, participant: models.Participant, next_view: str, +def auth_participant_common(request: HttpRequest, participant: models.Participation, next_view: str, email_verified: bool=True) -> HttpResponse: if not participant.email_verified and email_verified: participant.email_verified = True messages.info(request, _('Your email {} is now verified successfully!').format(participant.email)) - if participant.status == models.Participant.STATUS_FINISHED: - participant.status = models.Participant.STATUS_UPDATING - elif participant.status == models.Participant.STATUS_INVITED: - participant.status = models.Participant.STATUS_ONGOING + if participant.status == models.Participation.STATUS_FINISHED: + participant.status = models.Participation.STATUS_UPDATING + elif participant.status == models.Participation.STATUS_INVITED: + participant.status = models.Participation.STATUS_ONGOING if participant.form_revision != participant.form_revision.form.current_revision: participant.last_finished_view = '' participant.form_revision = participant.form_revision.form.current_revision @@ -190,14 +190,14 @@ def authenticate_participant_old(request: HttpRequest, uuid: str, if not uuid: raise Http404 clean_session(request) - participant = get_object_or_404(models.Participant.objects.all(), secret_key=uuid) + participant = get_object_or_404(models.Participation.objects.all(), secret_key=uuid) return expire_auth_link(request, participant) def authenticate_participant(request: HttpRequest, participant_id: int, password: str, next_view: str='contact_details') -> HttpResponse: clean_session(request) - participant = get_object_or_404(models.Participant.objects.all(), pk=participant_id) + participant = get_object_or_404(models.Participation.objects.all(), pk=participant_id) result = participant.check_auth_key(password) if result == participant.PasswordStatus.PASSWORD_NOK: messages.error(request, _( @@ -215,13 +215,13 @@ def authenticate_participant(request: HttpRequest, participant_id: int, password def authenticate_participant_mock(request: HttpRequest, participant_id: int, next_view: str='contact_details') -> HttpResponse: clean_session(request) - participant = get_object_or_404(models.Participant.objects.all(), pk=participant_id) + participant = get_object_or_404(models.Participation.objects.all(), pk=participant_id) user_has_serviceform_permission(request.user, participant.form, raise_permissiondenied=True) return auth_participant_common(request, participant, next_view, email_verified=False) @require_authenticated_participant(check_flow=False) -def delete_participation(request: HttpRequest, participant: models.Participant) -> HttpResponse: +def delete_participation(request: HttpRequest, participant: models.Participation) -> HttpResponse: form = forms.DeleteParticipationForm() service_form = participant.form if request.method == 'POST': @@ -243,7 +243,7 @@ def verify_email(request: HttpRequest, participant_id: int, password: str) -> Ht def unsubscribe(request: HttpRequest, secret_id: str) -> HttpResponse: - participant = get_object_or_404(models.Participant.objects, pk=decode(secret_id)) + participant = get_object_or_404(models.Participation.objects, pk=decode(secret_id)) participant.send_email_allowed = False participant.save(update_fields=['send_email_allowed']) return render(request, 'serviceform/login/unsubscribe_participant.html', diff --git a/serviceform/serviceform/views/reports_views.py b/serviceform/serviceform/views/reports_views.py index bf94a48..d137987 100644 --- a/serviceform/serviceform/views/reports_views.py +++ b/serviceform/serviceform/views/reports_views.py @@ -38,13 +38,13 @@ def authenticate_responsible_old(request: HttpRequest, uuid: str) -> HttpRespons """ if not uuid: raise Http404 - responsible = get_object_or_404(models.ResponsibilityPerson.objects.all(), secret_key=uuid) + responsible = get_object_or_404(models.Member.objects.all(), secret_key=uuid) return expire_auth_link(request, responsible) def authenticate_responsible(request: HttpRequest, responsible_id: int, password: str) -> HttpResponse: - responsible = get_object_or_404(models.ResponsibilityPerson.objects.all(), pk=responsible_id) + responsible = get_object_or_404(models.Member.objects.all(), pk=responsible_id) result = responsible.check_auth_key(password) if result == responsible.PasswordStatus.PASSWORD_NOK: messages.error(request, _( @@ -63,7 +63,7 @@ def authenticate_responsible_mock(request: HttpRequest, responsible_id: int) -> """ Mocked authentication to responsible view from admin panel """ - responsible = get_object_or_404(models.ResponsibilityPerson.objects.all(), pk=responsible_id) + responsible = get_object_or_404(models.Member.objects.all(), pk=responsible_id) user_has_serviceform_permission(request.user, responsible.form, raise_permissiondenied=True) request.session['authenticated_responsibility'] = responsible.pk @@ -110,9 +110,9 @@ def all_questions(request: HttpRequest, service_form: models.ServiceForm) -> Htt @require_authenticated_responsible -def view_participant(request: HttpRequest, responsible: models.ResponsibilityPerson, +def view_participant(request: HttpRequest, responsible: models.Member, participant_id: int) -> HttpResponse: - participant = get_object_or_404(models.Participant.objects, pk=participant_id) + participant = get_object_or_404(models.Participation.objects, pk=participant_id) anonymous = False if request.user.pk: @@ -138,9 +138,9 @@ def view_participant(request: HttpRequest, responsible: models.ResponsibilityPer @require_authenticated_responsible -def view_responsible(request: HttpRequest, auth_responsible: models.ResponsibilityPerson, +def view_responsible(request: HttpRequest, auth_responsible: models.Member, responsible_pk: int) -> HttpResponse: - responsible = models.ResponsibilityPerson.objects.get(pk=responsible_pk) + responsible = models.Member.objects.get(pk=responsible_pk) if not (user_has_serviceform_permission(request.user, responsible.form, raise_permissiondenied=False) or (auth_responsible and auth_responsible.show_full_report @@ -156,7 +156,7 @@ def view_responsible(request: HttpRequest, auth_responsible: models.Responsibili @require_authenticated_responsible -def preview_form(request: HttpRequest, responsible: models.ResponsibilityPerson, +def preview_form(request: HttpRequest, responsible: models.Member, slug: str) -> HttpResponse: service_form = get_object_or_404(models.ServiceForm.objects, slug=slug) user_has_serviceform_permission(request.user, service_form) @@ -167,7 +167,7 @@ def preview_form(request: HttpRequest, responsible: models.ResponsibilityPerson, @require_authenticated_responsible -def preview_printable(request: HttpRequest, responsible: models.ResponsibilityPerson, +def preview_printable(request: HttpRequest, responsible: models.Member, slug: str) -> HttpResponse: service_form = get_object_or_404(models.ServiceForm.objects, slug=slug) user_has_serviceform_permission(request.user, service_form) @@ -178,7 +178,7 @@ def preview_printable(request: HttpRequest, responsible: models.ResponsibilityPe @require_authenticated_responsible def edit_responsible(request: HttpRequest, - responsible: models.ResponsibilityPerson) -> HttpResponse: + responsible: models.Member) -> HttpResponse: if responsible is None: raise PermissionDenied service_form = responsible.form @@ -194,7 +194,7 @@ def edit_responsible(request: HttpRequest, @require_authenticated_responsible def responsible_report(request: HttpRequest, - responsible: models.ResponsibilityPerson) -> HttpResponse: + responsible: models.Member) -> HttpResponse: if responsible is None: raise PermissionDenied service_form = responsible.form @@ -209,7 +209,7 @@ def logout_view(request: HttpRequest, **kwargs) -> HttpResponse: logout(request) messages.info(request, _('You have been logged out')) if responsible_pk: - responsible = models.ResponsibilityPerson.objects.get(pk=responsible_pk) + responsible = models.Member.objects.get(pk=responsible_pk) return HttpResponseRedirect(reverse('password_login', args=(responsible.form.slug,))) return HttpResponseRedirect(reverse('main_page')) @@ -234,14 +234,14 @@ def invite(request: HttpRequest, serviceform_slug: str, **kwargs) -> HttpRespons @require_authenticated_responsible -def to_full_report(request: HttpRequest, responsible: models.ResponsibilityPerson) -> HttpResponse: +def to_full_report(request: HttpRequest, responsible: models.Member) -> HttpResponse: if not responsible.show_full_report: raise PermissionDenied return redirect('report', responsible.form.slug) def unsubscribe(request: HttpRequest, secret_id: str) -> HttpResponse: - responsible = get_object_or_404(models.ResponsibilityPerson.objects, pk=decode(secret_id)) + responsible = get_object_or_404(models.Member.objects, pk=decode(secret_id)) responsible.send_email_notifications = False responsible.save(update_fields=['send_email_notifications']) return render(request, 'serviceform/login/unsubscribe_responsible.html', diff --git a/tests/test_views.py b/tests/test_views.py index 62c1b67..dd6684b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -26,8 +26,8 @@ def test_hit_admin_pages(report_settings, db, admin_client: Client): def test_hit_admin_reports(db, report_settings, admin_client: Client): - p = models.Participant.objects.filter(form_revision__form__slug=SLUG).first() - r = models.ResponsibilityPerson.objects.filter(form__slug=SLUG).first() + p = models.Participation.objects.filter(form_revision__form__slug=SLUG).first() + r = models.Member.objects.filter(form__slug=SLUG).first() pages = [ f"/report/{SLUG}/", f"/report/{SLUG}/all_participants/", @@ -82,7 +82,7 @@ def test_flow_login_send_participant_email(db, client: Client): assert res.status_code == Http.OK assert 'Lomakkeelle ei löytynyt aikaisempaa osallistumistietoa' in res.context['email_form'].errors['email'][0] - email = models.Participant.objects.first().email + email = models.Participation.objects.first().email timestamp = timezone.now() res = client.post(page, {'email': email}) assert res.status_code == Http.REDIR @@ -99,7 +99,7 @@ def test_flow_login_send_responsible_email(db, client: Client): assert res.status_code == Http.OK assert 'Lomakkeelle ei löytynyt vastuuhenkilöä' in res.context['email_form'].errors['email'][0] - email = models.ResponsibilityPerson.objects.first().email + email = models.Member.objects.first().email timestamp = timezone.now() res = client.post(page, {'email': email}) assert res.status_code == Http.REDIR @@ -225,7 +225,7 @@ def skip_other_pages(updating=False): return client.post(Pages.PARTICIPATIONX % 6) - def check_responsible_reports(emails: QuerySet, resps: List[models.ResponsibilityPerson], num_responsibles: int): + def check_responsible_reports(emails: QuerySet, resps: List[models.Member], num_responsibles: int): assert len(resps) == num_responsibles assert len(emails) == num_responsibles + 1 - 1 # +1 to participant. -1 because 1 does not want email notifications. _full_report_hit = False @@ -284,8 +284,8 @@ def check_responsible_reports(emails: QuerySet, resps: List[models.Responsibilit first_activity: models.Activity = first_cat2.sub_items[0] first_choice: models.ActivityChoice = first_activity.sub_items[0] second_activity: models.Activity = first_cat2.sub_items[1] - earlier_p = models.Participant.objects.filter( - status=models.Participant.STATUS_FINISHED, form_revision=s.current_revision).first() + earlier_p = models.Participation.objects.filter( + status=models.Participation.STATUS_FINISHED, form_revision=s.current_revision).first() assert_forbidden() res = client.post(f'/{SLUG}/', {'password': s.password}) @@ -294,7 +294,7 @@ def check_responsible_reports(emails: QuerySet, resps: List[models.Responsibilit res = client.get(Pages.CONTACT) assert res.status_code == Http.OK participant_id = client.session['authenticated_participant'] - p = models.Participant.objects.get(pk=participant_id) + p = models.Participation.objects.get(pk=participant_id) user_data_without_email = dict(forenames='Forenames', surname='Surname', @@ -414,7 +414,7 @@ def check_responsible_reports(emails: QuerySet, resps: List[models.Responsibilit res = client.post(Pages.PREVIEW, {'submit': '1'}) assert res.status_code == Http.REDIR assert res.url == Pages.SUBMITTED - assert p.status == models.Participant.STATUS_ONGOING + assert p.status == models.Participation.STATUS_ONGOING timestamp = timezone.now() res = client.get(Pages.SUBMITTED) assert res.status_code == Http.OK @@ -432,7 +432,7 @@ def check_responsible_reports(emails: QuerySet, resps: List[models.Responsibilit assert len(emails) == 1 #just participant p.refresh_from_db() - assert p.status == models.Participant.STATUS_FINISHED + assert p.status == models.Participation.STATUS_FINISHED assert_forbidden() @@ -529,7 +529,7 @@ def check_responsible_reports(emails: QuerySet, resps: List[models.Responsibilit assert res.status_code == Http.REDIR assert res.url == Pages.LOGIN with pytest.raises(p.DoesNotExist): - models.Participant.objects.get(pk=p.pk) + models.Participation.objects.get(pk=p.pk) @pytest.mark.parametrize('full_raport', [False, True]) @@ -637,7 +637,7 @@ def test_invite_success(serviceform, admin_client: Client, emails, send_existing res = admin_client.get(Pages.INVITE) assert res.status_code == Http.OK part_email = 'timo.ahlroth@email.com' - participant = models.Participant.objects.get(email=part_email) + participant = models.Participation.objects.get(email=part_email) revision = models.FormRevision.objects.create(name='old', form=serviceform) participant.form_revision = revision @@ -653,7 +653,7 @@ def test_invite_success(serviceform, admin_client: Client, emails, send_existing assert len(models.EmailMessage.objects.filter(created_at__gt=timestamp)) == (3 if send_existing else 2) -def test_unsubscribe_participant(client: Client, participant: models.Participant): +def test_unsubscribe_participant(client: Client, participant: models.Participation): from serviceform.serviceform.utils import encode assert participant.send_email_allowed res = client.get(Pages.UNSUBSCRIBE_PARTICIPANT % encode(participant.pk)) @@ -662,7 +662,7 @@ def test_unsubscribe_participant(client: Client, participant: models.Participant assert not participant.send_email_allowed -def test_unsubscribe_responsible(client: Client, responsible: models.ResponsibilityPerson): +def test_unsubscribe_responsible(client: Client, responsible: models.Member): from serviceform.serviceform.utils import encode assert responsible.send_email_notifications res = client.get(Pages.UNSUBSCRIBE_RESPONSIBLE % encode(responsible.pk)) From 56e151118c52d304493051a2b22ff9c68640dd71 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:03:44 +0300 Subject: [PATCH 02/87] Move Participation from models.people to models.participation --- serviceform/serviceform/models/__init__.py | 4 +- serviceform/serviceform/models/email.py | 1 + serviceform/serviceform/models/mixins.py | 1 + .../serviceform/models/participation.py | 302 +++++++++++++++++- serviceform/serviceform/models/people.py | 281 +--------------- serviceform/serviceform/models/serviceform.py | 4 +- 6 files changed, 299 insertions(+), 294 deletions(-) diff --git a/serviceform/serviceform/models/__init__.py b/serviceform/serviceform/models/__init__.py index b0a9db9..325cc92 100644 --- a/serviceform/serviceform/models/__init__.py +++ b/serviceform/serviceform/models/__init__.py @@ -18,8 +18,8 @@ from .email import EmailMessage, EmailTemplate from .participation import (ParticipationActivity, ParticipationActivityChoice, ParticipantLog, - QuestionAnswer) -from .people import Participation, Organization, Member + QuestionAnswer, Participation) +from .people import Organization, Member from .serviceform import (ServiceForm, FormRevision, Activity, ActivityChoice, Level1Category, Level2Category, Question, ColorField) diff --git a/serviceform/serviceform/models/email.py b/serviceform/serviceform/models/email.py index 6f790e1..20cdf42 100644 --- a/serviceform/serviceform/models/email.py +++ b/serviceform/serviceform/models/email.py @@ -34,6 +34,7 @@ if TYPE_CHECKING: from .serviceform import ServiceForm + class EmailMessage(models.Model): created_at = models.DateTimeField(auto_now_add=True) last_modified = models.DateTimeField(auto_now=True) diff --git a/serviceform/serviceform/models/mixins.py b/serviceform/serviceform/models/mixins.py index 8ae8a61..809f28e 100644 --- a/serviceform/serviceform/models/mixins.py +++ b/serviceform/serviceform/models/mixins.py @@ -46,6 +46,7 @@ code='invalid', ) + class ContactDetailsMixin(models.Model): class Meta: abstract = True diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 8ab2bbf..2ed264e 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -15,26 +15,294 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . +from enum import Enum +from typing import Sequence, TYPE_CHECKING, Union, Iterator, Tuple, List, Optional -from typing import Sequence, TYPE_CHECKING +from django.conf import settings +from django.contrib import messages from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.urls import reverse +from django.utils import timezone +from django.utils.formats import localize from django.utils.functional import cached_property +from django.utils.html import format_html +from django.utils.translation import ugettext_lazy as _ + +from .mixins import PasswordMixin from .. import utils if TYPE_CHECKING: - from .people import Participation + from .email import EmailMessage + from .serviceform import ServiceForm -class ParticipantLog(models.Model): - created_at = models.DateTimeField(auto_now_add=True) - participant = models.ForeignKey('serviceform.Participation', on_delete=models.CASCADE) - writer_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - writer_id = models.PositiveIntegerField() - # Can be either responsible or django user - written_by = GenericForeignKey('writer_type', 'writer_id') - message = models.TextField() + +class Participation(PasswordMixin, models.Model): + class Meta: + verbose_name = _('Participation') + verbose_name_plural = _('Participants') + + # Current view is set by view decorator require_authenticated_participant + _current_view = 'contact_details' + AUTH_VIEW = 'authenticate_participant_new' + + class EmailIds(Enum): + ON_FINISH = object() + ON_UPDATE = object() + NEW_FORM_REVISION = object() + RESEND = object() + INVITE = object() + EMAIL_VERIFICATION = object() + + SEND_ALWAYS_EMAILS = [EmailIds.RESEND, + EmailIds.EMAIL_VERIFICATION, + EmailIds.ON_FINISH, + EmailIds.ON_UPDATE] + + STATUS_INVITED = 'invited' + STATUS_ONGOING = 'ongoing' + STATUS_UPDATING = 'updating' + STATUS_FINISHED = 'finished' + READY_STATUSES = (STATUS_UPDATING, STATUS_FINISHED) + EDIT_STATUSES = (STATUS_UPDATING, STATUS_ONGOING) + + STATUS_CHOICES = ( + (STATUS_INVITED, _('invited')), + (STATUS_ONGOING, _('ongoing')), + (STATUS_UPDATING, _('updating')), + (STATUS_FINISHED, _('finished'))) + STATUS_DICT = dict(STATUS_CHOICES) + + member = models.ForeignKey('serviceform.Member', on_delete=models.CASCADE) + #year_of_birth = models.SmallIntegerField(_('Year of birth'), null=True, blank=True) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ONGOING) + last_finished_view = models.CharField(max_length=32, default='') + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) + last_modified = models.DateTimeField(auto_now=True, verbose_name=_('Last modified')) + last_finished = models.DateTimeField(_('Last finished'), null=True) + + # Last form revision + form_revision = models.ForeignKey('serviceform.FormRevision', null=True, + on_delete=models.CASCADE) + + #email_verified = models.BooleanField(_('Email verified'), default=False) + + send_email_allowed = models.BooleanField(_('Sending email allowed'), default=True, help_text=_( + 'You will receive email that contains a link that allows later modification of the form. ' + 'Also when new version of form is published, you will be notified. ' + 'It is highly recommended that you keep this enabled unless you move away ' + 'and do not want to participate at all any more. You can also change this setting later ' + 'if you wish.')) + + @cached_property + def age(self) -> Union[int, 'str']: + return timezone.now().year - self.year_of_birth if self.year_of_birth else '-' + + @property + def is_updating(self) -> bool: + return self.status == self.STATUS_UPDATING + + @property + def contact_details(self) -> Iterator[str]: + yield from super().contact_details + yield _('Year of birth'), self.year_of_birth or '-' + + @property + def additional_data(self) -> Iterator[Tuple[str, str]]: + yield _('Participation created in system'), self.created_at + yield _('Last finished'), self.last_finished + yield _('Last modified'), self.last_modified + yield _('Email address verified'), (_('No'), _('Yes'))[self.email_verified] + yield _('Emails allowed'), (_('No'), _('Yes'))[self.send_email_allowed] + yield _('Form status'), self.STATUS_DICT[self.status] + + @cached_property + def item_count(self) -> int: + choices = ParticipationActivityChoice.objects.filter( + activity__participant=self).values_list('activity_id', flat=True) + choice_count = len(choices) + activity_count = self.participationactivity_set.exclude(pk__in=choices).count() + return activity_count + choice_count + + def make_new_verification_url(self) -> str: + return settings.SERVER_URL + reverse('verify_email', + args=(self.pk, self.make_new_password())) + + @cached_property + def activities(self) -> 'Sequence[ParticipationActivity]': + return self.participationactivity_set.select_related('activity') + + @cached_property + def questions(self) -> 'Sequence[QuestionAnswer]': + return self.questionanswer_set.select_related('question') + + def activities_display(self) -> str: + return ', '.join(a.activity.name for a in self.activities) + + activities_display.short_description = _('Activities') + + @cached_property + def form(self) -> 'ServiceForm': + return self.form_revision.form if self.form_revision else None + + def form_display(self) -> str: + return str(self.form) + + form_display.short_description = _('Form') + + def personal_link(self) -> str: + return format_html('{}', + reverse('authenticate_participant_mock', args=(self.pk,)), + self.pk) + + personal_link.short_description = _('Link to personal report') + + @property + def secret_id(self) -> str: + return utils.encode(self.id) + + @property + def list_unsubscribe_link(self) -> str: + return settings.SERVER_URL + reverse('unsubscribe_participant', args=(self.secret_id,)) + + def send_email_to_responsibles(self) -> None: + """ + Go through choices, activities, their categories and send email to responsibles. + + :return: + """ + responsibles = set() + + for pa in self.activities: + if self.last_finished is None or pa.created_at > self.last_finished: + responsibles.update(set(pa.activity.responsibles.all()) | + set(pa.activity.category.responsibles.all()) | + set(pa.activity.category.category.responsibles.all())) + for pc in pa.choices: + if self.last_finished is None or pc.created_at > self.last_finished: + responsibles.update(set(pc.activity_choice.responsibles.all())) + + for q in self.questionanswer_set.all(): + if self.last_finished is None or q.created_at > self.last_finished: + responsibles.update(set(q.question.responsibles.all())) + + for r in responsibles: + r.send_responsibility_email(self) + + def finish(self, email_participant: bool=True) -> None: + updating = self.status == self.STATUS_UPDATING + self.status = self.STATUS_FINISHED + if timezone.now() > self.form_revision.send_emails_after: + self.send_email_to_responsibles() + if email_participant: + self.send_participant_email( + self.EmailIds.ON_UPDATE if updating else self.EmailIds.ON_FINISH) + self.last_finished = timezone.now() + self.save(update_fields=['status', 'last_finished']) + + def send_participant_email(self, event: EmailIds, + extra_context: dict=None) -> 'Optional[EmailMessage]': + """ + Send email to participant + :return: False if email was not sent. Message if it was sent. + """ + if not self.send_email_allowed and event not in self.SEND_ALWAYS_EMAILS: + return + + self.form.create_email_templates() + + emailtemplates = {self.EmailIds.ON_FINISH: self.form.email_to_participant, + self.EmailIds.ON_UPDATE: self.form.email_to_participant_on_update, + self.EmailIds.NEW_FORM_REVISION: self.form.email_to_former_participants, + self.EmailIds.RESEND: self.form.resend_email_to_participant, + self.EmailIds.INVITE: self.form.email_to_invited_users, + self.EmailIds.EMAIL_VERIFICATION: + self.form.verification_email_to_participant, + } + + emailtemplate = emailtemplates[event] + url = (self.make_new_verification_url() + if event == self.EmailIds.EMAIL_VERIFICATION + else self.make_new_auth_url()) + context = { + 'participant': str(self), + 'contact': self.form.responsible.contact_display, + 'form': str(self.form), + 'url': str(url), + 'last_modified': localize(self.last_modified, use_l10n=True), + 'list_unsubscribe': self.list_unsubscribe_link, + } + if extra_context: + context.update(extra_context) + return EmailMessage.make(emailtemplate, context, self.email) + + def resend_auth_link(self) -> 'Optional[EmailMessage]': + return self.send_participant_email(self.EmailIds.RESEND) + + @property + def flow(self) -> List[str]: + from ..urls import participant_flow_urls + + rv = [i.name for i in participant_flow_urls] + if not self.form.questions: + rv.remove('questions') + if not self.form.require_email_verification or self.email_verified: + rv.remove('email_verification') + if self.form.require_email_verification and not self.email: + rv.remove('email_verification') + if not self.form.is_published: + rv = ['contact_details', 'submitted'] + return rv + + def can_access_view(self, view_name: str, auth: bool=False) -> bool: + """ + Access is granted to next view after last finished view + + auth: if query is for authentication (if we can already really proceed to view or not). + """ + if view_name == 'submitted' and not auth: + return False + last = self.flow.index( + self.last_finished_view) if self.last_finished_view in self.flow else -1 + cur = self.flow.index(view_name) if view_name in self.flow else last + 2 + if self.form.flow_by_categories and self.form.allow_skipping_categories: + # In participation view, allow going straight to questions if skipping categories + # is allowed + if self.form.require_email_verification: + if self.last_finished_view == 'email_verification': + last += 1 + elif self.last_finished_view == 'contact_details': + last += 1 + + return cur <= last + 1 + + def proceed_to_view(self, next_view: str) -> None: + if not self.can_access_view(next_view): + _next = self.flow.index(next_view) + self.last_finished_view = self.flow[_next - 1] + self.save(update_fields=['last_finished_view']) + + @property + def next_view_name(self) -> str: + return self.flow[self.flow.index(self._current_view) + 1] + + def redirect_next(self, request: HttpRequest, message: bool=True) -> HttpResponse: + if self.status == self.STATUS_UPDATING and message: + messages.warning(request, _( + 'Updated information has been stored! Please proceed until the end of the form.')) + return HttpResponseRedirect(reverse(self.next_view_name)) + + def redirect_last(self) -> HttpResponse: + last = self.flow.index( + self.last_finished_view) if self.last_finished_view in self.flow else -1 + return HttpResponseRedirect(reverse(self.flow[last + 1])) + + @cached_property + def log(self) -> 'Sequence[ParticipantLog]': + return self.participantlog_set.all() class ParticipationActivity(models.Model): @@ -43,7 +311,7 @@ class Meta: ordering = ( 'activity__category__category__order', 'activity__category__order', 'activity__order',) - participant = models.ForeignKey('serviceform.Participation', on_delete=models.CASCADE) + participant = models.ForeignKey(Participation, on_delete=models.CASCADE) activity = models.ForeignKey('serviceform.Activity', on_delete=models.CASCADE) additional_info = models.CharField(max_length=1024, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True, null=True) @@ -101,4 +369,14 @@ def cached_participant(self) -> 'Participation': return utils.get_participant(self.participant_id) def __str__(self): - return '%s: %s' % (self.question.question, self.answer) \ No newline at end of file + return '%s: %s' % (self.question.question, self.answer) + + +class ParticipantLog(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + participant = models.ForeignKey('serviceform.Participation', on_delete=models.CASCADE) + writer_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + writer_id = models.PositiveIntegerField() + # Can be either responsible or django user + written_by = GenericForeignKey('writer_type', 'writer_id') + message = models.TextField() diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index 9be426c..c69cdb9 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -15,27 +15,18 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . -from enum import Enum -from typing import Union, Iterator, Tuple, Optional, List, Sequence, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from django.conf import settings -from django.contrib import messages from django.db import models -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.urls import reverse -from django.utils import timezone -from django.utils.formats import localize from django.utils.functional import cached_property from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ -from .. import utils -from .mixins import PasswordMixin, ContactDetailsMixin, postalcode_regex, phone_regex from .email import EmailMessage - -if TYPE_CHECKING: - from .participation import ParticipationActivity, QuestionAnswer, ParticipantLog - from .serviceform import ServiceForm +from .mixins import PasswordMixin, postalcode_regex, phone_regex +from .. import utils class Organization(models.Model): @@ -178,269 +169,3 @@ def send_bulk_mail(self) -> 'Optional[EmailMessage]': } return EmailMessage.make(self.form.bulk_email_to_responsibles, context, self.email) - - -class Participation(PasswordMixin, models.Model): - class Meta: - verbose_name = _('Participation') - verbose_name_plural = _('Participants') - - # Current view is set by view decorator require_authenticated_participant - _current_view = 'contact_details' - AUTH_VIEW = 'authenticate_participant_new' - - class EmailIds(Enum): - ON_FINISH = object() - ON_UPDATE = object() - NEW_FORM_REVISION = object() - RESEND = object() - INVITE = object() - EMAIL_VERIFICATION = object() - - SEND_ALWAYS_EMAILS = [EmailIds.RESEND, - EmailIds.EMAIL_VERIFICATION, - EmailIds.ON_FINISH, - EmailIds.ON_UPDATE] - - STATUS_INVITED = 'invited' - STATUS_ONGOING = 'ongoing' - STATUS_UPDATING = 'updating' - STATUS_FINISHED = 'finished' - READY_STATUSES = (STATUS_UPDATING, STATUS_FINISHED) - EDIT_STATUSES = (STATUS_UPDATING, STATUS_ONGOING) - - STATUS_CHOICES = ( - (STATUS_INVITED, _('invited')), - (STATUS_ONGOING, _('ongoing')), - (STATUS_UPDATING, _('updating')), - (STATUS_FINISHED, _('finished'))) - STATUS_DICT = dict(STATUS_CHOICES) - - member = models.ForeignKey(Member, on_delete=models.CASCADE) - #year_of_birth = models.SmallIntegerField(_('Year of birth'), null=True, blank=True) - status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ONGOING) - last_finished_view = models.CharField(max_length=32, default='') - created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) - last_modified = models.DateTimeField(auto_now=True, verbose_name=_('Last modified')) - last_finished = models.DateTimeField(_('Last finished'), null=True) - - # Last form revision - form_revision = models.ForeignKey('serviceform.FormRevision', null=True, - on_delete=models.CASCADE) - - #email_verified = models.BooleanField(_('Email verified'), default=False) - - send_email_allowed = models.BooleanField(_('Sending email allowed'), default=True, help_text=_( - 'You will receive email that contains a link that allows later modification of the form. ' - 'Also when new version of form is published, you will be notified. ' - 'It is highly recommended that you keep this enabled unless you move away ' - 'and do not want to participate at all any more. You can also change this setting later ' - 'if you wish.')) - - @cached_property - def age(self) -> Union[int, 'str']: - return timezone.now().year - self.year_of_birth if self.year_of_birth else '-' - - @property - def is_updating(self) -> bool: - return self.status == self.STATUS_UPDATING - - @property - def contact_details(self) -> Iterator[str]: - yield from super().contact_details - yield _('Year of birth'), self.year_of_birth or '-' - - @property - def additional_data(self) -> Iterator[Tuple[str, str]]: - yield _('Participation created in system'), self.created_at - yield _('Last finished'), self.last_finished - yield _('Last modified'), self.last_modified - yield _('Email address verified'), (_('No'), _('Yes'))[self.email_verified] - yield _('Emails allowed'), (_('No'), _('Yes'))[self.send_email_allowed] - yield _('Form status'), self.STATUS_DICT[self.status] - - @cached_property - def item_count(self) -> int: - from .participation import ParticipationActivityChoice - choices = ParticipationActivityChoice.objects.filter( - activity__participant=self).values_list('activity_id', flat=True) - choice_count = len(choices) - activity_count = self.participationactivity_set.exclude(pk__in=choices).count() - return activity_count + choice_count - - def make_new_verification_url(self) -> str: - return settings.SERVER_URL + reverse('verify_email', - args=(self.pk, self.make_new_password())) - - @cached_property - def activities(self) -> 'Sequence[ParticipationActivity]': - return self.participationactivity_set.select_related('activity') - - @cached_property - def questions(self) -> 'Sequence[QuestionAnswer]': - return self.questionanswer_set.select_related('question') - - def activities_display(self) -> str: - return ', '.join(a.activity.name for a in self.activities) - - activities_display.short_description = _('Activities') - - @cached_property - def form(self) -> 'ServiceForm': - return self.form_revision.form if self.form_revision else None - - def form_display(self) -> str: - return str(self.form) - - form_display.short_description = _('Form') - - def personal_link(self) -> str: - return format_html('{}', - reverse('authenticate_participant_mock', args=(self.pk,)), - self.pk) - - personal_link.short_description = _('Link to personal report') - - @property - def secret_id(self) -> str: - return utils.encode(self.id) - - @property - def list_unsubscribe_link(self) -> str: - return settings.SERVER_URL + reverse('unsubscribe_participant', args=(self.secret_id,)) - - def send_email_to_responsibles(self) -> None: - """ - Go through choices, activities, their categories and send email to responsibles. - - :return: - """ - responsibles = set() - - for pa in self.activities: - if self.last_finished is None or pa.created_at > self.last_finished: - responsibles.update(set(pa.activity.responsibles.all()) | - set(pa.activity.category.responsibles.all()) | - set(pa.activity.category.category.responsibles.all())) - for pc in pa.choices: - if self.last_finished is None or pc.created_at > self.last_finished: - responsibles.update(set(pc.activity_choice.responsibles.all())) - - for q in self.questionanswer_set.all(): - if self.last_finished is None or q.created_at > self.last_finished: - responsibles.update(set(q.question.responsibles.all())) - - for r in responsibles: - r.send_responsibility_email(self) - - def finish(self, email_participant: bool=True) -> None: - updating = self.status == self.STATUS_UPDATING - self.status = self.STATUS_FINISHED - if timezone.now() > self.form_revision.send_emails_after: - self.send_email_to_responsibles() - if email_participant: - self.send_participant_email( - self.EmailIds.ON_UPDATE if updating else self.EmailIds.ON_FINISH) - self.last_finished = timezone.now() - self.save(update_fields=['status', 'last_finished']) - - def send_participant_email(self, event: EmailIds, - extra_context: dict=None) -> 'Optional[EmailMessage]': - """ - Send email to participant - :return: False if email was not sent. Message if it was sent. - """ - if not self.send_email_allowed and event not in self.SEND_ALWAYS_EMAILS: - return - - self.form.create_email_templates() - - emailtemplates = {self.EmailIds.ON_FINISH: self.form.email_to_participant, - self.EmailIds.ON_UPDATE: self.form.email_to_participant_on_update, - self.EmailIds.NEW_FORM_REVISION: self.form.email_to_former_participants, - self.EmailIds.RESEND: self.form.resend_email_to_participant, - self.EmailIds.INVITE: self.form.email_to_invited_users, - self.EmailIds.EMAIL_VERIFICATION: - self.form.verification_email_to_participant, - } - - emailtemplate = emailtemplates[event] - url = (self.make_new_verification_url() - if event == self.EmailIds.EMAIL_VERIFICATION - else self.make_new_auth_url()) - context = { - 'participant': str(self), - 'contact': self.form.responsible.contact_display, - 'form': str(self.form), - 'url': str(url), - 'last_modified': localize(self.last_modified, use_l10n=True), - 'list_unsubscribe': self.list_unsubscribe_link, - } - if extra_context: - context.update(extra_context) - return EmailMessage.make(emailtemplate, context, self.email) - - def resend_auth_link(self) -> 'Optional[EmailMessage]': - return self.send_participant_email(self.EmailIds.RESEND) - - @property - def flow(self) -> List[str]: - from ..urls import participant_flow_urls - - rv = [i.name for i in participant_flow_urls] - if not self.form.questions: - rv.remove('questions') - if not self.form.require_email_verification or self.email_verified: - rv.remove('email_verification') - if self.form.require_email_verification and not self.email: - rv.remove('email_verification') - if not self.form.is_published: - rv = ['contact_details', 'submitted'] - return rv - - def can_access_view(self, view_name: str, auth: bool=False) -> bool: - """ - Access is granted to next view after last finished view - - auth: if query is for authentication (if we can already really proceed to view or not). - """ - if view_name == 'submitted' and not auth: - return False - last = self.flow.index( - self.last_finished_view) if self.last_finished_view in self.flow else -1 - cur = self.flow.index(view_name) if view_name in self.flow else last + 2 - if self.form.flow_by_categories and self.form.allow_skipping_categories: - # In participation view, allow going straight to questions if skipping categories - # is allowed - if self.form.require_email_verification: - if self.last_finished_view == 'email_verification': - last += 1 - elif self.last_finished_view == 'contact_details': - last += 1 - - return cur <= last + 1 - - def proceed_to_view(self, next_view: str) -> None: - if not self.can_access_view(next_view): - _next = self.flow.index(next_view) - self.last_finished_view = self.flow[_next - 1] - self.save(update_fields=['last_finished_view']) - - @property - def next_view_name(self) -> str: - return self.flow[self.flow.index(self._current_view) + 1] - - def redirect_next(self, request: HttpRequest, message: bool=True) -> HttpResponse: - if self.status == self.STATUS_UPDATING and message: - messages.warning(request, _( - 'Updated information has been stored! Please proceed until the end of the form.')) - return HttpResponseRedirect(reverse(self.next_view_name)) - - def redirect_last(self) -> HttpResponse: - last = self.flow.index( - self.last_finished_view) if self.last_finished_view in self.flow else -1 - return HttpResponseRedirect(reverse(self.flow[last + 1])) - - @cached_property - def log(self) -> 'Sequence[ParticipantLog]': - return self.participantlog_set.all() \ No newline at end of file diff --git a/serviceform/serviceform/models/serviceform.py b/serviceform/serviceform/models/serviceform.py index 075578b..6866037 100644 --- a/serviceform/serviceform/models/serviceform.py +++ b/serviceform/serviceform/models/serviceform.py @@ -41,9 +41,9 @@ from ..utils import ColorStr from .mixins import SubitemMixin, NameDescriptionMixin, CopyMixin -from .people import Participation, Member, Organization +from .people import Member, Organization from .email import EmailTemplate -from .participation import QuestionAnswer +from .participation import QuestionAnswer, Participation if TYPE_CHECKING: from .participation import ParticipationActivity, ParticipationActivityChoice From fdd5176752f90dba964b5de7d321a375d7cd6f03 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:06:00 +0300 Subject: [PATCH 03/87] Remove ContactDetailsMixin classes --- serviceform/serviceform/models/__init__.py | 3 +- serviceform/serviceform/models/email.py | 1 + serviceform/serviceform/models/mixins.py | 52 ------------------- .../serviceform/models/participation.py | 1 + serviceform/serviceform/models/people.py | 1 + serviceform/serviceform/models/serviceform.py | 1 + 6 files changed, 5 insertions(+), 54 deletions(-) diff --git a/serviceform/serviceform/models/__init__.py b/serviceform/serviceform/models/__init__.py index 325cc92..51e386f 100644 --- a/serviceform/serviceform/models/__init__.py +++ b/serviceform/serviceform/models/__init__.py @@ -24,5 +24,4 @@ Level2Category, Question, ColorField) -from .mixins import (ContactDetailsMixinEmail, ContactDetailsMixin, CopyMixin, NameDescriptionMixin, - PasswordMixin, SubitemMixin) \ No newline at end of file +from .mixins import (CopyMixin, NameDescriptionMixin, PasswordMixin, SubitemMixin) \ No newline at end of file diff --git a/serviceform/serviceform/models/email.py b/serviceform/serviceform/models/email.py index 20cdf42..9f6904f 100644 --- a/serviceform/serviceform/models/email.py +++ b/serviceform/serviceform/models/email.py @@ -15,6 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . + import json import logging from typing import TYPE_CHECKING diff --git a/serviceform/serviceform/models/mixins.py b/serviceform/serviceform/models/mixins.py index 809f28e..2aa79ef 100644 --- a/serviceform/serviceform/models/mixins.py +++ b/serviceform/serviceform/models/mixins.py @@ -47,58 +47,6 @@ ) -class ContactDetailsMixin(models.Model): - class Meta: - abstract = True - - def __str__(self): - if self.forenames or self.surname: - return '%s %s' % (self.forenames.title(), self.surname.title()) - else: - return self.email - - @property - def address(self): - return ('%s\n%s %s' % ( - self.street_address.title(), self.postal_code, self.city.title())).strip() - - forenames = models.CharField(max_length=64, verbose_name=_('Forename(s)')) - surname = models.CharField(max_length=64, verbose_name=_('Surname')) - street_address = models.CharField(max_length=128, blank=False, - verbose_name=_('Street address')) - postal_code = models.CharField(max_length=32, blank=False, - verbose_name=_('Zip/Postal code'), - validators=[postalcode_regex]) - city = models.CharField(max_length=32, blank=False, verbose_name=_('City')) - email = models.EmailField(blank=False, verbose_name=_('Email'), db_index=True) - phone_number = models.CharField(max_length=32, validators=[phone_regex], blank=False, - verbose_name=_('Phone number')) - - @property - def contact_details(self): - yield _('Name'), '%s %s' % (self.forenames.title(), self.surname.title()) - if self.email: - yield _('Email'), self.email - if self.phone_number: - yield _('Phone number'), self.phone_number - if self.address: - yield _('Address'), '\n' + self.address - - @property - def contact_display(self): - return '\n'.join('%s: %s' % (k, v) for k, v in self.contact_details) - - -class ContactDetailsMixinEmail(ContactDetailsMixin): - class Meta: - abstract = True - -ContactDetailsMixinEmail._meta.get_field('street_address').blank = True -ContactDetailsMixinEmail._meta.get_field('postal_code').blank = True -ContactDetailsMixinEmail._meta.get_field('city').blank = True -ContactDetailsMixinEmail._meta.get_field('phone_number').blank = True - - class NameDescriptionMixin(models.Model): class Meta: abstract = True diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 2ed264e..7a1de5c 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -15,6 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . + from enum import Enum from typing import Sequence, TYPE_CHECKING, Union, Iterator, Tuple, List, Optional diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index c69cdb9..76de305 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -15,6 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . + from typing import Optional, TYPE_CHECKING from django.conf import settings diff --git a/serviceform/serviceform/models/serviceform.py b/serviceform/serviceform/models/serviceform.py index 6866037..4ece087 100644 --- a/serviceform/serviceform/models/serviceform.py +++ b/serviceform/serviceform/models/serviceform.py @@ -15,6 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . + import datetime import string import logging From 4cef5bb486ebcfb0f5bfa29f94d3013b4826ceb3 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:16:53 +0300 Subject: [PATCH 04/87] Fix imports --- serviceform/serviceform/models/people.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index 76de305..cb53250 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -30,6 +30,9 @@ from .. import utils +if TYPE_CHECKING + from .participation import Participation + class Organization(models.Model): name = models.CharField(_('Organization name'), max_length=64) @@ -134,8 +137,6 @@ def list_unsubscribe_link(self) -> str: return settings.SERVER_URL + reverse('unsubscribe_responsible', args=(self.secret_id,)) def resend_auth_link(self) -> 'EmailMessage': - # TODO - raise NotImplementedError context = {'responsible': str(self), 'form': str(self.form), 'url': self.make_new_auth_url(), @@ -145,8 +146,6 @@ def resend_auth_link(self) -> 'EmailMessage': return EmailMessage.make(self.form.email_to_responsible_auth_link, context, self.email) def send_responsibility_email(self, participant: 'Participation') -> None: - # TODO - raise NotImplementedError if self.send_email_notifications: context = {'responsible': str(self), 'participant': str(participant), @@ -159,8 +158,6 @@ def send_responsibility_email(self, participant: 'Participation') -> None: EmailMessage.make(self.form.email_to_responsibles, context, self.email) def send_bulk_mail(self) -> 'Optional[EmailMessage]': - #TODO - raise NotImplementedError if self.send_email_notifications: context = {'responsible': str(self), 'form': str(self.form), From 25089783ea2cfad3a404de4a8bcfe20e889b56e8 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:19:21 +0300 Subject: [PATCH 05/87] Fix imports and others --- serviceform/serviceform/admin.py | 6 ++++++ .../0009_remove_participation_contact_detail_fields.py | 4 ++++ serviceform/serviceform/models/participation.py | 2 +- serviceform/serviceform/models/people.py | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/serviceform/serviceform/admin.py b/serviceform/serviceform/admin.py index e47712c..84c992c 100644 --- a/serviceform/serviceform/admin.py +++ b/serviceform/serviceform/admin.py @@ -346,12 +346,18 @@ def save_related(self, request: HttpRequest, form, formsets, change: bool): return rv + @admin.register(models.EmailMessage) class EmailMessageAdmin(ExtendedLogMixin, admin.ModelAdmin): list_display = ('to_address', 'created_at', 'sent_at', 'subject_display', 'template', 'content_display',) +@admin.register(models.Organization) +class EmailMessageAdmin(ExtendedLogMixin, admin.ModelAdmin): + list_display = ('name',) + + @admin.register(models.Participation) class ParticipantAdmin(ExtendedLogMixin, admin.ModelAdmin): list_display = ( diff --git a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py index 36d18dc..a8edd68 100644 --- a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py +++ b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py @@ -48,4 +48,8 @@ class Migration(migrations.Migration): model_name='participation', name='year_of_birth', ), + migrations.AlterModelOptions( + name='participation', + options={'verbose_name': 'Participation', 'verbose_name_plural': 'Participations'}, + ), ] diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 7a1de5c..43bdb05 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -44,7 +44,7 @@ class Participation(PasswordMixin, models.Model): class Meta: verbose_name = _('Participation') - verbose_name_plural = _('Participants') + verbose_name_plural = _('Participations') # Current view is set by view decorator require_authenticated_participant _current_view = 'contact_details' diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index cb53250..3101acb 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -30,7 +30,7 @@ from .. import utils -if TYPE_CHECKING +if TYPE_CHECKING: from .participation import Participation class Organization(models.Model): From 1e1029964d6fe7ed1a6c0bee2ef5297ec6bad6fc Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:26:59 +0300 Subject: [PATCH 06/87] Copy PasswordMixin content to Member --- serviceform/serviceform/models/mixins.py | 1 + serviceform/serviceform/models/people.py | 55 +++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/serviceform/serviceform/models/mixins.py b/serviceform/serviceform/models/mixins.py index 2aa79ef..febe2c4 100644 --- a/serviceform/serviceform/models/mixins.py +++ b/serviceform/serviceform/models/mixins.py @@ -84,6 +84,7 @@ def has_responsible(self, r: 'Member') -> bool: return r in self._responsibles +#TODO: remove this class PasswordMixin(models.Model): """ New 'password' is generated every time user requests a auth email to be sent diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index 3101acb..df53c44 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -15,15 +15,21 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . - +from enum import Enum from typing import Optional, TYPE_CHECKING +import time + +import datetime from django.conf import settings +from django.contrib.auth.hashers import make_password, check_password +from django.contrib.postgres.fields import JSONField from django.db import models from django.urls import reverse from django.utils.functional import cached_property from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone from .email import EmailMessage from .mixins import PasswordMixin, postalcode_regex, phone_regex @@ -38,6 +44,10 @@ class Organization(models.Model): class Member(PasswordMixin, models.Model): + class PasswordStatus(Enum): + PASSWORD_EXPIRED = object() + PASSWORD_OK = True + PASSWORD_NOK = False MEMBER_EXTERNAL = 'external' MEMBER_NORMAL = 'normal' @@ -48,7 +58,6 @@ class Member(PasswordMixin, models.Model): (MEMBER_STAFF, _('staff')) ) - forenames = models.CharField(max_length=64, verbose_name=_('Forename(s)')) surname = models.CharField(max_length=64, verbose_name=_('Surname')) street_address = models.CharField(max_length=128, blank=True, @@ -74,6 +83,17 @@ class Member(PasswordMixin, models.Model): organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + # New style auth link hash + auth_keys_hash_storage = JSONField(default=[]) # List of (hash, expire) tuples + + # TODO: remove this field (as well as views using it) when all users are having new auth_key_hash set up. + secret_key = models.CharField(max_length=36, default=utils.generate_uuid, db_index=True, + unique=True, + verbose_name=_('Secret key')) + + + + # TODO: this might not be appropriate there any more AUTH_VIEW = 'authenticate_responsible_new' @@ -90,6 +110,37 @@ class Member(PasswordMixin, models.Model): hide_contact_details = models.BooleanField(_('Hide contact details in form'), default=False) show_full_report = models.BooleanField(_('Grant access to full reports'), default=False) + def make_new_password(self) -> str: + valid_hashes = [] + for key, expire in self.auth_keys_hash_storage: + if expire > time.time(): + valid_hashes.append((key, expire)) + + password = utils.generate_uuid() + + auth_key_hash = make_password(password) + auth_key_expire: datetime.datetime = (timezone.now() + + datetime.timedelta(days=getattr(settings, 'AUTH_KEY_EXPIRE_DAYS', 90))) + + valid_hashes.append((auth_key_hash, auth_key_expire.timestamp())) + self.auth_keys_hash_storage = valid_hashes[-getattr(settings, 'AUTH_STORE_KEYS', 10):] + self.save(update_fields=['auth_keys_hash_storage']) + return password + + def make_new_auth_url(self) -> str: + url = settings.SERVER_URL + reverse(self.AUTH_VIEW, args=(self.pk, + self.make_new_password(),)) + return url + + def check_auth_key(self, password: str) -> PasswordStatus: + for key, expire_timestamp in reversed(self.auth_keys_hash_storage): + if check_password(password, key): + if expire_timestamp < time.time(): + return self.PasswordStatus.PASSWORD_EXPIRED + return self.PasswordStatus.PASSWORD_OK + + return self.PasswordStatus.PASSWORD_NOK + def __str__(self): if self.forenames or self.surname: return '%s %s' % (self.forenames.title(), self.surname.title()) From 70d214a146774b503b8ec0eee995bdd51c0d61d3 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:38:48 +0300 Subject: [PATCH 07/87] Migrate auth keys --- .../migrations/0008_add_member_and_organization_fields.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py b/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py index 6b07a3d..1f7ccf8 100644 --- a/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py +++ b/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py @@ -42,7 +42,12 @@ def fill_participation_member(apps, schema_editor): m.email_verified = p.email_verified if p.year_of_birth: m.year_of_birth = p.year_of_birth - + + for a in p.auth_keys_hash_storage: + m.auth_keys_hash_storage.append(a) + + m.auth_keys_hash_storage.sort(key=lambda x: x[1]) + m.save() p.member = m From 0a9b04ebe503435b23287284aa8000bf79c1d6c6 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:42:05 +0300 Subject: [PATCH 08/87] Remove PasswordMixin from Participation --- .../0009_remove_participation_contact_detail_fields.py | 8 ++++++++ serviceform/serviceform/models/participation.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py index a8edd68..49dc22e 100644 --- a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py +++ b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py @@ -52,4 +52,12 @@ class Migration(migrations.Migration): name='participation', options={'verbose_name': 'Participation', 'verbose_name_plural': 'Participations'}, ), + migrations.RemoveField( + model_name='participation', + name='auth_keys_hash_storage', + ), + migrations.RemoveField( + model_name='participation', + name='secret_key', + ), ] diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 43bdb05..44c47a3 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -41,7 +41,7 @@ from .serviceform import ServiceForm -class Participation(PasswordMixin, models.Model): +class Participation(models.Model): class Meta: verbose_name = _('Participation') verbose_name_plural = _('Participations') From 9c98b5e3129dc5d5860bfc045df6c8454795d0f3 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:47:05 +0300 Subject: [PATCH 09/87] Code fixes to Member vs Participation changes --- ...ove_participation_contact_detail_fields.py | 4 ++++ .../serviceform/models/participation.py | 18 --------------- serviceform/serviceform/models/people.py | 22 ++++++++++++++++++- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py index 49dc22e..5c9948a 100644 --- a/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py +++ b/serviceform/serviceform/migrations/0009_remove_participation_contact_detail_fields.py @@ -60,4 +60,8 @@ class Migration(migrations.Migration): model_name='participation', name='secret_key', ), + migrations.RemoveField( + model_name='participation', + name='send_email_allowed', + ), ] diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 44c47a3..118ec7c 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -89,28 +89,10 @@ class EmailIds(Enum): form_revision = models.ForeignKey('serviceform.FormRevision', null=True, on_delete=models.CASCADE) - #email_verified = models.BooleanField(_('Email verified'), default=False) - - send_email_allowed = models.BooleanField(_('Sending email allowed'), default=True, help_text=_( - 'You will receive email that contains a link that allows later modification of the form. ' - 'Also when new version of form is published, you will be notified. ' - 'It is highly recommended that you keep this enabled unless you move away ' - 'and do not want to participate at all any more. You can also change this setting later ' - 'if you wish.')) - - @cached_property - def age(self) -> Union[int, 'str']: - return timezone.now().year - self.year_of_birth if self.year_of_birth else '-' - @property def is_updating(self) -> bool: return self.status == self.STATUS_UPDATING - @property - def contact_details(self) -> Iterator[str]: - yield from super().contact_details - yield _('Year of birth'), self.year_of_birth or '-' - @property def additional_data(self) -> Iterator[Tuple[str, str]]: yield _('Participation created in system'), self.created_at diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index df53c44..226377d 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -15,8 +15,9 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . + from enum import Enum -from typing import Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING, Union, Iterator import time @@ -105,11 +106,30 @@ class PasswordStatus(Enum): 'Send email notifications whenever new participation to administered activities is ' 'registered. Email contains also has a link that allows accessing raport of ' 'administered activities.')) + # TODO help text from participation + #help_text=_( + #'You will receive email that contains a link that allows later modification of the form. ' + #'Also when new version of form is published, you will be notified. ' + #'It is highly recommended that you keep this enabled unless you move away ' + #'and do not want to participate at all any more. You can also change this setting later ' + #'if you wish.') + # TODO: rename: allow_showing_contact_details_in_forms hide_contact_details = models.BooleanField(_('Hide contact details in form'), default=False) show_full_report = models.BooleanField(_('Grant access to full reports'), default=False) + @cached_property + def age(self) -> Union[int, str]: + return timezone.now().year - self.year_of_birth if self.year_of_birth else '-' + + + @property + def contact_details(self) -> Iterator[str]: + yield from super().contact_details + yield _('Year of birth'), self.year_of_birth or '-' + + def make_new_password(self) -> str: valid_hashes = [] for key, expire in self.auth_keys_hash_storage: From 8e86ea8e00a6e0af4b1b5fffe123a9ee557b56d7 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 10:53:20 +0300 Subject: [PATCH 10/87] Code fixes to Member vs Participation changes --- serviceform/serviceform/admin.py | 2 +- .../serviceform/models/participation.py | 26 +++------------ serviceform/serviceform/models/people.py | 33 ++++++++++++++----- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/serviceform/serviceform/admin.py b/serviceform/serviceform/admin.py index 84c992c..ed559c2 100644 --- a/serviceform/serviceform/admin.py +++ b/serviceform/serviceform/admin.py @@ -362,7 +362,7 @@ class EmailMessageAdmin(ExtendedLogMixin, admin.ModelAdmin): class ParticipantAdmin(ExtendedLogMixin, admin.ModelAdmin): list_display = ( 'id', '__str__', 'form_display', 'form_revision', 'status', 'activities_display', - 'created_at', 'last_modified', 'personal_link') + 'created_at', 'last_modified') fields = ('forenames', 'surname') def get_queryset(self, request): diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 118ec7c..17f88fe 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -98,8 +98,9 @@ def additional_data(self) -> Iterator[Tuple[str, str]]: yield _('Participation created in system'), self.created_at yield _('Last finished'), self.last_finished yield _('Last modified'), self.last_modified - yield _('Email address verified'), (_('No'), _('Yes'))[self.email_verified] - yield _('Emails allowed'), (_('No'), _('Yes'))[self.send_email_allowed] + # TODO: these need to be shown in Member view + #yield _('Email address verified'), (_('No'), _('Yes'))[self.email_verified] + #yield _('Emails allowed'), (_('No'), _('Yes'))[self.send_email_allowed] yield _('Form status'), self.STATUS_DICT[self.status] @cached_property @@ -110,10 +111,6 @@ def item_count(self) -> int: activity_count = self.participationactivity_set.exclude(pk__in=choices).count() return activity_count + choice_count - def make_new_verification_url(self) -> str: - return settings.SERVER_URL + reverse('verify_email', - args=(self.pk, self.make_new_password())) - @cached_property def activities(self) -> 'Sequence[ParticipationActivity]': return self.participationactivity_set.select_related('activity') @@ -128,7 +125,7 @@ def activities_display(self) -> str: activities_display.short_description = _('Activities') @cached_property - def form(self) -> 'ServiceForm': + def form(self) -> 'Optional[ServiceForm]': return self.form_revision.form if self.form_revision else None def form_display(self) -> str: @@ -136,21 +133,6 @@ def form_display(self) -> str: form_display.short_description = _('Form') - def personal_link(self) -> str: - return format_html('{}', - reverse('authenticate_participant_mock', args=(self.pk,)), - self.pk) - - personal_link.short_description = _('Link to personal report') - - @property - def secret_id(self) -> str: - return utils.encode(self.id) - - @property - def list_unsubscribe_link(self) -> str: - return settings.SERVER_URL + reverse('unsubscribe_participant', args=(self.secret_id,)) - def send_email_to_responsibles(self) -> None: """ Go through choices, activities, their categories and send email to responsibles. diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index 226377d..ba558e9 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -93,11 +93,6 @@ class PasswordStatus(Enum): verbose_name=_('Secret key')) - - - # TODO: this might not be appropriate there any more - AUTH_VIEW = 'authenticate_responsible_new' - # TODO: rename allow_send_email ? send_email_notifications = models.BooleanField( default=True, @@ -119,17 +114,33 @@ class PasswordStatus(Enum): hide_contact_details = models.BooleanField(_('Hide contact details in form'), default=False) show_full_report = models.BooleanField(_('Grant access to full reports'), default=False) + # TODO change view name + def personal_link(self) -> str: + return format_html('{}', + reverse('authenticate_participant_mock', args=(self.pk,)), + self.pk) + + personal_link.short_description = _('Link to personal report') + + @property + def secret_id(self) -> str: + return utils.encode(self.id) + + # TODO: change view name + @property + def list_unsubscribe_link(self) -> str: + return settings.SERVER_URL + reverse('unsubscribe_participant', args=(self.secret_id,)) + + @cached_property def age(self) -> Union[int, str]: return timezone.now().year - self.year_of_birth if self.year_of_birth else '-' - @property def contact_details(self) -> Iterator[str]: yield from super().contact_details yield _('Year of birth'), self.year_of_birth or '-' - def make_new_password(self) -> str: valid_hashes = [] for key, expire in self.auth_keys_hash_storage: @@ -148,10 +159,14 @@ def make_new_password(self) -> str: return password def make_new_auth_url(self) -> str: - url = settings.SERVER_URL + reverse(self.AUTH_VIEW, args=(self.pk, - self.make_new_password(),)) + url = settings.SERVER_URL + reverse('authenticate_responsible_new', args=(self.pk, + self.make_new_password(),)) return url + def make_new_verification_url(self) -> str: + return settings.SERVER_URL + reverse('verify_email', + args=(self.pk, self.make_new_password())) + def check_auth_key(self, password: str) -> PasswordStatus: for key, expire_timestamp in reversed(self.auth_keys_hash_storage): if check_password(password, key): From 73f789f3fc2bd0953c9f599db6903873359ee685 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 11:08:27 +0300 Subject: [PATCH 11/87] Allow emails booleanfields in Member --- serviceform/serviceform/forms.py | 4 ++-- ...0008_add_member_and_organization_fields.py | 14 +++++++++++- .../serviceform/models/participation.py | 1 - serviceform/serviceform/models/people.py | 22 +++++++++++-------- tests/test_views.py | 6 ++--- 5 files changed, 31 insertions(+), 16 deletions(-) diff --git a/serviceform/serviceform/forms.py b/serviceform/serviceform/forms.py index 441eaa3..81e2df9 100644 --- a/serviceform/serviceform/forms.py +++ b/serviceform/serviceform/forms.py @@ -182,7 +182,7 @@ class ContactForm(ModelForm): class Meta: model = models.Member fields = ('forenames', 'surname', 'year_of_birth', 'street_address', - 'postal_code', 'city', 'email', 'phone_number', 'send_email_notifications') + 'postal_code', 'city', 'email', 'phone_number', "allow_participant_email") def __init__(self, *args, user: 'AbstractUser'=None, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -270,7 +270,7 @@ class ResponsibleForm(ModelForm): class Meta: model = models.Member fields = ('forenames', 'surname', 'street_address', - 'postal_code', 'city', 'email', 'phone_number', 'send_email_notifications') + 'postal_code', 'city', 'email', 'phone_number', "allow_responsible_email") def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) diff --git a/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py b/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py index 1f7ccf8..03d75a8 100644 --- a/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py +++ b/serviceform/serviceform/migrations/0008_add_member_and_organization_fields.py @@ -37,7 +37,7 @@ def fill_participation_member(apps, schema_editor): m.membership_type = 'external' m.organization = p.form_revision.form.organization - m.send_email_notifications = p.send_email_allowed + m.allow_participant_email = p.send_email_allowed m.email_verified = p.email_verified if p.year_of_birth: @@ -86,6 +86,18 @@ class Migration(migrations.Migration): name='year_of_birth', field=models.SmallIntegerField(blank=True, null=True, verbose_name='Year of birth'), ), + migrations.RenameField( + model_name='member', + old_name='send_email_notifications', + new_name='allow_responsible_email', + ), + migrations.AddField( + model_name='member', + name='allow_participant_email', + field=models.BooleanField(default=True, + help_text='You will receive email that contains a link that allows later modification of the form. Also when new version of form is published, you will be notified. It is highly recommended that you keep this enabled unless you move away and do not want to participate at all any more. You can also change this setting later if you wish.', + verbose_name='Send email notifications'), + ), migrations.RunPython(fill_serviceform_organization, null), migrations.RunPython(fill_participation_member, null), migrations.AlterField( diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index 17f88fe..c43468c 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -48,7 +48,6 @@ class Meta: # Current view is set by view decorator require_authenticated_participant _current_view = 'contact_details' - AUTH_VIEW = 'authenticate_participant_new' class EmailIds(Enum): ON_FINISH = object() diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index ba558e9..e2441a2 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -94,7 +94,7 @@ class PasswordStatus(Enum): # TODO: rename allow_send_email ? - send_email_notifications = models.BooleanField( + allow_responsible_email = models.BooleanField( default=True, verbose_name=_('Send email notifications'), help_text=_( @@ -102,12 +102,16 @@ class PasswordStatus(Enum): 'registered. Email contains also has a link that allows accessing raport of ' 'administered activities.')) # TODO help text from participation - #help_text=_( - #'You will receive email that contains a link that allows later modification of the form. ' - #'Also when new version of form is published, you will be notified. ' - #'It is highly recommended that you keep this enabled unless you move away ' - #'and do not want to participate at all any more. You can also change this setting later ' - #'if you wish.') + + allow_participant_email = models.BooleanField( + default=True, + verbose_name=_('Send email notifications'), + help_text=_( + 'You will receive email that contains a link that allows later modification of the form. ' + 'Also when new version of form is published, you will be notified. ' + 'It is highly recommended that you keep this enabled unless you move away ' + 'and do not want to participate at all any more. You can also change this setting later ' + 'if you wish.')) # TODO: rename: allow_showing_contact_details_in_forms @@ -232,7 +236,7 @@ def resend_auth_link(self) -> 'EmailMessage': return EmailMessage.make(self.form.email_to_responsible_auth_link, context, self.email) def send_responsibility_email(self, participant: 'Participation') -> None: - if self.send_email_notifications: + if self.allow_responsible_email: context = {'responsible': str(self), 'participant': str(participant), 'form': str(self.form), @@ -244,7 +248,7 @@ def send_responsibility_email(self, participant: 'Participation') -> None: EmailMessage.make(self.form.email_to_responsibles, context, self.email) def send_bulk_mail(self) -> 'Optional[EmailMessage]': - if self.send_email_notifications: + if self.allow_responsible_email: context = {'responsible': str(self), 'form': str(self.form), 'url': self.make_new_auth_url(), diff --git a/tests/test_views.py b/tests/test_views.py index dd6684b..64625cc 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -233,7 +233,7 @@ def check_responsible_reports(emails: QuerySet, resps: List[models.Member], num_ _no_email_hit = False for r in resps: - if not r.send_email_notifications: + if not r.allow_responsible_email: _no_email_hit = True continue @@ -664,11 +664,11 @@ def test_unsubscribe_participant(client: Client, participant: models.Participati def test_unsubscribe_responsible(client: Client, responsible: models.Member): from serviceform.serviceform.utils import encode - assert responsible.send_email_notifications + assert responsible.allow_responsible_email res = client.get(Pages.UNSUBSCRIBE_RESPONSIBLE % encode(responsible.pk)) assert res.status_code == Http.OK responsible.refresh_from_db() - assert not responsible.send_email_notifications + assert not responsible.allow_responsible_email # TODO: From 03afaf4c9070271a512ee202a95f71cbbbebcb1c Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 11:13:37 +0300 Subject: [PATCH 12/87] Remove PasswordMixing etc. --- serviceform/serviceform/models/__init__.py | 2 +- serviceform/serviceform/models/mixins.py | 70 +------------------ .../serviceform/models/participation.py | 18 ++--- serviceform/serviceform/models/people.py | 6 +- 4 files changed, 12 insertions(+), 84 deletions(-) diff --git a/serviceform/serviceform/models/__init__.py b/serviceform/serviceform/models/__init__.py index 51e386f..47a21fb 100644 --- a/serviceform/serviceform/models/__init__.py +++ b/serviceform/serviceform/models/__init__.py @@ -24,4 +24,4 @@ Level2Category, Question, ColorField) -from .mixins import (CopyMixin, NameDescriptionMixin, PasswordMixin, SubitemMixin) \ No newline at end of file +from .mixins import (CopyMixin, NameDescriptionMixin, SubitemMixin) \ No newline at end of file diff --git a/serviceform/serviceform/models/mixins.py b/serviceform/serviceform/models/mixins.py index febe2c4..3e04e36 100644 --- a/serviceform/serviceform/models/mixins.py +++ b/serviceform/serviceform/models/mixins.py @@ -15,27 +15,18 @@ # # You should have received a copy of the GNU General Public License # along with Serviceform. If not, see . -import datetime -import time -from enum import Enum + from typing import TYPE_CHECKING -from django.conf import settings -from django.contrib.auth.hashers import make_password, check_password -from django.contrib.postgres.fields import JSONField from django.core.validators import RegexValidator from django.db import models from django.db.models.options import Options -from django.urls import reverse -from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ if TYPE_CHECKING: from .people import Member -from .. import utils - phone_regex = RegexValidator(regex=r'^\+?1?\d{9,15}$', message=_("Phone number must be entered in the format: " "'050123123' or '+35850123123'. " @@ -83,62 +74,3 @@ def sub_items(self): def has_responsible(self, r: 'Member') -> bool: return r in self._responsibles - -#TODO: remove this -class PasswordMixin(models.Model): - """ - New 'password' is generated every time user requests a auth email to be sent - to him. Password will expire after AUTH_KEY_EXPIRE_DAYS. We will store - AUTH_STORE_KEYS number of most recent keys in a json storage. - """ - - AUTH_VIEW: str - - class Meta: - abstract = True - - class PasswordStatus(Enum): - PASSWORD_EXPIRED = object() - PASSWORD_OK = True - PASSWORD_NOK = False - - # New style auth link hash - auth_keys_hash_storage = JSONField(default=[]) # List of (hash, expire) tuples - - # TODO: remove this field (as well as views using it) when all users are having new auth_key_hash set up. - secret_key = models.CharField(max_length=36, default=utils.generate_uuid, db_index=True, - unique=True, - verbose_name=_('Secret key')) - - def make_new_password(self) -> str: - valid_hashes = [] - for key, expire in self.auth_keys_hash_storage: - if expire > time.time(): - valid_hashes.append((key, expire)) - - password = utils.generate_uuid() - - auth_key_hash = make_password(password) - auth_key_expire: datetime.datetime = (timezone.now() + - datetime.timedelta(days=getattr(settings, 'AUTH_KEY_EXPIRE_DAYS', 90))) - - valid_hashes.append((auth_key_hash, auth_key_expire.timestamp())) - self.auth_keys_hash_storage = valid_hashes[-getattr(settings, 'AUTH_STORE_KEYS', 10):] - self.save(update_fields=['auth_keys_hash_storage']) - return password - - def make_new_auth_url(self) -> str: - url = settings.SERVER_URL + reverse(self.AUTH_VIEW, args=(self.pk, - self.make_new_password(),)) - return url - - def check_auth_key(self, password: str) -> PasswordStatus: - for key, expire_timestamp in reversed(self.auth_keys_hash_storage): - if check_password(password, key): - if expire_timestamp < time.time(): - return self.PasswordStatus.PASSWORD_EXPIRED - return self.PasswordStatus.PASSWORD_OK - - return self.PasswordStatus.PASSWORD_NOK - - diff --git a/serviceform/serviceform/models/participation.py b/serviceform/serviceform/models/participation.py index c43468c..409833b 100644 --- a/serviceform/serviceform/models/participation.py +++ b/serviceform/serviceform/models/participation.py @@ -19,7 +19,6 @@ from enum import Enum from typing import Sequence, TYPE_CHECKING, Union, Iterator, Tuple, List, Optional -from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.fields import GenericForeignKey @@ -30,10 +29,8 @@ from django.utils import timezone from django.utils.formats import localize from django.utils.functional import cached_property -from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ -from .mixins import PasswordMixin from .. import utils if TYPE_CHECKING: @@ -77,7 +74,6 @@ class EmailIds(Enum): STATUS_DICT = dict(STATUS_CHOICES) member = models.ForeignKey('serviceform.Member', on_delete=models.CASCADE) - #year_of_birth = models.SmallIntegerField(_('Year of birth'), null=True, blank=True) status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ONGOING) last_finished_view = models.CharField(max_length=32, default='') created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) @@ -173,7 +169,7 @@ def send_participant_email(self, event: EmailIds, Send email to participant :return: False if email was not sent. Message if it was sent. """ - if not self.send_email_allowed and event not in self.SEND_ALWAYS_EMAILS: + if not self.member.allow_participant_email and event not in self.SEND_ALWAYS_EMAILS: return self.form.create_email_templates() @@ -188,20 +184,20 @@ def send_participant_email(self, event: EmailIds, } emailtemplate = emailtemplates[event] - url = (self.make_new_verification_url() + url = (self.member.make_new_verification_url() if event == self.EmailIds.EMAIL_VERIFICATION - else self.make_new_auth_url()) + else self.member.make_new_auth_url()) context = { 'participant': str(self), 'contact': self.form.responsible.contact_display, 'form': str(self.form), 'url': str(url), 'last_modified': localize(self.last_modified, use_l10n=True), - 'list_unsubscribe': self.list_unsubscribe_link, + 'list_unsubscribe': self.member.list_unsubscribe_link, } if extra_context: context.update(extra_context) - return EmailMessage.make(emailtemplate, context, self.email) + return EmailMessage.make(emailtemplate, context, self.member.email) def resend_auth_link(self) -> 'Optional[EmailMessage]': return self.send_participant_email(self.EmailIds.RESEND) @@ -213,9 +209,9 @@ def flow(self) -> List[str]: rv = [i.name for i in participant_flow_urls] if not self.form.questions: rv.remove('questions') - if not self.form.require_email_verification or self.email_verified: + if not self.form.require_email_verification or self.member.email_verified: rv.remove('email_verification') - if self.form.require_email_verification and not self.email: + if self.form.require_email_verification and not self.member.email: rv.remove('email_verification') if not self.form.is_published: rv = ['contact_details', 'submitted'] diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index e2441a2..083415a 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -33,7 +33,7 @@ from django.utils import timezone from .email import EmailMessage -from .mixins import PasswordMixin, postalcode_regex, phone_regex +from .mixins import postalcode_regex, phone_regex from .. import utils @@ -44,7 +44,7 @@ class Organization(models.Model): name = models.CharField(_('Organization name'), max_length=64) -class Member(PasswordMixin, models.Model): +class Member(models.Model): class PasswordStatus(Enum): PASSWORD_EXPIRED = object() PASSWORD_OK = True @@ -221,7 +221,7 @@ def personal_link(self) -> str: def secret_id(self) -> str: return utils.encode(self.id) - # TODO: common unsubscribe + # TODO: common unsubscribe -- rename view @property def list_unsubscribe_link(self) -> str: return settings.SERVER_URL + reverse('unsubscribe_responsible', args=(self.secret_id,)) From ab2801598985c2e645ec8d3bb0c70b7a83281dc0 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 11:19:00 +0300 Subject: [PATCH 13/87] Add Members to Organization in admin --- serviceform/serviceform/admin.py | 23 ++++++++++++----------- serviceform/serviceform/models/people.py | 4 +--- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/serviceform/serviceform/admin.py b/serviceform/serviceform/admin.py index ed559c2..52661b5 100644 --- a/serviceform/serviceform/admin.py +++ b/serviceform/serviceform/admin.py @@ -190,15 +190,6 @@ class RevisionInline(NestedStackedInline): extra = 0 -#class ResponsibilityPersonInline(NestedStackedInline): -# model = models.Member -# extra = 0 -# fields = (('forenames', 'surname'), ('email', 'phone_number'), 'street_address', -# ('postal_code', 'city'), 'send_email_notifications', 'hide_contact_details', -# 'show_full_report', 'personal_link') -# readonly_fields = ('personal_link',) - - @admin.register(models.ServiceForm) class ServiceFormAdmin(OwnerSaveMixin, ExtendedLogMixin, NestedModelAdminMixin, GuardedModelAdminMixin, admin.ModelAdmin): @@ -346,16 +337,26 @@ def save_related(self, request: HttpRequest, form, formsets, change: bool): return rv - @admin.register(models.EmailMessage) class EmailMessageAdmin(ExtendedLogMixin, admin.ModelAdmin): list_display = ('to_address', 'created_at', 'sent_at', 'subject_display', 'template', 'content_display',) +class MemberInline(NestedStackedInline): + model = models.Member + extra = 0 + fields = (('forenames', 'surname'), ('email', 'phone_number'), 'street_address', + ('postal_code', 'city'), 'allow_responsible_email', + 'allow_participant_email', 'hide_contact_details', + 'show_full_report', 'personal_link') + readonly_fields = ('personal_link',) + + @admin.register(models.Organization) -class EmailMessageAdmin(ExtendedLogMixin, admin.ModelAdmin): +class OrganizationAdmin(ExtendedLogMixin, admin.ModelAdmin): list_display = ('name',) + inlines = [MemberInline] @admin.register(models.Participation) diff --git a/serviceform/serviceform/models/people.py b/serviceform/serviceform/models/people.py index 083415a..0dca840 100644 --- a/serviceform/serviceform/models/people.py +++ b/serviceform/serviceform/models/people.py @@ -93,7 +93,6 @@ class PasswordStatus(Enum): verbose_name=_('Secret key')) - # TODO: rename allow_send_email ? allow_responsible_email = models.BooleanField( default=True, verbose_name=_('Send email notifications'), @@ -101,7 +100,6 @@ class PasswordStatus(Enum): 'Send email notifications whenever new participation to administered activities is ' 'registered. Email contains also has a link that allows accessing raport of ' 'administered activities.')) - # TODO help text from participation allow_participant_email = models.BooleanField( default=True, @@ -114,7 +112,7 @@ class PasswordStatus(Enum): 'if you wish.')) - # TODO: rename: allow_showing_contact_details_in_forms + # TODO: rename: allow_showing_contact_details_in_forms etc. hide_contact_details = models.BooleanField(_('Hide contact details in form'), default=False) show_full_report = models.BooleanField(_('Grant access to full reports'), default=False) From af8f5453179b8a1acd5399fb7a1924ab75bc7715 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 11:49:34 +0300 Subject: [PATCH 14/87] Rename member related fields --- .../0010_rename_member_related_fields.py | 41 +++++++++++++++++++ serviceform/serviceform/models/serviceform.py | 6 +-- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 serviceform/serviceform/migrations/0010_rename_member_related_fields.py diff --git a/serviceform/serviceform/migrations/0010_rename_member_related_fields.py b/serviceform/serviceform/migrations/0010_rename_member_related_fields.py new file mode 100644 index 0000000..4763336 --- /dev/null +++ b/serviceform/serviceform/migrations/0010_rename_member_related_fields.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-25 08:47 +from __future__ import unicode_literals + +from django.db import migrations +import select2.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceform', '0009_remove_participation_contact_detail_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='responsibles', + field=select2.fields.ManyToManyField(blank=True, related_name='activity_responsibles', sorted=False, to='serviceform.Member', verbose_name='Responsible persons'), + ), + migrations.AlterField( + model_name='activitychoice', + name='responsibles', + field=select2.fields.ManyToManyField(blank=True, related_name='activitychoice_responsibles', sorted=False, to='serviceform.Member', verbose_name='Responsible persons'), + ), + migrations.AlterField( + model_name='level1category', + name='responsibles', + field=select2.fields.ManyToManyField(blank=True, related_name='level1category_responsibles', sorted=False, to='serviceform.Member', verbose_name='Responsible persons'), + ), + migrations.AlterField( + model_name='level2category', + name='responsibles', + field=select2.fields.ManyToManyField(blank=True, related_name='level2category_responsibles', sorted=False, to='serviceform.Member', verbose_name='Responsible persons'), + ), + migrations.AlterField( + model_name='question', + name='responsibles', + field=select2.fields.ManyToManyField(blank=True, related_name='question_responsibles', sorted=False, to='serviceform.Member', verbose_name='Responsible persons'), + ), + ] diff --git a/serviceform/serviceform/models/serviceform.py b/serviceform/serviceform/models/serviceform.py index 4ece087..03ab2e8 100644 --- a/serviceform/serviceform/models/serviceform.py +++ b/serviceform/serviceform/models/serviceform.py @@ -427,7 +427,7 @@ def links(self) -> Tuple[str]: def participation_count(self) -> str: if self.current_revision: old_time = timezone.now() - datetime.timedelta(minutes=20) - ready = self.current_revision.participant_set.filter( + ready = self.current_revision.participation_set.filter( status__in=Participation.READY_STATUSES) recent_ongoing = self.current_revision.participant_set.filter( status__in=[Participation.STATUS_ONGOING], @@ -447,7 +447,7 @@ def bulk_email_responsibles(self) -> None: def bulk_email_former_participants(self) -> None: logger.info('Bulk email former participants %s', self) - for p in Participation.objects.filter(send_email_allowed=True, + for p in Participation.objects.filter(member__allow_participant_email=True, form_revision__send_bulk_email_to_participants=True, form_revision__form=self, form_revision__valid_to__lt=timezone.now()).distinct(): @@ -482,7 +482,7 @@ class Meta: verbose_name=_('Order')) responsibles = select2_fields.ManyToManyField(Member, blank=True, verbose_name=_('Responsible persons'), - related_name='%(class)s_related', + related_name='%(class)s_responsibles', overlay=_('Choose responsibles'), ) From 392cfc3c3e227f218fb6fc61c913f574e2d791f5 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 13:37:12 +0300 Subject: [PATCH 15/87] Some refactoring --- serviceform/serviceform/views/decorators.py | 28 +++++++------ serviceform/serviceform/views/login_views.py | 4 +- .../serviceform/views/participation_views.py | 40 +++++++++---------- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/serviceform/serviceform/views/decorators.py b/serviceform/serviceform/views/decorators.py index 6d7857e..7daeea2 100644 --- a/serviceform/serviceform/views/decorators.py +++ b/serviceform/serviceform/views/decorators.py @@ -65,32 +65,36 @@ def wrapper(request: HttpRequest, *args, **kwargs) -> HttpResponse: return wrapper -def require_authenticated_participant(function=None, check_flow=True): +def require_authenticated_participation(function=None, check_flow=True, accept_anonymous=False): def actual_decorator(func): @wraps(func) def wrapper(request: HttpRequest, *args, title: str='', **kwargs) -> HttpResponse: current_view = request.resolver_match.view_name - participant_pk = request.session.get('authenticated_participant') - if participant_pk: - request.participant = participant = get_object_or_404( + # TODO: rename key + participation_pk = request.session.get('authenticated_participant') + if participation_pk: + # TODO: rename request.participant + request.participant = participation = get_object_or_404( models.Participation.objects.all(), - pk=participant_pk, + pk=participation_pk, status__in=models.Participation.EDIT_STATUSES) if check_flow: # Check flow status - participant._current_view = current_view - if not participant.can_access_view(current_view, auth=True): - return participant.redirect_last() - rv = func(request, participant, *args, **kwargs) + participation._current_view = current_view + if not participation.can_access_view(current_view, auth=True): + return participation.redirect_last() + rv = func(request, participation, *args, **kwargs) if isinstance(rv, HttpResponseRedirect): url = resolve(rv.url) next_view = url.view_name - participant.proceed_to_view(next_view) + participation.proceed_to_view(next_view) return rv else: - return func(request, participant, *args, **kwargs) - + return func(request, participation, *args, **kwargs) + # TODO: rename key + if request.session.get('anonymous_participant'): + return func(request, None, *args, **kwargs) else: raise PermissionDenied diff --git a/serviceform/serviceform/views/login_views.py b/serviceform/serviceform/views/login_views.py index cc03d81..0e218a2 100644 --- a/serviceform/serviceform/views/login_views.py +++ b/serviceform/serviceform/views/login_views.py @@ -33,9 +33,7 @@ def password_login(request: HttpRequest, service_form: models.ServiceForm) -> Ht if request.method == 'POST': password_form = forms.PasswordForm(service_form, request.POST) if password_form.is_valid(): - participant = models.Participation.objects.create( - form_revision=service_form.current_revision) - request.session['authenticated_participant'] = participant.pk + request.session['anonymous_participant'] = True return HttpResponseRedirect(reverse('contact_details')) return render(request, 'serviceform/login/password_login.html', diff --git a/serviceform/serviceform/views/participation_views.py b/serviceform/serviceform/views/participation_views.py index a85e9cd..3a17da7 100644 --- a/serviceform/serviceform/views/participation_views.py +++ b/serviceform/serviceform/views/participation_views.py @@ -28,37 +28,37 @@ from .. import forms, models from ..utils import clean_session, user_has_serviceform_permission, expire_auth_link, decode -from .decorators import require_authenticated_participant, require_published_form +from .decorators import require_authenticated_participation, require_published_form logger = logging.getLogger(__name__) -@require_authenticated_participant -def contact_details(request: HttpRequest, participant: models.Participation) -> HttpResponse: - if participant and participant.status == models.Participation.STATUS_FINISHED: +@require_authenticated_participation(accept_anonymous=True) +def contact_details(request: HttpRequest, participation: models.Participation) -> HttpResponse: + if participation and participation.status == models.Participation.STATUS_FINISHED: return HttpResponseRedirect(reverse('submitted')) - form = forms.ContactForm(instance=participant, user=request.user) + form = forms.ContactForm(instance=participation, user=request.user) if request.method == 'POST': - form = forms.ContactForm(request.POST, instance=participant, user=request.user) + form = forms.ContactForm(request.POST, instance=participation, user=request.user) if form.is_valid(): - participant = form.save() - if participant.form.is_published: - return participant.redirect_next(request) + participation = form.save() + if participation.form.is_published: + return participation.redirect_next(request) else: - participant.status = models.Participation.STATUS_FINISHED - participant.save(update_fields=['status']) + participation.status = models.Participation.STATUS_FINISHED + participation.save(update_fields=['status']) return HttpResponseRedirect(reverse('submitted')) return render(request, 'serviceform/participation/contact_view.html', {'form': form, - 'participant': participant, - 'service_form': participant.form, + 'participant': participation, + 'service_form': participation.form, 'bootstrap_checkbox_disabled': True}) -@require_authenticated_participant +@require_authenticated_participation @require_published_form def email_verification(request: HttpRequest, participant: models.Participation) -> HttpResponse: service_form = participant.form @@ -75,7 +75,7 @@ def email_verification(request: HttpRequest, participant: models.Participation) 'bootstrap_checkbox_disabled': True}) -@require_authenticated_participant +@require_authenticated_participation @require_published_form def participation(request: HttpRequest, participant: models.Participation, cat_num: int) -> HttpResponse: @@ -114,7 +114,7 @@ def participation(request: HttpRequest, participant: models.Participation, 'max_cat': max_cat}) -@require_authenticated_participant +@require_authenticated_participation @require_published_form def questions(request: HttpRequest, participant: models.Participation) -> HttpResponse: if not participant.form.questions: @@ -132,7 +132,7 @@ def questions(request: HttpRequest, participant: models.Participation) -> HttpRe {'form': form, 'service_form': participant.form}) -@require_authenticated_participant +@require_authenticated_participation @require_published_form def preview(request: HttpRequest, participant: models.Participation) -> HttpResponse: if request.method == 'POST' and 'submit' in request.POST: @@ -142,7 +142,7 @@ def preview(request: HttpRequest, participant: models.Participation) -> HttpResp {'service_form': participant.form, 'participant': participant}) -@require_authenticated_participant +@require_authenticated_participation def submitted(request: HttpRequest, participant: models.Participation) -> HttpResponse: participant.finish() clean_session(request) @@ -150,7 +150,7 @@ def submitted(request: HttpRequest, participant: models.Participation) -> HttpRe {'service_form': participant.form, 'participant': participant}) -@require_authenticated_participant(check_flow=False) +@require_authenticated_participation(check_flow=False) def send_auth_link(request: HttpRequest, participant: models.Participation, email: str) -> HttpResponse: if not email: @@ -220,7 +220,7 @@ def authenticate_participant_mock(request: HttpRequest, participant_id: int, return auth_participant_common(request, participant, next_view, email_verified=False) -@require_authenticated_participant(check_flow=False) +@require_authenticated_participation(check_flow=False) def delete_participation(request: HttpRequest, participant: models.Participation) -> HttpResponse: form = forms.DeleteParticipationForm() service_form = participant.form From cbfda2b21c638fbc7a3d5b694491ab6d374ab500 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 13:54:10 +0300 Subject: [PATCH 16/87] Some work on participation login but I'll leave it alone now for a while. Needs rethinking --- .../serviceform/views/participation_views.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/serviceform/serviceform/views/participation_views.py b/serviceform/serviceform/views/participation_views.py index 3a17da7..f582614 100644 --- a/serviceform/serviceform/views/participation_views.py +++ b/serviceform/serviceform/views/participation_views.py @@ -18,6 +18,8 @@ import logging +from typing import Optional + from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -34,16 +36,22 @@ @require_authenticated_participation(accept_anonymous=True) -def contact_details(request: HttpRequest, participation: models.Participation) -> HttpResponse: +def contact_details(request: HttpRequest, + participation: Optional[models.Participation]) -> HttpResponse: if participation and participation.status == models.Participation.STATUS_FINISHED: return HttpResponseRedirect(reverse('submitted')) - form = forms.ContactForm(instance=participation, user=request.user) + if participation: + member = participation.member + else: + member = None + + form = forms.ContactForm(instance=member, user=request.user) if request.method == 'POST': - form = forms.ContactForm(request.POST, instance=participation, user=request.user) + form = forms.ContactForm(request.POST, instance=member, user=request.user) if form.is_valid(): - participation = form.save() + member = form.save() if participation.form.is_published: return participation.redirect_next(request) else: From 4b1d969ec211630352d9e001be3fbee2ef510dfa Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Thu, 25 May 2017 13:59:31 +0300 Subject: [PATCH 17/87] Fix admin --- serviceform/serviceform/admin.py | 2 +- serviceform/serviceform/models/serviceform.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/serviceform/serviceform/admin.py b/serviceform/serviceform/admin.py index 52661b5..a04aff7 100644 --- a/serviceform/serviceform/admin.py +++ b/serviceform/serviceform/admin.py @@ -302,7 +302,7 @@ def get_form(self, request: HttpRequest, obj: models.ServiceForm=None, **kwargs) form = super().get_form(request, obj, **kwargs) if obj: request._responsibles = responsibles = models.Member.objects.filter( - form=obj) + organization_id=obj.organization_id) form.base_fields['responsible'].queryset = responsibles form.base_fields['current_revision'].queryset = models.FormRevision.objects.filter( form=obj) diff --git a/serviceform/serviceform/models/serviceform.py b/serviceform/serviceform/models/serviceform.py index 03ab2e8..50d55f3 100644 --- a/serviceform/serviceform/models/serviceform.py +++ b/serviceform/serviceform/models/serviceform.py @@ -429,7 +429,7 @@ def participation_count(self) -> str: old_time = timezone.now() - datetime.timedelta(minutes=20) ready = self.current_revision.participation_set.filter( status__in=Participation.READY_STATUSES) - recent_ongoing = self.current_revision.participant_set.filter( + recent_ongoing = self.current_revision.participation_set.filter( status__in=[Participation.STATUS_ONGOING], last_modified__gt=old_time) From c6b5b6125ed34d82288fa2f46fff836ce100c2c0 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Fri, 26 May 2017 11:21:17 +0300 Subject: [PATCH 18/87] Fix reports / all responsibles view. Cache responsibles. --- serviceform/serviceform/models/serviceform.py | 28 ++++++++++-- .../reports/contents/_all_responsibles.html | 2 +- serviceform/serviceform/utils.py | 45 +++++++++++++++++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/serviceform/serviceform/models/serviceform.py b/serviceform/serviceform/models/serviceform.py index 50d55f3..5b21df0 100644 --- a/serviceform/serviceform/models/serviceform.py +++ b/serviceform/serviceform/models/serviceform.py @@ -20,13 +20,15 @@ import string import logging from enum import Enum -from typing import Tuple, Set, Optional, Sequence, Iterator, Iterable, TYPE_CHECKING +from typing import Tuple, Set, Optional, Sequence, Iterator, Iterable, TYPE_CHECKING, List from colorful.fields import RGBColorField from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.db import models from django.db.models import Prefetch +from django.db.models.signals import post_save +from django.dispatch import receiver from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone @@ -39,7 +41,7 @@ from serviceform.tasks.models import Task from .. import emails, utils -from ..utils import ColorStr +from ..utils import ColorStr, django_cache, invalidate_cache from .mixins import SubitemMixin, NameDescriptionMixin, CopyMixin from .people import Member, Organization @@ -261,6 +263,21 @@ def can_access(self) -> str: can_access.short_description = _('Can access') + @django_cache('all_responsibles') + def all_responsibles(self) -> List[Member]: + rs = {self.responsible} + for cat1 in self.sub_items: + rs.update(cat1.responsibles.all()) + for cat2 in cat1.sub_items: + rs.update(cat2.responsibles.all()) + for act in cat2.sub_items: + rs.update(act.responsibles.all()) + for choice in act.sub_items: + rs.update(choice.responsibles.all()) + rs = list(rs) + rs.sort(key=lambda x: (x.surname, x.forenames)) + return rs + @cached_property def sub_items(self) -> 'Sequence[AbstractServiceFormItem]': lvl2s = Prefetch('level2category_set', @@ -442,7 +459,7 @@ def participation_count(self) -> str: def bulk_email_responsibles(self) -> None: logger.info('Bulk email responsibles %s', self) - for r in self.responsibilityperson_set.all(): + for r in self.all_responsibles: r.send_bulk_mail() def bulk_email_former_participants(self) -> None: @@ -470,6 +487,11 @@ def reschedule_bulk_email(self) -> None: scheduled_time=self.current_revision.valid_from) +@receiver(post_save, sender=ServiceForm) +def invalidate_serviceform_caches(sender: ServiceForm, **kwargs): + invalidate_cache(sender, 'all_participants') + + class AbstractServiceFormItem(models.Model): _responsibles: Set[Member] sub_items: 'Iterable[AbstractServiceFormItem]' diff --git a/serviceform/serviceform/templates/serviceform/reports/contents/_all_responsibles.html b/serviceform/serviceform/templates/serviceform/reports/contents/_all_responsibles.html index f735042..a07fe9d 100644 --- a/serviceform/serviceform/templates/serviceform/reports/contents/_all_responsibles.html +++ b/serviceform/serviceform/templates/serviceform/reports/contents/_all_responsibles.html @@ -1,5 +1,5 @@ {% load i18n %}

{% trans "Responsible contact persons" %} ({{service_form.responsibilityperson_set.all|length}})

- {% for p in service_form.responsibilityperson_set.all %} + {% for p in service_form.all_responsibles %} {% include "serviceform/reports/snippets/_participant_row.html" with participant=p is_responsible=1 %} {% endfor %} \ No newline at end of file diff --git a/serviceform/serviceform/utils.py b/serviceform/serviceform/utils.py index 4653cb8..674a175 100644 --- a/serviceform/serviceform/utils.py +++ b/serviceform/serviceform/utils.py @@ -23,15 +23,20 @@ import random import string import logging +from functools import wraps from itertools import chain -from typing import Match, Optional, TYPE_CHECKING, Iterable, Union +from typing import Optional, TYPE_CHECKING, Iterable, Union, Callable + +from django.core.serializers import serialize, deserialize +from django.db.models import Model if TYPE_CHECKING: - from .models import ServiceForm, Participation, Member, AbstractServiceFormItem + from .models import ServiceForm, Participation, Member + from .models.serviceform import AbstractServiceFormItem from colorful.forms import RGB_REGEX from django.contrib import messages -from django.core.cache import caches +from django.core.cache import caches, BaseCache from django.http import HttpRequest, HttpResponse from django.shortcuts import redirect from django.utils.safestring import mark_safe @@ -400,3 +405,37 @@ def decode(number: str) -> Optional[int]: result = None return result + +def django_cache(key, cache_name='default'): + """ + Decorator that caches list of django models into Django cache. + + Decorated function must return an iterable of django models. + + """ + cache: BaseCache = caches[cache_name] + + def decorator(fn): + @wraps(fn) + def wrapper(obj: Model, *args, **kwargs) -> Iterable[Model]: + cache_key = f'{obj.__class__.__name__}_{obj.pk}_{key}' + result_json = cache.get(cache_key) + + if result_json: + result = (i.object for i in deserialize('json', result_json)) + else: + result = fn(obj, *args, **kwargs) + cache.set(cache_key, serialize('json', result)) + + return result + return wrapper + + return decorator + + +def invalidate_cache(obj, key, cache_name='default'): + cache: BaseCache = caches[cache_name] + cache_key = f'{obj.__class__.__name__}_{obj.pk}_{key}' + cache.delete(cache_key) + + From aa3597c18289932097ac224497d71d7ebe353009 Mon Sep 17 00:00:00 2001 From: Tuomas Airaksinen Date: Fri, 26 May 2017 11:37:15 +0300 Subject: [PATCH 19/87] Fix reports / all participations view --- .../reports/snippets/_participant_row.html | 14 +++++++------- .../serviceform/reports/view_participant.html | 2 +- .../serviceform/templatetags/serviceform_tags.py | 8 +++++--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/serviceform/serviceform/templates/serviceform/reports/snippets/_participant_row.html b/serviceform/serviceform/templates/serviceform/reports/snippets/_participant_row.html index 0fd134a..71b9840 100644 --- a/serviceform/serviceform/templates/serviceform/reports/snippets/_participant_row.html +++ b/serviceform/serviceform/templates/serviceform/reports/snippets/_participant_row.html @@ -8,25 +8,25 @@ {% endif %}
{% if anonymous_hide_details %} - {{ participant }} + {{ participant.member }} {% else %} {% if is_responsible %} - {{ participant }} + {{ participant.member }} {% else %} - {{ participant }} + {{ participant.member }} {% endif %} {% endif %}
- {{ participant.phone_number }} + {{ participant.member.phone_number }}
- {{ participant.email }} + {{ participant.member.email }}
- {{ participant.age }} + {{ participant.member.age }}