From 5e65e2d74a387dab90a6fcc68564fd170e071050 Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Mon, 31 Aug 2020 20:15:48 +0200 Subject: [PATCH] :sparkles: Add "Lock note" feature --- apps/member/templates/member/base.html | 114 ++++++++++++++++-- apps/member/templates/member/club_detail.html | 9 -- .../templates/member/profile_detail.html | 9 -- apps/member/views.py | 26 +++- apps/note/api/serializers.py | 25 ++-- apps/note/api/views.py | 23 ++-- apps/note/models/notes.py | 39 ++++-- apps/note/models/transactions.py | 6 +- apps/note/tables.py | 28 ++--- apps/permission/fixtures/initial.json | 79 +++++++++++- note_kfet/static/js/base.js | 2 +- 11 files changed, 279 insertions(+), 81 deletions(-) diff --git a/apps/member/templates/member/base.html b/apps/member/templates/member/base.html index 650cd951..2e3e929c 100644 --- a/apps/member/templates/member/base.html +++ b/apps/member/templates/member/base.html @@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_info %} -
+

{% if user_object %} {% trans "Account #" %}{{ user_object.pk }} @@ -31,6 +31,11 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endif %}

+ {% if note.inactivity_reason %} +
+ {{ note.get_inactivity_reason_display }} +
+ {% endif %}
{% if user_object %} {% include "member/includes/profile_info.html" %} @@ -50,11 +55,11 @@ SPDX-License-Identifier: GPL-3.0-or-later {% elif club and not club.weiclub %} {% if can_add_members %} {% trans "Add member" %} + data-turbolinks="false"> {% trans "Add member" %} {% endif %} {% if ".change_"|has_perm:club %} + data-turbolinks="false"> {% trans 'Update Profile' %} {% endif %} @@ -63,6 +68,15 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans 'View Profile' %} {% endif %} {% endif %} + {% if can_lock_note %} + + {% elif can_unlock_note %} + + {% endif %}
{% endblock %} @@ -70,16 +84,102 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% block profile_content %}{% endblock %}
+ + {# Popup to confirm the action of locking the note. Managed by a button #} + + + {# Popup to confirm the action of unlocking the note. Managed by a button #} +
{% endblock %} {% block extrajavascript %} -{% if object %} -{% endif %} {% endblock %} \ No newline at end of file diff --git a/apps/member/templates/member/club_detail.html b/apps/member/templates/member/club_detail.html index b6cd1e33..a0b927e1 100644 --- a/apps/member/templates/member/club_detail.html +++ b/apps/member/templates/member/club_detail.html @@ -46,12 +46,3 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} {% endblock %} - -{% block extrajavascript %} - -{% endblock %} \ No newline at end of file diff --git a/apps/member/templates/member/profile_detail.html b/apps/member/templates/member/profile_detail.html index 598b291d..591fa879 100644 --- a/apps/member/templates/member/profile_detail.html +++ b/apps/member/templates/member/profile_detail.html @@ -37,12 +37,3 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblock %} - -{% block extrajavascript %} - -{% endblock %} \ No newline at end of file diff --git a/apps/member/views.py b/apps/member/views.py index 79cbed8d..cccffc4a 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -10,6 +10,7 @@ from django.contrib.auth import logout from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.contrib.auth.views import LoginView +from django.db import transaction from django.db.models import Q, F from django.shortcuts import redirect from django.urls import reverse_lazy @@ -151,6 +152,7 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): """ context = super().get_context_data(**kwargs) user = context['user_object'] + context["note"] = user.note history_list = \ Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\ .order_by("-created_at")\ @@ -164,6 +166,28 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): membership_table = MembershipTable(data=club_list, prefix='membership-') membership_table.paginate(per_page=10, page=self.request.GET.get("membership-page", 1)) context['club_list'] = membership_table + + # Check permissions to see if the authenticated user can lock/unlock the note + with transaction.atomic(): + modified_note = NoteUser.objects.get(pk=user.note.pk) + modified_note.is_active = True + modified_note.inactivity_reason = 'manual' + context["can_lock_note"] = user.note.is_active and PermissionBackend\ + .check_perm(self.request.user, "note.change_noteuser_is_active", + modified_note) + old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk) + modified_note.inactivity_reason = 'forced' + modified_note._force_save = True + modified_note.save() + context["can_force_lock"] = user.note.is_active and PermissionBackend\ + .check_perm(self.request.user, "note.change_note_is_active", modified_note) + old_note._force_save = True + old_note.save() + modified_note.refresh_from_db() + modified_note.is_active = True + context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ + .check_perm(self.request.user, "note.change_note_is_active", modified_note) + return context @@ -203,7 +227,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): else: qs = qs.none() - return qs[:20] + return qs class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index 1230de4b..5a5cff94 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -1,7 +1,9 @@ # Copyright (C) 2018-2020 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_polymorphic.serializers import PolymorphicSerializer from member.api.serializers import MembershipSerializer from member.models import Membership @@ -23,7 +25,7 @@ class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note fields = '__all__' - read_only_fields = [f.name for f in model._meta.get_fields()] # Notes are read-only protected + read_only_fields = ('balance', 'last_negative', 'created_at', ) # Note balances are read-only protected class NoteClubSerializer(serializers.ModelSerializer): @@ -36,7 +38,7 @@ class NoteClubSerializer(serializers.ModelSerializer): class Meta: model = NoteClub fields = '__all__' - read_only_fields = ('note', 'club', ) + read_only_fields = ('note', 'club', 'balance', 'last_negative', 'created_at', ) def get_name(self, obj): return str(obj) @@ -52,7 +54,7 @@ class NoteSpecialSerializer(serializers.ModelSerializer): class Meta: model = NoteSpecial fields = '__all__' - read_only_fields = ('note', ) + read_only_fields = ('note', 'balance', 'last_negative', 'created_at', ) def get_name(self, obj): return str(obj) @@ -68,7 +70,7 @@ class NoteUserSerializer(serializers.ModelSerializer): class Meta: model = NoteUser fields = '__all__' - read_only_fields = ('note', 'user', ) + read_only_fields = ('note', 'user', 'balance', 'last_negative', 'created_at', ) def get_name(self, obj): return str(obj) @@ -170,13 +172,22 @@ class TransactionSerializer(serializers.ModelSerializer): REST API Serializer for Transactions. The djangorestframework plugin will analyse the model `Transaction` and parse all fields in the API. """ + def validate_source(self, value): + if value.is_active: + raise ValidationError(_("The transaction can't be saved since the source note " + "or the destination note is not active.")) + + def validate_destination(self, value): + if value.is_active: + raise ValidationError(_("The transaction can't be saved since the source note " + "or the destination note is not active.")) class Meta: model = Transaction fields = '__all__' -class RecurrentTransactionSerializer(serializers.ModelSerializer): +class RecurrentTransactionSerializer(TransactionSerializer): """ REST API Serializer for Transactions. The djangorestframework plugin will analyse the model `RecurrentTransaction` and parse all fields in the API. @@ -187,7 +198,7 @@ class RecurrentTransactionSerializer(serializers.ModelSerializer): fields = '__all__' -class MembershipTransactionSerializer(serializers.ModelSerializer): +class MembershipTransactionSerializer(TransactionSerializer): """ REST API Serializer for Membership transactions. The djangorestframework plugin will analyse the model `MembershipTransaction` and parse all fields in the API. @@ -198,7 +209,7 @@ class MembershipTransactionSerializer(serializers.ModelSerializer): fields = '__all__' -class SpecialTransactionSerializer(serializers.ModelSerializer): +class SpecialTransactionSerializer(TransactionSerializer): """ REST API Serializer for Special transactions. The djangorestframework plugin will analyse the model `SpecialTransaction` and parse all fields in the API. diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 20818e1b..53fcaed6 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -18,7 +18,7 @@ from ..models.notes import Note, Alias from ..models.transactions import TransactionTemplate, Transaction, TemplateCategory -class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet): +class NotePolymorphicViewSet(ReadProtectedModelViewSet): """ REST API View set. The djangorestframework plugin will get all `Note` objects (with polymorhism), serialize it to JSON with the given serializer, @@ -36,25 +36,16 @@ class NotePolymorphicViewSet(ReadOnlyProtectedModelViewSet): Parse query and apply filters. :return: The filtered set of requested notes """ - queryset = super().get_queryset() + queryset = super().get_queryset().distinct() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( - name__iregex="^" + alias - ).union( - queryset.filter( - Q(normalized_name__iregex="^" + Alias.normalize(alias)) - & ~Q(name__iregex="^" + alias) - ), - all=True).union( - queryset.filter( - Q(normalized_name__iregex="^" + alias.lower()) - & ~Q(normalized_name__iregex="^" + Alias.normalize(alias)) - & ~Q(name__iregex="^" + alias) - ), - all=True) + Q(alias__name__iregex="^" + alias) + | Q(alias__normalized_name__iregex="^" + Alias.normalize(alias)) + | Q(alias__normalized_name__iregex="^" + alias.lower()) + ) - return queryset.distinct() + return queryset class AliasViewSet(ReadProtectedModelViewSet): diff --git a/apps/note/models/notes.py b/apps/note/models/notes.py index b5c43e6a..46c8af44 100644 --- a/apps/note/models/notes.py +++ b/apps/note/models/notes.py @@ -25,24 +25,20 @@ class Note(PolymorphicModel): A Note can be searched find throught an :model:`note.Alias` """ + balance = models.BigIntegerField( verbose_name=_('account balance'), help_text=_('in centimes, money credited for this instance'), default=0, ) + last_negative = models.DateTimeField( verbose_name=_('last negative date'), help_text=_('last time the balance was negative'), null=True, blank=True, ) - is_active = models.BooleanField( - _('active'), - default=True, - help_text=_( - 'Designates whether this note should be treated as active. ' - 'Unselect this instead of deleting notes.'), - ) + display_image = models.ImageField( verbose_name=_('display image'), max_length=255, @@ -51,11 +47,31 @@ class Note(PolymorphicModel): upload_to='pic/', default='pic/default.png' ) + created_at = models.DateTimeField( verbose_name=_('created at'), default=timezone.now, ) + is_active = models.BooleanField( + _('active'), + default=True, + help_text=_( + 'Designates whether this note should be treated as active. ' + 'Unselect this instead of deleting notes.'), + ) + + inactivity_reason = models.CharField( + max_length=255, + 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.")), + ], + null=True, + default=None, + ) + class Meta: verbose_name = _("note") verbose_name_plural = _("notes") @@ -141,9 +157,13 @@ class NoteUser(Note): old_note = NoteUser.objects.get(pk=self.pk) if old_note.balance >= 0: # Passage en négatif + super().save(*args, **kwargs) self.last_negative = timezone.now() + self._force_save = True + self.save(*args, **kwargs) self.send_mail_negative_balance() - super().save(*args, **kwargs) + else: + super().save(*args, **kwargs) def send_mail_negative_balance(self): plain_text = render_to_string("note/mails/negative_balance.txt", dict(note=self)) @@ -178,7 +198,10 @@ class NoteClub(Note): old_note = NoteClub.objects.get(pk=self.pk) if old_note.balance >= 0: # Passage en négatif + super().save(*args, **kwargs) self.last_negative = timezone.now() + self._force_save = True + self.save(*args, **kwargs) self.send_mail_negative_balance() super().save(*args, **kwargs) diff --git a/apps/note/models/transactions.py b/apps/note/models/transactions.py index 4adf9cb3..f9b9dbb0 100644 --- a/apps/note/models/transactions.py +++ b/apps/note/models/transactions.py @@ -221,10 +221,8 @@ class Transaction(PolymorphicModel): diff_source, diff_dest = self.validate() if not self.source.is_active or not self.destination.is_active: - if 'force_insert' not in kwargs or not kwargs['force_insert']: - if 'force_update' not in kwargs or not kwargs['force_update']: - raise ValidationError(_("The transaction can't be saved since the source note " - "or the destination note is not active.")) + raise ValidationError(_("The transaction can't be saved since the source note " + "or the destination note is not active.")) # If the aliases are not entered, we assume that the used alias is the name of the note if not self.source_alias: diff --git a/apps/note/tables.py b/apps/note/tables.py index d27915e7..547bf9c6 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -55,17 +55,19 @@ class HistoryTable(tables.Table): "id": lambda record: "validate_" + str(record.id), "class": lambda record: str(record.valid).lower() - + (' validate' if PermissionBackend.check_perm(get_current_authenticated_user(), - "note.change_transaction_invalidity_reason", - record) else ''), + + (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend + .check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record) + else ''), "data-toggle": "tooltip", "title": lambda record: (_("Click to invalidate") if record.valid else _("Click to validate")) if PermissionBackend.check_perm(get_current_authenticated_user(), - "note.change_transaction_invalidity_reason", record) else None, + "note.change_transaction_invalidity_reason", record) + and record.source.is_active and record.destination.is_active else None, "onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower() + ', "' + str(record.__class__.__name__) + '")' if PermissionBackend.check_perm(get_current_authenticated_user(), - "note.change_transaction_invalidity_reason", record) else None, + "note.change_transaction_invalidity_reason", record) + and record.source.is_active and record.destination.is_active else None, "onmouseover": lambda record: '$("#invalidity_reason_' + str(record.id) + '").show();$("#invalidity_reason_' + str(record.id) + '").focus();', @@ -108,7 +110,7 @@ class HistoryTable(tables.Table): val += "" return format_html(val) @@ -135,16 +137,10 @@ class AliasTable(tables.Table): delete_col = tables.TemplateColumn(template_code=DELETE_TEMPLATE, extra_context={"delete_trans": _('delete')}, - attrs= - {'td': - {'class': - lambda record: 'col-sm-1' - + (' d-none' if not PermissionBackend - .check_perm(get_current_authenticated_user(), - "note.delete_alias", record) else '') - } - }, - verbose_name=_("Delete"), ) + attrs={'td': {'class': lambda record: 'col-sm-1' + ( + ' d-none' if not PermissionBackend.check_perm( + get_current_authenticated_user(), "note.delete_alias", + record) else '')}}, verbose_name=_("Delete"), ) class ButtonTable(tables.Table): diff --git a/apps/permission/fixtures/initial.json b/apps/permission/fixtures/initial.json index 4a7aa824..7a6cb973 100644 --- a/apps/permission/fixtures/initial.json +++ b/apps/permission/fixtures/initial.json @@ -2503,6 +2503,70 @@ "description": "Supprimer ses propres invitations non validées à une activité" } }, + { + "model": "permission.permission", + "pk": 161, + "fields": { + "model": [ + "note", + "noteuser" + ], + "query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]", + "type": "change", + "mask": 1, + "field": "is_active", + "permanent": true, + "description": "(Dé)bloquer sa propre note manuellement" + } + }, + { + "model": "permission.permission", + "pk": 162, + "fields": { + "model": [ + "note", + "noteuser" + ], + "query": "[\"AND\", {\"user\": [\"user\"]}, [\"OR\", {\"inactivity_reason\": \"manual\"}, {\"inactivity_reason\": null}]]", + "type": "change", + "mask": 1, + "field": "inactivity_reason", + "permanent": true, + "description": "(Dé)bloquer sa propre note et indiquer que cela a été fait manuellement" + } + }, + { + "model": "permission.permission", + "pk": 163, + "fields": { + "model": [ + "note", + "note" + ], + "query": "{}", + "type": "change", + "mask": 3, + "field": "is_active", + "permanent": false, + "description": "(Dé)bloquer n'importe quelle note, y compris en mode forcé" + } + }, + { + "model": "permission.permission", + "pk": 164, + "fields": { + "model": [ + "note", + "note" + ], + "query": "{}", + "type": "change", + "mask": 3, + "field": "inactivity_reason", + "permanent": false, + "description": "(Dé)bloquer sa propre note et modifier la raison" + } + }, { "model": "permission.role", "pk": 1, @@ -2525,7 +2589,9 @@ 22, 48, 52, - 126 + 126, + 161, + 162 ] } }, @@ -2695,7 +2761,9 @@ 146, 147, 150, - 151 + 151, + 163, + 164 ] } }, @@ -2859,7 +2927,12 @@ 156, 157, 158, - 159 + 159, + 160, + 161, + 162, + 163, + 164 ] } }, diff --git a/note_kfet/static/js/base.js b/note_kfet/static/js/base.js index 97ef7005..a20c72bc 100644 --- a/note_kfet/static/js/base.js +++ b/note_kfet/static/js/base.js @@ -107,7 +107,7 @@ function displayStyle (note) { css += " text-warning"; else if (!note.email_confirmed) css += " text-white bg-primary"; - else if (note.membership && note.membership.date_end < new Date().toISOString()) + else if (!note.is_active || (note.membership && note.membership.date_end < new Date().toISOString())) css += "text-white bg-info"; return css; }