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:
parent
49a74e8e1e
commit
05eeb6a25c
29 changed files with 877 additions and 504 deletions
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
86
cof/urls.py
86
cof/urls.py
|
@ -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
|
||||||
|
|
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.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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 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):
|
def context_processor(request):
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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" %}
|
{% 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"> | </span>
|
<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>
|
</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 -->
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
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)
|
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",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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="/"))
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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
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
|
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
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