1
0
mirror of https://gitlab.crans.org/bde/nk20 synced 2025-06-20 17:41:55 +02:00

Merge branch 'beta' into traduction

This commit is contained in:
elkmaennchen
2020-09-13 12:17:25 +02:00
139 changed files with 5891 additions and 4687 deletions

View File

@ -0,0 +1,69 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Activity',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('description', models.TextField(verbose_name='description')),
('location', models.CharField(blank=True, default='', help_text='Place where the activity is organized, eg. Kfet.', max_length=255, verbose_name='location')),
('date_start', models.DateTimeField(verbose_name='start date')),
('date_end', models.DateTimeField(verbose_name='end date')),
('valid', models.BooleanField(default=False, verbose_name='valid')),
('open', models.BooleanField(default=False, verbose_name='open')),
],
options={
'verbose_name': 'activity',
'verbose_name_plural': 'activities',
},
),
migrations.CreateModel(
name='ActivityType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('manage_entries', models.BooleanField(default=False, help_text='Enable the support of entries for this activity.', verbose_name='manage entries')),
('can_invite', models.BooleanField(default=False, verbose_name='can invite')),
('guest_entry_fee', models.PositiveIntegerField(default=0, verbose_name='guest entry fee')),
],
options={
'verbose_name': 'activity type',
'verbose_name_plural': 'activity types',
},
),
migrations.CreateModel(
name='Entry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='entry time')),
],
options={
'verbose_name': 'entry',
'verbose_name_plural': 'entries',
},
),
migrations.CreateModel(
name='Guest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('last_name', models.CharField(max_length=255, verbose_name='last name')),
('first_name', models.CharField(max_length=255, verbose_name='first name')),
],
options={
'verbose_name': 'guest',
'verbose_name_plural': 'guests',
},
),
]

View File

@ -0,0 +1,89 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('activity', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
('note', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='GuestTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
('entry', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='activity.Entry')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('note.transaction',),
),
migrations.AddField(
model_name='guest',
name='activity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.Activity'),
),
migrations.AddField(
model_name='guest',
name='inviter',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='guests', to='note.NoteUser', verbose_name='inviter'),
),
migrations.AddField(
model_name='entry',
name='activity',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='activity.Activity', verbose_name='activity'),
),
migrations.AddField(
model_name='entry',
name='guest',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, to='activity.Guest'),
),
migrations.AddField(
model_name='entry',
name='note',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.NoteUser', verbose_name='note'),
),
migrations.AddField(
model_name='activity',
name='activity_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.ActivityType', verbose_name='type'),
),
migrations.AddField(
model_name='activity',
name='attendees_club',
field=models.ForeignKey(help_text='Club that is authorized to join the activity. Mostly the Kfet club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='attendees club'),
),
migrations.AddField(
model_name='activity',
name='creater',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='activity',
name='organizer',
field=models.ForeignKey(help_text='Club that organizes the activity. The entry fees will go to this club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='organizer'),
),
migrations.AlterUniqueTogether(
name='guest',
unique_together={('activity', 'last_name', 'first_name')},
),
migrations.AlterUniqueTogether(
name='entry',
unique_together={('activity', 'note', 'guest')},
),
migrations.AlterUniqueTogether(
name='activity',
unique_together={('name', 'date_start', 'date_end')},
),
]

View File

@ -1,6 +1,7 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from datetime import timedelta
from threading import Thread
@ -130,12 +131,16 @@ class Activity(models.Model):
raise ValidationError(_("The end date must be after the start date."))
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():
from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand
RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name)
RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name)
Thread(daemon=True, target=refresh_activities).start()
# Consider that we can update the wiki iff the WIKI_PASSWORD env var is not empty
RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name,
False, os.getenv("WIKI_PASSWORD"))
RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name,
False, os.getenv("WIKI_PASSWORD"))
Thread(daemon=True, target=refresh_activities).start()\
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
return ret
def __str__(self):
@ -183,6 +188,12 @@ class Entry(models.Model):
verbose_name = _("entry")
verbose_name_plural = _("entries")
def __str__(self):
return _("Entry for {guest}, invited by {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity)) if self.guest \
else _("Entry for {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity))
def save(self, *args, **kwargs):
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h1 class="text-white">{{ title }}</h1>
<div class="row">
<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">
{% trans "Transfer" %}
</a>

View File

@ -0,0 +1,175 @@
# 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>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
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
# SPDX-License-Identifier: GPL-3.0-or-later
from hashlib import md5
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.db.models import F, Q
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import DetailView, TemplateView, UpdateView
from django_tables2.views import SingleTableView
from note.models import Alias, NoteSpecial, NoteUser
@ -190,10 +194,10 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if pattern[0] != "^":
pattern = "^" + pattern
guest_qs = guest_qs.filter(
Q(first_name__regex=pattern)
| Q(last_name__regex=pattern)
| Q(inviter__alias__name__regex=pattern)
| Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
| Q(inviter__alias__name__iregex=pattern)
| Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern))
)
else:
guest_qs = guest_qs.none()
@ -226,21 +230,19 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
if "search" in self.request.GET and self.request.GET["search"]:
pattern = self.request.GET["search"]
note_qs = note_qs.filter(
Q(note__noteuser__user__first_name__regex=pattern)
| Q(note__noteuser__user__last_name__regex=pattern)
| Q(name__regex=pattern)
| Q(normalized_name__regex=Alias.normalize(pattern))
Q(note__noteuser__user__first_name__iregex=pattern)
| Q(note__noteuser__user__last_name__iregex=pattern)
| Q(name__iregex=pattern)
| Q(normalized_name__iregex=Alias.normalize(pattern))
)
else:
note_qs = note_qs.none()
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql':
note_qs = note_qs.distinct('note__pk')[:20]
else:
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
# In production mode, please use PostgreSQL.
note_qs = note_qs.distinct()[:20]
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
# In production mode, please use PostgreSQL.
note_qs = note_qs.distinct('note__pk')[:20]\
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
return note_qs
def get_context_data(self, **kwargs):
@ -281,3 +283,60 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
Entry(activity=a, note=self.request.user.note,))]
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")

View File

@ -6,6 +6,7 @@ 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.

View File

@ -4,6 +4,7 @@
from django.conf import settings
from django.conf.urls import url, include
from rest_framework import routers
from .viewsets import ContentTypeViewSet, UserViewSet
# Routers provide an easy way of automatically determining the URL conf.

View File

@ -6,11 +6,8 @@ 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 note_kfet.middlewares import get_current_session
from note.models import Alias
@ -29,7 +26,7 @@ class ReadProtectedModelViewSet(ModelViewSet):
def get_queryset(self):
user = self.request.user
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(ReadOnlyModelViewSet):
@ -44,8 +41,7 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
def get_queryset(self):
user = self.request.user
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):
@ -67,29 +63,37 @@ class UserViewSet(ReadProtectedModelViewSet):
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.
# Filter with different rules
# We use union-all to keep each filter rule sorted in result
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)
# 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")

View File

@ -0,0 +1,37 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Changelog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address')),
('instance_pk', models.CharField(max_length=255, verbose_name='identifier')),
('previous', models.TextField(null=True, verbose_name='previous data')),
('data', models.TextField(null=True, verbose_name='new data')),
('action', models.CharField(choices=[('create', 'create'), ('edit', 'edit'), ('delete', 'delete')], default='edit', max_length=16, verbose_name='action')),
('timestamp', models.DateTimeField(default=django.utils.timezone.now, verbose_name='timestamp')),
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType', verbose_name='model')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'changelog',
'verbose_name_plural': 'changelogs',
},
),
]

View File

@ -0,0 +1,17 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('logs', '0001_initial'),
]
operations = [
migrations.RunSQL(
"UPDATE logs_changelog SET previous = '' WHERE previous IS NULL;"
),
migrations.RunSQL(
"UPDATE logs_changelog SET data = '' WHERE data IS NULL;"
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.16 on 2020-09-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('logs', '0002_replace_null_by_blank'),
]
operations = [
migrations.AlterField(
model_name='changelog',
name='data',
field=models.TextField(blank=True, default='', verbose_name='new data'),
),
migrations.AlterField(
model_name='changelog',
name='previous',
field=models.TextField(blank=True, default='', verbose_name='previous data'),
),
]

View File

@ -44,12 +44,14 @@ class Changelog(models.Model):
)
previous = models.TextField(
null=True,
blank=True,
default="",
verbose_name=_('previous data'),
)
data = models.TextField(
null=True,
blank=True,
default="",
verbose_name=_('new data'),
)
@ -80,3 +82,7 @@ class Changelog(models.Model):
class Meta:
verbose_name = _("changelog")
verbose_name_plural = _("changelogs")
def __str__(self):
return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format(
action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp))

View File

