core -- Install django-allauth-ens

Refer to allauth doc for an accurate features list:
  http://django-allauth.readthedocs.io/en/latest/

Users can now change their password, ask for a password reset, or set
one if they don't have one.

In particular, it allows users whose account has been created via a
clipper authentication to configure a password before losing their
clipper. Even if they have already lost it, they are able to get one
using the "Reset password" functionality.

Allauth multiple emails management is deactivated. Requests to the
related url redirect to the home page.

All the login and logout views are replaced by the allauth' ones. It
also concerns the Django and Wagtail admin sites.

Note that users are no longer logged out of the clipper CAS server when
they authenticated via this server. Instead a message suggests the user
to disconnect.

Clipper connections and `login_clipper`
---------------------------------------

- Non-empty `login_clipper` are now unique among `CofProfile` instances.
- They are created once for users with a non-empty 'login_clipper' (with
the data migration 0014_create_clipper_connections).
- The `login_clipper` of CofProfile instances are sync with their
clipper connections:
    * `CofProfile.sync_clipper_connections` method updates the
connections based on `login_clipper`.
    * Signals receivers `sync_clipper…` update `login_clipper` based on
connections creations/updates/deletions.

Misc
----

- Add NullCharField (model field) which allows to use `unique=True` on
CharField (even with empty strings).
- Parts of kfet mixins for TestCase are now in shared.tests.testcase,
  as they are used elsewhere than in the kfet app.
This commit is contained in:
Aurélien Delobelle 2017-10-19 01:12:52 +02:00 committed by Aurélien Delobelle
parent 49a74e8e1e
commit 05eeb6a25c
29 changed files with 877 additions and 504 deletions

View file

@ -8,6 +8,7 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from ..models import CategorieSpectacle, Salle, Spectacle, Tirage from ..models import CategorieSpectacle, Salle, Spectacle, Tirage
@ -63,14 +64,10 @@ class BdATestHelpers:
def craft_redirect_url(user): def craft_redirect_url(user):
if redirect_url: if redirect_url:
return redirect_url return redirect_url
elif user is None: login_url = reverse("account_login")
# client is not logged in
login_url = "/login"
if url: if url:
login_url += "?{}".format(urlencode({"next": url}, safe="/")) login_url += "?{}".format(urlencode({"next": url}, safe="/"))
return login_url return login_url
else:
return "/"
for (user, client) in self.client_matrix: for (user, client) in self.client_matrix:
resp = client.get(url, follow=True) resp = client.get(url, follow=True)
@ -82,12 +79,6 @@ class BdATestHelpers:
class TestBdAViews(BdATestHelpers, TestCase): class TestBdAViews(BdATestHelpers, TestCase):
def setUp(self): def setUp(self):
# Signals handlers on login/logout send messages.
# Due to the way the Django' test Client performs login, this raise an
# error. As workaround, we mock the Django' messages module.
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
# Set up the helpers # Set up the helpers
super().setUp() super().setUp()
# Some BdA stuff # Some BdA stuff

View file

@ -69,7 +69,6 @@ INSTALLED_APPS = [
"django.contrib.admindocs", "django.contrib.admindocs",
"bda", "bda",
"captcha", "captcha",
"django_cas_ng",
"bootstrapform", "bootstrapform",
"kfet", "kfet",
"kfet.open", "kfet.open",
@ -95,6 +94,12 @@ INSTALLED_APPS = [
"kfet.auth", "kfet.auth",
"kfet.cms", "kfet.cms",
"corsheaders", "corsheaders",
"allauth_ens",
"allauth_cas",
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth_ens.providers.clipper",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -182,22 +187,26 @@ MAIL_DATA = {
}, },
} }
LOGIN_URL = "cof-login"
LOGIN_REDIRECT_URL = "home"
CAS_SERVER_URL = "https://cas.eleves.ens.fr/" # Authentication
CAS_VERSION = "3" # https://docs.djangoproject.com/en/1.11/ref/settings/#auth
CAS_LOGIN_MSG = None # https://django-allauth.readthedocs.io/en/latest/index.html
CAS_IGNORE_REFERER = True
CAS_REDIRECT_URL = "/"
CAS_EMAIL_FORMAT = "%s@clipper.ens.fr"
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
"django.contrib.auth.backends.ModelBackend", "allauth.account.auth_backends.AuthenticationBackend",
"gestioncof.shared.COFCASBackend",
"kfet.auth.backends.GenericBackend", "kfet.auth.backends.GenericBackend",
) )
LOGIN_URL = "account_login"
LOGIN_REDIRECT_URL = "home"
ACCOUNT_LOGOUT_REDIRECT_URL = "home"
ACCOUNT_ADAPTER = "shared.allauth_adapter.AccountAdapter"
ACCOUNT_AUTHENTICATED_LOGIN_REDIRECTS = False
ACCOUNT_HOME_URL = "home"
ACCOUNT_USER_DISPLAY = lambda u: u.get_short_name() or u.username
SOCIALACCOUNT_ADAPTER = "shared.allauth_adapter.SocialAccountAdapter"
# reCAPTCHA settings # reCAPTCHA settings
# https://github.com/praekelt/django-recaptcha # https://github.com/praekelt/django-recaptcha

View file

