Compare commits
4 commits
master
...
aureplop/i
Author | SHA1 | Date | |
---|---|---|---|
|
a4be431c4f | ||
|
030a02375c | ||
|
e56200a569 | ||
|
05eeb6a25c |
30 changed files with 927 additions and 645 deletions
|
@ -22,7 +22,7 @@ test:
|
||||||
stage: test
|
stage: test
|
||||||
before_script:
|
before_script:
|
||||||
- mkdir -p vendor/{pip,apt}
|
- 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 -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
|
- 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
|
# 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.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
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser, Group, Permission, User
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
from django.core import signing
|
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.test import RequestFactory, TestCase
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
@ -13,7 +12,6 @@ from .backends import AccountBackend, GenericBackend
|
||||||
from .middleware import TemporaryAuthMiddleware
|
from .middleware import TemporaryAuthMiddleware
|
||||||
from .models import GenericTeamToken
|
from .models import GenericTeamToken
|
||||||
from .utils import get_kfet_generic_user
|
from .utils import get_kfet_generic_user
|
||||||
from .views import GenericLoginView
|
|
||||||
|
|
||||||
##
|
##
|
||||||
# Forms
|
# Forms
|
||||||
|
@ -123,10 +121,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
|
||||||
|
@ -148,7 +142,7 @@ class GenericLoginViewTests(TestCase):
|
||||||
def test_url(self):
|
def test_url(self):
|
||||||
self.assertEqual(self.url, "/k-fet/login/generic")
|
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.
|
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.assertEqual(r.status_code, 200)
|
||||||
self.assertTemplateUsed(r, "kfet/confirm_form.html")
|
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
|
The kfet generic user is logged in.
|
||||||
logout url.
|
|
||||||
"""
|
"""
|
||||||
self.client.login(username="team", password="team")
|
self.client.login(username="team", password="team")
|
||||||
|
|
||||||
r = self.client.post(self.url)
|
r = self.client.post(self.url)
|
||||||
|
|
||||||
self.assertRedirects(
|
self.assertRedirects(r, reverse("kfet.kpsul"))
|
||||||
r, "/logout?next={}".format(self.url), fetch_redirect_response=False
|
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.
|
Logged in user must be a team user to initiate login as generic user.
|
||||||
"""
|
"""
|
||||||
|
@ -181,74 +175,28 @@ class GenericLoginViewTests(TestCase):
|
||||||
# With GET.
|
# With GET.
|
||||||
r = self.client.get(self.url)
|
r = self.client.get(self.url)
|
||||||
self.assertRedirects(
|
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.
|
# Also with POST.
|
||||||
r = self.client.post(self.url)
|
r = self.client.post(self.url)
|
||||||
self.assertRedirects(
|
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):
|
def test_post_redirect(self):
|
||||||
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):
|
|
||||||
"""
|
"""
|
||||||
A team user is logged in as the kfet generic user.
|
A team user is logged in as the kfet generic user.
|
||||||
"""
|
"""
|
||||||
self.client.login(username="team", password="team")
|
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.user, self.generic_user)
|
||||||
self.assertEqual(r.wsgi_request.path, "/k-fet/")
|
|
||||||
|
|
||||||
|
|
||||||
##
|
##
|
||||||
|
@ -262,10 +210,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())
|
||||||
|
|
|
@ -1,19 +1,17 @@
|
||||||
from django.contrib import messages
|
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.decorators import permission_required
|
||||||
from django.contrib.auth.models import Group, User
|
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.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.urlresolvers import reverse, reverse_lazy
|
from django.core.urlresolvers import reverse, reverse_lazy
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
from django.http import QueryDict
|
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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 import View
|
||||||
from django.views.generic.edit import CreateView, UpdateView
|
from django.views.generic.edit import CreateView, UpdateView
|
||||||
|
|
||||||
|
from kfet.decorators import teamkfet_required
|
||||||
|
|
||||||
from .forms import GroupForm
|
from .forms import GroupForm
|
||||||
from .models import GenericTeamToken
|
from .models import GenericTeamToken
|
||||||
|
|
||||||
|
@ -21,91 +19,50 @@ from .models import GenericTeamToken
|
||||||
class GenericLoginView(View):
|
class GenericLoginView(View):
|
||||||
"""
|
"""
|
||||||
View to authenticate as kfet generic user.
|
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):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
token = request.get_signed_cookie(self.TOKEN_COOKIE_NAME, None)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
if not token:
|
|
||||||
if not request.user.has_perm("kfet.is_team"):
|
|
||||||
return redirect_to_login(request.get_full_path())
|
|
||||||
|
|
||||||
if request.method == "POST":
|
def get(self, request, *args, **kwargs):
|
||||||
# Step 1: set token and logout user.
|
"""
|
||||||
return self.prepare_auth()
|
GET requests should not change server/client states. Prompt user for
|
||||||
else:
|
confirmation.
|
||||||
# GET request should not change server/client states. Send a
|
"""
|
||||||
# confirmation template to emit a POST request.
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"kfet/confirm_form.html",
|
"kfet/confirm_form.html",
|
||||||
{
|
{
|
||||||
"title": _("Ouvrir une session partagée"),
|
"title": _("Ouvrir une session partagée"),
|
||||||
"text": _(
|
"text": _(
|
||||||
"Êtes-vous sûr·e de vouloir ouvrir une session "
|
"Êtes-vous sûr·e de vouloir ouvrir une session " "partagée ?"
|
||||||
"partagée ?"
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# Step 2: validate token.
|
|
||||||
return self.validate_auth(token)
|
|
||||||
|
|
||||||
def prepare_auth(self):
|
def post(self, request, *args, **kwargs):
|
||||||
# Issue token.
|
# Issue token, used by GenericBackend.
|
||||||
token = GenericTeamToken.objects.create_token()
|
token = GenericTeamToken.objects.create_token()
|
||||||
|
|
||||||
# Prepare callback of logout.
|
logout(self.request)
|
||||||
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_url = reverse("cof-logout")
|
# Authenticate with GenericBackend. Should always return the kfet
|
||||||
logout_qd = QueryDict(mutable=True)
|
# generic user.
|
||||||
logout_qd["next"] = here_url
|
user = authenticate(request=self.request, kfet_token=token.token)
|
||||||
logout_url += "?{}".format(logout_qd.urlencode(safe="/"))
|
|
||||||
|
|
||||||
resp = redirect(logout_url)
|
if not user:
|
||||||
resp.set_signed_cookie(self.TOKEN_COOKIE_NAME, token.token, httponly=True)
|
return redirect(self.request.get_full_path())
|
||||||
return resp
|
|
||||||
|
|
||||||
def validate_auth(self, token):
|
|
||||||
# Authenticate with GenericBackend.
|
|
||||||
user = authenticate(request=self.request, kfet_token=token)
|
|
||||||
|
|
||||||
if user:
|
|
||||||
# Log in generic user.
|
# Log in generic user.
|
||||||
login(self.request, user)
|
login(self.request, user)
|
||||||
messages.success(
|
messages.success(self.request, _("K-Fêt — Ouverture d'une session partagée."))
|
||||||
self.request, _("K-Fêt — Ouverture d'une session partagée.")
|
return redirect(self.get_next_url())
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
def get_next_url(self):
|
def get_next_url(self):
|
||||||
return self.request.GET.get("next", reverse("kfet.kpsul"))
|
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")
|
@permission_required("kfet.manage_perms")
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
{% if user.username != 'kfet_genericteam' %}
|
{% if user.username != 'kfet_genericteam' %}
|
||||||
<li class="divider"></li>
|
<li class="divider"></li>
|
||||||
<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" %}
|
{% trans "Ouvrir une session partagée" %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -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.get_full_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