Merge branch 'Aufinal/simplify_stats' into 'master'

Simplifie massivement les statistiques K-Fêt + étend la période de stats

Closes #257 and #244

See merge request klub-dev-ens/gestioCOF!411
This commit is contained in:
Martin Pepin 2020-05-08 12:41:18 +02:00
commit 5a9ea4234e
5 changed files with 284 additions and 399 deletions

View file

@ -27,6 +27,7 @@ Liste des changements notables dans GestioCOF depuis la version 0.1 (septembre
- Les transferts apparaissent maintenant dans l'historique K-Fêt et l'historique - Les transferts apparaissent maintenant dans l'historique K-Fêt et l'historique
personnel. personnel.
- les statistiques K-Fêt remontent à plus d'un an (et le code est simplifié)
## Version 0.4.1 - 17/01/2020 ## Version 0.4.1 - 17/01/2020

View file

@ -1,28 +1,15 @@
(function($){ (function ($) {
window.StatsGroup = function (url, target) { window.StatsGroup = function (url, target) {
// a class to properly display statictics // a class to properly display statictics
// url : points to an ObjectResumeStat that lists the options through JSON // url : points to an ObjectResumeStat that lists the options through JSON
// target : element of the DOM where to put the stats // target : element of the DOM where to put the stats
var self = this;
var element = $(target); var element = $(target);
var content = $("<div class='full'>"); var content = $("<div class='full'>");
var buttons; var buttons;
function dictToArray (dict, start) { function handleTimeChart(data) {
// 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) {
// reads the balance data and put it into chartjs formatting // reads the balance data and put it into chartjs formatting
chart_data = new Array(); chart_data = new Array();
for (var i = 0; i < data.length; i++) { for (var i = 0; i < data.length; i++) {
@ -36,7 +23,7 @@
return chart_data; return chart_data;
} }
function showStats () { function showStats() {
// CALLBACK : called when a button is selected // CALLBACK : called when a button is selected
// shows the focus on the correct button // shows the focus on the correct button
@ -44,24 +31,20 @@
$(this).addClass("focus"); $(this).addClass("focus");
// loads data and shows it // 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 // reads the json data and updates the chart display
var chart_datasets = []; var chart_datasets = [];
var charts = dictToArray(data.charts);
// are the points indexed by timestamps? // are the points indexed by timestamps?
var is_time_chart = data.is_time_chart || false; var is_time_chart = data.is_time_chart || false;
// reads the charts data // reads the charts data
for (var i = 0; i < charts.length; i++) { for (let chart of data.charts) {
var chart = charts[i];
// format the data // 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( chart_datasets.push(
{ {
@ -76,29 +59,24 @@
// options for chartjs // options for chartjs
var chart_options = var chart_options =
{ {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
tooltips: { tooltips: {
mode: 'index', mode: 'index',
intersect: false, intersect: false,
}, },
hover: { hover: {
mode: 'nearest', mode: 'nearest',
intersect: false, intersect: false,
} }
}; };
// additionnal options for time-indexed charts // additionnal options for time-indexed charts
if (is_time_chart) { if (is_time_chart) {
chart_options['scales'] = { chart_options['scales'] = {
xAxes: [{ xAxes: [{
type: "time", type: "time",
display: true,
scaleLabel: {
display: false,
labelString: 'Date'
},
time: { time: {
tooltipFormat: 'll HH:mm', tooltipFormat: 'll HH:mm',
displayFormats: { displayFormats: {
@ -115,26 +93,19 @@
} }
}], }],
yAxes: [{
display: true,
scaleLabel: {
display: false,
labelString: 'value'
}
}]
}; };
} }
// global object for the options // global object for the options
var chart_model = var chart_model =
{ {
type: 'line', type: 'line',
options: chart_options, options: chart_options,
data: { data: {
labels: data.labels || [], labels: data.labels || [],
datasets: chart_datasets, datasets: chart_datasets,
} }
}; };
// saves the previous charts to be destroyed // saves the previous charts to be destroyed
var prev_chart = content.children(); var prev_chart = content.children();
@ -151,27 +122,30 @@
} }
// initialize the interface // initialize the interface
function initialize (data) { function initialize(data) {
// creates the bar with the buttons // creates the bar with the buttons
buttons = $("<ul>", buttons = $("<ul>",
{class: "nav stat-nav", {
"aria-label": "select-period"}); class: "nav stat-nav",
"aria-label": "select-period"
});
var to_click; var to_click;
var context = data.stats;
for (var i = 0; i < context.length; i++) { for (let stat of data.stats) {
// creates the button // creates the button
var btn_wrapper = $("<li>", {role:"presentation"}); var btn_wrapper = $("<li>", { role: "presentation" });
var btn = $("<a>", var btn = $("<a>",
{class: "btn btn-nav", {
type: "button"}) class: "btn btn-nav",
.text(context[i].label) type: "button"
.prop("stats_target_url", context[i].url) })
.text(stat.label)
.prop("stats_target_url", stat.url)
.on("click", showStats); .on("click", showStats);
// saves the default option to select // saves the default option to select
if (i == data.default_stat || i == 0) if (stat.default)
to_click = btn; to_click = btn;
// append the elements to the parent // append the elements to the parent
@ -189,7 +163,7 @@
// constructor // constructor
(function () { (function () {
$.getJSON(url, {format: 'json'}, initialize); $.getJSON(url, initialize);
})(); })();
}; };
})(jQuery); })(jQuery);

View file

@ -1,21 +1,22 @@
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
import pytz
from dateutil.parser import parse as dateutil_parse from dateutil.parser import parse as dateutil_parse
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.db.models import Sum
from django.utils import timezone 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): 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`
naive = datetime.combine(date(year, month, day), start_at) correspondant au début du 'jour K-Fêt' correspondant."""
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): 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) kfet_dt = kfet_day(year=dt.year, month=dt.month, day=dt.day)
if dt.time() < start_at: if dt.time() < start_at:
kfet_dt -= timedelta(days=1) kfet_dt -= timedelta(days=1)
@ -23,6 +24,17 @@ def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT):
class Scale(object): 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é, le début de la première subdivision
est généré via la fonction `get_chunk_start`.
"""
name = None name = None
step = None step = None
@ -52,7 +64,7 @@ class Scale(object):
"or use last and n_steps" "or use last and n_steps"
) )
self.datetimes = self.get_datetimes() self._gen_datetimes()
@staticmethod @staticmethod
def by_name(name): def by_name(name):
@ -61,9 +73,6 @@ class Scale(object):
return cls return cls
return None return None
def get_from(self, dt):
return self.std_chunk and self.get_chunk_start(dt) or dt
def __getitem__(self, i): def __getitem__(self, i):
return self.datetimes[i], self.datetimes[i + 1] return self.datetimes[i], self.datetimes[i + 1]
@ -73,13 +82,13 @@ class Scale(object):
def do_step(self, dt, n_steps=1): def do_step(self, dt, n_steps=1):
return dt + self.step * n_steps return dt + self.step * n_steps
def get_datetimes(self): def _gen_datetimes(self):
datetimes = [self.begin] datetimes = [self.begin]
tmp = self.begin tmp = self.begin
while tmp < self.end: while tmp < self.end:
tmp = self.do_step(tmp) tmp = self.do_step(tmp)
datetimes.append(tmp) datetimes.append(tmp)
return datetimes self.datetimes = datetimes
def get_labels(self, label_fmt=None): def get_labels(self, label_fmt=None):
if label_fmt is None: if label_fmt is None:
@ -89,93 +98,18 @@ class Scale(object):
for i, (begin, end) in enumerate(self) for i, (begin, end) in enumerate(self)
] ]
def chunkify_qs(self, qs, field=None): def chunkify_qs(self, qs, field="at", aggregate=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) begin_f = "{}__gte".format(field)
end_f = "{}__lte".format(field) end_f = "{}__lte".format(field)
return [qs.filter(**{begin_f: begin, end_f: end}) for begin, end in self] chunks = [qs.filter(**{begin_f: begin, end_f: end}) for begin, end in self]
if aggregate is None:
def get_by_chunks(self, qs, field_callback=None, field_db="at"): return chunks
"""Objects of queryset ranked according to the scale. else:
return [chunk.aggregate(agg=aggregate)["agg"] or 0 for chunk in chunks]
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)
class DayScale(Scale): class DayScale(Scale):
@ -191,7 +125,7 @@ class DayScale(Scale):
class WeekScale(Scale): class WeekScale(Scale):
name = "week" name = "week"
step = timedelta(days=7) step = timedelta(days=7)
label_fmt = "Semaine %W" label_fmt = "%d %b."
@classmethod @classmethod
def get_chunk_start(cls, dt): def get_chunk_start(cls, dt):
@ -210,111 +144,67 @@ class MonthScale(Scale):
return to_kfet_day(dt).replace(day=1) return to_kfet_day(dt).replace(day=1)
def stat_manifest( def scale_url_params(scales_def, **other_url_params):
scales_def=None, scale_args=None, scale_prefix=None, **other_url_params """
): Convertit une spécification de scales en arguments GET utilisables par ScaleMixin.
if scale_prefix is None: La spécification est de la forme suivante :
scale_prefix = "scale_" - scales_def : liste de champs de la forme (label, scale)
if scales_def is None: - scale_args : arguments à passer à Scale.__init__
scales_def = [] - other_url_params : paramètres GET supplémentaires
if scale_args is None: """
scale_args = {}
manifest = [] params_list = []
for label, cls in scales_def: for label, cls, params, default in scales_def:
url_params = {scale_prefix + "name": cls.name} url_params = {"scale_name": cls.name}
url_params.update( url_params.update({"scale_" + key: value for key, value in params.items()})
{scale_prefix + key: value for key, value in scale_args.items()}
)
url_params.update(other_url_params) url_params.update(other_url_params)
manifest.append(dict(label=label, url_params=url_params)) params_list.append(dict(label=label, url_params=url_params, default=default))
return manifest
return params_list
def last_stats_manifest(
scales_def=None, scale_args=None, scale_prefix=None, **url_params
):
scales_def = [
("Derniers mois", MonthScale),
("Dernières semaines", WeekScale),
("Derniers jours", DayScale),
]
if scale_args is None:
scale_args = {}
scale_args.update(dict(last=True, n_steps=7))
return stat_manifest(
scales_def=scales_def,
scale_args=scale_args,
scale_prefix=scale_prefix,
**url_params
)
# É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): class ScaleMixin(object):
scale_args_prefix = "scale_" def parse_scale_args(self):
"""
def get_scale_args(self, params=None, prefix=None): Récupère les paramètres de subdivision encodés dans une requête GET.
"""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`.
""" """
if params is None:
params = self.request.GET
if prefix is None:
prefix = self.scale_args_prefix
scale_args = {} scale_args = {}
name = params.get(prefix + "name", None) name = self.request.GET.get("scale_name", None)
if name is not None: if name is not None:
scale_args["name"] = name 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: if n_steps is not None:
scale_args["n_steps"] = int(n_steps) 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: if begin is not None:
scale_args["begin"] = dateutil_parse(begin) 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: if end is not None:
scale_args["end"] = dateutil_parse(end) 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: if last is not None:
scale_args["last"] = last in ["true", "True", "1"] and True or False scale_args["last"] = last in ["true", "True", "1"] and True or False
return scale_args return scale_args
def get_context_data(self, *args, **kwargs): 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_name = scale_args.pop("name", None)
scale_cls = Scale.by_name(scale_name) scale_cls = Scale.by_name(scale_name)
if scale_cls is None: if scale_cls is None:
scale = self.get_default_scale() self.scale = self.get_default_scale()
else: else:
scale = scale_cls(**scale_args) self.scale = scale_cls(**scale_args)
self.scale = scale return {"labels": self.scale.get_labels()}
context["labels"] = scale.get_labels()
return context
def get_default_scale(self): def get_default_scale(self):
return DayScale(n_steps=7, last=True) return DayScale(n_steps=7, last=True)

View file

@ -628,37 +628,51 @@ class AccountStatOperationListViewTests(ViewTestCaseMixin, TestCase):
expected_stats = [ expected_stats = [
{ {
"label": "Derniers mois", "label": "Tout le temps",
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["7"], "types": ["['purchase']"],
"scale_name": ["month"], "scale_name": ["month"],
"scale_last": ["True"],
"scale_begin": [
self.accounts["user1"].created_at.isoformat(" ")
],
},
},
},
{
"label": "1 an",
"url": {
"path": base_url,
"query": {
"types": ["['purchase']"], "types": ["['purchase']"],
"scale_n_steps": ["12"],
"scale_name": ["month"],
"scale_last": ["True"], "scale_last": ["True"],
}, },
}, },
}, },
{ {
"label": "Dernières semaines", "label": "3 mois",
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["7"], "types": ["['purchase']"],
"scale_n_steps": ["13"],
"scale_name": ["week"], "scale_name": ["week"],
"types": ["['purchase']"],
"scale_last": ["True"], "scale_last": ["True"],
}, },
}, },
}, },
{ {
"label": "Derniers jours", "label": "2 semaines",
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["7"],
"scale_name": ["day"],
"types": ["['purchase']"], "types": ["['purchase']"],
"scale_n_steps": ["14"],
"scale_name": ["day"],
"scale_last": ["True"], "scale_last": ["True"],
}, },
}, },
@ -1524,6 +1538,21 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
self.article = Article.objects.create( self.article = Article.objects.create(
name="Article", category=ArticleCategory.objects.create(name="Category") name="Article", category=ArticleCategory.objects.create(name="Category")
) )
checkout = Checkout.objects.create(
name="Checkout",
created_by=self.accounts["team"],
balance=5,
valid_from=self.now,
valid_to=self.now + timedelta(days=5),
)
self.opegroup = create_operation_group(
on_acc=self.accounts["user"],
checkout=checkout,
content=[
{"type": Operation.PURCHASE, "article": self.article, "article_nb": 2},
],
)
def test_ok(self): def test_ok(self):
r = self.client.get(self.url) r = self.client.get(self.url)
@ -1535,33 +1564,44 @@ class ArticleStatSalesListViewTests(ViewTestCaseMixin, TestCase):
expected_stats = [ expected_stats = [
{ {
"label": "Derniers mois", "label": "Tout le temps",
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["7"], "scale_name": ["month"],
"scale_last": ["True"],
"scale_begin": [self.opegroup.at.isoformat(" ")],
},
},
},
{
"label": "1 an",
"url": {
"path": base_url,
"query": {
"scale_n_steps": ["12"],
"scale_name": ["month"], "scale_name": ["month"],
"scale_last": ["True"], "scale_last": ["True"],
}, },
}, },
}, },
{ {
"label": "Dernières semaines", "label": "3 mois",
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["7"], "scale_n_steps": ["13"],
"scale_name": ["week"], "scale_name": ["week"],
"scale_last": ["True"], "scale_last": ["True"],
}, },
}, },
}, },
{ {
"label": "Derniers jours", "label": "2 semaines",
"url": { "url": {
"path": base_url, "path": base_url,
"query": { "query": {
"scale_n_steps": ["7"], "scale_n_steps": ["14"],
"scale_name": ["day"], "scale_name": ["day"],
"scale_last": ["True"], "scale_last": ["True"],
}, },

View file

@ -2,6 +2,7 @@ import ast
import heapq import heapq
import statistics import statistics
from collections import defaultdict from collections import defaultdict
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from typing import List from typing import List
from urllib.parse import urlencode from urllib.parse import urlencode
@ -76,7 +77,7 @@ from kfet.models import (
Transfer, Transfer,
TransferGroup, TransferGroup,
) )
from kfet.statistic import ScaleMixin, WeekScale, last_stats_manifest from kfet.statistic import DayScale, MonthScale, ScaleMixin, WeekScale, scale_url_params
from .auth import KFET_GENERIC_TRIGRAMME from .auth import KFET_GENERIC_TRIGRAMME
from .auth.views import ( # noqa from .auth.views import ( # noqa
@ -2199,7 +2200,7 @@ class SupplierUpdate(SuccessMessageMixin, UpdateView):
# Vues génériques # Vues génériques
# --------------- # ---------------
# source : docs.djangoproject.com/fr/1.10/topics/class-based-views/mixins/ # 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. A mixin that can be used to render a JSON response.
""" """
@ -2228,34 +2229,39 @@ class JSONDetailView(JSONResponseMixin, BaseDetailView):
return self.render_to_json_response(context) 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): 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: Attributs :
url to retrieve data, label, ... - url_stat : URL 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 = ""
nb_default = 0
stats = []
url_stat = None url_stat = None
stats = []
def get_stats(self):
return self.stats
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# On n'hérite pas # On n'hérite pas
object_id = self.object.id
context = {} context = {}
stats = [] stats = []
prefix = "{}_{}".format(self.id_prefix, object_id) # On peut avoir récupéré self.object via pk ou slug
for i, stat_def in enumerate(self.stats): if self.pk_url_kwarg in self.kwargs:
url_pk = getattr(self.object, self.pk_url_kwarg) 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", {}) url_params_d = stat_def.get("url_params", {})
if len(url_params_d) > 0: if len(url_params_d) > 0:
url_params = "?{}".format(urlencode(url_params_d)) url_params = "?{}".format(urlencode(url_params_d))
@ -2264,42 +2270,21 @@ class SingleResumeStat(JSONDetailView):
stats.append( stats.append(
{ {
"label": stat_def["label"], "label": stat_def["label"],
"btn": "btn_{}_{}".format(prefix, i),
"url": "{url}{params}".format( "url": "{url}{params}".format(
url=reverse(self.url_stat, args=[url_pk]), params=url_params url=reverse(self.url_stat, args=[url_pk]), params=url_params
), ),
"default": stat_def.get("default", False),
} }
) )
context["id_prefix"] = prefix
context["content_id"] = "content_%s" % prefix
context["stats"] = stats context["stats"] = stats
context["default_stat"] = self.nb_default
context["object_id"] = object_id
return context return context
# ----------------------- class UserAccountMixin:
# Evolution Balance perso """
# ----------------------- Mixin qui vérifie que le compte traité par la vue est celui de l'utilisateur·ice
ID_PREFIX_ACC_BALANCE = "balance_acc" actuel·le. Dans le cas contraire, renvoie un Http404.
"""
class AccountStatBalanceList(PkUrlMixin, SingleResumeStat):
"""Manifest for balance stats of an account."""
model = Account
context_object_name = "account"
pk_url_kwarg = "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}},
{"label": "6 mois", "url_params": {"last_days": 183}},
{"label": "3 mois", "url_params": {"last_days": 90}},
{"label": "30 jours", "url_params": {"last_days": 30}},
]
nb_default = 0
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs) obj = super().get_object(*args, **kwargs)
@ -2307,21 +2292,41 @@ class AccountStatBalanceList(PkUrlMixin, SingleResumeStat):
raise Http404 raise Http404
return obj return obj
@method_decorator(login_required)
def dispatch(self, *args, **kwargs): # -----------------------
return super().dispatch(*args, **kwargs) # Evolution Balance perso
# -----------------------
class AccountStatBalance(PkUrlMixin, JSONDetailView): @method_decorator(login_required, name="dispatch")
"""Datasets of balance of an account. class AccountStatBalanceList(UserAccountMixin, SingleResumeStat):
"""
Operations and Transfers are taken into account. Menu général pour l'historique de balance d'un compte
""" """
model = Account model = Account
pk_url_kwarg = "trigramme" slug_url_kwarg = "trigramme"
context_object_name = "account" slug_field = "trigramme"
url_stat = "kfet.account.stat.balance"
stats = [
{"label": "Tout le temps"},
{"label": "1 an", "url_params": {"last_days": 365}},
{"label": "6 mois", "url_params": {"last_days": 183}},
{"label": "3 mois", "url_params": {"last_days": 90}, "default": True},
{"label": "30 jours", "url_params": {"last_days": 30}},
]
@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.
"""
model = Account
slug_url_kwarg = "trigramme"
slug_field = "trigramme"
def get_changes_list(self, last_days=None, begin_date=None, end_date=None): def get_changes_list(self, last_days=None, begin_date=None, end_date=None):
account = self.object account = self.object
@ -2420,57 +2425,50 @@ class AccountStatBalance(PkUrlMixin, JSONDetailView):
# TODO: offset # TODO: offset
return context 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 # 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")
"""Manifest for operations stats of an account.""" class AccountStatOperationList(UserAccountMixin, SingleResumeStat):
"""
Menu général pour l'historique de consommation d'un compte
"""
model = Account model = Account
context_object_name = "account" slug_url_kwarg = "trigramme"
pk_url_kwarg = "trigramme" slug_field = "trigramme"
id_prefix = ID_PREFIX_ACC_LAST
nb_default = 2
stats = last_stats_manifest(types=[Operation.PURCHASE])
url_stat = "kfet.account.stat.operation" url_stat = "kfet.account.stat.operation"
def get_object(self, *args, **kwargs): def get_stats(self):
obj = super().get_object(*args, **kwargs) scales_def = [
if self.request.user != obj.user: (
raise Http404 "Tout le temps",
return obj MonthScale,
{"last": True, "begin": self.object.created_at},
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),
]
@method_decorator(login_required) return scale_url_params(scales_def, types=[Operation.PURCHASE])
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): @method_decorator(login_required, name="dispatch")
"""Datasets of operations of an account.""" class AccountStatOperation(UserAccountMixin, ScaleMixin, JSONDetailView):
"""
Statistiques (JSON) de consommation (nb d'items achetés) d'un compte.
"""
model = Account model = Account
pk_url_kwarg = "trigramme" slug_url_kwarg = "trigramme"
context_object_name = "account" slug_field = "trigramme"
id_prefix = ""
def get_operations(self, scale, types=None): def get_operations(self, types=None):
# On selectionne les opérations qui correspondent # On selectionne les opérations qui correspondent
# à l'article en question et qui ne sont pas annulées # à l'article en question et qui ne sont pas annulées
# puis on choisi pour chaques intervalle les opérations # puis on choisi pour chaques intervalle les opérations
@ -2482,28 +2480,20 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView):
) )
if types is not None: if types is not None:
all_operations = all_operations.filter(type__in=types) all_operations = all_operations.filter(type__in=types)
chunks = scale.get_by_chunks( return all_operations
all_operations,
field_db="group__at",
field_callback=(lambda d: d["group__at"]),
)
return chunks
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
old_ctx = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context = {"labels": old_ctx["labels"]}
scale = self.scale
types = self.request.GET.get("types", None) types = self.request.GET.get("types", None)
if types is not None: if types is not None:
types = ast.literal_eval(types) types = ast.literal_eval(types)
operations = self.get_operations(types=types, scale=scale) operations = self.get_operations(types=types)
# On compte les opérations # On compte les opérations
nb_ventes = [] nb_ventes = self.scale.chunkify_qs(
for chunk in operations: operations, field="group__at", aggregate=Sum("article_nb")
ventes = sum(ope["article_nb"] for ope in chunk) )
nb_ventes.append(ventes)
context["charts"] = [ context["charts"] = [
{ {
@ -2514,50 +2504,54 @@ class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView):
] ]
return context 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 # 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): class ArticleStatSalesList(SingleResumeStat):
"""Manifest for sales stats of an article.""" """
Menu pour les statistiques de vente d'un article.
"""
model = Article model = Article
context_object_name = "article"
id_prefix = ID_PREFIX_ART_LAST
nb_default = 2 nb_default = 2
url_stat = "kfet.article.stat.sales" url_stat = "kfet.article.stat.sales"
stats = last_stats_manifest()
@method_decorator(teamkfet_required) def get_stats(self):
def dispatch(self, *args, **kwargs): first_conso = (
return super().dispatch(*args, **kwargs) Operation.objects.filter(article=self.object)
.order_by("group__at")
.values_list("group__at", flat=True)
.first()
)
if first_conso is None:
# 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),
("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),
]
return scale_url_params(scales_def)
@method_decorator(teamkfet_required, name="dispatch")
class ArticleStatSales(ScaleMixin, JSONDetailView): 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 model = Article
context_object_name = "article" context_object_name = "article"
def get_context_data(self, *args, **kwargs): def get_context_data(self, *args, **kwargs):
old_ctx = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
context = {"labels": old_ctx["labels"]}
scale = self.scale scale = self.scale
all_purchases = ( all_purchases = (
@ -2570,23 +2564,13 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
liq_only = all_purchases.filter(group__on_acc__trigramme="LIQ") liq_only = all_purchases.filter(group__on_acc__trigramme="LIQ")
liq_exclude = all_purchases.exclude(group__on_acc__trigramme="LIQ") liq_exclude = all_purchases.exclude(group__on_acc__trigramme="LIQ")
chunks_liq = scale.get_by_chunks( nb_liq = scale.chunkify_qs(
liq_only, field_db="group__at", field_callback=lambda d: d["group__at"] liq_only, field="group__at", aggregate=Sum("article_nb")
) )
chunks_no_liq = scale.get_by_chunks( nb_accounts = scale.chunkify_qs(
liq_exclude, field_db="group__at", field_callback=lambda d: d["group__at"] liq_exclude, field="group__at", aggregate=Sum("article_nb")
) )
nb_ventes = [n1 + n2 for n1, n2 in zip(nb_liq, nb_accounts)]
# 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)
context["charts"] = [ context["charts"] = [
{ {
@ -2602,7 +2586,3 @@ class ArticleStatSales(ScaleMixin, JSONDetailView):
}, },
] ]
return context return context
@method_decorator(teamkfet_required)
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)