Merge branch 'beta' into 'master'

Add animated profile picture support

See merge request bde/nk20!116
This commit is contained in:
ynerant 2020-09-07 21:48:28 +02:00
commit 9b8caa7fa1
45 changed files with 842 additions and 501 deletions

View File

@ -188,6 +188,12 @@ class Entry(models.Model):
verbose_name = _("entry") verbose_name = _("entry")
verbose_name_plural = _("entries") 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): def save(self, *args, **kwargs):
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest) qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
import io import io
from PIL import Image from PIL import Image, ImageSequence
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm from django.contrib.auth.forms import AuthenticationForm
@ -20,7 +20,7 @@ from .models import Profile, Club, Membership
class CustomAuthenticationForm(AuthenticationForm): class CustomAuthenticationForm(AuthenticationForm):
permission_mask = forms.ModelChoiceField( permission_mask = forms.ModelChoiceField(
label="Masque de permissions", label=_("Permission mask"),
queryset=PermissionMask.objects.order_by("rank"), queryset=PermissionMask.objects.order_by("rank"),
empty_label=None, empty_label=None,
) )
@ -82,13 +82,19 @@ class ImageForm(forms.Form):
height = forms.FloatField(widget=forms.HiddenInput()) height = forms.FloatField(widget=forms.HiddenInput())
def clean(self): def clean(self):
"""Load image and crop""" """
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() cleaned_data = super().clean()
# Image size is limited by Django DATA_UPLOAD_MAX_MEMORY_SIZE # Image size is limited by Django DATA_UPLOAD_MAX_MEMORY_SIZE
image = cleaned_data.get('image') image = cleaned_data.get('image')
if image: if image:
# Let Pillow detect and load image # Let Pillow detect and load image
# If it is an animation, then there will be multiple frames
try: try:
im = Image.open(image) im = Image.open(image)
except OSError: except OSError:
@ -96,20 +102,30 @@ class ImageForm(forms.Form):
# but Pil is unable to load it # but Pil is unable to load it
raise forms.ValidationError(_('This image cannot be loaded.')) raise forms.ValidationError(_('This image cannot be loaded.'))
# Crop image # Crop each frame
x = cleaned_data.get('x', 0) x = cleaned_data.get('x', 0)
y = cleaned_data.get('y', 0) y = cleaned_data.get('y', 0)
w = cleaned_data.get('width', 200) w = cleaned_data.get('width', 200)
h = cleaned_data.get('height', 200) h = cleaned_data.get('height', 200)
im = im.crop((x, y, x + w, y + h)) frames = []
im = im.resize( 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), (settings.PIC_WIDTH, settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS, Image.ANTIALIAS,
) )
frames.append(frame)
# Save # Save
om = frames.pop(0) # Get first frame
om.info = im.info # Copy metadata
image.file = io.BytesIO() image.file = io.BytesIO()
im.save(image.file, "PNG") 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 return cleaned_data

View File

@ -27,8 +27,8 @@ def create_bde_and_kfet(apps, schema_editor):
parent_club_id=1, parent_club_id=1,
email="tresorerie.bde@example.com", email="tresorerie.bde@example.com",
require_memberships=True, require_memberships=True,
membership_fee_paid=500, membership_fee_paid=3500,
membership_fee_unpaid=500, membership_fee_unpaid=3500,
membership_duration=396, membership_duration=396,
membership_start="2020-08-01", membership_start="2020-08-01",
membership_end="2021-09-30", membership_end="2021-09-30",

View File

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

View File

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

View File

@ -46,7 +46,7 @@ class Profile(models.Model):
help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'), help_text=_('e.g. "1A0", "9A♥", "SAPHIRE"'),
max_length=255, max_length=255,
blank=True, blank=True,
null=True, default="",
) )
department = models.CharField( department = models.CharField(
@ -83,7 +83,7 @@ class Profile(models.Model):
verbose_name=_('address'), verbose_name=_('address'),
max_length=255, max_length=255,
blank=True, blank=True,
null=True, default="",
) )
paid = models.BooleanField( paid = models.BooleanField(
@ -94,11 +94,10 @@ class Profile(models.Model):
ml_events_registration = models.CharField( ml_events_registration = models.CharField(
blank=True, blank=True,
null=True, default='',
default=None,
max_length=2, max_length=2,
choices=[ choices=[
(None, _("No")), ('', _("No")),
('fr', _("Yes (receive them in french)")), ('fr', _("Yes (receive them in french)")),
('en', _("Yes (receive them in english)")), ('en', _("Yes (receive them in english)")),
], ],

View File

@ -6,7 +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 Hook to create and save a profile when an user is updated if it is not registered with the signup form
""" """
if not raw and created and instance.is_active: if not raw and created and instance.is_active and not hasattr(instance, "_no_signal"):
from .models import Profile from .models import Profile
Profile.objects.get_or_create(user=instance) Profile.objects.get_or_create(user=instance)
if instance.is_superuser: if instance.is_superuser:

View File

@ -48,7 +48,7 @@
<dd class="col-xl-6"> <dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}"> <a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}">
<i class="fa fa-edit"></i> <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> </a>
</dd> </dd>

View File

@ -138,7 +138,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
""" """
We can't display information of a not registered user. We can't display information of a not registered user.
""" """
return super().get_queryset().filter(profile__registration_valid=True) return super().get_queryset(**kwargs).filter(profile__registration_valid=True)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" """
@ -271,9 +271,17 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
def form_valid(self, form): def form_valid(self, form):
"""Save image to note""" """Save image to note"""
image_field = form.cleaned_data['image'] image = form.cleaned_data['image']
image_field.name = "{}_pic.png".format(self.object.note.pk)
self.object.note.display_image = image_field # 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() self.object.note.save()
return super().form_valid(form) return super().form_valid(form)

