forked from DGNum/gestioCOF
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:
commit
5a9ea4234e
5 changed files with 284 additions and 399 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
|
|
274
kfet/views.py
274
kfet/views.py
|
@ -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 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 = ""
|
|
||||||
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)
|
|
||||||
|
|
Loading…
Reference in a new issue