From 7d0928c729fb63571706a8edb3a298350de02915 Mon Sep 17 00:00:00 2001 From: Flavio Tarsetti <flavio.tarsetti@idiap.ch> Date: Thu, 8 Jun 2017 15:11:00 +0200 Subject: [PATCH] [accounts/ui/settings/navigation] added first registration step for supervisee --- .../migrations/0003_auto_20170518_1232.py | 39 ++++++++++ .../migrations/0004_auto_20170523_1736.py | 25 +++++++ .../migrations/0005_auto_20170608_0935.py | 25 +++++++ .../migrations/0006_auto_20170608_1451.py | 22 ++++++ beat/web/accounts/models.py | 43 +++++++++-- beat/web/navigation/admin.py | 72 ++++++++++++++++++- beat/web/settings/settings.py | 1 + beat/web/ui/registration/forms.py | 44 ++++++++++++ beat/web/ui/registration/models.py | 40 +++++++++++ .../templates/registration/activate.html | 22 +++--- .../registration/registration_form.html | 16 +++-- 11 files changed, 330 insertions(+), 19 deletions(-) create mode 100644 beat/web/accounts/migrations/0003_auto_20170518_1232.py create mode 100644 beat/web/accounts/migrations/0004_auto_20170523_1736.py create mode 100644 beat/web/accounts/migrations/0005_auto_20170608_0935.py create mode 100644 beat/web/accounts/migrations/0006_auto_20170608_1451.py diff --git a/beat/web/accounts/migrations/0003_auto_20170518_1232.py b/beat/web/accounts/migrations/0003_auto_20170518_1232.py new file mode 100644 index 000000000..f14663979 --- /dev/null +++ b/beat/web/accounts/migrations/0003_auto_20170518_1232.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2017-05-18 12:32 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0002_profile'), + ] + + operations = [ + migrations.CreateModel( + name='SupervisionTrack', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_valid', models.BooleanField(default=False)), + ('start_date', models.DateTimeField(blank=True, null=True)), + ('expiration_date', models.DateTimeField(blank=True, null=True)), + ('last_validation_date', models.DateTimeField(blank=True, null=True)), + ('godfather', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='supervisees', to=settings.AUTH_USER_MODEL)), + ('supervisee', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='supervisors', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RemoveField( + model_name='profile', + name='supervisees', + ), + migrations.AddField( + model_name='profile', + name='supervision', + field=models.ManyToManyField(blank=True, related_name='profiles', to='accounts.SupervisionTrack'), + ), + ] diff --git a/beat/web/accounts/migrations/0004_auto_20170523_1736.py b/beat/web/accounts/migrations/0004_auto_20170523_1736.py new file mode 100644 index 000000000..bfe5e0b23 --- /dev/null +++ b/beat/web/accounts/migrations/0004_auto_20170523_1736.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2017-05-23 17:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_auto_20170518_1232'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='supervision_key', + field=models.CharField(blank=True, max_length=40, null=True), + ), + migrations.AddField( + model_name='supervisiontrack', + name='supervision_key', + field=models.CharField(blank=True, max_length=40, null=True), + ), + ] diff --git a/beat/web/accounts/migrations/0005_auto_20170608_0935.py b/beat/web/accounts/migrations/0005_auto_20170608_0935.py new file mode 100644 index 000000000..60270a7b4 --- /dev/null +++ b/beat/web/accounts/migrations/0005_auto_20170608_0935.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2017-06-08 09:35 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_auto_20170523_1736'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='registration_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='profile', + name='status', + field=models.CharField(choices=[(b'N', b'New User'), (b'W', b'Waiting Validation'), (b'A', b'Accepted'), (b'R', b'Rejected'), (b'Y', b'Yearly revalidation'), (b'B', b'Blocked no supervisor')], default=b'B', max_length=1), + ), + ] diff --git a/beat/web/accounts/migrations/0006_auto_20170608_1451.py b/beat/web/accounts/migrations/0006_auto_20170608_1451.py new file mode 100644 index 000000000..a0e80203a --- /dev/null +++ b/beat/web/accounts/migrations/0006_auto_20170608_1451.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.5 on 2017-06-08 14:51 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_auto_20170608_0935'), + ] + + operations = [ + migrations.AlterField( + model_name='supervisiontrack', + name='godfather', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supervisees', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/beat/web/accounts/models.py b/beat/web/accounts/models.py index a61ed582a..88cf26c31 100644 --- a/beat/web/accounts/models.py +++ b/beat/web/accounts/models.py @@ -30,6 +30,9 @@ from django.contrib.auth.models import User from django.db.models.signals import post_save from django.dispatch import receiver +import random +import string + class AccountSettings(models.Model): class Meta: @@ -42,19 +45,44 @@ class AccountSettings(models.Model): database_notifications_enabled = models.BooleanField(default=True) environment_notifications_enabled = models.BooleanField(default=True) +class SupervisionTrack(models.Model): + + #_____ Fields __________ + + supervisee = models.OneToOneField(User, on_delete=models.CASCADE, + related_name='supervisors') + godfather = models.ForeignKey(User, on_delete=models.CASCADE, + related_name='supervisees') + is_valid = models.BooleanField(default=False) + start_date = models.DateTimeField(null=True, blank=True) + expiration_date = models.DateTimeField(null=True, blank=True) + last_validation_date = models.DateTimeField(null=True, blank=True) + supervision_key = models.CharField(max_length=40, null=True, blank=True) + + def __unicode__(self): + return u'Godfather: %s, Supervisee, %s, Validity: %s' % (self.godfather.username, self.supervisee.username, self.is_valid) + + class Profile(models.Model): #_____ Constants __________ + #New account creation 'N'/'W' + #Godfather acceptance/rejection 'A'/'R' + #Yearly revalidation/blockage 'A'/'B' + NEWUSER = 'N' + WAITINGVALIDATION = 'W' ACCEPTED = 'A' REJECTED = 'R' - WAITINGVALIDATION = 'W' + YEARREVALIDATION = 'Y' BLOCKED = 'B' PROFILE_STATUS = ( + (NEWUSER, 'New User'), + (WAITINGVALIDATION, 'Waiting Validation'), (ACCEPTED, 'Accepted'), (REJECTED, 'Rejected'), - (WAITINGVALIDATION, 'Waiting Validation'), - (BLOCKED, 'Blocked'), + (YEARREVALIDATION, 'Yearly revalidation'), + (BLOCKED, 'Blocked no supervisor'), ) #_____ Fields __________ @@ -65,11 +93,18 @@ class Profile(models.Model): # Other fields here status = models.CharField(max_length=1, choices=PROFILE_STATUS, default=BLOCKED) is_godfather = models.BooleanField(default=False) - supervisees = models.ManyToManyField(User, related_name='users', blank=True) + supervision = models.ManyToManyField(SupervisionTrack, related_name='profiles', blank=True) + supervision_key = models.CharField(max_length=40, null=True, blank=True) + registration_date = models.DateTimeField(null=True, blank=True) def __unicode__(self): return u'User: %s' % self.user.username + def _generate_current_supervision_key(self): + length = 40 + return ''.join(random.choice(string.ascii_letters + string.digits) for _ + in range(length)) + @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: diff --git a/beat/web/navigation/admin.py b/beat/web/navigation/admin.py index 23cf9bafa..726b02598 100644 --- a/beat/web/navigation/admin.py +++ b/beat/web/navigation/admin.py @@ -31,6 +31,7 @@ from django.contrib.auth.models import User from .models import Agreement from ..accounts.models import AccountSettings +from ..accounts.models import SupervisionTrack from ..accounts.models import Profile @@ -51,6 +52,14 @@ class AccountSettingsInline(admin.StackedInline): #---------------------------------------------------------- +class SupervisionTrackInline(admin.StackedInline): + model = SupervisionTrack + fk_name = 'godfather' + + +#---------------------------------------------------------- + + class ProfileInline(admin.StackedInline): model = Profile @@ -76,8 +85,19 @@ class UserAdmin(UserAdmin): def status(self, obj): return obj.profile.status - def supervisees(self, obj): - return obj.profile.supervisees + def supervision(self, obj): + if obj.is_staff: + return "Staff account" + if obj.profile.is_godfather: + return "Godfather account" + else: + supervisiontrack = SupervisionTrack.objects.get(supervisee=obj, + is_valid=True) + godfather = supervisiontrack.godfather + return godfather + + def supervision_key(self, obj): + return obj.profile.supervision_key @@ -90,7 +110,8 @@ class UserAdmin(UserAdmin): 'last_login', 'godfather', 'status', - 'supervisees', + 'supervision', + 'supervision_key', ) ordering = ( @@ -102,8 +123,53 @@ class UserAdmin(UserAdmin): inlines = ( AgreementInline, AccountSettingsInline, + SupervisionTrackInline, ProfileInline, ) admin.site.unregister(User) admin.site.register(User, UserAdmin) + + +#---------------------------------------------------------- + + +class SupervisionTrackAdmin(admin.ModelAdmin): + + + list_display = ( + 'godfather', + 'supervisee', + 'is_valid', + 'start_date', + 'expiration_date', + 'last_validation_date', + 'supervision_key', + ) + + ordering = ( + 'godfather', + ) + +admin.site.register(SupervisionTrack, SupervisionTrackAdmin) + + +#---------------------------------------------------------- + + +class ProfileAdmin(admin.ModelAdmin): + + + list_display = ( + 'user', + 'status', + 'registration_date', + 'is_godfather', + 'supervision_key', + ) + + ordering = ( + 'user', + ) + +admin.site.register(Profile, ProfileAdmin) diff --git a/beat/web/settings/settings.py b/beat/web/settings/settings.py index a5c324d86..0dc550136 100755 --- a/beat/web/settings/settings.py +++ b/beat/web/settings/settings.py @@ -216,6 +216,7 @@ DATASETS_ROOT_PATH = None ############################################################################## ACCOUNT_ACTIVATION_DAYS = 2 +ACCOUNT_ACTIVATION_DAYS_FROM_GODFATHER = 7 LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/login/' SYSTEM_ACCOUNT = 'system' diff --git a/beat/web/ui/registration/forms.py b/beat/web/ui/registration/forms.py index b41f5b997..a2ed0c97e 100644 --- a/beat/web/ui/registration/forms.py +++ b/beat/web/ui/registration/forms.py @@ -30,6 +30,7 @@ Forms and validation code for user registration. """ +import datetime from django.contrib.auth.models import User from django import forms @@ -39,6 +40,8 @@ from django.utils.translation import ugettext_lazy as _ from .models import RegistrationProfile from .models import PreregistrationProfile +from ...accounts.models import SupervisionTrack +from ...accounts.models import Profile # I put this on all required fields, because it's easier to pick up # on them with CSS or JavaScript if they have a class of "required" @@ -79,6 +82,11 @@ class RegistrationForm(forms.Form): label=_(u'Password')) password2 = forms.CharField(widget=forms.PasswordInput(attrs=attrs_dict, render_value=False), label=_(u'Password (again)')) + godfather = forms.RegexField(regex=r'^\w+$', + max_length=30, + widget=forms.TextInput(attrs=attrs_dict), + label=_(u'Godfather Username (Your account needs to be validated by a recognized person)')) + def clean_username(self): """ @@ -92,6 +100,22 @@ class RegistrationForm(forms.Form): return self.cleaned_data['username'] raise forms.ValidationError(_(u'This username is already taken. Please choose another.')) + def clean_godfather(self): + """ + Validate that the username is alphanumeric and exists. + + """ + try: + user = User.objects.get(username__iexact=self.cleaned_data['godfather']) + except User.DoesNotExist: + raise forms.ValidationError(_(u'This godfather username does not exist. Please choose another.')) + + if not user.profile.is_godfather: + raise forms.ValidationError(_(u'This user is not a recognized godfather. Please choose another.')) + + return self.cleaned_data['godfather'] + + def clean(self): """ Verifiy that the values entered into the two password fields @@ -123,6 +147,26 @@ class RegistrationForm(forms.Form): password=self.cleaned_data['password1'], email=self.cleaned_data['email'], ) + + #Create and assign key + new_user.profile.supervision_key = new_user.profile._generate_current_supervision_key() + godfather = User.objects.get(username = self.cleaned_data['godfather']) + supervisiontrack = SupervisionTrack.objects.create( + supervisee = new_user, + godfather = godfather, + is_valid = False, + ) + + #Assign key to supervision track + supervisiontrack.supervision_key = new_user.profile.supervision_key + supervisiontrack.save() + new_user.profile.is_godfather = False + new_user.profile.status = Profile.NEWUSER + new_user.profile.registration_date = datetime.datetime.now() + new_user.profile.supervision.add(supervisiontrack) + new_user.save() + + return new_user diff --git a/beat/web/ui/registration/models.py b/beat/web/ui/registration/models.py index 546669521..405c31daf 100644 --- a/beat/web/ui/registration/models.py +++ b/beat/web/ui/registration/models.py @@ -39,6 +39,9 @@ from django.template import loader from django.template import Context from django.utils.translation import ugettext_lazy as _ +from ...accounts.models import SupervisionTrack +from ...accounts.models import Profile + SHA1_RE = re.compile('^[a-f0-9]{40}$') @@ -93,6 +96,43 @@ class RegistrationManager(models.Manager): profile.activation_key = self.model.ACTIVATED profile.save() user_activated.send(sender=self.model, user=user) + # The user activated via the link but activation from godfather or admin + # is requested now to have access to the platform (so set it + # inactive again) + user.is_active = False + user.profile.status = Profile.WAITINGVALIDATION + user.profile.registration_date = datetime.datetime.now() + user.save() + + # Send email to godfather + # Fetch Godfather user from supervision track + supervisiontrack = SupervisionTrack.objects.get(supervision_key=user.profile.supervision_key) + godfather_user = supervisiontrack.godfather + + from django.core.mail import send_mail + + parsed_url = urlparse(settings.URL_PREFIX) + server_address = '%s://%s' % (parsed_url.scheme, parsed_url.hostname) + + c = Context({ 'supervisor': godfather_user, + 'supervisee': user, + 'prefix': server_address, + }) + + try: + t = loader.get_template('registration/mail.godfather_validation.subject.txt') + subject = t.render(c) + + # Note: e-mail subject *must not* contain newlines + subject = settings.EMAIL_SUBJECT_PREFIX + ''.join(subject.splitlines()) + + t = loader.get_template('registration/mail.godfather_validation.message.txt') + message = t.render(c) + + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [godfather_user.email]) + except: + pass + return user return False diff --git a/beat/web/ui/registration/templates/registration/activate.html b/beat/web/ui/registration/templates/registration/activate.html index 54c1d444c..deeaed58a 100644 --- a/beat/web/ui/registration/templates/registration/activate.html +++ b/beat/web/ui/registration/templates/registration/activate.html @@ -2,21 +2,21 @@ {% comment %} * Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/ * Contact: beat.support@idiap.ch - * + * * This file is part of the beat.web module of the BEAT platform. - * + * * Commercial License Usage * Licensees holding valid commercial BEAT licenses may use this file in * accordance with the terms contained in a written agreement between you * and Idiap. For further information contact tto@idiap.ch - * + * * Alternatively, this file may be used under the terms of the GNU Affero * Public License version 3 as published by the Free Software and appearing * in the file LICENSE.AGPL included in the packaging of this file. * The BEAT platform is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. - * + * * You should have received a copy of the GNU Affero Public License along * with the BEAT platform. If not, see http://www.gnu.org/licenses/. {% endcomment %} @@ -26,13 +26,19 @@ <div class="row"> <div class="col-sm-4 col-sm-offset-4"> - <h4>{% if account %}Account activated{% else %}Error{% endif %}</h4> + <h4>{% if account %}Account almost activated{% else %}Error{% endif %}</h4> {% if account %} - <p class="text">Your account was successfully activated. Welcome {{ account.username }}. - You can now <a href="{% url 'login' %}">sign in</a> and start your own experiments!</p> + <p class="text">Your account is almost activated. Welcome {{ account.username }}. + We have asked your "godfather" to validate your account and accept you as a + supervisee. You will be informed by email once this is done. + In the meantime you can remind your "godfather"(supervisor) to validate + your account. You will then be able to <a href="{% url 'login' %}">sign in</a> and start your own experiments!</p> {% else %} - <p class="text">Your account wasn't activated. Check that the URL you entered is correct. If you still have problems to activate your account, <a href="{% url 'contact' %}">contact a member of our support staff</a>.</p> + <p class="text">Your account wasn't activated. Check that the URL you entered is correct. + If you already clicked on the link sent to you by email, you need to wait + for your "godfather"(supervisor) to accept your account activation request. + If you still have problems to activate your account, <a href="{% url 'contact' %}">contact a member of our support staff</a>.</p> {% endif %} </div> diff --git a/beat/web/ui/registration/templates/registration/registration_form.html b/beat/web/ui/registration/templates/registration/registration_form.html index eb3cc436c..13702df70 100644 --- a/beat/web/ui/registration/templates/registration/registration_form.html +++ b/beat/web/ui/registration/templates/registration/registration_form.html @@ -2,21 +2,21 @@ {% comment %} * Copyright (c) 2016 Idiap Research Institute, http://www.idiap.ch/ * Contact: beat.support@idiap.ch - * + * * This file is part of the beat.web module of the BEAT platform. - * + * * Commercial License Usage * Licensees holding valid commercial BEAT licenses may use this file in * accordance with the terms contained in a written agreement between you * and Idiap. For further information contact tto@idiap.ch - * + * * Alternatively, this file may be used under the terms of the GNU Affero * Public License version 3 as published by the Free Software and appearing * in the file LICENSE.AGPL included in the packaging of this file. * The BEAT platform is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY * or FITNESS FOR A PARTICULAR PURPOSE. - * + * * You should have received a copy of the GNU Affero Public License along * with the BEAT platform. If not, see http://www.gnu.org/licenses/. {% endcomment %} @@ -91,6 +91,14 @@ {% endfor %} </div> + <div class="form-group{% if form.godfather.errors %} has-error{% endif %}"> + {{ form.godfather.label_tag }} + {{ form.godfather|tabindex:7|addclass:"form-control" }} + {% for error in form.godfather.errors %} + <div class="alert alert-danger" role="alert">{{ error }}</div> + {% endfor %} + </div> + <div class="form-group{% if form.tos.errors %} has-error{% endif %}"> {{ form.tos }} I have carefully read the <a href="{% url 'terms-of-service' %}">Terms of Service</a>, which include the Privacy and Data Protection Terms of Use, and fully agree and undertake to comply with all provisions therein by checking this box. {% for error in form.tos.errors %} -- GitLab