View File

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

View File

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

View File

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

View File

@ -70,8 +70,8 @@ class Note(PolymorphicModel):
"It can be reactivated at any time.")), "It can be reactivated at any time.")),
('forced', _("The note is blocked by the the BDE and can't be manually reactivated.")), ('forced', _("The note is blocked by the the BDE and can't be manually reactivated.")),
], ],
null=True, blank=True,
default=None, default="",
) )
class Meta: class Meta:

View File

@ -90,6 +90,9 @@ class TransactionTemplate(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('note:template_update', args=(self.pk,)) return reverse('note:template_update', args=(self.pk,))
def __str__(self):
return self.name
class Transaction(PolymorphicModel): class Transaction(PolymorphicModel):
""" """
@ -150,8 +153,7 @@ class Transaction(PolymorphicModel):
invalidity_reason = models.CharField( invalidity_reason = models.CharField(
verbose_name=_('invalidity reason'), verbose_name=_('invalidity reason'),
max_length=255, max_length=255,
default=None, default='',
null=True,
blank=True, blank=True,
) )
@ -173,7 +175,7 @@ class Transaction(PolymorphicModel):
created = self.pk is None created = self.pk is None
to_transfer = self.amount * self.quantity 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 # Revert old transaction
old_transaction = Transaction.objects.get(pk=self.pk) old_transaction = Transaction.objects.get(pk=self.pk)
# Check that nothing important changed # 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 # When a transaction is declared valid, we ensure that the invalidity reason is null, if it was
# previously invalid # previously invalid
self.invalidity_reason = None self.invalidity_reason = ""
if source_balance > 9223372036854775807 or source_balance < -9223372036854775808\ if source_balance > 9223372036854775807 or source_balance < -9223372036854775808\
or dest_balance > 9223372036854775807 or dest_balance < -9223372036854775808: or dest_balance > 9223372036854775807 or dest_balance < -9223372036854775808:
@ -242,14 +244,6 @@ class Transaction(PolymorphicModel):
self.destination._force_save = True self.destination._force_save = True
self.destination.save() 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 @property
def total(self): def total(self):
return self.amount * self.quantity return self.amount * self.quantity

View File

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

View File

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

View File

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

View File

@ -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..." %}" /> <input class="form-control mx-auto" type="text" id="source_note" placeholder="{% trans "Name or alias..." %}" />
<div id="source_me_div"> <div id="source_me_div">
<hr> <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" %} {% trans "I am the emitter" %}
</span> </a>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -28,7 +28,7 @@ def pre_save_object(sender, instance, **kwargs):
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED:
return return
if hasattr(instance, "_force_save"): if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"):
return return
user = get_current_authenticated_user() user = get_current_authenticated_user()
@ -82,7 +82,8 @@ def pre_delete_object(instance, **kwargs):
if instance._meta.label_lower in EXCLUDED: if instance._meta.label_lower in EXCLUDED:
return 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 # Don't check permissions on force-deleted objects
return return

View File

@ -78,7 +78,6 @@ class PermissionQueryTestCase(TestCase):
query = instanced.query query = instanced.query
model = perm.model.model_class() model = perm.model.model_class()
model.objects.filter(query).all() model.objects.filter(query).all()
# print("Good query for permission", perm)
except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError): except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError):
print("Query error for permission", perm) print("Query error for permission", perm)
print("Query:", perm.query) print("Query:", perm.query)