@ -50,10 +50,7 @@ def save_object(sender, instance, **kwargs):
in order to store each modification made
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
if hasattr(instance, "_no_log"):
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# noinspection PyProtectedMember
@ -102,7 +99,7 @@ def save_object(sender, instance, **kwargs):
model = instance.__class__
fields = changed_fields
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else ""
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
Changelog.objects.create(user=user,
@ -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
"""
# noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED:
return
if hasattr(instance, "_no_log"):
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
@ -155,6 +149,6 @@ def delete_object(sender, instance, **kwargs):
model=ContentType.objects.get_for_model(instance),
instance_pk=instance.pk,
previous=instance_json,
data=None,
data="",
action="delete"
).save()

View File

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

View File

@ -1,31 +0,0 @@
[
{
"model": "member.club",
"pk": 1,
"fields": {
"name": "BDE",
"email": "tresorerie.bde@example.com",
"require_memberships": true,
"membership_fee_paid": 500,
"membership_fee_unpaid": 500,
"membership_duration": 396,
"membership_start": "2019-08-31",
"membership_end": "2020-09-30"
}
},
{
"model": "member.club",
"pk": 2,
"fields": {
"name": "Kfet",
"email": "tresorerie.bde@example.com",
"parent_club": 1,
"require_memberships": true,
"membership_fee_paid": 3500,
"membership_fee_unpaid": 3500,
"membership_duration": 396,
"membership_start": "2019-08-31",
"membership_end": "2020-09-30"
}
}
]

View File

@ -1,7 +1,11 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import io
from PIL import Image, ImageSequence
from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
from django.forms import CheckboxSelectMultiple
@ -16,7 +20,7 @@ from .models import Profile, Club, Membership
class CustomAuthenticationForm(AuthenticationForm):
permission_mask = forms.ModelChoiceField(
label="Masque de permissions",
label=_("Permission mask"),
queryset=PermissionMask.objects.order_by("rank"),
empty_label=None,
)
@ -77,6 +81,54 @@ class ImageForm(forms.Form):
width = forms.FloatField(widget=forms.HiddenInput())
height = forms.FloatField(widget=forms.HiddenInput())
def clean(self):
"""
Load image and crop
In the future, when Pillow will support APNG we will be able to
simplify this code to save only PNG/APNG.
"""
cleaned_data = super().clean()
# Image size is limited by Django DATA_UPLOAD_MAX_MEMORY_SIZE
image = cleaned_data.get('image')
if image:
# Let Pillow detect and load image
# If it is an animation, then there will be multiple frames
try:
im = Image.open(image)
except OSError:
# Rare case in which Django consider the upload file as an image
# but Pil is unable to load it
raise forms.ValidationError(_('This image cannot be loaded.'))
# Crop each frame
x = cleaned_data.get('x', 0)
y = cleaned_data.get('y', 0)
w = cleaned_data.get('width', 200)
h = cleaned_data.get('height', 200)
frames = []
for frame in ImageSequence.Iterator(im):
frame = frame.crop((x, y, x + w, y + h))
frame = frame.resize(
(settings.PIC_WIDTH, settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS,
)
frames.append(frame)
# Save
om = frames.pop(0) # Get first frame
om.info = im.info # Copy metadata
image.file = io.BytesIO()
if len(frames) > 1:
# Save as GIF
om.save(image.file, "GIF", save_all=True, append_images=list(frames), loop=0)
else:
# Save as PNG
om.save(image.file, "PNG")
return cleaned_data
class ClubForm(forms.ModelForm):
def clean(self):

View File

@ -0,0 +1,76 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
import datetime
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import phonenumber_field.modelfields
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Club',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('email', models.EmailField(max_length=254, verbose_name='email')),
('require_memberships', models.BooleanField(default=True, help_text="Uncheck if this club don't require memberships.", verbose_name='require memberships')),
('membership_fee_paid', models.PositiveIntegerField(default=0, verbose_name='membership fee (paid students)')),
('membership_fee_unpaid', models.PositiveIntegerField(default=0, verbose_name='membership fee (unpaid students)')),
('membership_duration', models.PositiveIntegerField(blank=True, help_text='The longest time (in days) a membership can last (NULL = infinite).', null=True, verbose_name='membership duration')),
('membership_start', models.DateField(blank=True, help_text='Date from which the members can renew their membership.', null=True, verbose_name='membership start')),
('membership_end', models.DateField(blank=True, help_text='Maximal date of a membership, after which members must renew it.', null=True, verbose_name='membership end')),
],
options={
'verbose_name': 'club',
'verbose_name_plural': 'clubs',
},
),
migrations.CreateModel(
name='Profile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=50, null=True, region=None, verbose_name='phone number')),
('section', models.CharField(blank=True, help_text='e.g. "1A0", "9A♥", "SAPHIRE"', max_length=255, null=True, verbose_name='section')),
('department', models.CharField(choices=[('A0', 'Informatics (A0)'), ('A1', 'Mathematics (A1)'), ('A2', 'Physics (A2)'), ("A'2", "Applied physics (A'2)"), ('A2', "Chemistry (A''2)"), ('A3', 'Biology (A3)'), ('B1234', 'SAPHIRE (B1234)'), ('B1', 'Mechanics (B1)'), ('B2', 'Civil engineering (B2)'), ('B3', 'Mechanical engineering (B3)'), ('B4', 'EEA (B4)'), ('C', 'Design (C)'), ('D2', 'Economy-management (D2)'), ('D3', 'Social sciences (D3)'), ('E', 'English (E)'), ('EXT', 'External (EXT)')], max_length=8, verbose_name='department')),
('promotion', models.PositiveSmallIntegerField(default=2020, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion')),
('address', models.CharField(blank=True, max_length=255, null=True, verbose_name='address')),
('paid', models.BooleanField(default=False, help_text='Tells if the user receive a salary.', verbose_name='paid')),
('ml_events_registration', models.CharField(blank=True, choices=[(None, 'No'), ('fr', 'Yes (receive them in french)'), ('en', 'Yes (receive them in english)')], default=None, max_length=2, null=True, verbose_name='Register on the mailing list to stay informed of the events of the campus (1 mail/week)')),
('ml_sport_registration', models.BooleanField(default=False, verbose_name='Register on the mailing list to stay informed of the sport events of the campus (1 mail/week)')),
('ml_art_registration', models.BooleanField(default=False, verbose_name='Register on the mailing list to stay informed of the art events of the campus (1 mail/week)')),
('report_frequency', models.PositiveSmallIntegerField(default=0, verbose_name='report frequency (in days)')),
('last_report', models.DateTimeField(default=django.utils.timezone.now, verbose_name='last report date')),
('email_confirmed', models.BooleanField(default=False, verbose_name='email confirmed')),
('registration_valid', models.BooleanField(default=False, verbose_name='registration valid')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'user profile',
'verbose_name_plural': 'user profile',
},
),
migrations.CreateModel(
name='Membership',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_start', models.DateField(default=datetime.date.today, verbose_name='membership starts on')),
('date_end', models.DateField(null=True, verbose_name='membership ends on')),
('fee', models.PositiveIntegerField(verbose_name='fee')),
('club', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='club')),
],
options={
'verbose_name': 'membership',
'verbose_name_plural': 'memberships',
},
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('permission', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='membership',
name='roles',
field=models.ManyToManyField(to='permission.Role', verbose_name='roles'),
),
migrations.AddField(
model_name='membership',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='club',
name='parent_club',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='parent club'),
),
migrations.AddIndex(
model_name='profile',
index=models.Index(fields=['user'], name='member_prof_user_id_30c316_idx'),
),
migrations.AddIndex(
model_name='membership',
index=models.Index(fields=['user'], name='member_memb_user_id_945dbc_idx'),
),
]

View File

@ -0,0 +1,57 @@
from django.db import migrations
def create_bde_and_kfet(apps, schema_editor):
"""
The clubs BDE and Kfet are pre-injected.
"""
Club = apps.get_model("member", "club")
NoteClub = apps.get_model("note", "noteclub")
ContentType = apps.get_model('contenttypes', 'ContentType')
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
Club.objects.get_or_create(
id=1,
name="BDE",
email="tresorerie.bde@example.com",
require_memberships=True,
membership_fee_paid=500,
membership_fee_unpaid=500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
)
Club.objects.get_or_create(
id=2,
name="Kfet",
parent_club_id=1,
email="tresorerie.bde@example.com",
require_memberships=True,
membership_fee_paid=3500,
membership_fee_unpaid=3500,
membership_duration=396,
membership_start="2020-08-01",
membership_end="2021-09-30",
)
NoteClub.objects.get_or_create(
id=5,
club_id=1,
polymorphic_ctype_id=polymorphic_ctype_id,
)
NoteClub.objects.get_or_create(
id=6,
club_id=2,
polymorphic_ctype_id=polymorphic_ctype_id,
)
class Migration(migrations.Migration):
dependencies = [
('member', '0002_auto_20200904_2341'),
('note', '0002_create_special_notes'),
]
operations = [
migrations.RunPython(create_bde_and_kfet),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('member', '0003_create_bde_and_kfet'),
]
operations = [
migrations.RunSQL(
"UPDATE member_profile SET address = '' WHERE address IS NULL;",
),
migrations.RunSQL(
"UPDATE member_profile SET ml_events_registration = '' WHERE ml_events_registration IS NULL;",
),
migrations.RunSQL(
"UPDATE member_profile SET section = '' WHERE section IS NULL;",
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 2.2.16 on 2020-09-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('member', '0004_replace_null_by_blank'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='address',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='address'),
),
migrations.AlterField(
model_name='profile',
name='ml_events_registration',
field=models.CharField(blank=True, choices=[('', 'No'), ('fr', 'Yes (receive them in french)'), ('en', 'Yes (receive them in english)')], default='', max_length=2, verbose_name='Register on the mailing list to stay informed of the events of the campus (1 mail/week)'),
),
migrations.AlterField(
model_name='profile',
name='section',
field=models.CharField(blank=True, default='', help_text='e.g. "1A0", "9A♥", "SAPHIRE"', max_length=255, verbose_name='section'),
),
]

View File

@ -46,7 +46,7 @@ class Profile(models.Model):
help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'),
max_length=255,
blank=True,
null=True,
default="",
)
department = models.CharField(
@ -83,7 +83,7 @@ class Profile(models.Model):
verbose_name=_('address'),
max_length=255,
blank=True,
null=True,
default="",
)
paid = models.BooleanField(
@ -94,11 +94,10 @@ class Profile(models.Model):
ml_events_registration = models.CharField(
blank=True,
null=True,
default=None,
default='',
max_length=2,
choices=[
(None, _("No")),
('', _("No")),
('fr', _("Yes (receive them in french)")),
('en', _("Yes (receive them in english)")),
],
@ -172,19 +171,21 @@ class Profile(models.Model):
def send_email_validation_link(self):
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',
{
'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user),
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)),
'token': token,
'uid': uid,
})
html = loader.render_to_string('registration/mails/email_validation_email.html',
{
'user': self.user,
'domain': os.getenv("NOTE_URL", "note.example.com"),
'token': email_validation_token.make_token(self.user),
'uid': urlsafe_base64_encode(force_bytes(self.user.pk)),
'token': token,
'uid': uid,
})
self.user.email_user(subject, message, html_message=html)
@ -339,43 +340,85 @@ class Membership(models.Model):
return self.date_start.toordinal() <= datetime.datetime.now().toordinal()
def renew(self):
if Membership.objects.filter(
"""
If the current membership comes to expiration, create a new membership that starts immediately after this one.
"""
if not Membership.objects.filter(
user=self.user,
club=self.club,
date_start__gte=self.club.membership_start,
).exists():
# Membership is already renewed
return
new_membership = Membership(
# Membership is not renewed yet
new_membership = Membership(
user=self.user,
club=self.club,
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:
new_membership._force_renew_parent = True
if hasattr(self, '_soge') and self._soge:
new_membership._soge = True
if hasattr(self, '_force_save') and self._force_save:
new_membership._force_save = True
new_membership.save()
new_membership.roles.set(self.roles.all())
new_membership.save()
def renew_parent(self):
"""
Ensure that the parent membership is renewed, and renew/create it if needed.
"""
parent_membership = Membership.objects.filter(
user=self.user,
club=self.club,
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:
new_membership._force_renew_parent = True
if hasattr(self, '_soge') and self._soge:
new_membership._soge = True
if hasattr(self, '_force_save') and self._force_save:
new_membership._force_save = True
new_membership.save()
new_membership.roles.set(self.roles.all())
new_membership.save()
club=self.club.parent_club,
).order_by("-date_start")
if parent_membership.exists():
# Renew the previous membership of the parent club
parent_membership = parent_membership.first()
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.renew()
else:
# Create a new membership in the parent club
parent_membership = Membership(
user=self.user,
club=self.club.parent_club,
date_start=self.date_start,
)
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.save()
parent_membership.refresh_from_db()
if self.club.parent_club.name == "BDE":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
elif self.club.parent_club.name == "Kfet":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save()
def save(self, *args, **kwargs):
"""
Calculate fee and end date before saving the membership and creating the transaction if needed.
"""
if self.pk:
created = not self.pk
if not created:
for role in self.roles.all():
club = role.for_club
if club is not None:
if club.pk != self.club_id:
raise ValidationError(_('The role {role} does not apply to the club {club}.')
.format(role=role.name, club=club.name))
created = not self.pk
if created:
else:
if Membership.objects.filter(
user=self.user,
club=self.club,
@ -384,7 +427,7 @@ class Membership(models.Model):
).exists():
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
if not Membership.objects.filter(
user=self.user,
@ -392,56 +435,15 @@ class Membership(models.Model):
date_start__gte=self.club.parent_club.membership_start,
).exists():
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
parent_membership = Membership.objects.filter(
user=self.user,
club=self.club.parent_club,
).order_by("-date_start")
if parent_membership.exists():
# Renew the previous membership of the parent club
parent_membership = parent_membership.first()
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.renew()
else:
# Create a new membership in the parent club
parent_membership = Membership(
user=self.user,
club=self.club.parent_club,
date_start=self.date_start,
)
parent_membership._force_renew_parent = True
if hasattr(self, '_soge'):
parent_membership._soge = True
if hasattr(self, '_force_save'):
parent_membership._force_save = True
parent_membership.save()
parent_membership.refresh_from_db()
if self.club.parent_club.name == "BDE":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
elif self.club.parent_club.name == "Kfet":
parent_membership.roles.set(
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
else:
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
parent_membership.save()
self.renew_parent()
else:
raise ValidationError(_('User is not a member of the parent club')
+ ' ' + self.club.parent_club.name)
if self.user.profile.paid:
self.fee = self.club.membership_fee_paid
else:
self.fee = self.club.membership_fee_unpaid
self.fee = self.club.membership_fee_paid if self.user.profile.paid else 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)
else:
self.date_end = self.date_start + datetime.timedelta(days=424242)
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)
if self.club.membership_end is not None and self.date_end > self.club.membership_end:
self.date_end = self.club.membership_end

View File

@ -6,11 +6,7 @@ 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
"""
if raw:
# When provisionning data, do not try to autocreate
return
if created and instance.is_active:
if not raw and created and instance.is_active and not hasattr(instance, "_no_signal"):
from .models import Profile
Profile.objects.get_or_create(user=instance)
if instance.is_superuser:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -2,22 +2,22 @@
* On form submit, create a new alias
*/
function create_alias (e) {
// Do not submit HTML form
e.preventDefault();
// Do not submit HTML form
e.preventDefault()
// Get data and send to API
const formData = new FormData(e.target);
$.post("/api/note/alias/", {
"csrfmiddlewaretoken": formData.get("csrfmiddlewaretoken"),
"name": formData.get("name"),
"note": formData.get("note")
}).done(function () {
// Reload table
$("#alias_table").load(location.pathname + " #alias_table");
addMsg("Alias ajouté", "success");
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON);
});
// Get data and send to API
const formData = new FormData(e.target)
$.post('/api/note/alias/', {
csrfmiddlewaretoken: formData.get('csrfmiddlewaretoken'),
name: formData.get('name'),
note: formData.get('note')
}).done(function () {
// Reload table
$('#alias_table').load(location.pathname + ' #alias_table')
addMsg('Alias ajouté', 'success')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
/**
@ -25,19 +25,19 @@ function create_alias (e) {
* @param Integer button_id Alias id to remove
*/
function delete_button (button_id) {
$.ajax({
url: "/api/note/alias/" + button_id + "/",
method: "DELETE",
headers: { "X-CSRFTOKEN": CSRF_TOKEN }
}).done(function () {
addMsg('Alias supprimé', 'success');
$("#alias_table").load(location.pathname + " #alias_table");
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON);
});
$.ajax({
url: '/api/note/alias/' + button_id + '/',
method: 'DELETE',
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
}).done(function () {
addMsg('Alias supprimé', 'success')
$('#alias_table').load(location.pathname + ' #alias_table')
}).fail(function (xhr, _textStatus, _error) {
errMsg(xhr.responseJSON)
})
}
$(document).ready(function () {
// Attach event
document.getElementById("form_alias").addEventListener("submit", create_alias);
})
// Attach event
document.getElementById('form_alias').addEventListener('submit', create_alias)
})

View File

@ -48,10 +48,10 @@
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage aliases' %} ({{ user_object.note.alias_set.all|length }})
{% trans 'Manage aliases' %} ({{ club.note.alias_set.all|length }})
</a>
</dd>
<dt class="col-xl-4">{% trans 'email'|capfirst %}</dt>
<dd class="col-xl-8"><a href="mailto:{{ club.email }}">{{ club.email }}</a></dd>
</dl>
</dl>

View File

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

View File

@ -32,8 +32,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<span class="glyphicon glyphicon-zoom-out"></span>
</button>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">Nevermind</button>
<button type="button" class="btn btn-primary js-crop-and-upload">Crop and upload</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Nevermind" %}</button>
<button type="button" class="btn btn-primary js-crop-and-upload">{% trans "Crop and upload" %}</button>
</div>
</div>
</div>
@ -55,12 +55,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
/* SCRIPT TO OPEN THE MODAL WITH THE PREVIEW */
$("#id_image").change(function (e) {
if (this.files && this.files[0]) {
var reader = new FileReader();
reader.onload = function (e) {
$("#modal-image").attr("src", e.target.result);
$("#modalCrop").modal("show");
// Check the image size
if (this.files[0].size > 2*1024*1024) {
alert("Ce fichier est trop volumineux.")
} else {
// Read the selected image file
var reader = new FileReader();
reader.onload = function (e) {
$("#modal-image").attr("src", e.target.result);
$("#modalCrop").modal("show");
}
reader.readAsDataURL(this.files[0]);
}
reader.readAsDataURL(this.files[0]);
}
});
@ -104,4 +110,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
});
});
</script>
{% endblock %}
{% endblock %}

View File

