Commit 9b8caa7f authored by ynerant's avatar ynerant

Merge branch 'beta' into 'master'

Add animated profile picture support

See merge request !116
parents f1dac73c fa3c7231
Pipeline #3968 passed with stages
in 13 minutes and 19 seconds
......@@ -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)
......
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;"
),
]
# 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(
(settings.PIC_WIDTH, settings.PIC_RATIO * settings.PIC_WIDTH),
Image.ANTIALIAS,
)
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",
......
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;",
),
]
# 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,10 +48,10 @@
<dd class="col-xl-6">
<a class="badge badge-secondary" href="{% url 'member:club_alias' club.pk %}">
<i class="fa fa-edit"></i>
{% trans 'Manage aliases' %} ({{ user_object.note.alias_set.all|length }})
{% trans 'Manage aliases' %} ({{ club.note.alias_set.all|length }})
</a>
</dd>
<dt class="col-xl-4">{% trans 'email'|capfirst %}</dt>
<dd class="col-xl-8"><a href="mailto:{{ club.email }}">{{ club.email }}</a></dd>
</dl>
\ No newline at end of file
</dl>
......@@ -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',
)
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;"
),
]
# 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
# When provisionning data, do not try to autocreate
if not raw and not hasattr(instance, "_no_signal"):
from .models import NoteClub
NoteClub.objects.get_or_create(club=instance)
instance.note.save()
from .models import NoteClub
NoteClub.objects.get_or_create(club=instance)
instance.note.save()
def delete_transaction(instance, **_kwargs):
"""
Whenever we want to delete a transaction (caution with this), we ensure the transaction is invalid first.
"""
if not hasattr(instance, "_no_signal"):
instance.valid = False
instance.save()
{% 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>
......
{% 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"):