Merge branch 'beta' into 'master'

Beta

Closes #59

See merge request bde/nk20!107
This commit is contained in:
Pierre-antoine Comby 2020-09-04 22:32:14 +02:00
commit 3c636e9f71
59 changed files with 3064 additions and 1301 deletions

View File

@ -40,15 +40,7 @@ linters:
stage: quality-assurance stage: quality-assurance
image: debian:buster-backports image: debian:buster-backports
before_script: before_script:
- > - apt-get update && apt-get install -y tox
apt-get update &&
apt-get install --no-install-recommends -t buster-backports -y
python3-django python3-django-crispy-forms
python3-django-extensions python3-django-filters python3-django-polymorphic
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil
python3-babel python3-lockfile python3-pip python3-phonenumbers
python3-bs4 python3-setuptools tox
texlive-latex-extra texlive-lang-french lmodern texlive-fonts-recommended
script: tox -e linters script: tox -e linters
# Be nice to new contributors, but please use `tox` # Be nice to new contributors, but please use `tox`

115
README.md
View File

@ -4,16 +4,82 @@
[![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![pipeline status](https://gitlab.crans.org/bde/nk20/badges/master/pipeline.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
[![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master) [![coverage report](https://gitlab.crans.org/bde/nk20/badges/master/coverage.svg)](https://gitlab.crans.org/bde/nk20/commits/master)
## Installation sur un serveur ## Table des matières
On supposera pour la suite que vous utilisez une installation de Debian Buster ou Ubuntu 20.04 fraîche ou bien configuré. - [Installation d'une instance de développement](#installation-dune-instance-de-développement)
- [Installation d'une instance de production](#installation-dune-instance-de-production)
## Installation d'une instance de développement
L'instance de développement installe la majorité des dépendances dans un environnement Python isolé.
Bien que cela permette de créer une instance sur toutes les distributions,
**cela veut dire que vos dépendances ne seront pas mises à jour automatiquement.**
1. **Installation des dépendances de la distribution.**
Il y a quelques dépendances qui ne sont pas trouvable dans PyPI.
On donne ci-dessous l'exemple pour une distribution basée sur Debian, mais vous pouvez facilement adapter pour ArchLinux ou autre.
```bash
$ sudo apt update
$ sudo apt install --no-install-recommends -y \
ipython3 python3-setuptools python3-venv python3-dev \
texlive-latex-base texlive-lang-french lmodern texlive-fonts-recommended \
gettext libjs-bootstrap4 fonts-font-awesome git
```
2. **Clonage du dépot** là où vous voulez :
```bash
$ git clone git@gitlab.crans.org:bde/nk20.git && cd nk20
```
3. **Création d'un environment de travail Python décorrélé du système.**
On n'utilise pas `--system-site-packages` ici pour ne pas avoir des clashs de versions de modules avec le système.
```bash
$ python3 -m venv env
$ source env/bin/activate # entrer dans l'environnement
(env)$ pip3 install -r requirements.txt
(env)$ deactivate # sortir de l'environnement
```
4. **Variable d'environnement.**
Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
ce qu'il faut.
5. **Migrations et chargement des données initiales.**
Pour initialiser la base de données avec de quoi travailler.
```bash
(env)$ ./manage.py collectstatic --noinput
(env)$ ./manage.py compilemessages
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
```
6. Enjoy :
```bash
(env)$ ./manage.py runserver 0.0.0.0:8000
```
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
de la note sur un téléphone !
## Installation d'une instance de production
**En production on souhaite absolument utiliser les modules Python packagées dans le gestionnaire de paquet.**
Cela permet de mettre à jour facilement les dépendances critiques telles que Django.
L'installation d'une instance de production néccessite **une installation de Debian Buster ou d'Ubuntu 20.04**.
Pour aller vite vous pouvez lancer le Playbook Ansible fournit dans ce dépôt en l'adaptant. Pour aller vite vous pouvez lancer le Playbook Ansible fournit dans ce dépôt en l'adaptant.
Sinon vous pouvez suivre les étapes ici. Sinon vous pouvez suivre les étapes décrites ci-dessous.
### Installation avec Debian/Ubuntu 0. Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports.
0. **Activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports.
```bash ```bash
$ echo "deb http://deb.debian.org/debian buster-backports main" | sudo tee /etc/apt/sources.list.d/deb_debian_org_debian.list $ echo "deb http://deb.debian.org/debian buster-backports main" | sudo tee /etc/apt/sources.list.d/deb_debian_org_debian.list
@ -192,43 +258,6 @@ nk20:
- "traefik.http.services.nk20.loadbalancer.server.port=8080" - "traefik.http.services.nk20.loadbalancer.server.port=8080"
``` ```
### Lancer un serveur de développement
Avec `./manage.py runserver` il est très rapide de mettre en place
un serveur de développement par exemple sur son ordinateur.
1. Cloner le dépôt là où vous voulez :
$ git clone git@gitlab.crans.org:bde/nk20.git && cd nk20
2. Créer un environnement Python isolé
pour ne pas interférer avec les versions de paquets systèmes :
$ python3 -m venv venv
$ source venv/bin/activate
(env)$ pip install -r requirements.txt
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
ce qu'il faut
4. Migrations et chargement des données initiales :
(env)$ ./manage.py makemigrations
(env)$ ./manage.py migrate
(env)$ ./manage.py loaddata initial
5. Créer un super-utilisateur :
(env)$ ./manage.py createsuperuser
6. Enjoy :
(env)$ ./manage.py runserver 0.0.0.0:8000
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
de la note sur un téléphone !
## Documentation ## Documentation
Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC). Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).

View File

@ -130,7 +130,7 @@ class Activity(models.Model):
raise ValidationError(_("The end date must be after the start date.")) raise ValidationError(_("The end date must be after the start date."))
ret = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
if settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS: if not settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS:
def refresh_activities(): def refresh_activities():
from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand
RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name) RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name)

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h1 class="text-white">{{ title }}</h1> <h1 class="text-white">{{ title }}</h1>
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="btn-group btn-group-toggle bg-light" style="width: 100%" data-toggle="buttons"> <div class="btn-group btn-group-toggle bg-light" style="width: 100%">
<a href="{% url "note:transfer" %}#transfer" class="btn btn-sm btn-outline-primary"> <a href="{% url "note:transfer" %}#transfer" class="btn btn-sm btn-outline-primary">
{% trans "Transfer" %} {% trans "Transfer" %}
</a> </a>

View File

@ -0,0 +1,176 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from activity.models import Activity, ActivityType, Guest, Entry
from member.models import Club
class TestActivities(TestCase):
"""
Test activities
"""
fixtures = ('initial',)
def setUp(self):
self.user = User.objects.create_superuser(
username="admintoto",
password="tototototo",
email="toto@example.com"
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.activity = Activity.objects.create(
name="Activity",
description="This is a test activity\non two very very long lines\nbecause this is very important.",
location="Earth",
activity_type=ActivityType.objects.get(name="Pot"),
creater=self.user,
organizer=Club.objects.get(name="Kfet"),
attendees_club=Club.objects.get(name="Kfet"),
date_start=timezone.now(),
date_end=timezone.now() + timedelta(days=2),
valid=True,
)
self.guest = Guest.objects.create(
activity=self.activity,
inviter=self.user.note,
last_name="GUEST",
first_name="Guest",
)
def test_activity_list(self):
"""
Display the list of all activities
"""
response = self.client.get(reverse("activity:activity_list"))
self.assertEqual(response.status_code, 200)
def test_activity_create(self):
"""
Create a new activity
"""
response = self.client.get(reverse("activity:activity_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("activity:activity_create"), data=dict(
name="Activity created",
description="This activity was successfully created.",
location="Earth",
activity_type=ActivityType.objects.get(name="Soirée de club").id,
creater=self.user.id,
organizer=Club.objects.get(name="Kfet").id,
attendees_club=Club.objects.get(name="Kfet").id,
date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
valid=True,
))
self.assertTrue(Activity.objects.filter(name="Activity created").exists())
activity = Activity.objects.get(name="Activity created")
self.assertRedirects(response, reverse("activity:activity_detail", args=(activity.pk,)), 302, 200)
def test_activity_detail(self):
"""
Display the detail of an activity
"""
response = self.client.get(reverse("activity:activity_detail", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
def test_activity_update(self):
"""
Update an activity
"""
response = self.client.get(reverse("activity:activity_update", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("activity:activity_update", args=(self.activity.pk,)), data=dict(
name=str(self.activity) + " updated",
description="This activity was successfully updated.",
location="Earth",
activity_type=ActivityType.objects.get(name="Autre").id,
creater=self.user.id,
organizer=Club.objects.get(name="Kfet").id,
attendees_club=Club.objects.get(name="Kfet").id,
date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
valid=True,
))
self.assertTrue(Activity.objects.filter(name="Activity updated").exists())
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
def test_activity_entry(self):
"""
Create some entries
"""
self.activity.open = True
self.activity.save()
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=guest")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=admin")
self.assertEqual(response.status_code, 200)
# User entry
response = self.client.post("/api/activity/entry/", data=dict(
activity=self.activity.id,
note=self.user.note.id,
guest="",
))
self.assertEqual(response.status_code, 201) # 201 = Created
self.assertTrue(Entry.objects.filter(note=self.user.note, guest=None, activity=self.activity).exists())
# Guest entry
response = self.client.post("/api/activity/entry/", data=dict(
activity=self.activity.id,
note=self.user.note.id,
guest=self.guest.id,
))
self.assertEqual(response.status_code, 201) # 201 = Created
self.assertTrue(Entry.objects.filter(note=self.user.note, guest=self.guest.id, activity=self.activity).exists())
def test_activity_invite(self):
"""
Try to invite people to an activity
"""
response = self.client.get(reverse("activity:activity_invite", args=(self.activity.pk,)))
self.assertEqual(response.status_code, 200)
# The activity is started, can't invite
response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
activity=self.activity.id,
inviter=self.user.note.id,
last_name="GUEST2",
first_name="Guest",
))
self.assertEqual(response.status_code, 200)
self.activity.date_start += timedelta(days=1)
self.activity.save()
response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
activity=self.activity.id,
inviter=self.user.note.id,
last_name="GUEST2",
first_name="Guest",
))
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
def test_activity_ics(self):
"""
Render the ICS calendar
"""
response = self.client.get(reverse("activity:calendar_ics"))
self.assertEqual(response.status_code, 200)

View File

@ -14,4 +14,5 @@ urlpatterns = [
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'), path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'), path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
path('new/', views.ActivityCreateView.as_view(), name='activity_create'), path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
] ]

View File

@ -1,14 +1,18 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from hashlib import md5
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import F, Q from django.db.models import F, Q
from django.http import HttpResponse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import DetailView, TemplateView, UpdateView from django.views.generic import DetailView, TemplateView, UpdateView
from django_tables2.views import SingleTableView from django_tables2.views import SingleTableView
from note.models import Alias, NoteSpecial, NoteUser from note.models import Alias, NoteSpecial, NoteUser
@ -190,10 +194,10 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if pattern[0] != "^": if pattern[0] != "^":
pattern = "^" + pattern pattern = "^" + pattern
guest_qs = guest_qs.filter( guest_qs = guest_qs.filter(
Q(first_name__regex=pattern) Q(first_name__iregex=pattern)
| Q(last_name__regex=pattern) | Q(last_name__iregex=pattern)
| Q(inviter__alias__name__regex=pattern) | Q(inviter__alias__name__iregex=pattern)
| Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern)) | Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern))
) )
else: else:
guest_qs = guest_qs.none() guest_qs = guest_qs.none()
@ -226,21 +230,19 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and self.request.GET["search"]: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
note_qs = note_qs.filter( note_qs = note_qs.filter(
Q(note__noteuser__user__first_name__regex=pattern) Q(note__noteuser__user__first_name__iregex=pattern)
| Q(note__noteuser__user__last_name__regex=pattern) | Q(note__noteuser__user__last_name__iregex=pattern)
| Q(name__regex=pattern) | Q(name__iregex=pattern)
| Q(normalized_name__regex=Alias.normalize(pattern)) | Q(normalized_name__iregex=Alias.normalize(pattern))
) )
else: else:
note_qs = note_qs.none() note_qs = note_qs.none()
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql': # SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
note_qs = note_qs.distinct('note__pk')[:20] # have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
else: # In production mode, please use PostgreSQL.
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only note_qs = note_qs.distinct('note__pk')[:20]\
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page. if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
# In production mode, please use PostgreSQL.
note_qs = note_qs.distinct()[:20]
return note_qs return note_qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -281,3 +283,60 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
Entry(activity=a, note=self.request.user.note,))] Entry(activity=a, note=self.request.user.note,))]
return context return context
class CalendarView(View):
"""
Render an ICS calendar with all valid activities.
"""
def multilines(self, string, maxlength, offset=0):
newstring = string[:maxlength - offset]
string = string[maxlength - offset:]
while string:
newstring += "\r\n "
newstring += string[:maxlength - 1]
string = string[maxlength - 1:]
return newstring
def get(self, request, *args, **kwargs):
ics = """BEGIN:VCALENDAR
VERSION: 2.0
PRODID:Note Kfet 2020
X-WR-CALNAME:Kfet Calendar
NAME:Kfet Calendar
CALSCALE:GREGORIAN
BEGIN:VTIMEZONE
TZID:Europe/Berlin
X-LIC-LOCATION:Europe/Berlin
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
"""
for activity in Activity.objects.filter(valid=True).order_by("-date_start").all():
ics += f"""BEGIN:VEVENT
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}
DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)}
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + """
-- {activity.organizer.name}
END:VEVENT
"""
ics += "END:VCALENDAR"
ics = ics.replace("\r", "").replace("\n", "\r\n")
return HttpResponse(ics, content_type="text/calendar; charset=UTF-8")

33
apps/api/serializers.py Normal file
View File

@ -0,0 +1,33 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User
from rest_framework.serializers import ModelSerializer
class UserSerializer(ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = User
exclude = (
'password',
'groups',
'user_permissions',
)
class ContentTypeSerializer(ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = ContentType
fields = '__all__'

View File

@ -3,103 +3,9 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import url, include from django.conf.urls import url, include
from django.contrib.auth.models import User from rest_framework import routers
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import routers, serializers
from rest_framework.viewsets import ReadOnlyModelViewSet
from api.viewsets import ReadProtectedModelViewSet
from note.models import Alias
class UserSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = User
exclude = (
'password',
'groups',
'user_permissions',
)
class ContentTypeSerializer(serializers.ModelSerializer):
"""
REST API Serializer for Users.
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
"""
class Meta:
model = ContentType
fields = '__all__'
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
def get_queryset(self):
queryset = super().get_queryset().order_by("username")
if "search" in self.request.GET:
pattern = self.request.GET["search"]
# We match first a user by its username, then if an alias is matched without normalization
# And finally if the normalized pattern matches a normalized alias.
queryset = queryset.filter(
username__iregex="^" + pattern
).union(
queryset.filter(
Q(note__alias__name__iregex="^" + pattern)
& ~Q(username__iregex="^" + pattern)
), all=True).union(
queryset.filter(
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
& ~Q(username__iregex="^" + pattern)
),
all=True).union(
queryset.filter(
Q(note__alias__normalized_name__iregex="^" + pattern.lower())
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
& ~Q(username__iregex="^" + pattern)
),
all=True).union(
queryset.filter(
(Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern))
& ~Q(note__alias__normalized_name__iregex="^" + pattern.lower())
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
& ~Q(username__iregex="^" + pattern)
),
all=True)
return queryset
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer
from .viewsets import ContentTypeViewSet, UserViewSet
# Routers provide an easy way of automatically determining the URL conf. # Routers provide an easy way of automatically determining the URL conf.
# Register each app API router and user viewset # Register each app API router and user viewset

View File

@ -2,12 +2,19 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import Q
from django.conf import settings
from django.contrib.auth.models import User
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from rest_framework import viewsets
from note_kfet.middlewares import get_current_session from note_kfet.middlewares import get_current_session
from note.models import Alias
from .serializers import UserSerializer, ContentTypeSerializer
class ReadProtectedModelViewSet(viewsets.ModelViewSet): class ReadProtectedModelViewSet(ModelViewSet):
""" """
Protect a ModelViewSet by filtering the objects that the user cannot see. Protect a ModelViewSet by filtering the objects that the user cannot see.
""" """
@ -19,10 +26,10 @@ class ReadProtectedModelViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
get_current_session().setdefault("permission_mask", 42) get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet): class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
""" """
Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see. Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
""" """
@ -34,4 +41,72 @@ class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
get_current_session().setdefault("permission_mask", 42) get_current_session().setdefault("permission_mask", 42)
return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
class UserViewSet(ReadProtectedModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
def get_queryset(self):
queryset = super().get_queryset()
# Sqlite doesn't support ORDER BY in subqueries
queryset = queryset.order_by("username") \
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
if "search" in self.request.GET:
pattern = self.request.GET["search"]
# Filter with different rules
# We use union-all to keep each filter rule sorted in result
queryset = queryset.filter(
# Match without normalization
note__alias__name__iregex="^" + pattern
).union(
queryset.filter(
# Match with normalization
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
),
all=True,
).union(
queryset.filter(
# Match on lower pattern
Q(note__alias__normalized_name__iregex="^" + pattern.lower())
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
),
all=True,
).union(
queryset.filter(
# Match on firstname or lastname
(Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern))
& ~Q(note__alias__normalized_name__iregex="^" + pattern.lower())
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
& ~Q(note__alias__name__iregex="^" + pattern)
),
all=True,
)
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("username")
return queryset
# This ViewSet is the only one that is accessible from all authenticated users!
class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
REST API View set.
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
then render it on /api/users/
"""
queryset = ContentType.objects.all()
serializer_class = ContentTypeSerializer