@ -1,9 +1,9 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from note.models import TransactionTemplate, TemplateCategory
from django.urls import reverse
"""
Test that login page still works
@ -31,7 +31,20 @@ class TemplateLoggedInTests(TestCase):
sess.save()
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)
def test_admin_index(self):
@ -41,22 +54,3 @@ class TemplateLoggedInTests(TestCase):
def test_accounts_password_reset(self):
response = self.client.get('/accounts/password_reset/')
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(date.today()),
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(date.today()),
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(date.today()),
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

@ -1,10 +1,8 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import io
from datetime import timedelta, date
from PIL import Image
from django.conf import settings
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin
@ -97,8 +95,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
note = NoteUser.objects.filter(
alias__normalized_name=Alias.normalize(new_username))
if note.exists() and note.get().user != self.object:
form.add_error('username',
_("An alias with a similar name already exists."))
form.add_error('username', _("An alias with a similar name already exists."))
return super().form_invalid(form)
# Check if the username is one of user's aliases.
alias = Alias.objects.filter(name=new_username)
@ -141,10 +138,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
"""
We can't display information of a not registered user.
"""
qs = super().get_queryset()
if self.request.user.is_superuser and self.request.session.get("permission_mask", -1) >= 42:
return qs
return qs.filter(profile__registration_valid=True)
return super().get_queryset(**kwargs).filter(profile__registration_valid=True)
def get_context_data(self, **kwargs):
"""
@ -204,14 +198,16 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
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"))\
.filter(profile__registration_valid=True).order_by("username")
if "search" in self.request.GET:
pattern = self.request.GET["search"]
.filter(profile__registration_valid=True)
if not pattern:
return qs.none()
# Sqlite doesn't support order by in subqueries
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(
username__iregex="^" + pattern
@ -265,40 +261,27 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
return context
def get_success_url(self):
"""Redirect to profile page after upload"""
return reverse_lazy('member:user_detail', kwargs={'pk': self.object.id})
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = self.get_object()
if form.is_valid():
return self.form_valid(form)
else:
print('is_invalid')
print(form)
return self.form_invalid(form)
return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
def form_valid(self, form):
image_field = form.cleaned_data['image']
x = form.cleaned_data['x']
y = form.cleaned_data['y']
w = form.cleaned_data['width']
h = form.cleaned_data['height']
# image crop and resize
image_file = io.BytesIO(image_field.read())
# ext = image_field.name.split('.')[-1].lower()
# TODO: support GIF format
image = Image.open(image_file)
image = image.crop((x, y, x + w, y + h))
image_clean = image.resize((settings.PIC_WIDTH,
settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS)
image_file = io.BytesIO()
image_clean.save(image_file, "PNG")
image_field.file = image_file
# renaming
filename = "{}_pic.png".format(self.object.note.pk)
image_field.name = filename
self.object.note.display_image = image_field
"""Save image to note"""
image = form.cleaned_data['image']
# Rename as a PNG or GIF
extension = image.name.split(".")[-1]
if extension == "gif":
image.name = "{}_pic.gif".format(self.object.note.pk)
else:
image.name = "{}_pic.png".format(self.object.note.pk)
# Save
self.object.note.display_image = image
self.object.note.save()
return super().form_valid(form)
@ -320,8 +303,7 @@ class ManageAuthTokens(LoginRequiredMixin, TemplateView):
def get(self, request, *args, **kwargs):
if 'regenerate' in request.GET and Token.objects.filter(user=request.user).exists():
Token.objects.get(user=self.request.user).delete()
return redirect(reverse_lazy('member:auth_token') + "?show",
permanent=True)
return redirect(reverse_lazy('member:auth_token') + "?show")
return super().get(request, *args, **kwargs)
@ -351,8 +333,9 @@ class ClubCreateView(ProtectQuerysetMixin, ProtectedCreateView):
email="",
)
def form_valid(self, form):
return super().form_valid(form)
def get_success_url(self):
self.object.refresh_from_db()
return reverse_lazy("member:club_detail", kwargs={"pk": self.object.pk})
class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
@ -609,6 +592,64 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
return context
def perform_verifications(self, form, user, club, fee):
"""
Make some additional verifications to check that the membership can be created.
:return: True if the form is clean, False if there is an error.
"""
error = False
# Retrieve form data
credit_type = form.cleaned_data["credit_type"]
credit_amount = form.cleaned_data["credit_amount"]
last_name = form.cleaned_data["last_name"]
first_name = form.cleaned_data["first_name"]
bank = form.cleaned_data["bank"]
soge = form.cleaned_data["soge"] and not user.profile.soge and (club.name == "BDE" or club.name == "Kfet")
if not soge and user.note.balance + credit_amount < fee and not Membership.objects.filter(
club__name="Kfet",
user=user,
date_start__lte=date.today(),
date_end__gte=date.today(),
).exists():
# Users without a valid Kfet membership can't have a negative balance.
# TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note
form.add_error('user',
_("This user don't have enough money to join this club, and can't have a negative balance."))
error = True
if Membership.objects.filter(
user=form.instance.user,
club=club,
date_start__lte=form.instance.date_start,
date_end__gte=form.instance.date_start,
).exists():
form.add_error('user', _('User is already a member of the club'))
error = True
if club.membership_start and form.instance.date_start < club.membership_start:
form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
error = True
if club.membership_end and form.instance.date_start > club.membership_end:
form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.")
.format(form.instance.club.membership_end))
error = True
if credit_amount:
if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
if not last_name:
form.add_error('last_name', _("This field is required."))
if not first_name:
form.add_error('first_name', _("This field is required."))
if not bank and credit_type.special_type == "Chèque":
form.add_error('bank', _("This field is required."))
return self.form_invalid(form)
return not error
def form_valid(self, form):
"""
Create membership, check that all is good, make transactions
@ -655,36 +696,9 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
fee += c.membership_fee_paid if user.profile.paid else c.membership_fee_unpaid
c = c.parent_club
if user.note.balance + credit_amount < fee and not Membership.objects.filter(
club__name="Kfet",
user=user,
date_start__lte=date.today(),
date_end__gte=date.today(),
).exists():
# Users without a valid Kfet membership can't have a negative balance.
# TODO Send a notification to the user (with a mail?) to tell her/him to credit her/his note
form.add_error('user',
_("This user don't have enough money to join this club, and can't have a negative balance."))
return super().form_invalid(form)
if Membership.objects.filter(
user=form.instance.user,
club=club,
date_start__lte=form.instance.date_start,
date_end__gte=form.instance.date_start,
).exists():
form.add_error('user', _('User is already a member of the club'))
return super().form_invalid(form)
if club.membership_start and form.instance.date_start < club.membership_start:
form.add_error('user', _("The membership must start after {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
return super().form_invalid(form)
if club.membership_end and form.instance.date_start > club.membership_end:
form.add_error('user', _("The membership must begin before {:%m-%d-%Y}.")
.format(form.instance.club.membership_start))
return super().form_invalid(form)
# Make some verifications about the form, and if there is an error, then assume that the form is invalid
if not self.perform_verifications(form, user, club, fee):
return self.form_invalid(form)
# Now, all is fine, the membership can be created.
@ -696,15 +710,6 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
# Credit note before the membership is created.
if credit_amount > 0:
if not last_name or not first_name or (not bank and credit_type.special_type == "Chèque"):
if not last_name:
form.add_error('last_name', _("This field is required."))
if not first_name:
form.add_error('first_name', _("This field is required."))
if not bank and credit_type.special_type == "Chèque":
form.add_error('bank', _("This field is required."))
return self.form_invalid(form)
transaction = SpecialTransaction(
source=credit_type,
destination=user.note,
@ -719,46 +724,38 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
transaction._force_save = True
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
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()
elif club.name == "Kfet":
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()
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() \
if club.name == "Kfet"else Role.objects.filter(name="Membre de club").all()
form.instance.roles.set(member_role)
form.instance._force_save = True
form.instance.save()
# If Société générale pays, then we assume that this is the BDE membership, and we auto-renew the
# Kfet membership.
if soge:
# If not already done, create BDE and Kfet memberships
bde = Club.objects.get(name="BDE")
if soge and club.name == "BDE":
kfet = Club.objects.get(name="Kfet")
fee = kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
soge_clubs = [bde, kfet]
for club in soge_clubs:
fee = club.membership_fee_paid if user.profile.paid else club.membership_fee_unpaid
# Get current membership, to get the end date
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
# Get current membership, to get the end date
old_membership = Membership.objects.filter(
club=kfet,
user=user,
).order_by("-date_start")
if not old_membership.filter(date_start__gte=kfet.membership_start).exists():
# If the membership is not already renewed
membership = Membership(
club=club,
club=kfet,
user=user,
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,
)
membership._force_save = True
@ -767,10 +764,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView):
membership.refresh_from_db()
if old_membership.exists():
membership.roles.set(old_membership.get().roles.all())
elif c.name == "BDE":
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.roles.set(Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
membership.save()
return ret
@ -830,9 +824,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV
qs = qs.filter(date_start__lte=timezone.now().today(), date_end__gte=timezone.now().today())
if "roles" in self.request.GET:
if not self.request.GET["roles"]:
return qs.none()
roles_str = self.request.GET["roles"].replace(' ', '').split(',')
roles_str = self.request.GET["roles"].replace(' ', '').split(',') if self.request.GET["roles"] else ['0']
roles_int = map(int, roles_str)
qs = qs.filter(roles__in=roles_int)

View File

@ -127,7 +127,12 @@ class ConsumerSerializer(serializers.ModelSerializer):
# If the user has no right to see the note, then we only display the note identifier
return NotePolymorphicSerializer().to_representation(obj.note)\
if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\
else dict(id=obj.note.id, name=str(obj.note))
else dict(
id=obj.note.id,
name=str(obj.note),
is_active=obj.note.is_active,
display_image=obj.note.display_image.url,
)
def get_email_confirmed(self, obj):
if isinstance(obj.note, NoteUser):

View File

@ -1,5 +1,6 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.db.models import Q
from django.core.exceptions import ValidationError
@ -56,8 +57,9 @@ class AliasViewSet(ReadProtectedModelViewSet):
"""
queryset = Alias.objects.all()
serializer_class = AliasSerializer
filter_backends = [SearchFilter, OrderingFilter]
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['note']
ordering_fields = ['name', 'normalized_name']
def get_serializer_class(self):
@ -106,8 +108,9 @@ class AliasViewSet(ReadProtectedModelViewSet):
class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
queryset = Alias.objects.all()
serializer_class = ConsumerSerializer
filter_backends = [SearchFilter, OrderingFilter]
filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend]
search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ]
filterset_fields = ['note']
ordering_fields = ['name', 'normalized_name']
def get_queryset(self):
@ -116,29 +119,34 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet):
:return: The filtered set of requested aliases
"""
queryset = super().get_queryset()
queryset = super().get_queryset().distinct()
# Sqlite doesn't support ORDER BY in subqueries
queryset = queryset.order_by("username") \
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", None)
queryset = queryset.prefetch_related('note')
# We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias.
queryset = queryset.filter(
name__iregex="^" + alias
).union(
queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True).union(
queryset.filter(
Q(normalized_name__iregex="^" + alias.lower())
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True)
if alias:
# We match first an alias if it is matched without normalization,
# then if the normalized pattern matches a normalized alias.
queryset = queryset.filter(
name__iregex="^" + alias
).union(
queryset.filter(
Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True).union(
queryset.filter(
Q(normalized_name__iregex="^" + alias.lower())
& ~Q(normalized_name__iregex="^" + Alias.normalize(alias))
& ~Q(name__iregex="^" + alias)
),
all=True)
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
else queryset.order_by("name")
return queryset.distinct()
@ -176,8 +184,11 @@ class TransactionViewSet(ReadProtectedModelViewSet):
"""
queryset = Transaction.objects.order_by("-created_at").all()
serializer_class = TransactionPolymorphicSerializer
filter_backends = [SearchFilter]
filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter]
filterset_fields = ["source", "source_alias", "destination", "destination_alias", "quantity",
"polymorphic_ctype", "amount", "created_at", ]
search_fields = ['$reason', ]
ordering_fields = ['created_at', 'amount']
def get_queryset(self):
user = self.request.user

View File

@ -3,7 +3,7 @@
from django.apps import AppConfig
from django.conf import settings
from django.db.models.signals import post_save
from django.db.models.signals import post_save, pre_delete
from django.utils.translation import gettext_lazy as _
from . import signals
@ -25,3 +25,8 @@ class NoteConfig(AppConfig):
signals.save_club_note,
sender='member.Club',
)
pre_delete.connect(
signals.delete_transaction,
sender='note.transaction',
)

View File

@ -1,188 +0,0 @@
[
{
"model": "note.note",
"pk": 1,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:02:48.778Z"
}
},
{
"model": "note.note",
"pk": 2,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:39.546Z"
}
},
{
"model": "note.note",
"pk": 3,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:43.049Z"
}
},
{
"model": "note.note",
"pk": 4,
"fields": {
"polymorphic_ctype": [
"note",
"notespecial"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "",
"created_at": "2020-02-20T20:06:50.996Z"
}
},
{
"model": "note.note",
"pk": 5,
"fields": {
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "pic/default.png",
"created_at": "2020-02-20T20:09:38.615Z"
}
},
{
"model": "note.note",
"pk": 6,
"fields": {
"polymorphic_ctype": [
"note",
"noteclub"
],
"balance": 0,
"last_negative": null,
"is_active": true,
"display_image": "pic/default.png",
"created_at": "2020-02-20T20:16:14.753Z"
}
},
{
"model": "note.noteclub",
"pk": 5,
"fields": {
"club": 1
}
},
{
"model": "note.noteclub",
"pk": 6,
"fields": {
"club": 2
}
},
{
"model": "note.notespecial",
"pk": 1,
"fields": {
"special_type": "Esp\u00e8ces"
}
},
{
"model": "note.notespecial",
"pk": 2,
"fields": {
"special_type": "Carte bancaire"
}
},
{
"model": "note.notespecial",
"pk": 3,
"fields": {
"special_type": "Ch\u00e8que"
}
},
{
"model": "note.notespecial",
"pk": 4,
"fields": {
"special_type": "Virement bancaire"
}
},
{
"model": "note.alias",
"pk": 1,
"fields": {
"name": "Esp\u00e8ces",
"normalized_name": "especes",
"note": 1
}
},
{
"model": "note.alias",
"pk": 2,
"fields": {
"name": "Carte bancaire",
"normalized_name": "cartebancaire",
"note": 2
}
},
{
"model": "note.alias",
"pk": 3,
"fields": {
"name": "Ch\u00e8que",
"normalized_name": "cheque",
"note": 3
}
},
{
"model": "note.alias",
"pk": 4,
"fields": {
"name": "Virement bancaire",
"normalized_name": "virementbancaire",
"note": 4
}
},
{
"model": "note.alias",
"pk": 5,
"fields": {
"name": "BDE",
"normalized_name": "bde",
"note": 5
}
},
{
"model": "note.alias",
"pk": 6,
"fields": {
"name": "Kfet",
"normalized_name": "kfet",
"note": 6
}
}
]

