diff --git a/kfet/forms.py b/kfet/forms.py index 896621f0..a7637551 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -24,6 +24,7 @@ from kfet.models import ( Transfer, TransferGroup, ) +from kfet.statistic import SCALE_CLASS_CHOICES from . import KFET_DELETED_TRIGRAMME from .auth import KFET_GENERIC_TRIGRAMME @@ -601,3 +602,28 @@ class OrderArticleToInventoryForm(forms.Form): self.category = kwargs["initial"]["category"] self.category_name = kwargs["initial"]["category__name"] self.quantity_ordered = kwargs["initial"]["quantity_ordered"] + + +# ---- +# Formulaires pour les statistiques K-Fêt +# ---- + + +class StatScaleForm(forms.Form): + """Formulaire pour nettoyer les paramètres envoyés aux + vues de statistiques K-Fêt. Non destiné à être affiché. + """ + + name = forms.ChoiceField(choices=SCALE_CLASS_CHOICES) + begin = forms.DateTimeField(required=False) + end = forms.DateTimeField(required=False) + n_steps = forms.IntegerField(required=False) + last = forms.BooleanField(required=False) + + +class AccountStatForm(forms.Form): + """ Idem, mais pour la balance d'un compte """ + + begin_date = forms.DateTimeField(required=False) + end_date = forms.DateTimeField(required=False) + last_days = forms.IntegerField(required=False) diff --git a/kfet/statistic.py b/kfet/statistic.py index b2c1d882..4cf04387 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -1,6 +1,5 @@ from datetime import date, datetime, time, timedelta -from dateutil.parser import parse as dateutil_parse from dateutil.relativedelta import relativedelta from django.utils import timezone @@ -48,10 +47,10 @@ class Scale(object): if end is not None: end = self.do_step(self.get_chunk_start(end)) - if begin is not None and n_steps != 0: + if begin is not None and n_steps: self.begin = begin self.end = self.do_step(self.begin, n_steps=n_steps) - elif end is not None and n_steps != 0: + elif end is not None and n_steps: self.end = end self.begin = self.do_step(self.end, n_steps=-n_steps) elif begin is not None and end is not None: @@ -144,67 +143,24 @@ class MonthScale(Scale): return to_kfet_day(dt).replace(day=1) -def scale_url_params(scales_def, **other_url_params): +SCALE_CLASS_CHOICES = ((cls.name, cls.name) for cls in Scale.__subclasses__()) +SCALE_DICT = {cls.name: cls for cls in Scale.__subclasses__()} + + +def scale_url_params(scales_def): """ 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) + - scales_def : liste de champs de la forme (label, scale, scale_args, default) + - scale_args : arguments à passer à Scale.__init__ - - other_url_params : paramètres GET supplémentaires + - default : le graphe à montrer par défaut """ params_list = [] for label, cls, params, default in scales_def: - url_params = {"scale_name": cls.name} - url_params.update({"scale_" + key: value for key, value in params.items()}) - url_params.update(other_url_params) + url_params = {"scale-name": cls.name} + url_params.update({"scale-" + key: value for key, value in params.items()}) params_list.append(dict(label=label, url_params=url_params, default=default)) return params_list - - -class ScaleMixin(object): - def parse_scale_args(self): - """ - Récupère les paramètres de subdivision encodés dans une requête GET. - """ - scale_args = {} - - name = self.request.GET.get("scale_name", None) - if name is not None: - scale_args["name"] = name - - n_steps = self.request.GET.get("scale_n_steps", None) - if n_steps is not None: - scale_args["n_steps"] = int(n_steps) - - begin = self.request.GET.get("scale_begin", None) - if begin is not None: - scale_args["begin"] = dateutil_parse(begin) - - end = self.request.GET.get("scale_send", None) - if end is not None: - scale_args["end"] = dateutil_parse(end) - - 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 - - return scale_args - - def get_context_data(self, *args, **kwargs): - # On n'hérite pas - - 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: - self.scale = self.get_default_scale() - else: - self.scale = scale_cls(**scale_args) - - return {"labels": self.scale.get_labels()} - - def get_default_scale(self): - return DayScale(n_steps=7, last=True) diff --git a/kfet/tests/test_statistic.py b/kfet/tests/test_statistic.py index 5b5209cc..6d8ecb47 100644 --- a/kfet/tests/test_statistic.py +++ b/kfet/tests/test_statistic.py @@ -44,9 +44,9 @@ class TestStats(TestCase): "/k-fet/accounts/FOO/stat/operations?{}".format( "&".join( [ - "scale=day", - "types=['purchase']", - "scale_args={'n_steps':+7,+'last':+True}", + "scale-name=day", + "scale-n_steps=7", + "scale-last=True", "format=json", ] ) @@ -64,7 +64,17 @@ class TestStats(TestCase): # receives a Redirect response articles_urls = [ "/k-fet/articles/{}/stat/sales/list".format(article.pk), - "/k-fet/articles/{}/stat/sales".format(article.pk), + "/k-fet/articles/{}/stat/sales?{}".format( + article.pk, + "&".join( + [ + "scale-name=day", + "scale-n_steps=7", + "scale-last=True", + "format=json", + ] + ), + ), ] for url in articles_urls: resp = client.get(url) diff --git a/kfet/tests/test_views.py b/kfet/tests/test_views.py index b6738d17..47382aa1 100644 --- a/kfet/tests/test_views.py +++ b/kfet/tests/test_views.py @@ -651,10 +651,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "types": ["['purchase']"], - "scale_name": ["month"], - "scale_last": ["True"], - "scale_begin": [ + "scale-name": ["month"], + "scale-last": ["True"], + "scale-begin": [ self.accounts["user1"].created_at.isoformat(" ") ], }, @@ -665,10 +664,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "types": ["['purchase']"], - "scale_n_steps": ["12"], - "scale_name": ["month"], - "scale_last": ["True"], + "scale-n_steps": ["12"], + "scale-name": ["month"], + "scale-last": ["True"], }, }, }, @@ -677,10 +675,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "types": ["['purchase']"], - "scale_n_steps": ["13"], - "scale_name": ["week"], - "scale_last": ["True"], + "scale-n_steps": ["13"], + "scale-name": ["week"], + "scale-last": ["True"], }, }, }, @@ -689,10 +686,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "types": ["['purchase']"], - "scale_n_steps": ["14"], - "scale_name": ["day"], - "scale_last": ["True"], + "scale-n_steps": ["14"], + "scale-name": ["day"], + "scale-last": ["True"], }, }, }, @@ -739,7 +735,9 @@ class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase): return {"user1": create_user("user1", "001")} def test_ok(self): - r = self.client.get(self.url) + r = self.client.get( + self.url, {"scale-name": "day", "scale-n_steps": 7, "scale-last": True} + ) self.assertEqual(r.status_code, 200) @@ -1593,9 +1591,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "scale_name": ["month"], - "scale_last": ["True"], - "scale_begin": [self.opegroup.at.isoformat(" ")], + "scale-name": ["month"], + "scale-last": ["True"], + "scale-begin": [self.opegroup.at.strftime("%Y-%m-%d %H:%M:%S")], }, }, }, @@ -1604,9 +1602,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "scale_n_steps": ["12"], - "scale_name": ["month"], - "scale_last": ["True"], + "scale-n_steps": ["12"], + "scale-name": ["month"], + "scale-last": ["True"], }, }, }, @@ -1615,9 +1613,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "scale_n_steps": ["13"], - "scale_name": ["week"], - "scale_last": ["True"], + "scale-n_steps": ["13"], + "scale-name": ["week"], + "scale-last": ["True"], }, }, }, @@ -1626,9 +1624,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase): "url": { "path": base_url, "query": { - "scale_n_steps": ["14"], - "scale_name": ["day"], - "scale_last": ["True"], + "scale-n_steps": ["14"], + "scale-name": ["day"], + "scale-last": ["True"], }, }, }, @@ -1661,7 +1659,9 @@ class ArticleStatSalesViewTests(ViewTestCaseMixin, TestCase): ) def test_ok(self): - r = self.client.get(self.url) + r = self.client.get( + self.url, {"scale-name": "day", "scale-n_steps": 7, "scale-last": True} + ) self.assertEqual(r.status_code, 200) diff --git a/kfet/views.py b/kfet/views.py index bb45b1fb..a9dd1945 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1,4 +1,3 @@ -import ast import heapq import statistics from collections import defaultdict @@ -12,6 +11,7 @@ from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.models import Permission, User from django.contrib.messages.views import SuccessMessageMixin +from django.core.exceptions import SuspiciousOperation from django.db import transaction from django.db.models import Count, F, Prefetch, Q, Sum from django.forms import formset_factory @@ -36,6 +36,7 @@ from kfet.forms import ( AccountNoTriForm, AccountPwdForm, AccountRestrictForm, + AccountStatForm, AccountTriForm, AddcostForm, ArticleForm, @@ -55,6 +56,7 @@ from kfet.forms import ( KPsulOperationGroupForm, OrderArticleForm, OrderArticleToInventoryForm, + StatScaleForm, TransferFormSet, UserForm, UserGroupForm, @@ -78,7 +80,7 @@ from kfet.models import ( Transfer, TransferGroup, ) -from kfet.statistic import DayScale, MonthScale, ScaleMixin, WeekScale, scale_url_params +from kfet.statistic import SCALE_DICT, DayScale, MonthScale, WeekScale, scale_url_params from shared.views import AutocompleteView from .auth import KFET_GENERIC_TRIGRAMME @@ -2304,6 +2306,26 @@ class UserAccountMixin: return obj +class ScaleMixin(object): + """Mixin pour utiliser les outils de `kfet.statistic`.""" + + def get_context_data(self, *args, **kwargs): + # On n'hérite pas + form = StatScaleForm(self.request.GET, prefix="scale") + + if not form.is_valid(): + raise SuspiciousOperation( + "Invalid StatScaleForm. Did someone tamper with the GET parameters ?" + ) + + scale_name = form.cleaned_data.pop("name") + scale_cls = SCALE_DICT.get(scale_name) + + self.scale = scale_cls(**form.cleaned_data) + + return {"labels": self.scale.get_labels()} + + # ----------------------- # Evolution Balance perso # ----------------------- @@ -2416,15 +2438,14 @@ class AccountStatBalance(UserAccountMixin, JSONDetailView): def get_context_data(self, *args, **kwargs): context = {} - last_days = self.request.GET.get("last_days", None) - if last_days is not None: - last_days = int(last_days) - begin_date = self.request.GET.get("begin_date", None) - end_date = self.request.GET.get("end_date", None) + form = AccountStatForm(self.request.GET) - changes = self.get_changes_list( - last_days=last_days, begin_date=begin_date, end_date=end_date - ) + if not form.is_valid(): + raise SuspiciousOperation( + "Invalid AccountStatForm. Did someone tamper with the GET parameters ?" + ) + + changes = self.get_changes_list(**form.cleaned_data) context["charts"] = [ {"color": "rgb(200, 20, 60)", "label": "Balance", "values": changes} @@ -2466,7 +2487,7 @@ class AccountStatOperationList(UserAccountMixin, SingleResumeStat): ("2 semaines", DayScale, {"last": True, "n_steps": 14}, False), ] - return scale_url_params(scales_def, types=[Operation.PURCHASE]) + return scale_url_params(scales_def) @method_decorator(login_required, name="dispatch") @@ -2479,28 +2500,16 @@ class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): slug_url_kwarg = "trigramme" slug_field = "trigramme" - 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 - # effectuées dans ces intervalles de temps - all_operations = ( - Operation.objects.filter(group__on_acc=self.object, canceled_at=None) - .values("article_nb", "group__at") - .order_by("group__at") - ) - if types is not None: - all_operations = all_operations.filter(type__in=types) - return all_operations - def get_context_data(self, *args, **kwargs): 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) + operations = ( + Operation.objects.filter( + type=Operation.PURCHASE, group__on_acc=self.object, canceled_at=None + ) + .values("article_nb", "group__at") + .order_by("group__at") + ) # On compte les opérations nb_ventes = self.scale.chunkify_qs( operations, field="group__at", aggregate=Sum("article_nb") @@ -2517,7 +2526,7 @@ class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView): # ------------------------ -# Article Satistiques Last +# Article Statistiques Last # ------------------------ @@ -2542,7 +2551,12 @@ class ArticleStatSalesList(SingleResumeStat): # On le crée dans le passé au cas où first_conso = timezone.now() - timedelta(seconds=1) scales_def = [ - ("Tout le temps", MonthScale, {"last": True, "begin": first_conso}, False), + ( + "Tout le temps", + MonthScale, + {"last": True, "begin": first_conso.strftime("%Y-%m-%d %H:%M:%S")}, + False, + ), ("1 an", MonthScale, {"last": True, "n_steps": 12}, False), ("3 mois", WeekScale, {"last": True, "n_steps": 13}, True), ("2 semaines", DayScale, {"last": True, "n_steps": 14}, False), @@ -2572,16 +2586,16 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): .values("group__at", "article_nb") .order_by("group__at") ) - liq_only = all_purchases.filter(group__on_acc__trigramme="LIQ") - liq_exclude = all_purchases.exclude(group__on_acc__trigramme="LIQ") + cof_accts = all_purchases.filter(group__on_acc__cofprofile__is_cof=True) + noncof_accts = all_purchases.exclude(group__on_acc__cofprofile__is_cof=True) - nb_liq = scale.chunkify_qs( - liq_only, field="group__at", aggregate=Sum("article_nb") + nb_cof = scale.chunkify_qs( + cof_accts, field="group__at", aggregate=Sum("article_nb") ) - nb_accounts = scale.chunkify_qs( - liq_exclude, field="group__at", aggregate=Sum("article_nb") + nb_noncof = scale.chunkify_qs( + noncof_accts, field="group__at", aggregate=Sum("article_nb") ) - nb_ventes = [n1 + n2 for n1, n2 in zip(nb_liq, nb_accounts)] + nb_ventes = [n1 + n2 for n1, n2 in zip(nb_cof, nb_noncof)] context["charts"] = [ { @@ -2589,11 +2603,11 @@ class ArticleStatSales(ScaleMixin, JSONDetailView): "label": "Toutes consommations", "values": nb_ventes, }, - {"color": "rgb(54, 162, 235)", "label": "LIQ", "values": nb_liq}, + {"color": "rgb(54, 162, 235)", "label": "LIQ", "values": nb_cof}, { "color": "rgb(255, 205, 86)", "label": "Comptes K-Fêt", - "values": nb_accounts, + "values": nb_noncof, }, ] return context