Merge branch 'maj2021' into 'master'

Mise à jour 2021

Un certain nombre de changements qu'il était temps d'apporter :
- passage à Django 2.2
- basculement de Allauth à AuthENS (=> gestion des adresses mail avec django-simple-email-confirmation)
- statut "en scolarité/archicube" automatique
- débugs divers (carte des lieux, ...)
- mise à jour des dépendances

See merge request klub-dev-ens/experiENS!13
This commit is contained in:
Robin Champenois 2021-02-07 18:23:24 +01:00
commit 9c1092cf8f
36 changed files with 1082 additions and 420 deletions

View file

@ -9,7 +9,7 @@ Il est visible sur https://www.eleves.ens.fr/experiens/
Clonez le dépôt. Installez les pré-requis : Clonez le dépôt. Installez les pré-requis :
sudo apt-get install libxlst-dev python3.4-dev sudo apt-get install libxlst-dev libsals2-dev libxml2-dev libldap2-dev libssl-dev
On a besoin de SpatiaLite pour une base de données GIS. Essayez On a besoin de SpatiaLite pour une base de données GIS. Essayez

View file

@ -3,6 +3,8 @@ from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User from django.contrib.auth.models import User
from avisstage.models import * from avisstage.models import *
import authens.models as authmod
class NormalienInline(admin.StackedInline): class NormalienInline(admin.StackedInline):
model = Normalien model = Normalien
inline_classes = ("collapse open",) inline_classes = ("collapse open",)
@ -32,3 +34,6 @@ admin.site.register(User, UserAdmin)
admin.site.register(Lieu) admin.site.register(Lieu)
admin.site.register(StageMatiere, StageMatiereAdmin) admin.site.register(StageMatiere, StageMatiereAdmin)
admin.site.register(Stage, StageAdmin) admin.site.register(Stage, StageAdmin)
admin.site.register(authmod.CASAccount)
admin.site.register(authmod.OldCASAccount)

View file

@ -1,57 +0,0 @@
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.models import SocialAccount
from allauth_ens.adapter import LongTermClipperAccountAdapter, get_ldap_infos
class AccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
return False
class SocialAccountAdapter(LongTermClipperAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
# sociallogin.account is a SocialAccount instance.
# See https://github.com/pennersr/django-allauth/blob/master/allauth/socialaccount/models.py
if sociallogin.account.provider == 'clipper':
return True
# It returns AccountAdapter.is_open_for_signup().
# See https://github.com/pennersr/django-allauth/blob/master/allauth/socialaccount/adapter.py
return super().is_open_for_signup(request, sociallogin)
# TODO : HOTFIX pour un bug d'allauth_ens
# On remplace la déduplication des comptes faites avec "entrance_year"
# par une déduplication sur le nom d'utilisateur
# (Copié de allauth_ens)
def pre_social_login(self, request, sociallogin):
if sociallogin.account.provider != "clipper":
return super(LongTermClipperAccountAdapter,
self).pre_social_login(request, sociallogin)
clipper_uid = sociallogin.account.uid
try:
old_conn = SocialAccount.objects.get(provider='clipper_inactive',
uid=clipper_uid)
except SocialAccount.DoesNotExist:
return
ldap_data = get_ldap_infos(clipper_uid)
sociallogin._ldap_data = ldap_data
if ldap_data is None or 'entrance_year' not in ldap_data:
raise ValueError("No entrance year in LDAP data")
old_conn_username = old_conn.user.username
# HOTFIX ICI
if self.get_username(clipper_uid, ldap_data) != old_conn_username:
email = ldap_data.get('email', get_clipper_email(clipper_uid))
remove_email(old_conn.user, email)
return
old_conn.provider = 'clipper'
old_conn.save()
sociallogin.lookup()

View file

@ -1,7 +1,4 @@
from __future__ import unicode_literals
from django.apps import AppConfig from django.apps import AppConfig
class AvisstageConfig(AppConfig): class AvisstageConfig(AppConfig):
name = 'avisstage' name = 'avisstage'

View file

@ -1,11 +1,14 @@
# coding: utf-8 import unicodedata
from django import forms from django import forms
from django.contrib.auth.forms import PasswordResetForm
from django.utils import timezone from django.utils import timezone
from simple_email_confirmation.models import EmailAddress
import re import re
from .models import Normalien, Stage, Lieu, AvisLieu, AvisStage from .models import Normalien, Stage, Lieu, AvisLieu, AvisStage, User
from .widgets import LatLonField from .widgets import LatLonField
# Sur-classe utile # Sur-classe utile
@ -107,3 +110,40 @@ class FeedbackForm(forms.Form):
objet = forms.CharField(label="Objet", required=True) objet = forms.CharField(label="Objet", required=True)
message = forms.CharField(label="Message", required=True, widget=forms.widgets.Textarea()) 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

@ -0,0 +1,16 @@
from django.core.management.base import BaseCommand, CommandError
from avisstage.models import Normalien
from django.utils import timezone
from datetime import timedelta
class Command(BaseCommand):
help = 'Réinitialise les statuts "en scolarité" de tout le monde'
def add_arguments(self, parser):
return
def handle(self, *args, **options):
old_conn = timezone.now() - timedelta(days=365)
Normalien.objects.all().update(last_cas_connect=t)
self.stdout.write(self.style.SUCCESS(u'Terminé'))

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,107 @@
from django.apps import apps as global_apps
from django.db import migrations
from django.utils import timezone
def forwards(apps, schema_editor):
User = apps.get_model('auth', 'User')
try:
CASAccount = apps.get_model('authens', 'CASAccount')
except LookupError:
return
try:
SocialAccount = apps.get_model('socialaccount', 'SocialAccount')
OldEmailAddress = apps.get_model('account', 'EmailAddress')
except LookupError:
# Allauth not installed
# Simply create CAS accounts for every profile
# This procedure is not meant to be fast
from authens.shortcuts import fetch_cas_account
def migrate_user(user):
ldap_info = fetch_cas_account(user.username)
if ldap_info:
entrance_year = ldap_info["entrance_year"]
CASAccount.objects.create(
user=user, cas_login=user.username,
entrance_year=entrance_year
)
for user in User.objects.all():
migrate_user(user)
return
NewEmailAddress = apps.get_model('simple_email_confirmation',
'EmailAddress')
from simple_email_confirmation.models import EmailAddressManager
# Transfer from allauth to authens
# Assumes usernames have the format <clipper>@<promo>
# Assumes no clashing clipper accounts have ever been found
oldusers = (
User.objects.all().prefetch_related(
"emailaddress_set", "socialaccount_set")
)
is_ens_mail = lambda mail: (
mail is not None and (mail.endswith("ens.fr") or mail.endswith("ens.psl.eu")))
new_conns = []
new_mails = []
for user in oldusers:
# Move EmailAddress to new model
addresses = user.emailaddress_set.all()
for addr in addresses:
newaddr = NewEmailAddress(
user=user, email=addr.email,
set_at=timezone.now(),
confirmed_at=(timezone.now() if addr.verified else None),
key=EmailAddressManager().generate_key(),
)
if addr.primary and user.email != addr.email:
print("Adresse principale inconsistante",
user.email, addr.email)
new_mails.append(newaddr)
# Create new CASAccount connexion
saccounts = user.socialaccount_set.all()
if not saccounts:
continue
if len(saccounts) > 1:
print(saccounts)
saccount = saccounts[0]
clipper = saccount.uid
if "@" not in user.username:
print(user.username)
continue
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)))
NewEmailAddress.objects.bulk_create(new_mails)
CASAccount.objects.bulk_create(new_conns)
class Migration(migrations.Migration):
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop),
]
dependencies = [
('avisstage', '0003_auto_20210117_1208'),
('authens', '0002_old_cas_account'),
]
if global_apps.is_installed('allauth'):
dependencies.append(('socialaccount', '0003_extra_data_default_dict'))
if global_apps.is_installed('simple_email_confirmation'):
dependencies.append(('simple_email_confirmation', '0001_initial'))