View File

@ -50,10 +50,7 @@ def save_object(sender, instance, **kwargs):
in order to store each modification made in order to store each modification made
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_log"):
return
if hasattr(instance, "_no_log"):
return return
# noinspection PyProtectedMember # noinspection PyProtectedMember
@ -120,10 +117,7 @@ def delete_object(sender, instance, **kwargs):
Each time a model is deleted, an entry in the table `Changelog` is added in the database Each time a model is deleted, an entry in the table `Changelog` is added in the database
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_log"):
return
if hasattr(instance, "_no_log"):
return return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP

View File

@ -31,9 +31,7 @@ class CustomUserAdmin(UserAdmin):
""" """
When creating a new user don't show profile one the first step When creating a new user don't show profile one the first step
""" """
if not obj: return super().get_inline_instances(request, obj) if obj else []
return list()
return super().get_inline_instances(request, obj)
@admin.register(Club, site=admin_site) @admin.register(Club, site=admin_site)

View File

@ -172,19 +172,21 @@ class Profile(models.Model):
def send_email_validation_link(self): def send_email_validation_link(self):
subject = "[Note Kfet] " + str(_("Activate your Note Kfet account")) subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
token = email_validation_token.make_token(self.user)
uid = urlsafe_base64_encode(force_bytes(self.user_id))
message = loader.render_to_string('registration/mails/email_validation_email.txt', message = loader.render_to_string('registration/mails/email_validation_email.txt',
{ {
'user': self.user, 'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"), 'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user), 'token': token,
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)), 'uid': uid,
}) })
html = loader.render_to_string('registration/mails/email_validation_email.html', html = loader.render_to_string('registration/mails/email_validation_email.html',
{ {
'user': self.user, 'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"), 'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user), 'token': token,
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)), 'uid': uid,
}) })
self.user.email_user(subject, message, html_message=html) self.user.email_user(subject, message, html_message=html)
@ -339,43 +341,40 @@ class Membership(models.Model):
return self.date_start.toordinal() <= datetime.datetime.now().toordinal() return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
def renew(self): def renew(self):
if Membership.objects.filter( if not Membership.objects.filter(
user=self.user, user=self.user,
club=self.club, club=self.club,
date_start__gte=self.club.membership_start, date_start__gte=self.club.membership_start,
).exists(): ).exists():
# Membership is already renewed # Membership is not renewed yet
return new_membership = Membership(
new_membership = Membership( user=self.user,
user=self.user, club=self.club,
club=self.club, date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start),
date_start=max(self.date_end + datetime.timedelta(days=1), self.club.membership_start), )
) if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
if hasattr(self, '_force_renew_parent') and self._force_renew_parent: new_membership._force_renew_parent = True
new_membership._force_renew_parent = True if hasattr(self, '_soge') and self._soge:
if hasattr(self, '_soge') and self._soge: new_membership._soge = True
new_membership._soge = True if hasattr(self, '_force_save') and self._force_save:
if hasattr(self, '_force_save') and self._force_save: new_membership._force_save = True
new_membership._force_save = True new_membership.save()
new_membership.save() new_membership.roles.set(self.roles.all())
new_membership.roles.set(self.roles.all()) new_membership.save()
new_membership.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Calculate fee and end date before saving the membership and creating the transaction if needed. Calculate fee and end date before saving the membership and creating the transaction if needed.
""" """
created = not self.pk
if self.pk: if not created:
for role in self.roles.all(): for role in self.roles.all():
club = role.for_club club = role.for_club
if club is not None: if club is not None:
if club.pk != self.club_id: if club.pk != self.club_id:
raise ValidationError(_('The role {role} does not apply to the club {club}.') raise ValidationError(_('The role {role} does not apply to the club {club}.')
.format(role=role.name, club=club.name)) .format(role=role.name, club=club.name))
else:
created = not self.pk
if created:
if Membership.objects.filter( if Membership.objects.filter(
user=self.user, user=self.user,
club=self.club, club=self.club,
@ -384,7 +383,7 @@ class Membership(models.Model):
).exists(): ).exists():
raise ValidationError(_('User is already a member of the club')) raise ValidationError(_('User is already a member of the club'))
if self.club.parent_club is not None and not self.pk: if self.club.parent_club is not None:
# Check that the user is already a member of the parent club if the membership is created # Check that the user is already a member of the parent club if the membership is created
if not Membership.objects.filter( if not Membership.objects.filter(
user=self.user, user=self.user,
@ -433,15 +432,10 @@ class Membership(models.Model):
raise ValidationError(_('User is not a member of the parent club') raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name) + ' ' + self.club.parent_club.name)
if self.user.profile.paid: self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
self.fee = self.club.membership_fee_paid
else:
self.fee = self.club.membership_fee_unpaid
if self.club.membership_duration is not None: self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) \
self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) if self.club.membership_duration is not None else self.date_start + datetime.timedelta(days=424242)
else:
self.date_end = self.date_start + datetime.timedelta(days=424242)
if self.club.membership_end is not None and self.date_end > self.club.membership_end: if self.club.membership_end is not None and self.date_end > self.club.membership_end:
self.date_end = self.club.membership_end self.date_end = self.club.membership_end