@ -2,13 +2,12 @@
Fichier principal de configuration des urls du projet GestioCOF Fichier principal de configuration des urls du projet GestioCOF
""" """
from allauth_ens.views import capture_login, capture_logout
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as django_views from django.views.generic import RedirectView
from django.views.generic.base import TemplateView
from django_cas_ng import views as django_cas_views
from wagtail.wagtailadmin import urls as wagtailadmin_urls from wagtail.wagtailadmin import urls as wagtailadmin_urls
from wagtail.wagtailcore import urls as wagtail_urls from wagtail.wagtailcore import urls as wagtail_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls from wagtail.wagtaildocs import urls as wagtaildocs_urls
@ -26,6 +25,8 @@ from gestioncof.urls import (
admin.autodiscover() admin.autodiscover()
redirect_to_home = RedirectView.as_view(pattern_name="home")
urlpatterns = [ urlpatterns = [
# Page d'accueil # Page d'accueil
url(r"^$", gestioncof_views.home, name="home"), url(r"^$", gestioncof_views.home, name="home"),
@ -43,30 +44,6 @@ urlpatterns = [
url(r"^calendar/", include(calendar_patterns)), url(r"^calendar/", include(calendar_patterns)),
# Clubs # Clubs
url(r"^clubs/", include(clubs_patterns)), url(r"^clubs/", include(clubs_patterns)),
# Authentification
url(
r"^cof/denied$",
TemplateView.as_view(template_name="cof-denied.html"),
name="cof-denied",
),
url(r"^cas/login$", django_cas_views.login, name="cas_login_view"),
url(r"^cas/logout$", django_cas_views.logout),
url(r"^outsider/login$", gestioncof_views.login_ext, name="ext_login_view"),
url(r"^outsider/logout$", django_views.logout, {"next_page": "home"}),
url(r"^login$", gestioncof_views.login, name="cof-login"),
url(r"^logout$", gestioncof_views.logout, name="cof-logout"),
# Infos persos
url(r"^profile$", gestioncof_views.profile, name="profile"),
url(
r"^outsider/password-change$",
django_views.password_change,
name="password_change",
),
url(
r"^outsider/password-change-done$",
django_views.password_change_done,
name="password_change_done",
),
# Inscription d'un nouveau membre # Inscription d'un nouveau membre
url(r"^registration$", gestioncof_views.registration, name="registration"), url(r"^registration$", gestioncof_views.registration, name="registration"),
url( url(
@ -95,15 +72,6 @@ urlpatterns = [
gestioncof_views.user_autocomplete, gestioncof_views.user_autocomplete,
name="cof-user-autocomplete", name="cof-user-autocomplete",
), ),
# Interface admin
url(r"^admin/logout/", gestioncof_views.logout),
url(r"^admin/doc/", include("django.contrib.admindocs.urls")),
url(
r"^admin/(?P<app_label>[\d\w]+)/(?P<model_name>[\d\w]+)/csv/",
csv_views.admin_list_export,
{"fields": ["username"]},
),
url(r"^admin/", include(admin.site.urls)),
# Liens utiles du COF et du BdA # Liens utiles du COF et du BdA
url(r"^utile_cof$", gestioncof_views.utile_cof, name="utile_cof"), url(r"^utile_cof$", gestioncof_views.utile_cof, name="utile_cof"),
url(r"^utile_bda$", gestioncof_views.utile_bda, name="utile_bda"), url(r"^utile_bda$", gestioncof_views.utile_bda, name="utile_bda"),
@ -115,12 +83,40 @@ urlpatterns = [
name="ml_bda_revente", name="ml_bda_revente",
), ),
url(r"^k-fet/", include("kfet.urls")), url(r"^k-fet/", include("kfet.urls")),
url(r"^cms/", include(wagtailadmin_urls)),
url(r"^documents/", include(wagtaildocs_urls)),
# djconfig # djconfig
url(r"^config", gestioncof_views.ConfigUpdate.as_view(), name="config.edit"), url(r"^config", gestioncof_views.ConfigUpdate.as_view(), name="config.edit"),
] ]
# Admin site
admin_urls = [
# Replace the login and logout views with allauth ones.
url(r"^login/", capture_login),
url(r"^logout/", capture_logout),
url(r"^doc/", include("django.contrib.admindocs.urls")),
url(
r"^(?P<app_label>[\d\w]+)/(?P<model_name>[\d\w]+)/csv/",
csv_views.admin_list_export,
{"fields": ["username"]},
),
url(r"^", include(admin.site.urls)),
]
urlpatterns += [url(r"^admin/", include(admin_urls))]
# Profile urls.
# https://django-allauth.readthedocs.io/en/latest/
profile_urls = [
url(r"^edition/$", gestioncof_views.profile, name="profile.edit"),
# allauth urls
# Multiple emails management is unused.
url(r"^email/", redirect_to_home),
url(r"^", include("allauth.urls")),
]
urlpatterns += [url(r"^profil/", include(profile_urls))]
if "debug_toolbar" in settings.INSTALLED_APPS: if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
@ -131,5 +127,15 @@ if settings.DEBUG:
# Il faut dire à Django de servir MEDIA_ROOT lui-même en développement. # Il faut dire à Django de servir MEDIA_ROOT lui-même en développement.
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Wagtail for uncatched cms_urls = [
urlpatterns += [url(r"", include(wagtail_urls))] # Replace Wagtail login and logout views with allauth ones.
url(r"^cms/login/", capture_login),
url(r"^cms/logout/", capture_logout),
# Wagtail admin.
url(r"^cms/", include(wagtailadmin_urls)),
url(r"^documents/", include(wagtaildocs_urls)),
# Wagtail serves all uncatched requests.
url(r"", include(wagtail_urls)),
]
urlpatterns += cms_urls

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import utils.models
def convert_empty_clipper(apps, schema_editor):
CofProfile = apps.get_model("gestioncof", "CofProfile")
CofProfile.objects.filter(login_clipper="").update(login_clipper=None)
class Migration(migrations.Migration):
dependencies = [("gestioncof", "0014_cofprofile_mailing_unernestaparis")]
operations = [
migrations.AlterField(
model_name="cofprofile",
name="login_clipper",
field=models.CharField(
verbose_name="Login clipper",
max_length=32,
null=True,
blank=True,
default="",
),
),
migrations.RunPython(convert_empty_clipper),
migrations.AlterField(
model_name="cofprofile",
name="login_clipper",
field=utils.models.NullCharField(
verbose_name="Login clipper", max_length=32, default="", unique=True
),
),
]

View file

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
from django.db import migrations
def create_clipper_connections(apps, schema_editor):
CofProfile = apps.get_model("gestioncof", "CofProfile")
SocialAccount = apps.get_model("socialaccount", "SocialAccount")
profiles = CofProfile.objects.exclude(login_clipper="").values(
"user_id", "login_clipper"
)
SocialAccount.objects.bulk_create(
[
SocialAccount(
provider="clipper", user_id=p["user_id"], uid=p["login_clipper"]
)
for p in profiles
]
)
class Migration(migrations.Migration):
"""
As part of the allauth integration, this migration creates SocialAccount
instances for Clipper provider, based on the `CofProfile.login_clipper`
values.
"""
dependencies = [
("gestioncof", "0015_unique_login_clipper"),
("socialaccount", "0003_extra_data_default_dict"),
]
operations = [migrations.RunPython(create_clipper_connections)]

View file

@ -1,3 +1,4 @@
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from django.db.models.signals import post_delete, post_save from django.db.models.signals import post_delete, post_save
@ -6,6 +7,7 @@ from django.utils.translation import ugettext_lazy as _
from bda.models import Spectacle from bda.models import Spectacle
from gestioncof.petits_cours_models import choices_length from gestioncof.petits_cours_models import choices_length
from utils.models import NullCharField
TYPE_COMMENT_FIELD = (("text", _("Texte long")), ("char", _("Texte court"))) TYPE_COMMENT_FIELD = (("text", _("Texte long")), ("char", _("Texte court")))
@ -46,7 +48,9 @@ class CofProfile(models.Model):
) )
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
login_clipper = models.CharField("Login clipper", max_length=32, blank=True) login_clipper = NullCharField(
_("Login clipper"), max_length=32, blank=True, unique=True, default=""
)
is_cof = models.BooleanField("Membre du COF", default=False) is_cof = models.BooleanField("Membre du COF", default=False)
phone = models.CharField("Téléphone", max_length=20, blank=True) phone = models.CharField("Téléphone", max_length=20, blank=True)
occupation = models.CharField( occupation = models.CharField(
@ -86,6 +90,53 @@ class CofProfile(models.Model):
def __str__(self): def __str__(self):
return self.user.username return self.user.username
def save(self, *args, **kwargs):
created = self.pk is None
res = super().save(*args, **kwargs)
self.sync_clipper_connections(created=created)
return res
def sync_clipper_connections(self, created):
"""
Update the clipper connections of the user according to the value of
`login_clipper`.
If empty, all clipper connections are removed.
See also `sync_clipper` signals handlers (in `signals` module). They
sync `login_clipper` from the clipper connections.
Raises
IntegrityError: login_clipper is already used by another user.
"""
user, clipper = self.user, self.login_clipper
conns = user.socialaccount_set
clipper_conns = conns.filter(provider="clipper")
if created and clipper:
conns.create(provider="clipper", uid=clipper)
return
if clipper:
try:
# If a clipper connection already exists with the uid, call
# save to update last_login value (an auto_now field).
conn = clipper_conns.get(uid=clipper)
conn.save()
except SocialAccount.DoesNotExist:
# Nothing prevents the user from having multiple clipper
# connections. Let's update the most recently used with the
# given identifier. If none exists, create it.
try:
conn = clipper_conns.latest("last_login")
if conn.uid != clipper:
conn.uid = clipper
conn.save(update_fields=["uid"])
except SocialAccount.DoesNotExist:
conns.create(provider="clipper", uid=clipper)
else:
clipper_conns.delete()
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs): def create_user_profile(sender, instance, created, **kwargs):

View file

@ -3,16 +3,15 @@ import json
from custommail.shortcuts import render_custom_mail from custommail.shortcuts import render_custom_mail
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import mail from django.core import mail
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, render
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from gestioncof.decorators import buro_required from gestioncof.decorators import buro_required, cof_required
from gestioncof.models import CofProfile from gestioncof.models import CofProfile
from gestioncof.petits_cours_forms import DemandeForm, MatieresFormSet from gestioncof.petits_cours_forms import DemandeForm, MatieresFormSet
from gestioncof.petits_cours_models import ( from gestioncof.petits_cours_models import (
@ -336,11 +335,9 @@ def _traitement_post(request, demande):
) )
@login_required @cof_required
def inscription(request): def inscription(request):
profile, created = CofProfile.objects.get_or_create(user=request.user) profile, created = CofProfile.objects.get_or_create(user=request.user)
if not profile.is_cof:
return redirect("cof-denied")
success = False success = False
if request.method == "POST": if request.method == "POST":
formset = MatieresFormSet(request.POST, instance=request.user) formset = MatieresFormSet(request.POST, instance=request.user)

View file

@ -1,22 +1,4 @@
from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django_cas_ng.backends import CASBackend
class COFCASBackend(CASBackend):
def clean_username(self, username):
# Le CAS de l'ENS accepte les logins avec des espaces au début
# et à la fin, ainsi quavec une casse variable. On normalise pour
# éviter les doublons.
return username.strip().lower()
def configure_user(self, user):
clipper = user.username
user.profile.login_clipper = clipper
user.profile.save()
user.email = settings.CAS_EMAIL_FORMAT % clipper
user.save()
return user
def context_processor(request): def context_processor(request):

View file

@ -1,22 +1,40 @@
from django.contrib import messages from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.signals import user_logged_in from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from django_cas_ng.signals import cas_user_authenticated
@receiver(user_logged_in) @receiver(post_save, sender=SocialAccount)
def messages_on_out_login(request, user, **kwargs): def sync_clipper_on_saving_connection(sender, instance, **kwargs):
if user.backend.startswith("django.contrib.auth"): if instance.provider != "clipper":
msg = _("Connexion à GestioCOF réussie. Bienvenue {}.").format( return
user.get_short_name()
) # Saving instance makes it the most recently used clipper connection.
messages.success(request, msg) # Always update login_clipper, if value is not the used identifier.
profile = instance.user.profile
if profile.login_clipper != instance.uid:
profile.login_clipper = instance.uid
profile.save(update_fields=["login_clipper"])
@receiver(cas_user_authenticated) @receiver(post_delete, sender=SocialAccount)
def mesagges_on_cas_login(request, user, **kwargs): def sync_clipper_on_deleting_connection(sender, instance, **kwargs):
msg = _("Connexion à GestioCOF par CAS réussie. Bienvenue {}.").format( if instance.provider != "clipper":
user.get_short_name() return
)
messages.success(request, msg) profile = instance.user.profile
try:
# Get the most recently used clipper connection. Set its identifier as
# login_clipper value of the related CofProfile.
conn = SocialAccount.objects.filter(
provider="clipper", user=profile.user
).latest("last_login")
if profile.login_clipper != conn.uid:
profile.login_clipper = conn.uid
profile.save(update_fields=["login_clipper"])
except SocialAccount.DoesNotExist:
# If none lefts, flush it.
profile.login_clipper = ""
profile.save(update_fields=["login_clipper"])

View file

@ -299,24 +299,6 @@ fieldset legend {
display: none; display: none;
} }
#main-login-container {
margin-top : 7em;
margin-bottom: 7em;
}
#main-login-container .banner {
padding-right: 15px;
padding-left: 15px;
}
#main-login {
background-color: #DE826B;
}
#main-login .btn-primary {
background-color: #B56A59
}
.title-link { .title-link {
float: none; float: none;
display: block; display: block;

View file

@ -1,5 +0,0 @@
{% extends "base_title.html" %}
{% block realcontent %}
<h2>Section réservée aux membres du COF -- merci de vous inscrire au COF ou de passer au COF/nous envoyer un mail si vous êtes déjà membre :)</h2>
{% endblock %}

View file

@ -1,14 +0,0 @@
{% extends "base_title.html" %}
{% block realcontent %}
{% if error_type == "use_clipper_login" %}
<h2><strong>Votre identifiant est lié à un compte <tt>clipper</tt></strong></h2>
<p>Veuillez vous connecter à l'aide de votre <a href="{% url 'cas_login_view' %}">compte <tt>clipper</tt></a></p>
{% elif error_type == "no_password" %}
<h2><strong>Votre compte n'a pas de mot de passe associé</strong></h2>
<p>Veuillez <a href="mailto:cof@clipper.ens.fr">nous contacter</a> pour que nous en définissions un et que nous vous le transmettions !</p>
{% else %}
<h1><strong>{{ error_title }}</strong></h1>
<p>{{ error_description }}</p>
{% endif %}
{% endblock %}

View file

@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load i18n %}
{% block content %} {% block content %}
<header> <header>
@ -11,7 +12,12 @@
</a> </a>
<div class="secondary"> <div class="secondary">
<span class="hidden-xxs">&nbsp;&nbsp;|&nbsp; </span> <span class="hidden-xxs">&nbsp;&nbsp;|&nbsp; </span>
<span><a href="{% url "cof-logout" %}">Se déconnecter&nbsp;<span class="glyphicon glyphicon-log-out"></span></a></span> <span>
<a href="{% url "account_logout" %}">
{% trans "Se déconnecter" %}
<span class="glyphicon glyphicon-log-out"></span>
</a>
</span>
</div> </div>
<h2 class="member-status">{% if user.first_name %}{{ user.first_name }}{% else %}<tt>{{ user.username }}</tt>{% endif %}, {% if user.profile.is_cof %}<tt class="user-is-cof">au COF{% else %}<tt class="user-is-not-cof">non-COF{% endif %}</tt></h2> <h2 class="member-status">{% if user.first_name %}{{ user.first_name }}{% else %}<tt>{{ user.username }}</tt>{% endif %}, {% if user.profile.is_cof %}<tt class="user-is-cof">au COF{% else %}<tt class="user-is-not-cof">non-COF{% endif %}</tt></h2>
</div><!-- /.container --> </div><!-- /.container -->

