Merge branch 'Aufinal/stat_2' into 'master'

Repassage sur les stats

Closes #246 and #255

See merge request klub-dev-ens/gestioCOF!462
This commit is contained in:
Martin Pepin 2020-09-22 21:06:46 +02:00
commit 57901c0013
5 changed files with 137 additions and 131 deletions

View file

@ -24,6 +24,7 @@ from kfet.models import (
Transfer, Transfer,
TransferGroup, TransferGroup,
) )
from kfet.statistic import SCALE_CLASS_CHOICES
from . import KFET_DELETED_TRIGRAMME from . import KFET_DELETED_TRIGRAMME
from .auth import KFET_GENERIC_TRIGRAMME from .auth import KFET_GENERIC_TRIGRAMME
@ -601,3 +602,28 @@ class OrderArticleToInventoryForm(forms.Form):
self.category = kwargs["initial"]["category"] self.category = kwargs["initial"]["category"]
self.category_name = kwargs["initial"]["category__name"] self.category_name = kwargs["initial"]["category__name"]
self.quantity_ordered = kwargs["initial"]["quantity_ordered"] 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)

View file

@ -1,6 +1,5 @@
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from dateutil.parser import parse as dateutil_parse
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.utils import timezone from django.utils import timezone
@ -48,10 +47,10 @@ class Scale(object):
if end is not None: if end is not None:
end = self.do_step(self.get_chunk_start(end)) 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.begin = begin
self.end = self.do_step(self.begin, n_steps=n_steps) 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.end = end
self.begin = self.do_step(self.end, n_steps=-n_steps) self.begin = self.do_step(self.end, n_steps=-n_steps)
elif begin is not None and end is not None: 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) 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. Convertit une spécification de scales en arguments GET utilisables par ScaleMixin.
La spécification est de la forme suivante : 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__ - scale_args : arguments à passer à Scale.__init__
- other_url_params : paramètres GET supplémentaires - default : le graphe à montrer par défaut
""" """
params_list = [] params_list = []
for label, cls, params, default in scales_def: for label, cls, params, default in scales_def:
url_params = {"scale_name": cls.name} url_params = {"scale-name": cls.name}
url_params.update({"scale_" + key: value for key, value in params.items()}) url_params.update({"scale-" + key: value for key, value in params.items()})
url_params.update(other_url_params)
params_list.append(dict(label=label, url_params=url_params, default=default)) params_list.append(dict(label=label, url_params=url_params, default=default))
return params_list 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)

View file

@ -44,9 +44,9 @@ class TestStats(TestCase):
"/k-fet/accounts/FOO/stat/operations?{}".format( "/k-fet/accounts/FOO/stat/operations?{}".format(
"&".join( "&".join(
[ [
"scale=day", "scale-name=day",
"types=['purchase']", "scale-n_steps=7",
"scale_args={'n_steps':+7,+'last':+True}", "scale-last=True",
"format=json", "format=json",
] ]
) )
@ -64,7 +64,17 @@ class TestStats(TestCase):
# receives a Redirect response # receives a Redirect response
articles_urls = [ articles_urls = [
"/k-fet/articles/{}/stat/sales/list".format(article.pk), "/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: for url in articles_urls:
resp = client.get(url) resp = client.get(url)

View file

@ -651,10 +651,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"types": ["['purchase']"], "scale-name": ["month"],
"scale_name": ["month"], "scale-last": ["True"],
"scale_last": ["True"], "scale-begin": [
"scale_begin": [
self.accounts["user1"].created_at.isoformat(" ") self.accounts["user1"].created_at.isoformat(" ")
], ],
}, },
@ -665,10 +664,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"types": ["['purchase']"], "scale-n_steps": ["12"],
"scale_n_steps": ["12"], "scale-name": ["month"],
"scale_name": ["month"], "scale-last": ["True"],
"scale_last": ["True"],
}, },
}, },
}, },
@ -677,10 +675,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"types": ["['purchase']"], "scale-n_steps": ["13"],
"scale_n_steps": ["13"], "scale-name": ["week"],
"scale_name": ["week"], "scale-last": ["True"],
"scale_last": ["True"],
}, },
}, },
}, },
@ -689,10 +686,9 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"types": ["['purchase']"], "scale-n_steps": ["14"],
"scale_n_steps": ["14"], "scale-name": ["day"],
"scale_name": ["day"], "scale-last": ["True"],
"scale_last": ["True"],
}, },
}, },
}, },
@ -739,7 +735,9 @@ class AccountStatOperationViewTests(ViewTestCaseMixin, TestCase):
return {"user1": create_user("user1", "001")} return {"user1": create_user("user1", "001")}
def test_ok(self): 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) self.assertEqual(r.status_code, 200)
@ -1593,9 +1591,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_name": ["month"], "scale-name": ["month"],
"scale_last": ["True"], "scale-last": ["True"],
"scale_begin": [self.opegroup.at.isoformat(" ")], "scale-begin": [self.opegroup.at.strftime("%Y-%m-%d %H:%M:%S")],
}, },
}, },
}, },
@ -1604,9 +1602,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["12"], "scale-n_steps": ["12"],
"scale_name": ["month"], "scale-name": ["month"],
"scale_last": ["True"], "scale-last": ["True"],
}, },
}, },
}, },
@ -1615,9 +1613,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["13"], "scale-n_steps": ["13"],
"scale_name": ["week"], "scale-name": ["week"],
"scale_last": ["True"], "scale-last": ["True"],
}, },
}, },
}, },
@ -1626,9 +1624,9 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["14"], "scale-n_steps": ["14"],
"scale_name": ["day"], "scale-name": ["day"],
"scale_last": ["True"], "scale-last": ["True"],
}, },
}, },
}, },
@ -1661,7 +1659,9 @@ class ArticleStatSalesViewTests(ViewTestCaseMixin, TestCase):
) )
def test_ok(self): 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) self.assertEqual(r.status_code, 200)