View File

@ -0,0 +1,209 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Note',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('balance', models.BigIntegerField(default=0, help_text='in centimes, money credited for this instance', verbose_name='account balance')),
('last_negative', models.DateTimeField(blank=True, help_text='last time the balance was negative', null=True, verbose_name='last negative date')),
('display_image', models.ImageField(default='pic/default.png', max_length=255, upload_to='pic/', verbose_name='display image')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created at')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this note should be treated as active. Unselect this instead of deleting notes.', verbose_name='active')),
('inactivity_reason', models.CharField(choices=[('manual', 'The user blocked his/her note manually, eg. when he/she left the school for holidays. It can be reactivated at any time.'), ('forced', "The note is blocked by the the BDE and can't be manually reactivated.")], default=None, max_length=255, null=True)),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_note.note_set+', to='contenttypes.ContentType')),
],
options={
'verbose_name': 'note',
'verbose_name_plural': 'notes',
},
),
migrations.CreateModel(
name='TemplateCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=31, unique=True, verbose_name='name')),
],
options={
'verbose_name': 'transaction category',
'verbose_name_plural': 'transaction categories',
},
),
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_alias', models.CharField(default='', max_length=255, verbose_name='used alias')),
('destination_alias', models.CharField(default='', max_length=255, verbose_name='used alias')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created at')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='quantity')),
('amount', models.PositiveIntegerField(verbose_name='amount')),
('reason', models.CharField(max_length=255, verbose_name='reason')),
('valid', models.BooleanField(default=True, verbose_name='valid')),
('invalidity_reason', models.CharField(blank=True, default=None, max_length=255, null=True, verbose_name='invalidity reason')),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='note.Note', verbose_name='destination')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_note.transaction_set+', to='contenttypes.ContentType')),
('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='note.Note', verbose_name='source')),
],
options={
'verbose_name': 'transaction',
'verbose_name_plural': 'transactions',
},
),
migrations.CreateModel(
name='MembershipTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
],
options={
'verbose_name': 'membership transaction',
'verbose_name_plural': 'membership transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='NoteClub',
fields=[
('note_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Note')),
],
options={
'verbose_name': 'club note',
'verbose_name_plural': 'clubs notes',
},
bases=('note.note',),
),
migrations.CreateModel(
name='NoteSpecial',
fields=[
('note_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Note')),
('special_type', models.CharField(max_length=255, unique=True, verbose_name='type')),
],
options={
'verbose_name': 'special note',
'verbose_name_plural': 'special notes',
},
bases=('note.note',),
),
migrations.CreateModel(
name='NoteUser',
fields=[
('note_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Note')),
],
options={
'verbose_name': "one's note",
'verbose_name_plural': 'users note',
},
bases=('note.note',),
),
migrations.CreateModel(
name='RecurrentTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
],
options={
'verbose_name': 'recurrent transaction',
'verbose_name_plural': 'recurrent transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='SpecialTransaction',
fields=[
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
('last_name', models.CharField(max_length=255, verbose_name='name')),
('first_name', models.CharField(max_length=255, verbose_name='first_name')),
('bank', models.CharField(blank=True, max_length=255, verbose_name='bank')),
],
options={
'verbose_name': 'Special transaction',
'verbose_name_plural': 'Special transactions',
},
bases=('note.transaction',),
),
migrations.CreateModel(
name='Alias',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='name')),
('normalized_name', models.CharField(editable=False, max_length=255, unique=True)),
('note', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.Note')),
],
options={
'verbose_name': 'alias',
'verbose_name_plural': 'aliases',
},
),
migrations.CreateModel(
name='TransactionTemplate',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(error_messages={'unique': 'A template with this name already exist'}, max_length=255, unique=True, verbose_name='name')),
('amount', models.PositiveIntegerField(verbose_name='amount')),
('display', models.BooleanField(default=True, verbose_name='display')),
('highlighted', models.BooleanField(default=False, verbose_name='highlighted')),
('description', models.CharField(blank=True, max_length=255, verbose_name='description')),
('category', models.ForeignKey(max_length=31, on_delete=django.db.models.deletion.PROTECT, related_name='templates', to='note.TemplateCategory', verbose_name='type')),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='note.NoteClub', verbose_name='destination')),
],
options={
'verbose_name': 'transaction template',
'verbose_name_plural': 'transaction templates',
},
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['created_at'], name='note_transa_created_bea8b1_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['source'], name='note_transa_source__4a1a1e_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['destination'], name='note_transa_destina_6e1bb4_idx'),
),
migrations.AddField(
model_name='recurrenttransaction',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.TransactionTemplate'),
),
migrations.AddField(
model_name='noteuser',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='note', to=settings.AUTH_USER_MODEL, verbose_name='user'),
),
migrations.AddField(
model_name='noteclub',
name='club',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='note', to='member.Club', verbose_name='club'),
),
migrations.AddField(
model_name='membershiptransaction',
name='membership',
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='transaction', to='member.Membership'),
),
migrations.AddIndex(
model_name='alias',
index=models.Index(fields=['name'], name='note_alias_name_a89405_idx'),
),
migrations.AddIndex(
model_name='alias',
index=models.Index(fields=['normalized_name'], name='note_alias_normali_bd52b4_idx'),
),
]

View File

@ -0,0 +1,25 @@
from django.db import migrations
def create_special_notes(apps, schema_editor):
"""
We create the four special note to make transfers.
"""
NoteSpecial = apps.get_model("note", "notespecial")
ContentType = apps.get_model('contenttypes', 'ContentType')
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteSpecial).id
NoteSpecial.objects.get_or_create(id=1, special_type="Espèces", polymorphic_ctype_id=polymorphic_ctype_id)
NoteSpecial.objects.get_or_create(id=2, special_type="Carte bancaire", polymorphic_ctype_id=polymorphic_ctype_id)
NoteSpecial.objects.get_or_create(id=3, special_type="Chèque", polymorphic_ctype_id=polymorphic_ctype_id)
NoteSpecial.objects.get_or_create(id=4, special_type="Virement bancaire", polymorphic_ctype_id=polymorphic_ctype_id)
class Migration(migrations.Migration):
dependencies = [
('note', '0001_initial'),
]
operations = [
migrations.RunPython(create_special_notes),
]

View File

@ -0,0 +1,17 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('note', '0002_create_special_notes'),
]
operations = [
migrations.RunSQL(
"UPDATE note_note SET inactivity_reason = '' WHERE inactivity_reason IS NULL;"
),
migrations.RunSQL(
"UPDATE note_transaction SET invalidity_reason = '' WHERE invalidity_reason IS NULL;"
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 2.2.16 on 2020-09-06 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('note', '0003_replace_null_by_blank'),
]
operations = [
migrations.AlterField(
model_name='note',
name='inactivity_reason',
field=models.CharField(blank=True, choices=[('manual', 'The user blocked his/her note manually, eg. when he/she left the school for holidays. It can be reactivated at any time.'), ('forced', "The note is blocked by the the BDE and can't be manually reactivated.")], default='', max_length=255),
),
migrations.AlterField(
model_name='transaction',
name='invalidity_reason',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='invalidity reason'),
),
]

View File

@ -70,8 +70,8 @@ class Note(PolymorphicModel):
"It can be reactivated at any time.")),
('forced', _("The note is blocked by the the BDE and can't be manually reactivated.")),
],
null=True,
default=None,
blank=True,
default="",
)
class Meta:
@ -102,13 +102,19 @@ class Note(PolymorphicModel):
super().save(*args, **kwargs)
if not Alias.objects.filter(name=str(self)).exists():
if not Alias.objects.filter(normalized_name=Alias.normalize(str(self))).exists():
a = Alias(name=str(self))
a.clean()
# Save alias
a.note = self
a.save(force_insert=True)
else:
# Check if the name of the note changed without changing the normalized form of the alias
alias = Alias.objects.get(normalized_name=Alias.normalize(str(self)))
if alias.name != str(self):
alias.name = str(self)
alias.save()
def clean(self, *args, **kwargs):
"""

View File

@ -90,6 +90,9 @@ class TransactionTemplate(models.Model):
def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk,))
def __str__(self):
return self.name
class Transaction(PolymorphicModel):
"""
@ -150,8 +153,7 @@ class Transaction(PolymorphicModel):
invalidity_reason = models.CharField(
verbose_name=_('invalidity reason'),
max_length=255,
default=None,
null=True,
default='',
blank=True,
)
@ -173,7 +175,7 @@ class Transaction(PolymorphicModel):
created = self.pk is None
to_transfer = self.amount * self.quantity
if not created:
if not created and not self.valid and not hasattr(self, "_force_save"):
# Revert old transaction
old_transaction = Transaction.objects.get(pk=self.pk)
# Check that nothing important changed
@ -195,7 +197,7 @@ class Transaction(PolymorphicModel):
# When a transaction is declared valid, we ensure that the invalidity reason is null, if it was
# previously invalid
self.invalidity_reason = None
self.invalidity_reason = ""
if source_balance > 9223372036854775807 or source_balance < -9223372036854775808\
or dest_balance > 9223372036854775807 or dest_balance < -9223372036854775808:
@ -242,14 +244,6 @@ class Transaction(PolymorphicModel):
self.destination._force_save = True
self.destination.save()
def delete(self, **kwargs):
"""
Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
"""
self.valid = False
self.save(**kwargs)
super().delete(**kwargs)
@property
def total(self):
return self.amount * self.quantity
@ -356,4 +350,4 @@ class MembershipTransaction(Transaction):
@property
def type(self):
return _('membership transaction')
return _('membership').capitalize()

View File

@ -6,7 +6,8 @@ def save_user_note(instance, raw, **_kwargs):
"""
Hook to create and save a note when an user is updated
"""
if not raw and (instance.is_superuser or instance.profile.registration_valid):
if not raw and (instance.is_superuser or instance.profile.registration_valid)\
and not hasattr(instance, "_no_signal"):
# Create note only when the registration is validated
from note.models import NoteUser
NoteUser.objects.get_or_create(user=instance)
@ -17,10 +18,17 @@ def save_club_note(instance, raw, **_kwargs):
"""
Hook to create and save a note when a club is updated
"""
if raw:
# When provisionning data, do not try to autocreate
return
# When provisionning data, do not try to autocreate
if not raw and not hasattr(instance, "_no_signal"):
from .models import NoteClub
NoteClub.objects.get_or_create(club=instance)
instance.note.save()
from .models import NoteClub
NoteClub.objects.get_or_create(club=instance)
instance.note.save()
def delete_transaction(instance, **_kwargs):
"""
Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
"""
if not hasattr(instance, "_no_signal"):
instance.valid = False
instance.save()

View File

@ -0,0 +1,262 @@
// Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
// SPDX-License-Identifier: GPL-3.0-or-later
// When a transaction is performed, lock the interface to prevent spam clicks.
var LOCK = false
/**
* Refresh the history table on the consumptions page.
*/
function refreshHistory () {
$('#history').load('/note/consos/ #history')
$('#most_used').load('/note/consos/ #most_used')
}
$(document).ready(function () {
// If hash of a category in the URL, then select this category
// else select the first one
if (location.hash) {
$("a[href='" + location.hash + "']").tab('show')
} else {
$("a[data-toggle='tab']").first().tab('show')
}
// When selecting a category, change URL
$(document.body).on('click', "a[data-toggle='tab']", function () {
location.hash = this.getAttribute('href')
})
// Switching in double consumptions mode should update the layout
$('#double_conso').change(function () {
$('#consos_list_div').removeClass('d-none')
$('#infos_div').attr('class', 'col-sm-5 col-xl-6')
const note_list_obj = $('#note_list')
if (buttons.length > 0 && note_list_obj.text().length > 0) {
$('#consos_list').html(note_list_obj.html())
note_list_obj.html('')
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, 'consos_list')()
})
})
}
})
$('#single_conso').change(function () {
$('#consos_list_div').addClass('d-none')
$('#infos_div').attr('class', 'col-sm-5 col-md-4')
const consos_list_obj = $('#consos_list')
if (buttons.length > 0) {
if (notes_display.length === 0 && consos_list_obj.text().length > 0) {
$('#note_list').html(consos_list_obj.html())
consos_list_obj.html('')
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, 'note_list')()
})
})
} else {
buttons.length = 0
consos_list_obj.html('')
}
}
})
// Ensure we begin in single consumption. Fix issue with TurboLinks and BootstrapJS
$("label[for='double_conso']").removeClass('active')
$('#consume_all').click(consumeAll)
})
notes = []
notes_display = []
buttons = []
// When the user searches an alias, we update the auto-completion
autoCompleteNote('note', 'note_list', notes, notes_display,
'alias', 'note', 'user_note', 'profile_pic', function () {
if (buttons.length > 0 && $('#single_conso').is(':checked')) {
consumeAll()
return false
}
return true
})
/**
* Add a transaction from a button.
* @param dest Where the money goes
* @param amount The price of the item
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category_id The category identifier
* @param category_name The category name
* @param template_id The identifier of the button
* @param template_name The name of the button
*/
function addConso (dest, amount, type, category_id, category_name, template_id, template_name) {
var button = null
buttons.forEach(function (b) {
if (b.id === template_id) {
b.quantity += 1
button = b
}
})
if (button == null) {
button = {
id: template_id,
name: template_name,
dest: dest,
quantity: 1,
amount: amount,
type: type,
category_id: category_id,
category_name: category_name
}
buttons.push(button)
}
const dc_obj = $('#double_conso')
if (dc_obj.is(':checked') || notes_display.length === 0) {
const list = dc_obj.is(':checked') ? 'consos_list' : 'note_list'
let html = ''
buttons.forEach(function (button) {
html += li('conso_button_' + button.id, button.name +
'<span class="badge badge-dark badge-pill">' + button.quantity + '</span>')
})
$('#' + list).html(html)
buttons.forEach(function (button) {
$('#conso_button_' + button.id).click(function () {
if (LOCK) { return }
removeNote(button, 'conso_button', buttons, list)()
})
})
} else { consumeAll() }
}
/**
* Reset the page as its initial state.
*/
function reset () {
notes_display.length = 0
notes.length = 0
buttons.length = 0
$('#note_list').html('')
$('#consos_list').html('')
$('#note').val('')
$('#note').attr('data-original-title', '').tooltip('hide')
$('#profile_pic').attr('src', '/static/member/img/default_picture.png')
$('#profile_pic_link').attr('href', '#')
refreshHistory()
refreshBalance()
LOCK = false
}
/**
* Apply all transactions: all notes in `notes` buy each item in `buttons`
*/
function consumeAll () {
if (LOCK) { return }
LOCK = true
let error = false
if (notes_display.length === 0) {
$('#note').addClass('is-invalid')
$('#note_list').html(li('', '<strong>Ajoutez des émetteurs.</strong>', 'text-danger'))
error = true
}
if (buttons.length === 0) {
$('#consos_list').html(li('', '<strong>Ajoutez des consommations.</strong>', 'text-danger'))
error = true
}
if (error) {
LOCK = false
return
}
notes_display.forEach(function (note_display) {
buttons.forEach(function (button) {
consume(note_display.note, note_display.name, button.dest, button.quantity * note_display.quantity, button.amount,
button.name + ' (' + button.category_name + ')', button.type, button.category_id, button.id)
})
})
}
/**
* Create a new transaction from a button through the API.
* @param source The note that paid the item (type: note)
* @param source_alias The alias used for the source (type: str)
* @param dest The note that sold the item (type: int)
* @param quantity The quantity sold (type: int)
* @param amount The price of one item, in cents (type: int)
* @param reason The transaction details (type: str)
* @param type The type of the transaction (content type id for RecurrentTransaction)
* @param category The category id of the button (type: int)
* @param template The button id (type: int)
*/
function consume (source, source_alias, dest, quantity, amount, reason, type, category, template) {
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: quantity,
amount: amount,
reason: reason,
valid: true,
polymorphic_ctype: type,
resourcetype: 'RecurrentTransaction',
source: source.id,
source_alias: source_alias,
destination: dest,
template: template
})
.done(function () {
if (!isNaN(source.balance)) {
const newBalance = source.balance - quantity * amount
if (newBalance <= -5000) {
addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' +
'succès, mais la note émettrice ' + source_alias + ' est en négatif sévère.',
'danger', 30000)
} else if (newBalance < 0) {
addMsg('Attention, La transaction depuis la note ' + source_alias + ' a été réalisée avec ' +
'succès, mais la note émettrice ' + source_alias + ' est en négatif.',
'warning', 30000)
}
if (source.membership && source.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.",
'danger', 30000)
}
}
reset()
}).fail(function (e) {
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: quantity,
amount: amount,
reason: reason,
valid: false,
invalidity_reason: 'Solde insuffisant',
polymorphic_ctype: type,
resourcetype: 'RecurrentTransaction',
source: source,
source_alias: source_alias,
destination: dest,
template: template
}).done(function () {
reset()
addMsg("La transaction n'a pas pu être validée pour cause de solde insuffisant.", 'danger', 10000)
}).fail(function () {
reset()
errMsg(e.responseJSON)
})
})
}

