Gestion des adresses et du mot de passe

This commit is contained in:
Robin Champenois 2021-01-24 23:11:10 +01:00
parent 4e683f62e1
commit 90aa558896
17 changed files with 650 additions and 188 deletions

View file

@ -1,11 +1,14 @@
# coding: utf-8
import unicodedata
from django import forms
from django.contrib.auth.forms import PasswordResetForm
from django.utils import timezone
from simple_email_confirmation.models import EmailAddress
import re
from .models import Normalien, Stage, Lieu, AvisLieu, AvisStage
from .models import Normalien, Stage, Lieu, AvisLieu, AvisStage, User
from .widgets import LatLonField
# Sur-classe utile
@ -107,3 +110,40 @@ class FeedbackForm(forms.Form):
objet = forms.CharField(label="Objet", required=True)
message = forms.CharField(label="Message", required=True, widget=forms.widgets.Textarea())
# Nouvelle adresse mail
class AdresseEmailForm(forms.Form):
def __init__(self, _user, **kwargs):
self._user = _user
super().__init__(**kwargs)
email = forms.EmailField(widget=forms.widgets.EmailInput(attrs={"placeholder": "Nouvelle adresse"}))
def clean_email(self):
email = self.cleaned_data["email"]
if EmailAddress.objects.filter(user=self._user, email=email).exists():
raise forms.ValidationError(
"Cette adresse est déjà associée à ce compte")
return email
def _unicode_ci_compare(s1, s2):
"""
Perform case-insensitive comparison of two identifiers, using the
recommended algorithm from Unicode Technical Report 36, section
2.11.2(B)(2).
"""
return unicodedata.normalize('NFKC', s1).casefold() == unicodedata.normalize('NFKC', s2).casefold()
# (Ré)initialisation du mot de passe
class ReinitMdpForm(PasswordResetForm):
def get_users(self, email):
"""Override default method to allow unusable passwords"""
email_field_name = User.get_email_field_name()
active_users = User._default_manager.filter(**{
'%s__iexact' % email_field_name: email,
'is_active': True,
})
return (
u for u in active_users
if _unicode_ci_compare(email, getattr(u, email_field_name))
)

View file