View file

@ -1,4 +1,5 @@
{% extends "gestioncof/base_header.html" %} {% extends "gestioncof/base_header.html" %}
{% load i18n %}
{% load wagtailcore_tags %} {% load wagtailcore_tags %}
{% block homelink %} {% block homelink %}
@ -66,15 +67,38 @@
</ul> </ul>
</div> </div>
<h3 class="block-title">
<span class="pull-right glyphicon glyphicon-user"></span>
{% trans "Mon compte" %}
</h3>
<div class="hm-block">
<ul>
<li>
<a href="{% url "profile.edit" %}">
{% trans "Éditer mon profil" %}
</a>
</li>
<li>
{# This also allows for clipper users to set a password. #}
<a href="{% url "account_change_password" %}">
{% trans "Changer mon mot de passe" %}
</a>
</li>
<li>
<a href="{% url "socialaccount_connections" %}">
{% trans "Connexion par tiers" %}
</a>
</li>
</ul>
</div>
{% if user.profile.is_cof %} {% if user.profile.is_cof %}
<h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3> <h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3>
<div class="hm-block"> <div class="hm-block">
<ul> <ul>
<li><a href="{% url "calendar" %}">Calendrier dynamique</a></li> <li><a href="{% url "calendar" %}">Calendrier dynamique</a></li>
{% if user.profile.is_cof %}<li><a href="{% url "petits-cours-inscription" %}">Inscription pour donner des petits cours</a></li>{% endif %} <li><a href="{% url "petits-cours-inscription" %}">Inscription pour donner des petits cours</a></li>
<li><a href="{% url "profile" %}">Éditer mon profil</a></li>
{% if not user.profile.login_clipper %}<li><a href="{% url "password_change" %}">Changer mon mot de passe</a></li>{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View file

@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
from allauth.socialaccount.models import SocialAccount
from django.db import IntegrityError, transaction
from django.test import TestCase
from gestioncof.models import CofProfile, User
class UserTests(TestCase):
def test_has_profile(self):
"""
A User always has a related CofProfile and a CofProfile always has a
related User.
"""
u = User.objects.create_user("foo", "", "")
# Creating a User creates a related CofProfile.
self.assertTrue(hasattr(u, "profile"))
# there's no point in having a cofprofile without a user associated.
p = u.profile
u.delete()
with transaction.atomic():
with self.assertRaises(CofProfile.DoesNotExist):
p.refresh_from_db()
# there's no point in having a user without a cofprofile associated.
u = User.objects.create_user("foo", "", "")
u.profile.delete()
with transaction.atomic():
with self.assertRaises(User.DoesNotExist):
u.refresh_from_db()
class CofProfileTests(TestCase):
def setUp(self):
self.u = User.objects.create_user("user", "", "")
self.p = self.u.profile
def test_login_clipper_sync_clipper_connections(self):
"""
The value of `login_clipper` is sync with the identifier of the most
recently used clipper connection.
"""
def socialaccount_transform(o):
"""
Function to compare QuerySet of `SocialAccount`, instead of the
default, `repr`, only depends on the involved user.
"""
return "{provider} {username} {uid}".format(
provider=o.provider, username=o.user.username, uid=o.uid
)
def assertClipperAccountQuerysetEquals(user, *args, **kwargs):
kwargs.setdefault("transform", socialaccount_transform)
qs = SocialAccount.objects.filter(provider="clipper", user=user).order_by(
"last_login"
)
self.assertQuerysetEqual(qs, *args, **kwargs)
# Not saved in the database.
self.assertEqual(CofProfile().login_clipper, "")
# This CofProfile has been created without any value for login_clipper.
u, p = self.u, self.p
self.assertEqual(p.login_clipper, "")
# Filling value for the first time triggers the creation of a clipper
# connection (SocialAccount) for the related user.
p.login_clipper = "theclipper"
p.save()
self.assertEqual(p.login_clipper, "theclipper")
assertClipperAccountQuerysetEquals(u, ["clipper user theclipper"])
# Assigning a new value updates the existing connection.
p.login_clipper = "anotherclipper"
p.save()
self.assertEqual(p.login_clipper, "anotherclipper")
assertClipperAccountQuerysetEquals(u, ["clipper user anotherclipper"])
# Creating a clipper connection, using SocialAccount, sets the used
# identifier as value.
conn = SocialAccount.objects.create(provider="clipper", user=u, uid="clip")
self.assertEqual(p.login_clipper, "clip")
assertClipperAccountQuerysetEquals(
u, ["clipper user anotherclipper", "clipper user clip"]
)
# Removing a clipper connection sets the identifier most recently
# used one as value.
conn.delete()
p.refresh_from_db()
self.assertEqual(p.login_clipper, "anotherclipper")
assertClipperAccountQuerysetEquals(u, ["clipper user anotherclipper"])
# If the deletion of SocialAccount(s) leaves no clipper connection,
# flush the value.
SocialAccount.objects.filter(provider="clipper", user=u).delete()
p.refresh_from_db()
self.assertEqual(p.login_clipper, "")
# Assigning an identifier already in use updates the related connection
# as the most recently used.
SocialAccount.objects.bulk_create(
[
SocialAccount(provider="clipper", user=u, uid="theclipper"),
SocialAccount(provider="clipper", user=u, uid="clip"),
]
)
# Note 'clip' is the most recent one, and value is empty because
# bulk_create didn't trigger post_save signals.
p.login_clipper = "theclipper"
p.save()
self.assertEqual(p.login_clipper, "theclipper")
assertClipperAccountQuerysetEquals(
u, ["clipper user clip", "clipper user theclipper"]
)
# Flushing value removes all clipper connections, and set value to None
# to avoid failure due to the unicity constraint.
p.login_clipper = ""
p.save()
self.assertEqual(p.login_clipper, "")
self.assertFalse(
SocialAccount.objects.filter(provider="clipper", user=u).exists()
)
# Value is unique among all CofProfile instances…
p.login_clipper = "theclipper"
p.save()
p2 = User.objects.create_user("user2", "", "").profile
p2.login_clipper = "theclipper"
with transaction.atomic():
with self.assertRaises(IntegrityError):
p2.save()
# …except for '' (stored as NULL).
p.login_clipper = ""
p.save()
p2.login_clipper = ""
p2.save()

View file

@ -355,31 +355,31 @@ class HomeViewTests(ViewTestCaseMixin, TestCase):
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
class ProfileViewTests(ViewTestCaseMixin, TestCase): class ProfileEditViewTests(ViewTestCaseMixin, TestCase):
url_name = "profile" url_name = "profile.edit"
url_expected = "/profile" url_expected = "/profil/edition/"
http_methods = ["GET", "POST"] http_methods = ["GET", "POST"]
auth_user = "member" auth_user = "user"
auth_forbidden = [None, "user"] auth_forbidden = [None]
def test_get(self): def test_get(self):
r = self.client.get(self.url) r = self.client.get(self.url)
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
def test_post(self): def test_post(self):
u = self.users["member"] u = self.users["user"]
r = self.client.post( r = self.client.post(
self.url, self.url,
{ {
"u-first_name": "First", "u-first_name": "First",
"u-last_name": "Last", "u-last_name": "Last",
"p-phone": "", "u-phone": "",
# 'mailing_cof': '1', # "p-mailing_cof": "1",
# 'mailing_bda': '1', # "p-mailing_bda": "1",
# 'mailing_bda_revente': '1', # "p-mailing_bda_revente': "1",
}, },
) )

View file

@ -6,11 +6,7 @@ from custommail.shortcuts import send_custom_mail
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.auth.views import ( from django.contrib.auth.views import redirect_to_login
login as django_login_view,
logout as django_logout_view,
redirect_to_login,
)
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.urlresolvers import reverse_lazy from django.core.urlresolvers import reverse_lazy
from django.http import Http404, HttpResponse, HttpResponseForbidden from django.http import Http404, HttpResponse, HttpResponseForbidden
@ -18,7 +14,6 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView from django.views.generic import FormView
from django_cas_ng.views import logout as cas_logout_view
from icalendar import Calendar, Event as Vevent from icalendar import Calendar, Event as Vevent
from bda.models import Spectacle, Tirage from bda.models import Spectacle, Tirage
@ -72,55 +67,6 @@ def home(request):
return render(request, "home.html", data) return render(request, "home.html", data)
def login(request):
if request.user.is_authenticated:
return redirect("home")
context = {}
if request.method == "GET" and "next" in request.GET:
context["next"] = request.GET["next"]
return render(request, "login_switch.html", context)
def login_ext(request):
if request.method == "POST" and "username" in request.POST:
try:
user = User.objects.get(username=request.POST["username"])
if not user.has_usable_password() or user.password in ("", "!"):
profile, created = CofProfile.objects.get_or_create(user=user)
if profile.login_clipper:
return render(
request, "error.html", {"error_type": "use_clipper_login"}
)
else:
return render(request, "error.html", {"error_type": "no_password"})
except User.DoesNotExist:
pass
context = {}
if request.method == "GET" and "next" in request.GET:
context["next"] = request.GET["next"]
if request.method == "POST" and "next" in request.POST:
context["next"] = request.POST["next"]
return django_login_view(request, template_name="login.html", extra_context=context)
@login_required
def logout(request, next_page=None):
if next_page is None:
next_page = request.GET.get("next", None)
profile = getattr(request.user, "profile", None)
if profile and profile.login_clipper:
msg = _("Déconnexion de GestioCOF et CAS réussie. À bientôt {}.")
logout_view = cas_logout_view
else:
msg = _("Déconnexion de GestioCOF réussie. À bientôt {}.")
logout_view = django_logout_view
messages.success(request, msg.format(request.user.get_short_name()))
return logout_view(request, next_page=next_page)
@login_required @login_required
def survey(request, survey_id): def survey(request, survey_id):
survey = get_object_or_404( survey = get_object_or_404(
@ -359,7 +305,7 @@ def survey_status(request, survey_id):
) )
@cof_required @login_required
def profile(request): def profile(request):
user = request.user user = request.user
data = request.POST if request.method == "POST" else None data = request.POST if request.method == "POST" else None

View file

@ -123,10 +123,6 @@ class GenericBackendTests(TestCase):
class GenericLoginViewTests(TestCase): class GenericLoginViewTests(TestCase):
def setUp(self): def setUp(self):
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
user_acc = Account(trigramme="000") user_acc = Account(trigramme="000")
user_acc.save({"username": "user"}) user_acc.save({"username": "user"})
self.user = user_acc.user self.user = user_acc.user
@ -262,10 +258,6 @@ class GenericLoginViewTests(TestCase):
class TemporaryAuthTests(TestCase): class TemporaryAuthTests(TestCase):
def setUp(self): def setUp(self):
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
self.factory = RequestFactory() self.factory = RequestFactory()
self.middleware = TemporaryAuthMiddleware(mock.Mock()) self.middleware = TemporaryAuthMiddleware(mock.Mock())

View file

@ -73,7 +73,7 @@ class GenericLoginView(View):
here_qd["next"] = self.request.GET["next"] here_qd["next"] = self.request.GET["next"]
here_url += "?{}".format(here_qd.urlencode()) here_url += "?{}".format(here_qd.urlencode())
logout_url = reverse("cof-logout") logout_url = reverse("account_logout")
logout_qd = QueryDict(mutable=True) logout_qd = QueryDict(mutable=True)
logout_qd["next"] = here_url logout_qd["next"] = here_url
logout_url += "?{}".format(logout_qd.urlencode(safe="/")) logout_url += "?{}".format(logout_qd.urlencode(safe="/"))

View file

@ -106,11 +106,6 @@ class OpenKfetViewsTest(ChannelTestCase):
"""OpenKfet views unit-tests suite.""" """OpenKfet views unit-tests suite."""
def setUp(self): def setUp(self):
# Need this (and here) because of '<client>.login' in setUp
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
# get some permissions # get some permissions
perms = { perms = {
"kfet.is_team": Permission.objects.get(codename="is_team"), "kfet.is_team": Permission.objects.get(codename="is_team"),
@ -187,8 +182,7 @@ class OpenKfetConsumerTest(ChannelTestCase):
OpenKfetConsumer.group_send("kfet.open.team", {"test": "plop"}) OpenKfetConsumer.group_send("kfet.open.team", {"test": "plop"})
self.assertIsNone(c.receive()) self.assertIsNone(c.receive())
@mock.patch("gestioncof.signals.messages") def test_team_user(self):
def test_team_user(self, mock_messages):
"""Team user is added to kfet.open.team group.""" """Team user is added to kfet.open.team group."""
# setup team user and its client # setup team user and its client
t = User.objects.create_user("team", "", "team") t = User.objects.create_user("team", "", "team")
@ -217,10 +211,6 @@ class OpenKfetScenarioTest(ChannelTestCase):
"""OpenKfet functionnal tests suite.""" """OpenKfet functionnal tests suite."""
def setUp(self): def setUp(self):
# Need this (and here) because of '<client>.login' in setUp
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
# anonymous client (for views) # anonymous client (for views)
self.c = Client() self.c = Client()

View file

@ -101,8 +101,8 @@
{% endif %} {% endif %}
<li class="divider"></li> <li class="divider"></li>
<li> <li>
<a href="{% url "cof-logout" %}?next={{ kfet_home_url|urlencode }}"> <a href="#" data-url="{% url "account_logout" %}?next={{ kfet_home_url|urlencode }}" onclick="submit_url(this)">
<span class="glyphicon glyphicon-log-out"></span><span>Déconnexion</span> <span class="glyphicon glyphicon-log-out"></span><span>{% trans "Déconnexion" %}</span>
</a> </a>
</li> </li>
</ul> </ul>
@ -110,15 +110,14 @@
{% endif %} {% endif %}
{% if user.is_authenticated and not perms.kfet.is_team %} {% if user.is_authenticated and not perms.kfet.is_team %}
<li> <li>
<a href="{% url "cof-logout" %}?next={{ kfet_home_url|urlencode }}" title="Déconnexion"> <a href="#" data-url="{% url "account_logout" %}?next={{ kfet_home_url|urlencode }}" title="{% trans "Déconnexion" %}" onclick="submit_url(this)">
<span class="glyphicon glyphicon-log-out"></span> <span class="glyphicon glyphicon-log-out"></span>
</a> </a>
</li> </li>
{% elif not user.is_authenticated %} {% elif not user.is_authenticated %}
<li> <li>
<a href="{% url "cof-login" %}?next={{ request.path|urlencode }}" title="Connexion"> <a href="{% url "account_login" %}?next={{ request.path|urlencode }}" title="{% trans "Connexion" %}">
<span>Connexion</span><!-- <span>{% trans "Connexion" %}</span><span class="glyphicon glyphicon-log-in"></span>
--><span class="glyphicon glyphicon-log-in"></span>
</a> </a>
</li> </li>
{% endif %} {% endif %}

View file

@ -1,5 +1,3 @@
from unittest.mock import patch
from django.contrib.auth.models import Permission, User from django.contrib.auth.models import Permission, User
from django.test import Client, TestCase from django.test import Client, TestCase
@ -7,8 +5,7 @@ from kfet.models import Account, Article, ArticleCategory
class TestStats(TestCase): class TestStats(TestCase):
@patch("gestioncof.signals.messages") def test_user_stats(self):
def test_user_stats(self, mock_messages):
""" """
Checks that we can get the stat-related pages without any problem. Checks that we can get the stat-related pages without any problem.
""" """
@ -68,5 +65,5 @@ class TestStats(TestCase):
for url in articles_urls: for url in articles_urls:
resp = client.get(url) resp = client.get(url)
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
resp2 = client2.get(url, follow=True) resp2 = client2.get(url)
self.assertRedirects(resp2, "/") self.assertRedirects(resp2, "/profil/login/?next={}".format(url))

View file

@ -1,67 +1,14 @@
from unittest import mock from shared.tests.testcases import (
from urllib.parse import parse_qs, urlparse TestCaseMixin as BaseTestCaseMixin,
ViewTestCaseMixin as BaseViewTestCaseMixin,
from django.core.urlresolvers import reverse )
from django.http import QueryDict
from django.test import Client
from django.utils import timezone
from django.utils.functional import cached_property
from .utils import create_root, create_team, create_user from .utils import create_root, create_team, create_user
class TestCaseMixin: class TestCaseMixin(BaseTestCaseMixin):
"""Extends TestCase for kfet application tests.""" """Extends TestCase for kfet application tests."""
def assertForbidden(self, response):
"""
Test that the response (retrieved with a Client) is a denial of access.
The response should verify one of the following:
- its HTTP response code is 403,
- it redirects to the login page with a GET parameter named 'next'
whose value is the url of the requested page.
"""
request = response.wsgi_request
try:
try:
# Is this an HTTP Forbidden response ?
self.assertEqual(response.status_code, 403)
except AssertionError:
# A redirection to the login view is fine too.
# Let's build the login url with the 'next' param on current
# page.
full_path = request.get_full_path()
querystring = QueryDict(mutable=True)
querystring["next"] = full_path
login_url = "/login?" + querystring.urlencode(safe="/")
# We don't focus on what the login view does.
# So don't fetch the redirect.
self.assertRedirects(response, login_url, fetch_redirect_response=False)
except AssertionError:
raise AssertionError(
"%(http_method)s request at %(path)s should be forbidden for "
"%(username)s user.\n"
"Response isn't 403, nor a redirect to login view. Instead, "
"response code is %(code)d."
% {
"http_method": request.method,
"path": request.get_full_path(),
"username": (
"'{}'".format(request.user)
if request.user.is_authenticated
else "anonymous"
),
"code": response.status_code,
}
)
def assertForbiddenKfet(self, response, form_ctx="form"): def assertForbiddenKfet(self, response, form_ctx="form"):
""" """
Test that a response (retrieved with a Client) contains error due to Test that a response (retrieved with a Client) contains error due to
@ -113,65 +60,13 @@ class TestCaseMixin:
value = value() value = value()
self.assertEqual(value, expected_value) self.assertEqual(value, expected_value)
def assertUrlsEqual(self, actual, expected):
"""
Test that the url 'actual' is as 'expected'.
Arguments: class ViewTestCaseMixin(TestCaseMixin, BaseViewTestCaseMixin):
actual (str): Url to verify.
expected: Two forms are accepted.
* (str): Expected url. Strings equality is checked.
* (dict): Its keys must be attributes of 'urlparse(actual)'.
Equality is checked for each present key, except for
'query' which must be a dict of the expected query string
parameters.
"""
if type(expected) == dict:
parsed = urlparse(actual)
for part, expected_part in expected.items():
if part == "query":
self.assertDictEqual(
parse_qs(parsed.query), expected.get("query", {})
)
else:
self.assertEqual(getattr(parsed, part), expected_part)
else:
self.assertEqual(actual, expected)
class ViewTestCaseMixin(TestCaseMixin):
""" """
TestCase extension to ease tests of kfet views. TestCase extension to ease tests of kfet views.
Most information can be found in the base parent class doc.
Urls concerns This class performs some changes to users management, detailed below.
-------------
# Basic usage
Attributes:
url_name (str): Name of view under test, as given to 'reverse'
function.
url_args (list, optional): Will be given to 'reverse' call.
url_kwargs (dict, optional): Same.
url_expcted (str): What 'reverse' should return given previous
attributes.
View url can then be accessed at the 'url' attribute.
# Advanced usage
If multiple combinations of url name, args, kwargs can be used for a view,
it is possible to define 'urls_conf' attribute. It must be a list whose
each item is a dict defining arguments for 'reverse' call ('name', 'args',
'kwargs' keys) and its expected result ('expected' key).
The reversed urls can be accessed at the 't_urls' attribute.
Users concerns
--------------
During setup, three users are created with their kfet account: During setup, three users are created with their kfet account:
- 'user': a basic user without any permission, account trigramme: 000, - 'user': a basic user without any permission, account trigramme: 000,
@ -181,78 +76,19 @@ class ViewTestCaseMixin(TestCaseMixin):
trigramme: LIQ. trigramme: LIQ.
Their password is their username. Their password is their username.
One can create additionnal users with 'get_users_extra' method, or prevent
these 3 users to be created with 'get_users_base' method. See these two
methods for further informations.
By using 'register_user' method, these users can then be accessed at By using 'register_user' method, these users can then be accessed at
'users' attribute by their label. Similarly, their kfet account is 'users' attribute by their label. Similarly, their kfet account, if any, is
registered on 'accounts' attribute. registered on 'accounts' attribute.
A user label can be given to 'auth_user' attribute. The related user is
then authenticated on self.client during test setup. Its value defaults to
'None', meaning no user is authenticated.
Automated tests
---------------
# Url reverse
Based on url-related attributes/properties, the test 'test_urls' checks
that expected url is returned by 'reverse' (once with basic url usage and
each for advanced usage).
# Forbidden responses
The 'test_forbidden' test verifies that each user, from labels of
'auth_forbidden' attribute, can't access the url(s), i.e. response should
be a 403, or a redirect to login view.
Tested HTTP requests are given by 'http_methods' attribute. Additional data
can be given by defining an attribute '<method(lowercase)>_data'.
""" """
url_name = None
url_expected = None
http_methods = ["GET"]
auth_user = None
auth_forbidden = []
with_liq = False with_liq = False
def setUp(self): def setUp(self):
""" """
Warning: Do not forget to call super().setUp() in subclasses. Warning: Do not forget to call super().setUp() in subclasses.
""" """
# Signals handlers on login/logout send messages.
# Due to the way the Django' test Client performs login, this raise an
# error. As workaround, we mock the Django' messages module.
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
# A test can mock 'django.utils.timezone.now' and give this as return
# value. E.g. it is useful if the test checks values of 'auto_now' or
# 'auto_now_add' fields.
self.now = timezone.now()
# These attributes register users and accounts instances.
self.users = {}
self.accounts = {} self.accounts = {}
super().setUp()
for label, user in dict(self.users_base, **self.users_extra).items():
self.register_user(label, user)
if self.auth_user:
self.client.force_login(self.users[self.auth_user])
def tearDown(self):
del self.users_base
del self.users_extra
def get_users_base(self): def get_users_base(self):
""" """
@ -277,80 +113,7 @@ class ViewTestCaseMixin(TestCaseMixin):
users_base["liq"] = create_user("liq", "LIQ") users_base["liq"] = create_user("liq", "LIQ")
return users_base return users_base
@cached_property
def users_base(self):
return self.get_users_base()
def get_users_extra(self):
"""
Dict of <label: user instance>.
Note: Don't access yourself this property. Use 'users_base' attribute
which cache the returned value from here.
It allows to give functions calls, which create users instances, as
values here.
"""
return {}
@cached_property
def users_extra(self):
return self.get_users_extra()
def register_user(self, label, user): def register_user(self, label, user):
self.users[label] = user super().register_user(label, user)
if hasattr(user.profile, "account_kfet"): if hasattr(user.profile, "account_kfet"):
self.accounts[label] = user.profile.account_kfet self.accounts[label] = user.profile.account_kfet
def get_user(self, label):
if self.auth_user is not None:
return self.auth_user
return self.auth_user_mapping.get(label)
@property
def urls_conf(self):
return [
{
"name": self.url_name,
"args": getattr(self, "url_args", []),
"kwargs": getattr(self, "url_kwargs", {}),
"expected": self.url_expected,
}
]
@property
def t_urls(self):
return [
reverse(
url_conf["name"],
args=url_conf.get("args", []),
kwargs=url_conf.get("kwargs", {}),
)
for url_conf in self.urls_conf
]
@property
def url(self):
return self.t_urls[0]
def test_urls(self):
for url, conf in zip(self.t_urls, self.urls_conf):
self.assertEqual(url, conf["expected"])
def test_forbidden(self):
for method in self.http_methods:
for user in self.auth_forbidden:
for url in self.t_urls:
self.check_forbidden(method, url, user)
def check_forbidden(self, method, url, user=None):
method = method.lower()
client = Client()
if user is not None:
client.login(username=user, password=user)
send_request = getattr(client, method)
data = getattr(self, "{}_data".format(method), {})
r = send_request(url, data)
self.assertForbidden(r)

View file

@ -1,8 +1,8 @@
configparser==3.5.0 configparser==3.5.0
Django==1.11.* Django==1.11.*
django-autocomplete-light==3.1.3 django-autocomplete-light==3.1.3
django-allauth-ens==1.1.*
django-autoslug==1.9.3 django-autoslug==1.9.3
django-cas-ng==3.5.7
django-djconfig==0.5.3 django-djconfig==0.5.3
django-recaptcha==1.4.0 django-recaptcha==1.4.0
django-redis-cache==1.7.1 django-redis-cache==1.7.1

0
shared/__init__.py Normal file
View file

21
shared/allauth_adapter.py Normal file
View file

@ -0,0 +1,21 @@
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
class AccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
"""
Signup is not allowed by default.
"""
return False
class SocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
"""
Authorize user connecting via Clipper to get a GestioCOF account.
"""
provider = sociallogin.account.provider
if provider == "clipper":
return True
return super().is_open_for_signup(request, sociallogin)

354
shared/tests/test_auth.py Normal file
View file

@ -0,0 +1,354 @@
import re
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.providers import registry as providers_registry
from allauth_cas.test.testcases import CASViewTestCase
from django.contrib.auth import HASH_SESSION_KEY, get_user_model
from django.core import mail
from django.core.urlresolvers import reverse
from django.test import Client, TestCase
from .testcases import ViewTestCaseMixin
User = get_user_model()
def prevent_logout_pwd_change(client, user):
"""
Updating a user's password logs out all sessions for the user.
By calling this function this behavior will be prevented.
See this link, and the source code of `update_session_auth_hash`:
https://docs.djangoproject.com/en/dev/topics/auth/default/#session-invalidation-on-password-change
"""
if hasattr(user, "get_session_auth_hash"):
session = client.session
session[HASH_SESSION_KEY] = user.get_session_auth_hash()
session.save()
def get_reset_password_link(email_msg):
m = re.search(r"http://testserver(/profil/password/reset/key/.*/)", email_msg.body)
return m.group(1)
class LoginViewTests(ViewTestCaseMixin, TestCase):
url_name = "account_login"
url_expected = "/profil/login/"
http_methods = ["GET", "POST"]
def test_get(self):
"""
Unauthenticated users can access the login form.
"""
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_get_already_auth(self):
"""
Even already authenticated users can access the login form.
They may have been redirected due to a lack of authorizations.
"""
self.client.login(username="user", password="user")
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post(self):
"""
Users can log in.
"""
r = self.client.post(self.url, {"login": "user", "password": "user"})
self.assertRedirects(r, reverse("home"))
self.assertEqual(r.wsgi_request.user, self.users["user"])
def test_post_redirect(self):
"""
On login, user is redirected to the value of the `next` GET parameter.
"""
redirect_url = reverse("account_logout")
url = self.url + "?next=" + redirect_url
r = self.client.post(url, {"login": "user", "password": "user"})
self.assertRedirects(r, redirect_url)
self.assertEqual(r.wsgi_request.user, self.users["user"])
def test_post_invalid_password(self):
"""
If credentials are incorrect, the page is displayed again.
"""
r = self.client.post(self.url, {"username": "user", "password": "bad password"})
self.assertEqual(r.status_code, 200)
self.assertTrue(r.wsgi_request.user.is_anonymous())
class LogoutViewTests(ViewTestCaseMixin, TestCase):
url_name = "account_logout"
url_expected = "/profil/logout/"
http_methods = ["GET", "POST"]
auth_user = "user"
def test_get(self):
"""
Using the HTTP method GET, only a confirmation is prompted to the
user.
"""
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post(self):
"""
With a POST request, user is logged out.
"""
r = self.client.post(self.url)
self.assertRedirects(r, reverse("home"), fetch_redirect_response=False)
self.assertTrue(r.wsgi_request.user.is_anonymous())
def test_post_redirect(self):
"""
On logout, user is redirected to the value of the `next` GET parameter.
"""
redirect_url = reverse("account_set_password")
url = self.url + "?next=" + redirect_url
r = self.client.post(url)
self.assertRedirects(r, redirect_url, fetch_redirect_response=False)
self.assertTrue(r.wsgi_request.user.is_anonymous())
class ChangePasswordViewTests(ViewTestCaseMixin, TestCase):
url_name = "account_change_password"
url_expected = "/profil/password/change/"
http_methods = ["GET", "POST"]
auth_user = "user"
auth_forbidden = [None]
def test_get(self):
"""
Authenticated users can access the page to change their password.
"""
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_get_no_password(self):
"""
Authenticated users who do not have a password are redirected to the
`account_set_password` view.
"""
user = self.users["user"]
user.set_unusable_password()
user.save()
prevent_logout_pwd_change(self.client, user)
r = self.client.get(self.url)
self.assertRedirects(r, reverse("account_set_password"))
def test_post(self):
"""
Authenticated users can change their password.
"""
user = self.users["user"]
r = self.client.post(
self.url,
{"oldpassword": "user", "password1": "usertruc", "password2": "usertruc"},
)
self.assertRedirects(r, reverse("account_change_password"))
user.refresh_from_db()
self.assertTrue(user.check_password("usertruc"))
class SetPasswordViewTests(ViewTestCaseMixin, TestCase):
url_name = "account_set_password"
url_expected = "/profil/password/set/"
http_methods = ["GET", "POST"]
auth_user = "user_nopwd"
auth_forbidden = [None]
def setUp(self):
super().setUp()
user_nopwd = self.users["user_nopwd"]
user_nopwd.set_unusable_password()
user_nopwd.save()
prevent_logout_pwd_change(self.client, user_nopwd)
def get_users_extra(self):
# `user_nopwd` is created with a password to use the `login` method of
# the test client. The password is then removed.
return {"user_nopwd": User.objects.create_user("user_nopwd", "", "user_nopwd")}
def test_get(self):
"""
Authenticated users who do not have a password can access the page.
"""
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_get_has_password(self):
"""
Authenticated users who already have a password are redirected to the
`account_change_password` view.
"""
client = Client()
client.login(username="user", password="user")
r = client.get(self.url)
self.assertRedirects(r, reverse("account_change_password"))
def test_post(self):
"""
Authenticated users can set their password.
"""
user = self.users["user_nopwd"]
r = self.client.post(
self.url, {"password1": "plop2fois", "password2": "plop2fois"}
)
self.assertRedirects(
r, reverse("account_set_password"), fetch_redirect_response=False
)
user.refresh_from_db()
self.assertTrue(user.check_password("plop2fois"))
class ResetPasswordViewTests(ViewTestCaseMixin, TestCase):
url_name = "account_reset_password"
url_expected = "/profil/password/reset/"
http_methods = ["GET", "POST"]
def setUp(self):
super().setUp()
user = self.users["user"]
user.email = "user@mail.net"
user.save()
def test_get(self):
"""
Users can access the page.
"""
r = self.client.get(self.url)
self.assertEqual(r.status_code, 200)
def test_post(self):
"""
Users can ask for a link to be sent to reset their password.
"""
r = self.client.post(self.url, {"email": "user@mail.net"})
self.assertRedirects(r, reverse("account_reset_password_done"))
get_reset_password_link(mail.outbox[0])
class ResetPasswordKeyViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("user", "user@mail.net", "user")
self.client.post(reverse("account_reset_password"), {"email": "user@mail.net"})
self.reset_link = get_reset_password_link(mail.outbox[0])
def test_get(self):
"""
With valid link, users can access the form to reset their password.
"""
# Redirection happens in order to "hide" the reset key.
r = self.client.get(self.reset_link, follow=True)
self.assertEqual(r.status_code, 200)
self.assertIn("form", r.context)
def test_get_bad_token(self):
"""
"""
# Edit the key (remove the last slash, add some keys)
bad_link = self.reset_link[:-1] + "reallybad/"
r = self.client.get(bad_link, follow=True)
self.assertEqual(r.status_code, 200)
self.assertNotIn("form", r.context)
def test_post(self):
"""
If the form is valid, user password is changed.
"""
r = self.client.get(self.reset_link, follow=True)
r = self.client.post(
r.redirect_chain[-1][0],
{"password1": "thepassword", "password2": "thepassword"},
)
self.assertRedirects(r, reverse("home"))
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("thepassword"))
class ClipperAuthTests(CASViewTestCase):
def setUp(self):
self.user = User.objects.create_user("user", "", "user")
self.provider = providers_registry.by_id("clipper")
# When the Clipper callback view verifies ticket with the CAS server,
# this ensures it will approve the ticket for the identifier
# 'theclipper'.
self.patch_cas_response(username="theclipper", valid_ticket="__all__")
self.callback_url = reverse("clipper_callback") + "?ticket=any"
def test_login(self):
SocialAccount.objects.create(
provider="clipper", user=self.user, uid="theclipper"
)
self.client.get(reverse("clipper_login"))
r = self.client.get(self.callback_url)
self.assertRedirects(r, reverse("home"))
self.assertEqual(r.wsgi_request.user, self.user)
def test_autosignup(self):
"""
Connecting via Clipper automatically creates a User instance, if none
is already linked to the used clipper login.
This identifier is used as username (trimmed, lowercased).
"""
self.assertFalse(User.objects.filter(username="theclipper").exists())
self.client.get(reverse("clipper_login"))
r = self.client.get(self.callback_url)
self.assertRedirects(r, reverse("home"))
user = User.objects.get(username="theclipper")
SocialAccount.objects.get(provider="clipper", user=user, uid="theclipper")
self.assertEqual(r.wsgi_request.user, user)
self.assertEqual(user.email, "theclipper@clipper.ens.fr")
def test_autosignup_conflict_username(self):
"""
When creating User via Clipper auto-signup, if the username is not
available, a similar one is chosen.
"""
User.objects.create_user("theclipper", "", "")
previous_user_pks = list(User.objects.values_list("pk", flat=True))
self.client.get(reverse("clipper_login"))
r = self.client.get(self.callback_url)
self.assertRedirects(r, reverse("home"))
user = User.objects.exclude(pk__in=previous_user_pks).get()
SocialAccount.objects.get(provider="clipper", user=user, uid="theclipper")
self.assertEqual(r.wsgi_request.user, user)
self.assertTrue(user.username.startswith("theclipper"))
self.assertEqual(user.email, "theclipper@clipper.ens.fr")

View file

@ -41,7 +41,7 @@ class TestCaseMixin:
querystring["next"] = full_path querystring["next"] = full_path
login_url = "{}?{}".format( login_url = "{}?{}".format(
reverse("cof-login"), querystring.urlencode(safe="/") reverse("account_login"), querystring.urlencode(safe="/")
) )
# We don't focus on what the login view does. # We don't focus on what the login view does.
@ -239,13 +239,6 @@ class ViewTestCaseMixin(TestCaseMixin):
""" """
Warning: Do not forget to call super().setUp() in subclasses. Warning: Do not forget to call super().setUp() in subclasses.
""" """
# Signals handlers on login/logout send messages.
# Due to the way the Django' test Client performs login, this raise an
# error. As workaround, we mock the Django' messages module.
patcher_messages = mock.patch("gestioncof.signals.messages")
patcher_messages.start()
self.addCleanup(patcher_messages.stop)
# A test can mock 'django.utils.timezone.now' and give this as return # A test can mock 'django.utils.timezone.now' and give this as return
# value. E.g. it is useful if the test checks values of 'auto_now' or # value. E.g. it is useful if the test checks values of 'auto_now' or
# 'auto_now_add' fields. # 'auto_now_add' fields.

43
utils/models.py Normal file
View file

@ -0,0 +1,43 @@
from django.core.exceptions import ImproperlyConfigured
from django.db import models
class NullCharField(models.CharField):
"""
CharField that stores '' as NULL and returns NULL as ''.
Use this for unique CharField.
As long as this field is referenced in a migration, this definition must be
kept.
When upgrading to Django >= 1.11, it is possible to drop the methods:
`from_db_value`, `to_python` and `get_prep_value`.
Source:
https://github.com/django-oscar/django-oscar/blob/1a2e8279161a396e70e44339d7206f25355b33a3/src/oscar/models/fields/__init__.py#L94
"""
description = "CharField that stores '' as NULL and returns NULL as ''"
def __init__(self, *args, **kwargs):
if not kwargs.get("null", True) or not kwargs.get("blank", True):
raise ImproperlyConfigured("NullCharField implies 'null == blank == True'.")
kwargs["null"] = kwargs["blank"] = True
super().__init__(*args, **kwargs)
def from_db_value(self, value, *args, **kwargs):
return self.to_python(value)
def to_python(self, *args, **kwargs):
val = super().to_python(*args, **kwargs)
return val if val is not None else ""
def get_prep_value(self, *args, **kwargs):
prepped = super().get_prep_value(*args, **kwargs)
return prepped if prepped != "" else None
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
del kwargs["null"]
del kwargs["blank"]
return name, path, args, kwargs