View file

@ -0,0 +1,18 @@
# Generated by Django 2.2.17 on 2021-01-17 20:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('avisstage', '0004_allauth_to_authens'),
]
operations = [
migrations.AddField(
model_name='normalien',
name='en_scolarite',
field=models.BooleanField(blank=True, default=False),
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 2.2.17 on 2021-01-31 18:54
import avisstage.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('avisstage', '0005_normalien_en_scolarite'),
]
operations = [
migrations.RemoveField(
model_name='normalien',
name='en_scolarite',
),
migrations.RemoveField(
model_name='normalien',
name='mail',
),
migrations.AddField(
model_name='normalien',
name='last_cas_login',
field=models.DateField(default=avisstage.models._default_cas_login),
),
migrations.AlterField(
model_name='normalien',
name='contactez_moi',
field=models.BooleanField(default=True, help_text='Affiche votre adresse e-mail principale sur votre profil public', verbose_name='Inviter les visiteurs à me contacter'),
),
]

View file

@ -1,10 +1,3 @@
# coding: utf-8
from __future__ import unicode_literals
from allauth.account.models import EmailAddress
from allauth.socialaccount.models import SocialAccount
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -20,26 +13,36 @@ from django.utils.html import strip_tags
from taggit_autosuggest.managers import TaggableManager from taggit_autosuggest.managers import TaggableManager
from tinymce.models import HTMLField as RichTextField from tinymce.models import HTMLField as RichTextField
from .utils import choices_length from authens.signals import post_cas_connect
from .statics import DEPARTEMENTS_DEFAUT, PAYS_OPTIONS, TYPE_LIEU_OPTIONS, TYPE_STAGE_OPTIONS, TYPE_LIEU_DICT, TYPE_STAGE_DICT, NIVEAU_SCOL_OPTIONS, NIVEAU_SCOL_DICT from authens.models import CASAccount
import ldap from datetime import timedelta
from .utils import choices_length, is_email_ens
from .statics import (
DEPARTEMENTS_DEFAUT, PAYS_OPTIONS, TYPE_LIEU_OPTIONS, TYPE_STAGE_OPTIONS, TYPE_LIEU_DICT,
TYPE_STAGE_DICT, NIVEAU_SCOL_OPTIONS, NIVEAU_SCOL_DICT
)
def _default_cas_login():
return (timezone.now()-timedelta(days=365)).date()
# #
# Profil Normalien (extension du modèle User) # Profil Normalien (extension du modèle User)
# #
class Normalien(models.Model): class Normalien(models.Model):
user = models.OneToOneField(User, related_name="profil") user = models.OneToOneField(User, related_name="profil",
on_delete=models.SET_NULL, null=True)
# Infos spécifiques # Infos spécifiques
nom = models.CharField(u"Nom complet", max_length=255, blank=True) nom = models.CharField(u"Nom complet", max_length=255, blank=True)
promotion = models.CharField(u"Promotion", max_length=40, blank=True) promotion = models.CharField(u"Promotion", max_length=40, blank=True)
mail = models.EmailField(u"Adresse e-mail permanente", contactez_moi = models.BooleanField(
max_length=200, blank=True) u"Inviter les visiteurs à me contacter",
contactez_moi = models.BooleanField(u"Inviter les visiteurs à me contacter", default=True, help_text="Affiche votre adresse e-mail principale sur votre profil public")
default=True) bio = models.TextField(u"À propos de moi", blank=True, default="")
bio = models.TextField(u"À propos de moi", blank=True, default=""); last_cas_login = models.DateField(default=_default_cas_login)
class Meta: class Meta:
verbose_name = u"Profil élève" verbose_name = u"Profil élève"
@ -52,53 +55,66 @@ class Normalien(models.Model):
def stages_publics(self): def stages_publics(self):
return self.stages.filter(public=True).order_by('-date_debut') return self.stages.filter(public=True).order_by('-date_debut')
@cached_property
def en_scolarite(self):
return SocialAccount.objects.filter(user_id=self.user_id,
provider="clipper").exists()
def has_nonENS_email(self): def has_nonENS_email(self):
a = EmailAddress.objects.filter(user_id=self.user_id, return (
verified=True) \ self.user.email_address_set
.exclude(email__endswith="ens.fr") .exclude(confirmed_at__isnull=True)
return a.exists() .exclude(email__endswith="ens.fr")
.exclude(email__endswith="ens.psl.eu")
.exists()
)
def nom_complet(self):
if self.nom.strip():
return self.nom
return self.user.username
@property
def en_scolarite(self):
return self.last_cas_login > (timezone.now() - timedelta(days=60)).date()
@property @property
def preferred_email(self): def preferred_email(self):
a = EmailAddress.objects.filter(user_id=self.user_id, return self.user.email
verified=True) \
.exclude(email__endswith="ens.fr")\
.order_by('-primary')
if len(a) == 0:
a = EmailAddress.objects.filter(user_id=self.user_id,
verified=True) \
.order_by('-primary')
if len(a) == 0:
return ""
else:
return a[0].email
# Hook à la création d'un nouvel utilisateur : récupération de ses infos par LDAP # Hook à la création d'un nouvel utilisateur : information de base
def create_user_profile(sender, instance, created, **kwargs): def create_basic_user_profile(sender, instance, created, **kwargs):
if created: if created:
profil, created = Normalien.objects.get_or_create(user=instance) profil, created = Normalien.objects.get_or_create(user=instance)
try:
saccount = SocialAccount.objects.get(user=instance, if not created and profil.promotion != "":
provider="clipper")
except SocialAccount.DoesNotExist:
profil.save()
return return
edata = saccount.extra_data.get("ldap", {})
dep = ""
if "department_code" in edata:
dep = dict(DEPARTEMENTS_DEFAUT).get(
edata["department_code"].lower(), '')
profil.promotion = "%s %s" % (dep, edata["entrance_year"]) if "@" in instance.username:
profil.nom = edata.get("name", "") profil.promotion = instance.username.split("@")[1]
profil.save()
post_save.connect(create_basic_user_profile, sender=User)
# Hook d'authENS : information du CAS
def handle_cas_connection(sender, instance, created, cas_login, attributes, **kwargs):
profil, created = Normalien.objects.get_or_create(user=instance)
profil.last_cas_login = timezone.now().date()
if not created:
profil.save() profil.save()
return
post_save.connect(create_user_profile, sender=User) dirs = attributes.get("homeDirectory", "").split("/")
if len(dirs) < 4:
print("HomeDirectory invalide", dirs)
return
year = dirs[2]
departement = dirs[3]
dep = dict(DEPARTEMENTS_DEFAUT).get(departement.lower(), "")
profil.promotion = "%s %s" % (dep, year)
profil.nom = attributes.get("name", "")
profil.save()
post_cas_connect.connect(handle_cas_connection, sender=User)
# #
# Lieu de stage # Lieu de stage
@ -121,7 +137,7 @@ class Lieu(models.Model):
max_length=choices_length(PAYS_OPTIONS)) max_length=choices_length(PAYS_OPTIONS))
# Coordonnées # Coordonnées
objects = geomodels.GeoManager() # Requis par GeoDjango #objects = geomodels.GeoManager() # Requis par GeoDjango
coord = geomodels.PointField(u"Coordonnées", coord = geomodels.PointField(u"Coordonnées",
geography=True, geography=True,
srid = 4326) srid = 4326)
@ -163,7 +179,8 @@ class StageMatiere(models.Model):
class Stage(models.Model): class Stage(models.Model):
# Misc # Misc
auteur = models.ForeignKey(Normalien, related_name="stages") auteur = models.ForeignKey(Normalien, related_name="stages",
on_delete=models.SET_NULL, null=True)
public = models.BooleanField(u"Visible publiquement", default=False) public = models.BooleanField(u"Visible publiquement", default=False)
date_creation = models.DateTimeField(u"Créé le", default=timezone.now) date_creation = models.DateTimeField(u"Créé le", default=timezone.now)
date_maj = models.DateTimeField(u"Mis à jour le", default=timezone.now) date_maj = models.DateTimeField(u"Mis à jour le", default=timezone.now)
@ -260,7 +277,8 @@ class Stage(models.Model):
# #
class AvisStage(models.Model): class AvisStage(models.Model):
stage = models.OneToOneField(Stage, related_name="avis_stage") stage = models.OneToOneField(Stage, related_name="avis_stage",
on_delete=models.CASCADE)
chapo = models.TextField(u"En quelques mots", blank=True) chapo = models.TextField(u"En quelques mots", blank=True)
avis_ambiance = RichTextField(u"L'ambiance de travail", blank=True) avis_ambiance = RichTextField(u"L'ambiance de travail", blank=True)
@ -283,8 +301,8 @@ class AvisStage(models.Model):
class AvisLieu(models.Model): class AvisLieu(models.Model):
stage = models.ForeignKey(Stage) stage = models.ForeignKey(Stage, on_delete=models.CASCADE)
lieu = models.ForeignKey(Lieu) lieu = models.ForeignKey(Lieu, on_delete=models.CASCADE)
order = models.IntegerField("Ordre", default=0) order = models.IntegerField("Ordre", default=0)
chapo = models.TextField(u"En quelques mots", blank=True) chapo = models.TextField(u"En quelques mots", blank=True)