View File

@ -8,6 +8,7 @@ from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import Q from django.db.models import Q
from django.forms import HiddenInput from django.forms import HiddenInput
from django.http import Http404
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView, TemplateView, CreateView from django.views.generic import UpdateView, TemplateView, CreateView
from member.models import Membership 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 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). 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) 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): def get_form(self, form_class=None):
form = super().get_form(form_class) form = super().get_form(form_class)

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@
</p> </p>
<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>
<p> <p>

View File

@ -8,7 +8,7 @@ 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 "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 "Thanks" %},

View File

@ -364,7 +364,7 @@ class TestValidateRegistration(TestCase):
self.assertTrue(Membership.objects.filter(club__name="Kfet", 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.assertTrue(SogeCredit.objects.filter(user=self.user).exists())
self.assertEqual(Transaction.objects.filter( self.assertEqual(Transaction.objects.filter(
Q(source=self.user.note) | Q(destination=self.user.note)).count(), 2) Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
self.assertFalse(Transaction.objects.filter(valid=True).exists()) self.assertFalse(Transaction.objects.filter(valid=True).exists())
response = self.client.get(self.user.profile.get_absolute_url()) response = self.client.get(self.user.profile.get_absolute_url())

View File

@ -21,6 +21,7 @@ from note.templatetags.pretty_money import pretty_money
from permission.backends import PermissionBackend from permission.backends import PermissionBackend
from permission.models import Role from permission.models import Role
from permission.views import ProtectQuerysetMixin from permission.views import ProtectQuerysetMixin
from treasury.models import SogeCredit
from .forms import SignUpForm, ValidationForm from .forms import SignUpForm, ValidationForm
from .tables import FutureUserTable from .tables import FutureUserTable
@ -219,6 +220,9 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid
kfet = Club.objects.get(name="Kfet") kfet = Club.objects.get(name="Kfet")
fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid 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, ) ctx["total_fee"] = "{:.02f}".format(fee / 100, )
return ctx return ctx
@ -342,6 +346,11 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin,
membership.roles.add(Role.objects.get(name="Adhérent Kfet")) membership.roles.add(Role.objects.get(name="Adhérent Kfet"))
membership.save() membership.save()
if soge:
soge_credit = SogeCredit.objects.get(user=user)
# Update the credit transaction amount
soge_credit.save()
return ret return ret
def get_success_url(self): def get_success_url(self):

@ -1 +1 @@
Subproject commit 4f5a794798a48cbbf10b42f0a519743fcbb96c33 Subproject commit e5b76b7c35592aba4225115f933f2a7ed3a66df3

View File

@ -109,6 +109,9 @@ class Invoice(models.Model):
verbose_name = _("invoice") verbose_name = _("invoice")
verbose_name_plural = _("invoices") verbose_name_plural = _("invoices")
def __str__(self):
return _("Invoice #{id}").format(id=self.id)
class Product(models.Model): class Product(models.Model):
""" """
@ -151,6 +154,9 @@ class Product(models.Model):
verbose_name = _("product") verbose_name = _("product")
verbose_name_plural = _("products") verbose_name_plural = _("products")
def __str__(self):
return f"{self.designation} ({self.invoice})"
class RemittanceType(models.Model): class RemittanceType(models.Model):
""" """
@ -256,6 +262,9 @@ class SpecialTransactionProxy(models.Model):
verbose_name = _("special transaction proxy") verbose_name = _("special transaction proxy")
verbose_name_plural = _("special transaction proxies") verbose_name_plural = _("special transaction proxies")
def __str__(self):
return str(self.transaction)
class SogeCredit(models.Model): class SogeCredit(models.Model):
""" """
@ -282,11 +291,11 @@ class SogeCredit(models.Model):
@property @property
def valid(self): def valid(self):
return self.credit_transaction is not None return self.credit_transaction.valid
@property @property
def amount(self): def amount(self):
return sum(transaction.total for transaction in self.transactions.all()) return sum(transaction.total for transaction in self.transactions.all()) + 8000
def invalidate(self): def invalidate(self):
""" """
@ -295,11 +304,7 @@ class SogeCredit(models.Model):
""" """
if self.valid: if self.valid:
self.credit_transaction.valid = False self.credit_transaction.valid = False
self.credit_transaction._force_save = True
self.credit_transaction.save() self.credit_transaction.save()
self.credit_transaction._force_delete = True
self.credit_transaction.delete()
self.credit_transaction = None
for transaction in self.transactions.all(): for transaction in self.transactions.all():
transaction.valid = False transaction.valid = False
transaction._force_save = True transaction._force_save = True
@ -312,17 +317,10 @@ class SogeCredit(models.Model):
# First invalidate all transaction and delete the credit if already did (and force mode) # First invalidate all transaction and delete the credit if already did (and force mode)
self.invalidate() self.invalidate()
self.credit_transaction = SpecialTransaction.objects.create( # Refresh credit amount
source=NoteSpecial.objects.get(special_type="Virement bancaire"), self.save()
destination=self.user.note, self.credit_transaction.valid = True
quantity=1, self.credit_transaction.save()
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,
)
self.save() self.save()
for transaction in self.transactions.all(): for transaction in self.transactions.all():
@ -331,6 +329,25 @@ class SogeCredit(models.Model):
transaction.created_at = timezone.now() transaction.created_at = timezone.now()
transaction.save() 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): def delete(self, **kwargs):
""" """
Deleting a SogeCredit is equivalent to say that the Société générale didn't pay. Deleting a SogeCredit is equivalent to say that the Société générale didn't pay.
@ -349,8 +366,14 @@ class SogeCredit(models.Model):
transaction.valid = True transaction.valid = True
transaction.created_at = timezone.now() transaction.created_at = timezone.now()
transaction.save() transaction.save()
self.credit_transaction.valid = False
self.credit_transaction.reason += " (invalide)"
self.credit_transaction.save()
super().delete(**kwargs) super().delete(**kwargs)
class Meta: class Meta:
verbose_name = _("Credit from the Société générale") verbose_name = _("Credit from the Société générale")
verbose_name_plural = _("Credits from the Société générale") verbose_name_plural = _("Credits from the Société générale")
def __str__(self):
return _("Soge credit for {user}").format(user=str(self.user))

View File

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

View File

@ -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 ..."> <input id="searchbar" type="text" class="form-control" placeholder="Nom/prénom/note ...">
<div class="form-check"> <div class="form-check">
<label for="invalid_only" class="form-check-label"> <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" %} {% trans "Filter with unvalidated credits only" %}
</label> </label>
</div> </div>

View File

@ -353,7 +353,6 @@ class TestSogeCredits(TestCase):
soge_credit.refresh_from_db() soge_credit.refresh_from_db()
self.assertTrue(soge_credit.valid) self.assertTrue(soge_credit.valid)
self.user.note.refresh_from_db() self.user.note.refresh_from_db()
self.assertEqual(self.user.note.balance, 0)
self.assertEqual( self.assertEqual(
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
self.assertTrue(self.user.profile.soge) self.assertTrue(self.user.profile.soge)
@ -391,7 +390,7 @@ class TestSogeCredits(TestCase):
self.user.note.refresh_from_db() self.user.note.refresh_from_db()
self.assertEqual(self.user.note.balance, 0) self.assertEqual(self.user.note.balance, 0)
self.assertEqual( self.assertEqual(
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3) Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 4)
self.assertFalse(self.user.profile.soge) self.assertFalse(self.user.profile.soge)
def test_invoice_api(self): def test_invoice_api(self):

View File

@ -209,7 +209,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
# The file has to be rendered twice # The file has to be rendered twice
for ignored in range(2): for ignored in range(2):
error = subprocess.Popen( error = subprocess.Popen(
["xelatex", "-interaction=nonstopmode", "invoice-{}.tex".format(pk)], ["/usr/bin/xelatex", "-interaction=nonstopmode", "invoice-{}.tex".format(pk)],
cwd=tmp_dir, cwd=tmp_dir,
stdin=open(os.devnull, "r"), stdin=open(os.devnull, "r"),
stderr=open(os.devnull, "wb"), stderr=open(os.devnull, "wb"),
@ -425,11 +425,8 @@ class SogeCreditListView(LoginRequiredMixin, ProtectQuerysetMixin, SingleTableVi
| Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern)) | Q(user__note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
) )
if "valid" in self.request.GET: if "valid" not in self.request.GET or not self.request.GET["valid"]:
q = Q(credit_transaction=None) qs = qs.filter(credit_transaction__valid=False)
if not self.request.GET["valid"]:
q = ~q
qs = qs.filter(q)
return qs[:20] return qs[:20]

View File

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

View File

@ -1103,7 +1103,7 @@ class MemberListRenderView(LoginRequiredMixin, View):
with open(os.devnull, "wb") as devnull: with open(os.devnull, "wb") as devnull:
error = subprocess.Popen( error = subprocess.Popen(
["xelatex", "-interaction=nonstopmode", "{}/wei-list.tex".format(tmp_dir)], ["/usr/bin/xelatex", "-interaction=nonstopmode", "{}/wei-list.tex".format(tmp_dir)],
cwd=tmp_dir, cwd=tmp_dir,
stderr=devnull, stderr=devnull,
stdout=devnull, stdout=devnull,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@
SPDX-License-Identifier: GPL-3.0-or-later SPDX-License-Identifier: GPL-3.0-or-later
{% endcomment %} {% endcomment %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:"en" }}" class="position-relative h-100"> {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE|default:"en" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %} class="position-relative h-100">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
@ -37,7 +38,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static "js/base.js" %}"></script> <script src="{% static "js/base.js" %}"></script>
<script src="{% static "js/konami.js" %}"></script> <script src="{% static "js/konami.js" %}"></script>
{# Si un formulaire requiert des données supplémentaires (notamment JS), les données sont chargées #} {# If extra ressources are needed for a form, load here #}
{% if form.media %} {% if form.media %}
{{ form.media }} {{ form.media }}
{% endif %} {% endif %}
@ -46,7 +47,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</head> </head>
<body class="d-flex w-100 h-100 flex-column"> <body class="d-flex w-100 h-100 flex-column">
<main class="mb-auto"> <main class="mb-auto">
<nav class="navbar navbar-expand-md navbar-dark bg-primary fixed-navbar shadow-sm"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-navbar shadow-sm">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="/">{{ request.site.name }}</a> <a class="navbar-brand" href="/">{{ request.site.name }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" <button class="navbar-toggler" type="button" data-toggle="collapse"
@ -121,10 +122,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
<div class="dropdown-menu dropdown-menu-right" <div class="dropdown-menu dropdown-menu-right"
aria-labelledby="navbarDropdownMenuLink"> aria-labelledby="navbarDropdownMenuLink">
<a class="dropdown-item" href="{% url 'member:user_detail' pk=request.user.pk %}"> <a class="dropdown-item" href="{% url 'member:user_detail' pk=request.user.pk %}">
<i class="fa fa-user"></i> Mon compte <i class="fa fa-user"></i> {% trans "My account" %}
</a> </a>
<a class="dropdown-item" href="{% url 'logout' %}"> <a class="dropdown-item" href="{% url 'logout' %}">
<i class="fa fa-sign-out"></i> Se déconnecter <i class="fa fa-sign-out"></i> {% trans "Log out" %}
</a> </a>
</div> </div>
</li> </li>
@ -132,14 +133,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if request.path != "/registration/signup/" %} {% if request.path != "/registration/signup/" %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'registration:signup' %}"> <a class="nav-link" href="{% url 'registration:signup' %}">
<i class="fa fa-user-plus"></i> S'inscrire <i class="fa fa-user-plus"></i> {% trans "Sign up" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
{% if request.path != "/accounts/login/" %} {% if request.path != "/accounts/login/" %}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'login' %}"> <a class="nav-link" href="{% url 'login' %}">
<i class="fa fa-sign-in"></i> Se connecter <i class="fa fa-sign-in"></i> {% trans "Log in" %}
</a> </a>
</li> </li>
{% endif %} {% endif %}
@ -168,7 +169,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
class="form-inline"> class="form-inline">
<span class="text-muted mr-1"> <span class="text-muted mr-1">
<a href="mailto:{{ "CONTACT_EMAIL" | getenv }}" <a href="mailto:{{ "CONTACT_EMAIL" | getenv }}"
class="text-muted">Nous contacter</a> &mdash; class="text-muted">{% trans "Contact us" %}</a> &mdash;
</span> </span>
{% csrf_token %} {% csrf_token %}
<select title="language" name="language" <select title="language" name="language"

View File

@ -22,6 +22,7 @@ commands =
deps = deps =
flake8 flake8
flake8-colors flake8-colors
flake8-django
flake8-import-order flake8-import-order
flake8-typing-imports flake8-typing-imports
pep8-naming pep8-naming