From 26281af673cbdcb3c7aaad15d9015aa9f6f1e27a Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sun, 5 Apr 2020 04:26:42 +0200 Subject: [PATCH 01/22] Send an e-mail verification to a new registered user --- apps/member/forms.py | 9 +- apps/member/models.py | 6 + apps/member/tokens.py | 30 +++++ apps/member/urls.py | 3 + apps/member/views.py | 111 ++++++++++++++++-- .../account_activation_complete.html | 16 +++ .../account_activation_email.html | 11 ++ .../account_activation_email_sent.html | 7 ++ 8 files changed, 179 insertions(+), 14 deletions(-) create mode 100644 apps/member/tokens.py create mode 100644 templates/registration/account_activation_complete.html create mode 100644 templates/registration/account_activation_email.html create mode 100644 templates/registration/account_activation_email_sent.html diff --git a/apps/member/forms.py b/apps/member/forms.py index a37d143e..e6e73612 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -4,6 +4,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput from permission.models import PermissionMask @@ -23,10 +24,14 @@ class SignUpForm(UserCreationForm): super().__init__(*args, **kwargs) self.fields['username'].widget.attrs.pop("autofocus", None) self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) + self.fields['first_name'].required = True + self.fields['last_name'].required = True + self.fields['email'].required = True + self.fields['email'].help_text = _("This address must be valid.") class Meta: model = User - fields = ['first_name', 'last_name', 'username', 'email'] + fields = ('first_name', 'last_name', 'username', 'email', ) class ProfileForm(forms.ModelForm): @@ -37,7 +42,7 @@ class ProfileForm(forms.ModelForm): class Meta: model = Profile fields = '__all__' - exclude = ['user'] + exclude = ('user', 'email_confirmed', ) class ClubForm(forms.ModelForm): diff --git a/apps/member/models.py b/apps/member/models.py index 693854af..3cf92ff1 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -45,6 +45,12 @@ class Profile(models.Model): ) paid = models.BooleanField( verbose_name=_("paid"), + help_text=_("Tells if the user receive a salary."), + default=False, + ) + + email_confirmed = models.BooleanField( + verbose_name=_("email confirmed"), default=False, ) diff --git a/apps/member/tokens.py b/apps/member/tokens.py new file mode 100644 index 00000000..f45c00de --- /dev/null +++ b/apps/member/tokens.py @@ -0,0 +1,30 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later +# Copied from https://gitlab.crans.org/bombar/codeflix/-/blob/master/codeflix/codeflix/tokens.py + +from django.contrib.auth.tokens import PasswordResetTokenGenerator + + +class AccountActivationTokenGenerator(PasswordResetTokenGenerator): + """ + Create a unique token generator to confirm email addresses. + """ + def _make_hash_value(self, user, timestamp): + """ + Hash the user's primary key and some user state that's sure to change + after an account validation to produce a token that invalidated when + it's used: + 1. The user.profile.email_confirmed field will change upon an account + validation. + 2. The last_login field will usually be updated very shortly after + an account validation. + Failing those things, settings.PASSWORD_RESET_TIMEOUT_DAYS eventually + invalidates the token. + """ + # Truncate microseconds so that tokens are consistent even if the + # database doesn't support microseconds. + login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, tzinfo=None) + return str(user.pk) + str(user.profile.email_confirmed) + str(login_timestamp) + str(timestamp) + + +account_activation_token = AccountActivationTokenGenerator() diff --git a/apps/member/urls.py b/apps/member/urls.py index 1214f024..9b6ccbd5 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -8,6 +8,9 @@ from . import views app_name = 'member' urlpatterns = [ path('signup/', views.UserCreateView.as_view(), name="signup"), + path('accounts/activate/sent', views.UserActivationEmailSentView.as_view(), name='account_activation_sent'), + path('accounts/activate//', views.UserActivateView.as_view(), name='account_activation'), + path('club/', views.ClubListView.as_view(), name="club_list"), path('club/create/', views.ClubCreateView.as_view(), name="club_create"), diff --git a/apps/member/views.py b/apps/member/views.py index f695002f..f1df5a47 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -9,12 +9,18 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.contrib.auth.views import LoginView +from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError from django.db.models import Q from django.forms import HiddenInput -from django.shortcuts import redirect +from django.shortcuts import redirect, resolve_url +from django.template import loader from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_protect from django.views.generic import CreateView, DetailView, UpdateView, TemplateView from django.views.generic.base import View from django.views.generic.edit import FormMixin @@ -30,6 +36,7 @@ from permission.views import ProtectQuerysetMixin from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm from .models import Club, Membership from .tables import ClubTable, UserTable, MembershipTable +from .tokens import account_activation_token class CustomLoginView(LoginView): @@ -57,15 +64,90 @@ class UserCreateView(CreateView): return context def form_valid(self, form): + """ + If the form is valid, then the user is created with is_active set to False + so that the user cannot log in until the email has been validated. + """ profile_form = ProfileForm(self.request.POST) - if form.is_valid() and profile_form.is_valid(): - user = form.save(commit=False) - user.profile = profile_form.save(commit=False) - user.save() - user.profile.save() + if not profile_form.is_valid(): + return self.form_invalid(form) + + user = form.save(commit=False) + user.is_active = False + user.profile = profile_form.save(commit=False) + user.save() + user.profile.save() + site = get_current_site(self.request) + subject = "Activate your {} account".format(site.name) + message = loader.render_to_string('registration/account_activation_email.html', + { + 'user': user, + 'domain': site.domain, + 'site_name': "La Note Kfet", + 'protocol': 'https', + 'token': account_activation_token.make_token(user), + 'uid': urlsafe_base64_encode(force_bytes(user.pk)).decode('UTF-8'), + }) + user.email_user(subject, message) return super().form_valid(form) +class UserActivateView(TemplateView): + title = _("Account Activation") + template_name = 'registration/account_activation_complete.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + The dispatch method looks at the request to determine whether it is a GET, POST, etc, + and relays the request to a matching method if one is defined, or raises HttpResponseNotAllowed + if not. We chose to check the token in the dispatch method to mimic PasswordReset from + django.contrib.auth + """ + assert 'uidb64' in kwargs and 'token' in kwargs + + self.validlink = False + user = self.get_user(kwargs['uidb64']) + token = kwargs['token'] + + if user is not None and account_activation_token.check_token(user, token): + self.validlink = True + user.is_active = True + user.profile.email_confirmed = True + user.save() + return super().dispatch(*args, **kwargs) + else: + # Display the "Account Activation unsuccessful" page. + return self.render_to_response(self.get_context_data()) + + def get_user(self, uidb64): + print(uidb64) + try: + # urlsafe_base64_decode() decodes to bytestring + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist, ValidationError): + user = None + return user + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['login_url'] = resolve_url(settings.LOGIN_URL) + if self.validlink: + context['validlink'] = True + else: + context.update({ + 'title': _('Account Activation unsuccessful'), + 'validlink': False, + }) + return context + + +class UserActivationEmailSentView(TemplateView): + template_name = 'registration/account_activation_email_sent.html' + title = _('Account activation email sent') + + class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): model = User fields = ['first_name', 'last_name', 'username', 'email'] @@ -75,14 +157,20 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + + form = context['form'] + form.fields['username'].widget.attrs.pop("autofocus", None) + form.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) + form.fields['first_name'].required = True + form.fields['last_name'].required = True + form.fields['email'].required = True + form.fields['email'].help_text = _("This address must be valid.") + context['profile_form'] = self.profile_form(instance=context['user_object'].profile) context['title'] = _("Update Profile") return context - def get_form(self, form_class=None): - form = super().get_form(form_class) - if 'username' not in form.data: - return form + def form_valid(self, form): new_username = form.data['username'] # Si l'utilisateur cherche à modifier son pseudo, le nouveau pseudo ne doit pas être proche d'un alias existant note = NoteUser.objects.filter( @@ -90,9 +178,8 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): if note.exists() and note.get().user != self.object: form.add_error('username', _("An alias with a similar name already exists.")) - return form + return super().form_invalid(form) - def form_valid(self, form): profile_form = ProfileForm( data=self.request.POST, instance=self.object.profile, diff --git a/templates/registration/account_activation_complete.html b/templates/registration/account_activation_complete.html new file mode 100644 index 00000000..185fbfb0 --- /dev/null +++ b/templates/registration/account_activation_complete.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +

{% trans Activation %}

+ +{% if validlink %} +{% blocktrans trimmed %} +Your account have successfully been activated. You can now log in. +{% endblocktrans %} +{% else %} +{% blocktrans trimmed %} +The link was invalid. The token may have expired. Please send us an email to activate your account. +{% endblocktrans %} +{% endif %} +{% endblock %} diff --git a/templates/registration/account_activation_email.html b/templates/registration/account_activation_email.html new file mode 100644 index 00000000..e8f2032d --- /dev/null +++ b/templates/registration/account_activation_email.html @@ -0,0 +1,11 @@ +Hi {{ user.username }}, + +Welcome to {{ site_name }}. Please click on the link below to confirm your registration. + +{{ protocol }}://{{ domain }}{% url 'member:account_activation' uidb64=uid token=token %} + +This link is only valid for a couple of days, after that you will need to contact us to validate your email. + +Thanks, + +{{ site_name }} team. diff --git a/templates/registration/account_activation_email_sent.html b/templates/registration/account_activation_email_sent.html new file mode 100644 index 00000000..bd4cf8d8 --- /dev/null +++ b/templates/registration/account_activation_email_sent.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} +

Account Activation

+ +An email has been sent. Please click on the link to activate your account. +{% endblock %} \ No newline at end of file From 0f77b9df9a254664a02b5db5160cda7f258f2747 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sun, 5 Apr 2020 04:37:29 +0200 Subject: [PATCH 02/22] Unvalidate e-mail when the user changes it, but the user still active --- apps/member/models.py | 18 ++++++++++++++++++ apps/member/views.py | 31 +++++++++++++------------------ templates/base.html | 5 +++++ 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/apps/member/models.py b/apps/member/models.py index 3cf92ff1..462b23fd 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -7,8 +7,13 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.db import models +from django.template import loader from django.urls import reverse, reverse_lazy +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ + +from member.tokens import account_activation_token from note.models import MembershipTransaction @@ -62,6 +67,19 @@ class Profile(models.Model): def get_absolute_url(self): return reverse('user_detail', args=(self.pk,)) + def send_email_validation_link(self): + subject = "Activate your Note Kfet account" + message = loader.render_to_string('registration/account_activation_email.html', + { + 'user': self.user, + 'domain': "nk20.ynerant.fr", + 'site_name': "La Note Kfet", + 'protocol': 'https', + 'token': account_activation_token.make_token(self.user), + 'uid': urlsafe_base64_encode(force_bytes(self.user.pk)).decode('UTF-8'), + }) + self.user.email_user(subject, message) + class Club(models.Model): """ diff --git a/apps/member/views.py b/apps/member/views.py index f1df5a47..0d128394 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -9,16 +9,13 @@ from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.contrib.auth.views import LoginView -from django.contrib.sites.shortcuts import get_current_site from django.core.exceptions import ValidationError from django.db.models import Q from django.forms import HiddenInput from django.shortcuts import redirect, resolve_url -from django.template import loader from django.urls import reverse_lazy from django.utils.decorators import method_decorator -from django.utils.encoding import force_bytes -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_protect from django.views.generic import CreateView, DetailView, UpdateView, TemplateView @@ -49,11 +46,11 @@ class CustomLoginView(LoginView): class UserCreateView(CreateView): """ - Une vue pour inscrire un utilisateur et lui créer un profile + Une vue pour inscrire un utilisateur et lui créer un profil """ form_class = SignUpForm - success_url = reverse_lazy('login') + success_url = reverse_lazy('member:login') template_name = 'member/signup.html' second_form = ProfileForm @@ -77,18 +74,9 @@ class UserCreateView(CreateView): user.profile = profile_form.save(commit=False) user.save() user.profile.save() - site = get_current_site(self.request) - subject = "Activate your {} account".format(site.name) - message = loader.render_to_string('registration/account_activation_email.html', - { - 'user': user, - 'domain': site.domain, - 'site_name': "La Note Kfet", - 'protocol': 'https', - 'token': account_activation_token.make_token(user), - 'uid': urlsafe_base64_encode(force_bytes(user.pk)).decode('UTF-8'), - }) - user.email_user(subject, message) + + user.profile.send_email_validation_link() + return super().form_valid(form) @@ -195,11 +183,18 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): if similar.exists(): similar.delete() + olduser = User.objects.get(pk=form.instance.pk) + user = form.save(commit=False) profile = profile_form.save(commit=False) profile.user = user profile.save() user.save() + + if olduser.email != user.email: + user.profile.email_confirmed = False + user.profile.send_email_validation_link() + return super().form_valid(form) def get_success_url(self, **kwargs): diff --git a/templates/base.html b/templates/base.html index c44e2467..9f131054 100644 --- a/templates/base.html +++ b/templates/base.html @@ -138,6 +138,11 @@ SPDX-License-Identifier: GPL-3.0-or-later
+ {% if not user.profile.email_confirmed %} +
+ {% trans "Your e-mail address is not validated. Please check your mail inbox and click on the validation link." %} +
+ {% endif %} {% block contenttitle %}

{{ title }}

{% endblock %}
{% block content %} From 49807d33d92c698acfc88851acb07082e52a3334 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sun, 5 Apr 2020 05:17:28 +0200 Subject: [PATCH 03/22] Use a separate app for registration --- apps/member/forms.py | 18 +--- apps/member/models.py | 1 - apps/member/urls.py | 5 -- apps/member/views.py | 100 +-------------------- apps/registration/__init__.py | 4 + apps/registration/api/__init__.py | 0 apps/registration/apps.py | 10 +++ apps/registration/forms.py | 21 +++++ apps/registration/migrations/__init__.py | 0 apps/registration/urls.py | 13 +++ apps/registration/views.py | 110 +++++++++++++++++++++++ note_kfet/settings/base.py | 7 +- note_kfet/urls.py | 8 +- templates/base.html | 4 +- 14 files changed, 168 insertions(+), 133 deletions(-) create mode 100644 apps/registration/__init__.py create mode 100644 apps/registration/api/__init__.py create mode 100644 apps/registration/apps.py create mode 100644 apps/registration/forms.py create mode 100644 apps/registration/migrations/__init__.py create mode 100644 apps/registration/urls.py create mode 100644 apps/registration/views.py diff --git a/apps/member/forms.py b/apps/member/forms.py index e6e73612..5b20fd15 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -2,9 +2,8 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django import forms -from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.models import User -from django.utils.translation import ugettext_lazy as _ from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput from permission.models import PermissionMask @@ -19,21 +18,6 @@ class CustomAuthenticationForm(AuthenticationForm): ) -class SignUpForm(UserCreationForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['username'].widget.attrs.pop("autofocus", None) - self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) - self.fields['first_name'].required = True - self.fields['last_name'].required = True - self.fields['email'].required = True - self.fields['email'].help_text = _("This address must be valid.") - - class Meta: - model = User - fields = ('first_name', 'last_name', 'username', 'email', ) - - class ProfileForm(forms.ModelForm): """ A form for the extras field provided by the :model:`member.Profile` model. diff --git a/apps/member/models.py b/apps/member/models.py index 462b23fd..294643af 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -12,7 +12,6 @@ from django.urls import reverse, reverse_lazy from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ - from member.tokens import account_activation_token from note.models import MembershipTransaction diff --git a/apps/member/urls.py b/apps/member/urls.py index 9b6ccbd5..37ff8d2b 100644 --- a/apps/member/urls.py +++ b/apps/member/urls.py @@ -7,11 +7,6 @@ from . import views app_name = 'member' urlpatterns = [ - path('signup/', views.UserCreateView.as_view(), name="signup"), - path('accounts/activate/sent', views.UserActivationEmailSentView.as_view(), name='account_activation_sent'), - path('accounts/activate//', views.UserActivateView.as_view(), name='account_activation'), - - path('club/', views.ClubListView.as_view(), name="club_list"), path('club/create/', views.ClubCreateView.as_view(), name="club_create"), path('club//', views.ClubDetailView.as_view(), name="club_detail"), diff --git a/apps/member/views.py b/apps/member/views.py index 0d128394..ac5dd59e 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -12,12 +12,9 @@ from django.contrib.auth.views import LoginView from django.core.exceptions import ValidationError from django.db.models import Q from django.forms import HiddenInput -from django.shortcuts import redirect, resolve_url +from django.shortcuts import redirect from django.urls import reverse_lazy -from django.utils.decorators import method_decorator -from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_protect from django.views.generic import CreateView, DetailView, UpdateView, TemplateView from django.views.generic.base import View from django.views.generic.edit import FormMixin @@ -30,10 +27,9 @@ from note.tables import HistoryTable, AliasTable from permission.backends import PermissionBackend from permission.views import ProtectQuerysetMixin -from .forms import SignUpForm, ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm +from .forms import ProfileForm, ClubForm, MembershipForm, CustomAuthenticationForm from .models import Club, Membership from .tables import ClubTable, UserTable, MembershipTable -from .tokens import account_activation_token class CustomLoginView(LoginView): @@ -44,98 +40,6 @@ class CustomLoginView(LoginView): return super().form_valid(form) -class UserCreateView(CreateView): - """ - Une vue pour inscrire un utilisateur et lui créer un profil - """ - - form_class = SignUpForm - success_url = reverse_lazy('member:login') - template_name = 'member/signup.html' - second_form = ProfileForm - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["profile_form"] = self.second_form() - - return context - - def form_valid(self, form): - """ - If the form is valid, then the user is created with is_active set to False - so that the user cannot log in until the email has been validated. - """ - profile_form = ProfileForm(self.request.POST) - if not profile_form.is_valid(): - return self.form_invalid(form) - - user = form.save(commit=False) - user.is_active = False - user.profile = profile_form.save(commit=False) - user.save() - user.profile.save() - - user.profile.send_email_validation_link() - - return super().form_valid(form) - - -class UserActivateView(TemplateView): - title = _("Account Activation") - template_name = 'registration/account_activation_complete.html' - - @method_decorator(csrf_protect) - def dispatch(self, *args, **kwargs): - """ - The dispatch method looks at the request to determine whether it is a GET, POST, etc, - and relays the request to a matching method if one is defined, or raises HttpResponseNotAllowed - if not. We chose to check the token in the dispatch method to mimic PasswordReset from - django.contrib.auth - """ - assert 'uidb64' in kwargs and 'token' in kwargs - - self.validlink = False - user = self.get_user(kwargs['uidb64']) - token = kwargs['token'] - - if user is not None and account_activation_token.check_token(user, token): - self.validlink = True - user.is_active = True - user.profile.email_confirmed = True - user.save() - return super().dispatch(*args, **kwargs) - else: - # Display the "Account Activation unsuccessful" page. - return self.render_to_response(self.get_context_data()) - - def get_user(self, uidb64): - print(uidb64) - try: - # urlsafe_base64_decode() decodes to bytestring - uid = urlsafe_base64_decode(uidb64).decode() - user = User.objects.get(pk=uid) - except (TypeError, ValueError, OverflowError, User.DoesNotExist, ValidationError): - user = None - return user - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['login_url'] = resolve_url(settings.LOGIN_URL) - if self.validlink: - context['validlink'] = True - else: - context.update({ - 'title': _('Account Activation unsuccessful'), - 'validlink': False, - }) - return context - - -class UserActivationEmailSentView(TemplateView): - template_name = 'registration/account_activation_email_sent.html' - title = _('Account activation email sent') - - class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): model = User fields = ['first_name', 'last_name', 'username', 'email'] diff --git a/apps/registration/__init__.py b/apps/registration/__init__.py new file mode 100644 index 00000000..700d9f00 --- /dev/null +++ b/apps/registration/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +default_app_config = 'registration.apps.RegistrationConfig' diff --git a/apps/registration/api/__init__.py b/apps/registration/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/registration/apps.py b/apps/registration/apps.py new file mode 100644 index 00000000..dec89274 --- /dev/null +++ b/apps/registration/apps.py @@ -0,0 +1,10 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class RegistrationConfig(AppConfig): + name = 'registration' + verbose_name = _('registration') diff --git a/apps/registration/forms.py b/apps/registration/forms.py new file mode 100644 index 00000000..3f4063d5 --- /dev/null +++ b/apps/registration/forms.py @@ -0,0 +1,21 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.models import User +from django.utils.translation import gettext_lazy as _ + + +class SignUpForm(UserCreationForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['username'].widget.attrs.pop("autofocus", None) + self.fields['first_name'].widget.attrs.update({"autofocus": "autofocus"}) + self.fields['first_name'].required = True + self.fields['last_name'].required = True + self.fields['email'].required = True + self.fields['email'].help_text = _("This address must be valid.") + + class Meta: + model = User + fields = ('first_name', 'last_name', 'username', 'email', ) diff --git a/apps/registration/migrations/__init__.py b/apps/registration/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/registration/urls.py b/apps/registration/urls.py new file mode 100644 index 00000000..e752922c --- /dev/null +++ b/apps/registration/urls.py @@ -0,0 +1,13 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.urls import path + +from . import views + +app_name = 'registration' +urlpatterns = [ + path('signup/', views.UserCreateView.as_view(), name="signup"), + path('accounts/activate/sent', views.UserActivationEmailSentView.as_view(), name='account_activation_sent'), + path('accounts/activate//', views.UserActivateView.as_view(), name='account_activation'), +] diff --git a/apps/registration/views.py b/apps/registration/views.py new file mode 100644 index 00000000..f4a26c3b --- /dev/null +++ b/apps/registration/views.py @@ -0,0 +1,110 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.shortcuts import resolve_url +from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.utils.http import urlsafe_base64_decode +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_protect +from django.views.generic import CreateView, TemplateView +from member.forms import ProfileForm +from member.tokens import account_activation_token + +from .forms import SignUpForm + + +class UserCreateView(CreateView): + """ + Une vue pour inscrire un utilisateur et lui créer un profil + """ + + form_class = SignUpForm + success_url = reverse_lazy('member:login') + template_name = 'member/signup.html' + second_form = ProfileForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["profile_form"] = self.second_form() + + return context + + def form_valid(self, form): + """ + If the form is valid, then the user is created with is_active set to False + so that the user cannot log in until the email has been validated. + """ + profile_form = ProfileForm(self.request.POST) + if not profile_form.is_valid(): + return self.form_invalid(form) + + user = form.save(commit=False) + user.is_active = False + user.profile = profile_form.save(commit=False) + user.save() + user.profile.save() + + user.profile.send_email_validation_link() + + return super().form_valid(form) + + +class UserActivateView(TemplateView): + title = _("Account Activation") + template_name = 'registration/account_activation_complete.html' + + @method_decorator(csrf_protect) + def dispatch(self, *args, **kwargs): + """ + The dispatch method looks at the request to determine whether it is a GET, POST, etc, + and relays the request to a matching method if one is defined, or raises HttpResponseNotAllowed + if not. We chose to check the token in the dispatch method to mimic PasswordReset from + django.contrib.auth + """ + assert 'uidb64' in kwargs and 'token' in kwargs + + self.validlink = False + user = self.get_user(kwargs['uidb64']) + token = kwargs['token'] + + if user is not None and account_activation_token.check_token(user, token): + self.validlink = True + user.is_active = True + user.profile.email_confirmed = True + user.save() + return super().dispatch(*args, **kwargs) + else: + # Display the "Account Activation unsuccessful" page. + return self.render_to_response(self.get_context_data()) + + def get_user(self, uidb64): + print(uidb64) + try: + # urlsafe_base64_decode() decodes to bytestring + uid = urlsafe_base64_decode(uidb64).decode() + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist, ValidationError): + user = None + return user + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['login_url'] = resolve_url(settings.LOGIN_URL) + if self.validlink: + context['validlink'] = True + else: + context.update({ + 'title': _('Account Activation unsuccessful'), + 'validlink': False, + }) + return context + + +class UserActivationEmailSentView(TemplateView): + template_name = 'registration/account_activation_email_sent.html' + title = _('Account activation email sent') + diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 61e5ea51..283f8e56 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -54,13 +54,14 @@ INSTALLED_APPS = [ 'rest_framework.authtoken', # Note apps + 'api', 'activity', + 'logs', 'member', 'note', - 'treasury', 'permission', - 'api', - 'logs', + 'registration', + 'treasury', ] LOGIN_REDIRECT_URL = '/note/transfer/' diff --git a/note_kfet/urls.py b/note_kfet/urls.py index a7afab29..90d44a07 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -16,6 +16,7 @@ urlpatterns = [ # Include project routers path('note/', include('note.urls')), path('accounts/', include('member.urls')), + path('registration/', include('registration.urls')), path('activity/', include('activity.urls')), path('treasury/', include('treasury.urls')), @@ -37,14 +38,7 @@ if "cas_server" in settings.INSTALLED_APPS: # Include CAS Server routers path('cas/', include('cas_server.urls', namespace="cas_server")), ] -if "cas" in settings.INSTALLED_APPS: - from cas import views as cas_views - urlpatterns += [ - # Include CAS Client routers - path('accounts/login/cas/', cas_views.login, name='cas_login'), - path('accounts/logout/cas/', cas_views.logout, name='cas_logout'), - ] if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar urlpatterns = [ diff --git a/templates/base.html b/templates/base.html index 9f131054..8e16a419 100644 --- a/templates/base.html +++ b/templates/base.html @@ -124,7 +124,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% else %} @@ -138,7 +138,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
- {% if not user.profile.email_confirmed %} + {% if user.is_authenticated and not user.profile.email_confirmed %}
{% trans "Your e-mail address is not validated. Please check your mail inbox and click on the validation link." %}
From f10497bac3a5d1d12b340f983429b16046e8dce8 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sun, 5 Apr 2020 06:40:03 +0200 Subject: [PATCH 04/22] List pending users --- apps/member/forms.py | 2 +- apps/member/models.py | 7 ++++- apps/member/views.py | 2 +- apps/note/signals.py | 11 +++---- apps/registration/tables.py | 23 +++++++++++++++ apps/{member => registration}/tokens.py | 0 apps/registration/urls.py | 5 ++-- apps/registration/views.py | 29 +++++++++++++++++-- templates/base.html | 7 +++++ templates/registration/future_user_list.html | 22 ++++++++++++++ .../{member => registration}/signup.html | 0 11 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 apps/registration/tables.py rename apps/{member => registration}/tokens.py (100%) create mode 100644 templates/registration/future_user_list.html rename templates/{member => registration}/signup.html (100%) diff --git a/apps/member/forms.py b/apps/member/forms.py index 5b20fd15..70ffcf5e 100644 --- a/apps/member/forms.py +++ b/apps/member/forms.py @@ -26,7 +26,7 @@ class ProfileForm(forms.ModelForm): class Meta: model = Profile fields = '__all__' - exclude = ('user', 'email_confirmed', ) + exclude = ('user', 'email_confirmed', 'registration_valid', ) class ClubForm(forms.ModelForm): diff --git a/apps/member/models.py b/apps/member/models.py index 294643af..46051fe1 100644 --- a/apps/member/models.py +++ b/apps/member/models.py @@ -12,7 +12,7 @@ from django.urls import reverse, reverse_lazy from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ -from member.tokens import account_activation_token +from registration.tokens import account_activation_token from note.models import MembershipTransaction @@ -53,6 +53,11 @@ class Profile(models.Model): default=False, ) + registration_valid = models.BooleanField( + verbose_name=_("registration valid"), + default=False, + ) + email_confirmed = models.BooleanField( verbose_name=_("email confirmed"), default=False, diff --git a/apps/member/views.py b/apps/member/views.py index ac5dd59e..29b16222 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -139,7 +139,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): template_name = 'member/user_list.html' def get_queryset(self, **kwargs): - qs = super().get_queryset() + qs = super().get_queryset().filter(profile__registration_valid=True) if "search" in self.request.GET: pattern = self.request.GET["search"] diff --git a/apps/note/signals.py b/apps/note/signals.py index e62115b3..78312682 100644 --- a/apps/note/signals.py +++ b/apps/note/signals.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later -def save_user_note(instance, created, raw, **_kwargs): +def save_user_note(instance, raw, **_kwargs): """ Hook to create and save a note when an user is updated """ @@ -10,10 +10,11 @@ def save_user_note(instance, created, raw, **_kwargs): # When provisionning data, do not try to autocreate return - if created: - from .models import NoteUser - NoteUser.objects.create(user=instance) - instance.note.save() + if instance.profile.registration_valid and instance.is_active: + # Create note only when the registration is validated + from note.models import NoteUser + NoteUser.objects.get_or_create(user=instance) + instance.note.save() def save_club_note(instance, created, raw, **_kwargs): diff --git a/apps/registration/tables.py b/apps/registration/tables.py new file mode 100644 index 00000000..7fd7537f --- /dev/null +++ b/apps/registration/tables.py @@ -0,0 +1,23 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import django_tables2 as tables +from django.contrib.auth.models import User + + +class FutureUserTable(tables.Table): + phone_number = tables.Column(accessor='profile.phone_number') + + section = tables.Column(accessor='profile.section') + + class Meta: + attrs = { + 'class': 'table table-condensed table-striped table-hover' + } + template_name = 'django_tables2/bootstrap4.html' + fields = ('last_name', 'first_name', 'username', 'email', ) + model = User + row_attrs = { + 'class': 'table-row', + 'data-href': lambda record: record.pk + } diff --git a/apps/member/tokens.py b/apps/registration/tokens.py similarity index 100% rename from apps/member/tokens.py rename to apps/registration/tokens.py diff --git a/apps/registration/urls.py b/apps/registration/urls.py index e752922c..ce2977dc 100644 --- a/apps/registration/urls.py +++ b/apps/registration/urls.py @@ -8,6 +8,7 @@ from . import views app_name = 'registration' urlpatterns = [ path('signup/', views.UserCreateView.as_view(), name="signup"), - path('accounts/activate/sent', views.UserActivationEmailSentView.as_view(), name='account_activation_sent'), - path('accounts/activate//', views.UserActivateView.as_view(), name='account_activation'), + path('validate_email/sent', views.UserActivationEmailSentView.as_view(), name='account_activation_sent'), + path('validate_email//', views.UserActivateView.as_view(), name='account_activation'), + path('validate_user/', views.FutureUserListView.as_view(), name="future_user_list"), ] diff --git a/apps/registration/views.py b/apps/registration/views.py index f4a26c3b..543f2fb9 100644 --- a/apps/registration/views.py +++ b/apps/registration/views.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.shortcuts import resolve_url @@ -11,10 +12,13 @@ from django.utils.http import urlsafe_base64_decode from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_protect from django.views.generic import CreateView, TemplateView +from django_tables2 import SingleTableView from member.forms import ProfileForm -from member.tokens import account_activation_token +from permission.views import ProtectQuerysetMixin from .forms import SignUpForm +from .tables import FutureUserTable +from .tokens import account_activation_token class UserCreateView(CreateView): @@ -23,8 +27,8 @@ class UserCreateView(CreateView): """ form_class = SignUpForm - success_url = reverse_lazy('member:login') - template_name = 'member/signup.html' + success_url = reverse_lazy('registration:account_activation_sent') + template_name = 'registration/signup.html' second_form = ProfileForm def get_context_data(self, **kwargs): @@ -108,3 +112,22 @@ class UserActivationEmailSentView(TemplateView): template_name = 'registration/account_activation_email_sent.html' title = _('Account activation email sent') + +class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): + """ + Affiche la liste des utilisateurs, avec une fonction de recherche statique + """ + model = User + table_class = FutureUserTable + template_name = 'registration/future_user_list.html' + + def get_queryset(self, **kwargs): + return super().get_queryset().filter(profile__registration_valid=False) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["title"] = _("Unregistered users") + + return context + diff --git a/templates/base.html b/templates/base.html index 8e16a419..3c2c637f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -94,6 +94,13 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans 'Clubs' %} {% endif %} + {% if "member.change_profile_registration_valid"|has_perm:user %} + + {% endif %} {% if "activity.activity"|not_empty_model_list %}