View file

@ -1,4 +1,3 @@
import ast
import heapq import heapq
import statistics import statistics
from collections import defaultdict 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.mixins import PermissionRequiredMixin
from django.contrib.auth.models import Permission, User from django.contrib.auth.models import Permission, User
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation
from django.db import transaction from django.db import transaction
from django.db.models import Count, F, Prefetch, Q, Sum from django.db.models import Count, F, Prefetch, Q, Sum
from django.forms import formset_factory from django.forms import formset_factory
@ -36,6 +36,7 @@ from kfet.forms import (
AccountNoTriForm, AccountNoTriForm,
AccountPwdForm, AccountPwdForm,
AccountRestrictForm, AccountRestrictForm,
AccountStatForm,
AccountTriForm, AccountTriForm,
AddcostForm, AddcostForm,
ArticleForm, ArticleForm,
@ -55,6 +56,7 @@ from kfet.forms import (
KPsulOperationGroupForm, KPsulOperationGroupForm,
OrderArticleForm, OrderArticleForm,
OrderArticleToInventoryForm, OrderArticleToInventoryForm,
StatScaleForm,
TransferFormSet, TransferFormSet,
UserForm, UserForm,
UserGroupForm, UserGroupForm,
@ -78,7 +80,7 @@ from kfet.models import (
Transfer, Transfer,
TransferGroup, 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 shared.views import AutocompleteView
from .auth import KFET_GENERIC_TRIGRAMME from .auth import KFET_GENERIC_TRIGRAMME
@ -2304,6 +2306,26 @@ class UserAccountMixin:
return obj 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 # Evolution Balance perso
# ----------------------- # -----------------------
@ -2416,15 +2438,14 @@ class AccountStatBalance(UserAccountMixin, JSONDetailView):
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
context = {} context = {}
last_days = self.request.GET.get("last_days", None) form = AccountStatForm(self.request.GET)
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)
changes = self.get_changes_list( if not form.is_valid():
last_days=last_days, begin_date=begin_date, end_date=end_date raise SuspiciousOperation(
) "Invalid AccountStatForm. Did someone tamper with the GET parameters ?"
)
changes = self.get_changes_list(**form.cleaned_data)
context["charts"] = [ context["charts"] = [
{"color": "rgb(200, 20, 60)", "label": "Balance", "values": changes} {"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), ("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") @method_decorator(login_required, name="dispatch")
@ -2479,28 +2500,16 @@ class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView):
slug_url_kwarg = "trigramme" slug_url_kwarg = "trigramme"
slug_field = "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): def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
types = self.request.GET.get("types", None) operations = (
if types is not None: Operation.objects.filter(
types = ast.literal_eval(types) type=Operation.PURCHASE, group__on_acc=self.object, canceled_at=None
)
operations = self.get_operations(types=types) .values("article_nb", "group__at")
.order_by("group__at")
)
# On compte les opérations # On compte les opérations
nb_ventes = self.scale.chunkify_qs( nb_ventes = self.scale.chunkify_qs(
operations, field="group__at", aggregate=Sum("article_nb") 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ù # On le crée dans le passé au cas où
first_conso = timezone.now() - timedelta(seconds=1) first_conso = timezone.now() - timedelta(seconds=1)
scales_def = [ 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), ("1 an", MonthScale, {"last": True, "n_steps": 12}, False),
("3 mois", WeekScale, {"last": True, "n_steps": 13}, True), ("3 mois", WeekScale, {"last": True, "n_steps": 13}, True),
("2 semaines", DayScale, {"last": True, "n_steps": 14}, False), ("2 semaines", DayScale, {"last": True, "n_steps": 14}, False),
@ -2572,16 +2586,16 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
.values("group__at", "article_nb") .values("group__at", "article_nb")
.order_by("group__at") .order_by("group__at")
) )
liq_only = all_purchases.filter(group__on_acc__trigramme="LIQ") cof_accts = all_purchases.filter(group__on_acc__cofprofile__is_cof=True)
liq_exclude = all_purchases.exclude(group__on_acc__trigramme="LIQ") noncof_accts = all_purchases.exclude(group__on_acc__cofprofile__is_cof=True)
nb_liq = scale.chunkify_qs( nb_cof = scale.chunkify_qs(
liq_only, field="group__at", aggregate=Sum("article_nb") cof_accts, field="group__at", aggregate=Sum("article_nb")
) )
nb_accounts = scale.chunkify_qs( nb_noncof = scale.chunkify_qs(
liq_exclude, field="group__at", aggregate=Sum("article_nb") 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"] = [ context["charts"] = [
{ {
@ -2589,11 +2603,11 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
"label": "Toutes consommations", "label": "Toutes consommations",
"values": nb_ventes, "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)", "color": "rgb(255, 205, 86)",
"label": "Comptes K-Fêt", "label": "Comptes K-Fêt",
"values": nb_accounts, "values": nb_noncof,
}, },
] ]
return context return context