View File

@ -0,0 +1,422 @@
var LOCK = false
sources = []
sources_notes_display = []
dests = []
dests_notes_display = []
function refreshHistory () {
$('#history').load('/note/transfer/ #history')
}
function reset (refresh = true) {
sources_notes_display.length = 0
sources.length = 0
dests_notes_display.length = 0
dests.length = 0
$('#source_note_list').html('')
$('#dest_note_list').html('')
const source_field = $('#source_note')
source_field.val('')
const event = jQuery.Event('keyup')
event.originalEvent = { charCode: 97 }
source_field.trigger(event)
source_field.removeClass('is-invalid')
source_field.attr('data-original-title', '').tooltip('hide')
const dest_field = $('#dest_note')
dest_field.val('')
dest_field.trigger(event)
dest_field.removeClass('is-invalid')
dest_field.attr('data-original-title', '').tooltip('hide')
const amount_field = $('#amount')
amount_field.val('')
amount_field.removeClass('is-invalid')
$('#amount-required').html('')
const reason_field = $('#reason')
reason_field.val('')
reason_field.removeClass('is-invalid')
$('#reason-required').html('')
$('#last_name').val('')
$('#first_name').val('')
$('#bank').val('')
$('#user_note').val('')
$('#profile_pic').attr('src', '/static/member/img/default_picture.png')
$('#profile_pic_link').attr('href', '#')
if (refresh) {
refreshBalance()
refreshHistory()
}
LOCK = false
}
$(document).ready(function () {
/**
* If we are in credit/debit mode, check that only one note is entered.
* More over, get first name and last name to autocomplete fields.
*/
function checkUniqueNote () {
if ($('#type_credit').is(':checked') || $('#type_debit').is(':checked')) {
const arr = $('#type_credit').is(':checked') ? dests_notes_display : sources_notes_display
if (arr.length === 0) { return }
const last = arr[arr.length - 1]
arr.length = 0
arr.push(last)
last.quantity = 1
if (!last.note.user) {
$.getJSON('/api/note/note/' + last.note.id + '/?format=json', function (note) {
last.note.user = note.user
$.getJSON('/api/user/' + last.note.user + '/', function (user) {
$('#last_name').val(user.last_name)
$('#first_name').val(user.first_name)
})
})
} else {
$.getJSON('/api/user/' + last.note.user + '/', function (user) {
$('#last_name').val(user.last_name)
$('#first_name').val(user.first_name)
})
}
}
return true
}
autoCompleteNote('source_note', 'source_note_list', sources, sources_notes_display,
'source_alias', 'source_note', 'user_note', 'profile_pic', checkUniqueNote)
autoCompleteNote('dest_note', 'dest_note_list', dests, dests_notes_display,
'dest_alias', 'dest_note', 'user_note', 'profile_pic', checkUniqueNote)
const source = $('#source_note')
const dest = $('#dest_note')
$('#type_transfer').change(function () {
if (LOCK) { return }
$('#source_me_div').removeClass('d-none')
$('#source_note').removeClass('is-invalid')
$('#dest_note').removeClass('is-invalid')
$('#special_transaction_div').addClass('d-none')
source.removeClass('d-none')
$('#source_note_list').removeClass('d-none')
$('#credit_type').addClass('d-none')
dest.removeClass('d-none')
$('#dest_note_list').removeClass('d-none')
$('#debit_type').addClass('d-none')
$('#source_note_label').text(select_emitters_label)
$('#dest_note_label').text(select_receveirs_label)
location.hash = 'transfer'
})
$('#type_credit').change(function () {
if (LOCK) { return }
$('#source_me_div').addClass('d-none')
$('#source_note').removeClass('is-invalid')
$('#dest_note').removeClass('is-invalid')
$('#special_transaction_div').removeClass('d-none')
$('#source_note_list').addClass('d-none')
$('#dest_note_list').removeClass('d-none')
source.addClass('d-none')
source.tooltip('hide')
$('#credit_type').removeClass('d-none')
dest.removeClass('d-none')
dest.val('')
dest.tooltip('hide')
$('#debit_type').addClass('d-none')
$('#source_note_label').text(transfer_type_label)
$('#dest_note_label').text(select_receveir_label)
if (dests_notes_display.length > 1) {
$('#dest_note_list').html('')
dests_notes_display.length = 0
}
location.hash = 'credit'
})
$('#type_debit').change(function () {
if (LOCK) { return }
$('#source_me_div').addClass('d-none')
$('#source_note').removeClass('is-invalid')
$('#dest_note').removeClass('is-invalid')
$('#special_transaction_div').removeClass('d-none')
$('#source_note_list').removeClass('d-none')
$('#dest_note_list').addClass('d-none')
source.removeClass('d-none')
source.val('')
source.tooltip('hide')
$('#credit_type').addClass('d-none')
dest.addClass('d-none')
dest.tooltip('hide')
$('#debit_type').removeClass('d-none')
$('#source_note_label').text(select_emitter_label)
$('#dest_note_label').text(transfer_type_label)
if (sources_notes_display.length > 1) {
$('#source_note_list').html('')
sources_notes_display.length = 0
}
location.hash = 'debit'
})
$('#credit_type').change(function () {
const type = $('#credit_type option:selected').text()
if ($('#type_credit').is(':checked')) { source.val(type) } else { dest.val(type) }
})
// Ensure we begin in transfer mode. Removing these lines may cause problems when reloading.
const type_transfer = $('#type_transfer') // Default mode
type_transfer.removeAttr('checked')
$('#type_credit').removeAttr('checked')
$('#type_debit').removeAttr('checked')
if (location.hash) { $('#type_' + location.hash.substr(1)).click() } else { type_transfer.click() }
$('#source_me').click(function () {
if (LOCK) { return }
// Shortcut to set the current user as the only emitter
sources_notes_display.length = 0
sources.length = 0
$('#source_note_list').html('')
const source_note = $('#source_note')
source_note.focus()
source_note.val('')
let event = jQuery.Event('keyup')
event.originalEvent = { charCode: 97 }
source_note.trigger(event)
source_note.val(username)
event = jQuery.Event('keyup')
event.originalEvent = { charCode: 97 }
source_note.trigger(event)
const fill_note = function () {
if (sources.length === 0) {
setTimeout(fill_note, 100)
return
}
event = jQuery.Event('keypress')
event.originalEvent = { charCode: 13 }
source_note.trigger(event)
source_note.tooltip('hide')
source_note.val('')
$('#dest_note').focus()
}
fill_note()
})
})
$('#btn_transfer').click(function () {
if (LOCK) { return }
LOCK = true
let error = false
const amount_field = $('#amount')
amount_field.removeClass('is-invalid')
$('#amount-required').html('')
const reason_field = $('#reason')
reason_field.removeClass('is-invalid')
$('#reason-required').html('')
if (!amount_field.val() || isNaN(amount_field.val()) || amount_field.val() <= 0) {
amount_field.addClass('is-invalid')
$('#amount-required').html('<strong>Ce champ est requis et doit comporter un nombre décimal strictement positif.</strong>')
error = true
}
const amount = Math.floor(100 * amount_field.val())
if (amount > 2147483647) {
amount_field.addClass('is-invalid')
$('#amount-required').html('<strong>Le montant ne doit pas excéder 21474836.47 €.</strong>')
error = true
}
if (!reason_field.val()) {
reason_field.addClass('is-invalid')
$('#reason-required').html('<strong>Ce champ est requis.</strong>')
error = true
}
if (!sources_notes_display.length && !$('#type_credit').is(':checked')) {
$('#source_note').addClass('is-invalid')
error = true
}
if (!dests_notes_display.length && !$('#type_debit').is(':checked')) {
$('#dest_note').addClass('is-invalid')
error = true
}
if (error) {
LOCK = false
return
}
let reason = reason_field.val()
if ($('#type_transfer').is(':checked')) {
// We copy the arrays to ensure that transactions are well-processed even if the form is reset
[...sources_notes_display].forEach(function (source) {
[...dests_notes_display].forEach(function (dest) {
if (source.note.id === dest.note.id) {
addMsg('Attention : la transaction de ' + pretty_money(amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + " n'a pas été faite car il s'agit de la même note au départ" +
" et à l'arrivée.", 'warning', 10000)
LOCK = false
return
}
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: source.quantity * dest.quantity,
amount: amount,
reason: reason,
valid: true,
polymorphic_ctype: TRANSFER_POLYMORPHIC_CTYPE,
resourcetype: 'Transaction',
source: source.note.id,
source_alias: source.name,
destination: dest.note.id,
destination_alias: dest.name
}).done(function () {
if (source.note.membership && source.note.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note émettrice ' + source.name + " n'est plus adhérente.",
'danger', 30000)
}
if (dest.note.membership && dest.note.membership.date_end < new Date().toISOString()) {
addMsg('Attention : la note destination ' + dest.name + " n'est plus adhérente.",
'danger', 30000)
}
if (!isNaN(source.note.balance)) {
const newBalance = source.note.balance - source.quantity * dest.quantity * amount
if (newBalance <= -5000) {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' +
source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
'mais la note émettrice est en négatif sévère.', 'danger', 10000)
reset()
return
} else if (newBalance < 0) {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' +
source.name + ' vers la note ' + dest.name + ' a été fait avec succès, ' +
'mais la note émettrice est en négatif.', 'warning', 10000)
reset()
return
}
}
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a été fait avec succès !', 'success', 10000)
reset()
}).fail(function (err) { // do it again but valid = false
const errObj = JSON.parse(err.responseText)
if (errObj.non_field_errors) {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a échoué : ' + errObj.non_field_errors, 'danger')
LOCK = false
return
}
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: source.quantity * dest.quantity,
amount: amount,
reason: reason,
valid: false,
invalidity_reason: 'Solde insuffisant',
polymorphic_ctype: TRANSFER_POLYMORPHIC_CTYPE,
resourcetype: 'Transaction',
source: source.note.id,
source_alias: source.name,
destination: dest.note.id,
destination_alias: dest.name
}).done(function () {
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a échoué : Solde insuffisant', 'danger', 10000)
reset()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText }
addMsg('Le transfert de ' +
pretty_money(source.quantity * dest.quantity * amount) + ' de la note ' + source.name +
' vers la note ' + dest.name + ' a échoué : ' + error, 'danger')
LOCK = false
})
})
})
})
} else if ($('#type_credit').is(':checked') || $('#type_debit').is(':checked')) {
let special_note
let user_note
let alias
const given_reason = reason
let source_id, dest_id
if ($('#type_credit').is(':checked')) {
special_note = $('#credit_type').val()
user_note = dests_notes_display[0].note
alias = dests_notes_display[0].name
source_id = special_note
dest_id = user_note.id
reason = 'Crédit ' + $('#credit_type option:selected').text().toLowerCase()
if (given_reason.length > 0) { reason += ' (' + given_reason + ')' }
} else {
special_note = $('#debit_type').val()
user_note = sources_notes_display[0].note
alias = sources_notes_display[0].name
source_id = user_note.id
dest_id = special_note
reason = 'Retrait ' + $('#credit_type option:selected').text().toLowerCase()
if (given_reason.length > 0) { reason += ' (' + given_reason + ')' }
}
$.post('/api/note/transaction/transaction/',
{
csrfmiddlewaretoken: CSRF_TOKEN,
quantity: 1,
amount: amount,
reason: reason,
valid: true,
polymorphic_ctype: SPECIAL_TRANSFER_POLYMORPHIC_CTYPE,
resourcetype: 'SpecialTransaction',
source: source_id,
source_alias: sources_notes_display.length ? alias : null,
destination: dest_id,
destination_alias: dests_notes_display.length ? alias : null,
last_name: $('#last_name').val(),
first_name: $('#first_name').val(),
bank: $('#bank').val()
}).done(function () {
addMsg('Le crédit/retrait a bien été effectué !', 'success', 10000)
if (user_note.membership && user_note.membership.date_end < new Date().toISOString()) { addMsg('Attention : la note ' + alias + " n'est plus adhérente.", 'danger', 10000) }
reset()
}).fail(function (err) {
const errObj = JSON.parse(err.responseText)
let error = errObj.detail ? errObj.detail : errObj.non_field_errors
if (!error) { error = err.responseText }
addMsg('Le crédit/retrait a échoué : ' + error, 'danger', 10000)
LOCK = false
})
}
})

