Merge branch 'beta' into traduction
69
apps/activity/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
89
apps/activity/migrations/0002_auto_20200904_2341.py
Normal 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')},
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
@ -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>
|
||||
|
175
apps/activity/tests/test_activities.py
Normal 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)
|
@ -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'),
|
||||
]
|
||||
|
@ -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")
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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")
|
||||
|
37
apps/logs/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
17
apps/logs/migrations/0002_replace_null_by_blank.py
Normal 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;"
|
||||
),
|
||||
]
|
23
apps/logs/migrations/0003_remove_null_tag_on_charfields.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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))
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
@ -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):
|
||||
|
76
apps/member/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
42
apps/member/migrations/0002_auto_20200904_2341.py
Normal 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'),
|
||||
),
|
||||
]
|
57
apps/member/migrations/0003_create_bde_and_kfet.py
Normal 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),
|
||||
]
|
20
apps/member/migrations/0004_replace_null_by_blank.py
Normal 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;",
|
||||
),
|
||||
]
|
28
apps/member/migrations/0005_remove_null_tag_on_charfields.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
BIN
apps/member/static/member/img/default_picture.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
@ -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)
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends "member/base.html" %}
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
|
@ -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 %}
|
||||
|
@ -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)
|
||||
|
405
apps/member/tests/test_memberships.py
Normal 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))
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
209
apps/note/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
25
apps/note/migrations/0002_create_special_notes.py
Normal 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),
|
||||
]
|
17
apps/note/migrations/0003_replace_null_by_blank.py
Normal 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;"
|
||||
),
|
||||
]
|
23
apps/note/migrations/0004_remove_null_tag_on_charfields.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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):
|
||||
"""
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
262
apps/note/static/note/js/consos.js
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
422
apps/note/static/note/js/transfer.js
Normal 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
|
||||
})
|
||||
}
|
||||
})
|
@ -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={
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
32
apps/note/templates/note/mails/weekly_report.txt
Normal 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" %}
|
@ -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 %}
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
]
|
||||
}
|
||||
},
|
||||
|
71
apps/permission/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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': [],
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
44
apps/permission/tests/test_rights_page.py
Normal 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"])
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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>
|
||||
|
@ -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" %}
|
||||
|
0
apps/registration/tests/__init__.py
Normal file
386
apps/registration/tests/test_registration.py
Normal 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)
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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', )
|
||||
|
107
apps/treasury/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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))
|
||||
|
@ -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()
|
||||
|
BIN
apps/treasury/static/img/Finalist.png
Normal file
After Width: | Height: | Size: 752 KiB |
BIN
apps/treasury/static/img/Finalist_bg.jpg
Normal file
After Width: | Height: | Size: 63 KiB |
BIN
apps/treasury/static/img/Kataclist.png
Normal file
After Width: | Height: | Size: 664 KiB |
BIN
apps/treasury/static/img/Kataclist_bg.jpg
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
apps/treasury/static/img/Listorique.png
Normal file
After Width: | Height: | Size: 414 KiB |
BIN
apps/treasury/static/img/Listorique_bg.jpg
Normal file
After Width: | Height: | Size: 58 KiB |
BIN
apps/treasury/static/img/Monopolist.png
Normal file
After Width: | Height: | Size: 375 KiB |
BIN
apps/treasury/static/img/Monopolist_bg.jpg
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
apps/treasury/static/img/Saperlistpopette.png
Normal file
After Width: | Height: | Size: 2.6 MiB |
BIN
apps/treasury/static/img/Saperlistpopette_bg.jpg
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
apps/treasury/static/img/Satellist.png
Normal file
After Width: | Height: | Size: 202 KiB |
BIN
apps/treasury/static/img/Satellist_bg.jpg
Normal file
After Width: | Height: | Size: 29 KiB |
@ -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={
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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");
|
||||
|
||||
|
0
apps/treasury/tests/__init__.py
Normal file
401
apps/treasury/tests/test_treasury.py
Normal 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)
|
@ -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]
|
||||
|
||||
|
@ -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)
|
120
apps/wei/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
@ -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 }}}
|
||||
|
@ -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
|
||||
|