forked from DGNum/gestioCOF
Compare commits
4 commits
master
...
aureplop/i
Author | SHA1 | Date | |
---|---|---|---|
|
a4be431c4f | ||
|
030a02375c | ||
|
e56200a569 | ||
|
05eeb6a25c |
|
@ -22,7 +22,7 @@ test:
|
|||
stage: test
|
||||
before_script:
|
||||
- mkdir -p vendor/{pip,apt}
|
||||
- apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client
|
||||
- apt-get update -q && apt-get -o dir::cache::archives="vendor/apt" install -yqq postgresql-client build-essential python3-dev libldap2-dev libsasl2-dev
|
||||
- sed -E 's/^REDIS_HOST.*/REDIS_HOST = "redis"/' cof/settings/secret_example.py > cof/settings/secret.py
|
||||
- sed -i.bak -E 's;^REDIS_PASSWD = .*$;REDIS_PASSWD = "";' cof/settings/secret.py
|
||||
# Remove the old test database if it has not been done yet
|
||||
|
|
|
@ -8,6 +8,7 @@ from django.conf import settings
|
|||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import CategorieSpectacle, Salle, Spectacle, Tirage
|
||||
|
@ -63,14 +64,10 @@ class BdATestHelpers:
|
|||
def craft_redirect_url(user):
|
||||
if redirect_url:
|
||||
return redirect_url
|
||||
elif user is None:
|
||||
# client is not logged in
|
||||
login_url = "/login"
|
||||
if url:
|
||||
login_url += "?{}".format(urlencode({"next": url}, safe="/"))
|
||||
return login_url
|
||||
else:
|
||||
return "/"
|
||||
login_url = reverse("account_login")
|
||||
if url:
|
||||
login_url += "?{}".format(urlencode({"next": url}, safe="/"))
|
||||
return login_url
|
||||
|
||||
for (user, client) in self.client_matrix:
|
||||
resp = client.get(url, follow=True)
|
||||
|
@ -82,12 +79,6 @@ class BdATestHelpers:
|
|||
|
||||
class TestBdAViews(BdATestHelpers, TestCase):
|
||||
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
|
||||
super().setUp()
|
||||
# Some BdA stuff
|
||||
|
|
|
@ -69,7 +69,6 @@ INSTALLED_APPS = [
|
|||
"django.contrib.admindocs",
|
||||
"bda",
|
||||
"captcha",
|
||||
"django_cas_ng",
|
||||
"bootstrapform",
|
||||
"kfet",
|
||||
"kfet.open",
|
||||
|
@ -95,6 +94,12 @@ INSTALLED_APPS = [
|
|||
"kfet.auth",
|
||||
"kfet.cms",
|
||||
"corsheaders",
|
||||
"allauth_ens",
|
||||
"allauth_cas",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth_ens.providers.clipper",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@ -182,22 +187,26 @@ MAIL_DATA = {
|
|||
},
|
||||
}
|
||||
|
||||
LOGIN_URL = "cof-login"
|
||||
LOGIN_REDIRECT_URL = "home"
|
||||
|
||||
CAS_SERVER_URL = "https://cas.eleves.ens.fr/"
|
||||
CAS_VERSION = "3"
|
||||
CAS_LOGIN_MSG = None
|
||||
CAS_IGNORE_REFERER = True
|
||||
CAS_REDIRECT_URL = "/"
|
||||
CAS_EMAIL_FORMAT = "%s@clipper.ens.fr"
|
||||
# Authentication
|
||||
# https://docs.djangoproject.com/en/1.11/ref/settings/#auth
|
||||
# https://django-allauth.readthedocs.io/en/latest/index.html
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"gestioncof.shared.COFCASBackend",
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
"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
|
||||
# https://github.com/praekelt/django-recaptcha
|
||||
|
|
86
cof/urls.py
86
cof/urls.py
|
@ -2,13 +2,12 @@
|
|||
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.urls import include, url
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import views as django_views
|
||||
from django.views.generic.base import TemplateView
|
||||
from django_cas_ng import views as django_cas_views
|
||||
from django.views.generic import RedirectView
|
||||
from wagtail.wagtailadmin import urls as wagtailadmin_urls
|
||||
from wagtail.wagtailcore import urls as wagtail_urls
|
||||
from wagtail.wagtaildocs import urls as wagtaildocs_urls
|
||||
|
@ -26,6 +25,8 @@ from gestioncof.urls import (
|
|||
|
||||
admin.autodiscover()
|
||||
|
||||
redirect_to_home = RedirectView.as_view(pattern_name="home")
|
||||
|
||||
urlpatterns = [
|
||||
# Page d'accueil
|
||||
url(r"^$", gestioncof_views.home, name="home"),
|
||||
|
@ -43,30 +44,6 @@ urlpatterns = [
|
|||
url(r"^calendar/", include(calendar_patterns)),
|
||||
# Clubs
|
||||
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
|
||||
url(r"^registration$", gestioncof_views.registration, name="registration"),
|
||||
url(
|
||||
|
@ -95,15 +72,6 @@ urlpatterns = [
|
|||
gestioncof_views.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
|
||||
url(r"^utile_cof$", gestioncof_views.utile_cof, name="utile_cof"),
|
||||
url(r"^utile_bda$", gestioncof_views.utile_bda, name="utile_bda"),
|
||||
|
@ -115,12 +83,40 @@ urlpatterns = [
|
|||
name="ml_bda_revente",
|
||||
),
|
||||
url(r"^k-fet/", include("kfet.urls")),
|
||||
url(r"^cms/", include(wagtailadmin_urls)),
|
||||
url(r"^documents/", include(wagtaildocs_urls)),
|
||||
# djconfig
|
||||
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:
|
||||
import debug_toolbar
|
||||
|
||||
|
@ -131,5 +127,15 @@ if settings.DEBUG:
|
|||
# Il faut dire à Django de servir MEDIA_ROOT lui-même en développement.
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
# Wagtail for uncatched
|
||||
urlpatterns += [url(r"", include(wagtail_urls))]
|
||||
cms_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
|
||||
|
|
38
gestioncof/migrations/0015_unique_login_clipper.py
Normal file
38
gestioncof/migrations/0015_unique_login_clipper.py
Normal 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
|
||||
),
|
||||
),
|
||||
]
|
35
gestioncof/migrations/0016_create_clipper_conns.py
Normal file
35
gestioncof/migrations/0016_create_clipper_conns.py
Normal 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)]
|
|
@ -1,3 +1,4 @@
|
|||
from allauth.socialaccount.models import SocialAccount
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
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 gestioncof.petits_cours_models import choices_length
|
||||
from utils.models import NullCharField
|
||||
|
||||
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")
|
||||
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)
|
||||
phone = models.CharField("Téléphone", max_length=20, blank=True)
|
||||
occupation = models.CharField(
|
||||
|
@ -86,6 +90,53 @@ class CofProfile(models.Model):
|
|||
def __str__(self):
|
||||
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)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
|
|
|
@ -3,16 +3,15 @@ import json
|
|||
from custommail.shortcuts import render_custom_mail
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
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.views.decorators.csrf import csrf_exempt
|
||||
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.petits_cours_forms import DemandeForm, MatieresFormSet
|
||||
from gestioncof.petits_cours_models import (
|
||||
|
@ -336,11 +335,9 @@ def _traitement_post(request, demande):
|
|||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@cof_required
|
||||
def inscription(request):
|
||||
profile, created = CofProfile.objects.get_or_create(user=request.user)
|
||||
if not profile.is_cof:
|
||||
return redirect("cof-denied")
|
||||
success = False
|
||||
if request.method == "POST":
|
||||
formset = MatieresFormSet(request.POST, instance=request.user)
|
||||
|
|
|
@ -1,22 +1,4 @@
|
|||
from django.conf import settings
|
||||
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 qu’avec 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):
|
||||
|
|
|
@ -1,22 +1,40 @@
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from allauth.socialaccount.models import SocialAccount
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
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)
|
||||
def messages_on_out_login(request, user, **kwargs):
|
||||
if user.backend.startswith("django.contrib.auth"):
|
||||
msg = _("Connexion à GestioCOF réussie. Bienvenue {}.").format(
|
||||
user.get_short_name()
|
||||
)
|
||||
messages.success(request, msg)
|
||||
@receiver(post_save, sender=SocialAccount)
|
||||
def sync_clipper_on_saving_connection(sender, instance, **kwargs):
|
||||
if instance.provider != "clipper":
|
||||
return
|
||||
|
||||
# Saving instance makes it the most recently used clipper connection.
|
||||
# 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)
|
||||
def mesagges_on_cas_login(request, user, **kwargs):
|
||||
msg = _("Connexion à GestioCOF par CAS réussie. Bienvenue {}.").format(
|
||||
user.get_short_name()
|
||||
)
|
||||
messages.success(request, msg)
|
||||
@receiver(post_delete, sender=SocialAccount)
|
||||
def sync_clipper_on_deleting_connection(sender, instance, **kwargs):
|
||||
if instance.provider != "clipper":
|
||||
return
|
||||
|
||||
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"])
|
||||
|
|
|
@ -299,24 +299,6 @@ fieldset legend {
|
|||
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 {
|
||||
float: none;
|
||||
display: block;
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<header>
|
||||
|
@ -11,7 +12,12 @@
|
|||
</a>
|
||||
<div class="secondary">
|
||||
<span class="hidden-xxs"> | </span>
|
||||
<span><a href="{% url "cof-logout" %}">Se déconnecter <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>
|
||||
<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 -->
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "gestioncof/base_header.html" %}
|
||||
{% load i18n %}
|
||||
{% load wagtailcore_tags %}
|
||||
|
||||
{% block homelink %}
|
||||
|
@ -66,15 +67,38 @@
|
|||
</ul>
|
||||
</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 %}
|
||||
<h3 class="block-title">Divers<span class="pull-right glyphicon glyphicon-question-sign"></span></h3>
|
||||
<div class="hm-block">
|
||||
<ul>
|
||||
<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 "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 %}
|
||||
<li><a href="{% url "petits-cours-inscription" %}">Inscription pour donner des petits cours</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
155
gestioncof/tests/test_models.py
Normal file
155
gestioncof/tests/test_models.py
Normal 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()
|
|
@ -355,31 +355,31 @@ class HomeViewTests(ViewTestCaseMixin, TestCase):
|
|||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
|
||||
class ProfileViewTests(ViewTestCaseMixin, TestCase):
|
||||
url_name = "profile"
|
||||
url_expected = "/profile"
|
||||
class ProfileEditViewTests(ViewTestCaseMixin, TestCase):
|
||||
url_name = "profile.edit"
|
||||
url_expected = "/profil/edition/"
|
||||
|
||||
http_methods = ["GET", "POST"]
|
||||
|
||||
auth_user = "member"
|
||||
auth_forbidden = [None, "user"]
|
||||
auth_user = "user"
|
||||
auth_forbidden = [None]
|
||||
|
||||
def test_get(self):
|
||||
r = self.client.get(self.url)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_post(self):
|
||||
u = self.users["member"]
|
||||
u = self.users["user"]
|
||||
|
||||
r = self.client.post(
|
||||
self.url,
|
||||
{
|
||||
"u-first_name": "First",
|
||||
"u-last_name": "Last",
|
||||
"p-phone": "",
|
||||
# 'mailing_cof': '1',
|
||||
# 'mailing_bda': '1',
|
||||
# 'mailing_bda_revente': '1',
|
||||
"u-phone": "",
|
||||
# "p-mailing_cof": "1",
|
||||
# "p-mailing_bda": "1",
|
||||
# "p-mailing_bda_revente': "1",
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
@ -6,11 +6,7 @@ from custommail.shortcuts import send_custom_mail
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.views import (
|
||||
login as django_login_view,
|
||||
logout as django_logout_view,
|
||||
redirect_to_login,
|
||||
)
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.urlresolvers import reverse_lazy
|
||||
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.translation import ugettext_lazy as _
|
||||
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 bda.models import Spectacle, Tirage
|
||||
|
@ -72,55 +67,6 @@ def home(request):
|
|||
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
|
||||
def survey(request, survey_id):
|
||||
survey = get_object_or_404(
|
||||
|
@ -359,7 +305,7 @@ def survey_status(request, survey_id):
|
|||
)
|
||||
|
||||
|
||||
@cof_required
|
||||
@login_required
|
||||
def profile(request):
|
||||
user = request.user
|
||||
data = request.POST if request.method == "POST" else None
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser, Group, Permission, User
|
||||
from django.core import signing
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
|
@ -13,7 +12,6 @@ from .backends import AccountBackend, GenericBackend
|
|||
from .middleware import TemporaryAuthMiddleware
|
||||
from .models import GenericTeamToken
|
||||
from .utils import get_kfet_generic_user
|
||||
from .views import GenericLoginView
|
||||
|
||||
##
|
||||
# Forms
|
||||
|
@ -123,10 +121,6 @@ class GenericBackendTests(TestCase):
|
|||
|
||||
class GenericLoginViewTests(TestCase):
|
||||
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.save({"username": "user"})
|
||||
self.user = user_acc.user
|
||||
|
@ -148,7 +142,7 @@ class GenericLoginViewTests(TestCase):
|
|||
def test_url(self):
|
||||
self.assertEqual(self.url, "/k-fet/login/generic")
|
||||
|
||||
def test_notoken_get(self):
|
||||
def test_get(self):
|
||||
"""
|
||||
Send confirmation for user to emit POST request, instead of GET.
|
||||
"""
|
||||
|
@ -159,20 +153,20 @@ class GenericLoginViewTests(TestCase):
|
|||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTemplateUsed(r, "kfet/confirm_form.html")
|
||||
|
||||
def test_notoken_post(self):
|
||||
def test_post(self):
|
||||
"""
|
||||
POST request without token in COOKIES sets a token and redirects to
|
||||
logout url.
|
||||
The kfet generic user is logged in.
|
||||
"""
|
||||
self.client.login(username="team", password="team")
|
||||
|
||||
r = self.client.post(self.url)
|
||||
|
||||
self.assertRedirects(
|
||||
r, "/logout?next={}".format(self.url), fetch_redirect_response=False
|
||||
)
|
||||
self.assertRedirects(r, reverse("kfet.kpsul"))
|
||||
self.assertEqual(r.wsgi_request.user, self.generic_user)
|
||||
with self.assertRaises(GenericTeamToken.DoesNotExist):
|
||||
GenericTeamToken.objects.get()
|
||||
|
||||
def test_notoken_not_team(self):
|
||||
def test_not_team(self):
|
||||
"""
|
||||
Logged in user must be a team user to initiate login as generic user.
|
||||
"""
|
||||
|
@ -181,74 +175,28 @@ class GenericLoginViewTests(TestCase):
|
|||
# With GET.
|
||||
r = self.client.get(self.url)
|
||||
self.assertRedirects(
|
||||
r, "/login?next={}".format(self.url), fetch_redirect_response=False
|
||||
r, "/profil/login/?next={}".format(self.url), fetch_redirect_response=False
|
||||
)
|
||||
|
||||
# Also with POST.
|
||||
r = self.client.post(self.url)
|
||||
self.assertRedirects(
|
||||
r, "/login?next={}".format(self.url), fetch_redirect_response=False
|
||||
r, "/profil/login/?next={}".format(self.url), fetch_redirect_response=False
|
||||
)
|
||||
|
||||
def _set_signed_cookie(self, client, key, value):
|
||||
signed_value = signing.get_cookie_signer(salt=key).sign(value)
|
||||
client.cookies.load({key: signed_value})
|
||||
|
||||
def _is_cookie_deleted(self, client, key):
|
||||
try:
|
||||
self.assertNotIn(key, client.cookies)
|
||||
except AssertionError:
|
||||
try:
|
||||
cookie = client.cookies[key]
|
||||
# It also can be emptied.
|
||||
self.assertEqual(cookie.value, "")
|
||||
self.assertEqual(cookie["expires"], "Thu, 01-Jan-1970 00:00:00 GMT")
|
||||
self.assertEqual(cookie["max-age"], 0)
|
||||
except AssertionError:
|
||||
raise AssertionError("The cookie '%s' still exists." % key)
|
||||
|
||||
def test_withtoken_valid(self):
|
||||
"""
|
||||
The kfet generic user is logged in.
|
||||
"""
|
||||
token = GenericTeamToken.objects.create(token="valid")
|
||||
self._set_signed_cookie(
|
||||
self.client, GenericLoginView.TOKEN_COOKIE_NAME, "valid"
|
||||
)
|
||||
|
||||
r = self.client.get(self.url)
|
||||
|
||||
self.assertRedirects(r, reverse("kfet.kpsul"))
|
||||
self.assertEqual(r.wsgi_request.user, self.generic_user)
|
||||
self._is_cookie_deleted(self.client, GenericLoginView.TOKEN_COOKIE_NAME)
|
||||
with self.assertRaises(GenericTeamToken.DoesNotExist):
|
||||
token.refresh_from_db()
|
||||
|
||||
def test_withtoken_invalid(self):
|
||||
"""
|
||||
If token is invalid, delete it and try again.
|
||||
"""
|
||||
self._set_signed_cookie(
|
||||
self.client, GenericLoginView.TOKEN_COOKIE_NAME, "invalid"
|
||||
)
|
||||
|
||||
r = self.client.get(self.url)
|
||||
|
||||
self.assertRedirects(r, self.url, fetch_redirect_response=False)
|
||||
self.assertEqual(r.wsgi_request.user, AnonymousUser())
|
||||
self._is_cookie_deleted(self.client, GenericLoginView.TOKEN_COOKIE_NAME)
|
||||
|
||||
def test_flow_ok(self):
|
||||
def test_post_redirect(self):
|
||||
"""
|
||||
A team user is logged in as the kfet generic user.
|
||||
"""
|
||||
self.client.login(username="team", password="team")
|
||||
next_url = "/k-fet/"
|
||||
|
||||
r = self.client.post("{}?next={}".format(self.url, next_url), follow=True)
|
||||
next_url = "/any-url/"
|
||||
url = self.url + "?next=" + next_url
|
||||
|
||||
r = self.client.post(url)
|
||||
|
||||
self.assertRedirects(r, next_url, fetch_redirect_response=False)
|
||||
self.assertEqual(r.wsgi_request.user, self.generic_user)
|
||||
self.assertEqual(r.wsgi_request.path, "/k-fet/")
|
||||
|
||||
|
||||
##
|
||||
|
@ -262,10 +210,6 @@ class GenericLoginViewTests(TestCase):
|
|||
|
||||
class TemporaryAuthTests(TestCase):
|
||||
def setUp(self):
|
||||
patcher_messages = mock.patch("gestioncof.signals.messages")
|
||||
patcher_messages.start()
|
||||
self.addCleanup(patcher_messages.stop)
|
||||
|
||||
self.factory = RequestFactory()
|
||||
|
||||
self.middleware = TemporaryAuthMiddleware(mock.Mock())
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth import authenticate, login
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.urlresolvers import reverse, reverse_lazy
|
||||
from django.db.models import Prefetch
|
||||
from django.http import QueryDict
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import CreateView, UpdateView
|
||||
|
||||
from kfet.decorators import teamkfet_required
|
||||
|
||||
from .forms import GroupForm
|
||||
from .models import GenericTeamToken
|
||||
|
||||
|
@ -21,91 +19,50 @@ from .models import GenericTeamToken
|
|||
class GenericLoginView(View):
|
||||
"""
|
||||
View to authenticate as kfet generic user.
|
||||
|
||||
It is a 2-step view. First, issue a token if user is a team member and send
|
||||
him to the logout view (for proper disconnect) with callback url to here.
|
||||
Then authenticate the token to log in as the kfet generic user.
|
||||
|
||||
Token is stored in COOKIES to avoid share it with the authentication
|
||||
provider, which can be external. Session is unusable as it will be cleared
|
||||
on logout.
|
||||
"""
|
||||
|
||||
TOKEN_COOKIE_NAME = "kfettoken"
|
||||
|
||||
@method_decorator(require_http_methods(["GET", "POST"]))
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
token = request.get_signed_cookie(self.TOKEN_COOKIE_NAME, None)
|
||||
if not token:
|
||||
if not request.user.has_perm("kfet.is_team"):
|
||||
return redirect_to_login(request.get_full_path())
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
if request.method == "POST":
|
||||
# Step 1: set token and logout user.
|
||||
return self.prepare_auth()
|
||||
else:
|
||||
# GET request should not change server/client states. Send a
|
||||
# confirmation template to emit a POST request.
|
||||
return render(
|
||||
request,
|
||||
"kfet/confirm_form.html",
|
||||
{
|
||||
"title": _("Ouvrir une session partagée"),
|
||||
"text": _(
|
||||
"Êtes-vous sûr·e de vouloir ouvrir une session "
|
||||
"partagée ?"
|
||||
),
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Step 2: validate token.
|
||||
return self.validate_auth(token)
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
GET requests should not change server/client states. Prompt user for
|
||||
confirmation.
|
||||
"""
|
||||
return render(
|
||||
request,
|
||||
"kfet/confirm_form.html",
|
||||
{
|
||||
"title": _("Ouvrir une session partagée"),
|
||||
"text": _(
|
||||
"Êtes-vous sûr·e de vouloir ouvrir une session " "partagée ?"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def prepare_auth(self):
|
||||
# Issue token.
|
||||
def post(self, request, *args, **kwargs):
|
||||
# Issue token, used by GenericBackend.
|
||||
token = GenericTeamToken.objects.create_token()
|
||||
|
||||
# Prepare callback of logout.
|
||||
here_url = reverse(login_generic)
|
||||
if "next" in self.request.GET:
|
||||
# Keep given next page.
|
||||
here_qd = QueryDict(mutable=True)
|
||||
here_qd["next"] = self.request.GET["next"]
|
||||
here_url += "?{}".format(here_qd.urlencode())
|
||||
logout(self.request)
|
||||
|
||||
logout_url = reverse("cof-logout")
|
||||
logout_qd = QueryDict(mutable=True)
|
||||
logout_qd["next"] = here_url
|
||||
logout_url += "?{}".format(logout_qd.urlencode(safe="/"))
|
||||
# Authenticate with GenericBackend. Should always return the kfet
|
||||
# generic user.
|
||||
user = authenticate(request=self.request, kfet_token=token.token)
|
||||
|
||||
resp = redirect(logout_url)
|
||||
resp.set_signed_cookie(self.TOKEN_COOKIE_NAME, token.token, httponly=True)
|
||||
return resp
|
||||
if not user:
|
||||
return redirect(self.request.get_full_path())
|
||||
|
||||
def validate_auth(self, token):
|
||||
# Authenticate with GenericBackend.
|
||||
user = authenticate(request=self.request, kfet_token=token)
|
||||
|
||||
if user:
|
||||
# Log in generic user.
|
||||
login(self.request, user)
|
||||
messages.success(
|
||||
self.request, _("K-Fêt — Ouverture d'une session partagée.")
|
||||
)
|
||||
resp = redirect(self.get_next_url())
|
||||
else:
|
||||
# Try again.
|
||||
resp = redirect(self.request.get_full_path())
|
||||
|
||||
# Prevents blocking due to an invalid COOKIE.
|
||||
resp.delete_cookie(self.TOKEN_COOKIE_NAME)
|
||||
return resp
|
||||
# Log in generic user.
|
||||
login(self.request, user)
|
||||
messages.success(self.request, _("K-Fêt — Ouverture d'une session partagée."))
|
||||
return redirect(self.get_next_url())
|
||||
|
||||
def get_next_url(self):
|
||||
return self.request.GET.get("next", reverse("kfet.kpsul"))
|
||||
|
||||
|
||||
login_generic = GenericLoginView.as_view()
|
||||
login_generic = teamkfet_required(GenericLoginView.as_view())
|
||||
|
||||
|
||||
@permission_required("kfet.manage_perms")
|
||||
|
|
|
@ -106,11 +106,6 @@ class OpenKfetViewsTest(ChannelTestCase):
|
|||
"""OpenKfet views unit-tests suite."""
|
||||
|
||||
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
|
||||
perms = {
|
||||
"kfet.is_team": Permission.objects.get(codename="is_team"),
|
||||
|
@ -187,8 +182,7 @@ class OpenKfetConsumerTest(ChannelTestCase):
|
|||
OpenKfetConsumer.group_send("kfet.open.team", {"test": "plop"})
|
||||
self.assertIsNone(c.receive())
|
||||
|
||||
@mock.patch("gestioncof.signals.messages")
|
||||
def test_team_user(self, mock_messages):
|
||||
def test_team_user(self):
|
||||
"""Team user is added to kfet.open.team group."""
|
||||
# setup team user and its client
|
||||
t = User.objects.create_user("team", "", "team")
|
||||
|
@ -217,10 +211,6 @@ class OpenKfetScenarioTest(ChannelTestCase):
|
|||
"""OpenKfet functionnal tests suite."""
|
||||
|
||||
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)
|
||||
self.c = Client()
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
{% if user.username != 'kfet_genericteam' %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="#" data-url="{% url "kfet.login.generic" %}" onclick="submit_url(this)">
|
||||
<a href="#" data-url="{% url "kfet.login.generic" %}?next={{ request.get_full_path|urlencode }}" onclick="submit_url(this)">
|
||||
{% trans "Ouvrir une session partagée" %}
|
||||
</a>
|
||||
</li>
|
||||
|
@ -101,8 +101,8 @@
|
|||
{% endif %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="{% url "cof-logout" %}?next={{ kfet_home_url|urlencode }}">
|
||||
<span class="glyphicon glyphicon-log-out"></span><span>Déconnexion</span>
|
||||
<a href="#" data-url="{% url "account_logout" %}?next={{ kfet_home_url|urlencode }}" onclick="submit_url(this)">
|
||||
<span class="glyphicon glyphicon-log-out"></span><span>{% trans "Déconnexion" %}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -110,15 +110,14 @@
|
|||
{% endif %}
|
||||
{% if user.is_authenticated and not perms.kfet.is_team %}
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
{% elif not user.is_authenticated %}
|
||||
<li>
|
||||
<a href="{% url "cof-login" %}?next={{ request.path|urlencode }}" title="Connexion">
|
||||
<span>Connexion</span><!--
|
||||
--><span class="glyphicon glyphicon-log-in"></span>
|
||||
<a href="{% url "account_login" %}?next={{ request.get_full_path|urlencode }}" title="{% trans "Connexion" %}">
|
||||
<span>{% trans "Connexion" %}</span><span class="glyphicon glyphicon-log-in"></span>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import Permission, User
|
||||
from django.test import Client, TestCase
|
||||
|
||||
|
@ -7,8 +5,7 @@ from kfet.models import Account, Article, ArticleCategory
|
|||
|
||||
|
||||
class TestStats(TestCase):
|
||||
@patch("gestioncof.signals.messages")
|
||||
def test_user_stats(self, mock_messages):
|
||||
def test_user_stats(self):
|
||||
"""
|
||||
Checks that we can get the stat-related pages without any problem.
|
||||
"""
|
||||
|
@ -68,5 +65,5 @@ class TestStats(TestCase):
|
|||
for url in articles_urls:
|
||||
resp = client.get(url)
|
||||
self.assertEqual(200, resp.status_code)
|
||||
resp2 = client2.get(url, follow=True)
|
||||
self.assertRedirects(resp2, "/")
|
||||
resp2 = client2.get(url)
|
||||
self.assertRedirects(resp2, "/profil/login/?next={}".format(url))
|
||||
|
|
|
@ -1,67 +1,14 @@
|
|||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
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 shared.tests.testcases import (
|
||||
TestCaseMixin as BaseTestCaseMixin,
|
||||
ViewTestCaseMixin as BaseViewTestCaseMixin,
|
||||
)
|
||||
|
||||
from .utils import create_root, create_team, create_user
|
||||
|
||||
|
||||
class TestCaseMixin:
|
||||
class TestCaseMixin(BaseTestCaseMixin):
|
||||
"""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"):
|
||||
"""
|
||||
Test that a response (retrieved with a Client) contains error due to
|
||||
|
@ -113,65 +60,13 @@ class TestCaseMixin:
|
|||
value = value()
|
||||
self.assertEqual(value, expected_value)
|
||||
|
||||
def assertUrlsEqual(self, actual, expected):
|
||||
"""
|
||||
Test that the url 'actual' is as 'expected'.
|
||||
|
||||
Arguments:
|
||||
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):
|
||||
class ViewTestCaseMixin(TestCaseMixin, BaseViewTestCaseMixin):
|
||||
"""
|
||||
TestCase extension to ease tests of kfet views.
|
||||
|
||||
|
||||
Urls concerns
|
||||
-------------
|
||||
|
||||
# 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
|
||||
--------------
|
||||
Most information can be found in the base parent class doc.
|
||||
This class performs some changes to users management, detailed below.
|
||||
|
||||
During setup, three users are created with their kfet account:
|
||||
- 'user': a basic user without any permission, account trigramme: 000,
|
||||
|
@ -181,78 +76,19 @@ class ViewTestCaseMixin(TestCaseMixin):
|
|||
trigramme: LIQ.
|
||||
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
|
||||
'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.
|
||||
|
||||
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
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
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 = {}
|
||||
|
||||
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
|
||||
super().setUp()
|
||||
|
||||
def get_users_base(self):
|
||||
"""
|
||||
|
@ -277,80 +113,7 @@ class ViewTestCaseMixin(TestCaseMixin):
|
|||
users_base["liq"] = create_user("liq", "LIQ")
|
||||
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):
|
||||
self.users[label] = user
|
||||
super().register_user(label, user)
|
||||
if hasattr(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)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
configparser==3.5.0
|
||||
Django==1.11.*
|
||||
django-autocomplete-light==3.1.3
|
||||
django-allauth-ens==1.1.*
|
||||
django-autoslug==1.9.3
|
||||
django-cas-ng==3.5.7
|
||||
django-djconfig==0.5.3
|
||||
django-recaptcha==1.4.0
|
||||
django-redis-cache==1.7.1
|
||||
|
|
0
shared/__init__.py
Normal file
0
shared/__init__.py
Normal file
21
shared/allauth_adapter.py
Normal file
21
shared/allauth_adapter.py
Normal 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
354
shared/tests/test_auth.py
Normal 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")
|
|
@ -41,7 +41,7 @@ class TestCaseMixin:
|
|||
querystring["next"] = full_path
|
||||
|
||||
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.
|
||||
|
@ -239,13 +239,6 @@ class ViewTestCaseMixin(TestCaseMixin):
|
|||
"""
|
||||
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.
|
||||
|
|
43
utils/models.py
Normal file
43
utils/models.py
Normal 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
|
Loading…
Reference in a new issue