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_plural = _("entries")
def __str__(self):
return _("Entry for {guest}, invited by {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity)) if self.guest \
else _("Entry for {note} to the activity {activity}").format(
guest=str(self.guest), note=str(self.note), activity=str(self.activity))
def save(self, *args, **kwargs):
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)

View File

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

View File

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

View File

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

View File

@ -50,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()

View File

@ -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

View File

@ -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",

View File

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

View File

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

View File

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

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
"""
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:

View File

@ -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>

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,8 @@ def save_user_note(instance, raw, **_kwargs):
"""
Hook to create and save a note when an user is updated
"""
if not raw and (instance.is_superuser or instance.profile.registration_valid):
if not raw and (instance.is_superuser or instance.profile.registration_valid)\
and not hasattr(instance, "_no_signal"):
# Create note only when the registration is validated
from note.models import NoteUser
NoteUser.objects.get_or_create(user=instance)
@ -17,10 +18,17 @@ def save_club_note(instance, raw, **_kwargs):
"""
Hook to create and save a note when a club is updated
"""
if raw:
# When provisionning data, do not try to autocreate
return
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()

View File

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

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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)

View File

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

View File

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

View File

@ -5,14 +5,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
<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 %}

View File

@ -5,13 +5,17 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
<h2>{% trans "Account activation" %}</h2>
<p>
<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." %}
</p>
</p>
<p>
{% trans "You must also go to the Kfet to pay your membership." %}
</p>
</div>
</div>
{% endblock %}

View File

@ -27,7 +27,7 @@
</p>
<p>
{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet. Note that the WEI registration includes the Kfet membership." %}
{% trans "After that, you'll have to wait that someone validates your account before you can log in. You will need to pay your membership in the Kfet." %}
</p>
<p>

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 "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" %},

View File

@ -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())

View File

@ -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

View File

@ -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))

View File

@ -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()

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 ...">
<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>

View File

@ -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):

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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> &mdash;
class="text-muted">{% trans "Contact us" %}</a> &mdash;
</span>
{% csrf_token %}
<select title="language" name="language"

View File

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