2017-04-04 18:11:15 +02:00
|
|
|
from datetime import date, datetime, time, timedelta
|
2017-04-03 03:12:52 +02:00
|
|
|
|
2017-04-12 18:03:31 +02:00
|
|
|
import pytz
|
2018-10-06 12:35:49 +02:00
|
|
|
from dateutil.parser import parse as dateutil_parse
|
|
|
|
from dateutil.relativedelta import relativedelta
|
2017-02-15 14:21:00 +01:00
|
|
|
from django.db.models import Sum
|
2018-10-06 12:35:49 +02:00
|
|
|
from django.utils import timezone
|
2017-01-17 17:16:53 +01:00
|
|
|
|
2017-04-04 18:11:15 +02:00
|
|
|
KFET_WAKES_UP_AT = time(7, 0)
|
2017-01-17 17:16:53 +01:00
|
|
|
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
def kfet_day(year, month, day, start_at=KFET_WAKES_UP_AT):
|
2020-03-09 15:06:55 +01:00
|
|
|
"""Étant donné une date, renvoie un objet `datetime`
|
|
|
|
correspondant au début du 'jour K-Fêt' correspondant."""
|
2017-04-12 18:03:31 +02:00
|
|
|
naive = datetime.combine(date(year, month, day), start_at)
|
2018-10-06 12:35:49 +02:00
|
|
|
return pytz.timezone("Europe/Paris").localize(naive, is_dst=None)
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT):
|
2020-03-09 15:06:55 +01:00
|
|
|
"""
|
|
|
|
Retourne le 'jour K-Fêt' correspondant à un objet `datetime` donné
|
|
|
|
"""
|
2017-04-03 00:40:52 +02:00
|
|
|
kfet_dt = kfet_day(year=dt.year, month=dt.month, day=dt.day)
|
2017-04-04 18:11:15 +02:00
|
|
|
if dt.time() < start_at:
|
|
|
|
kfet_dt -= timedelta(days=1)
|
2017-04-03 00:40:52 +02:00
|
|
|
return kfet_dt
|
|
|
|
|
|
|
|
|
2017-04-04 18:11:15 +02:00
|
|
|
class Scale(object):
|
2020-03-09 15:06:55 +01:00
|
|
|
"""
|
|
|
|
Classe utilisée pour subdiviser un QuerySet (e.g. des opérations) sur
|
|
|
|
une échelle de temps donnée, avec un pas de temps fixe.
|
|
|
|
Cette échelle peut être spécifiée :
|
|
|
|
- par un début et une fin,
|
|
|
|
- par un début/une fin et un nombre de subdivisions.
|
|
|
|
|
|
|
|
Si le booléen `std_chunk` est activé, les subdivisions sont standardisées :
|
|
|
|
on appelle `get_chunk_start` sur toutes les subdivisions (enfin, sur la première).
|
|
|
|
"""
|
|
|
|
|
2017-04-03 00:40:52 +02:00
|
|
|
name = None
|
|
|
|
step = None
|
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
def __init__(self, n_steps=0, begin=None, end=None, last=False, std_chunk=True):
|
2017-04-03 00:40:52 +02:00
|
|
|
self.std_chunk = std_chunk
|
|
|
|
if last:
|
|
|
|
end = timezone.now()
|
2017-04-12 18:03:31 +02:00
|
|
|
if std_chunk:
|
|
|
|
if begin is not None:
|
|
|
|
begin = self.get_chunk_start(begin)
|
|
|
|
if end is not None:
|
|
|
|
end = self.do_step(self.get_chunk_start(end))
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
if begin is not None and n_steps != 0:
|
2017-04-12 18:03:31 +02:00
|
|
|
self.begin = begin
|
2017-04-03 00:40:52 +02:00
|
|
|
self.end = self.do_step(self.begin, n_steps=n_steps)
|
|
|
|
elif end is not None and n_steps != 0:
|
2017-04-12 18:03:31 +02:00
|
|
|
self.end = end
|
2017-04-03 00:40:52 +02:00
|
|
|
self.begin = self.do_step(self.end, n_steps=-n_steps)
|
|
|
|
elif begin is not None and end is not None:
|
2017-04-12 18:03:31 +02:00
|
|
|
self.begin = begin
|
|
|
|
self.end = end
|
2017-04-03 00:40:52 +02:00
|
|
|
else:
|
2018-10-06 12:35:49 +02:00
|
|
|
raise Exception(
|
|
|
|
"Two of these args must be specified: "
|
|
|
|
"n_steps, begin, end; "
|
|
|
|
"or use last and n_steps"
|
|
|
|
)
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
self.datetimes = self.get_datetimes()
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
def by_name(name):
|
2017-04-04 18:11:15 +02:00
|
|
|
for cls in Scale.__subclasses__():
|
2017-04-03 00:40:52 +02:00
|
|
|
if cls.name == name:
|
|
|
|
return cls
|
2017-04-03 03:12:52 +02:00
|
|
|
return None
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
def get_from(self, dt):
|
|
|
|
return self.std_chunk and self.get_chunk_start(dt) or dt
|
|
|
|
|
|
|
|
def __getitem__(self, i):
|
2018-10-06 12:35:49 +02:00
|
|
|
return self.datetimes[i], self.datetimes[i + 1]
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
def __len__(self):
|
|
|
|
return len(self.datetimes) - 1
|
|
|
|
|
|
|
|
def do_step(self, dt, n_steps=1):
|
|
|
|
return dt + self.step * n_steps
|
|
|
|
|
|
|
|
def get_datetimes(self):
|
|
|
|
datetimes = [self.begin]
|
|
|
|
tmp = self.begin
|
2017-04-12 18:03:31 +02:00
|
|
|
while tmp < self.end:
|
2017-04-03 00:40:52 +02:00
|
|
|
tmp = self.do_step(tmp)
|
|
|
|
datetimes.append(tmp)
|
|
|
|
return datetimes
|
|
|
|
|
|
|
|
def get_labels(self, label_fmt=None):
|
|
|
|
if label_fmt is None:
|
|
|
|
label_fmt = self.label_fmt
|
2017-05-19 17:40:06 +02:00
|
|
|
return [
|
2018-10-06 12:35:49 +02:00
|
|
|
begin.strftime(label_fmt.format(i=i, rev_i=len(self) - i))
|
2017-05-19 17:40:06 +02:00
|
|
|
for i, (begin, end) in enumerate(self)
|
|
|
|
]
|
2017-04-03 00:40:52 +02:00
|
|
|
|
2017-04-13 14:11:44 +02:00
|
|
|
def chunkify_qs(self, qs, field=None):
|
|
|
|
if field is None:
|
2018-10-06 12:35:49 +02:00
|
|
|
field = "at"
|
2020-03-09 15:06:55 +01:00
|
|
|
"""
|
|
|
|
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é...
|
|
|
|
"""
|
2018-10-06 12:35:49 +02:00
|
|
|
begin_f = "{}__gte".format(field)
|
|
|
|
end_f = "{}__lte".format(field)
|
|
|
|
return [qs.filter(**{begin_f: begin, end_f: end}) for begin, end in self]
|
2017-04-13 14:11:44 +02:00
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
def get_by_chunks(self, qs, field_callback=None, field_db="at"):
|
2017-04-13 14:11:44 +02:00
|
|
|
"""Objects of queryset ranked according to the scale.
|
|
|
|
|
|
|
|
Returns a generator whose each item, corresponding to a scale chunk,
|
|
|
|
is a generator of objects from qs for this chunk.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
qs: Queryset of source objects, must be ordered *first* on the
|
|
|
|
same field returned by `field_callback`.
|
|
|
|
field_callback: Callable which gives value from an object used
|
|
|
|
to compare against limits of the scale chunks.
|
|
|
|
Default to: lambda obj: getattr(obj, field_db)
|
|
|
|
field_db: Used to filter against `scale` limits.
|
|
|
|
Default to 'at'.
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
If queryset `qs` use `values()`, `field_callback` must be set and
|
|
|
|
could be: `lambda d: d['at']`
|
|
|
|
If `field_db` use foreign attributes (eg with `__`), it should be
|
|
|
|
something like: `lambda obj: obj.group.at`.
|
|
|
|
|
|
|
|
"""
|
|
|
|
if field_callback is None:
|
2018-10-06 12:35:49 +02:00
|
|
|
|
2017-04-13 14:11:44 +02:00
|
|
|
def field_callback(obj):
|
|
|
|
return getattr(obj, field_db)
|
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
begin_f = "{}__gte".format(field_db)
|
|
|
|
end_f = "{}__lte".format(field_db)
|
2017-04-13 14:11:44 +02:00
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
qs = qs.filter(**{begin_f: self.begin, end_f: self.end})
|
2017-04-13 14:11:44 +02:00
|
|
|
|
|
|
|
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)
|
|
|
|
|
2017-04-03 00:40:52 +02:00
|
|
|
|
2017-04-04 18:11:15 +02:00
|
|
|
class DayScale(Scale):
|
2018-10-06 12:35:49 +02:00
|
|
|
name = "day"
|
2017-04-04 18:11:15 +02:00
|
|
|
step = timedelta(days=1)
|
2018-10-06 12:35:49 +02:00
|
|
|
label_fmt = "%A"
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def get_chunk_start(cls, dt):
|
|
|
|
return to_kfet_day(dt)
|
|
|
|
|
|
|
|
|
2017-04-04 18:11:15 +02:00
|
|
|
class WeekScale(Scale):
|
2018-10-06 12:35:49 +02:00
|
|
|
name = "week"
|
2017-04-04 18:11:15 +02:00
|
|
|
step = timedelta(days=7)
|
2018-10-06 12:35:49 +02:00
|
|
|
label_fmt = "Semaine %W"
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
@classmethod
|
2017-04-03 15:10:53 +02:00
|
|
|
def get_chunk_start(cls, dt):
|
|
|
|
dt_kfet = to_kfet_day(dt)
|
2017-04-04 18:11:15 +02:00
|
|
|
offset = timedelta(days=dt_kfet.weekday())
|
2017-04-03 15:10:53 +02:00
|
|
|
return dt_kfet - offset
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
|
2017-04-04 18:11:15 +02:00
|
|
|
class MonthScale(Scale):
|
2018-10-06 12:35:49 +02:00
|
|
|
name = "month"
|
2017-04-03 00:40:52 +02:00
|
|
|
step = relativedelta(months=1)
|
2018-10-06 12:35:49 +02:00
|
|
|
label_fmt = "%B"
|
2017-04-03 00:40:52 +02:00
|
|
|
|
|
|
|
@classmethod
|
|
|
|
def get_chunk_start(cls, dt):
|
|
|
|
return to_kfet_day(dt).replace(day=1)
|
2016-12-09 21:45:34 +01:00
|
|
|
|
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
def stat_manifest(
|
|
|
|
scales_def=None, scale_args=None, scale_prefix=None, **other_url_params
|
|
|
|
):
|
2017-04-04 20:12:21 +02:00
|
|
|
if scale_prefix is None:
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_prefix = "scale_"
|
2017-04-03 03:12:52 +02:00
|
|
|
if scales_def is None:
|
|
|
|
scales_def = []
|
|
|
|
if scale_args is None:
|
|
|
|
scale_args = {}
|
2017-04-04 20:12:21 +02:00
|
|
|
manifest = []
|
|
|
|
for label, cls in scales_def:
|
2018-10-06 12:35:49 +02:00
|
|
|
url_params = {scale_prefix + "name": cls.name}
|
|
|
|
url_params.update(
|
|
|
|
{scale_prefix + key: value for key, value in scale_args.items()}
|
|
|
|
)
|
2017-04-04 20:12:21 +02:00
|
|
|
url_params.update(other_url_params)
|
2018-10-06 12:35:49 +02:00
|
|
|
manifest.append(dict(label=label, url_params=url_params))
|
2017-04-04 20:12:21 +02:00
|
|
|
return manifest
|
2017-04-03 03:12:52 +02:00
|
|
|
|
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
def last_stats_manifest(
|
|
|
|
scales_def=None, scale_args=None, scale_prefix=None, **url_params
|
|
|
|
):
|
2017-04-03 03:12:52 +02:00
|
|
|
scales_def = [
|
2018-10-06 12:35:49 +02:00
|
|
|
("Derniers mois", MonthScale),
|
|
|
|
("Dernières semaines", WeekScale),
|
|
|
|
("Derniers jours", DayScale),
|
2017-04-03 03:12:52 +02:00
|
|
|
]
|
|
|
|
if scale_args is None:
|
|
|
|
scale_args = {}
|
2018-10-06 12:35:49 +02:00
|
|
|
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
|
|
|
|
)
|
2020-03-09 15:06:55 +01:00
|
|
|
"""
|
|
|
|
Convertit une spécification de scales en arguments GET utilisables par ScaleMixin.
|
|
|
|
La spécification est de la forme suivante :
|
|
|
|
- scales_def : liste de champs de la forme (label, scale)
|
|
|
|
- scale_args : arguments à passer à Scale.__init__
|
|
|
|
- other_url_params : paramètres GET supplémentaires
|
|
|
|
"""
|
2016-12-09 21:45:34 +01:00
|
|
|
|
|
|
|
|
|
|
|
# Étant donné un queryset d'operations
|
|
|
|
# rend la somme des article_nb
|
|
|
|
def tot_ventes(queryset):
|
2018-10-06 12:35:49 +02:00
|
|
|
res = queryset.aggregate(Sum("article_nb"))["article_nb__sum"]
|
2017-02-15 14:21:00 +01:00
|
|
|
return res and res or 0
|
2017-04-03 03:12:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
class ScaleMixin(object):
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_args_prefix = "scale_"
|
2017-04-04 20:12:21 +02:00
|
|
|
|
|
|
|
def get_scale_args(self, params=None, prefix=None):
|
|
|
|
|
2020-03-09 15:06:55 +01:00
|
|
|
"""
|
|
|
|
Récupère les paramètres de subdivision encodés dans une requête GET.
|
2017-04-04 20:12:21 +02:00
|
|
|
"""
|
|
|
|
if params is None:
|
|
|
|
params = self.request.GET
|
|
|
|
if prefix is None:
|
|
|
|
prefix = self.scale_args_prefix
|
|
|
|
|
|
|
|
scale_args = {}
|
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
name = params.get(prefix + "name", None)
|
2017-04-04 20:12:21 +02:00
|
|
|
if name is not None:
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_args["name"] = name
|
2017-04-04 20:12:21 +02:00
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
n_steps = params.get(prefix + "n_steps", None)
|
2017-04-04 20:12:21 +02:00
|
|
|
if n_steps is not None:
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_args["n_steps"] = int(n_steps)
|
2017-04-04 20:12:21 +02:00
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
begin = params.get(prefix + "begin", None)
|
2017-04-04 20:12:21 +02:00
|
|
|
if begin is not None:
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_args["begin"] = dateutil_parse(begin)
|
2017-04-04 20:12:21 +02:00
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
end = params.get(prefix + "send", None)
|
2017-04-04 20:12:21 +02:00
|
|
|
if end is not None:
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_args["end"] = dateutil_parse(end)
|
2017-04-04 20:12:21 +02:00
|
|
|
|
2018-10-06 12:35:49 +02:00
|
|
|
last = params.get(prefix + "last", None)
|
2017-04-04 20:12:21 +02:00
|
|
|
if last is not None:
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_args["last"] = last in ["true", "True", "1"] and True or False
|
2017-04-04 20:12:21 +02:00
|
|
|
|
|
|
|
return scale_args
|
2017-04-03 03:12:52 +02:00
|
|
|
|
|
|
|
def get_context_data(self, *args, **kwargs):
|
|
|
|
context = super().get_context_data(*args, **kwargs)
|
|
|
|
|
2017-04-04 20:12:21 +02:00
|
|
|
scale_args = self.get_scale_args()
|
2018-10-06 12:35:49 +02:00
|
|
|
scale_name = scale_args.pop("name", None)
|
2017-04-04 20:12:21 +02:00
|
|
|
scale_cls = Scale.by_name(scale_name)
|
2017-04-03 03:12:52 +02:00
|
|
|
|
2017-04-04 20:12:21 +02:00
|
|
|
if scale_cls is None:
|
2017-04-03 03:12:52 +02:00
|
|
|
scale = self.get_default_scale()
|
|
|
|
else:
|
2017-04-04 20:12:21 +02:00
|
|
|
scale = scale_cls(**scale_args)
|
2017-04-03 03:12:52 +02:00
|
|
|
|
|
|
|
self.scale = scale
|
2018-10-06 12:35:49 +02:00
|
|
|
context["labels"] = scale.get_labels()
|
2017-04-03 03:12:52 +02:00
|
|
|
return context
|
|
|
|
|
|
|
|
def get_default_scale(self):
|
2017-04-04 18:11:15 +02:00
|
|
|
return DayScale(n_steps=7, last=True)
|