View File

@ -6,11 +6,10 @@ def save_user_profile(instance, created, raw, **_kwargs):
""" """
Hook to create and save a profile when an user is updated if it is not registered with the signup form Hook to create and save a profile when an user is updated if it is not registered with the signup form
""" """
if raw: if not raw and created and instance.is_active:
# When provisionning data, do not try to autocreate
return
if created and instance.is_active:
from .models import Profile from .models import Profile
Profile.objects.get_or_create(user=instance) Profile.objects.get_or_create(user=instance)
if instance.is_superuser:
instance.profile.email_confirmed = True
instance.profile.registration_valid = True
instance.profile.save() instance.profile.save()

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -48,7 +48,9 @@
</dl> </dl>
{% if user_object.pk == user_object.pk %} {% if user_object.pk == user_object.pk %}
<a class="small float-right text-decoration-none" href="{% url 'member:auth_token' %}"> <div class="text-center">
{% trans 'Manage auth token' %} <a class="small badge badge-secondary" href="{% url 'member:auth_token' %}">
</a> <i class="fa fa-cogs"></i>{% trans 'API token' %}
{% endif %} </a>
</div>
{% endif %}

View File

@ -1,4 +1,4 @@
{% extends "member/base.html" %} {% extends "base.html" %}
{% comment %} {% comment %}
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}

View File