View file

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

View file

@ -46,7 +46,7 @@ em, i {
a { a {
font-weight: bold; font-weight: bold;
color: $compl * 0.9; color: darken($compl, 10%);
text-decoration: none; text-decoration: none;
} }
@ -107,7 +107,7 @@ header {
color: lighten($fond, 40%); color: lighten($fond, 40%);
&:hover { &:hover {
background: $barre * 0.6; background: darken($barre, 40%);
} }
} }
} }
@ -181,7 +181,6 @@ p.warning {
li { li {
display: table; display: table;
width: 100%; width: 100%;
//border: 1px solid $fond * 1.3;
background: #fff; background: #fff;
margin: 12px; 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 // Détail d'un stage
@ -432,7 +482,7 @@ input[type="submit"], .btn {
font: $textfontsize $textfont; font: $textfontsize $textfont;
background-color: $fond; background-color: $fond;
color: #fff; color: #fff;
border: 1px solid $fond * 0.7; border: 1px solid darken($fond, 30%);
border-radius: 5px; border-radius: 5px;
padding: 8px 12px; padding: 8px 12px;
display: inline-block; display: inline-block;

File diff suppressed because it is too large Load diff

View file

@ -63,10 +63,10 @@ function SelectLieuWidget(STATIC_ROOT, API_LIEU, MAPBOX_API_KEY, target, callbac
// Affiche la carte // Affiche la carte
map = L.map(map_el[0]).setView([48.8422411,2.3430553], 15); map = L.map(map_el[0]).setView([48.8422411,2.3430553], 15);
var layer = L.tileLayer('https://api.tiles.mapbox.com/v4/{id}/{z}/{x}/{y}.png?access_token={accessToken}', { var layer = L.tileLayer('https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', {
attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>', attribution: 'Map data &copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery © <a href="https://www.mapbox.com/">Mapbox</a>',
maxZoom: 18, maxZoom: 18,
id: 'mapbox.streets', id: 'mapbox/streets-v11',
accessToken: MAPBOX_API_KEY accessToken: MAPBOX_API_KEY
}); });
map.addLayer(layer); map.addLayer(layer);

View file

@ -37,9 +37,9 @@
<li><a href="{% url 'avisstage:moderation' %}">Modo</a></li> <li><a href="{% url 'avisstage:moderation' %}">Modo</a></li>
{% endif %} {% endif %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<li><a href="{% url "account_logout" %}"><span class="username">{{ user.username }}</span><br/> Déconnexion</a></li> <li><a href="{% url "authens:logout" %}"><span class="username">{{ user.username }}</span><br/> Déconnexion</a></li>
{% else %} {% else %}
<li><a href="{% url "account_login" %}">Connexion</a></li> <li><a href="{% url "authens:login" %}">Connexion</a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>

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

@ -5,10 +5,10 @@
<script type="text/javascript" src="{% static "js/render.js" %}"></script> <script type="text/javascript" src="{% static "js/render.js" %}"></script>
{% endblock %} {% endblock %}
{% block title %}Profil de {{ object.nom }} - ExperiENS{% endblock %} {% block title %}Profil de {{ object.nom_complet }} - ExperiENS{% endblock %}
{% block content %} {% block content %}
<h1>Profil de {{ object.nom }} <h1>Profil de {{ object.nom_complet }}
{% if object.user == user %} {% if object.user == user %}
<a href="{% url "avisstage:profil_edit" %}" class="btn edit-btn">Modifier mes infos</a> <a href="{% url "avisstage:profil_edit" %}" class="btn edit-btn">Modifier mes infos</a>
{% endif %} {% endif %}

View file

@ -72,7 +72,7 @@
<h1>{{ object.sujet }}</h1> <h1>{{ object.sujet }}</h1>
<p class="dates"><span class="year">{{ object.date_debut|date:"Y" }}</span><span class="debut">{{ object.date_debut|date:"d/m" }}</span><span class="fin">{{ object.date_fin|date:"d/m" }}</span></p> <p class="dates"><span class="year">{{ object.date_debut|date:"Y" }}</span><span class="debut">{{ object.date_debut|date:"d/m" }}</span><span class="fin">{{ object.date_fin|date:"d/m" }}</span></p>
</div> </div>
<p><a href="{% url "avisstage:profil" object.auteur.user.username %}">{{ object.auteur.nom }}</a> <p><a href="{% url "avisstage:profil" object.auteur.user.username %}">{{ object.auteur.nom_complet }}</a>
a fait {{ object.type_stage_fem|yesno:"cette,ce" }} <b>{{ object.type_stage_fancy }}</b> a fait {{ object.type_stage_fem|yesno:"cette,ce" }} <b>{{ object.type_stage_fancy }}</b>
{% if object.niveau_scol %}{{ object.niveau_scol_fancy }},{% endif %} {% if object.niveau_scol %}{{ object.niveau_scol_fancy }},{% endif %}
{% if object.structure %}au sein de {{ object.structure }}{% endif %}{% if object.encadrants %}, supervisé par {{ object.encadrants }}{% endif %}.</p> {% if object.structure %}au sein de {{ object.structure }}{% endif %}{% if object.encadrants %}, supervisé par {{ object.encadrants }}{% endif %}.</p>

View file

@ -18,6 +18,13 @@
</div> </div>
</div> </div>
{% endfor %} {% 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> </form>
{% endblock %} {% endblock %}

View file

@ -11,9 +11,9 @@
{% if not user.is_authenticated %} {% if not user.is_authenticated %}
<div class="entrer"> <div class="entrer">
<p><a href="{% url "clipper_login" %}" class="btn">Connexion</a></p> <p><a href="{% url "authens:login.cas" %}" class="btn">Connexion</a></p>
<p class="helptext">Connexion via le serveur central d'authentification ENS <br />(identifiants clipper)</p> <p class="helptext">Connexion via le serveur central d'authentification ENS <br />(identifiants clipper)</p>
<p class="archicubes"><a href="{% url "account_login" %}">Accès archicubes</a> <br /><i>Pour continuer à tenir à jour ses fiches, sans voir celles des autres</i></p> <p class="archicubes"><a href="{% url "authens:login.pwd" %}">Accès archicubes</a> <br /><i>Pour continuer à tenir à jour ses fiches, sans voir celles des autres</i></p>
</div> </div>
{% endif %} {% endif %}

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

@ -4,38 +4,42 @@
{% block title %}Espace personnel - ExperiENS{% endblock %} {% block title %}Espace personnel - ExperiENS{% endblock %}
{% block content %} {% block content %}
<h1>Bonjour {{ user.profil.nom }} !</h1> <h1>Bonjour {{ user.profil.nom_complet }} !</h1>
<article> <article>
<h2>Mon compte</h2> <h2>Mon compte</h2>
<section class="profil"> <section class="two-cols">
{% if user.profil.en_scolarite %} <section class="profil">
<h3 class="scolarite">Statut : En scolarité</h3> {% if user.profil.en_scolarite %}
<p>Vous pouvez accéder à l'ensemble du site, et aux fiches de stages.</p> <h3 class="scolarite">Statut : En scolarité</h3>
<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>Vous pouvez accéder à l'ensemble du site, et aux fiches de stages.</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> <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>
{% else %} <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>
<h3 class="scolarite">Statut : Archicube</h3> {% else %}
<p>Vous ne pouvez plus accéder qu'à vos propres expériences pour les modifier, et tenir à jour votre profil.</p> <h3 class="scolarite">Statut : Archicube</h3>
<p>Si vous êtes encore en scolarité, merci de vous <a href="{% url "clipper_login" %}?process=connect">reconnecter en passant par le serveur d'authentification de l'ENS</a> pour mettre à jour votre statut.</p> <p>Vous ne pouvez plus accéder qu'à vos propres expériences pour les modifier, et tenir à jour votre profil.</p>
{% endif %} <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>
<p><i>Le statut est mis à jour automatiquement chaque année selon le mode de connexion que vous utilisez.</i></p> {% endif %}
</section> <hr />
<section class="profil"> <p><i>Le statut est mis à jour automatiquement tous les deux mois selon le mode de connexion que vous utilisez.</i></p>
<h3>Adresses e-mail</h3> </section>
{% 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 %} <section class="profil">
<p><a href="{% url "account_email" %}">Gérer les adresses e-mail liées à mon compte</a></p> <h3>Connexion</h3>
</section> <p><b>Adresse e-mail principale :</b><br/> {{ user.email }}</p>
<section class="profil"> {% 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 %}
<h3>Mode de connexion</h3> <hr/>
{% 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 l'identifiant {{ user.username }}</p>{% endif %}
{% if not user.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><b>Mot de passe interne :</b> {% if user.password and user.has_usable_password %}Défini{% else %}Non défini{% endif %}</p>
<p><a href="{% url "account_change_password" %}">Créer / changer mon mot de passe ExperiENS</a></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> </section>
</article> </article>
<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 %} {% with object=user.profil %}
<section class="profil"> <section class="profil">
<div class="infos"> <div class="infos">

View file

@ -1,16 +1,18 @@
from allauth.socialaccount.models import SocialAccount from authens.tests.cas_utils import FakeCASClient
from allauth_cas.test.testcases import CASTestCase from authens.models import CASAccount, OldCASAccount
from allauth_ens.adapter import deprecate_clippers
from datetime import date from datetime import date, timedelta
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.utils import timezone
from unittest import mock
from .models import User, Normalien, Lieu, Stage, StageMatiere, AvisLieu from .models import User, Normalien, Lieu, Stage, StageMatiere, AvisLieu
class ExperiENSTestCase(CASTestCase): class ExperiENSTestCase(TestCase):
# Dummy database # Dummy database
@ -19,14 +21,16 @@ class ExperiENSTestCase(CASTestCase):
'conscrit@ens.fr', 'conscrit@ens.fr',
'conscrit') 'conscrit')
self.p_conscrit = self.u_conscrit.profil self.p_conscrit = self.u_conscrit.profil
self.p_conscrit.nom="Petit conscrit" self.p_conscrit.nom = "Petit conscrit"
self.p_conscrit.promotion="Serpentard 2000" self.p_conscrit.promotion = "Serpentard 2020"
self.p_conscrit.bio="Je suis un petit conscrit" self.p_conscrit.bio = "Je suis un petit conscrit"
self.p_conscrit.save() self.p_conscrit.save()
self.sa_conscrit = SocialAccount(user=self.u_conscrit, self.sa_conscrit = CASAccount(
provider="clipper", user=self.u_conscrit,
uid="conscrit") cas_login="conscrit",
entrance_year=2020,
)
self.sa_conscrit.save() self.sa_conscrit.save()
self.u_archi = User.objects.create_user('archicube', self.u_archi = User.objects.create_user('archicube',
@ -34,7 +38,7 @@ class ExperiENSTestCase(CASTestCase):
'archicube') 'archicube')
self.p_archi = self.u_archi.profil self.p_archi = self.u_archi.profil
self.p_archi.nom="Vieil archicube" self.p_archi.nom="Vieil archicube"
self.p_archi.promotion="Gryffondor 1994" self.p_archi.promotion="Gryffondor 2014"
self.p_archi.bio="Je suis un vieil archicube" self.p_archi.bio="Je suis un vieil archicube"
self.lieu1 = Lieu(nom="Beaux-Bâtons", type_lieu="universite", self.lieu1 = Lieu(nom="Beaux-Bâtons", type_lieu="universite",
@ -52,8 +56,8 @@ class ExperiENSTestCase(CASTestCase):
self.matiere2.save() self.matiere2.save()
self.cstage1 = Stage(auteur=self.p_conscrit, sujet="Wingardium Leviosa", self.cstage1 = Stage(auteur=self.p_conscrit, sujet="Wingardium Leviosa",
date_debut=date(2000, 5, 10), date_debut=date(2020, 5, 10),
date_fin=date(2000, 8, 26), date_fin=date(2020, 8, 26),
type_stage="recherche", type_stage="recherche",
niveau_scol="M1", public=True) niveau_scol="M1", public=True)
self.cstage1.save() self.cstage1.save()
@ -63,8 +67,8 @@ class ExperiENSTestCase(CASTestCase):
alieu1.save() alieu1.save()
self.cstage2 = Stage(auteur=self.p_conscrit, sujet="Avada Kedavra", self.cstage2 = Stage(auteur=self.p_conscrit, sujet="Avada Kedavra",
date_debut=date(2001, 5, 10), date_debut=date(2021, 5, 10),
date_fin=date(2001, 8, 26), date_fin=date(2021, 8, 26),
type_stage="sejour_dri", type_stage="sejour_dri",
niveau_scol="M2", public=False) niveau_scol="M2", public=False)
self.cstage2.save() self.cstage2.save()
@ -75,8 +79,8 @@ class ExperiENSTestCase(CASTestCase):
self.astage1 = Stage(auteur=self.p_archi, sujet="Alohomora", self.astage1 = Stage(auteur=self.p_archi, sujet="Alohomora",
date_debut=date(1994, 5, 10), date_debut=date(2014, 5, 10),
date_fin=date(1994, 8, 26), date_fin=date(2014, 8, 26),
type_stage="recherche", type_stage="recherche",
niveau_scol="M2", public=True) niveau_scol="M2", public=True)
self.astage1.save() self.astage1.save()
@ -100,7 +104,7 @@ class ExperiENSTestCase(CASTestCase):
""" """
ACCÈS PUBLICS ACCÈS PUBLIC
""" """
class PublicViewsTest(ExperiENSTestCase): class PublicViewsTest(ExperiENSTestCase):
""" """
@ -196,6 +200,7 @@ ACCÈS ARCHICUBE
class ArchicubeViewsTest(ExperiENSTestCase): class ArchicubeViewsTest(ExperiENSTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
# Connexion with password
self.client.login(username='archicube', password='archicube') self.client.login(username='archicube', password='archicube')
def assert403Archicubes(self, testurl): def assert403Archicubes(self, testurl):
@ -309,16 +314,29 @@ class ArchicubeViewsTest(ExperiENSTestCase):
class DeprecatedArchicubeViewsTest(ArchicubeViewsTest): class DeprecatedArchicubeViewsTest(ArchicubeViewsTest):
def setUp(self): @mock.patch("authens.backends.get_cas_client")
def setUp(self, mock_cas_client):
super().setUp() super().setUp()
self.sa_archi = SocialAccount(user=self.u_archi, fake_cas_client = FakeCASClient(cas_login="archicube", entrance_year=2012)
provider="clipper", mock_cas_client.return_value = fake_cas_client
uid="archicube")
self.sa_archi = OldCASAccount(
user=self.u_archi,
cas_login="archicube",
entrance_year=2012,
)
self.sa_archi.save() self.sa_archi.save()
deprecate_clippers() # First connexion through CAS
self.client.login(ticket="dummy")
self.client.logout()
# Time flies
self.p_archi.last_cas_login = (timezone.now() - timedelta(days=365)).date()
self.p_archi.save()
# New connexion with password
self.client.login(username='archicube', password='archicube') self.client.login(username='archicube', password='archicube')
@ -328,26 +346,34 @@ class DeprecatedArchicubeViewsTest(ArchicubeViewsTest):
ACCÈS EN SCOLARITE ACCÈS EN SCOLARITE
""" """
class ScolariteViewsTest(ExperiENSTestCase): class ScolariteViewsTest(ExperiENSTestCase):
def setUp(self): @mock.patch("authens.backends.get_cas_client")
def setUp(self, mock_cas_client):
super().setUp() super().setUp()
self.u_vieuxcon = User.objects.create_user('vieuxcon', fake_cas_client = FakeCASClient(cas_login="vieuxcon", entrance_year=2017)
'vieuxcon@ens.fr', mock_cas_client.return_value = fake_cas_client
'vieuxcon')
self.u_vieuxcon = User.objects.create_user(
'vieuxcon',
'vieuxcon@ens.fr',
'vieuxcon'
)
self.p_vieuxcon = self.u_vieuxcon.profil self.p_vieuxcon = self.u_vieuxcon.profil
self.p_vieuxcon.nom="Vieux con" self.p_vieuxcon.nom="Vieux con"
self.p_vieuxcon.promotion="Poufsouffle 1997" self.p_vieuxcon.promotion="Poufsouffle 2017"
self.p_vieuxcon.bio="Je suis un vieux con encore en scolarité" self.p_vieuxcon.bio="Je suis un vieux con encore en scolarité"
self.p_vieuxcon.save() self.p_vieuxcon.save()
self.sa_vieuxcon = SocialAccount(user=self.u_vieuxcon, self.sa_vieuxcon = CASAccount(
provider="clipper", user=self.u_vieuxcon,
uid="vieuxcon") cas_login="vieuxcon",
entrance_year=2017,
)
self.sa_vieuxcon.save() self.sa_vieuxcon.save()
self.vstage1 = Stage(auteur=self.p_vieuxcon, sujet="Oubliettes", self.vstage1 = Stage(auteur=self.p_vieuxcon, sujet="Oubliettes",
date_debut=date(1998, 5, 10), date_debut=date(2018, 5, 10),
date_fin=date(1998, 8, 26), date_fin=date(2018, 8, 26),
type_stage="recherche", type_stage="recherche",
niveau_scol="M1", public=False) niveau_scol="M1", public=False)
self.vstage1.save() self.vstage1.save()
@ -356,7 +382,8 @@ class ScolariteViewsTest(ExperiENSTestCase):
chapo="Pas si mal") chapo="Pas si mal")
alieu1.save() alieu1.save()
self.client.login(username='vieuxcon', password='vieuxcon') # Connexion through CAS
self.client.login(ticket="dummy")
""" """
Vérifie que les seules fiches de stages visibles sont les siennes ou celles Vérifie que les seules fiches de stages visibles sont les siennes ou celles
@ -400,7 +427,7 @@ class ScolariteViewsTest(ExperiENSTestCase):
""" """
Vérifie que la recherche et les autres pages sont accessible Vérifie que la recherche et les autres pages sont accessibles
""" """
def test_pages_visibility_scolarite(self): def test_pages_visibility_scolarite(self):
testurl = reverse('avisstage:recherche') testurl = reverse('avisstage:recherche')

View file

@ -1,4 +1,4 @@
from django.conf.urls import include, url from django.urls import include, path
from . import views, api from . import views, api
from tastypie.api import Api from tastypie.api import Api
@ -7,27 +7,45 @@ v1_api.register(api.LieuResource())
v1_api.register(api.StageResource()) v1_api.register(api.StageResource())
v1_api.register(api.AuteurResource()) v1_api.register(api.AuteurResource())
app_name = "avisstage"
urlpatterns = [ urlpatterns = [
url(r'^$', views.index, name='index'), path('', views.index, name='index'),
url(r'^perso/$', views.perso, name='perso'), path('perso/', views.perso, name='perso'),
url(r'^faq/$', views.faq, name='faq'), path('faq/', views.faq, name='faq'),
url(r'^stage/nouveau/$', views.manage_stage, name='stage_ajout'), path('stage/nouveau/', views.manage_stage, name='stage_ajout'),
url(r'^stage/(?P<pk>\w+)/$', views.StageView.as_view(), name='stage'), path('stage/<int:pk>/', views.StageView.as_view(), name='stage'),
url(r'^stage/(?P<pk>\w+)/edit/$', views.manage_stage, name='stage_edit'), path('stage/<int:pk>/edit/', views.manage_stage, name='stage_edit'),
url(r'^stage/(?P<pk>\w+)/publication/$', views.publier_stage, path('stage/<int:pk>/publication/', views.publier_stage,
name='stage_publication'), name='stage_publication'),
url(r'^403/archicubes/$', views.archicubes_interdits, path('403/archicubes/', views.archicubes_interdits,
name='403-archicubes'), name='403-archicubes'),
url(r'^lieu/save/$', views.save_lieu, name='lieu_ajout'), path('lieu/save/', views.save_lieu, name='lieu_ajout'),
url(r'^profil/show/(?P<username>[\w@]+)/$', views.ProfilView.as_view(), path('profil/show/<str:username>/', views.ProfilView.as_view(),
name='profil'), name='profil'),
url(r'^profil/edit/$', views.ProfilEdit.as_view(), name='profil_edit'), path('profil/edit/', views.ProfilEdit.as_view(), name='profil_edit'),
url(r'^recherche/$', views.recherche, name='recherche'), path('profil/parametres/', views.MesParametres.as_view(), name='parametres'),
url(r'^recherche/resultats/$', views.recherche_resultats, 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'), name='recherche_resultats'),
url(r'^recherche/items/$', views.stage_items, name='stage_items'), path('recherche/items/', views.stage_items, name='stage_items'),
url(r'^feedback/$', views.feedback, name='feedback'), path('feedback/', views.feedback, name='feedback'),
url(r'^moderation/$', views.statistiques, name='moderation'), path('moderation/', views.statistiques, name='moderation'),
url(r'^api/', include(v1_api.urls)), path('api/', include(v1_api.urls)),
] ]

View file

@ -1,6 +1,3 @@
# coding: utf-8
from allauth.socialaccount.models import SocialAccount
from functools import reduce from functools import reduce
from math import cos, radians, sqrt from math import cos, radians, sqrt
@ -18,3 +15,8 @@ def approximate_distance(a, b):
dlat = (lat_a - lat_b) dlat = (lat_a - lat_b)
distance = 6371000 * sqrt(dlon*dlon + dlat*dlat) distance = 6371000 * sqrt(dlon*dlon + dlat*dlat)
return distance return distance
def is_email_ens(mail, none=False):
if mail is None:
return none
return mail.endswith("ens.fr") or mail.endswith("ens.psl.eu")

View file

@ -2,21 +2,31 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import DetailView, ListView from django.views.generic import (
from django.views.generic.edit import UpdateView, CreateView DetailView, ListView, UpdateView, CreateView, TemplateView, DeleteView,
FormView, View
)
from django.views.generic.detail import SingleObjectMixin
from django import forms from django import forms
from django.urls import reverse from django.urls import reverse, reverse_lazy
from django.conf import settings from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_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 braces.views import LoginRequiredMixin
from django.http import JsonResponse, HttpResponseForbidden, Http404 from django.http import JsonResponse, HttpResponseForbidden, Http404
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db.models import Q, Count from django.db.models import Q, Count
from collections import Counter, defaultdict from collections import Counter, defaultdict
from simple_email_confirmation.models import EmailAddress
from .models import Normalien, Stage, Lieu, AvisLieu, AvisStage 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 .utils import en_scolarite
from .views_search import * from .views_search import *
@ -343,3 +353,126 @@ def statistiques(request):
'num_lieux_utiles': nlieux, 'num_lieux_utiles': nlieux,
'num_par_longueur': nbylength, '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

@ -1,5 +1,12 @@
from django_cas_ng.backends import CASBackend from authens.backends import ENSCASBackend as AuthENSBackend
from authens.utils import parse_entrance_year
class ENSCASBackend(CASBackend): class ENSCASBackend(AuthENSBackend):
def clean_username(self, username): # Override AuthENS backend user creation to implement the @<promo> logic
return username.lower().strip()
def get_free_username(self, cas_login, attributes):
entrance_year = parse_entrance_year(attributes.get("homeDirectory"))
if entrance_year is None:
return super().get_free_username(cas_login, attributes)
entrance_year %= 100
return "%s@%02d" % (cas_login, entrance_year)

View file

@ -10,7 +10,7 @@ https://docs.djangoproject.com/en/1.7/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
from django.core.urlresolvers import reverse_lazy from django.urls import reverse_lazy
from .secrets import SECRET_KEY, GOOGLE_API_KEY, MAPBOX_API_KEY from .secrets import SECRET_KEY, GOOGLE_API_KEY, MAPBOX_API_KEY
@ -35,16 +35,12 @@ INSTALLED_APPS = [
'django_elasticsearch_dsl', 'django_elasticsearch_dsl',
'widget_tweaks', #'allauth', # Uncomment that part when you
'allauth_ens', #'allauth.account', # apply migration
#'allauth.socialaccount', # Allauth -> AuthENS
'allauth',
'allauth.account',
'allauth.socialaccount',
'allauth_cas',
'allauth_ens.providers.clipper',
'simple_email_confirmation',
'authens',
'tastypie', 'tastypie',
'braces', 'braces',
'tinymce', 'tinymce',
@ -53,12 +49,12 @@ INSTALLED_APPS = [
'avisstage' 'avisstage'
] ]
MIDDLEWARE_CLASSES = ( MIDDLEWARE = (
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
) )
@ -121,20 +117,13 @@ AUTHENTICATION_BACKENDS = (
) )
CAS_SERVER_URL = "https://cas.eleves.ens.fr/" #SPI CAS CAS_SERVER_URL = "https://cas.eleves.ens.fr/" #SPI CAS
CAS_VERIFY_URL = "https://cas.eleves.ens.fr/"
CAS_IGNORE_REFERER = True
CAS_REDIRECT_URL = reverse_lazy('avisstage:perso')
CAS_EMAIL_FORMAT = "%s@clipper.ens.fr"
CAS_FORCE_CHANGE_USERNAME_CASE = "lower"
CAS_VERSION = 'CAS_2_SAML_1_0'
ACCOUNT_ADAPTER = 'avisstage.allauth_adapter.AccountAdapter' AUTHENS_USE_OLDCAS = False
SOCIALACCOUNT_ADAPTER = 'avisstage.allauth_adapter.SocialAccountAdapter'
LOGIN_URL = reverse_lazy('account_login') LOGIN_URL = reverse_lazy('authens:login')
LOGOUT_URL = reverse_lazy('account_logout') LOGOUT_URL = reverse_lazy('authens:logout')
LOGIN_REDIRECT_URL = reverse_lazy('avisstage:perso') LOGIN_REDIRECT_URL = reverse_lazy('avisstage:perso')
ACCOUNT_HOME_URL = reverse_lazy('avisstage:index') LOGOUT_REDIRECT_URL = reverse_lazy('avisstage:index')
LOGGING = { LOGGING = {
'version': 1, 'version': 1,

View file

@ -9,16 +9,16 @@ DATABASES = {
} }
} }
USE_DEBUG_TOOLBAR = True USE_DEBUG_TOOLBAR = False
if USE_DEBUG_TOOLBAR: if USE_DEBUG_TOOLBAR:
INSTALLED_APPS += [ INSTALLED_APPS += [
'debug_toolbar', 'debug_toolbar',
] ]
MIDDLEWARE_CLASSES = ( MIDDLEWARE = (
'debug_toolbar.middleware.DebugToolbarMiddleware', 'debug_toolbar.middleware.DebugToolbarMiddleware',
) + MIDDLEWARE_CLASSES ) + MIDDLEWARE
INTERNAL_IPS = ['127.0.0.1'] INTERNAL_IPS = ['127.0.0.1']

View file

@ -1,19 +1,20 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.urls import include, path
from django.contrib import admin from django.contrib import admin
urlpatterns = [ urlpatterns = [
url(r'^', include('avisstage.urls', namespace='avisstage')), path('', include('avisstage.urls')),
url(r'^account/', include('allauth_ens.urls')),
url(r'^tinymce/', include('tinymce.urls')), path("authens/", include("authens.urls")),
url(r'^taggit_autosuggest/', include('taggit_autosuggest.urls')),
url(r'^admin/', include(admin.site.urls)), path('tinymce/', include('tinymce.urls')),
path('taggit_autosuggest/', include('taggit_autosuggest.urls')),
path('admin/', admin.site.urls),
] ]
if settings.DEBUG: if settings.DEBUG:
import debug_toolbar import debug_toolbar
urlpatterns = [ urlpatterns = [
url(r'^__debug__/', include(debug_toolbar.urls)), path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns ] + urlpatterns

View file

@ -1,12 +1,11 @@
django==1.11.* django==2.2.*
django-cas-ng==3.5.* django-taggit==1.3.*
django-taggit==0.22.* django-tinymce==3.2.*
python-ldap==3.0.* django-braces==1.14.*
django-tinymce==2.7.*
django-braces==1.12.*
django-taggit-autosuggest==0.3.* django-taggit-autosuggest==0.3.*
pytz==2018.* pytz==2020.*
django-tastypie==0.14.* django-tastypie==0.14.*
lxml==4.2.* lxml==4.6.*
django-elasticsearch-dsl==0.4.* django-elasticsearch-dsl==7.1.*
django-allauth-ens==1.1.* authens
django-simple-email-confirmation==0.*