View File

@ -29,6 +29,7 @@ class HistoryTable(tables.Table):
source = tables.Column(
attrs={
"td": {
"class": "text-nowrap",
"data-toggle": "tooltip",
"title": lambda record: _("used alias").capitalize() + " : " + record.source_alias,
}
@ -38,15 +39,47 @@ class HistoryTable(tables.Table):
destination = tables.Column(
attrs={
"td": {
"class": "text-nowrap",
"data-toggle": "tooltip",
"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()
total = tables.Column() # will use Transaction.total() !!
total = tables.Column( # will use Transaction.total() !!
attrs={
"td": {
"class": "text-nowrap",
},
}
)
valid = tables.Column(
attrs={

View File

@ -10,22 +10,22 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %}
<div class="row mt-4">
<div class="col-sm-5 col-md-4" id="infos_div">
<div class="row">
<div class="row justify-content-center justify-content-md-end">
{# User details column #}
<div class="col">
<div class="card bg-light border-success mb-4 text-center">
<div class="col picture-col">
<div class="card bg-light mb-4 text-center">
<a id="profile_pic_link" href="#">
<img src="/media/pic/default.png"
id="profile_pic" alt="" class="card-img-top">
<img src="{% static "member/img/default_picture.png" %}"
id="profile_pic" alt="" class="card-img-top d-none d-sm-block">
</a>
<div class="card-body text-center text-break">
<span id="user_note"></span>
<div class="card-body text-center text-break p-2">
<span id="user_note"><i class="small">{% trans "Please select a note" %}</i></span>
</div>
</div>
</div>
{# User selection column #}
<div class="col-xl-7" id="user_select_div">
<div class="col-xl" id="user_select_div">
<div class="card bg-light border-success mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
@ -44,6 +44,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</div>
</div>
{# Summary of consumption and consume button #}
<div class="col-xl-5 d-none" id="consos_list_div">
<div class="card bg-light border-info mb-4">
@ -65,7 +66,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
</div>
{# Show last used buttons #}
<div class="card bg-light mb-4">
<div class="card-header">
@ -159,7 +159,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblock %}
{% block extrajavascript %}
<script type="text/javascript" src="{% static "js/consos.js" %}"></script>
<script type="text/javascript" src="{% static "note/js/consos.js" %}"></script>
<script type="text/javascript">
{% for button in highlighted %}
{% if button.display %}

View File

@ -1,5 +1,7 @@
{% load pretty_money %}
{% load getenv %}
{% load render_table from django_tables2 %}
{% load static %}
{% load i18n %}
<!DOCTYPE html>
@ -8,13 +10,8 @@
<meta charset="UTF-8">
<title>[Note Kfet] Rapport de la Note Kfet</title>
<link rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
crossorigin="anonymous">
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"
integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://{{ "NOTE_URL"|getenv }}{% static "bootstrap4/css/bootstrap.min.css" %}">
<script src="https://{{ "NOTE_URL"|getenv }}{% static "bootstrap4/js/bootstrap.min.js" %}"></script>
</head>
<body>
<p>
@ -27,7 +24,7 @@
Ensuite, un rapport vous est envoyé à la fréquence demandée seulement si vous avez consommé
depuis le dernier rapport.<br>
Pour arrêter de recevoir des rapports, il vous suffit de modifier votre profil Note et de
mettre la fréquence des rapports à 0 ou -1.<br>
mettre la fréquence des rapports à 0.<br>
Pour toutes suggestions par rapport à ce service, contactez
<a href="mailto:notekfet2020@lists.crans.org">notekfet2020@lists.crans.org</a>.
</p>

View File

@ -0,0 +1,32 @@
{% load pretty_money %}
{% load getenv %}
{% load i18n %}
Bonjour,
Vous recevez ce mail car vous avez défini une « Fréquence des rapports » dans la Note.
Le premier rapport récapitule toutes vos consommations depuis la création de votre compte.
Ensuite, un rapport vous est envoyé à la fréquence demandée seulement si vous avez consommé
depuis le dernier rapport.
Pour arrêter de recevoir des rapports, il vous suffit de modifier votre profil Note et de
mettre la fréquence des rapports à 0.
Pour toutes suggestions par rapport à ce service, contactez notekfet2020@lists.crans.org.
Rapport d'activité de {{ user.first_name|safe }} {{ user.last_name|safe }} (note : {{ user|safe }})
depuis le {{ last_report }} jusqu'au {{ now }}.
Dépenses totales : {{ outcoming|pretty_money }}
Apports totaux : {{ incoming|pretty_money }}
Différentiel : {{ diff|pretty_money }}
Nouveau solde : {{ user.note.balance|pretty_money }}
Rapport détaillé :
| Source | Destination | Créée le | Quantité | Montant | Raison | Type | Total | Valide |
+----------------------+----------------------+---------------------+----------+----------+----------------------------------+------------------+----------+---------+
{% for tr in last_transactions %}| {{ tr.source|safe|truncatechars:20|center:"20" }} | {{ tr.destination|safe|truncatechars:20|center:"20" }} | {{ tr.created_at|date:"Y-m-d H:i:s" }} | {{ tr.quantity|center:"8" }} | {{ tr.amount|pretty_money|center:"8" }} | {{ tr.reason|safe|truncatechars:32|center:"32" }} | {{ tr.type|safe|center:"16" }} | {{ tr.total|pretty_money|center:"8" }} | {{ tr.valid|yesno|center:"7" }} |
{% endfor %}
--
Le BDE
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}

View File

@ -34,21 +34,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<hr>
<div class="row">
<div class="row justify-content-center">
{# Preview note profile (picture, username and balance) #}
<div class="col-md-3" id="note_infos_div">
<div class="card bg-light border-success shadow mb-4">
<a id="profile_pic_link" href="#"><img src="/media/pic/default.png"
<div class="col-md picture-col" id="note_infos_div">
<div class="card bg-light mb-4 text-center">
<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>
<div class="card-body text-center">
<span id="user_note"></span>
<div class="card-body text-center p-2">
<span id="user_note"><i class="small">{% trans "Please select a note" %}</i></span>
</div>
</div>
</div>
{# list of emitters #}
<div class="col-md-3" id="emitters_div">
<div class="card bg-light border-success shadow mb-4">
<div class="card bg-light mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
<label for="source_note" id="source_note_label">{% trans "Select emitters" %}</label>
@ -65,9 +65,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias..." %}" />
<div id="source_me_div">
<hr>
<span class="form-control mx-auto btn btn-secondary" id="source_me">
<a class="btn-block btn btn-secondary" href="#" id="source_me" data-turbolinks="false">
{% trans "I am the emitter" %}
</span>
</a>
</div>
</div>
</div>
@ -75,7 +75,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
{# list of receiver #}
<div class="col-md-3" id="dests_div">
<div class="card bg-light border-info shadow mb-4">
<div class="card bg-light mb-4">
<div class="card-header">
<p class="card-text font-weight-bold" id="dest_title">
<label for="dest_note" id="dest_note_label">{% trans "Select receivers" %}</label>
@ -97,8 +97,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
{# Information on transaction (amount, reason, name,...) #}
<div class="col-md-3" id="external_div">
<div class="card bg-light border-warning shadow mb-4">
<div class="col-md" id="external_div">
<div class="card bg-light mb-4">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Action" %}
@ -153,7 +153,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
{# transaction history #}
<div class="card shadow mb-4" id="history">
<div class="card mb-4" id="history">
<div class="card-header">
<p class="card-text font-weight-bold">
{% trans "Recent transactions history" %}
@ -176,5 +176,5 @@ SPDX-License-Identifier: GPL-2.0-or-later
select_receveirs_label = "{% trans "Select receivers" %}";
transfer_type_label = "{% trans "Transfer type" %}";
</script>
<script src="/static/js/transfer.js"></script>
<script src="{% static "note/js/transfer.js" %}"></script>
{% endblock %}

View File

@ -144,7 +144,7 @@ class TransactionTemplateUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, Up
class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
"""
The Magic View that make people pay their beer and burgers.
(Most of the magic happens in the dark world of Javascript see `note_kfet/static/js/consos.js`)
(Most of the magic happens in the dark world of Javascript see `static/note/js/consos.js`)
"""
model = Transaction
template_name = "note/conso_form.html"

View File

@ -1103,7 +1103,7 @@
"treasury",
"sogecredit"
],
"query": "{\"credit_transaction\": null}",
"query": "{}",
"type": "add",
"mask": 1,
"field": "",
@ -2567,6 +2567,182 @@
"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.permission",
"pk": 169,
"fields": {
"model": [
"note",
"noteclub"
],
"query": "{\"club\": [\"club\"]}",
"type": "change",
"mask": 1,
"field": "display_image",
"permanent": false,
"description": "Changer l'image de la note de son club"
}
},
{
"model": "permission.permission",
"pk": 170,
"fields": {
"model": [
"note",
"alias"
],
"query": "{\"note__is_active\": true}",
"type": "add",
"mask": 1,
"field": "",
"permanent": false,
"description": "Ajouter n'importe quel alias à une note non bloquée"
}
},
{
"model": "permission.permission",
"pk": 171,
"fields": {
"model": [
"note",
"alias"
],
"query": "{\"note__is_active\": true}",
"type": "delete",
"mask": 3,
"field": "",
"permanent": false,
"description": "Supprimer n'importe quel alias à une note non bloquée"
}
},
{
"model": "permission.permission",
"pk": 172,
"fields": {
"model": [
"treasury",
"remittance"
],
"query": "{}",
"type": "view",
"mask": 3,
"field": "",
"permanent": false,
"description": "Voir toutes les remises"
}
},
{
"model": "permission.permission",
"pk": 173,
"fields": {
"model": [
"treasury",
"remittance"
],
"query": "{}",
"type": "add",
"mask": 3,
"field": "",
"permanent": false,
"description": "Ajouter une remise"
}
},
{
"model": "permission.permission",
"pk": 174,
"fields": {
"model": [
"treasury",
"remittance"
],
"query": "{}",
"type": "change",
"mask": 3,
"field": "",
"permanent": false,
"description": "Modifier une remise"
}
},
{
"model": "permission.permission",
"pk": 175,
"fields": {
"model": [
"treasury",
"remittance"
],
"query": "{}",
"type": "delete",
"mask": 3,
"field": "",
"permanent": false,
"description": "Supprimer une remise"
}
},
{
"model": "permission.role",
"pk": 1,
@ -2591,7 +2767,8 @@
52,
126,
161,
162
162,
165
]
}
},
@ -2661,7 +2838,8 @@
47,
49,
50,
141
141,
169
]
}
},
@ -2696,8 +2874,14 @@
62,
127,
133,
135,
136,
141,
142
142,
150,
166,
167,
168
]
}
},
@ -2711,8 +2895,7 @@
24,
25,
26,
27,
33
27
]
}
},
@ -2763,7 +2946,13 @@
150,
151,
163,
164
164,
170,
171,
172,
173,
174,
175
]
}
},
@ -2932,7 +3121,18 @@
161,
162,
163,
164
164,
165,
166,
167,
168,
169,
170,
171,
172,
173,
174,
175
]
}
},
@ -2944,7 +3144,6 @@
"name": "GC Kfet",
"permissions": [
32,
33,
56,
58,
55,
@ -2959,7 +3158,13 @@
29,
30,
31,
143
70,
143,
166,
167,
168,
170,
171
]
}
},

View File

@ -0,0 +1,71 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('member', '0001_initial'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='Permission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('query', models.TextField(verbose_name='query')),
('type', models.CharField(choices=[('add', 'add'), ('view', 'view'), ('change', 'change'), ('delete', 'delete')], max_length=15, verbose_name='type')),
('field', models.CharField(blank=True, max_length=255, verbose_name='field')),
('permanent', models.BooleanField(default=False, help_text='Tells if the permission should be granted even if the membership of the user is expired.', verbose_name='permanent')),
('description', models.CharField(blank=True, max_length=255, verbose_name='description')),
],
options={
'verbose_name': 'permission',
'verbose_name_plural': 'permissions',
},
),
migrations.CreateModel(
name='PermissionMask',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rank', models.PositiveSmallIntegerField(unique=True, verbose_name='rank')),
('description', models.CharField(max_length=255, unique=True, verbose_name='description')),
],
options={
'verbose_name': 'permission mask',
'verbose_name_plural': 'permission masks',
},
),
migrations.CreateModel(
name='Role',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('for_club', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='member.Club', verbose_name='for club')),
('permissions', models.ManyToManyField(to='permission.Permission', verbose_name='permissions')),
],
options={
'verbose_name': 'role permissions',
'verbose_name_plural': 'role permissions',
},
),
migrations.AddField(
model_name='permission',
name='mask',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='permissions', to='permission.PermissionMask', verbose_name='mask'),
),
migrations.AddField(
model_name='permission',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.ContentType', verbose_name='model'),
),
migrations.AlterUniqueTogether(
name='permission',
unique_together={('model', 'query', 'type', 'field')},
),
]

View File