@ -1,9 +1,9 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from note.models import TransactionTemplate, TemplateCategory from django.urls import reverse
""" """
Test that login page still works Test that login page still works
@ -31,7 +31,20 @@ class TemplateLoggedInTests(TestCase):
sess.save() sess.save()
def test_login_page(self): def test_login_page(self):
response = self.client.get('/accounts/login/') response = self.client.get(reverse("login"))
self.assertEqual(response.status_code, 200)
self.client.logout()
response = self.client.post('/accounts/login/', data=dict(
username="admin",
password="adminadmin",
permission_mask=3,
))
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, 302, 200)
def test_logout(self):
response = self.client.get(reverse("logout"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_admin_index(self): def test_admin_index(self):
@ -41,22 +54,3 @@ class TemplateLoggedInTests(TestCase):
def test_accounts_password_reset(self): def test_accounts_password_reset(self):
response = self.client.get('/accounts/password_reset/') response = self.client.get('/accounts/password_reset/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_logout_page(self):
response = self.client.get('/accounts/logout/')
self.assertEqual(response.status_code, 200)
def test_transfer_page(self):
response = self.client.get('/note/transfer/')
self.assertEqual(response.status_code, 200)
def test_consos_page(self):
# Create one button and ensure that it is visible
cat = TemplateCategory.objects.create()
TransactionTemplate.objects.create(
destination_id=5,
category=cat,
amount=0,
)
response = self.client.get('/note/consos/')
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,405 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import hashlib
import os
from datetime import date, timedelta
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from member.models import Club, Membership, Profile
from note.models import Alias, NoteSpecial
from permission.models import Role
from treasury.models import SogeCredit
"""
Create some users and clubs and test that all pages are rendering properly
and that memberships are working.
"""
class TestMemberships(TestCase):
fixtures = ('initial', )
def setUp(self) -> None:
"""
Create a sample superuser, a club and a membership for all tests.
"""
self.user = User.objects.create_superuser(
username="toto",
email="toto@example.com",
password="toto",
)
self.user.profile.registration_valid = True
self.user.profile.email_confirmed = True
self.user.profile.save()
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.club = Club.objects.create(name="totoclub", parent_club=Club.objects.get(name="BDE"))
self.bde_membership = Membership.objects.create(user=self.user, club=Club.objects.get(name="BDE"))
self.membership = Membership.objects.create(user=self.user, club=self.club)
self.membership.roles.add(Role.objects.get(name="Bureau de club"))
self.membership.save()
def test_admin_pages(self):
"""
Check that Django Admin pages for the member app are loading successfully.
"""
response = self.client.get(reverse("admin:index") + "member/membership/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "member/club/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "auth/user/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") + "auth/user/" + str(self.user.pk) + "/change/")
self.assertEqual(response.status_code, 200)
def test_render_club_list(self):
"""
Render the list of all clubs, with a search.
"""
response = self.client.get(reverse("member:club_list"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:club_list") + "?search=toto")
self.assertEqual(response.status_code, 200)
def test_render_club_create(self):
"""
Try to create a new club.
"""
response = self.client.get(reverse("member:club_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:club_create"), data=dict(
name="Club toto",
email="clubtoto@example.com",
parent_club=self.club.pk,
require_memberships=False,
membership_fee_paid=0,
membership_fee_unpaid=0,
))
self.assertTrue(Club.objects.filter(name="Club toto").exists())
club = Club.objects.get(name="Club toto")
self.assertRedirects(response, club.get_absolute_url(), 302, 200)
def test_render_club_detail(self):
"""
Display the detail of a club.
"""
response = self.client.get(reverse("member:club_detail", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
def test_render_club_update(self):
"""
Try to update the information about a club.
"""
response = self.client.get(reverse("member:club_update", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:club_update", args=(self.club.pk, )), data=dict(
name="Toto club updated",
email="clubtoto@example.com",
require_memberships=True,
membership_fee_paid=0,
membership_fee_unpaid=0,
))
self.assertRedirects(response, self.club.get_absolute_url(), 302, 200)
self.assertTrue(Club.objects.exclude(name="Toto club updated"))
def test_render_club_update_picture(self):
"""
Try to update the picture of the note of a club.
"""
response = self.client.get(reverse("member:club_update_pic", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
old_pic = self.club.note.display_image
with open("apps/member/static/member/img/default_picture.png", "rb") as f:
image = SimpleUploadedFile("image.png", f.read(), "image/png")
response = self.client.post(reverse("member:club_update_pic", args=(self.club.pk,)), dict(
image=image,
x=0,
y=0,
width=200,
height=200,
))
self.assertRedirects(response, self.club.get_absolute_url(), 302, 200)
self.club.note.refresh_from_db()
self.assertTrue(os.path.exists(self.club.note.display_image.path))
os.remove(self.club.note.display_image.path)
self.club.note.display_image = old_pic
self.club.note.save()
def test_render_club_aliases(self):
"""
Display the list of the aliases of a club.
"""
# Alias creation and deletion is already tested in the note app
response = self.client.get(reverse("member:club_alias", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
def test_render_club_members(self):
"""
Display the list of the members of a club.
"""
response = self.client.get(reverse("member:club_members", args=(self.club.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:club_members", args=(self.club.pk,)) + "?search=toto&roles="
+ ",".join([str(role.pk) for role in
Role.objects.filter(weirole__isnull=True).all()]))
self.assertEqual(response.status_code, 200)
def test_render_club_add_member(self):
"""
Try to add memberships and renew them.
"""
response = self.client.get(reverse("member:club_add_member", args=(Club.objects.get(name="BDE").pk,)))
self.assertEqual(response.status_code, 200)
user = User.objects.create(username="totototo")
user.profile.registration_valid = True
user.profile.email_confirmed = True
user.profile.save()
user.save()
# We create a club without any parent and one club with parent BDE (that is the club Kfet)
for bde_parent in False, True:
if bde_parent:
club = Club.objects.get(name="Kfet")
else:
club = Club.objects.create(
name="Second club " + ("with BDE" if bde_parent else "without BDE"),
parent_club=None,
email="newclub@example.com",
require_memberships=True,
membership_fee_paid=1000,
membership_fee_unpaid=500,
membership_start=date.today(),
membership_end=date.today() + timedelta(days=366),
membership_duration=366,
)
response = self.client.get(reverse("member:club_add_member", args=(club.pk,)))
self.assertEqual(response.status_code, 200)
# Create a new membership
response = self.client.post(reverse("member:club_add_member", args=(club.pk,)), data=dict(
user=user.pk,
date_start="{:%Y-%m-%d}".format(timezone.now().date()),
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=4200,
last_name="TOTO",
first_name="Toto",
bank="Le matelas",
))
self.assertRedirects(response, club.get_absolute_url(), 302, 200)
self.assertTrue(Membership.objects.filter(user=user, club=club).exists())
# Membership is sent to the past to check renewals
membership = Membership.objects.get(user=user, club=club)
self.assertTrue(membership.valid)
membership.date_start = date(year=2000, month=1, day=1)
membership.date_end = date(year=2000, month=12, day=31)
membership.save()
self.assertFalse(membership.valid)
response = self.client.get(reverse("member:club_members", args=(club.pk,)) + "?only_active=0")
self.assertEqual(response.status_code, 200)
bde_membership = self.bde_membership
if bde_parent:
bde_membership = Membership.objects.get(club__name="BDE", user=user)
bde_membership.date_start = date(year=2000, month=1, day=1)
bde_membership.date_end = date(year=2000, month=12, day=31)
bde_membership.save()
response = self.client.get(reverse("member:club_renew_membership", args=(bde_membership.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:club_renew_membership", args=(membership.pk,)))
self.assertEqual(response.status_code, 200)
# Renew membership
response = self.client.post(reverse("member:club_renew_membership", args=(membership.pk,)), data=dict(
user=user.pk,
date_start="{:%Y-%m-%d}".format(timezone.now().date()),
soge=bde_parent,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=14242,
last_name="TOTO",
first_name="Toto",
bank="Bank",
))
self.assertRedirects(response, club.get_absolute_url(), 302, 200)
response = self.client.get(user.profile.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_auto_join_kfet_when_join_bde_with_soge(self):
"""
When we join the BDE club with a Soge registration, a Kfet membership is automatically created.
We check that it is the case.
"""
user = User.objects.create(username="new1A")
user.profile.registration_valid = True
user.profile.email_confirmed = True
user.profile.save()
user.save()
bde = Club.objects.get(name="BDE")
kfet = Club.objects.get(name="Kfet")
response = self.client.post(reverse("member:club_add_member", args=(bde.pk,)), data=dict(
user=user.pk,
date_start="{:%Y-%m-%d}".format(timezone.now().date()),
soge=True,
credit_type=NoteSpecial.objects.get(special_type="Virement bancaire").id,
credit_amount=(bde.membership_fee_paid + kfet.membership_fee_paid) / 100,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
))
self.assertRedirects(response, bde.get_absolute_url(), 302, 200)
self.assertTrue(Membership.objects.filter(user=user, club=bde).exists())
self.assertTrue(Membership.objects.filter(user=user, club=kfet).exists())
self.assertTrue(SogeCredit.objects.filter(user=user).exists())
def test_change_roles(self):
"""
Check to change the roles of a membership.
"""
response = self.client.get(reverse("member:club_manage_roles", args=(self.membership.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:club_manage_roles", args=(self.membership.pk,)), data=dict(
roles=[role.id for role in Role.objects.filter(
Q(name="Membre de club") | Q(name="Trésorier·ère de club") | Q(name="Bureau de club")).all()],
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.membership.refresh_from_db()
self.assertEqual(self.membership.roles.count(), 3)
def test_render_user_list(self):
"""
Display the user search page.
"""
response = self.client.get(reverse("member:user_list"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:user_list") + "?search=toto")
self.assertEqual(response.status_code, 200)
def test_render_user_detail(self):
"""
Display the user detail page.
"""
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_render_user_update(self):
"""
Update some data about the user.
"""
response = self.client.get(reverse("member:user_update_profile", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("member:user_update_profile", args=(self.user.pk,)), data=dict(
first_name="Toto",
last_name="Toto",
username="toto changed",
email="updated@example.com",
phone_number="+33600000000",
section="",
department="A0",
promotion=timezone.now().year,
address="Earth",
paid=True,
ml_events_registration="en",
ml_sports_registration=True,
ml_art_registration=True,
report_frequency=7,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.assertTrue(User.objects.filter(username="toto changed").exists())
self.assertTrue(Profile.objects.filter(address="Earth").exists())
self.assertTrue(Alias.objects.filter(normalized_name="totochanged").exists())
def test_render_user_update_picture(self):
"""
Update the note picture of the user.
"""
response = self.client.get(reverse("member:user_update_pic", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
old_pic = self.user.note.display_image
with open("apps/member/static/member/img/default_picture.png", "rb") as f:
image = SimpleUploadedFile("image.png", f.read(), "image/png")
response = self.client.post(reverse("member:user_update_pic", args=(self.user.pk,)), dict(
image=image,
x=0,
y=0,
width=200,
height=200,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.user.note.refresh_from_db()
self.assertTrue(os.path.exists(self.user.note.display_image.path))
os.remove(self.user.note.display_image.path)
self.user.note.display_image = old_pic
self.user.note.save()
def test_render_user_aliases(self):
"""
Display the list of aliases of the user.
"""
# Alias creation and deletion is already tested in the note app
response = self.client.get(reverse("member:user_alias", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
def test_manage_auth_token(self):
"""
Display the page to see the API authentication token, see it and regenerate it.
:return:
"""
response = self.client.get(reverse("member:auth_token"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:auth_token") + "?view")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("member:auth_token") + "?regenerate")
self.assertRedirects(response, reverse("member:auth_token") + "?view", 302, 200)
def test_random_coverage(self):
# Useless, only for coverage
self.assertEqual(str(self.user), str(self.user.profile))
self.user.profile.promotion = None
self.assertEqual(self.user.profile.ens_year, 0)
self.membership.date_end = None
self.assertTrue(self.membership.valid)
def test_nk15_hasher(self):
"""
Test that NK15 passwords are successfully imported.
"""
salt = "42"
password = "strongpassword42"
hashed = hashlib.sha256((salt + password).encode("utf-8")).hexdigest()
self.user.password = "custom_nk15$1$" + salt + "|" + hashed
self.user.save()
self.assertTrue(self.user.check_password(password))

View File

@ -97,8 +97,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
note = NoteUser.objects.filter( note = NoteUser.objects.filter(
alias__normalized_name=Alias.normalize(new_username)) alias__normalized_name=Alias.normalize(new_username))
if note.exists() and note.get().user != self.object: if note.exists() and note.get().user != self.object:
form.add_error('username', form.add_error('username', _("An alias with a similar name already exists."))
_("An alias with a similar name already exists."))
return super().form_invalid(form) return super().form_invalid(form)
# Check if the username is one of user's aliases. # Check if the username is one of user's aliases.
alias = Alias.objects.filter(name=new_username) alias = Alias.objects.filter(name=new_username)
@ -141,10 +140,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
We can't display information of a not registered user. We can't display information of a not registered user.
""" """
qs = super().get_queryset() return super().get_queryset().filter(profile__registration_valid=True)
if self.request.user.is_superuser and self.request.session.get("permission_mask", -1) >= 42:
return qs
return qs.filter(profile__registration_valid=True)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" """
@ -204,14 +200,16 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
""" """
Filter the user list with the given pattern. Filter the user list with the given pattern.
""" """
qs = super().get_queryset().distinct("username").annotate(alias=F("note__alias__name"))\ qs = super().get_queryset().annotate(alias=F("note__alias__name"))\
.annotate(normalized_alias=F("note__alias__normalized_name"))\ .annotate(normalized_alias=F("note__alias__normalized_name"))\
.filter(profile__registration_valid=True).order_by("username") .filter(profile__registration_valid=True)
if "search" in self.request.GET:
pattern = self.request.GET["search"]
if not pattern: # Sqlite doesn't support order by in subqueries
return qs.none() qs = qs.order_by("username").distinct("username")\
if settings.DATABASES[qs.db]["ENGINE"] == 'django.db.backends.postgresql' else qs.distinct()
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
qs = qs.filter( qs = qs.filter(
username__iregex="^" + pattern username__iregex="^" + pattern
@ -270,12 +268,7 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
form = self.get_form() form = self.get_form()
self.object = self.get_object() self.object = self.get_object()
if form.is_valid(): return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
return self.form_valid(form)
else:
print('is_invalid')
print(form)
return self.form_invalid(form)
def form_valid(self, form): def form_valid(self, form):
image_field = form.cleaned_data['image'] image_field = form.cleaned_data['image']
@ -320,8 +313,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists(): if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists():
Token.objects.get(user=self.request.user).delete() Token.objects.get(user=self.request.user).delete()
return redirect(reverse_lazy('member:auth_token') + "?show", return redirect(reverse_lazy('member:auth_token') + "?show")
permanent=True)
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
@ -351,8 +343,9 @@ class ClubCreateView(ProtectQuerysetMixin, ProtectedCreateView):
email="", email="",
) )
def form_valid(self, form): def get_success_url(self):
return super().form_valid(form) self.object.refresh_from_db()
return reverse_lazy("member:club_detail", kwargs={"pk": self.object.pk})
class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
@ -655,7 +648,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
fee += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid fee += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid
c = c.parent_club c = c.parent_club
if user.note.balance + credit_amount < fee and not Membership.objects.filter( if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter(
club__name="Kfet", club__name="Kfet",
user=user, user=user,
date_start__lte=date.today(), date_start__lte=date.today(),
@ -683,7 +676,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
if club.membership_end and form.instance.date_start > club.membership_end: if club.membership_end and form.instance.date_start > club.membership_end:
form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.") form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.")
.format(form.instance.club.membership_start)) .format(form.instance.club.membership_end))
return super().form_invalid(form) return super().form_invalid(form)
# Now, all is fine, the membership can be created. # Now, all is fine, the membership can be created.
@ -719,46 +712,38 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
transaction._force_save = True transaction._force_save = True
transaction.save() transaction.save()
# Parent club memberships are automatically renewed / created.
# For example, a Kfet membership creates a BDE membership if it does not exist.
form.instance._force_renew_parent = True form.instance._force_renew_parent = True
ret = super().form_valid(form) ret = super().form_valid(form)
if club.name == "BDE": member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() \
member_role = Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all() if club.name == "BDE" else Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all() \
elif club.name == "Kfet": if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
member_role = Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all()
else:
member_role = Role.objects.filter(name="Membre de club").all()
form.instance.roles.set(member_role) form.instance.roles.set(member_role)
form.instance._force_save = True form.instance._force_save = True
form.instance.save() form.instance.save()
# If Société générale pays, then we assume that this is the BDE membership, and we auto-renew the # If Société générale pays, then we assume that this is the BDE membership, and we auto-renew the
# Kfet membership. # Kfet membership.
if soge: if soge and club.name == "BDE":
# If not already done, create BDE and Kfet memberships
bde = Club.objects.get(name="BDE")
kfet = Club.objects.get(name="Kfet") kfet = Club.objects.get(name="Kfet")
fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
soge_clubs = [bde, kfet] # Get current membership, to get the end date
for club in soge_clubs: old_membership = Membership.objects.filter(
fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid club=kfet,
user=user,
# Get current membership, to get the end date ).order_by("-date_start")
old_membership = Membership.objects.filter(
club=club,
user=user,
).order_by("-date_start")
if old_membership.filter(date_start__gte=club.membership_start).exists():
# Membership is already renewed
continue
if not old_membership.filter(date_start__gte=kfet.membership_start).exists():
# If the membership is not already renewed
membership = Membership( membership = Membership(
club=club, club=kfet,
user=user, user=user,
fee=fee, fee=fee,
date_start=max(old_membership.first().date_end + timedelta(days=1), club.membership_start) date_start=max(old_membership.first().date_end + timedelta(days=1), kfet.membership_start)
if old_membership.exists() else form.instance.date_start, if old_membership.exists() else form.instance.date_start,
) )
membership._force_save = True membership._force_save = True
@ -767,10 +752,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db() membership.refresh_from_db()
if old_membership.exists(): if old_membership.exists():
membership.roles.set(old_membership.get().roles.all()) membership.roles.set(old_membership.get().roles.all())
elif c.name == "BDE": membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
membership.roles.set(Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
elif c.name == "Kfet":
membership.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
membership.save() membership.save()
return ret return ret
@ -830,9 +812,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
qs = qs.filter(date_start__lte=timezone.now().today(), date_end__gte=timezone.now().today()) qs = qs.filter(date_start__lte=timezone.now().today(), date_end__gte=timezone.now().today())
if "roles" in self.request.GET: if "roles" in self.request.GET:
if not self.request.GET["roles"]: roles_str = self.request.GET["roles"].replace(' ', '').split(',') if self.request.GET["roles"] else ['0']
return qs.none()
roles_str = self.request.GET["roles"].replace(' ', '').split(',')
roles_int = map(int, roles_str) roles_int = map(int, roles_str)
qs = qs.filter(roles__in=roles_int) qs = qs.filter(roles__in=roles_int)

View File

@ -1,6 +1,6 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -117,6 +117,9 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
""" """
queryset = super().get_queryset() queryset = super().get_queryset()
# Sqlite doesn't support ORDER BY in subqueries
queryset = queryset.order_by("name") \
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
alias = self.request.query_params.get("alias", ".*") alias = self.request.query_params.get("alias", ".*")
queryset = queryset.prefetch_related('note') queryset = queryset.prefetch_related('note')
@ -137,7 +140,10 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
), ),
all=True) all=True)
return queryset.order_by('name').distinct() queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("name")
return queryset.distinct()
class TemplateCategoryViewSet(ReadProtectedModelViewSet): class TemplateCategoryViewSet(ReadProtectedModelViewSet):

View File

@ -356,4 +356,4 @@ class MembershipTransaction(Transaction):
@property @property
def type(self): def type(self):
return _('membership transaction') return _('membership').capitalize()

View File

@ -29,6 +29,7 @@ class HistoryTable(tables.Table):
source = tables.Column( source = tables.Column(
attrs={ attrs={
"td": { "td": {
"class": "text-nowrap",
"data-toggle": "tooltip", "data-toggle": "tooltip",
"title": lambda record: _("used alias").capitalize() + " : " + record.source_alias, "title": lambda record: _("used alias").capitalize() + " : " + record.source_alias,
} }
@ -38,15 +39,46 @@ class HistoryTable(tables.Table):
destination = tables.Column( destination = tables.Column(
attrs={ attrs={
"td": { "td": {
"class": "text-nowrap",
"data-toggle": "tooltip", "data-toggle": "tooltip",
"title": lambda record: _("used alias").capitalize() + " : " + record.destination_alias, "title": lambda record: _("used alias").capitalize() + " : " + record.destination_alias,
} }
} }
) )
created_at = tables.DateTimeColumn(format='Y-m-d H:i:s',
attrs={
"td": {
"class": "text-nowrap",
},
}
)
amount = tables.Column(
attrs={
"td": {
"class": "text-nowrap",
},
}
)
reason = tables.Column(
attrs={
"td": {
"class": "text-break",
},
}
)
type = tables.Column() type = tables.Column()
total = tables.Column() # will use Transaction.total() !! total = tables.Column( # will use Transaction.total() !!
attrs={
"td": {
"class": "text-nowrap",
},
}
)
valid = tables.Column( valid = tables.Column(
attrs={ attrs={

View File

@ -15,7 +15,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="col"> <div class="col">
<div class="card bg-light border-success mb-4 text-center"> <div class="card bg-light border-success mb-4 text-center">
<a id="profile_pic_link" href="#"> <a id="profile_pic_link" href="#">
<img src="/media/pic/default.png" <img src="{% static "member/img/default_picture.png" %}"
id="profile_pic" alt="" class="card-img-top"> id="profile_pic" alt="" class="card-img-top">
</a> </a>
<div class="card-body text-center text-break"> <div class="card-body text-center text-break">

View File

@ -38,7 +38,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# Preview note profile (picture, username and balance) #} {# Preview note profile (picture, username and balance) #}
<div class="col-md-3" id="note_infos_div"> <div class="col-md-3" id="note_infos_div">
<div class="card bg-light border-success shadow mb-4"> <div class="card bg-light border-success shadow mb-4">
<a id="profile_pic_link" href="#"><img src="/media/pic/default.png" <a id="profile_pic_link" href="#"><img src="{% static "member/img/default_picture.png" %}"
id="profile_pic" alt="" class="img-fluid rounded mx-auto"></a> id="profile_pic" alt="" class="img-fluid rounded mx-auto"></a>
<div class="card-body text-center"> <div class="card-body text-center">
<span id="user_note"></span> <span id="user_note"></span>

View File

@ -2567,6 +2567,70 @@
"description": "(Dé)bloquer sa propre note et modifier la raison" "description": "(Dé)bloquer sa propre note et modifier la raison"
} }
}, },
{
"model": "permission.permission",
"pk": 165,
"fields": {
"model": [
"auth",
"user"
],
"query": "{}",
"type": "change",
"mask": 1,
"field": "password",
"permanent": true,
"description": "Changer son mot de passe"
}
},
{
"model": "permission.permission",
"pk": 166,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}}, {\"valid\": false}]",
"type": "add",
"mask": 2,
"field": "",
"permanent": false,
"description": "Créer une transaction quelconque tant que la source reste au-dessus de -50 €"
}
},
{
"model": "permission.permission",
"pk": 167,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": true}, {\"destination__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": false}]",
"type": "change",
"mask": 2,
"field": "valid",
"permanent": false,
"description": "Modifier le statut de validation d'une transaction si c'est possible"
}
},
{
"model": "permission.permission",
"pk": 168,
"fields": {
"model": [
"note",
"transaction"
],
"query": "[\"OR\", {\"source__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": true}, {\"destination__balance__gte\": {\"F\": [\"SUB\", [\"MUL\", [\"F\", \"amount\"], [\"F\", \"quantity\"]], 5000]}, \"valid\": false}]",
"type": "change",
"mask": 2,
"field": "invalidity_reason",
"permanent": false,
"description": "Modifier la raison d'invalidité d'une transaction si c'est possible"
}
},
{ {
"model": "permission.role", "model": "permission.role",
"pk": 1, "pk": 1,
@ -2591,7 +2655,8 @@
52, 52,
126, 126,
161, 161,
162 162,
165
] ]
} }
}, },
@ -2697,7 +2762,11 @@
127, 127,
133, 133,
141, 141,
142 142,
150,
166,
167,
168
] ]
} }
}, },
@ -2711,8 +2780,7 @@
24, 24,
25, 25,
26, 26,
27, 27
33
] ]
} }
}, },
@ -2932,7 +3000,8 @@
161, 161,
162, 162,
163, 163,
164 164,
165
] ]
} }
}, },
@ -2944,7 +3013,6 @@
"name": "GC Kfet", "name": "GC Kfet",
"permissions": [ "permissions": [
32, 32,
33,
56, 56,
58, 58,
55, 55,
@ -2959,7 +3027,10 @@
29, 29,
30, 30,
31, 31,
143 143,
166,
167,
168
] ]
} }
}, },

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date from datetime import date
from json.decoder import JSONDecodeError
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
@ -56,29 +57,29 @@ class PermissionQueryTestCase(TestCase):
We use a random user with a random WEIClub (to use permissions for the WEI) in a random team in a random bus. We use a random user with a random WEIClub (to use permissions for the WEI) in a random team in a random bus.
""" """
for perm in Permission.objects.all(): for perm in Permission.objects.all():
instanced = perm.about(
user=User.objects.get(),
club=WEIClub.objects.get(),
membership=Membership.objects.get(),
User=User,
Club=Club,
Membership=Membership,
Note=Note,
NoteUser=NoteUser,
NoteClub=NoteClub,
NoteSpecial=NoteSpecial,
F=F,
Q=Q,
now=timezone.now(),
today=date.today(),
)
try: try:
instanced = perm.about(
user=User.objects.get(),
club=WEIClub.objects.get(),
membership=Membership.objects.get(),
User=User,
Club=Club,
Membership=Membership,
Note=Note,
NoteUser=NoteUser,
NoteClub=NoteClub,
NoteSpecial=NoteSpecial,
F=F,
Q=Q,
now=timezone.now(),
today=date.today(),
)
instanced.update_query() instanced.update_query()
query = instanced.query query = instanced.query
model = perm.model.model_class() model = perm.model.model_class()
model.objects.filter(query).all() model.objects.filter(query).all()
# print("Good query for permission", perm) # print("Good query for permission", perm)
except (FieldError, AttributeError, ValueError, TypeError): except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError):
print("Query error for permission", perm) print("Query error for permission", perm)
print("Query:", perm.query) print("Query:", perm.query)
if instanced.query: if instanced.query:

View File

View File

@ -0,0 +1,386 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from member.models import Club, Membership
from note.models import NoteUser, NoteSpecial, Transaction
from registration.tokens import email_validation_token
from treasury.models import SogeCredit
"""
Check that pre-registrations and validations are working as well.
"""
class TestSignup(TestCase):
"""
Assume we are a new user.
Check that it can pre-register without any problem.
"""
fixtures = ("initial", )
def test_signup(self):
"""
A first year member signs up and validates its email address.
"""
response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
# Signup
response = self.client.post(reverse("registration:signup"), dict(
first_name="Toto",
last_name="TOTO",
username="toto",
email="toto@example.com",
password1="toto1234",
password2="toto1234",
phone_number="+33123456789",
department="EXT",
promotion=Club.objects.get(name="BDE").membership_start.year,
address="Earth",
paid=False,
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
))
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
self.assertTrue(User.objects.filter(username="toto").exists())
user = User.objects.get(username="toto")
# A preregistred user has no note
self.assertFalse(NoteUser.objects.filter(user=user).exists())
self.assertFalse(user.profile.registration_valid)
self.assertFalse(user.profile.email_confirmed)
self.assertFalse(user.is_active)
response = self.client.get(reverse("registration:email_validation_sent"))
self.assertEqual(response.status_code, 200)
# Check that the email validation link is valid
token = email_validation_token.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=uid, token=token)))
self.assertEqual(response.status_code, 200)
user.profile.refresh_from_db()
self.assertTrue(user.profile.email_confirmed)
# Token has expired
response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=uid, token=token)))
self.assertEqual(response.status_code, 400)
# Uid does not exist
response = self.client.get(reverse("registration:email_validation", kwargs=dict(uidb64=0, token="toto")))
self.assertEqual(response.status_code, 400)
def test_invalid_signup(self):
"""
Send wrong data and check that it is not valid
"""
User.objects.create_superuser(
first_name="Toto",
last_name="TOTO",
username="toto",
email="toto@example.com",
password="toto1234",
)
# The email is already used
response = self.client.post(reverse("registration:signup"), dict(
first_name="Toto",
last_name="TOTO",
username="tôtö",
email="toto@example.com",
password1="toto1234",
password2="toto1234",
phone_number="+33123456789",
department="EXT",
promotion=Club.objects.get(name="BDE").membership_start.year,
address="Earth",
paid=False,
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
))
self.assertTrue(response.status_code, 200)
# The username is similar to a known alias
response = self.client.post(reverse("registration:signup"), dict(
first_name="Toto",
last_name="TOTO",
username="tôtö",
email="othertoto@example.com",
password1="toto1234",
password2="toto1234",
phone_number="+33123456789",
department="EXT",
promotion=Club.objects.get(name="BDE").membership_start.year,
address="Earth",
paid=False,
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
))
self.assertTrue(response.status_code, 200)
# The phone number is invalid
response = self.client.post(reverse("registration:signup"), dict(
first_name="Toto",
last_name="TOTO",
username="Ihaveanotherusername",
email="othertoto@example.com",
password1="toto1234",
password2="toto1234",
phone_number="invalid phone number",
department="EXT",
promotion=Club.objects.get(name="BDE").membership_start.year,
address="Earth",
paid=False,
ml_events_registration="en",
ml_sport_registration=True,
ml_art_registration=True,
))
self.assertTrue(response.status_code, 200)
class TestValidateRegistration(TestCase):
"""
Test the admin interface to validate users
"""
fixtures = ('initial',)
def setUp(self) -> None:
self.superuser = User.objects.create_superuser(
username="admintoto",
password="toto1234",
email="admin.toto@example.com",
)
self.client.force_login(self.superuser)
self.user = User.objects.create(
username="toto",
first_name="Toto",
last_name="TOTO",
email="toto@example.com",
)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
def test_future_user_list(self):
"""
Display the list of pre-registered users
"""
response = self.client.get(reverse("registration:future_user_list"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("registration:future_user_list") + "?search=toto")
self.assertEqual(response.status_code, 200)
def test_invalid_registrations(self):
"""
Send wrong data and check that errors are detected
"""
# BDE Membership is mandatory
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=4200,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
join_BDE=False,
join_Kfet=False,
))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].errors)
# Same
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type="",
credit_amount=0,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
join_BDE=False,
join_Kfet=True,
))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].errors)
# The BDE membership is not free
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=0,
last_name="TOTO",
first_name="Toto",
bank="J'ai pas d'argent",
join_BDE=True,
join_Kfet=True,
))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].errors)
# Last and first names are required for a credit
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=4000,
last_name="",
first_name="",
bank="",
join_BDE=True,
join_Kfet=True,
))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].errors)
# The username admïntoto is too similar with the alias admintoto.
# Since the form is valid, the user must update its username.
self.user.username = "admïntoto"
self.user.save()
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=500,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
join_BDE=True,
join_Kfet=False,
))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["form"].errors)
def test_validate_bde_registration(self):
"""
The user wants only to join the BDE. We validate the registration.
"""
response = self.client.get(reverse("registration:future_user_detail", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 404)
self.user.profile.email_confirmed = True
self.user.profile.save()
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Chèque").id,
credit_amount=500,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
join_BDE=True,
join_Kfet=False,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.user.profile.refresh_from_db()
self.assertTrue(self.user.profile.registration_valid)
self.assertTrue(NoteUser.objects.filter(user=self.user).exists())
self.assertTrue(Membership.objects.filter(club__name="BDE", user=self.user).exists())
self.assertFalse(Membership.objects.filter(club__name="Kfet", user=self.user).exists())
self.assertFalse(SogeCredit.objects.filter(user=self.user).exists())
self.assertEqual(Transaction.objects.filter(
Q(source=self.user.note) | Q(destination=self.user.note)).count(), 2)
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_validate_kfet_registration(self):
"""
The user joins the BDE and the Kfet.
"""
response = self.client.get(reverse("registration:future_user_detail", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 404)
self.user.profile.email_confirmed = True
self.user.profile.save()
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=False,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=4000,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
join_BDE=True,
join_Kfet=True,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.user.profile.refresh_from_db()
self.assertTrue(self.user.profile.registration_valid)
self.assertTrue(NoteUser.objects.filter(user=self.user).exists())
self.assertTrue(Membership.objects.filter(club__name="BDE", user=self.user).exists())
self.assertTrue(Membership.objects.filter(club__name="Kfet", user=self.user).exists())
self.assertFalse(SogeCredit.objects.filter(user=self.user).exists())
self.assertEqual(Transaction.objects.filter(
Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_validate_kfet_registration_with_soge(self):
"""
The user joins the BDE and the Kfet, but the membership is paid by the Société générale.
"""
response = self.client.get(reverse("registration:future_user_detail", args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 404)
self.user.profile.email_confirmed = True
self.user.profile.save()
response = self.client.post(reverse("registration:future_user_detail", args=(self.user.pk,)), data=dict(
soge=True,
credit_type=NoteSpecial.objects.get(special_type="Espèces").id,
credit_amount=4000,
last_name="TOTO",
first_name="Toto",
bank="Société générale",
join_BDE=True,
join_Kfet=True,
))
self.assertRedirects(response, self.user.profile.get_absolute_url(), 302, 200)
self.user.profile.refresh_from_db()
self.assertTrue(self.user.profile.registration_valid)
self.assertTrue(NoteUser.objects.filter(user=self.user).exists())
self.assertTrue(Membership.objects.filter(club__name="BDE", user=self.user).exists())
self.assertTrue(Membership.objects.filter(club__name="Kfet", user=self.user).exists())
self.assertTrue(SogeCredit.objects.filter(user=self.user).exists())
self.assertEqual(Transaction.objects.filter(
Q(source=self.user.note) | Q(destination=self.user.note)).count(), 2)
self.assertFalse(Transaction.objects.filter(valid=True).exists())
response = self.client.get(self.user.profile.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_invalidate_registration(self):
"""
Try to invalidate (= delete) pre-registration.
"""
response = self.client.get(reverse("registration:future_user_invalidate", args=(self.user.pk,)))
self.assertRedirects(response, reverse("registration:future_user_list"), 302, 200)
self.assertFalse(User.objects.filter(pk=self.user.pk).exists())
def test_resend_email_validation_link(self):
"""
Resend email validation linK.
"""
response = self.client.get(reverse("registration:email_validation_resend", args=(self.user.pk,)))
self.assertRedirects(response, reverse("registration:future_user_detail", args=(self.user.pk,)), 302, 200)

View File

@ -16,7 +16,7 @@ from django.views.generic.edit import FormMixin
from django_tables2 import SingleTableView from django_tables2 import SingleTableView
from member.forms import ProfileForm from member.forms import ProfileForm
from member.models import Membership, Club from member.models import Membership, Club
from note.models import SpecialTransaction from note.models import SpecialTransaction, Alias
from note.templatetags.pretty_money import pretty_money from note.templatetags.pretty_money import pretty_money
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.models import Role from permission.models import Role
@ -101,7 +101,7 @@ class UserValidateView(TemplateView):
user.profile.email_confirmed = True user.profile.email_confirmed = True
user.save() user.save()
user.profile.save() user.profile.save()
return self.render_to_response(self.get_context_data()) return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400)
def get_user(self, uidb64): def get_user(self, uidb64):
""" """
@ -169,12 +169,9 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
:return: :return:
""" """
qs = super().get_queryset().distinct().filter(profile__registration_valid=False) qs = super().get_queryset().distinct().filter(profile__registration_valid=False)
if "search" in self.request.GET: if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"] pattern = self.request.GET["search"]
if not pattern:
return qs.none()
qs = qs.filter( qs = qs.filter(
Q(first_name__iregex=pattern) Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern) | Q(last_name__iregex=pattern)
@ -205,10 +202,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
form = self.get_form() form = self.get_form()
self.object = self.get_object() self.object = self.get_object()
if form.is_valid(): return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
return self.form_valid(form)
else:
return self.form_invalid(form)
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
""" """
@ -239,6 +233,10 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
def form_valid(self, form): def form_valid(self, form):
user = self.get_object() user = self.get_object()
if Alias.objects.filter(normalized_name=Alias.normalize(user.username)).exists():
form.add_error(None, _("An alias with a similar name already exists."))
return self.form_invalid(form)
# Get form data # Get form data
soge = form.cleaned_data["soge"] soge = form.cleaned_data["soge"]
credit_type = form.cleaned_data["credit_type"] credit_type = form.cleaned_data["credit_type"]
@ -276,9 +274,6 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
if credit_type is None: if credit_type is None:
credit_amount = 0 credit_amount = 0
if join_Kfet and not join_BDE:
form.add_error('join_Kfet', _("You must join BDE club before joining Kfet club."))
if fee > credit_amount and not soge: if fee > credit_amount and not soge:
# Check if the user credits enough money # Check if the user credits enough money
form.add_error('credit_type', form.add_error('credit_type',

@ -1 +1 @@
Subproject commit c1c0a8797179d110ad919912378f05b030f44f61 Subproject commit 4e1bcd1808a24b532aa27bf2a119f6f8155af534

View File

@ -24,9 +24,7 @@ class RemittanceAdmin(admin.ModelAdmin):
list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', ) list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', )
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
if not obj: return not obj or (not obj.closed and super().has_change_permission(request, obj))
return True
return not obj.closed and super().has_change_permission(request, obj)
@admin.register(SogeCredit, site=admin_site) @admin.register(SogeCredit, site=admin_site)

View File

@ -16,7 +16,7 @@ class InvoiceViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Invoice` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Invoice` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/invoice/ then render it on /api/treasury/invoice/
""" """
queryset = Invoice.objects.all() queryset = Invoice.objects.order_by("id").all()
serializer_class = InvoiceSerializer serializer_class = InvoiceSerializer
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
filterset_fields = ['bde', ] filterset_fields = ['bde', ]
@ -28,7 +28,7 @@ class ProductViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Product` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Product` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/product/ then render it on /api/treasury/product/
""" """
queryset = Product.objects.all() queryset = Product.objects.order_by("invoice_id", "id").all()
serializer_class = ProductSerializer serializer_class = ProductSerializer
filter_backends = [SearchFilter] filter_backends = [SearchFilter]
search_fields = ['$designation', ] search_fields = ['$designation', ]
@ -40,7 +40,7 @@ class RemittanceTypeViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `RemittanceType` objects, serialize it to JSON with the given serializer The djangorestframework plugin will get all `RemittanceType` objects, serialize it to JSON with the given serializer
then render it on /api/treasury/remittance_type/ then render it on /api/treasury/remittance_type/
""" """
queryset = RemittanceType.objects queryset = RemittanceType.objects.order_by("id")
serializer_class = RemittanceTypeSerializer serializer_class = RemittanceTypeSerializer
@ -50,7 +50,7 @@ class RemittanceViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `Remittance` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `Remittance` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/remittance/ then render it on /api/treasury/remittance/
""" """
queryset = Remittance.objects queryset = Remittance.objects.order_by("id")
serializer_class = RemittanceSerializer serializer_class = RemittanceSerializer
@ -60,5 +60,5 @@ class SogeCreditViewSet(ReadProtectedModelViewSet):
The djangorestframework plugin will get all `SogeCredit` objects, serialize it to JSON with the given serializer, The djangorestframework plugin will get all `SogeCredit` objects, serialize it to JSON with the given serializer,
then render it on /api/treasury/soge_credit/ then render it on /api/treasury/soge_credit/
""" """
queryset = SogeCredit.objects queryset = SogeCredit.objects.order_by("id")
serializer_class = SogeCreditSerializer serializer_class = SogeCreditSerializer

View File

@ -16,21 +16,15 @@ class InvoiceForm(forms.ModelForm):
""" """
def clean(self): def clean(self):
# If the invoice is locked, it can't be updated.
if self.instance and self.instance.locked: if self.instance and self.instance.locked:
for field_name in self.fields: for field_name in self.fields:
self.cleaned_data[field_name] = getattr(self.instance, field_name) self.cleaned_data[field_name] = getattr(self.instance, field_name)
self.errors.clear() self.errors.clear()
self.add_error(None, _('This invoice is locked and can no longer be edited.'))
return self.cleaned_data return self.cleaned_data
return super().clean() return super().clean()
def save(self, commit=True):
"""
If the invoice is locked, don't save it
"""
if not self.instance.locked:
super().save(commit)
return self.instance
class Meta: class Meta:
model = Invoice model = Invoice
exclude = ('bde', 'date', 'tex', ) exclude = ('bde', 'date', 'tex', )

View File

@ -85,7 +85,7 @@ class Invoice(models.Model):
old_invoice = Invoice.objects.filter(id=self.id) old_invoice = Invoice.objects.filter(id=self.id)
if old_invoice.exists(): if old_invoice.exists():
if old_invoice.get().locked: if old_invoice.get().locked and not self._force_save:
raise ValidationError(_("This invoice is locked and can no longer be edited.")) raise ValidationError(_("This invoice is locked and can no longer be edited."))
products = self.products.all() products = self.products.all()
@ -224,7 +224,7 @@ class Remittance(models.Model):
def save(self, force_insert=False, force_update=False, using=None, update_fields=None): def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
# Check if all transactions have the right type. # Check if all transactions have the right type.
if self.transactions.filter(~Q(source=self.remittance_type.note)).exists(): if self.transactions.exists() and self.transactions.filter(~Q(source=self.remittance_type.note)).exists():
raise ValidationError("All transactions in a remittance must have the same type") raise ValidationError("All transactions in a remittance must have the same type")
return super().save(force_insert, force_update, using, update_fields) return super().save(force_insert, force_update, using, update_fields)

View File

Before

Width:  |  Height:  |  Size: 752 KiB

After

Width:  |  Height:  |  Size: 752 KiB

View File

Before

Width:  |  Height:  |  Size: 664 KiB

After

Width:  |  Height:  |  Size: 664 KiB

View File

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 414 KiB

View File

Before

Width:  |  Height:  |  Size: 375 KiB

After

Width:  |  Height:  |  Size: 375 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View File

@ -34,7 +34,7 @@ class InvoiceTable(tables.Table):
delete = tables.LinkColumn( delete = tables.LinkColumn(
'treasury:invoice_delete', 'treasury:invoice_delete',
args=[A('pk')], args=[A('id')],
verbose_name=_("delete"), verbose_name=_("delete"),
text=_("Delete"), text=_("Delete"),
attrs={ attrs={

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons"> <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="#" class="btn btn-sm btn-outline-primary active"> <a href="#" class="btn btn-sm btn-outline-primary active">
{% trans "Invoice" %}s {% trans "Invoice" %}s
</a> </a>

View File

@ -58,7 +58,7 @@
\parbox[b][\paperheight]{\paperwidth}{% \parbox[b][\paperheight]{\paperwidth}{%
\vfill \vfill
\centering \centering
{\transparent{0.1}\includegraphics[width=\textwidth]{../../static/img/{{ obj.bde }}}}% {\transparent{0.1}\includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}}}%
\vfill \vfill
} }
} }

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons"> <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "treasury:invoice_list" %}" class="btn btn-sm btn-outline-primary"> <a href="{% url "treasury:invoice_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Invoice" %}s {% trans "Invoice" %}s
</a> </a>

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0" data-toggle="buttons"> <div class="btn-group btn-group-toggle" style="width: 100%; padding: 0 0 2em 0">
<a href="{% url "treasury:invoice_list" %}" class="btn btn-sm btn-outline-primary"> <a href="{% url "treasury:invoice_list" %}" class="btn btn-sm btn-outline-primary">
{% trans "Invoice" %}s {% trans "Invoice" %}s
</a> </a>
@ -59,9 +59,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
function reloadTable() { function reloadTable() {
let pattern = searchbar_obj.val(); let pattern = searchbar_obj.val();
if (pattern === old_pattern || pattern === "")
return;
$("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + ( $("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
invalid_only_obj.is(':checked') ? "&valid=false" : "") + " #credits_table"); invalid_only_obj.is(':checked') ? "&valid=false" : "") + " #credits_table");

View File

View File

@ -0,0 +1,403 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from member.models import Membership, Club
from note.models import SpecialTransaction, NoteSpecial, Transaction
from treasury.models import Invoice, Product, Remittance, RemittanceType, SogeCredit
class TestInvoices(TestCase):
"""
Check that invoices can be created and rendered properly.
"""
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="admintoto",
password="totototo",
email="admin@example.com",
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.invoice = Invoice.objects.create(
id=1,
object="Object",
description="Description",
name="Me",
address="Earth",
acquitted=False,
)
self.product = Product.objects.create(
invoice=self.invoice,
designation="Product",
quantity=3,
amount=3.14,
)
def test_admin_page(self):
"""
Display the invoice admin page.
"""
response = self.client.get(reverse("admin:index") + "treasury/invoice/")
self.assertEqual(response.status_code, 200)
def test_invoices_list(self):
"""
Display the list of invoices.
"""
response = self.client.get(reverse("treasury:invoice_list"))
self.assertEqual(response.status_code, 200)
def test_invoice_create(self):
"""
Try to create a new invoice.
"""
response = self.client.get(reverse("treasury:invoice_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("treasury:invoice_create"), data={
"id": 42,
"object": "Same object",
"description": "Longer description",
"name": "Me and others",
"address": "Alwways earth",
"acquitted": True,
"products-0-designation": "Designation",
"products-0-quantity": 1,
"products-0-amount": 42,
"products-TOTAL_FORMS": 1,
"products-INITIAL_FORMS": 0,
"products-MIN_NUM_FORMS": 0,
"products-MAX_NUM_FORMS": 1000,
})
self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200)
self.assertTrue(Invoice.objects.filter(object="Same object", id=42).exists())
self.assertTrue(Product.objects.filter(designation="Designation", invoice_id=42).exists())
self.assertTrue(Invoice.objects.get(id=42).tex)
def test_invoice_update(self):
"""
Try to update an invoice.
"""
response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,)))
self.assertEqual(response.status_code, 200)
data = {
"object": "Same object",
"description": "Longer description",
"name": "Me and others",
"address": "Always earth",
"acquitted": True,
"locked": True,
"products-0-designation": "Designation",
"products-0-quantity": 1,
"products-0-amount": 4200,
"products-1-designation": "Second designation",
"products-1-quantity": 5,
"products-1-amount": -1800,
"products-TOTAL_FORMS": 2,
"products-INITIAL_FORMS": 0,
"products-MIN_NUM_FORMS": 0,
"products-MAX_NUM_FORMS": 1000,
}
response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data)
self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200)
self.invoice.refresh_from_db()
self.assertTrue(Invoice.objects.filter(pk=1, object="Same object", locked=True).exists())
self.assertTrue(Product.objects.filter(designation="Second designation", invoice_id=1).exists())
# Resend the same data, but the invoice is locked.
response = self.client.get(reverse("treasury:invoice_update", args=(self.invoice.id,)))
self.assertTrue(response.status_code, 200)
response = self.client.post(reverse("treasury:invoice_update", args=(self.invoice.id,)), data=data)
self.assertTrue(response.status_code, 200)
def test_delete_invoice(self):
"""
Try to delete an invoice.
"""
response = self.client.get(reverse("treasury:invoice_delete", args=(self.invoice.id,)))
self.assertEqual(response.status_code, 200)
# Can't delete a locked invoice
self.invoice.locked = True
self.invoice.save()
response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,)))
self.assertEqual(response.status_code, 403)
self.assertTrue(Invoice.objects.filter(pk=self.invoice.id).exists())
# Unlock invoice and truly delete it.
self.invoice.locked = False
self.invoice._force_save = True
self.invoice.save()
response = self.client.delete(reverse("treasury:invoice_delete", args=(self.invoice.id,)))
self.assertRedirects(response, reverse("treasury:invoice_list"), 302, 200)
self.assertFalse(Invoice.objects.filter(pk=self.invoice.id).exists())
def test_invoice_render_pdf(self):
"""
Generate the PDF file of an invoice.
"""
response = self.client.get(reverse("treasury:invoice_render", args=(self.invoice.id,)))
self.assertEqual(response.status_code, 200)
def test_invoice_api(self):
"""
Load some API pages
"""
response = self.client.get("/api/treasury/invoice/")
self.assertEqual(response.status_code, 200)
response = self.client.get("/api/treasury/product/")
self.assertEqual(response.status_code, 200)
class TestRemittances(TestCase):
"""
Create some credits and close remittances.
"""
fixtures = ('initial',)
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="admintoto",
password="totototo",
email="admin@example.com",
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.credit = SpecialTransaction.objects.create(
source=NoteSpecial.objects.get(special_type="Chèque"),
destination=self.user.note,
amount=4200,
reason="Credit",
last_name="TOTO",
first_name="Toto",
bank="Société générale",
)
self.second_credit = SpecialTransaction.objects.create(
source=self.user.note,
destination=NoteSpecial.objects.get(special_type="Chèque"),
amount=424200,
reason="Second credit",
last_name="TOTO",
first_name="Toto",
bank="Société générale",
)
self.remittance = Remittance.objects.create(
remittance_type=RemittanceType.objects.get(),
comment="Test remittance",
closed=False,
)
self.credit.specialtransactionproxy.remittance = self.remittance
self.credit.specialtransactionproxy.save()
def test_admin_page(self):
"""
Load the admin page.
"""
response = self.client.get(reverse("admin:index") + "treasury/remittance/")
self.assertEqual(response.status_code, 200)
def test_remittances_list(self):
"""
Display the remittance list.
:return:
"""
response = self.client.get(reverse("treasury:remittance_list"))
self.assertEqual(response.status_code, 200)
def test_remittance_create(self):
"""
Create a new Remittance.
"""
response = self.client.get(reverse("treasury:remittance_create"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("treasury:remittance_create"), data=dict(
remittance_type=RemittanceType.objects.get().pk,
comment="Created remittance",
))
self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
self.assertTrue(Remittance.objects.filter(comment="Created remittance").exists())
def test_remittance_update(self):
"""
Update an existing remittance.
"""
response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict(
comment="Updated remittance",
))
self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
self.assertTrue(Remittance.objects.filter(comment="Updated remittance").exists())
def test_remittance_close(self):
"""
Try to close an open remittance.
"""
response = self.client.get(reverse("treasury:remittance_update", args=(self.remittance.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("treasury:remittance_update", args=(self.remittance.pk,)), data=dict(
comment="Closed remittance",
close=True,
))
self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
self.assertTrue(Remittance.objects.filter(comment="Closed remittance", closed=True).exists())
def test_remittance_link_transaction(self):
"""
Link a transaction to an open remittance.
"""
response = self.client.get(reverse("treasury:link_transaction", args=(self.credit.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("treasury:link_transaction", args=(self.credit.pk,)), data=dict(
remittance=self.remittance.pk,
last_name="Last Name",
first_name="First Name",
bank="Bank",
))
self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
self.credit.refresh_from_db()
self.assertEqual(self.credit.last_name, "Last Name")
self.assertEqual(self.remittance.transactions.count(), 1)
response = self.client.get(reverse("treasury:unlink_transaction", args=(self.credit.pk,)))
self.assertRedirects(response, reverse("treasury:remittance_list"), 302, 200)
def test_invoice_api(self):
"""
Load some API pages
"""
response = self.client.get("/api/treasury/remittance_type/")
self.assertEqual(response.status_code, 200)
response = self.client.get("/api/treasury/remittance/")
self.assertEqual(response.status_code, 200)
class TestSogeCredits(TestCase):
"""
Check that credits from the Société générale are working correctly.
"""
fixtures = ('initial',)
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="admintoto",
password="totototo",
email="admin@example.com",
)
self.client.force_login(self.user)
sess = self.client.session
sess["permission_mask"] = 42
sess.save()
self.kfet = Club.objects.get(name="Kfet")
self.bde = self.kfet.parent_club
self.kfet_membership = Membership(
user=self.user,
club=self.kfet,
)
self.kfet_membership._force_renew_parent = True
self.kfet_membership._soge = True
self.kfet_membership.save()
def test_admin_page(self):
"""
Render the admin page.
"""
response = self.client.get(reverse("admin:index") + "treasury/sogecredit/")
self.assertEqual(response.status_code, 200)
def test_sogecredit_list(self):
"""
Display the list of all credits.
"""
response = self.client.get(reverse("treasury:soge_credits"))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("treasury:soge_credits") + "?search=toto&valid=")
self.assertEqual(response.status_code, 200)
def test_validate_soge_credit(self):
"""
Try to validate a credit.
"""
soge_credit = SogeCredit.objects.get(user=self.user)
response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(
validate=True,
))
self.assertRedirects(response, reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), 302, 200)
soge_credit.refresh_from_db()
self.assertTrue(soge_credit.valid)
self.user.note.refresh_from_db()
self.assertEqual(self.user.note.balance, 0)
self.assertEqual(
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
self.assertTrue(self.user.profile.soge)
def test_delete_soge_credit(self):
"""
Try to invalidate a credit.
"""
soge_credit = SogeCredit.objects.get(user=self.user)
response = self.client.get(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)))
self.assertEqual(response.status_code, 200)
try:
self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)), data=dict(delete=True))
raise AssertionError("It is not possible to delete the soge credit until the note is not credited.")
except ValidationError:
pass
SpecialTransaction.objects.create(
source=NoteSpecial.objects.get(special_type="Carte bancaire"),
destination=self.user.note,
amount=self.bde.membership_fee_paid + self.kfet.membership_fee_paid,
quantity=1,
reason="Registration is not complete, pliz pay",
last_name="TOTO",
first_name="Toto",
)
response = self.client.post(reverse("treasury:manage_soge_credit", args=(soge_credit.pk,)),
data=dict(delete=True))
# 403 because no SogeCredit exists anymore, then a PermissionDenied is raised
self.assertRedirects(response, reverse("treasury:soge_credits"), 302, 403)
self.assertFalse(SogeCredit.objects.filter(pk=soge_credit.pk))
self.user.note.refresh_from_db()
self.assertEqual(self.user.note.balance, 0)
self.assertEqual(
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
self.assertFalse(self.user.profile.soge)
def test_invoice_api(self):
"""
Load some API pages
"""
response = self.client.get("/api/treasury/soge_credit/")
self.assertEqual(response.status_code, 200)

View File

@ -60,6 +60,11 @@ class InvoiceCreateView(ProtectQuerysetMixin, ProtectedCreateView):
return context return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
del form.fields["locked"]
return form
def form_valid(self, form): def form_valid(self, form):
ret = super().form_valid(form) ret = super().form_valid(form)
@ -134,6 +139,11 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
return context return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
del form.fields["id"]
return form
def form_valid(self, form): def form_valid(self, form):
ret = super().form_valid(form) ret = super().form_valid(form)
@ -165,6 +175,11 @@ class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
model = Invoice model = Invoice
extra_context = {"title": _("Delete invoice")} extra_context = {"title": _("Delete invoice")}
def delete(self, request, *args, **kwargs):
if self.get_object().locked:
raise PermissionDenied(_("This invoice is locked and can't be deleted."))
return super().delete(request, *args, **kwargs)
def get_success_url(self): def get_success_url(self):
return reverse_lazy('treasury:invoice_list') return reverse_lazy('treasury:invoice_list')
@ -387,7 +402,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
if not request.user.is_authenticated: if not request.user.is_authenticated:
return self.handle_no_permission() return self.handle_no_permission()
if not self.get_queryset().exists(): if not super().get_queryset().exists():
raise PermissionDenied(_("You are not able to see the treasury interface.")) raise PermissionDenied(_("You are not able to see the treasury interface."))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -122,7 +122,7 @@ function displayStyle (note) {
*/ */
function displayNote (note, alias, user_note_field = null, profile_pic_field = null) { function displayNote (note, alias, user_note_field = null, profile_pic_field = null) {
if (!note.display_image) { if (!note.display_image) {
note.display_image = '/media/pic/default.png'; note.display_image = '/static/member/img/default_picture.png';
} }
let img = note.display_image; let img = note.display_image;
if (alias !== note.name && note.name) if (alias !== note.name && note.name)

View File

@ -27,7 +27,7 @@ $(document).ready(function() {
}); });
// Switching in double consumptions mode should update the layout // Switching in double consumptions mode should update the layout
$("#double_conso").click(function() { $("#double_conso").change(function() {
$("#consos_list_div").removeClass('d-none'); $("#consos_list_div").removeClass('d-none');
$("#user_select_div").attr('class', 'col-xl-4'); $("#user_select_div").attr('class', 'col-xl-4');
$("#infos_div").attr('class', 'col-sm-5 col-xl-6'); $("#infos_div").attr('class', 'col-sm-5 col-xl-6');
@ -47,7 +47,7 @@ $(document).ready(function() {
} }
}); });
$("#single_conso").click(function() { $("#single_conso").change(function() {
$("#consos_list_div").addClass('d-none'); $("#consos_list_div").addClass('d-none');
$("#user_select_div").attr('class', 'col-xl-7'); $("#user_select_div").attr('class', 'col-xl-7');
$("#infos_div").attr('class', 'col-sm-5 col-md-4'); $("#infos_div").attr('class', 'col-sm-5 col-md-4');
@ -158,7 +158,7 @@ function reset() {
$("#consos_list").html(""); $("#consos_list").html("");
$("#note").val(""); $("#note").val("");
$("#note").attr("data-original-title", "").tooltip("hide"); $("#note").attr("data-original-title", "").tooltip("hide");
$("#profile_pic").attr("src", "/media/pic/default.png"); $("#profile_pic").attr("src", "/static/member/img/default_picture.png");
$("#profile_pic_link").attr("href", "#"); $("#profile_pic_link").attr("href", "#");
refreshHistory(); refreshHistory();
refreshBalance(); refreshBalance();

View File

@ -0,0 +1,45 @@
/*
* Konami code support
*/
// Cursor denote the position in konami code
let cursor = 0
const KONAMI_CODE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]
function afterKonami() {
// Load Rythm.js
var rythmScript = document.createElement('script')
rythmScript.setAttribute('src','//unpkg.com/rythm.js@2.2.5/rythm.min.js')
document.head.appendChild(rythmScript)
rythmScript.addEventListener('load', function() {
// Ker-Lyon audio courtesy of @adalan, ker-lyon.fr
const audioElement = new Audio('/static/song/konami.ogg')
audioElement.loop = true
audioElement.play()
const rythm = new Rythm()
rythm.connectExternalAudioElement(audioElement)
rythm.addRythm('card', 'pulse', 50, 50, {
min: 1,
max: 1.1
})
rythm.addRythm('d-flex', 'color', 50, 50, {
from: [64,64,64],
to:[128,64,128]
})
rythm.addRythm('nav-link', 'jump', 150, 50, {
min: 0,
max: 10
})
rythm.start()
});
}
// Register custom event
document.addEventListener('keydown', (e) => {
cursor = (e.keyCode == KONAMI_CODE[cursor]) ? cursor + 1 : 0;
if (cursor == KONAMI_CODE.length) {
afterKonami()
}
});

View File

@ -40,7 +40,7 @@ function reset(refresh=true) {
$("#first_name").val(""); $("#first_name").val("");
$("#bank").val(""); $("#bank").val("");
$("#user_note").val(""); $("#user_note").val("");
$("#profile_pic").attr("src", "/media/pic/default.png"); $("#profile_pic").attr("src", "/static/member/img/default_picture.png");
$("#profile_pic_link").attr("href", "#"); $("#profile_pic_link").attr("href", "#");
if (refresh) { if (refresh) {
refreshBalance(); refreshBalance();
@ -96,7 +96,7 @@ $(document).ready(function() {
let source = $("#source_note"); let source = $("#source_note");
let dest = $("#dest_note"); let dest = $("#dest_note");
$("#type_transfer").click(function() { $("#type_transfer").change(function() {
if (LOCK) if (LOCK)
return; return;
@ -117,7 +117,7 @@ $(document).ready(function() {
location.hash = "transfer"; location.hash = "transfer";
}); });
$("#type_credit").click(function() { $("#type_credit").change(function() {
if (LOCK) if (LOCK)
return; return;
@ -146,7 +146,7 @@ $(document).ready(function() {
location.hash = "credit"; location.hash = "credit";
}); });
$("#type_debit").click(function() { $("#type_debit").change(function() {
if (LOCK) if (LOCK)
return; return;

Binary file not shown.

View File

@ -35,6 +35,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/turbolinks/5.2.0/turbolinks.js"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="{% static "js/base.js" %}"></script> <script src="{% static "js/base.js" %}"></script>
<script src="{% static "js/konami.js" %}"></script>
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} {# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #}
{% if form.media %} {% if form.media %}

View File

@ -1,7 +1,17 @@
django-htcpcp-tea==0.3.1 beautifulsoup4~=4.7.1
django-mailer==2.0.1 Django~=2.2.15
django-phonenumber-field==5.0.0 django-bootstrap-datepicker-plus~=3.0.5
django-tables2==2.3.1 django-cas-server>=0.9.0
django-rest-polymorphic==0.1.9 django-colorfield~=0.3.2
django-bootstrap-datepicker-plus==3.0.5 django-crispy-forms~=1.7.2
django-colorfield==0.3.2 django-extensions~=2.1.4
django-filter~=2.1.0
django-htcpcp-tea~=0.3.1
django-mailer~=2.0.1
django-phonenumber-field~=5.0.0
django-polymorphic~=2.0.3
djangorestframework~=3.9.0
django-rest-polymorphic~=0.1.9
django-tables2~=2.3.1
phonenumbers~=8.9.10
Pillow>=5.4.1

View File

@ -11,8 +11,6 @@ skipsdist = True
[testenv] [testenv]
sitepackages = True sitepackages = True
setenv =
PYTHONWARNINGS = all
deps = deps =
-r{toxinidir}/requirements.txt -r{toxinidir}/requirements.txt
coverage coverage
@ -23,7 +21,6 @@ commands =
[testenv:linters] [testenv:linters]
deps = deps =
-r{toxinidir}/requirements.txt
flake8 flake8
flake8-colors flake8-colors
flake8-import-order flake8-import-order
@ -34,8 +31,7 @@ commands =
flake8 apps/activity apps/api apps/logs apps/member apps/note apps/permission apps/treasury apps/wei flake8 apps/activity apps/api apps/logs apps/member apps/note apps/permission apps/treasury apps/wei
[flake8] [flake8]
# Ignore too many errors, should be reduced in the future ignore = W503, I100, I101
ignore = D203, W503, E203, I100, I101, C901
exclude = exclude =
.tox, .tox,
.git, .git,