mirror of
https://gitlab.crans.org/bde/nk20
synced 2024-11-26 18:37:12 +00:00
Merge branch 'beta' into 'master'
Add animated profile picture support See merge request bde/nk20!116
This commit is contained in:
commit
9b8caa7fa1
@ -188,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)
|
||||
|
17
apps/logs/migrations/0002_replace_null_by_blank.py
Normal file
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
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,7 +50,7 @@ def save_object(sender, instance, **kwargs):
|
||||
in order to store each modification made
|
||||
"""
|
||||
# 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
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
@ -99,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,
|
||||
@ -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
|
||||
"""
|
||||
# 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
|
||||
|
||||
# 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),
|
||||
instance_pk=instance.pk,
|
||||
previous=instance_json,
|
||||
data=None,
|
||||
data="",
|
||||
action="delete"
|
||||
).save()
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageSequence
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
@ -20,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,
|
||||
)
|
||||
@ -82,13 +82,19 @@ class ImageForm(forms.Form):
|
||||
height = forms.FloatField(widget=forms.HiddenInput())
|
||||
|
||||
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()
|
||||
|
||||
# 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:
|
||||
@ -96,20 +102,30 @@ class ImageForm(forms.Form):
|
||||
# but Pil is unable to load it
|
||||
raise forms.ValidationError(_('This image cannot be loaded.'))
|
||||
|
||||
# Crop image
|
||||
# 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)
|
||||
im = im.crop((x, y, x + w, y + h))
|
||||
im = im.resize(
|
||||
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()
|
||||
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
|
||||
|
||||
|
@ -27,8 +27,8 @@ def create_bde_and_kfet(apps, schema_editor):
|
||||
parent_club_id=1,
|
||||
email="tresorerie.bde@example.com",
|
||||
require_memberships=True,
|
||||
membership_fee_paid=500,
|
||||
membership_fee_unpaid=500,
|
||||
membership_fee_paid=3500,
|
||||
membership_fee_unpaid=3500,
|
||||
membership_duration=396,
|
||||
membership_start="2020-08-01",
|
||||
membership_end="2021-09-30",
|
||||
|
20
apps/member/migrations/0004_replace_null_by_blank.py
Normal file
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
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)")),
|
||||
],
|
||||
|
@ -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
|
||||
"""
|
||||
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
|
||||
Profile.objects.get_or_create(user=instance)
|
||||
if instance.is_superuser:
|
||||
|
@ -48,7 +48,7 @@
|
||||
<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>
|
||||
|
||||
|
@ -138,7 +138,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
@ -271,9 +271,17 @@ class PictureUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, Det
|
||||
|
||||
def form_valid(self, form):
|
||||
"""Save image to note"""
|
||||
image_field = form.cleaned_data['image']
|
||||
image_field.name = "{}_pic.png".format(self.object.note.pk)
|
||||
self.object.note.display_image = image_field
|
||||
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)
|
||||
|
||||
|
@ -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',
|
||||
)
|
||||
|
17
apps/note/migrations/0003_replace_null_by_blank.py
Normal file
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
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:
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
if not raw and not hasattr(instance, "_no_signal"):
|
||||
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()
|
||||
|
@ -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
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" %}
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -78,7 +78,6 @@ class PermissionQueryTestCase(TestCase):
|
||||
query = instanced.query
|
||||
model = perm.model.model_class()
|
||||
model.objects.filter(query).all()
|
||||
# print("Good query for permission", perm)
|
||||
except (FieldError, AttributeError, ValueError, TypeError, JSONDecodeError):
|
||||
print("Query error for permission", perm)
|
||||
print("Query:", perm.query)
|
||||
|
@ -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)
|
||||
|
@ -17,3 +17,7 @@ Font-Awesome attribution is already done inside SVG files
|
||||
-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 %}
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
<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. The WEI registration includes the BDE membership." %}
|
||||
{% 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>
|
||||
|
@ -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 "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" %},
|
||||
|
||||
|
@ -364,7 +364,7 @@ class TestValidateRegistration(TestCase):
|
||||
self.assertTrue(Membership.objects.filter(club__name="Kfet", user=self.user).exists())
|
||||
self.assertTrue(SogeCredit.objects.filter(user=self.user).exists())
|
||||
self.assertEqual(Transaction.objects.filter(
|
||||
Q(source=self.user.note) | Q(destination=self.user.note)).count(), 2)
|
||||
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())
|
||||
|
@ -21,6 +21,7 @@ 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
|
||||
@ -219,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
|
||||
@ -342,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):
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 4f5a794798a48cbbf10b42f0a519743fcbb96c33
|
||||
Subproject commit e5b76b7c35592aba4225115f933f2a7ed3a66df3
|
@ -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):
|
||||
"""
|
||||
@ -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,11 @@ 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 sum(transaction.total for transaction in self.transactions.all()) + 8000
|
||||
|
||||
def invalidate(self):
|
||||
"""
|
||||
@ -295,11 +304,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 +317,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 +329,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 +366,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,6 +9,7 @@ def save_special_transaction(instance, created, **kwargs):
|
||||
When a special transaction is created, we create its linked proxy
|
||||
"""
|
||||
|
||||
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()
|
||||
|
@ -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>
|
||||
|
@ -353,7 +353,6 @@ class TestSogeCredits(TestCase):
|
||||
soge_credit.refresh_from_db()
|
||||
self.assertTrue(soge_credit.valid)
|
||||
self.user.note.refresh_from_db()
|
||||
self.assertEqual(self.user.note.balance, 0)
|
||||
self.assertEqual(
|
||||
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
|
||||
self.assertTrue(self.user.profile.soge)
|
||||
@ -391,7 +390,7 @@ class TestSogeCredits(TestCase):
|
||||
self.user.note.refresh_from_db()
|
||||
self.assertEqual(self.user.note.balance, 0)
|
||||
self.assertEqual(
|
||||
Transaction.objects.filter(Q(source=self.user.note) | Q(destination=self.user.note)).count(), 3)
|
||||
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):
|
||||
|
@ -209,7 +209,7 @@ class InvoiceRenderView(LoginRequiredMixin, View):
|
||||
# The file has to be rendered twice
|
||||
for ignored in range(2):
|
||||
error = subprocess.Popen(
|
||||
["xelatex", "-interaction=nonstopmode", "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"),
|
||||
@ -425,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]
|
||||
|
||||
|
@ -690,7 +690,7 @@ class TestWEIRegistration(TestCase):
|
||||
"""
|
||||
with open("/dev/null", "wb") as devnull:
|
||||
return subprocess.call(
|
||||
["which", "xelatex"],
|
||||
["/usr/bin/which", "xelatex"],
|
||||
stdout=devnull,
|
||||
stderr=devnull,
|
||||
) == 0
|
||||
|
@ -1103,7 +1103,7 @@ class MemberListRenderView(LoginRequiredMixin, View):
|
||||
|
||||
with open(os.devnull, "wb") as devnull:
|
||||
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,
|
||||
stderr=devnull,
|
||||
stdout=devnull,
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,8 @@
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
<!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>
|
||||
<meta charset="utf-8">
|
||||
<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/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 %}
|
||||
{{ form.media }}
|
||||
{% endif %}
|
||||
@ -46,7 +47,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</head>
|
||||
<body class="d-flex w-100 h-100 flex-column">
|
||||
<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">
|
||||
<a class="navbar-brand" href="/">{{ request.site.name }}</a>
|
||||
<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"
|
||||
aria-labelledby="navbarDropdownMenuLink">
|
||||
<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 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>
|
||||
</div>
|
||||
</li>
|
||||
@ -132,14 +133,14 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% if request.path != "/registration/signup/" %}
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if request.path != "/accounts/login/" %}
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
</li>
|
||||
{% endif %}
|
||||
@ -168,7 +169,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
class="form-inline">
|
||||
<span class="text-muted mr-1">
|
||||
<a href="mailto:{{ "CONTACT_EMAIL" | getenv }}"
|
||||
class="text-muted">Nous contacter</a> —
|
||||
class="text-muted">{% trans "Contact us" %}</a> —
|
||||
</span>
|
||||
{% csrf_token %}
|
||||
<select title="language" name="language"
|
||||
|
Loading…
Reference in New Issue
Block a user