@ -57,8 +57,8 @@ class InstancedPermission:
# Force insertion, no data verification, no trigger
obj._force_save = True
# We don't want log anything
obj._no_log = True
# We don't want to trigger any signal (log, ...)
obj._no_signal = True
Model.save(obj, force_insert=True)
ret = self.model.model_class().objects.filter(self.query & Q(pk=0)).exists()
# Delete testing object

View File

@ -14,6 +14,7 @@ class StrongDjangoObjectPermissions(DjangoObjectPermissions):
This is a simple patch of this class that controls view access.
"""
# The queryset is filtered, and permissions are more powerful than a simple check than just "can view this model"
perms_map = {
'GET': ['%(app_label)s.view_%(model_name)s'],
'OPTIONS': [],

View File

@ -28,7 +28,7 @@ def pre_save_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED:
return
if hasattr(instance, "_force_save"):
if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"):
return
user = get_current_authenticated_user()
@ -82,7 +82,8 @@ def pre_delete_object(instance, **kwargs):
if instance._meta.label_lower in EXCLUDED:
return
if hasattr(instance, "_force_delete") or hasattr(instance, "pk") and instance.pk == 0:
if hasattr(instance, "_force_delete") or hasattr(instance, "pk") and instance.pk == 0 \
or hasattr(instance, "_no_signal"):
# Don't check permissions on force-deleted objects
return

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from json.decoder import JSONDecodeError
from django.contrib.auth.models import User
from django.core.exceptions import FieldError
@ -56,29 +57,28 @@ 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.
"""
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:
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()
query = instanced.query
model = perm.model.model_class()
model.objects.filter(query).all()
# 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:", perm.query)
if instanced.query:

View File

@ -0,0 +1,44 @@
# 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.test import TestCase
from django.urls import reverse
from member.models import Membership, Club
from permission.models import Role
class TestRightsPage(TestCase):
"""
Display the rights page.
"""
fixtures = ("initial",)
def test_anonymous_rights_page(self):
"""
Check that we can properly see the rights page even if we are not connected.
We can't nethertheless see the club managers.
"""
response = self.client.get(reverse("permission:rights"))
self.assertEqual(response.status_code, 200)
self.assertFalse("special_memberships_table" in response.context)
self.assertFalse("superusers" in response.context)
def test_authenticated_rights_page(self):
"""
Connect to the note and check that the club mangers are also displayed.
"""
user = User.objects.create_superuser(
username="ploptoto",
password="totototo",
email="toto@example.com",
)
self.client.force_login(user)
membership = Membership.objects.create(user=user, club=Club.objects.get(name="BDE"))
membership.roles.add(Role.objects.get(name="Respo info"))
membership.save()
response = self.client.get(reverse("permission:rights"))
self.assertEqual(response.status_code, 200)
self.assertIsNotNone(response.context["special_memberships_table"])
self.assertIsNotNone(response.context["superusers"])

View File

@ -8,6 +8,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.forms import HiddenInput
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView, TemplateView, CreateView
from member.models import Membership
@ -24,9 +25,20 @@ class ProtectQuerysetMixin:
Display 404 error if the user can't see an object, remove the fields the user can't
update on an update form (useful if the user can't change only specified fields).
"""
def get_queryset(self, **kwargs):
def get_queryset(self, filter_permissions=True, **kwargs):
qs = super().get_queryset(**kwargs)
return qs.filter(PermissionBackend.filter_queryset(self.request.user, qs.model, "view")).distinct()
return qs.filter(PermissionBackend.filter_queryset(self.request.user, qs.model, "view")).distinct()\
if filter_permissions else qs
def get_object(self, queryset=None):
try:
return super().get_object(queryset)
except Http404 as e:
try:
super().get_object(self.get_queryset(filter_permissions=False))
raise PermissionDenied()
except Http404:
raise e
def get_form(self, form_class=None):
form = super().get_form(form_class)

View File

@ -16,4 +16,8 @@ Font-Awesome attribution is already done inside SVG files
#login-form select {
-moz-appearance: none;
cursor: pointer;
}
}
#login-form .asteriskField {
display: none;
}

View File

@ -5,14 +5,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
{% if validlink %}
{% trans "Your email have successfully been validated." %}
<div class="card bg-light">
<h3 class="card-header text-center">
{{ title }}
</h3>
<div class="card-body">
{% if validlink %}
<p>
{% trans "Your email have successfully been validated." %}
</p>
{% if user_object.profile.registration_valid %}
<p>
{% blocktrans %}You can now <a href="{{ login_url }}">log in</a>.{% endblocktrans %}
</p>
{% else %}
<p>
{% trans "You must pay now your membership in the Kfet to complete your registration." %}
</p>
{% endif %}
{% else %}
{% trans "The link was invalid. The token may have expired. Please send us an email to activate your account." %}
{% endif %}
{% else %}
<p>
{% trans "The link was invalid. The token may have expired. Please send us an email to activate your account." %}
</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -5,13 +5,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
<h2>{% trans "Account activation" %}</h2>
<p>
{% trans "An email has been sent. Please click on the link to activate your account." %}
</p>
<p>
{% trans "You must also go to the Kfet to pay your membership. The WEI registration includes the BDE membership." %}
</p>
{% endblock %}
<div class="card bg-light">
<h3 class="card-header text-center">
{% trans "Account activation" %}
</h3>
<div class="card-body">
<p>
{% trans "An email has been sent. Please click on the link to activate your account." %}
</p>
<p>
{% trans "You must also go to the Kfet to pay your membership." %}
</p>
</div>
</div>
{% endblock %}

View File

@ -27,7 +27,7 @@
</p>
<p>
{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet. Note that the WEI registration includes the Kfet membership." %}
{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet." %}
</p>
<p>
@ -38,4 +38,4 @@
<p>
{% trans "The Note Kfet team." %}<br>
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
</p>
</p>

View File

@ -8,9 +8,9 @@ https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=toke
{% trans "This link is only valid for a couple of days, after that you will need to contact us to validate your email." %}
{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet. Note that the WEI registration includes the Kfet membership." %}
{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet." %}
{% trans "Thanks" %},
{% trans "The Note Kfet team." %}
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}
{% trans "Mail generated by the Note Kfet on the" %} {% now "j F Y à H:i:s" %}

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(), 3)
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,11 +16,12 @@ from django.views.generic.edit import FormMixin
from django_tables2 import SingleTableView
from member.forms import ProfileForm
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 permission.backends import PermissionBackend
from permission.models import Role
from permission.views import ProtectQuerysetMixin
from treasury.models import SogeCredit
from .forms import SignUpForm, ValidationForm
from .tables import FutureUserTable
@ -101,7 +102,7 @@ class UserValidateView(TemplateView):
user.profile.email_confirmed = True
user.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):
"""
@ -169,12 +170,9 @@ class FutureUserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableVi
:return:
"""
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"]
if not pattern:
return qs.none()
qs = qs.filter(
Q(first_name__iregex=pattern)
| Q(last_name__iregex=pattern)
@ -205,10 +203,7 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = self.get_object()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
return self.form_valid(form) if form.is_valid() else self.form_invalid(form)
def get_queryset(self, **kwargs):
"""
@ -225,6 +220,9 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid
# In 2020, for COVID-19 reasons, the BDE offered 80 € to each new member that opens a Sogé account,
# since there is no WEI.
fee += 8000
ctx["total_fee"] = "{:.02f}".format(fee / 100, )
return ctx
@ -239,6 +237,10 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
def form_valid(self, form):
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
soge = form.cleaned_data["soge"]
credit_type = form.cleaned_data["credit_type"]
@ -276,9 +278,6 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
if credit_type is None:
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:
# Check if the user credits enough money
form.add_error('credit_type',
@ -347,6 +346,11 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.save()
if soge:
soge_credit = SogeCredit.objects.get(user=user)
# Update the credit transaction amount
soge_credit.save()
return ret
def get_success_url(self):

View File

@ -24,9 +24,7 @@ class RemittanceAdmin(admin.ModelAdmin):
list_display = ('remittance_type', 'date', 'comment', 'count', 'amount', 'closed', )
def has_change_permission(self, request, obj=None):
if not obj:
return True
return not obj.closed and super().has_change_permission(request, obj)
return not obj or (not obj.closed and super().has_change_permission(request, obj))
@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,
then render it on /api/treasury/invoice/
"""
queryset = Invoice.objects.all()
queryset = Invoice.objects.order_by("id").all()
serializer_class = InvoiceSerializer
filter_backends = [DjangoFilterBackend]
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,
then render it on /api/treasury/product/
"""
queryset = Product.objects.all()
queryset = Product.objects.order_by("invoice_id", "id").all()
serializer_class = ProductSerializer
filter_backends = [SearchFilter]
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
then render it on /api/treasury/remittance_type/
"""
queryset = RemittanceType.objects
queryset = RemittanceType.objects.order_by("id")
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,
then render it on /api/treasury/remittance/
"""
queryset = Remittance.objects
queryset = Remittance.objects.order_by("id")
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,
then render it on /api/treasury/soge_credit/
"""
queryset = SogeCredit.objects
queryset = SogeCredit.objects.order_by("id")
serializer_class = SogeCreditSerializer

View File

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

View File

@ -0,0 +1,107 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
import datetime
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('note', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.PositiveIntegerField(primary_key=True, serialize=False, verbose_name='Invoice identifier')),
('bde', models.CharField(choices=[('Saperlistpopette.png', 'Saper[list]popette'), ('Finalist.png', 'Fina[list]'), ('Listorique.png', '[List]orique'), ('Satellist.png', 'Satel[list]'), ('Monopolist.png', 'Monopo[list]'), ('Kataclist.png', 'Katac[list]')], default='Saperlistpopette.png', max_length=32, verbose_name='BDE')),
('object', models.CharField(max_length=255, verbose_name='Object')),
('description', models.TextField(verbose_name='Description')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('address', models.TextField(verbose_name='Address')),
('date', models.DateField(default=datetime.date.today, verbose_name='Date')),
('acquitted', models.BooleanField(default=False, verbose_name='Acquitted')),
('locked', models.BooleanField(default=False, help_text="An invoice can't be edited when it is locked.", verbose_name='Locked')),
('tex', models.TextField(default='', verbose_name='tex source')),
],
options={
'verbose_name': 'invoice',
'verbose_name_plural': 'invoices',
},
),
migrations.CreateModel(
name='Remittance',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Date')),
('comment', models.CharField(max_length=255, verbose_name='Comment')),
('closed', models.BooleanField(default=False, verbose_name='Closed')),
],
options={
'verbose_name': 'remittance',
'verbose_name_plural': 'remittances',
},
),
migrations.CreateModel(
name='SpecialTransactionProxy',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('remittance', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='treasury.Remittance', verbose_name='Remittance')),
('transaction', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='note.SpecialTransaction')),
],
options={
'verbose_name': 'special transaction proxy',
'verbose_name_plural': 'special transaction proxies',
},
),
migrations.CreateModel(
name='SogeCredit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('credit_transaction', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, to='note.SpecialTransaction', verbose_name='credit transaction')),
('transactions', models.ManyToManyField(related_name='_sogecredit_transactions_+', to='note.MembershipTransaction', verbose_name='membership transactions')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
options={
'verbose_name': 'Credit from the Société générale',
'verbose_name_plural': 'Credits from the Société générale',
},
),
migrations.CreateModel(
name='RemittanceType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('note', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='note.NoteSpecial')),
],
options={
'verbose_name': 'remittance type',
'verbose_name_plural': 'remittance types',
},
),
migrations.AddField(
model_name='remittance',
name='remittance_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='treasury.RemittanceType', verbose_name='Type'),
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('designation', models.CharField(max_length=255, verbose_name='Designation')),
('quantity', models.PositiveIntegerField(verbose_name='Quantity')),
('amount', models.IntegerField(verbose_name='Unit price')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='treasury.Invoice', verbose_name='invoice')),
],
options={
'verbose_name': 'product',
'verbose_name_plural': 'products',
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.16 on 2020-09-06 13:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('treasury', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='invoice',
name='bde',
field=models.CharField(choices=[('Saperlistpopette', 'Saper[list]popette'), ('Finalist', 'Fina[list]'), ('Listorique', '[List]orique'), ('Satellist', 'Satel[list]'), ('Monopolist', 'Monopo[list]'), ('Kataclist', 'Katac[list]')], default='Saperlistpopette', max_length=32, verbose_name='BDE'),
),
]

View File

@ -25,14 +25,14 @@ class Invoice(models.Model):
bde = models.CharField(
max_length=32,
default='Saperlistpopette.png',
default='Saperlistpopette',
choices=(
('Saperlistpopette.png', 'Saper[list]popette'),
('Finalist.png', 'Fina[list]'),
('Listorique.png', '[List]orique'),
('Satellist.png', 'Satel[list]'),
('Monopolist.png', 'Monopo[list]'),
('Kataclist.png', 'Katac[list]'),
('Saperlistpopette', 'Saper[list]popette'),
('Finalist', 'Fina[list]'),
('Listorique', '[List]orique'),
('Satellist', 'Satel[list]'),
('Monopolist', 'Monopo[list]'),
('Kataclist', 'Katac[list]'),
),
verbose_name=_("BDE"),
)
@ -85,7 +85,7 @@ class Invoice(models.Model):
old_invoice = Invoice.objects.filter(id=self.id)
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."))
products = self.products.all()
@ -109,6 +109,9 @@ class Invoice(models.Model):
verbose_name = _("invoice")
verbose_name_plural = _("invoices")
def __str__(self):
return _("Invoice #{id}").format(id=self.id)
class Product(models.Model):
"""
@ -151,6 +154,9 @@ class Product(models.Model):
verbose_name = _("product")
verbose_name_plural = _("products")
def __str__(self):
return f"{self.designation} ({self.invoice})"
class RemittanceType(models.Model):
"""
@ -224,7 +230,7 @@ class Remittance(models.Model):
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
# 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")
return super().save(force_insert, force_update, using, update_fields)
@ -256,6 +262,9 @@ class SpecialTransactionProxy(models.Model):
verbose_name = _("special transaction proxy")
verbose_name_plural = _("special transaction proxies")
def __str__(self):
return str(self.transaction)
class SogeCredit(models.Model):
"""
@ -282,11 +291,12 @@ class SogeCredit(models.Model):
@property
def valid(self):
return self.credit_transaction is not None
return self.credit_transaction.valid
@property
def amount(self):
return sum(transaction.total for transaction in self.transactions.all())
return self.credit_transaction.total if self.valid \
else sum(transaction.total for transaction in self.transactions.all()) + 8000
def invalidate(self):
"""
@ -295,11 +305,7 @@ class SogeCredit(models.Model):
"""
if self.valid:
self.credit_transaction.valid = False
self.credit_transaction._force_save = True
self.credit_transaction.save()
self.credit_transaction._force_delete = True
self.credit_transaction.delete()
self.credit_transaction = None
for transaction in self.transactions.all():
transaction.valid = False
transaction._force_save = True
@ -312,17 +318,10 @@ class SogeCredit(models.Model):
# First invalidate all transaction and delete the credit if already did (and force mode)
self.invalidate()
self.credit_transaction = SpecialTransaction.objects.create(
source=NoteSpecial.objects.get(special_type="Virement bancaire"),
destination=self.user.note,
quantity=1,
amount=self.amount,
reason="Crédit société générale",
last_name=self.user.last_name,
first_name=self.user.first_name,
bank="Société générale",
created_at=self.transactions.order_by("-created_at").first().created_at,
)
# Refresh credit amount
self.save()
self.credit_transaction.valid = True
self.credit_transaction.save()
self.save()
for transaction in self.transactions.all():
@ -331,6 +330,25 @@ class SogeCredit(models.Model):
transaction.created_at = timezone.now()
transaction.save()
def save(self, *args, **kwargs):
if not self.credit_transaction:
self.credit_transaction = SpecialTransaction.objects.create(
source=NoteSpecial.objects.get(special_type="Virement bancaire"),
destination=self.user.note,
quantity=1,
amount=0,
reason="Crédit société générale",
last_name=self.user.last_name,
first_name=self.user.first_name,
bank="Société générale",
valid=False,
)
elif not self.valid:
self.credit_transaction.amount = self.amount
self.credit_transaction._force_save = True
self.credit_transaction.save()
super().save(*args, **kwargs)
def delete(self, **kwargs):
"""
Deleting a SogeCredit is equivalent to say that the Société générale didn't pay.
@ -349,8 +367,14 @@ class SogeCredit(models.Model):
transaction.valid = True
transaction.created_at = timezone.now()
transaction.save()
self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)"
self.credit_transaction.save()
super().delete(**kwargs)
class Meta:
verbose_name = _("Credit from the Société générale")
verbose_name_plural = _("Credits from the Société générale")
def __str__(self):
return _("Soge credit for {user}").format(user=str(self.user))

View File

@ -9,9 +9,10 @@ def save_special_transaction(instance, created, **kwargs):
When a special transaction is created, we create its linked proxy
"""
if instance.is_credit():
if created and RemittanceType.objects.filter(note=instance.source).exists():
SpecialTransactionProxy.objects.create(transaction=instance, remittance=None).save()
else:
if created and RemittanceType.objects.filter(note=instance.destination).exists():
SpecialTransactionProxy.objects.create(transaction=instance, remittance=None).save()
if not hasattr(instance, "_no_signal"):
if instance.is_credit():
if created and RemittanceType.objects.filter(note=instance.source).exists():
SpecialTransactionProxy.objects.create(transaction=instance, remittance=None).save()
else:
if created and RemittanceType.objects.filter(note=instance.destination).exists():
SpecialTransactionProxy.objects.create(transaction=instance, remittance=None).save()

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

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

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %}
<div class="row">
<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">
{% trans "Invoice" %}s
</a>

View File

@ -1,19 +1,13 @@
{% load escape_tex %}
\documentclass[a4paper,11pt]{article}
\nonstopmode
\documentclass[11pt]{article}
\usepackage[french]{babel}
\usepackage[T1]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage[a4paper]{geometry}
\usepackage{units}
\usepackage{bera}
\usepackage{fontspec}
\usepackage{geometry}
\usepackage{graphicx}
\usepackage{fancyhdr}
\usepackage{fp}
\usepackage{transparent}
\usepackage{eso-pic}
\usepackage{ifthen}
\def\TVA{0} % Taux de la TVA
@ -58,7 +52,7 @@
\parbox[b][\paperheight]{\paperwidth}{%
\vfill
\centering
{\transparent{0.1}\includegraphics[width=\textwidth]{../../static/img/{{ obj.bde }}}}%
\includegraphics[width=\textwidth]{../../apps/treasury/static/img/{{ obj.bde }}_bg.jpg}
\vfill
}
}

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %}
<div class="row">
<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">
{% trans "Invoice" %}s
</a>

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block content %}
<div class="row">
<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">
{% trans "Invoice" %}s
</a>
@ -30,7 +30,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
<div class="form-check">
<label for="invalid_only" class="form-check-label">
<input id="invalid_only" name="invalid_only" type="checkbox" class="checkboxinput form-check-input">
<input id="invalid_only" name="invalid_only" type="checkbox" class="checkboxinput form-check-input" checked>
{% trans "Filter with unvalidated credits only" %}
</label>
</div>
@ -59,9 +59,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
function reloadTable() {
let pattern = searchbar_obj.val();
if (pattern === old_pattern || pattern === "")
return;
$("#credits_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + (
invalid_only_obj.is(':checked') ? "&valid=false" : "") + " #credits_table");

