From 137dd655d13127f7e10a9eecd7878093ce573f67 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 11 Mar 2020 22:30:47 +0100 Subject: [PATCH 01/44] =?UTF-8?q?Harmonise=20les=20comptes=20non-lisibles?= =?UTF-8?q?=20ou=20=C3=A9ditables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kfet/forms.py | 7 ++++++- kfet/models.py | 9 +++++++++ kfet/views.py | 6 +++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/kfet/forms.py b/kfet/forms.py index b6fad26f..9419d9f8 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -24,6 +24,8 @@ from kfet.models import ( TransferGroup, ) +from . import KFET_DELETED_TRIGRAMME +from .auth import KFET_GENERIC_TRIGRAMME from .auth.forms import UserGroupForm # noqa # ----- @@ -324,7 +326,10 @@ class KPsulOperationGroupForm(forms.ModelForm): widget=forms.HiddenInput(), ) on_acc = forms.ModelChoiceField( - queryset=Account.objects.exclude(trigramme="GNR"), widget=forms.HiddenInput() + queryset=Account.objects.exclude( + trigramme__in=[KFET_DELETED_TRIGRAMME, KFET_GENERIC_TRIGRAMME] + ), + widget=forms.HiddenInput(), ) class Meta: diff --git a/kfet/models.py b/kfet/models.py index 814f857a..2eacf06f 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -150,6 +150,15 @@ class Account(models.Model): def readable(self): return self.trigramme not in [KFET_DELETED_TRIGRAMME, KFET_GENERIC_TRIGRAMME] + @property + def editable(self): + return self.trigramme not in [ + KFET_DELETED_TRIGRAMME, + KFET_GENERIC_TRIGRAMME, + "LIQ", + "#13", + ] + @property def is_team(self): return self.has_perm("kfet.is_team") diff --git a/kfet/views.py b/kfet/views.py index 655e856d..0b1c5f91 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -328,7 +328,9 @@ def account_update(request, trigramme): account = get_object_or_404(Account, trigramme=trigramme) # Checking permissions - if not request.user.has_perm("kfet.is_team") and request.user != account.user: + if not account.editable or ( + not request.user.has_perm("kfet.is_team") and request.user != account.user + ): raise Http404 user_info_form = UserInfoForm(instance=account.user) @@ -911,6 +913,8 @@ def kpsul_get_settings(request): @teamkfet_required def account_read_json(request, trigramme): account = get_object_or_404(Account, trigramme=trigramme) + if not account.readable: + raise Http404 data = { "id": account.pk, "name": account.name, From a3b0ea9b8d15311539fc0df460be83f9adea5209 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 11:06:48 +0100 Subject: [PATCH 02/44] Fetch transfers in `history_json` --- kfet/views.py | 47 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index 655e856d..1b8fc0dc 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -12,7 +12,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.models import Permission, User from django.contrib.messages.views import SuccessMessageMixin from django.db import transaction -from django.db.models import Count, F, Prefetch, Sum +from django.db.models import Count, F, Prefetch, Q, Sum from django.forms import formset_factory from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render @@ -1416,42 +1416,73 @@ def history_json(request): # Récupération des paramètres from_date = request.POST.get("from", None) to_date = request.POST.get("to", None) - limit = request.POST.get("limit", None) checkouts = request.POST.getlist("checkouts[]", None) accounts = request.POST.getlist("accounts[]", None) + transfers_only = request.POST.get("transfersonly", None) + opes_only = request.POST.get("opesonly", None) # Construction de la requête (sur les opérations) pour le prefetch - queryset_prefetch = Operation.objects.select_related( + ope_queryset_prefetch = Operation.objects.select_related( "article", "canceled_by", "addcost_for" ) + ope_prefetch = Prefetch("opes", queryset=ope_queryset_prefetch) + + transfer_queryset_prefetch = Transfer.objects.select_related( + "from_acc", "to_acc", "canceled_by" + ) + + if accounts: + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc__trigramme__in=accounts) | Q(to_acc__trigramme__in=accounts) + ) + + if not request.user.has_perm("kfet.is_team"): + acc = request.user.profile.account_kfet + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc=acc) | Q(to_acc=acc) + ) + + transfer_prefetch = Prefetch( + "transfers", queryset=transfer_queryset_prefetch, to_attr="filtered_transfers" + ) # Construction de la requête principale opegroups = ( - OperationGroup.objects.prefetch_related( - Prefetch("opes", queryset=queryset_prefetch) - ) + OperationGroup.objects.prefetch_related(ope_prefetch) .select_related("on_acc", "valid_by") .order_by("at") ) + transfergroups = ( + TransferGroup.objects.prefetch_related(transfer_prefetch) + .select_related("valid_by") + .order_by("at") + ) + # Application des filtres if from_date: opegroups = opegroups.filter(at__gte=from_date) + transfergroups = transfergroups.filter(at__gte=from_date) if to_date: opegroups = opegroups.filter(at__lt=to_date) + transfergroups = transfergroups.filter(at__lt=to_date) if checkouts: opegroups = opegroups.filter(checkout_id__in=checkouts) + transfergroups = TransferGroup.objects.none() + if transfers_only: + opegroups = OperationGroup.objects.none() + if opes_only: + transfergroups = TransferGroup.objects.none() if accounts: opegroups = opegroups.filter(on_acc_id__in=accounts) # Un non-membre de l'équipe n'a que accès à son historique if not request.user.has_perm("kfet.is_team"): opegroups = opegroups.filter(on_acc=request.user.profile.account_kfet) - if limit: - opegroups = opegroups[:limit] # Construction de la réponse opegroups_list = [] for opegroup in opegroups: opegroup_dict = { + "type": "opegroup", "id": opegroup.id, "amount": opegroup.amount, "at": opegroup.at, From bf117ec070fa832daa48cf750d802b7fdf6be9d6 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 11:07:02 +0100 Subject: [PATCH 03/44] Renvoie les transferts dans l'historique --- kfet/views.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/kfet/views.py b/kfet/views.py index 1b8fc0dc..cb35cca8 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1515,6 +1515,37 @@ def history_json(request): ) opegroup_dict["opes"].append(ope_dict) opegroups_list.append(opegroup_dict) + for transfergroup in transfergroups: + if transfergroup.filtered_transfers: + transfergroup_dict = { + "type": "transfergroup", + "id": transfergroup.id, + "at": transfergroup.at, + "comment": transfergroup.comment, + "opes": [], + } + if request.user.has_perm("kfet.is_team"): + transfergroup_dict["valid_by__trigramme"] = ( + transfergroup.valid_by and transfergroup.valid_by.trigramme or None + ) + + for transfer in transfergroup.filtered_transfers: + transfer_dict = { + "id": transfer.id, + "amount": transfer.amount, + "canceled_at": transfer.canceled_at, + "from_acc": transfer.from_acc.trigramme, + "to_acc": transfer.to_acc.trigramme, + } + if request.user.has_perm("kfet.is_team"): + transfer_dict["canceled_by__trigramme"] = ( + transfer.canceled_by and transfer.canceled_by.trigramme or None + ) + transfergroup_dict["opes"].append(transfer_dict) + opegroups_list.append(transfergroup_dict) + + opegroups_list.sort(key=lambda group: group["at"]) + return JsonResponse({"opegroups": opegroups_list}) From c3b5de336a23fe86b9514092bca92ecaf52f900c Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 11:30:25 +0100 Subject: [PATCH 04/44] =?UTF-8?q?G=C3=A8re=20l'affichage=20des=20transfert?= =?UTF-8?q?s=20dans=20l'historique?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kfet/static/kfet/css/history.css | 9 ++++ kfet/static/kfet/js/history.js | 78 ++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index 9cd4cd28..e1e1ab42 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -108,3 +108,12 @@ #history .transfer .from_acc { padding-left:10px; } + +#history .opegroup .infos { + text-align:center; + width:145px; +} + +#history .ope .glyphicon { + padding-left:15px; +} diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index a7372b87..cb9399b2 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -19,10 +19,22 @@ function KHistory(options = {}) { var trigramme = opegroup['on_acc_trigramme']; var is_cof = opegroup['is_cof']; - for (var i = 0; i < opegroup['opes'].length; i++) { - var $ope = this._opeHtml(opegroup['opes'][i], is_cof, trigramme); - $ope.data('opegroup', opegroup['id']); - $opegroup.after($ope); + var type = opegroup['type'] + switch (type) { + case 'opegroup': + for (let ope of opegroup['opes']) { + var $ope = this._opeHtml(ope, is_cof, trigramme); + $ope.data('opegroup', opegroup['id']); + $opegroup.after($ope); + } + break; + case 'transfergroup': + for (let transfer of opegroup['opes']) { + var $transfer = this._transferHtml(transfer); + $transfer.data('transfergroup', opegroup['id']); + $opegroup.after($transfer); + } + break; } } @@ -54,7 +66,8 @@ function KHistory(options = {}) { } $ope_html - .data('ope', ope['id']) + .data('type', 'ope') + .data('id', ope['id']) .find('.amount').text(amount).end() .find('.infos1').text(infos1).end() .find('.infos2').text(infos2).end(); @@ -62,7 +75,7 @@ function KHistory(options = {}) { var addcost_for = ope['addcost_for__trigramme']; if (addcost_for) { var addcost_amount = parseFloat(ope['addcost_amount']); - $ope_html.find('.addcost').text('(' + amountDisplay(addcost_amount, is_cof) + 'UKF pour ' + addcost_for + ')'); + $ope_html.find('.addcost').text('(' + amountDisplay(addcost_amount, is_cof) + ' UKF pour ' + addcost_for + ')'); } if (ope['canceled_at']) @@ -71,9 +84,28 @@ function KHistory(options = {}) { return $ope_html; } + this._transferHtml = function (transfer) { + var $transfer_html = $(this.template_transfer); + var parsed_amount = parseFloat(transfer['amount']); + var amount = parsed_amount.toFixed(2) + '€'; + + $transfer_html + .data('type', 'transfer') + .data('id', transfer['id']) + .find('.amount').text(amount).end() + .find('.infos1').text(transfer['from_acc']).end() + .find('.infos2').text(transfer['to_acc']).end(); + + if (transfer['canceled_at']) + this.cancelOpe(transfer, $transfer_html); + + return $transfer_html; + } + + this.cancelOpe = function (ope, $ope = null) { if (!$ope) - $ope = this.findOpe(ope['id']); + $ope = this.findOpe(ope['id'], ope["type"]); var cancel = 'Annulé'; var canceled_at = dateUTCToParis(ope['canceled_at']); @@ -85,16 +117,31 @@ function KHistory(options = {}) { } this._opeGroupHtml = function (opegroup) { - var $opegroup_html = $(this.template_opegroup); + var type = opegroup['type']; + + + switch (type) { + case 'opegroup': + var $opegroup_html = $(this.template_opegroup); + var trigramme = opegroup['on_acc__trigramme']; + var amount = amountDisplay( + parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); + break; + case 'transfergroup': + var $opegroup_html = $(this.template_transfergroup); + $opegroup_html.find('.infos').text('Transferts').end() + var trigramme = ''; + var amount = ''; + break; + } + var at = dateUTCToParis(opegroup['at']).format('HH:mm:ss'); - var trigramme = opegroup['on_acc__trigramme']; - var amount = amountDisplay( - parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); var comment = opegroup['comment'] || ''; $opegroup_html - .data('opegroup', opegroup['id']) + .data('type', type) + .data('id', opegroup['id']) .find('.time').text(at).end() .find('.amount').text(amount).end() .find('.comment').text(comment).end() @@ -102,6 +149,7 @@ function KHistory(options = {}) { if (!this.display_trigramme) $opegroup_html.find('.trigramme').remove(); + $opegroup_html.find('.info').remove(); if (opegroup['valid_by__trigramme']) $opegroup_html.find('.valid_by').text('Par ' + opegroup['valid_by__trigramme']); @@ -127,9 +175,9 @@ function KHistory(options = {}) { }); } - this.findOpe = function (id) { + this.findOpe = function (id, type = 'ope') { return this.$container.find('.ope').filter(function () { - return $(this).data('ope') == id + return ($(this).data('id') == id && $(this).data('type') == type) }); } @@ -147,6 +195,8 @@ KHistory.default_options = { container: '#history', template_day: '
', template_opegroup: '
', + template_transfergroup: '
', template_ope: '
', + template_transfer: '
', display_trigramme: true, } From 9b2c4c1f9853fc6351ee381265758d5756b0a651 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 11:34:08 +0100 Subject: [PATCH 05/44] Change l'affichage de la date dans l'historique Fixes #233 --- kfet/static/kfet/js/history.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index cb9399b2..c087d56a 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -166,7 +166,7 @@ function KHistory(options = {}) { if ($day.length == 1) return $day; var $day = $(this.template_day).prependTo(this.$container); - return $day.data('date', at_ser).text(at.format('D MMMM')); + return $day.data('date', at_ser).text(at.format('D MMMM YYYY')); } this.findOpeGroup = function (id) { From 36d6a4a1cd80fe8248c7cbe9c2b138264cd11b67 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 12:54:30 +0100 Subject: [PATCH 06/44] =?UTF-8?q?D=C3=A9place=20la=20logique=20de=20l'hist?= =?UTF-8?q?orique=20dans=20`history.js`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On change le lock en `window.lock` pour y avoir accès partout --- kfet/static/kfet/js/history.js | 71 ++++++++++++++++++++++ kfet/templates/kfet/kpsul.html | 104 ++++----------------------------- 2 files changed, 83 insertions(+), 92 deletions(-) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index c087d56a..2869ea86 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -7,6 +7,20 @@ function KHistory(options = {}) { this.$container = $(this.container); + this.$container.selectable({ + filter: 'div.opegroup, div.ope', + selected: function (e, ui) { + $(ui.selected).each(function () { + if ($(this).hasClass('opegroup')) { + var opegroup = $(this).data('id'); + $(this).siblings('.ope').filter(function () { + return $(this).data('opegroup') == opegroup + }).addClass('ui-selected'); + } + }); + }, + }); + this.reset = function () { this.$container.html(''); }; @@ -189,6 +203,63 @@ function KHistory(options = {}) { $opegroup.find('.amount').text(amount); } + this.fetch = function (fetch_options) { + options = $.extend({}, this.fetch_options, fetch_options); + var that = this; + $.ajax({ + dataType: "json", + url: django_urls["kfet.history.json"](), + method: "POST", + data: options, + }).done(function (data) { + for (let opegroup of data['opegroups']) { + that.addOpeGroup(opegroup); + } + }); + } + + this.cancel_opes = function (opes, password = "") { + if (window.lock == 1) + return false + window.lock = 1; + $.ajax({ + dataType: "json", + url: django_urls["kfet.kpsul.cancel_operations"](), + method: "POST", + data: { + 'operations': opes + }, + beforeSend: function ($xhr) { + $xhr.setRequestHeader("X-CSRFToken", csrftoken); + if (password != '') + $xhr.setRequestHeader("KFetPassword", password); + }, + + }).done(function (data) { + window.lock = 0; + }).fail(function ($xhr) { + var data = $xhr.responseJSON; + switch ($xhr.status) { + case 403: + requestAuth(data, function (password) { + this.cancel(opes, password); + }); + break; + case 400: + displayErrors(getErrorsHtml(data)); + break; + } + window.lock = 0; + }); + } + + this.cancel_selected = function () { + var opes_to_cancel = this.$container.find('.ope.ui-selected').map(function () { + return $(this).data('id'); + }).toArray(); + if (opes_to_cancel.length > 0) + this.cancel(opes_to_cancel); + } } KHistory.default_options = { diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index 171c7030..c745d598 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -189,7 +189,7 @@ $(document).ready(function() { // ----- // Lock to avoid multiple requests - lock = 0; + window.lock = 0; // Retrieve settings @@ -479,9 +479,9 @@ $(document).ready(function() { var operations = $('#operation_formset'); function performOperations(password = '') { - if (lock == 1) + if (window.lock == 1) return false; - lock = 1; + window.lock = 1; var data = operationGroup.serialize() + '&' + operations.serialize(); $.ajax({ dataType: "json", @@ -497,7 +497,7 @@ $(document).ready(function() { .done(function(data) { updatePreviousOp(); coolReset(); - lock = 0; + window.lock = 0; }) .fail(function($xhr) { var data = $xhr.responseJSON; @@ -513,7 +513,7 @@ $(document).ready(function() { } break; } - lock = 0; + window.lock = 0; }); } @@ -522,55 +522,6 @@ $(document).ready(function() { performOperations(); }); - // ----- - // Cancel operations - // ----- - - var cancelButton = $('#cancel_operations'); - var cancelForm = $('#cancel_form'); - - function cancelOperations(opes_array, password = '') { - if (lock == 1) - return false - lock = 1; - var data = { 'operations' : opes_array } - $.ajax({ - dataType: "json", - url : "{% url 'kfet.kpsul.cancel_operations' %}", - method : "POST", - data : data, - beforeSend: function ($xhr) { - $xhr.setRequestHeader("X-CSRFToken", csrftoken); - if (password != '') - $xhr.setRequestHeader("KFetPassword", password); - }, - - }) - .done(function(data) { - coolReset(); - lock = 0; - }) - .fail(function($xhr) { - var data = $xhr.responseJSON; - switch ($xhr.status) { - case 403: - requestAuth(data, function(password) { - cancelOperations(opes_array, password); - }, triInput); - break; - case 400: - displayErrors(getErrorsHtml(data)); - break; - } - lock = 0; - }); - } - - // Event listeners - cancelButton.on('click', function() { - cancelOperations(); - }); - // ----- // Articles data // ----- @@ -1189,24 +1140,12 @@ $(document).ready(function() { // History // ----- - khistory = new KHistory(); - - function getHistory() { - var data = { + khistory = new KHistory({ + fetch_options: { from: moment().subtract(1, 'days').format('YYYY-MM-DD HH:mm:ss'), - }; - $.ajax({ - dataType: "json", - url : "{% url 'kfet.history.json' %}", - method : "POST", - data : data, - }) - .done(function(data) { - for (var i=0; i 0) - cancelOperations(opes_to_cancel); + khistory.cancel_selected() } }); @@ -1396,7 +1316,7 @@ $(document).ready(function() { khistory.reset(); resetSettings().done(function (){ getArticles(); - getHistory(); + khistory.fetch(); displayAddcost(); }); } From 41ad2a15ac7dd42e8f25ab76acefb83d9d4544e2 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 12:59:46 +0100 Subject: [PATCH 07/44] Update websocket data --- kfet/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kfet/views.py b/kfet/views.py index cb35cca8..d7a7be55 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1159,6 +1159,7 @@ def kpsul_perform_operations(request): websocket_data["opegroups"] = [ { "add": True, + "type": "opegroup", "id": operationgroup.pk, "amount": operationgroup.amount, "checkout__name": operationgroup.checkout.name, From c95e1818b21449fd366538d8f6ed1c15053137db Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 13:20:47 +0100 Subject: [PATCH 08/44] Fix ws tests --- kfet/tests/test_views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 0a5c4e49..71c9c72c 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -2000,6 +2000,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, + "type": "opegroup", "at": mock.ANY, "amount": Decimal("-5.00"), "checkout__name": "Checkout", @@ -2272,6 +2273,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, + "type": "opegroup", "at": mock.ANY, "amount": Decimal("10.75"), "checkout__name": "Checkout", @@ -2446,6 +2448,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, + "type": "opegroup", "at": mock.ANY, "amount": Decimal("-10.75"), "checkout__name": "Checkout", @@ -2604,6 +2607,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, + "type": "opegroup", "at": mock.ANY, "amount": Decimal("10.75"), "checkout__name": "Checkout", @@ -3173,6 +3177,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, + "type": "opegroup", "at": mock.ANY, "amount": Decimal("-9.00"), "checkout__name": "Checkout", From af0de33d4c6bb0c069b01604e84260f320435ef4 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 14:16:23 +0100 Subject: [PATCH 09/44] =?UTF-8?q?Suppression=20des=20op=C3=A9rations=20et?= =?UTF-8?q?=20des=20transferts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kfet/static/kfet/js/history.js | 40 +++++++++++++++++++++++++--------- kfet/urls.py | 4 ++-- kfet/views.py | 2 +- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index 2869ea86..b398a65c 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -218,17 +218,15 @@ function KHistory(options = {}) { }); } - this.cancel_opes = function (opes, password = "") { + this.cancel = function (type, opes, password = "") { if (window.lock == 1) return false window.lock = 1; $.ajax({ dataType: "json", - url: django_urls["kfet.kpsul.cancel_operations"](), + url: django_urls[`kfet.${type}.cancel`](), method: "POST", - data: { - 'operations': opes - }, + data: opes, beforeSend: function ($xhr) { $xhr.setRequestHeader("X-CSRFToken", csrftoken); if (password != '') @@ -254,11 +252,33 @@ function KHistory(options = {}) { } this.cancel_selected = function () { - var opes_to_cancel = this.$container.find('.ope.ui-selected').map(function () { - return $(this).data('id'); - }).toArray(); - if (opes_to_cancel.length > 0) - this.cancel(opes_to_cancel); + var opes_to_cancel = { + "transfers": [], + "operations": [], + } + this.$container.find('.ope.ui-selected').each(function () { + if ($(this).data("transfergroup")) + opes_to_cancel["transfers"].push($(this).data("id")); + else + opes_to_cancel["operations"].push($(this).data("id")); + }); + if (opes_to_cancel["transfers"].length > 0 && opes_to_cancel["operations"].length > 0) { + // Lancer 2 requêtes AJAX et gérer tous les cas d'erreurs possibles est trop complexe + $.alert({ + title: 'Erreur', + content: "Impossible de supprimer des transferts et des opérations en même temps !", + backgroundDismiss: true, + animation: 'top', + closeAnimation: 'bottom', + keyboardEnabled: true, + }); + } else if (opes_to_cancel["transfers"].length > 0) { + delete opes_to_cancel["operations"]; + this.cancel("transfers", opes_to_cancel); + } else if (opes_to_cancel["operations"].length > 0) { + delete opes_to_cancel["transfers"]; + this.cancel("operations", opes_to_cancel); + } } } diff --git a/kfet/urls.py b/kfet/urls.py index 03c174f3..88220845 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -219,8 +219,8 @@ urlpatterns = [ ), path( "k-psul/cancel_operations", - views.kpsul_cancel_operations, - name="kfet.kpsul.cancel_operations", + views.cancel_operations, + name="kfet.operations.cancel", ), path( "k-psul/articles_data", diff --git a/kfet/views.py b/kfet/views.py index d7a7be55..c06bd074 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1208,7 +1208,7 @@ def kpsul_perform_operations(request): @teamkfet_required @kfet_password_auth -def kpsul_cancel_operations(request): +def cancel_operations(request): # Pour la réponse data = {"canceled": [], "warnings": {}, "errors": {}} From 550a073d5176c60d53f5e0d95be2c3fde7b075eb Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 14:17:35 +0100 Subject: [PATCH 10/44] Fix tests again --- kfet/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 71c9c72c..852d5bf1 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -3239,7 +3239,7 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): """ - url_name = "kfet.kpsul.cancel_operations" + url_name = "kfet.operations.cancel" url_expected = "/k-fet/k-psul/cancel_operations" http_methods = ["POST"] From 49ef8b3c15a813c9c271622d078b1b839a0e71d0 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 15:09:41 +0100 Subject: [PATCH 11/44] Pas besoin de ws pour les suppressions --- kfet/static/kfet/js/history.js | 41 +++++++++++++++++++++------------- kfet/templates/kfet/kpsul.html | 7 ------ kfet/views.py | 40 +++++++++++++-------------------- 3 files changed, 41 insertions(+), 47 deletions(-) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index b398a65c..22aff4d6 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -35,14 +35,14 @@ function KHistory(options = {}) { var is_cof = opegroup['is_cof']; var type = opegroup['type'] switch (type) { - case 'opegroup': + case 'operation': for (let ope of opegroup['opes']) { var $ope = this._opeHtml(ope, is_cof, trigramme); $ope.data('opegroup', opegroup['id']); $opegroup.after($ope); } break; - case 'transfergroup': + case 'transfer': for (let transfer of opegroup['opes']) { var $transfer = this._transferHtml(transfer); $transfer.data('transfergroup', opegroup['id']); @@ -80,7 +80,7 @@ function KHistory(options = {}) { } $ope_html - .data('type', 'ope') + .data('type', 'operation') .data('id', ope['id']) .find('.amount').text(amount).end() .find('.infos1').text(infos1).end() @@ -119,7 +119,7 @@ function KHistory(options = {}) { this.cancelOpe = function (ope, $ope = null) { if (!$ope) - $ope = this.findOpe(ope['id'], ope["type"]); + $ope = this.findOpe(ope["id"], ope["type"]); var cancel = 'Annulé'; var canceled_at = dateUTCToParis(ope['canceled_at']); @@ -135,13 +135,13 @@ function KHistory(options = {}) { switch (type) { - case 'opegroup': + case 'operation': var $opegroup_html = $(this.template_opegroup); var trigramme = opegroup['on_acc__trigramme']; var amount = amountDisplay( parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); break; - case 'transfergroup': + case 'transfer': var $opegroup_html = $(this.template_transfergroup); $opegroup_html.find('.infos').text('Transferts').end() var trigramme = ''; @@ -183,20 +183,20 @@ function KHistory(options = {}) { return $day.data('date', at_ser).text(at.format('D MMMM YYYY')); } - this.findOpeGroup = function (id) { + this.findOpeGroup = function (id, type = "operation") { return this.$container.find('.opegroup').filter(function () { - return $(this).data('opegroup') == id + return ($(this).data('id') == id && $(this).data("type") == type) }); } - this.findOpe = function (id, type = 'ope') { + this.findOpe = function (id, type = 'operation') { return this.$container.find('.ope').filter(function () { return ($(this).data('id') == id && $(this).data('type') == type) }); } - this.cancelOpeGroup = function (opegroup) { - var $opegroup = this.findOpeGroup(opegroup['id']); + this.update_opegroup = function (opegroup, type = "operation") { + var $opegroup = this.findOpeGroup(opegroup['id'], type); var trigramme = $opegroup.find('.trigramme').text(); var amount = amountDisplay( parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); @@ -218,13 +218,14 @@ function KHistory(options = {}) { }); } - this.cancel = function (type, opes, password = "") { + this._cancel = function (type, opes, password = "") { if (window.lock == 1) return false window.lock = 1; + var that = this; $.ajax({ dataType: "json", - url: django_urls[`kfet.${type}.cancel`](), + url: django_urls[`kfet.${type}s.cancel`](), method: "POST", data: opes, beforeSend: function ($xhr) { @@ -235,6 +236,16 @@ function KHistory(options = {}) { }).done(function (data) { window.lock = 0; + that.$container.find('.ui-selected').removeClass('ui-selected'); + for (let ope of data["canceled"]) { + ope["type"] = type; + that.cancelOpe(ope); + } + if (type == "operation") { + for (let opegroup of data["opegroups_to_update"]) { + that.update_opegroup(opegroup) + } + } }).fail(function ($xhr) { var data = $xhr.responseJSON; switch ($xhr.status) { @@ -274,10 +285,10 @@ function KHistory(options = {}) { }); } else if (opes_to_cancel["transfers"].length > 0) { delete opes_to_cancel["operations"]; - this.cancel("transfers", opes_to_cancel); + this._cancel("transfer", opes_to_cancel); } else if (opes_to_cancel["operations"].length > 0) { delete opes_to_cancel["transfers"]; - this.cancel("operations", opes_to_cancel); + this._cancel("operation", opes_to_cancel); } } } diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index c745d598..0b7f946e 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -1256,13 +1256,6 @@ $(document).ready(function() { for (var i=0; i Date: Mon, 23 Dec 2019 15:16:04 +0100 Subject: [PATCH 12/44] On renvoie les promesses --- kfet/static/kfet/js/history.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index 22aff4d6..5608c02f 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -206,7 +206,7 @@ function KHistory(options = {}) { this.fetch = function (fetch_options) { options = $.extend({}, this.fetch_options, fetch_options); var that = this; - $.ajax({ + return $.ajax({ dataType: "json", url: django_urls["kfet.history.json"](), method: "POST", @@ -223,7 +223,7 @@ function KHistory(options = {}) { return false window.lock = 1; var that = this; - $.ajax({ + return $.ajax({ dataType: "json", url: django_urls[`kfet.${type}s.cancel`](), method: "POST", From f7ce2edd87f1d6cdaf97c07d13fa1a6b3f5b5e81 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 18:54:58 +0100 Subject: [PATCH 13/44] Plug new history in templates --- kfet/templates/kfet/account_read.html | 32 +++---- kfet/templates/kfet/history.html | 129 +------------------------- kfet/templates/kfet/transfers.html | 105 ++++----------------- 3 files changed, 35 insertions(+), 231 deletions(-) diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index bbd1cff7..d1af035a 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -5,6 +5,7 @@ {% block extra_head %} + @@ -81,7 +82,7 @@ $(document).ready(function() { {% endif %} -
+
@@ -93,29 +94,22 @@ $(document).ready(function() { khistory = new KHistory({ display_trigramme: false, - }); - - function getHistory() { - var data = { + fetch_options: { 'accounts': [{{ account.pk }}], } + }); - $.ajax({ - dataType: "json", - url : "{% url 'kfet.history.json' %}", - method : "POST", - data : data, - }) - .done(function(data) { - for (var i=0; i diff --git a/kfet/templates/kfet/history.html b/kfet/templates/kfet/history.html index ae63358e..204e0d57 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -5,6 +5,7 @@ {{ filter_form.media }} + {% endblock %} @@ -40,6 +41,8 @@ $(document).ready(function() { settings = { 'subvention_cof': parseFloat({{ kfet_config.subvention_cof|unlocalize }})} + window.lock = 0; + khistory = new KHistory(); var $from_date = $('#id_from_date'); @@ -67,16 +70,7 @@ $(document).ready(function() { var accounts = getSelectedMultiple($accounts); data['accounts'] = accounts; - $.ajax({ - dataType: "json", - url : "{% url 'kfet.history.json' %}", - method : "POST", - data : data, - }) - .done(function(data) { - for (var i=0; i 0) - confirmCancel(opes_to_cancel); + khistory.cancel_selected() } }); - - function confirmCancel(opes_to_cancel) { - var nb = opes_to_cancel.length; - var content = nb+" opérations vont être annulées"; - $.confirm({ - title: 'Confirmation', - content: content, - backgroundDismiss: true, - animation: 'top', - closeAnimation: 'bottom', - keyboardEnabled: true, - confirm: function() { - cancelOperations(opes_to_cancel); - } - }); - } - - function requestAuth(data, callback) { - var content = getErrorsHtml(data); - content += '', - $.confirm({ - title: 'Authentification requise', - content: content, - backgroundDismiss: true, - animation:'top', - closeAnimation:'bottom', - keyboardEnabled: true, - confirm: function() { - var password = this.$content.find('input').val(); - callback(password); - }, - onOpen: function() { - var that = this; - this.$content.find('input').on('keypress', function(e) { - if (e.keyCode == 13) - that.$confirmButton.click(); - }); - }, - }); - } - - function getErrorsHtml(data) { - var content = ''; - if ('missing_perms' in data['errors']) { - content += 'Permissions manquantes'; - content += '
    '; - for (var i=0; i'; - content += '
'; - } - if ('negative' in data['errors']) { - var url_base = "{% url 'kfet.account.update' LIQ}"; - url_base = base_url(0, url_base.length-8); - for (var i=0; iAutorisation de négatif requise pour '+data['errors']['negative'][i]+''; - } - } - return content; - } - - function cancelOperations(opes_array, password = '') { - var data = { 'operations' : opes_array } - $.ajax({ - dataType: "json", - url : "{% url 'kfet.kpsul.cancel_operations' %}", - method : "POST", - data : data, - beforeSend: function ($xhr) { - $xhr.setRequestHeader("X-CSRFToken", csrftoken); - if (password != '') - $xhr.setRequestHeader("KFetPassword", password); - }, - - }) - .done(function(data) { - khistory.$container.find('.ui-selected').removeClass('ui-selected'); - }) - .fail(function($xhr) { - var data = $xhr.responseJSON; - switch ($xhr.status) { - case 403: - requestAuth(data, function(password) { - cancelOperations(opes_array, password); - }); - break; - case 400: - displayErrors(getErrorsHtml(data)); - break; - } - - }); - } - - getHistory(); }); diff --git a/kfet/templates/kfet/transfers.html b/kfet/templates/kfet/transfers.html index f6778b3f..83f20c70 100644 --- a/kfet/templates/kfet/transfers.html +++ b/kfet/templates/kfet/transfers.html @@ -1,9 +1,16 @@ {% extends 'kfet/base_col_2.html' %} {% load staticfiles %} +{% load l10n staticfiles widget_tweaks %} {% block title %}Transferts{% endblock %} {% block header-title %}Transferts{% endblock %} +{% block extra_head %} + + + +{% endblock %} + {% block fixed %}
@@ -16,109 +23,31 @@ {% block main %} -
- {% for transfergroup in transfergroups %} -
- {{ transfergroup.at }} - {{ transfergroup.valid_by.trigramme }} - {{ transfergroup.comment }} -
- {% for transfer in transfergroup.transfers.all %} -
- {{ transfer.amount }} € - {{ transfer.from_acc.trigramme }} - - {{ transfer.to_acc.trigramme }} -
- {% endfor %} - {% endfor %} -
+ +
From 74384451109b95dc73558d7235b6013e90276c40 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 18:55:15 +0100 Subject: [PATCH 14/44] Last tweaks --- kfet/static/kfet/js/history.js | 14 +++++++------- kfet/views.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index 5608c02f..98bc7a2a 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -14,7 +14,7 @@ function KHistory(options = {}) { if ($(this).hasClass('opegroup')) { var opegroup = $(this).data('id'); $(this).siblings('.ope').filter(function () { - return $(this).data('opegroup') == opegroup + return $(this).data('group') == opegroup }).addClass('ui-selected'); } }); @@ -38,14 +38,16 @@ function KHistory(options = {}) { case 'operation': for (let ope of opegroup['opes']) { var $ope = this._opeHtml(ope, is_cof, trigramme); - $ope.data('opegroup', opegroup['id']); + $ope.data('group', opegroup['id']); + $ope.data('group_type', type); $opegroup.after($ope); } break; case 'transfer': for (let transfer of opegroup['opes']) { var $transfer = this._transferHtml(transfer); - $transfer.data('transfergroup', opegroup['id']); + $transfer.data('group', opegroup['id']); + $transfer.data('group_type', type); $opegroup.after($transfer); } break; @@ -268,10 +270,8 @@ function KHistory(options = {}) { "operations": [], } this.$container.find('.ope.ui-selected').each(function () { - if ($(this).data("transfergroup")) - opes_to_cancel["transfers"].push($(this).data("id")); - else - opes_to_cancel["operations"].push($(this).data("id")); + type = $(this).data("group_type"); + opes_to_cancel[`${type}s`].push($(this).data("id")); }); if (opes_to_cancel["transfers"].length > 0 && opes_to_cancel["operations"].length > 0) { // Lancer 2 requêtes AJAX et gérer tous les cas d'erreurs possibles est trop complexe diff --git a/kfet/views.py b/kfet/views.py index 4944546e..e4fd2564 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1799,7 +1799,7 @@ def cancel_transfers(request): .filter(pk__in=transfers) .order_by("pk") ) - data["canceled"] = transfers + data["canceled"] = list(transfers) if transfers_already_canceled: data["warnings"]["already_canceled"] = transfers_already_canceled return JsonResponse(data) From fb4455af39cbfbb32346863dab3421270892f228 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 23 Dec 2019 18:55:45 +0100 Subject: [PATCH 15/44] Fix tests 3 --- kfet/tests/test_views.py | 185 ++++++++++++++++++++++----------------- 1 file changed, 105 insertions(+), 80 deletions(-) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 852d5bf1..853ec449 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -3358,7 +3358,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": {}, + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3370,26 +3389,7 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.kpsul_consumer_mock.group_send.assert_called_with( "kfet.kpsul", - { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], - "checkouts": [], - "articles": [{"id": self.article.pk, "stock": 22}], - }, + {"checkouts": [], "articles": [{"id": self.article.pk, "stock": 22}]}, ) def test_purchase_with_addcost(self): @@ -3546,7 +3546,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": {}, + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3559,22 +3578,6 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.kpsul_consumer_mock.group_send.assert_called_with( "kfet.kpsul", { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], "checkouts": [{"id": self.checkout.pk, "balance": Decimal("89.25")}], "articles": [], }, @@ -3630,7 +3633,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": {}, + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3643,22 +3665,6 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.kpsul_consumer_mock.group_send.assert_called_with( "kfet.kpsul", { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], "checkouts": [{"id": self.checkout.pk, "balance": Decimal("110.75")}], "articles": [], }, @@ -3714,7 +3720,26 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): ) self.assertDictEqual( - json_data, {"canceled": [operation.pk], "errors": {}, "warnings": {}} + json_data, + { + "canceled": [ + { + "id": operation.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + } + ], + "errors": {}, + "warnings": {}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], + }, ) self.account.refresh_from_db() @@ -3725,27 +3750,7 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): self.assertEqual(self.checkout.balance, Decimal("100.00")) self.kpsul_consumer_mock.group_send.assert_called_with( - "kfet.kpsul", - { - "opegroups": [ - { - "cancellation": True, - "id": group.pk, - "amount": Decimal("0.00"), - "is_cof": False, - } - ], - "opes": [ - { - "cancellation": True, - "id": operation.pk, - "canceled_by__trigramme": None, - "canceled_at": self.now + timedelta(seconds=15), - } - ], - "checkouts": [], - "articles": [], - }, + "kfet.kpsul", {"checkouts": [], "articles": []}, ) @mock.patch("django.utils.timezone.now") @@ -3966,13 +3971,33 @@ class KPsulCancelOperationsViewTests(ViewTestCaseMixin, TestCase): group.refresh_from_db() self.assertEqual(group.amount, Decimal("10.75")) self.assertEqual(group.opes.exclude(canceled_at=None).count(), 3) - + self.maxDiff = None self.assertDictEqual( json_data, { - "canceled": [operation1.pk, operation2.pk], - "warnings": {"already_canceled": [operation3.pk]}, + "canceled": [ + { + "id": operation1.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + }, + { + "id": operation2.id, + # l'encodage des dates en JSON est relou... + "canceled_at": mock.ANY, + "canceled_by__trigramme": None, + }, + ], "errors": {}, + "warnings": {"already_canceled": [operation3.pk]}, + "opegroups_to_update": [ + { + "id": group.pk, + "amount": str(group.amount), + "is_cof": group.is_cof, + } + ], }, ) From 677ba5b92e7b5883ed1f648294f7707dabcbeb0d Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 25 Dec 2019 11:34:34 +0100 Subject: [PATCH 16/44] Fix : le ws K-Psul remarche --- kfet/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kfet/views.py b/kfet/views.py index e4fd2564..3122636b 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1159,7 +1159,7 @@ def kpsul_perform_operations(request): websocket_data["opegroups"] = [ { "add": True, - "type": "opegroup", + "type": "operation", "id": operationgroup.pk, "amount": operationgroup.amount, "checkout__name": operationgroup.checkout.name, From 786c8f132f03fa5f5c03a5ebfef823240c3eb4ed Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 25 Dec 2019 11:53:08 +0100 Subject: [PATCH 17/44] =?UTF-8?q?Fix:=20tests=20cass=C3=A9s=20par=20commit?= =?UTF-8?q?=20pr=C3=A9c=C3=A9dent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kfet/tests/test_views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index 853ec449..e69c81d9 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -2000,7 +2000,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, - "type": "opegroup", + "type": "operation", "at": mock.ANY, "amount": Decimal("-5.00"), "checkout__name": "Checkout", @@ -2273,7 +2273,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, - "type": "opegroup", + "type": "operation", "at": mock.ANY, "amount": Decimal("10.75"), "checkout__name": "Checkout", @@ -2448,7 +2448,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, - "type": "opegroup", + "type": "operation", "at": mock.ANY, "amount": Decimal("-10.75"), "checkout__name": "Checkout", @@ -2607,7 +2607,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, - "type": "opegroup", + "type": "operation", "at": mock.ANY, "amount": Decimal("10.75"), "checkout__name": "Checkout", @@ -3177,7 +3177,7 @@ class KPsulPerformOperationsViewTests(ViewTestCaseMixin, TestCase): "opegroups": [ { "add": True, - "type": "opegroup", + "type": "operation", "at": mock.ANY, "amount": Decimal("-9.00"), "checkout__name": "Checkout", From 8d11044610dd25c5656e97947e5afd9f2552d5f0 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 25 Dec 2019 12:35:46 +0100 Subject: [PATCH 18/44] =?UTF-8?q?Fix:=20pas=20d'erreur=20quand=20pas=20de?= =?UTF-8?q?=20compte=20K-F=C3=AAt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kfet/tests/test_views.py | 10 ++++++++-- kfet/views.py | 11 +++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index e69c81d9..3baed2c3 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from decimal import Decimal from unittest import mock -from django.contrib.auth.models import Group +from django.contrib.auth.models import Group, User from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone @@ -4151,12 +4151,18 @@ class HistoryJSONViewTests(ViewTestCaseMixin, TestCase): url_expected = "/k-fet/history.json" auth_user = "user" - auth_forbidden = [None] + auth_forbidden = [None, "noaccount"] def test_ok(self): r = self.client.post(self.url) self.assertEqual(r.status_code, 200) + def get_users_extra(self): + noaccount = User.objects.create(username="noaccount") + noaccount.set_password("noaccount") + noaccount.save() + return {"noaccount": noaccount} + class AccountReadJSONViewTests(ViewTestCaseMixin, TestCase): url_name = "kfet.account.read.json" diff --git a/kfet/views.py b/kfet/views.py index 3122636b..9d2d2c09 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1423,10 +1423,13 @@ def history_json(request): ) if not request.user.has_perm("kfet.is_team"): - acc = request.user.profile.account_kfet - transfer_queryset_prefetch = transfer_queryset_prefetch.filter( - Q(from_acc=acc) | Q(to_acc=acc) - ) + try: + acc = request.user.profile.account_kfet + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc=acc) | Q(to_acc=acc) + ) + except Account.DoesNotExist: + return JsonResponse({}, status=403) transfer_prefetch = Prefetch( "transfers", queryset=transfer_queryset_prefetch, to_attr="filtered_transfers" From b450cb09e681a163d1b7fa0e1b1164c856c43640 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 25 Dec 2019 12:39:41 +0100 Subject: [PATCH 19/44] Petit refactor --- kfet/views.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index 9d2d2c09..3d9ff79a 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1407,16 +1407,13 @@ def history_json(request): transfers_only = request.POST.get("transfersonly", None) opes_only = request.POST.get("opesonly", None) - # Construction de la requête (sur les opérations) pour le prefetch - ope_queryset_prefetch = Operation.objects.select_related( - "article", "canceled_by", "addcost_for" - ) - ope_prefetch = Prefetch("opes", queryset=ope_queryset_prefetch) + # Construction de la requête (sur les transferts) pour le prefetch transfer_queryset_prefetch = Transfer.objects.select_related( "from_acc", "to_acc", "canceled_by" ) + # Le check sur les comptes est dans le prefetch pour les transferts if accounts: transfer_queryset_prefetch = transfer_queryset_prefetch.filter( Q(from_acc__trigramme__in=accounts) | Q(to_acc__trigramme__in=accounts) @@ -1435,6 +1432,12 @@ def history_json(request): "transfers", queryset=transfer_queryset_prefetch, to_attr="filtered_transfers" ) + # Construction de la requête (sur les opérations) pour le prefetch + ope_queryset_prefetch = Operation.objects.select_related( + "article", "canceled_by", "addcost_for" + ) + ope_prefetch = Prefetch("opes", queryset=ope_queryset_prefetch) + # Construction de la requête principale opegroups = ( OperationGroup.objects.prefetch_related(ope_prefetch) From 931b2c4e1f23ed940f96ed0dc508cfcbda28fe24 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Wed, 25 Dec 2019 17:28:36 +0100 Subject: [PATCH 20/44] Refactor js code Harmonize history denominations * opegroups/transfergroups -> groups * opes/transfers -> entries * snake/camel case -> snake case --- kfet/static/kfet/css/history.css | 42 ++++------ kfet/static/kfet/js/history.js | 140 +++++++++++++++---------------- kfet/templates/kfet/kpsul.html | 6 +- kfet/tests/test_views.py | 50 +++++------ kfet/views.py | 24 +++--- 5 files changed, 127 insertions(+), 135 deletions(-) diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index e1e1ab42..42e73527 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -20,7 +20,7 @@ z-index:10; } -#history .opegroup { +#history .group { height:30px; line-height:30px; background-color: #c63b52; @@ -30,29 +30,29 @@ overflow:auto; } -#history .opegroup .time { +#history .group .time { width:70px; } -#history .opegroup .trigramme { +#history .group .trigramme { width:55px; text-align:right; } -#history .opegroup .amount { +#history .group .amount { text-align:right; width:90px; } -#history .opegroup .valid_by { +#history .group .valid_by { padding-left:20px } -#history .opegroup .comment { +#history .group .comment { padding-left:20px; } -#history .ope { +#history .entry { position:relative; height:25px; line-height:24px; @@ -61,38 +61,38 @@ overflow:auto; } -#history .ope .amount { +#history .entry .amount { width:50px; text-align:right; } -#history .ope .infos1 { +#history .entry .infos1 { width:80px; text-align:right; } -#history .ope .infos2 { +#history .entry .infos2 { padding-left:15px; } -#history .ope .addcost { +#history .entry .addcost { padding-left:20px; } -#history .ope .canceled { +#history .entry .canceled { padding-left:20px; } -#history div.ope.ui-selected, #history div.ope.ui-selecting { +#history div.entry.ui-selected, #history div.entry.ui-selecting { background-color:rgba(200,16,46,0.6); color:#FFF; } -#history .ope.canceled, #history .transfer.canceled { +#history .entry.canceled { color:#444; } -#history .ope.canceled::before, #history.transfer.canceled::before { +#history .entry.canceled::before { position: absolute; content: ' '; width:100%; @@ -101,19 +101,11 @@ border-top: 1px solid rgba(200,16,46,0.5); } -#history .transfer .amount { - width:80px; -} - -#history .transfer .from_acc { - padding-left:10px; -} - -#history .opegroup .infos { +#history .group .infos { text-align:center; width:145px; } -#history .ope .glyphicon { +#history .entry .glyphicon { padding-left:15px; } diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index 98bc7a2a..540c8239 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -2,19 +2,20 @@ function dateUTCToParis(date) { return moment.tz(date, 'UTC').tz('Europe/Paris'); } +// TODO : classifier (later) function KHistory(options = {}) { $.extend(this, KHistory.default_options, options); this.$container = $(this.container); this.$container.selectable({ - filter: 'div.opegroup, div.ope', + filter: 'div.group, div.entry', selected: function (e, ui) { $(ui.selected).each(function () { - if ($(this).hasClass('opegroup')) { - var opegroup = $(this).data('id'); - $(this).siblings('.ope').filter(function () { - return $(this).data('group') == opegroup + if ($(this).hasClass('group')) { + var id = $(this).data('id'); + $(this).siblings('.entry').filter(function () { + return $(this).data('group_id') == id }).addClass('ui-selected'); } }); @@ -25,36 +26,35 @@ function KHistory(options = {}) { this.$container.html(''); }; - this.addOpeGroup = function (opegroup) { - var $day = this._getOrCreateDay(opegroup['at']); - var $opegroup = this._opeGroupHtml(opegroup); + this.add_history_group = function (group) { + var $day = this._get_or_create_day(group['at']); + var $group = this._group_html(group); - $day.after($opegroup); + $day.after($group); - var trigramme = opegroup['on_acc_trigramme']; - var is_cof = opegroup['is_cof']; - var type = opegroup['type'] + var trigramme = group['on_acc_trigramme']; + var is_cof = group['is_cof']; + var type = group['type'] + // TODO : simplifier ça ? switch (type) { case 'operation': - for (let ope of opegroup['opes']) { - var $ope = this._opeHtml(ope, is_cof, trigramme); - $ope.data('group', opegroup['id']); - $ope.data('group_type', type); - $opegroup.after($ope); + for (let ope of group['entries']) { + var $ope = this._ope_html(ope, is_cof, trigramme); + $ope.data('group_id', group['id']); + $group.after($ope); } break; case 'transfer': - for (let transfer of opegroup['opes']) { - var $transfer = this._transferHtml(transfer); - $transfer.data('group', opegroup['id']); - $transfer.data('group_type', type); - $opegroup.after($transfer); + for (let transfer of group['entries']) { + var $transfer = this._transfer_html(transfer); + $transfer.data('group_id', group['id']); + $group.after($transfer); } break; } } - this._opeHtml = function (ope, is_cof, trigramme) { + this._ope_html = function (ope, is_cof, trigramme) { var $ope_html = $(this.template_ope); var parsed_amount = parseFloat(ope['amount']); var amount = amountDisplay(parsed_amount, is_cof, trigramme); @@ -95,12 +95,12 @@ function KHistory(options = {}) { } if (ope['canceled_at']) - this.cancelOpe(ope, $ope_html); + this.cancel_entry(ope, $ope_html); return $ope_html; } - this._transferHtml = function (transfer) { + this._transfer_html = function (transfer) { var $transfer_html = $(this.template_transfer); var parsed_amount = parseFloat(transfer['amount']); var amount = parsed_amount.toFixed(2) + '€'; @@ -113,67 +113,67 @@ function KHistory(options = {}) { .find('.infos2').text(transfer['to_acc']).end(); if (transfer['canceled_at']) - this.cancelOpe(transfer, $transfer_html); + this.cancel_entry(transfer, $transfer_html); return $transfer_html; } - this.cancelOpe = function (ope, $ope = null) { - if (!$ope) - $ope = this.findOpe(ope["id"], ope["type"]); + this.cancel_entry = function (entry, $entry = null) { + if (!$entry) + $entry = this.find_entry(entry["id"], entry["type"]); var cancel = 'Annulé'; - var canceled_at = dateUTCToParis(ope['canceled_at']); - if (ope['canceled_by__trigramme']) - cancel += ' par ' + ope['canceled_by__trigramme']; + var canceled_at = dateUTCToParis(entry['canceled_at']); + if (entry['canceled_by__trigramme']) + cancel += ' par ' + entry['canceled_by__trigramme']; cancel += ' le ' + canceled_at.format('DD/MM/YY à HH:mm:ss'); - $ope.addClass('canceled').find('.canceled').text(cancel); + $entry.addClass('canceled').find('.canceled').text(cancel); } - this._opeGroupHtml = function (opegroup) { - var type = opegroup['type']; + this._group_html = function (group) { + var type = group['type']; switch (type) { case 'operation': - var $opegroup_html = $(this.template_opegroup); - var trigramme = opegroup['on_acc__trigramme']; + var $group_html = $(this.template_opegroup); + var trigramme = group['on_acc__trigramme']; var amount = amountDisplay( - parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); + parseFloat(group['amount']), group['is_cof'], trigramme); break; case 'transfer': - var $opegroup_html = $(this.template_transfergroup); - $opegroup_html.find('.infos').text('Transferts').end() + var $group_html = $(this.template_transfergroup); + $group_html.find('.infos').text('Transferts').end() var trigramme = ''; var amount = ''; break; } - var at = dateUTCToParis(opegroup['at']).format('HH:mm:ss'); - var comment = opegroup['comment'] || ''; + var at = dateUTCToParis(group['at']).format('HH:mm:ss'); + var comment = group['comment'] || ''; - $opegroup_html + $group_html .data('type', type) - .data('id', opegroup['id']) + .data('id', group['id']) .find('.time').text(at).end() .find('.amount').text(amount).end() .find('.comment').text(comment).end() .find('.trigramme').text(trigramme).end(); if (!this.display_trigramme) - $opegroup_html.find('.trigramme').remove(); - $opegroup_html.find('.info').remove(); + $group_html.find('.trigramme').remove(); + $group_html.find('.info').remove(); - if (opegroup['valid_by__trigramme']) - $opegroup_html.find('.valid_by').text('Par ' + opegroup['valid_by__trigramme']); + if (group['valid_by__trigramme']) + $group_html.find('.valid_by').text('Par ' + group['valid_by__trigramme']); - return $opegroup_html; + return $group_html; } - this._getOrCreateDay = function (date) { + this._get_or_create_day = function (date) { var at = dateUTCToParis(date); var at_ser = at.format('YYYY-MM-DD'); var $day = this.$container.find('.day').filter(function () { @@ -185,24 +185,24 @@ function KHistory(options = {}) { return $day.data('date', at_ser).text(at.format('D MMMM YYYY')); } - this.findOpeGroup = function (id, type = "operation") { - return this.$container.find('.opegroup').filter(function () { + this.find_group = function (id, type = "operation") { + return this.$container.find('.group').filter(function () { return ($(this).data('id') == id && $(this).data("type") == type) }); } - this.findOpe = function (id, type = 'operation') { - return this.$container.find('.ope').filter(function () { + this.find_entry = function (id, type = 'operation') { + return this.$container.find('.entry').filter(function () { return ($(this).data('id') == id && $(this).data('type') == type) }); } - this.update_opegroup = function (opegroup, type = "operation") { - var $opegroup = this.findOpeGroup(opegroup['id'], type); - var trigramme = $opegroup.find('.trigramme').text(); + this.update_opegroup = function (group, type = "operation") { + var $group = this.find_group(group['id'], type); + var trigramme = $group.find('.trigramme').text(); var amount = amountDisplay( - parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); - $opegroup.find('.amount').text(amount); + parseFloat(group['amount']), group['is_cof'], trigramme); + $group.find('.amount').text(amount); } this.fetch = function (fetch_options) { @@ -214,8 +214,8 @@ function KHistory(options = {}) { method: "POST", data: options, }).done(function (data) { - for (let opegroup of data['opegroups']) { - that.addOpeGroup(opegroup); + for (let group of data['groups']) { + that.add_history_group(group); } }); } @@ -239,9 +239,9 @@ function KHistory(options = {}) { }).done(function (data) { window.lock = 0; that.$container.find('.ui-selected').removeClass('ui-selected'); - for (let ope of data["canceled"]) { - ope["type"] = type; - that.cancelOpe(ope); + for (let entry of data["canceled"]) { + entry["type"] = type; + that.cancel_entry(entry); } if (type == "operation") { for (let opegroup of data["opegroups_to_update"]) { @@ -269,8 +269,8 @@ function KHistory(options = {}) { "transfers": [], "operations": [], } - this.$container.find('.ope.ui-selected').each(function () { - type = $(this).data("group_type"); + this.$container.find('.entry.ui-selected').each(function () { + type = $(this).data("type"); opes_to_cancel[`${type}s`].push($(this).data("id")); }); if (opes_to_cancel["transfers"].length > 0 && opes_to_cancel["operations"].length > 0) { @@ -296,9 +296,9 @@ function KHistory(options = {}) { KHistory.default_options = { container: '#history', template_day: '
', - template_opegroup: '
', - template_transfergroup: '
', - template_ope: '
', - template_transfer: '
', + template_opegroup: '
', + template_transfergroup: '
', + template_ope: '
', + template_transfer: '
', display_trigramme: true, } diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index 0b7f946e..7b292087 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -1253,9 +1253,9 @@ $(document).ready(function() { // ----- OperationWebSocket.add_handler(function(data) { - for (var i=0; i Date: Thu, 26 Dec 2019 18:58:55 +0100 Subject: [PATCH 21/44] Simplify transfer view --- kfet/urls.py | 2 +- kfet/views.py | 15 +++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/kfet/urls.py b/kfet/urls.py index 88220845..12c06d26 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -252,7 +252,7 @@ urlpatterns = [ # ----- # Transfers urls # ----- - path("transfers/", views.transfers, name="kfet.transfers"), + path("transfers/", views.TransferView.as_view(), name="kfet.transfers"), path("transfers/new", views.transfers_create, name="kfet.transfers.create"), path("transfers/perform", views.perform_transfers, name="kfet.transfers.perform"), path("transfers/cancel", views.cancel_transfers, name="kfet.transfers.cancel"), diff --git a/kfet/views.py b/kfet/views.py index d5ab30a7..70e5d453 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1598,18 +1598,9 @@ config_update = permission_required("kfet.change_config")(SettingsUpdate.as_view # ----- -@teamkfet_required -def transfers(request): - transfers_pre = Prefetch( - "transfers", queryset=(Transfer.objects.select_related("from_acc", "to_acc")) - ) - - transfergroups = ( - TransferGroup.objects.select_related("valid_by") - .prefetch_related(transfers_pre) - .order_by("-at") - ) - return render(request, "kfet/transfers.html", {"transfergroups": transfergroups}) +@method_decorator(teamkfet_required, name="dispatch") +class TransferView(TemplateView): + template_name = "kfet/transfers.html" @teamkfet_required From 9eebc7fb2285bc8dd940ab9041ce4ceef99445ca Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Thu, 23 Apr 2020 13:13:31 +0200 Subject: [PATCH 22/44] Fix: les transferts apparaissent dans l'historique perso --- kfet/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/kfet/views.py b/kfet/views.py index 70e5d453..2d13b3d3 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1404,8 +1404,8 @@ def history_json(request): to_date = request.POST.get("to", None) checkouts = request.POST.getlist("checkouts[]", None) accounts = request.POST.getlist("accounts[]", None) - transfers_only = request.POST.get("transfersonly", None) - opes_only = request.POST.get("opesonly", None) + transfers_only = request.POST.get("transfersonly", False) + opes_only = request.POST.get("opesonly", False) # Construction de la requête (sur les transferts) pour le prefetch @@ -1416,7 +1416,7 @@ def history_json(request): # Le check sur les comptes est dans le prefetch pour les transferts if accounts: transfer_queryset_prefetch = transfer_queryset_prefetch.filter( - Q(from_acc__trigramme__in=accounts) | Q(to_acc__trigramme__in=accounts) + Q(from_acc__in=accounts) | Q(to_acc__in=accounts) ) if not request.user.has_perm("kfet.is_team"): @@ -1458,14 +1458,14 @@ def history_json(request): opegroups = opegroups.filter(at__lt=to_date) transfergroups = transfergroups.filter(at__lt=to_date) if checkouts: - opegroups = opegroups.filter(checkout_id__in=checkouts) + opegroups = opegroups.filter(checkout__in=checkouts) transfergroups = TransferGroup.objects.none() if transfers_only: opegroups = OperationGroup.objects.none() if opes_only: transfergroups = TransferGroup.objects.none() if accounts: - opegroups = opegroups.filter(on_acc_id__in=accounts) + opegroups = opegroups.filter(on_acc__in=accounts) # Un non-membre de l'équipe n'a que accès à son historique if not request.user.has_perm("kfet.is_team"): opegroups = opegroups.filter(on_acc=request.user.profile.account_kfet) From 6362740a77618743c52e664dc18a6aaf17709821 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Thu, 23 Apr 2020 13:54:10 +0200 Subject: [PATCH 23/44] =?UTF-8?q?Fix:=20`history.html`=20=20marche=20(?= =?UTF-8?q?=C3=A0=20peu=20pr=C3=A8s)=20correctement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kfet/templates/kfet/history.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/kfet/templates/kfet/history.html b/kfet/templates/kfet/history.html index 204e0d57..94bba48c 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -28,6 +28,9 @@
  • Comptes {{ filter_form.accounts }}
  • +
    + +
    {% endblock %} @@ -71,7 +74,7 @@ $(document).ready(function() { data['accounts'] = accounts; khistory.fetch(data).done(function () { - var nb_opes = khistory.$container.find('.ope:not(.canceled)').length; + var nb_opes = khistory.$container.find('.entry:not(.canceled)').length; $('#nb_opes').text(nb_opes); }); } @@ -106,7 +109,7 @@ $(document).ready(function() { countSelected: "# sur %" }); - $("input").on('dp.change change', function() { + $("#btn-fetch").on('click', function() { khistory.reset(); getHistory(); }); From c8b8c90580a4c8ea858bfc0a1cbc898ca0b6799e Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Fri, 24 Apr 2020 21:03:16 +0200 Subject: [PATCH 24/44] CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 269e5194..9ecea3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre - Les boutons "afficher/cacher" des mails et noms des participant⋅e⋅s à un spectacle BdA fonctionnent à nouveau. +### Nouvelles fonctionnalités + +- Les transferts apparaissent maintenant dans l'historique K-Fêt et l'historique + personnel. + ## Version 0.4.1 - 17/01/2020 - Corrige un bug sur K-Psul lorsqu'un trigramme contient des caractères réservés From 914888d18aee68f813b3ce5ce863e72c7a460aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Thu, 2 Jan 2020 16:01:13 +0100 Subject: [PATCH 25/44] Merge the utils and shared apps --- .gitlab-ci.yml | 4 ++-- bda/views.py | 2 +- gestioncof/views.py | 2 +- setup.cfg | 3 +-- shared/views/autocomplete.py | 45 ++++++++++++++++++++++++++++++++++++ utils/__init__.py | 0 utils/views/__init__.py | 0 utils/views/autocomplete.py | 25 -------------------- 8 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 shared/views/autocomplete.py delete mode 100644 utils/__init__.py delete mode 100644 utils/views/__init__.py delete mode 100644 utils/views/autocomplete.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a8bece7d..9bad2072 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -61,9 +61,9 @@ linters: - pip install --upgrade black isort flake8 script: - black --check . - - isort --recursive --check-only --diff bda bds clubs cof events gestioncof kfet petitscours provisioning shared utils + - isort --recursive --check-only --diff bda bds clubs cof events gestioncof kfet petitscours provisioning shared # Print errors only - - flake8 --exit-zero bda bds clubs cof events gestioncof kfet petitscours provisioning shared utils + - flake8 --exit-zero bda bds clubs cof events gestioncof kfet petitscours provisioning shared cache: key: linters paths: diff --git a/bda/views.py b/bda/views.py index f33b7013..f799360d 100644 --- a/bda/views.py +++ b/bda/views.py @@ -42,7 +42,7 @@ from bda.models import ( Tirage, ) from gestioncof.decorators import BuroRequiredMixin, buro_required, cof_required -from utils.views.autocomplete import Select2QuerySetView +from shared.views.autocomplete import Select2QuerySetView @cof_required diff --git a/gestioncof/views.py b/gestioncof/views.py index ced35cfc..07a0ae03 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -58,7 +58,7 @@ from gestioncof.models import ( SurveyQuestion, SurveyQuestionAnswer, ) -from utils.views.autocomplete import Select2QuerySetView +from shared.views.autocomplete import Select2QuerySetView class HomeView(LoginRequiredMixin, TemplateView): diff --git a/setup.cfg b/setup.cfg index 100ddb22..1a9901cb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,7 +9,6 @@ source = kfet petitscours shared - utils omit = *migrations* *test*.py @@ -37,7 +36,7 @@ default_section = THIRDPARTY force_grid_wrap = 0 include_trailing_comma = true known_django = django -known_first_party = bda,bds,clubs,cof,events,gestioncof,kfet,petitscours,shared,utils +known_first_party = bda,bds,clubs,cof,events,gestioncof,kfet,petitscours,shared line_length = 88 multi_line_output = 3 not_skip = __init__.py diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py new file mode 100644 index 00000000..7fc7a886 --- /dev/null +++ b/shared/views/autocomplete.py @@ -0,0 +1,45 @@ +from dal import autocomplete +from django.db.models import Q + + +class ModelSearch: + """Basic search engine for models based on filtering. + + Subclasses should override the ``model`` class attribute and specify the list of + search fields to be searched in. + """ + + model = None + search_fields = [] + + def get_queryset_filter(self, keywords): + filter_q = Q() + + if not keywords: + return filter_q + + for keyword in keywords: + kw_filter = Q() + for field in self.search_fields: + kw_filter |= Q(**{"{}__icontains".format(field): keyword}) + filter_q &= kw_filter + + return filter_q + + def search(self, keywords): + """Returns the queryset of model instances matching all the keywords. + + The semantic of the search is the following: a model instance appears in the + search results iff all of the keywords given as arguments occur in at least one + of the search fields. + """ + + return self.model.objects.filter(self.get_queryset_filter(keywords)) + + +class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView): + """Compatibility layer between ModelSearch and Select2QuerySetView.""" + + def get_queryset(self): + keywords = self.q.split() + return super().search(keywords) diff --git a/utils/__init__.py b/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/utils/views/__init__.py b/utils/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/utils/views/autocomplete.py b/utils/views/autocomplete.py deleted file mode 100644 index c5d51343..00000000 --- a/utils/views/autocomplete.py +++ /dev/null @@ -1,25 +0,0 @@ -from dal import autocomplete -from django.db.models import Q - - -class Select2QuerySetView(autocomplete.Select2QuerySetView): - model = None - search_fields = [] - - def get_queryset_filter(self): - q = self.q - filter_q = Q() - - if not q: - return filter_q - - words = q.split() - - for word in words: - for field in self.search_fields: - filter_q |= Q(**{"{}__icontains".format(field): word}) - - return filter_q - - def get_queryset(self): - return self.model.objects.filter(self.get_queryset_filter()) From d2c6c9da7ae51fa993cca7146ebf4ef6dbd1b822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Thu, 2 Jan 2020 17:07:50 +0100 Subject: [PATCH 26/44] Type hints in shared.views.autocomplete --- shared/views/autocomplete.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py index 7fc7a886..270eae63 100644 --- a/shared/views/autocomplete.py +++ b/shared/views/autocomplete.py @@ -1,18 +1,22 @@ -from dal import autocomplete -from django.db.models import Q +from typing import Generic, Iterable, Type, TypeVar + +from dal import autocomplete # type: ignore +from django.db.models import Model, Q + +M = TypeVar("M", bound=Model) -class ModelSearch: +class ModelSearch(Generic[M]): """Basic search engine for models based on filtering. Subclasses should override the ``model`` class attribute and specify the list of search fields to be searched in. """ - model = None - search_fields = [] + model: Type[M] + search_fields: Iterable[str] - def get_queryset_filter(self, keywords): + def get_queryset_filter(self, keywords: Iterable[str]) -> Q: filter_q = Q() if not keywords: @@ -26,7 +30,7 @@ class ModelSearch: return filter_q - def search(self, keywords): + def search(self, keywords: Iterable[str]) -> Iterable[M]: """Returns the queryset of model instances matching all the keywords. The semantic of the search is the following: a model instance appears in the From e45ee3fb40358036211116d803547788f0b43ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Fri, 3 Jan 2020 17:26:12 +0100 Subject: [PATCH 27/44] More documentation for ModelSearch --- shared/views/autocomplete.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py index 270eae63..e8d90590 100644 --- a/shared/views/autocomplete.py +++ b/shared/views/autocomplete.py @@ -9,8 +9,23 @@ M = TypeVar("M", bound=Model) class ModelSearch(Generic[M]): """Basic search engine for models based on filtering. - Subclasses should override the ``model`` class attribute and specify the list of - search fields to be searched in. + As the type hints indicate, the class is generic with respect to the model. This + means that the ``search`` method only returns instances of the model specified as + the ``model`` class attribute in subclasses. + + The ``search_fields`` attributes indicates which fields to search in during the + search. + + Example: + + >>> from django.contrib.auth.models import User + >>> + >>> class UserSearch(ModelSearch): + ... model = User + ... search_fields = ["username", "first_name", "last_name"] + >>> + >>> user_search = UserSearch() # has type ModelSearch[User] + >>> user_search.search(["toto", "foo"]) # returns a queryset of Users """ model: Type[M] From a259b04d9cf9b3d8ddd4fe3f96a16cd75a5d6a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Fri, 3 Jan 2020 17:29:55 +0100 Subject: [PATCH 28/44] Explicative comment about the Type[M] annotation --- shared/views/autocomplete.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py index e8d90590..708fe554 100644 --- a/shared/views/autocomplete.py +++ b/shared/views/autocomplete.py @@ -28,6 +28,8 @@ class ModelSearch(Generic[M]): >>> user_search.search(["toto", "foo"]) # returns a queryset of Users """ + # This says that `model` is the class corresponding to the type variable M (or a + # subclass). model: Type[M] search_fields: Iterable[str] From b8cd5f1da50a60cce446f3d2c1b270f6d3462c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 12 Feb 2020 19:01:08 +0100 Subject: [PATCH 29/44] Drop type hints in shared.views.autocomplete --- shared/views/autocomplete.py | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py index 708fe554..095dc3f8 100644 --- a/shared/views/autocomplete.py +++ b/shared/views/autocomplete.py @@ -1,20 +1,13 @@ -from typing import Generic, Iterable, Type, TypeVar - -from dal import autocomplete # type: ignore -from django.db.models import Model, Q - -M = TypeVar("M", bound=Model) +from dal import autocomplete +from django.db.models import Q -class ModelSearch(Generic[M]): +class ModelSearch: """Basic search engine for models based on filtering. - As the type hints indicate, the class is generic with respect to the model. This - means that the ``search`` method only returns instances of the model specified as - the ``model`` class attribute in subclasses. - - The ``search_fields`` attributes indicates which fields to search in during the - search. + The class should be configured through its ``model`` class attribute: the ``search`` + method will return a queryset of instances of this model. The ``search_fields`` + attributes indicates which fields to search in. Example: @@ -28,12 +21,10 @@ class ModelSearch(Generic[M]): >>> user_search.search(["toto", "foo"]) # returns a queryset of Users """ - # This says that `model` is the class corresponding to the type variable M (or a - # subclass). - model: Type[M] - search_fields: Iterable[str] + model = None + search_fields = [] - def get_queryset_filter(self, keywords: Iterable[str]) -> Q: + def get_queryset_filter(self, keywords): filter_q = Q() if not keywords: @@ -47,7 +38,7 @@ class ModelSearch(Generic[M]): return filter_q - def search(self, keywords: Iterable[str]) -> Iterable[M]: + def search(self, keywords): """Returns the queryset of model instances matching all the keywords. The semantic of the search is the following: a model instance appears in the From b1d8bb04c4d9e772c4cc205de22147b893e01991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 11 Dec 2019 22:00:10 +0100 Subject: [PATCH 30/44] Generic auto-completion mechanism --- gestioncof/autocomplete.py | 118 ++++++---------- gestioncof/templates/autocomplete_user.html | 29 ---- .../templates/gestioncof/search_results.html | 56 ++++++++ gestioncof/tests/test_views.py | 24 ++-- shared/__init__.py | 0 shared/tests/testcases.py | 2 +- shared/views/autocomplete.py | 128 +++++++++++++++++- 7 files changed, 235 insertions(+), 122 deletions(-) delete mode 100644 gestioncof/templates/autocomplete_user.html create mode 100644 gestioncof/templates/gestioncof/search_results.html create mode 100644 shared/__init__.py diff --git a/gestioncof/autocomplete.py b/gestioncof/autocomplete.py index e27cdb92..239317f8 100644 --- a/gestioncof/autocomplete.py +++ b/gestioncof/autocomplete.py @@ -1,94 +1,56 @@ -from django import shortcuts -from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.db.models import Q from django.http import Http404 +from django.views.generic import TemplateView from gestioncof.decorators import buro_required -from gestioncof.models import CofProfile +from shared.views import autocomplete -if getattr(settings, "LDAP_SERVER_URL", None): - from ldap3 import Connection -else: - # shared.tests.testcases.TestCaseMixin.mockLDAP needs - # Connection to be defined in order to mock it. - Connection = None +User = get_user_model() -class Clipper(object): - def __init__(self, clipper, fullname): - if fullname is None: - fullname = "" - assert isinstance(clipper, str) - assert isinstance(fullname, str) - self.clipper = clipper - self.fullname = fullname +class COFMemberSearch(autocomplete.ModelSearch): + model = User + search_fields = ["username", "first_name", "last_name"] - def __str__(self): - return "{} ({})".format(self.clipper, self.fullname) - - def __eq__(self, other): - return self.clipper == other.clipper and self.fullname == other.fullname + def get_queryset_filter(self, *args, **kwargs): + qset_filter = super().get_queryset_filter(*args, **kwargs) + qset_filter &= Q(profile__is_cof=True) + return qset_filter -@buro_required -def autocomplete(request): - if "q" not in request.GET: - raise Http404 - q = request.GET["q"] - data = {"q": q} +class COFOthersSearch(autocomplete.ModelSearch): + model = User + search_fields = ["username", "first_name", "last_name"] - queries = {} - bits = q.split() + def get_queryset_filter(self, *args, **kwargs): + qset_filter = super().get_queryset_filter(*args, **kwargs) + qset_filter &= Q(profile__is_cof=False) + return qset_filter - # Fetching data from User and CofProfile tables - queries["members"] = CofProfile.objects.filter(is_cof=True) - queries["users"] = User.objects.filter(profile__is_cof=False) - for bit in bits: - queries["members"] = queries["members"].filter( - Q(user__first_name__icontains=bit) - | Q(user__last_name__icontains=bit) - | Q(user__username__icontains=bit) - | Q(login_clipper__icontains=bit) - ) - queries["users"] = queries["users"].filter( - Q(first_name__icontains=bit) - | Q(last_name__icontains=bit) - | Q(username__icontains=bit) - ) - queries["members"] = queries["members"].distinct() - queries["users"] = queries["users"].distinct() - # Clearing redundancies - usernames = set(queries["members"].values_list("login_clipper", flat="True")) | set( - queries["users"].values_list("profile__login_clipper", flat="True") - ) +class COFSearch(autocomplete.Compose): + search_units = [ + ("members", "username", COFMemberSearch), + ("others", "username", COFOthersSearch), + ("clippers", "clipper", autocomplete.LDAPSearch), + ] - # Fetching data from the SPI - if getattr(settings, "LDAP_SERVER_URL", None): - # Fetching - ldap_query = "(&{:s})".format( - "".join( - "(|(cn=*{bit:s}*)(uid=*{bit:s}*))".format(bit=bit) - for bit in bits - if bit.isalnum() - ) - ) - if ldap_query != "(&)": - # If none of the bits were legal, we do not perform the query - entries = None - with Connection(settings.LDAP_SERVER_URL) as conn: - conn.search("dc=spi,dc=ens,dc=fr", ldap_query, attributes=["uid", "cn"]) - entries = conn.entries - # Clearing redundancies - queries["clippers"] = [ - Clipper(entry.uid.value, entry.cn.value) - for entry in entries - if entry.uid.value and entry.uid.value not in usernames - ] - # Resulting data - data.update(queries) - data["options"] = sum(len(query) for query in queries) +cof_search = COFSearch() - return shortcuts.render(request, "autocomplete_user.html", data) + +class AutocompleteView(TemplateView): + template_name = "gestioncof/search_results.html" + + def get_context_data(self, *args, **kwargs): + ctx = super().get_context_data(*args, **kwargs) + if "q" not in self.request.GET: + raise Http404 + q = self.request.GET["q"] + ctx["q"] = q + ctx.update(cof_search.search(q.split())) + return ctx + + +autocomplete = buro_required(AutocompleteView.as_view()) diff --git a/gestioncof/templates/autocomplete_user.html b/gestioncof/templates/autocomplete_user.html deleted file mode 100644 index face824d..00000000 --- a/gestioncof/templates/autocomplete_user.html +++ /dev/null @@ -1,29 +0,0 @@ -{% load utils %} -
      -{% if members %} -
    • Membres du COF
    • - {% for member in members %}{% if forloop.counter < 5 %} -
    • {{ member.user|highlight_user:q }}
    • - {% elif forloop.counter == 5 %}
    • ...{% endif %}{% endfor %} -{% endif %} -{% if users %} -
    • Utilisateurs de GestioCOF
    • - {% for user in users %}{% if forloop.counter < 5 %} -
    • {{ user|highlight_user:q }}
    • - {% elif forloop.counter == 5 %}
    • ...{% endif %}{% endfor %} -{% endif %} -{% if clippers %} -
    • Utilisateurs clipper
    • - {% for clipper in clippers %}{% if forloop.counter < 5 %} -
    • {{ clipper|highlight_clipper:q }}
    • - {% elif forloop.counter == 5 %}
    • ...{% endif %}{% endfor %} -{% endif %} - -{% if not options %} -
    • Aucune correspondance trouvée
    • -{% else %} -
    • Pas dans la liste ?
    • -{% endif %} -
    • Créer un compte
    • - -
    diff --git a/gestioncof/templates/gestioncof/search_results.html b/gestioncof/templates/gestioncof/search_results.html new file mode 100644 index 00000000..ba8b6580 --- /dev/null +++ b/gestioncof/templates/gestioncof/search_results.html @@ -0,0 +1,56 @@ +{% load utils %} + +
      + {% if members %} +
    • Membres
    • + {% for user in members %} + {% if forloop.counter < 5 %} +
    • + + {{ user|highlight_user:q }} + +
    • + {% elif forloop.counter == 5 %} +
    • ...
    • + {% endif %} + {% endfor %} + {% endif %} + + {% if others %} +
    • Non-membres
    • + {% for user in others %} + {% if forloop.counter < 5 %} +
    • + + {{ user|highlight_user:q }} + +
    • + {% elif forloop.counter == 5 %} +
    • ...
    • + {% endif %} + {% endfor %} + {% endif %} + + {% if clippers %} +
    • Utilisateurs clipper
    • + {% for clipper in clippers %} + {% if forloop.counter < 5 %} +
    • + + {{ clipper|highlight_clipper:q }} + +
    • + {% elif forloop.counter == 5 %} +
    • ...
    • + {% endif %} + {% endfor %} + {% endif %} + + {% if total %} +
    • Pas dans la liste ?
    • + {% else %} +
    • Aucune correspondance trouvée
    • + {% endif %} + +
    • Créer un compte
    • +
    diff --git a/gestioncof/tests/test_views.py b/gestioncof/tests/test_views.py index 31cb8d8a..f757b4c2 100644 --- a/gestioncof/tests/test_views.py +++ b/gestioncof/tests/test_views.py @@ -15,9 +15,9 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from bda.models import Salle, Tirage -from gestioncof.autocomplete import Clipper from gestioncof.models import CalendarSubscription, Club, Event, Survey, SurveyAnswer from gestioncof.tests.testcases import ViewTestCaseMixin +from shared.views.autocomplete import Clipper from .utils import create_member, create_root, create_user @@ -285,21 +285,19 @@ class RegistrationAutocompleteViewTests(ViewTestCaseMixin, TestCase): self.mockLDAP([]) - def _test(self, query, expected_users, expected_members, expected_clippers): + def _test(self, query, expected_others, expected_members, expected_clippers): r = self.client.get(self.url, {"q": query}) self.assertEqual(r.status_code, 200) self.assertQuerysetEqual( - r.context["users"], map(repr, expected_users), ordered=False + r.context["others"], map(repr, expected_others), ordered=False ) self.assertQuerysetEqual( - r.context["members"], - map(lambda u: repr(u.profile), expected_members), - ordered=False, + r.context["members"], map(repr, expected_members), ordered=False, ) self.assertCountEqual( - map(str, r.context.get("clippers", [])), map(str, expected_clippers) + map(str, r.context["clippers"]), map(str, expected_clippers) ) def test_username(self): @@ -322,7 +320,7 @@ class RegistrationAutocompleteViewTests(ViewTestCaseMixin, TestCase): mock_ldap.search.assert_called_once_with( "dc=spi,dc=ens,dc=fr", "(&(|(cn=*aa*)(uid=*aa*))(|(cn=*bb*)(uid=*bb*)))", - attributes=["uid", "cn"], + attributes=["cn", "uid"], ) def test_clipper_escaped(self): @@ -333,14 +331,14 @@ class RegistrationAutocompleteViewTests(ViewTestCaseMixin, TestCase): mock_ldap.search.assert_not_called() def test_clipper_no_duplicate(self): - self.mockLDAP([("uid", "uu_u1")]) + self.mockLDAP([("uid", "abc")]) - self._test("uu u1", [self.u1], [], [Clipper("uid", "uu_u1")]) + self._test("abc", [self.u1], [], [Clipper("uid", "abc")]) - self.u1.profile.login_clipper = "uid" - self.u1.profile.save() + self.u1.username = "uid" + self.u1.save() - self._test("uu u1", [self.u1], [], []) + self._test("abc", [self.u1], [], []) class HomeViewTests(ViewTestCaseMixin, TestCase): diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shared/tests/testcases.py b/shared/tests/testcases.py index 35d697e7..507e1361 100644 --- a/shared/tests/testcases.py +++ b/shared/tests/testcases.py @@ -111,7 +111,7 @@ class TestCaseMixin: mock_context_manager.return_value.__enter__.return_value = mock_connection patcher = mock.patch( - "gestioncof.autocomplete.Connection", new=mock_context_manager + "shared.views.autocomplete.Connection", new=mock_context_manager ) patcher.start() self.addCleanup(patcher.stop) diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py index 095dc3f8..5254f8c8 100644 --- a/shared/views/autocomplete.py +++ b/shared/views/autocomplete.py @@ -1,8 +1,37 @@ +from collections import namedtuple + from dal import autocomplete +from django.conf import settings from django.db.models import Q +if getattr(settings, "LDAP_SERVER_URL", None): + from ldap3 import Connection +else: + # shared.tests.testcases.TestCaseMixin.mockLDAP needs + # Connection to be defined + Connection = None -class ModelSearch: + +class SearchUnit: + """Base class for all the search utilities. + + A search unit should implement a ``search`` method taking a list of keywords as + argument and returning an iterable of search results. + """ + + def search(self, _keywords): + raise NotImplementedError( + "Class implementing the SeachUnit interface should implement the search " + "method" + ) + + +# --- +# Model-based search +# --- + + +class ModelSearch(SearchUnit): """Basic search engine for models based on filtering. The class should be configured through its ``model`` class attribute: the ``search`` @@ -55,3 +84,100 @@ class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView): def get_queryset(self): keywords = self.q.split() return super().search(keywords) + + +# --- +# LDAP search +# --- + +Clipper = namedtuple("Clipper", "clipper fullname") + + +class LDAPSearch(SearchUnit): + ldap_server_url = getattr(settings, "LDAP_SERVER_URL", None) + domain_component = "dc=spi,dc=ens,dc=fr" + search_fields = ["cn", "uid"] + + def get_ldap_query(self, keywords): + # Dumb but safe + keywords = filter(str.isalnum, keywords) + + ldap_filters = [] + + for keyword in keywords: + ldap_filter = "(|{})".format( + "".join( + "({}=*{}*)".format(field, keyword) for field in self.search_fields + ) + ) + ldap_filters.append(ldap_filter) + + return "(&{})".format("".join(ldap_filters)) + + def search(self, keywords): + """Return a list of Clipper objects matching all the keywords. + + The semantic of the search is the following: a Clipper appears in the + search results iff all of the keywords given as arguments occur in at least one + of the search fields. + """ + + query = self.get_ldap_query(keywords) + + if Connection is None or query == "(&)": + return [] + + with Connection(self.ldap_server_url) as conn: + conn.search(self.domain_component, query, attributes=self.search_fields) + return [Clipper(entry.uid.value, entry.cn.value) for entry in conn.entries] + + +# --- +# Composition of autocomplete units +# --- + + +class Compose: + """Search with several units and remove duplicate results. + + The ``search_units`` class attribute should be a list of tuples of the form ``(name, + uniq_key, search_unit)``. + + The ``search`` method produces a dictionnary whose keys are the ``name``s given in + ``search_units`` and whose values are iterables produced by the different search + units. + + The ``uniq_key``s are used to remove duplicates: for instance, say that search unit + 1 has ``uniq_key = "username"`` and search unit 2 has ``uniq_key = "clipper"``, then + search results from unit 2 whose ``.clipper`` attribute is equal to the + ``.username`` attribute of some result from unit 1 are omitted. + + Typical Example: + + >>> from django.contrib.auth.models import User + >>> + >>> class UserSearch(ModelSearch): + ... model = User + ... search_fields = ["username", "first_name", "last_name"] + >>> + >>> class UserAndClipperSearch(Compose): + ... search_units = [ + ... ("users", "username", UserSearch), + ... ("clippers", "clipper", LDAPSearch), + ... ] + + In this example, clipper accounts that already have an associated user (i.e. with a + username equal to the clipper login), will not appear in the results. + """ + + search_units = [] + + def search(self, keywords): + uniq_results = set() + results = {} + for name, uniq_key, search_unit in self.search_units: + res = search_unit().search(keywords) + res = [r for r in res if getattr(r, uniq_key) not in uniq_results] + uniq_results |= set((getattr(r, uniq_key) for r in res)) + results[name] = res + return results From 3b0d4ba58fca9dcd22969fff46b17e122b4a524b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Thu, 7 May 2020 15:44:37 +0200 Subject: [PATCH 31/44] lstephan's suggestions --- shared/views/autocomplete.py | 37 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/shared/views/autocomplete.py b/shared/views/autocomplete.py index 5254f8c8..af5e3980 100644 --- a/shared/views/autocomplete.py +++ b/shared/views/autocomplete.py @@ -15,7 +15,7 @@ else: class SearchUnit: """Base class for all the search utilities. - A search unit should implement a ``search`` method taking a list of keywords as + A search unit should implement a `search` method taking a list of keywords as argument and returning an iterable of search results. """ @@ -34,8 +34,8 @@ class SearchUnit: class ModelSearch(SearchUnit): """Basic search engine for models based on filtering. - The class should be configured through its ``model`` class attribute: the ``search`` - method will return a queryset of instances of this model. The ``search_fields`` + The class should be configured through its `model` class attribute: the `search` + method will return a queryset of instances of this model. The `search_fields` attributes indicates which fields to search in. Example: @@ -90,7 +90,7 @@ class Select2QuerySetView(ModelSearch, autocomplete.Select2QuerySetView): # LDAP search # --- -Clipper = namedtuple("Clipper", "clipper fullname") +Clipper = namedtuple("Clipper", ["clipper", "fullname"]) class LDAPSearch(SearchUnit): @@ -99,6 +99,12 @@ class LDAPSearch(SearchUnit): search_fields = ["cn", "uid"] def get_ldap_query(self, keywords): + """Return a search query with the following semantics: + + A Clipper appears in the search results iff all of the keywords given as + arguments occur in at least one of the search fields. + """ + # Dumb but safe keywords = filter(str.isalnum, keywords) @@ -115,12 +121,7 @@ class LDAPSearch(SearchUnit): return "(&{})".format("".join(ldap_filters)) def search(self, keywords): - """Return a list of Clipper objects matching all the keywords. - - The semantic of the search is the following: a Clipper appears in the - search results iff all of the keywords given as arguments occur in at least one - of the search fields. - """ + """Return a list of Clipper objects matching all the keywords.""" query = self.get_ldap_query(keywords) @@ -140,17 +141,17 @@ class LDAPSearch(SearchUnit): class Compose: """Search with several units and remove duplicate results. - The ``search_units`` class attribute should be a list of tuples of the form ``(name, - uniq_key, search_unit)``. + The `search_units` class attribute should be a list of tuples of the form `(name, + uniq_key, search_unit)`. - The ``search`` method produces a dictionnary whose keys are the ``name``s given in - ``search_units`` and whose values are iterables produced by the different search + The `search` method produces a dictionary whose keys are the `name`s given in + `search_units` and whose values are iterables produced by the different search units. - The ``uniq_key``s are used to remove duplicates: for instance, say that search unit - 1 has ``uniq_key = "username"`` and search unit 2 has ``uniq_key = "clipper"``, then - search results from unit 2 whose ``.clipper`` attribute is equal to the - ``.username`` attribute of some result from unit 1 are omitted. + The `uniq_key`s are used to remove duplicates: for instance, say that search unit + 1 has `uniq_key = "username"` and search unit 2 has `uniq_key = "clipper"`, then + search results from unit 2 whose `.clipper` attribute is equal to the + `.username` attribute of some result from unit 1 are omitted. Typical Example: From 4f15bb962417b5f5525c51ac54a0c41dbaa43003 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Thu, 7 May 2020 18:40:07 +0200 Subject: [PATCH 32/44] CHANGELOG --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ecea3ce..6af67f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre - Nouveau module de gestion des événements - Nouveau module BDS - Nouveau module clubs +- Module d'autocomplétion indépendant des apps ## Upcoming @@ -19,6 +20,8 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre - Les montants en K-Fêt sont à nouveau affichés en UKF (et non en €). - Les boutons "afficher/cacher" des mails et noms des participant⋅e⋅s à un spectacle BdA fonctionnent à nouveau. +- on ne peut plus compter de consos sur ☠☠☠, ni éditer les comptes spéciaux +(LIQ, GNR, ☠☠☠, #13). ### Nouvelles fonctionnalités From 6767ba8e8c925c3917272f848c6ff4ab91226907 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 9 Mar 2020 15:06:55 +0100 Subject: [PATCH 33/44] Rajoute de la doc partout --- kfet/statistic.py | 39 +++++++++++++++++++++++++++++---------- kfet/views.py | 41 +++++++++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 20 deletions(-) diff --git a/kfet/statistic.py b/kfet/statistic.py index 02171267..f308011e 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -10,12 +10,16 @@ KFET_WAKES_UP_AT = time(7, 0) def kfet_day(year, month, day, start_at=KFET_WAKES_UP_AT): - """datetime wrapper with time offset.""" + """Étant donné une date, renvoie un objet `datetime` + correspondant au début du 'jour K-Fêt' correspondant.""" naive = datetime.combine(date(year, month, day), start_at) return pytz.timezone("Europe/Paris").localize(naive, is_dst=None) def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT): + """ + Retourne le 'jour K-Fêt' correspondant à un objet `datetime` donné + """ kfet_dt = kfet_day(year=dt.year, month=dt.month, day=dt.day) if dt.time() < start_at: kfet_dt -= timedelta(days=1) @@ -23,6 +27,17 @@ def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT): class Scale(object): + """ + Classe utilisée pour subdiviser un QuerySet (e.g. des opérations) sur + une échelle de temps donnée, avec un pas de temps fixe. + Cette échelle peut être spécifiée : + - par un début et une fin, + - par un début/une fin et un nombre de subdivisions. + + Si le booléen `std_chunk` est activé, les subdivisions sont standardisées : + on appelle `get_chunk_start` sur toutes les subdivisions (enfin, sur la première). + """ + name = None step = None @@ -92,6 +107,10 @@ class Scale(object): def chunkify_qs(self, qs, field=None): if field is None: field = "at" + """ + Découpe un queryset en subdivisions, avec agrégation optionnelle des résultats + NB : on pourrait faire ça en une requête, au détriment de la lisibilité... + """ begin_f = "{}__gte".format(field) end_f = "{}__lte".format(field) return [qs.filter(**{begin_f: begin, end_f: end}) for begin, end in self] @@ -247,6 +266,13 @@ def last_stats_manifest( scale_prefix=scale_prefix, **url_params ) + """ + Convertit une spécification de scales en arguments GET utilisables par ScaleMixin. + La spécification est de la forme suivante : + - scales_def : liste de champs de la forme (label, scale) + - scale_args : arguments à passer à Scale.__init__ + - other_url_params : paramètres GET supplémentaires + """ # Étant donné un queryset d'operations @@ -260,16 +286,9 @@ class ScaleMixin(object): scale_args_prefix = "scale_" def get_scale_args(self, params=None, prefix=None): - """Retrieve scale args from params. - - Should search the same args of Scale constructor. - - Args: - params (dict, optional): Scale args are searched in this. - Default to GET params of request. - prefix (str, optional): Appended at the begin of scale args names. - Default to `self.scale_args_prefix`. + """ + Récupère les paramètres de subdivision encodés dans une requête GET. """ if params is None: params = self.request.GET diff --git a/kfet/views.py b/kfet/views.py index a04cda24..b9c690dd 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -2235,11 +2235,19 @@ class PkUrlMixin(object): class SingleResumeStat(JSONDetailView): - """Manifest for a kind of a stat about an object. + """ + Génère l'interface de sélection pour les statistiques d'un compte/article. + L'interface est constituée d'une série de boutons, qui récupèrent et graphent + des statistiques du même type, sur le même objet mais avec des arguments différents. - Returns JSON whose payload is an array containing descriptions of a stat: - url to retrieve data, label, ... + Attributs : + - url_stat : URL où récupérer les statistiques + - stats : liste de dictionnaires avec les clés suivantes : + - label : texte du bouton + - url_params : paramètres GET à rajouter à `url_stat` + - default : si `True`, graphe à montrer par défaut + On peut aussi définir `stats` dynamiquement, via la fonction `get_stats`. """ id_prefix = "" @@ -2285,7 +2293,8 @@ ID_PREFIX_ACC_BALANCE = "balance_acc" class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): - """Manifest for balance stats of an account.""" + Menu général pour l'historique de balance d'un compte + """ model = Account context_object_name = "account" @@ -2313,10 +2322,11 @@ class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): class AccountStatBalance(PkUrlMixin, JSONDetailView): - """Datasets of balance of an account. - Operations and Transfers are taken into account. + """ + Statistiques (JSON) d'historique de balance d'un compte. + Prend en compte les opérations et transferts sur la période donnée. """ model = Account @@ -2441,7 +2451,10 @@ ID_PREFIX_ACC_LAST_MONTHS = "last_months_acc" class AccountStatOperationList(PkUrlMixin, SingleResumeStat): - """Manifest for operations stats of an account.""" +@method_decorator(login_required, name="dispatch") + """ + Menu général pour l'historique de consommation d'un compte + """ model = Account context_object_name = "account" @@ -2463,7 +2476,10 @@ class AccountStatOperationList(PkUrlMixin, SingleResumeStat): class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): - """Datasets of operations of an account.""" +@method_decorator(login_required, name="dispatch") + """ + Statistiques (JSON) de consommation (nb d'items achetés) d'un compte. + """ model = Account pk_url_kwarg = "trigramme" @@ -2535,7 +2551,9 @@ ID_PREFIX_ART_LAST_MONTHS = "last_months_art" class ArticleStatSalesList(SingleResumeStat): - """Manifest for sales stats of an article.""" + """ + Menu pour les statistiques de vente d'un article. + """ model = Article context_object_name = "article" @@ -2550,7 +2568,10 @@ class ArticleStatSalesList(SingleResumeStat): class ArticleStatSales(ScaleMixin, JSONDetailView): - """Datasets of sales of an article.""" + """ + Statistiques (JSON) de vente d'un article. + Sépare LIQ et les comptes K-Fêt, et rajoute le total. + """ model = Article context_object_name = "article" From 78ad4402b03bd215bcb360cf64683aa29aa40dae Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 9 Mar 2020 15:10:02 +0100 Subject: [PATCH 34/44] Plus de timezones --- kfet/statistic.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/kfet/statistic.py b/kfet/statistic.py index f308011e..81f81c1d 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -1,19 +1,17 @@ from datetime import date, datetime, time, timedelta -import pytz from dateutil.parser import parse as dateutil_parse from dateutil.relativedelta import relativedelta from django.db.models import Sum from django.utils import timezone -KFET_WAKES_UP_AT = time(7, 0) +KFET_WAKES_UP_AT = time(5, 0) # La K-Fêt ouvre à 5h (UTC) du matin def kfet_day(year, month, day, start_at=KFET_WAKES_UP_AT): """Étant donné une date, renvoie un objet `datetime` correspondant au début du 'jour K-Fêt' correspondant.""" - naive = datetime.combine(date(year, month, day), start_at) - return pytz.timezone("Europe/Paris").localize(naive, is_dst=None) + return datetime.combine(date(year, month, day), start_at) def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT): From 26bcd729bbccf31a19f8f3126d14f37a36e363ba Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 9 Mar 2020 16:09:12 +0100 Subject: [PATCH 35/44] Supprime le code mort ou redondant --- kfet/statistic.py | 34 +++++-------------- kfet/views.py | 85 +++++++++++++---------------------------------- 2 files changed, 33 insertions(+), 86 deletions(-) diff --git a/kfet/statistic.py b/kfet/statistic.py index 81f81c1d..45f8fb65 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -65,7 +65,7 @@ class Scale(object): "or use last and n_steps" ) - self.datetimes = self.get_datetimes() + self._gen_datetimes() @staticmethod def by_name(name): @@ -74,9 +74,6 @@ class Scale(object): return cls return None - def get_from(self, dt): - return self.std_chunk and self.get_chunk_start(dt) or dt - def __getitem__(self, i): return self.datetimes[i], self.datetimes[i + 1] @@ -86,13 +83,13 @@ class Scale(object): def do_step(self, dt, n_steps=1): return dt + self.step * n_steps - def get_datetimes(self): + def _gen_datetimes(self): datetimes = [self.begin] tmp = self.begin while tmp < self.end: tmp = self.do_step(tmp) datetimes.append(tmp) - return datetimes + self.datetimes = datetimes def get_labels(self, label_fmt=None): if label_fmt is None: @@ -273,45 +270,32 @@ def last_stats_manifest( """ -# Étant donné un queryset d'operations -# rend la somme des article_nb -def tot_ventes(queryset): - res = queryset.aggregate(Sum("article_nb"))["article_nb__sum"] - return res and res or 0 class ScaleMixin(object): - scale_args_prefix = "scale_" - - def get_scale_args(self, params=None, prefix=None): - + def parse_scale_args(self): """ Récupère les paramètres de subdivision encodés dans une requête GET. """ - if params is None: - params = self.request.GET - if prefix is None: - prefix = self.scale_args_prefix - scale_args = {} - name = params.get(prefix + "name", None) + name = self.request.GET.get("scale_name", None) if name is not None: scale_args["name"] = name - n_steps = params.get(prefix + "n_steps", None) + n_steps = self.request.GET.get("scale_n_steps", None) if n_steps is not None: scale_args["n_steps"] = int(n_steps) - begin = params.get(prefix + "begin", None) + begin = self.request.GET.get("scale_begin", None) if begin is not None: scale_args["begin"] = dateutil_parse(begin) - end = params.get(prefix + "send", None) + end = self.request.GET.get("scale_send", None) if end is not None: scale_args["end"] = dateutil_parse(end) - last = params.get(prefix + "last", None) + last = self.request.GET.get("scale_last", None) if last is not None: scale_args["last"] = last in ["true", "True", "1"] and True or False diff --git a/kfet/views.py b/kfet/views.py index b9c690dd..5455be8a 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -2199,7 +2199,7 @@ class SupplierUpdate(SuccessMessageMixin, UpdateView): # Vues génériques # --------------- # source : docs.djangoproject.com/fr/1.10/topics/class-based-views/mixins/ -class JSONResponseMixin(object): +class JSONResponseMixin: """ A mixin that can be used to render a JSON response. """ @@ -2228,12 +2228,6 @@ class JSONDetailView(JSONResponseMixin, BaseDetailView): return self.render_to_json_response(context) -class PkUrlMixin(object): - def get_object(self, *args, **kwargs): - get_by = self.kwargs.get(self.pk_url_kwarg) - return get_object_or_404(self.model, **{self.pk_url_kwarg: get_by}) - - class SingleResumeStat(JSONDetailView): """ Génère l'interface de sélection pour les statistiques d'un compte/article. @@ -2286,13 +2280,28 @@ class SingleResumeStat(JSONDetailView): return context +class UserAccountMixin: + """ + Mixin qui vérifie que le compte traité par la vue est celui de l'utilisateur·ice + actuel·le. Dans le cas contraire, renvoie un Http404. + """ + + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user != obj.user: + raise Http404 + return obj + + # ----------------------- # Evolution Balance perso # ----------------------- ID_PREFIX_ACC_BALANCE = "balance_acc" -class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): +@method_decorator(login_required, name="dispatch") +class AccountStatBalanceList(UserAccountMixin, SingleResumeStat): + """ Menu général pour l'historique de balance d'un compte """ @@ -2310,20 +2319,11 @@ class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): ] nb_default = 0 - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) -class AccountStatBalance(PkUrlMixin, JSONDetailView): - +@method_decorator(login_required, name="dispatch") +class AccountStatBalance(UserAccountMixin, JSONDetailView): """ Statistiques (JSON) d'historique de balance d'un compte. Prend en compte les opérations et transferts sur la période donnée. @@ -2430,28 +2430,15 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView): # TODO: offset return context - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) # ------------------------ # Consommation personnelle # ------------------------ -ID_PREFIX_ACC_LAST = "last_acc" -ID_PREFIX_ACC_LAST_DAYS = "last_days_acc" -ID_PREFIX_ACC_LAST_WEEKS = "last_weeks_acc" -ID_PREFIX_ACC_LAST_MONTHS = "last_months_acc" -class AccountStatOperationList(PkUrlMixin, SingleResumeStat): @method_decorator(login_required, name="dispatch") +class AccountStatOperationList(UserAccountMixin, SingleResumeStat): """ Menu général pour l'historique de consommation d'un compte """ @@ -2464,19 +2451,11 @@ class AccountStatOperationList(PkUrlMixin, SingleResumeStat): stats = last_stats_manifest(types=[Operation.PURCHASE]) url_stat = "kfet.account.stat.operation" - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) -class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): + @method_decorator(login_required, name="dispatch") +class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): """ Statistiques (JSON) de consommation (nb d'items achetés) d'un compte. """ @@ -2530,26 +2509,13 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): ] return context - def get_object(self, *args, **kwargs): - obj = super().get_object(*args, **kwargs) - if self.request.user != obj.user: - raise Http404 - return obj - - @method_decorator(login_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) - # ------------------------ # Article Satistiques Last # ------------------------ -ID_PREFIX_ART_LAST = "last_art" -ID_PREFIX_ART_LAST_DAYS = "last_days_art" -ID_PREFIX_ART_LAST_WEEKS = "last_weeks_art" -ID_PREFIX_ART_LAST_MONTHS = "last_months_art" +@method_decorator(teamkfet_required, name="dispatch") class ArticleStatSalesList(SingleResumeStat): """ Menu pour les statistiques de vente d'un article. @@ -2567,6 +2533,7 @@ class ArticleStatSalesList(SingleResumeStat): return super().dispatch(*args, **kwargs) +@method_decorator(teamkfet_required, name="dispatch") class ArticleStatSales(ScaleMixin, JSONDetailView): """ Statistiques (JSON) de vente d'un article. @@ -2623,7 +2590,3 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): }, ] return context - - @method_decorator(teamkfet_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) From ef35f45ad2aafa638674ce4a1aa6946125d40617 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 9 Mar 2020 16:11:08 +0100 Subject: [PATCH 36/44] Fusionne deux fonctions `chunkify` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On rajoute de l'agrégation optionnelle dans la fonction. --- kfet/statistic.py | 92 ++++------------------------------------------- kfet/views.py | 42 +++++++--------------- 2 files changed, 18 insertions(+), 116 deletions(-) diff --git a/kfet/statistic.py b/kfet/statistic.py index 45f8fb65..98bcee32 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -2,7 +2,6 @@ from datetime import date, datetime, time, timedelta from dateutil.parser import parse as dateutil_parse from dateutil.relativedelta import relativedelta -from django.db.models import Sum from django.utils import timezone KFET_WAKES_UP_AT = time(5, 0) # La K-Fêt ouvre à 5h (UTC) du matin @@ -99,97 +98,18 @@ class Scale(object): for i, (begin, end) in enumerate(self) ] - def chunkify_qs(self, qs, field=None): - if field is None: - field = "at" + def chunkify_qs(self, qs, field="at", aggregate=None): """ Découpe un queryset en subdivisions, avec agrégation optionnelle des résultats NB : on pourrait faire ça en une requête, au détriment de la lisibilité... """ begin_f = "{}__gte".format(field) end_f = "{}__lte".format(field) - return [qs.filter(**{begin_f: begin, end_f: end}) for begin, end in self] - - def get_by_chunks(self, qs, field_callback=None, field_db="at"): - """Objects of queryset ranked according to the scale. - - Returns a generator whose each item, corresponding to a scale chunk, - is a generator of objects from qs for this chunk. - - Args: - qs: Queryset of source objects, must be ordered *first* on the - same field returned by `field_callback`. - field_callback: Callable which gives value from an object used - to compare against limits of the scale chunks. - Default to: lambda obj: getattr(obj, field_db) - field_db: Used to filter against `scale` limits. - Default to 'at'. - - Examples: - If queryset `qs` use `values()`, `field_callback` must be set and - could be: `lambda d: d['at']` - If `field_db` use foreign attributes (eg with `__`), it should be - something like: `lambda obj: obj.group.at`. - - """ - if field_callback is None: - - def field_callback(obj): - return getattr(obj, field_db) - - begin_f = "{}__gte".format(field_db) - end_f = "{}__lte".format(field_db) - - qs = qs.filter(**{begin_f: self.begin, end_f: self.end}) - - obj_iter = iter(qs) - - last_obj = None - - def _objects_until(obj_iter, field_callback, end): - """Generator of objects until `end`. - - Ends if objects source is empty or when an object not verifying - field_callback(obj) <= end is met. - - If this object exists, it is stored in `last_obj` which is found - from outer scope. - Also, if this same variable is non-empty when the function is - called, it first yields its content. - - Args: - obj_iter: Source used to get objects. - field_callback: Returned value, when it is called on an object - will be used to test ordering against `end`. - end - - """ - nonlocal last_obj - - if last_obj is not None: - yield last_obj - last_obj = None - - for obj in obj_iter: - if field_callback(obj) <= end: - yield obj - else: - last_obj = obj - return - - for begin, end in self: - # forward last seen object, if it exists, to the right chunk, - # and fill with empty generators for intermediate chunks of scale - if last_obj is not None: - if field_callback(last_obj) > end: - yield iter(()) - continue - - # yields generator for this chunk - # this set last_obj to None if obj_iter reach its end, otherwise - # it's set to the first met object from obj_iter which doesn't - # belong to this chunk - yield _objects_until(obj_iter, field_callback, end) + chunks = [qs.filter(**{begin_f: begin, end_f: end}) for begin, end in self] + if aggregate is None: + return chunks + else: + return [chunk.aggregate(agg=aggregate)["agg"] or 0 for chunk in chunks] class DayScale(Scale): diff --git a/kfet/views.py b/kfet/views.py index 5455be8a..647d78d9 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -2465,7 +2465,7 @@ class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): context_object_name = "account" id_prefix = "" - def get_operations(self, scale, types=None): + def get_operations(self, types=None): # On selectionne les opérations qui correspondent # à l'article en question et qui ne sont pas annulées # puis on choisi pour chaques intervalle les opérations @@ -2477,28 +2477,20 @@ class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): ) if types is not None: all_operations = all_operations.filter(type__in=types) - chunks = scale.get_by_chunks( - all_operations, - field_db="group__at", - field_callback=(lambda d: d["group__at"]), - ) - return chunks + return all_operations def get_context_data(self, *args, **kwargs): - old_ctx = super().get_context_data(*args, **kwargs) - context = {"labels": old_ctx["labels"]} - scale = self.scale + context = super().get_context_data(*args, **kwargs) types = self.request.GET.get("types", None) if types is not None: types = ast.literal_eval(types) - operations = self.get_operations(types=types, scale=scale) + operations = self.get_operations(types=types) # On compte les opérations - nb_ventes = [] - for chunk in operations: - ventes = sum(ope["article_nb"] for ope in chunk) - nb_ventes.append(ventes) + nb_ventes = self.scale.chunkify_qs( + operations, field="group__at", aggregate=Sum("article_nb") + ) context["charts"] = [ { @@ -2558,23 +2550,13 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): liq_only = all_purchases.filter(group__on_acc__trigramme="LIQ") liq_exclude = all_purchases.exclude(group__on_acc__trigramme="LIQ") - chunks_liq = scale.get_by_chunks( - liq_only, field_db="group__at", field_callback=lambda d: d["group__at"] + nb_liq = scale.chunkify_qs( + liq_only, field="group__at", aggregate=Sum("article_nb") ) - chunks_no_liq = scale.get_by_chunks( - liq_exclude, field_db="group__at", field_callback=lambda d: d["group__at"] + nb_accounts = scale.chunkify_qs( + liq_exclude, field="group__at", aggregate=Sum("article_nb") ) - - # On compte les opérations - nb_ventes = [] - nb_accounts = [] - nb_liq = [] - for chunk_liq, chunk_no_liq in zip(chunks_liq, chunks_no_liq): - sum_accounts = sum(ope["article_nb"] for ope in chunk_no_liq) - sum_liq = sum(ope["article_nb"] for ope in chunk_liq) - nb_ventes.append(sum_accounts + sum_liq) - nb_accounts.append(sum_accounts) - nb_liq.append(sum_liq) + nb_ventes = [n1 + n2 for n1, n2 in zip(nb_liq, nb_accounts)] context["charts"] = [ { From 48ad5cd1c711b09359350f1333d7cb0cc5025f66 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 9 Mar 2020 16:15:15 +0100 Subject: [PATCH 37/44] Misc cleanup On utilise SingleObjectMixin partout, et on simplifie 2-3 trucs --- kfet/statistic.py | 14 ++++++-------- kfet/views.py | 42 +++++++++++++++--------------------------- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/kfet/statistic.py b/kfet/statistic.py index 98bcee32..1578101b 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -125,7 +125,7 @@ class DayScale(Scale): class WeekScale(Scale): name = "week" step = timedelta(days=7) - label_fmt = "Semaine %W" + label_fmt = "%d %b." @classmethod def get_chunk_start(cls, dt): @@ -222,20 +222,18 @@ class ScaleMixin(object): return scale_args def get_context_data(self, *args, **kwargs): - context = super().get_context_data(*args, **kwargs) + # On n'hérite pas - scale_args = self.get_scale_args() + scale_args = self.parse_scale_args() scale_name = scale_args.pop("name", None) scale_cls = Scale.by_name(scale_name) if scale_cls is None: - scale = self.get_default_scale() + self.scale = self.get_default_scale() else: - scale = scale_cls(**scale_args) + self.scale = scale_cls(**scale_args) - self.scale = scale - context["labels"] = scale.get_labels() - return context + return {"labels": self.scale.get_labels()} def get_default_scale(self): return DayScale(n_steps=7, last=True) diff --git a/kfet/views.py b/kfet/views.py index 647d78d9..1dfde369 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -2244,7 +2244,6 @@ class SingleResumeStat(JSONDetailView): On peut aussi définir `stats` dynamiquement, via la fonction `get_stats`. """ - id_prefix = "" nb_default = 0 stats = [] @@ -2252,12 +2251,15 @@ class SingleResumeStat(JSONDetailView): def get_context_data(self, **kwargs): # On n'hérite pas - object_id = self.object.id context = {} stats = [] - prefix = "{}_{}".format(self.id_prefix, object_id) - for i, stat_def in enumerate(self.stats): + # On peut avoir récupéré self.object via pk ou slug + if self.pk_url_kwarg in self.kwargs: url_pk = getattr(self.object, self.pk_url_kwarg) + else: + url_pk = getattr(self.object, self.slug_url_kwarg) + + for stat_def in self.get_stats(): url_params_d = stat_def.get("url_params", {}) if len(url_params_d) > 0: url_params = "?{}".format(urlencode(url_params_d)) @@ -2266,17 +2268,13 @@ class SingleResumeStat(JSONDetailView): stats.append( { "label": stat_def["label"], - "btn": "btn_{}_{}".format(prefix, i), "url": "{url}{params}".format( url=reverse(self.url_stat, args=[url_pk]), params=url_params ), } ) - context["id_prefix"] = prefix - context["content_id"] = "content_%s" % prefix context["stats"] = stats context["default_stat"] = self.nb_default - context["object_id"] = object_id return context @@ -2296,7 +2294,6 @@ class UserAccountMixin: # ----------------------- # Evolution Balance perso # ----------------------- -ID_PREFIX_ACC_BALANCE = "balance_acc" @method_decorator(login_required, name="dispatch") @@ -2306,10 +2303,9 @@ class AccountStatBalanceList(UserAccountMixin, SingleResumeStat): """ model = Account - context_object_name = "account" - pk_url_kwarg = "trigramme" + slug_url_kwarg = "trigramme" + slug_field = "trigramme" url_stat = "kfet.account.stat.balance" - id_prefix = ID_PREFIX_ACC_BALANCE stats = [ {"label": "Tout le temps"}, {"label": "1 an", "url_params": {"last_days": 365}}, @@ -2330,8 +2326,8 @@ class AccountStatBalance(UserAccountMixin, JSONDetailView): """ model = Account - pk_url_kwarg = "trigramme" - context_object_name = "account" + slug_url_kwarg = "trigramme" + slug_field = "trigramme" def get_changes_list(self, last_days=None, begin_date=None, end_date=None): account = self.object @@ -2444,9 +2440,8 @@ class AccountStatOperationList(UserAccountMixin, SingleResumeStat): """ model = Account - context_object_name = "account" - pk_url_kwarg = "trigramme" - id_prefix = ID_PREFIX_ACC_LAST + slug_url_kwarg = "trigramme" + slug_field = "trigramme" nb_default = 2 stats = last_stats_manifest(types=[Operation.PURCHASE]) url_stat = "kfet.account.stat.operation" @@ -2461,9 +2456,8 @@ class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): """ model = Account - pk_url_kwarg = "trigramme" - context_object_name = "account" - id_prefix = "" + slug_url_kwarg = "trigramme" + slug_field = "trigramme" def get_operations(self, types=None): # On selectionne les opérations qui correspondent @@ -2514,15 +2508,10 @@ class ArticleStatSalesList(SingleResumeStat): """ model = Article - context_object_name = "article" - id_prefix = ID_PREFIX_ART_LAST nb_default = 2 url_stat = "kfet.article.stat.sales" stats = last_stats_manifest() - @method_decorator(teamkfet_required) - def dispatch(self, *args, **kwargs): - return super().dispatch(*args, **kwargs) @method_decorator(teamkfet_required, name="dispatch") @@ -2536,8 +2525,7 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): context_object_name = "article" def get_context_data(self, *args, **kwargs): - old_ctx = super().get_context_data(*args, **kwargs) - context = {"labels": old_ctx["labels"]} + context = super().get_context_data(*args, **kwargs) scale = self.scale all_purchases = ( From c66fb7eb6fb8417857aeaa08faca158736e7b120 Mon Sep 17 00:00:00 2001 From: Ludovic Stephan Date: Mon, 9 Mar 2020 16:19:06 +0100 Subject: [PATCH 38/44] Simplify statistic.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On supprime des fonctions inutiles, on lint, et on simplifie 2-3 options inutilisées. --- kfet/static/kfet/js/statistic.js | 108 ++++++++++++------------------- 1 file changed, 41 insertions(+), 67 deletions(-) diff --git a/kfet/static/kfet/js/statistic.js b/kfet/static/kfet/js/statistic.js index 9baa08c4..23d66efe 100644 --- a/kfet/static/kfet/js/statistic.js +++ b/kfet/static/kfet/js/statistic.js @@ -1,28 +1,15 @@ -(function($){ +(function ($) { window.StatsGroup = function (url, target) { // a class to properly display statictics // url : points to an ObjectResumeStat that lists the options through JSON // target : element of the DOM where to put the stats - var self = this; var element = $(target); var content = $("
    "); var buttons; - function dictToArray (dict, start) { - // converts the dicts returned by JSONResponse to Arrays - // necessary because for..in does not guarantee the order - if (start === undefined) start = 0; - var array = new Array(); - for (var k in dict) { - array[k] = dict[k]; - } - array.splice(0, start); - return array; - } - - function handleTimeChart (data) { + function handleTimeChart(data) { // reads the balance data and put it into chartjs formatting chart_data = new Array(); for (var i = 0; i < data.length; i++) { @@ -36,7 +23,7 @@ return chart_data; } - function showStats () { + function showStats() { // CALLBACK : called when a button is selected // shows the focus on the correct button @@ -44,24 +31,20 @@ $(this).addClass("focus"); // loads data and shows it - $.getJSON(this.stats_target_url, {format: 'json'}, displayStats); + $.getJSON(this.stats_target_url, displayStats); } - function displayStats (data) { + function displayStats(data) { // reads the json data and updates the chart display var chart_datasets = []; - var charts = dictToArray(data.charts); - // are the points indexed by timestamps? var is_time_chart = data.is_time_chart || false; // reads the charts data - for (var i = 0; i < charts.length; i++) { - var chart = charts[i]; - + for (let chart of data.charts) { // format the data - var chart_data = is_time_chart ? handleTimeChart(chart.values) : dictToArray(chart.values, 0); + var chart_data = is_time_chart ? handleTimeChart(chart.values) : chart.values; chart_datasets.push( { @@ -76,29 +59,24 @@ // options for chartjs var chart_options = - { - responsive: true, - maintainAspectRatio: false, - tooltips: { - mode: 'index', - intersect: false, - }, - hover: { - mode: 'nearest', - intersect: false, - } - }; + { + responsive: true, + maintainAspectRatio: false, + tooltips: { + mode: 'index', + intersect: false, + }, + hover: { + mode: 'nearest', + intersect: false, + } + }; // additionnal options for time-indexed charts if (is_time_chart) { chart_options['scales'] = { xAxes: [{ type: "time", - display: true, - scaleLabel: { - display: false, - labelString: 'Date' - }, time: { tooltipFormat: 'll HH:mm', displayFormats: { @@ -115,26 +93,19 @@ } }], - yAxes: [{ - display: true, - scaleLabel: { - display: false, - labelString: 'value' - } - }] }; } // global object for the options var chart_model = - { - type: 'line', - options: chart_options, - data: { - labels: data.labels || [], - datasets: chart_datasets, - } - }; + { + type: 'line', + options: chart_options, + data: { + labels: data.labels || [], + datasets: chart_datasets, + } + }; // saves the previous charts to be destroyed var prev_chart = content.children(); @@ -151,23 +122,26 @@ } // initialize the interface - function initialize (data) { + function initialize(data) { // creates the bar with the buttons buttons = $("