@ -77,8 +77,14 @@ def forwards(apps, schema_editor):
if "@" not in user.username:
print(user.username)
continue
entrance_year = "20" + saccount.extra_data.get(
entrance_year = saccount.extra_data.get(
"entrance_year", user.username.split("@")[1])
try:
entrance_year = 2000 + int(entrance_year)
except ValueError:
print(entrance_year)
continue
new_conns.append(CASAccount(user=user, cas_login=clipper,
entrance_year=int(entrance_year)))

View file

@ -35,8 +35,9 @@ class Normalien(models.Model):
promotion = models.CharField(u"Promotion", max_length=40, blank=True)
mail = models.EmailField(u"Adresse e-mail permanente",
max_length=200, blank=True)
contactez_moi = models.BooleanField(u"Inviter les visiteurs à me contacter",
default=True)
contactez_moi = models.BooleanField(
u"Inviter les visiteurs à me contacter",
default=True, help_text="Affiche votre adresse e-mail principale sur votre profil public")
bio = models.TextField(u"À propos de moi", blank=True, default="")
en_scolarite = models.BooleanField(default=False, blank=True)

View file

@ -177,14 +177,14 @@
display: block;
text-align: left;
font-size: 0.95em;
color: $compl * 0.8;
color: darken($compl, 20%);
margin-top: 0;
width: auto;
}
.help_text {
text-align: right;
color: $fond * 0.4;
color: darken($fond, 60%);
}
.input {

View file

@ -46,7 +46,7 @@ em, i {
a {
font-weight: bold;
color: $compl * 0.9;
color: darken($compl, 10%);
text-decoration: none;
}
@ -107,7 +107,7 @@ header {
color: lighten($fond, 40%);
&:hover {
background: $barre * 0.6;
background: darken($barre, 40%);
}
}
}
@ -181,7 +181,6 @@ p.warning {
li {
display: table;
width: 100%;
//border: 1px solid $fond * 1.3;
background: #fff;
margin: 12px;
@ -363,6 +362,57 @@ section.profil {
}
}
section.two-cols {
display: flex;
display: flexbox;
align-items: center;
& > * {
flex: 1;
width: 50%;
margin: 10px;
}
}
ul.mes-emails {
li {
display: flex;
background: #fff;
margin: 5px;
padding: 10px;
min-height: 70px;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
& > * {
flex: 1;
text-align: center;
}
.adresse {
text-align: left;
font-weight: bold;
}
.confirmee {
width: 20px;
}
.supprimer {
flex: 0.7;
}
form {
display: flex;
align-items: center;
justify-content: space-around;
.field {
flex: 1;
}
}
}
}
//
//
// Détail d'un stage
@ -432,7 +482,7 @@ input[type="submit"], .btn {
font: $textfontsize $textfont;
background-color: $fond;
color: #fff;
border: 1px solid $fond * 0.7;
border: 1px solid darken($fond, 30%);
border-radius: 5px;
padding: 8px 12px;
display: inline-block;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
{% extends "avisstage/base.html" %}
{% load staticfiles %}
{% block title %}Confirmation requise - ExperiENS{% endblock %}
{% block content %}
<h1>Confirmation requise</h1>
<article>
{% if object.confirmed_at %}
<p>L'adresse {{ object.email }} a déjà été confirmée.</p>
{% else %}
<p>Un mail de confirmation vous a été envoyé à l'adresse {{ object.email }} pour la vérifier.</p>
<p>Merci de cliquer sur le lien inclus pour confirmer qu'elle est correcte.</p>
<p>Si vous ne recevez rien, vérifier dans vos indésirables.</p>
{% endif %}
<p><a href="{% url "avisstage:parametres" %}">Retour</a></p>
</article>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "avisstage/base.html" %}
{% load staticfiles %}
{% block content %}
<h1>Définir un mot de passe</h1>
<form action="" method="post">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="field">
<label>Nom d'utilisateur</label>
<div class="input">
{{ user.username }}
</div>
</div>
{% for field in form %}
{{ field.errors }}
<div class="field">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="input">
{{ field }}
{% if field.help_text %}
<p class="help_text">{{ field.help_text }}</p>
{% endif %}
</div>
</div>
{% endfor %}
<input type="submit" value="Enregistrer" />
</form>
{% endblock %}

View file

@ -0,0 +1,18 @@
{% extends "avisstage/base.html" %}
{% load staticfiles %}
{% block title %}Supprimer une adresse mail - ExperiENS{% endblock %}
{% block content %}
<h1>Supprimer une adresse mail</h1>
<article>
<section class="profil">
<form action="" method="POST">
{% csrf_token %}
<p>Êtes-vous sûr⋅e de vouloir supprimer l'adresse mail {{ object.email }} ?</p>
<p><a href="{% url "avisstage:parametres" %}">Retour</a> &nbsp; <input type="submit" value="Confirmer la suppression"></p>
</form>
</section>
</article>
{% endblock %}

View file

@ -0,0 +1,76 @@
{% extends "avisstage/base.html" %}
{% load staticfiles %}
{% block title %}Mes paramètres - ExperiENS{% endblock %}
{% block content %}
<h1>Mes paramètres</h1>
<article>
<h2>Adresses e-mail</h2>
<ul class="mes-emails">
{% for email in request.user.email_address_set.all %}
<li>
<span class="adresse">{{ email.email }}
<span class="confirmee">{{ email.confirmed_at|yesno:"&#10003;,&#10007;"|safe }}</span></span>
{% if email.confirmed_at %}
<span class="principale">
{% if email.email == user.email %}
Principale
{% else %}
<form action="{% url "avisstage:emails_principal" email.email %}" method="POST">
{% csrf_token %}
<input type="submit" value="Rendre principale">
</form>
{% endif %}
</span>
{% else %}
<span class="confirmer">
<form action="{% url "avisstage:emails_reconfirme" email.email %}" method="POST">
{% csrf_token %}
<input type="submit" value="Renvoyer le lien de confirmation">
</form>
</span>
{% endif %}
<span class="supprimer">
{% if not email.email == user.email %}
<a href="{% url "avisstage:emails_supprime" email.email %}">Supprimer</a>
{% endif %}
</span>
</li>
{% endfor %}
<li>
<form action="" method="POST">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
{{ field.errors }}
<div class="field">
<div class="input">
{{ field }}
</div>
</div>
{% endfor %}
<input type="submit" value="Ajouter l'adresse">
</form>
</li>
</ul>
</article>
<article>
<h2>Mot de passe</h2>
<section class="profil">
{% if request.user.password and request.user.has_usable_password %}
<p>Un mot de passe interne est déjà défini pour ce compte.</p>
{% else %}
<p>Aucun mot de passe n'est défini pour ce compte. Créez-en un pour pouvoir vous connecter après la fin de votre scolarité à l'ENS.</p>
{% endif %}
<form action="{% url "avisstage:mdp_demande" %}" method="POST">
{% csrf_token %}
<input type="submit" value="Définir un nouveau mot de passe" />
</form>
<p>En cliquant sur ce bouton, un lien unique vous sera envoyé à votre adresse e-mail principale ({{ request.user.email }}) qui vous donnera accès au formulaire d'édition du mot de passe.</p>
</section>
</article>
{% endblock %}

View file

@ -18,6 +18,13 @@
</div>
</div>
{% endfor %}
<input type="submit" />
<div class="field">
<label>Adresse e-mail</label>
<div class="input">
{{ request.user.email }}
<p class="help_text">Allez dans <a href="{% url "avisstage:parametres" %}">les paramètres de connexion</a> pour modifier votre adresse principale</p>
</div>
</div>
<input type="submit" value="Enregistrer" />
</form>
{% endblock %}

View file

@ -0,0 +1,8 @@
Bonjour,
Pour créer ou modifier le mot de passe associé à votre compte {{ user.get_username }}, merci de cliquer sur le lien suivant ou de le copier dans votre navigateur :
{{ protocol }}://{{ domain }}{% url 'avisstage:mdp_edit' uidb64=uid token=token %}
Cordialement,
L'équipe ExperiENS

View file

@ -0,0 +1 @@
[ExperiENS] Définition du mot de passe

View file

@ -8,34 +8,38 @@
<article>
<h2>Mon compte</h2>
<section class="profil">
{% if user.profil.en_scolarite %}
<h3 class="scolarite">Statut : En scolarité</h3>
<p>Vous pouvez accéder à l'ensemble du site, et aux fiches de stages.</p>
<p>Quand vous n'aurez plus de compte clipper (après votre scolarité), votre accès sera restreint à vos propres expériences, que vous pourrez ajouter, modifier, supprimer.</p>
<p>Pensez à renseigner une adresse e-mail non-ENS pour conserver cet accès, et permettre aux futur⋅e⋅s normalien⋅ne⋅s de toujours vous contacter !</p>
{% else %}
<h3 class="scolarite">Statut : Archicube</h3>
<p>Vous ne pouvez plus accéder qu'à vos propres expériences pour les modifier, et tenir à jour votre profil.</p>
<p>Si vous êtes encore en scolarité, merci de vous <a href="{% url "authens:login.cas" %}">reconnecter en passant par le serveur d'authentification de l'ENS</a> pour mettre à jour votre statut.</p>
{% endif %}
<p><i>Le statut est mis à jour automatiquement chaque année selon le mode de connexion que vous utilisez.</i></p>
</section>
<section class="profil">
<h3>Adresse e-mail</h3>
{% if not user.profil.has_nonENS_email %}<p align="center" class="warning">Vous n'avez pas renseigné d'adresse mail autre que celle de l'ENS. Pensez à le faire, pour que les générations futures puissent toujours vous contacter !</p>{% endif %}
<p><a href="{% url "avisstage:perso" %}">Gérer les adresses e-mail liées à mon compte</a></p>
</section>
<section class="profil">
<h3>Mode de connexion</h3>
{% if user.profil.en_scolarite %}<p>En scolarité, utilisez le serveur central d'authentification pour vous connecter. Quand vous n'aurez plus de compte clipper, vous devrez vous connecter directement via l'accès archicubes, avec votre login {{ user.cas_account.cas_login }} et le mot de passe spécifique à ExperiENS que vous aurez défini.</p>{% endif %}
{% if not user.password or not user.has_usable_password %}<p class="warning" align="center">Vous n'avez pas créé de mot de passe interne à ExperiENS. Pensez-y pour garder l'accès au site quand vous n'aurez plus de compte clipper !</p>{% endif %}
<p><a href="{% url "authens:reset.pwd" %}">Créer / changer mon mot de passe ExperiENS</a></p>
<section class="two-cols">
<section class="profil">
{% if user.profil.en_scolarite %}
<h3 class="scolarite">Statut : En scolarité</h3>
<p>Vous pouvez accéder à l'ensemble du site, et aux fiches de stages.</p>
<p>Quand vous n'aurez plus de compte clipper (après votre scolarité), votre accès sera restreint à vos propres expériences, que vous pourrez ajouter, modifier, supprimer.</p>
<p>Pensez à renseigner une adresse e-mail non-ENS pour conserver cet accès, et permettre aux futur⋅e⋅s normalien⋅ne⋅s de toujours vous contacter !</p>
{% else %}
<h3 class="scolarite">Statut : Archicube</h3>
<p>Vous ne pouvez plus accéder qu'à vos propres expériences pour les modifier, et tenir à jour votre profil.</p>
<p>Si vous êtes encore en scolarité, merci de vous <a href="{% url "authens:login.cas" %}">reconnecter en passant par le serveur d'authentification de l'ENS</a> pour mettre à jour votre statut.</p>
{% endif %}
<hr />
<p><i>Le statut est mis à jour automatiquement chaque année selon le mode de connexion que vous utilisez.</i></p>
</section>
<section class="profil">
<h3>Connexion</h3>
<p><b>Adresse e-mail principale :</b><br/> {{ user.email }}</p>
{% if not user.profil.has_nonENS_email %}<p align="center" class="warning">Vous n'avez pas renseigné d'adresse mail autre que celle de l'ENS. Pensez à le faire, pour que les générations futures puissent toujours vous contacter !</p>{% endif %}
<hr/>
<p><b>Mot de passe interne :</b> {% if user.password and user.has_usable_password %}Défini{% else %}Non défini{% endif %}</p>
{% if not user.password or not user.has_usable_password %}<p class="warning" align="center">Pensez à définir un mot de passe propre à ExperiENS pour garder l'accès au site quand vous n'aurez plus de compte clipper !</p>{% endif %}
{% if user.profil.en_scolarite %}<p>En scolarité, utilisez le serveur central d'authentification pour vous connecter. Quand vous n'aurez plus de compte clipper, vous devrez vous connecter directement via l'accès archicubes, avec votre login <b>{{ user.username }}</b> et le mot de passe spécifique à ExperiENS que vous aurez défini.</p>{% endif %}
<hr/>
<p><a href="{% url "avisstage:parametres" %}">Gérer mes paramètres de connexion</a></p>
</section>
</section>
</article>
<article>
<h2><a href="{% url "avisstage:profil" user.username %}">Mon profil public</a> <a href="{% url "avisstage:profil_edit" %}" class="edit-btn btn">Modifier mes infos</a></h2>
<h2>Mon profil public <a href="{% url "avisstage:profil" user.username %}" class="btn">Voir</a> <a href="{% url "avisstage:profil_edit" %}" class="edit-btn btn">Modifier mes infos</a></h2>
{% with object=user.profil %}
<section class="profil">
<div class="infos">

View file

@ -24,6 +24,23 @@ urlpatterns = [
path('profil/show/<str:username>/', views.ProfilView.as_view(),
name='profil'),
path('profil/edit/', views.ProfilEdit.as_view(), name='profil_edit'),
path('profil/parametres/', views.MesParametres.as_view(), name='parametres'),
path('profil/emails/<str:email>/aconfirmer/',
views.AdresseAConfirmer.as_view(), name="emails_aconfirmer"),
path('profil/emails/<str:email>/supprime/', views.SupprimeAdresse.as_view(),
name="emails_supprime"),
path('profil/emails/<str:email>/reconfirme/',
views.ReConfirmeAdresse.as_view(),
name="emails_reconfirme"),
path('profil/emails/<str:email>/principal/',
views.RendAdressePrincipale.as_view(), name="emails_principal"),
path('profil/emails/confirme/<str:key>/', views.ConfirmeAdresse.as_view(),
name="emails_confirme"),
path('profil/mdp/demande/',
views.EnvoieLienMotDePasse.as_view(), name="mdp_demande"),
path('profil/mdp/<str:uidb64>/<str:token>/',
views.DefinirMotDePasse.as_view(), name="mdp_edit"),
path('recherche/', views.recherche, name='recherche'),
path('recherche/resultats/', views.recherche_resultats,
name='recherche_resultats'),

View file

@ -2,21 +2,31 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import DetailView, ListView
from django.views.generic.edit import UpdateView, CreateView
from django.views.generic import (
DetailView, ListView, UpdateView, CreateView, TemplateView, DeleteView,
FormView, View
)
from django.views.generic.detail import SingleObjectMixin
from django import forms
from django.urls import reverse
from django.urls import reverse, reverse_lazy
from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required
from django.contrib.auth.tokens import default_token_generator
from django.contrib.auth.views import PasswordResetConfirmView
from django.contrib import messages
from braces.views import LoginRequiredMixin
from django.http import JsonResponse, HttpResponseForbidden, Http404
from django.core.mail import send_mail
from django.db.models import Q, Count
from collections import Counter, defaultdict
from simple_email_confirmation.models import EmailAddress
from .models import Normalien, Stage, Lieu, AvisLieu, AvisStage
from .forms import StageForm, LieuForm, AvisStageForm, AvisLieuForm, FeedbackForm
from .forms import (
StageForm, LieuForm, AvisStageForm, AvisLieuForm, FeedbackForm, AdresseEmailForm,
ReinitMdpForm
)
from .utils import en_scolarite
from .views_search import *
@ -343,3 +353,126 @@ def statistiques(request):
'num_lieux_utiles': nlieux,
'num_par_longueur': nbylength,
})
#
# Compte
#
class MesAdressesMixin(LoginRequiredMixin):
slug_url_kwarg = "email"
slug_field = "email"
confirmed_only = False
def get_queryset(self, *args, **kwargs):
qs = self.request.user.email_address_set.all()
if self.confirmed_only:
qs = qs.filter(confirmed_at__isnull=False)
return qs
def _send_confirm_mail(email, request):
confirm_url = request.build_absolute_uri(
reverse("avisstage:emails_confirme", kwargs={"key": email.key}))
send_mail(
"[ExperiENS] Confirmez votre adresse a-mail",
"""Bonjour,
Vous venez d'ajouter cette adresse e-mail à votre compte ExperiENS.
Pour la vérifier, merci de cliquer sur le lien suivant, ou de copier l'adresse dans votre navigateur :
{confirm_url}
Cordialement,
L'équipe ExperiENS""".format(confirm_url=confirm_url),
'experiens-nepasrepondre@eleves.ens.fr',
[email.email],
fail_silently=False,
)
return redirect(reverse("avisstage:emails_aconfirmer",
kwargs={"email": email.email}))
class MesParametres(LoginRequiredMixin, FormView):
model = EmailAddress
template_name = "avisstage/compte/parametres.html"
form_class = AdresseEmailForm
def get_form_kwargs(self, *args, **kwargs):
kwargs = super().get_form_kwargs(*args, **kwargs)
kwargs["_user"] = self.request.user
return kwargs
def form_valid(self, form):
new = EmailAddress.objects.create_unconfirmed(
form.cleaned_data["email"], self.request.user)
return _send_confirm_mail(new, self.request)
class RendAdressePrincipale(MesAdressesMixin, SingleObjectMixin, View):
model = EmailAddress
confirmed_only = True
def post(self, *args, **kwargs):
if not hasattr(self, "object"):
self.object = self.get_object()
self.request.user.email = self.object.email
self.request.user.save()
return redirect(reverse("avisstage:parametres"))
class AdresseAConfirmer(MesAdressesMixin, DetailView):
model = EmailAddress
template_name = "avisstage/compte/aconfirmer.html"
class ReConfirmeAdresse(MesAdressesMixin, DetailView):
model = EmailAddress
def post(self, *args, **kwargs):
email = self.get_object()
if email.confirmed_at is None:
return _send_confirm_mail(email, self.request)
return redirect(reverse("avisstage:parametres"))
class ConfirmeAdresse(LoginRequiredMixin, View):
def get(self, *args, **kwargs):
try:
email = EmailAddress.objects.confirm(self.kwargs["key"],
self.request.user, True)
except Exception as e:
raise Http404()
messages.add_message(
self.request, messages.SUCCESS,
"L'adresse email {email} a bien été confirmée".format(email=email.email))
return redirect(reverse("avisstage:parametres"))
class SupprimeAdresse(MesAdressesMixin, DeleteView):
model = EmailAddress
template_name = "avisstage/compte/email_supprime.html"
success_url = reverse_lazy("avisstage:parametres")
def get_queryset(self, *args, **kwargs):
qs = super().get_queryset(*args, **kwargs)
return qs.exclude(email=self.request.user.email)
class EnvoieLienMotDePasse(LoginRequiredMixin, View):
def post(self, *args, **kwargs):
form = ReinitMdpForm({"email": self.request.user.email})
form.is_valid()
form.save(
email_template_name = 'avisstage/mails/reinit_mdp.html',
from_email = 'experiens-nepasrepondre@eleves.ens.fr',
subject_template_name = 'avisstage/mails/reinit_mdp.txt',
)
messages.add_message(
self.request, messages.INFO,
"Un mail a été envoyé à {email}. Merci de vérifier vos indésirables si vous ne le recevez pas bientôt".format(email=self.request.user.email)
)
return redirect(reverse("avisstage:parametres"))
class DefinirMotDePasse(PasswordResetConfirmView):
template_name = "avisstage/compte/edit_mdp.html"
success_url = reverse_lazy("avisstage:perso")
def get_user(self, *args, **kwargs):
user = super().get_user(*args, **kwargs)
if self.request.user.is_authenticated and user != self.request.user:
raise Http404("Ce token n'est pas valide pour votre compte")
return user

View file

@ -35,9 +35,9 @@ INSTALLED_APPS = [
'django_elasticsearch_dsl',
#'allauth',
#'allauth.account', # Uncomment for transition
#'allauth.socialaccount', # Allauth -> Authens
#'allauth', # Uncomment that part when you
#'allauth.account', # apply migration
#'allauth.socialaccount', # Allauth -> AuthENS
'widget_tweaks',