View File

View File

@ -0,0 +1,401 @@
# 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(
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(), 4)
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
def get_form(self, form_class=None):
form = super().get_form(form_class)
del form.fields["locked"]
return form
def form_valid(self, form):
ret = super().form_valid(form)
@ -134,6 +139,11 @@ class InvoiceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
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):
ret = super().form_valid(form)
@ -165,6 +175,11 @@ class InvoiceDeleteView(ProtectQuerysetMixin, LoginRequiredMixin, DeleteView):
model = 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):
return reverse_lazy('treasury:invoice_list')
@ -194,7 +209,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
# The file has to be rendered twice
for ignored in range(2):
error = subprocess.Popen(
["pdflatex", "invoice-{}.tex".format(pk)],
["/usr/bin/xelatex", "-interaction=nonstopmode", "invoice-{}.tex".format(pk)],
cwd=tmp_dir,
stdin=open(os.devnull, "r"),
stderr=open(os.devnull, "wb"),
@ -202,7 +217,9 @@ class InvoiceRenderView(LoginRequiredMixin, View):
).wait()
if error:
raise IOError("An error attempted while generating a invoice (code=" + str(error) + ")")
with open("{}/invoice-{:d}.log".format(tmp_dir, pk), "r") as f:
log = f.read()
raise IOError("An error attempted while generating a invoice (code=" + str(error) + ")\n\n" + log)
# Display the generated pdf as a HTTP Response
pdf = open("{}/invoice-{}.pdf".format(tmp_dir, pk), 'rb').read()
@ -387,7 +404,7 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
if not request.user.is_authenticated:
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."))
return super().dispatch(request, *args, **kwargs)
@ -408,11 +425,8 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
)
if "valid" in self.request.GET:
q = Q(credit_transaction=None)
if not self.request.GET["valid"]:
q = ~q
qs = qs.filter(q)
if "valid" not in self.request.GET or not self.request.GET["valid"]:
qs = qs.filter(credit_transaction__valid=False)
return qs[:20]

View File

@ -1,59 +0,0 @@
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from django.core.management import BaseCommand
from django.db.models import Q
from member.models import Membership, Club
from wei.models import WEIClub
class Command(BaseCommand):
help = "Get mailing list registrations from the last wei. " \
"Usage: manage.py extract_ml_registrations -t {events,art,sport}. " \
"You can write this into a file with a pipe, then paste the document into your mail manager."
def add_arguments(self, parser):
parser.add_argument('--type', '-t', choices=["members", "clubs", "events", "art", "sport"], default="members",
help='Select the type of the mailing list (default members)')
parser.add_argument('--year', '-y', type=int, default=None,
help='Select the year of the concerned WEI. Default: last year')
def handle(self, *args, **options):
###########################################################
# WARNING #
###########################################################
#
# This code is obsolete.
# TODO: Improve the mailing list extraction system, and link it automatically with Mailman.
if options["type"] == "members":
for membership in Membership.objects.filter(
club__name="BDE",
date_start__lte=date.today(),
date_end__gte=date.today(),
).all():
self.stdout.write(membership.user.email)
return
if options["type"] == "clubs":
for club in Club.objects.all():
self.stdout.write(club.email)
return
if options["year"] is None:
wei = WEIClub.objects.order_by('-year').first()
else:
wei = WEIClub.objects.filter(year=options["year"])
if wei.exists():
wei = wei.get()
else:
wei = WEIClub.objects.order_by('-year').first()
self.stderr.write(self.style.WARNING("Warning: there was no WEI in year " + str(options["year"]) + ". "
+ "Assuming the last WEI (year " + str(wei.year) + ")"))
q = Q(ml_events_registration=True) if options["type"] == "events" else Q(ml_art_registration=True)\
if options["type"] == "art" else Q(ml_sport_registration=True)
registrations = wei.users.filter(q)
for registration in registrations.all():
self.stdout.write(registration.user.email)

View File

@ -0,0 +1,120 @@
# Generated by Django 2.2.16 on 2020-09-04 21:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import phonenumber_field.modelfields
class Migration(migrations.Migration):
initial = True
dependencies = [
('permission', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('member', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Bus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('description', models.TextField(blank=True, default='', verbose_name='description')),
('information_json', models.TextField(default='{}', help_text='Information about the survey for new members, encoded in JSON', verbose_name='survey information')),
],
options={
'verbose_name': 'Bus',
'verbose_name_plural': 'Buses',
},
),
migrations.CreateModel(
name='BusTeam',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='name')),
('color', models.PositiveIntegerField(help_text='The color of the T-Shirt, stored with its number equivalent', verbose_name='color')),
('description', models.TextField(blank=True, default='', verbose_name='description')),
('bus', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='wei.Bus', verbose_name='bus')),
],
options={
'verbose_name': 'Bus team',
'verbose_name_plural': 'Bus teams',
'unique_together': {('bus', 'name')},
},
),
migrations.CreateModel(
name='WEIClub',
fields=[
('club_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='member.Club')),
('year', models.PositiveIntegerField(default=2020, unique=True, verbose_name='year')),
('date_start', models.DateField(verbose_name='date start')),
('date_end', models.DateField(verbose_name='date end')),
],
options={
'verbose_name': 'WEI',
'verbose_name_plural': 'WEI',
},
bases=('member.club',),
),
migrations.CreateModel(
name='WEIRole',
fields=[
('role_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='permission.Role')),
],
options={
'verbose_name': 'WEI Role',
'verbose_name_plural': 'WEI Roles',
},
bases=('permission.role',),
),
migrations.CreateModel(
name='WEIRegistration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('soge_credit', models.BooleanField(default=False, verbose_name='Credit from Société générale')),
('caution_check', models.BooleanField(default=False, verbose_name='Caution check given')),
('birth_date', models.DateField(verbose_name='birth date')),
('gender', models.CharField(choices=[('male', 'Male'), ('female', 'Female'), ('nonbinary', 'Non binary')], max_length=16, verbose_name='gender')),
('clothing_cut', models.CharField(choices=[('male', 'Male'), ('female', 'Female')], max_length=16, verbose_name='clothing cut')),
('clothing_size', models.CharField(choices=[('XS', 'XS'), ('S', 'S'), ('M', 'M'), ('L', 'L'), ('XL', 'XL'), ('XXL', 'XXL')], max_length=4, verbose_name='clothing size')),
('health_issues', models.TextField(blank=True, default='', verbose_name='health issues')),
('emergency_contact_name', models.CharField(max_length=255, verbose_name='emergency contact name')),
('emergency_contact_phone', phonenumber_field.modelfields.PhoneNumberField(max_length=32, region=None, verbose_name='emergency contact phone')),
('first_year', models.BooleanField(default=False, help_text='Tells if the user is new in the school.', verbose_name='first year')),
('information_json', models.TextField(default='{}', help_text='Information about the registration (buses for old members, survey fot the new members), encoded in JSON', verbose_name='registration information')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wei', to=settings.AUTH_USER_MODEL, verbose_name='user')),
('wei', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='users', to='wei.WEIClub', verbose_name='WEI')),
],
options={
'verbose_name': 'WEI User',
'verbose_name_plural': 'WEI Users',
'unique_together': {('user', 'wei')},
},
),
migrations.CreateModel(
name='WEIMembership',
fields=[
('membership_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='member.Membership')),
('bus', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='wei.Bus', verbose_name='bus')),
('registration', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='membership', to='wei.WEIRegistration', verbose_name='WEI registration')),
('team', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='memberships', to='wei.BusTeam', verbose_name='team')),
],
options={
'verbose_name': 'WEI membership',
'verbose_name_plural': 'WEI memberships',
},
bases=('member.membership',),
),
migrations.AddField(
model_name='bus',
name='wei',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='buses', to='wei.WEIClub', verbose_name='WEI'),
),
migrations.AlterUniqueTogether(
name='bus',
unique_together={('wei', 'name')},
),
]

View File

@ -1,15 +1,11 @@
\documentclass[landscape,10pt]{article}
\usepackage[utf8]{inputenc}
\usepackage[T1]{fontenc}
\usepackage[french]{babel}
\documentclass[a4paper,landscape,10pt]{article}
\usepackage{fontspec}
\usepackage[margin=1.5cm]{geometry}
\usepackage{lmodern}
\begin{document}
\begin{center}
\huge{Liste des inscrits \og {{ wei.name }} \fg{}}
\huge{Liste des inscrits « {{ wei.name }} »}
{% if bus %}
\LARGE{Bus {{ bus.name|safe }}}

View File

@ -690,7 +690,7 @@ class TestWEIRegistration(TestCase):
"""
with open("/dev/null", "wb") as devnull:
return subprocess.call(
["which", "pdflatex"],
["/usr/bin/which", "xelatex"],
stdout=devnull,
stderr=devnull,
) == 0

Some files were not shown because too many files have changed in this diff Show More