diff --git a/cof/urls.py b/cof/urls.py index 7ec728da..06b1087a 100644 --- a/cof/urls.py +++ b/cof/urls.py @@ -84,7 +84,7 @@ urlpatterns = [ url(r'^k-fet/', include('kfet.urls')), ] -if settings.DEBUG: +if 'debug_toolbar' in settings.INSTALLED_APPS: import debug_toolbar urlpatterns += [ url(r'^__debug__/', include(debug_toolbar.urls)), diff --git a/kfet/models.py b/kfet/models.py index c039ab06..6ddac84d 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -491,24 +491,29 @@ class TransferGroup(models.Model): related_name = "+", blank = True, null = True, default = None) + class Transfer(models.Model): group = models.ForeignKey( - TransferGroup, on_delete = models.PROTECT, - related_name = "transfers") + TransferGroup, on_delete=models.PROTECT, + related_name="transfers") from_acc = models.ForeignKey( - Account, on_delete = models.PROTECT, - related_name = "transfers_from") + Account, on_delete=models.PROTECT, + related_name="transfers_from") to_acc = models.ForeignKey( - Account, on_delete = models.PROTECT, - related_name = "transfers_to") - amount = models.DecimalField(max_digits = 6, decimal_places = 2) + Account, on_delete=models.PROTECT, + related_name="transfers_to") + amount = models.DecimalField(max_digits=6, decimal_places=2) # Optional canceled_by = models.ForeignKey( - Account, on_delete = models.PROTECT, - null = True, blank = True, default = None, - related_name = "+") + Account, on_delete=models.PROTECT, + null=True, blank=True, default=None, + related_name="+") canceled_at = models.DateTimeField( - null = True, blank = True, default = None) + null=True, blank=True, default=None) + + def __str__(self): + return '{} -> {}: {}€'.format(self.from_acc, self.to_acc, self.amount) + class OperationGroup(models.Model): on_acc = models.ForeignKey( diff --git a/kfet/static/kfet/css/index.css b/kfet/static/kfet/css/index.css index 5a82b5cf..f21fdaba 100644 --- a/kfet/static/kfet/css/index.css +++ b/kfet/static/kfet/css/index.css @@ -106,6 +106,7 @@ textarea { .panel-md-margin{ background-color: white; + overflow:hidden; padding-left: 15px; padding-right: 15px; padding-bottom: 15px; diff --git a/kfet/static/kfet/js/statistic.js b/kfet/static/kfet/js/statistic.js index 7ab56f1d..f210c11d 100644 --- a/kfet/static/kfet/js/statistic.js +++ b/kfet/static/kfet/js/statistic.js @@ -1,10 +1,10 @@ (function($){ window.StatsGroup = function (url, target) { // a class to properly display statictics - + // url : points to an ObjectResumeStat that lists the options through JSON // target : element of the DOM where to put the stats - + var self = this; var element = $(target); var content = $("
"); @@ -22,28 +22,29 @@ return array; } - function handleTimeChart (dict) { + function handleTimeChart (data) { // reads the balance data and put it into chartjs formatting - var data = dictToArray(dict, 0); + chart_data = new Array(); for (var i = 0; i < data.length; i++) { var source = data[i]; - data[i] = { x: new Date(source.at), - y: source.balance, - label: source.label } + chart_data[i] = { + x: new Date(source.at), + y: source.balance, + label: source.label, + } } - return data; + return chart_data; } - + function showStats () { // CALLBACK : called when a button is selected - + // shows the focus on the correct button buttons.find(".focus").removeClass("focus"); $(this).addClass("focus"); // loads data and shows it - $.getJSON(this.stats_target_url + "?format=json", - displayStats); + $.getJSON(this.stats_target_url, {format: 'json'}, displayStats); } function displayStats (data) { @@ -51,14 +52,14 @@ var chart_datasets = []; var charts = dictToArray(data.charts); - + // are the points indexed by timestamps? var is_time_chart = data.is_time_chart || false; // reads the charts data for (var i = 0; i < charts.length; i++) { var chart = charts[i]; - + // format the data var chart_data = is_time_chart ? handleTimeChart(chart.values) : dictToArray(chart.values, 1); @@ -78,6 +79,7 @@ var chart_options = { responsive: true, + maintainAspectRatio: false, tooltips: { mode: 'index', intersect: false, @@ -130,25 +132,25 @@ type: 'line', options: chart_options, data: { - labels: dictToArray(data.labels, 1), + labels: (data.labels || []).slice(1), datasets: chart_datasets, } }; // saves the previous charts to be destroyed var prev_chart = content.children(); - + + // clean + prev_chart.remove(); + // creates a blank canvas element and attach it to the DOM - var canvas = $(""); + var canvas = $(""); content.append(canvas); // create the chart var chart = new Chart(canvas, chart_model); - - // clean - prev_chart.remove(); } - + // initialize the interface function initialize (data) { // creates the bar with the buttons @@ -158,8 +160,8 @@ "aria-label": "select-period"}); var to_click; - var context = dictToArray(data.stats); - + var context = data.stats; + for (var i = 0; i < context.length; i++) { // creates the button var btn_wrapper = $("
", @@ -191,7 +193,7 @@ // constructor (function () { - $.getJSON(url + "?format=json", initialize); + $.getJSON(url, {format: 'json'}, initialize); })(); }; })(jQuery); diff --git a/kfet/statistic.py b/kfet/statistic.py index 09f9e935..fe948f73 100644 --- a/kfet/statistic.py +++ b/kfet/statistic.py @@ -1,98 +1,155 @@ # -*- coding: utf-8 -*- + +from datetime import date, datetime, time, timedelta + +from dateutil.relativedelta import relativedelta +from dateutil.parser import parse as dateutil_parse + from django.utils import timezone from django.db.models import Sum -KFET_WAKES_UP_AT = 7 - -# donne le nom des jours d'une liste de dates -# dans un dico ordonné -def daynames(dates): - names = {} - for i in dates: - names[i] = dates[i].strftime("%A") - return names +KFET_WAKES_UP_AT = time(7, 0) -# donne le nom des semaines une liste de dates -# dans un dico ordonné -def weeknames(dates): - names = {} - for i in dates: - names[i] = dates[i].strftime("Semaine %W") - return names +def kfet_day(year, month, day, start_at=KFET_WAKES_UP_AT): + """datetime wrapper with time offset.""" + return datetime.combine(date(year, month, day), start_at) -# donne le nom des mois d'une liste de dates -# dans un dico ordonné -def monthnames(dates): - names = {} - for i in dates: - names[i] = dates[i].strftime("%B") - return names +def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT): + kfet_dt = kfet_day(year=dt.year, month=dt.month, day=dt.day) + if dt.time() < start_at: + kfet_dt -= timedelta(days=1) + return kfet_dt -# rend les dates des nb derniers jours -# dans l'ordre chronologique -# aujourd'hui compris -# nb = 1 : rend hier -def lastdays(nb): - morning = this_morning() - days = {} - for i in range(1, nb+1): - days[i] = morning - timezone.timedelta(days=nb - i + 1) - return days +class Scale(object): + name = None + step = None + + def __init__(self, n_steps=0, begin=None, end=None, + last=False, std_chunk=True): + self.std_chunk = std_chunk + if last: + end = timezone.now() + + if begin is not None and n_steps != 0: + self.begin = self.get_from(begin) + self.end = self.do_step(self.begin, n_steps=n_steps) + elif end is not None and n_steps != 0: + self.end = self.get_from(end) + self.begin = self.do_step(self.end, n_steps=-n_steps) + elif begin is not None and end is not None: + self.begin = self.get_from(begin) + self.end = self.get_from(end) + else: + raise Exception('Two of these args must be specified: ' + 'n_steps, begin, end; ' + 'or use last and n_steps') + + self.datetimes = self.get_datetimes() + + @staticmethod + def by_name(name): + for cls in Scale.__subclasses__(): + if cls.name == name: + return cls + return None + + def get_from(self, dt): + return self.std_chunk and self.get_chunk_start(dt) or dt + + def __getitem__(self, i): + return self.datetimes[i], self.datetimes[i+1] + + 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 + while tmp <= self.end: + 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 + return [begin.strftime(label_fmt) for begin, end in self] -def lastweeks(nb): - monday_morning = this_monday_morning() - mondays = {} - for i in range(1, nb+1): - mondays[i] = monday_morning \ - - timezone.timedelta(days=7*(nb - i + 1)) - return mondays +class DayScale(Scale): + name = 'day' + step = timedelta(days=1) + label_fmt = '%A' + + @classmethod + def get_chunk_start(cls, dt): + return to_kfet_day(dt) -def lastmonths(nb): - first_month_day = this_first_month_day() - first_days = {} - this_year = first_month_day.year - this_month = first_month_day.month - for i in range(1, nb+1): - month = ((this_month - 1 - (nb - i)) % 12) + 1 - year = this_year + (nb - i) // 12 - first_days[i] = timezone.datetime(year=year, - month=month, - day=1, - hour=KFET_WAKES_UP_AT) - return first_days +class WeekScale(Scale): + name = 'week' + step = timedelta(days=7) + label_fmt = 'Semaine %W' + + @classmethod + def get_chunk_start(cls, dt): + dt_kfet = to_kfet_day(dt) + offset = timedelta(days=dt_kfet.weekday()) + return dt_kfet - offset -def this_first_month_day(): - now = timezone.now() - first_day = timezone.datetime(year=now.year, - month=now.month, - day=1, - hour=KFET_WAKES_UP_AT) - return first_day +class MonthScale(Scale): + name = 'month' + step = relativedelta(months=1) + label_fmt = '%B' + + @classmethod + def get_chunk_start(cls, dt): + return to_kfet_day(dt).replace(day=1) -def this_monday_morning(): - now = timezone.now() - monday = now - timezone.timedelta(days=now.isoweekday()-1) - monday_morning = timezone.datetime(year=monday.year, - month=monday.month, - day=monday.day, - hour=KFET_WAKES_UP_AT) - return monday_morning +def stat_manifest(scales_def=None, scale_args=None, scale_prefix=None, + **other_url_params): + if scale_prefix is None: + scale_prefix = 'scale_' + if scales_def is None: + scales_def = [] + if scale_args is None: + scale_args = {} + manifest = [] + for label, cls in scales_def: + url_params = {scale_prefix+'name': cls.name} + url_params.update({scale_prefix+key: value + for key, value in scale_args.items()}) + url_params.update(other_url_params) + manifest.append(dict( + label=label, + url_params=url_params, + )) + return manifest -def this_morning(): - now = timezone.now() - morning = timezone.datetime(year=now.year, - month=now.month, - day=now.day, - hour=KFET_WAKES_UP_AT) - return morning +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 @@ -100,3 +157,78 @@ def this_morning(): def tot_ventes(queryset): res = queryset.aggregate(Sum('article_nb'))['article_nb__sum'] return res and res or 0 + + +class ScaleMixin(object): + scale_args_prefix = 'scale_' + + def get_scale_args(self, params=None, prefix=None): + """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 = {} + + name = params.get(prefix+'name', None) + if name is not None: + scale_args['name'] = name + + n_steps = params.get(prefix+'n_steps', None) + if n_steps is not None: + scale_args['n_steps'] = int(n_steps) + + begin = params.get(prefix+'begin', None) + if begin is not None: + scale_args['begin'] = dateutil_parse(begin) + + end = params.get(prefix+'send', None) + if end is not None: + scale_args['end'] = dateutil_parse(end) + + last = params.get(prefix+'last', None) + if last is not None: + scale_args['last'] = ( + last in ['true', 'True', '1'] and True or False) + + return scale_args + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + scale_args = self.get_scale_args() + scale_name = scale_args.pop('name', None) + scale_cls = Scale.by_name(scale_name) + + if scale_cls is None: + scale = self.get_default_scale() + else: + scale = scale_cls(**scale_args) + + self.scale = scale + context['labels'] = scale.get_labels() + return context + + def get_default_scale(self): + return DayScale(n_steps=7, last=True) + + def chunkify_qs(self, qs, scale, field=None): + if field is None: + field = 'at' + begin_f = '{}__gte'.format(field) + end_f = '{}__lte'.format(field) + return [ + qs.filter(**{begin_f: begin, end_f: end}) + for begin, end in scale + ] diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index 50ab7f20..3c2ccbcd 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -13,12 +13,17 @@ {% if account.user == request.user %} - {% endif %} @@ -66,22 +71,22 @@ {% if account.user == request.user %}

Statistiques

-
-
-
-

Ma balance

-
-
+
+
+
+

Ma balance

+
-
-
-
-
-

Ma consommation

-
-
+
+
+
+
+
+

Ma consommation

+
-
+
+
{% endif %}
diff --git a/kfet/templates/kfet/account_read_title.html b/kfet/templates/kfet/account_read_title.html deleted file mode 100644 index 6712ed77..00000000 --- a/kfet/templates/kfet/account_read_title.html +++ /dev/null @@ -1,5 +0,0 @@ -{% if account.user == request.user %} - Mon compte -{% else %} - Informations du compte {{ account.trigramme }} -{% endif %} diff --git a/kfet/templates/kfet/article_read.html b/kfet/templates/kfet/article_read.html index 35b484a5..6fe025f6 100644 --- a/kfet/templates/kfet/article_read.html +++ b/kfet/templates/kfet/article_read.html @@ -1,6 +1,11 @@ {% extends 'kfet/base.html' %} {% load staticfiles %} +{% block extra_head %} + + +{% endblock %} + {% block title %}Informations sur l'article {{ article }}{% endblock %} {% block content-header-title %}Article - {{ article.name }}{% endblock %} @@ -82,27 +87,26 @@

Statistiques

-
-
-
-

Ventes de {{ article.name }}

-
-
+
+
+
+

Ventes de {{ article.name }}

+
-
+
+
-{% endblock %} -{% block extra_head %} - - - + {% endblock %} diff --git a/kfet/tests.py b/kfet/tests.py index 5bea7afa..991b2545 100644 --- a/kfet/tests.py +++ b/kfet/tests.py @@ -1,5 +1,70 @@ # -*- coding: utf-8 -*- -from django.test import TestCase +from unittest.mock import patch -# Écrire les tests ici +from django.test import TestCase, Client +from django.contrib.auth.models import User, Permission + +from .models import Account, Article, ArticleCategory + + +class TestStats(TestCase): + @patch('kfet.signals.messages') + def test_user_stats(self, mock_messages): + """ + Checks that we can get the stat-related pages without any problem. + """ + # We setup two users and an article. Only the first user is part of the + # team. + user = User.objects.create(username="Foobar") + user.set_password("foobar") + user.save() + Account.objects.create(trigramme="FOO", cofprofile=user.profile) + perm = Permission.objects.get(codename="is_team") + user.user_permissions.add(perm) + + user2 = User.objects.create(username="Barfoo") + user2.set_password("barfoo") + user2.save() + Account.objects.create(trigramme="BAR", cofprofile=user2.profile) + + article = Article.objects.create( + name="article", + category=ArticleCategory.objects.create(name="C") + ) + + # Each user have its own client + client = Client() + client.login(username="Foobar", password="foobar") + client2 = Client() + client2.login(username="Barfoo", password="barfoo") + + # 1. FOO should be able to get these pages but BAR receives a Forbidden + # response + user_urls = [ + "/k-fet/accounts/FOO/stat/operations/list", + "/k-fet/accounts/FOO/stat/operations?{}".format( + '&'.join(["scale=day", + "types=['purchase']", + "scale_args={'n_steps':+7,+'last':+True}", + "format=json"])), + "/k-fet/accounts/FOO/stat/balance/list", + "/k-fet/accounts/FOO/stat/balance?format=json" + ] + for url in user_urls: + resp = client.get(url) + self.assertEqual(200, resp.status_code) + resp2 = client2.get(url) + self.assertEqual(403, resp2.status_code) + + # 2. FOO is a member of the team and can get these pages but BAR + # receives a Redirect response + articles_urls = [ + "/k-fet/articles/{}/stat/sales/list".format(article.pk), + "/k-fet/articles/{}/stat/sales".format(article.pk) + ] + for url in articles_urls: + resp = client.get(url) + self.assertEqual(200, resp.status_code) + resp2 = client2.get(url, follow=True) + self.assertRedirects(resp2, "/") diff --git a/kfet/urls.py b/kfet/urls.py index 94a9eaec..0ffeb84f 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -69,28 +69,19 @@ urlpatterns = [ name='kfet.account.negative'), # Account - Statistics - url('^accounts/(?P.{3})/stat/last/$', - views.AccountStatLastAll.as_view(), - name = 'kfet.account.stat.last'), - url('^accounts/(?P.{3})/stat/last/month/$', - views.AccountStatLastMonth.as_view(), - name = 'kfet.account.stat.last.month'), - url('^accounts/(?P.{3})/stat/last/week/$', - views.AccountStatLastWeek.as_view(), - name = 'kfet.account.stat.last.week'), - url('^accounts/(?P.{3})/stat/last/day/$', - views.AccountStatLastDay.as_view(), - name = 'kfet.account.stat.last.day'), + url('^accounts/(?P.{3})/stat/operations/list$', + views.AccountStatOperationList.as_view(), + name='kfet.account.stat.operation.list'), + url('^accounts/(?P.{3})/stat/operations$', + views.AccountStatOperation.as_view(), + name='kfet.account.stat.operation'), - url('^accounts/(?P.{3})/stat/balance/$', - views.AccountStatBalanceAll.as_view(), - name = 'kfet.account.stat.balance'), - url('^accounts/(?P.{3})/stat/balance/d/(?P\d*)/$', + url(r'^accounts/(?P.{3})/stat/balance/list$', + views.AccountStatBalanceList.as_view(), + name='kfet.account.stat.balance.list'), + url(r'^accounts/(?P.{3})/stat/balance$', views.AccountStatBalance.as_view(), - name = 'kfet.account.stat.balance.days'), - url('^accounts/(?P.{3})/stat/balance/anytime/$', - views.AccountStatBalance.as_view(), - name = 'kfet.account.stat.balance.anytime'), + name='kfet.account.stat.balance'), # ----- # Checkout urls @@ -149,20 +140,14 @@ urlpatterns = [ # Article - Update url('^articles/(?P\d+)/edit$', teamkfet_required(views.ArticleUpdate.as_view()), - name = 'kfet.article.update'), + name='kfet.article.update'), # Article - Statistics - url('^articles/(?P\d+)/stat/last/$', - views.ArticleStatLastAll.as_view(), - name = 'kfet.article.stat.last'), - url('^articles/(?P\d+)/stat/last/month/$', - views.ArticleStatLastMonth.as_view(), - name = 'kfet.article.stat.last.month'), - url('^articles/(?P\d+)/stat/last/week/$', - views.ArticleStatLastWeek.as_view(), - name = 'kfet.article.stat.last.week'), - url('^articles/(?P\d+)/stat/last/day/$', - views.ArticleStatLastDay.as_view(), - name = 'kfet.article.stat.last.day'), + url(r'^articles/(?P\d+)/stat/sales/list$', + views.ArticleStatSalesList.as_view(), + name='kfet.article.stat.sales.list'), + url(r'^articles/(?P\d+)/stat/sales$', + views.ArticleStatSales.as_view(), + name='kfet.article.stat.sales'), # ----- # K-Psul urls diff --git a/kfet/views.py b/kfet/views.py index 78c65002..524b35ba 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- +import ast +from urllib.parse import urlencode + from django.shortcuts import render, get_object_or_404, redirect from django.core.exceptions import PermissionDenied from django.core.cache import cache from django.views.generic import ListView, DetailView, TemplateView -from django.views.generic.list import BaseListView, MultipleObjectTemplateResponseMixin -from django.views.generic.detail import BaseDetailView, SingleObjectTemplateResponseMixin -from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView +from django.views.generic.detail import BaseDetailView +from django.views.generic.edit import CreateView, UpdateView from django.core.urlresolvers import reverse, reverse_lazy from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin @@ -46,10 +48,8 @@ from decimal import Decimal import django_cas_ng import heapq import statistics -from .statistic import daynames, monthnames, weeknames, \ - lastdays, lastweeks, lastmonths, \ - this_morning, this_monday_morning, this_first_month_day, \ - tot_ventes +from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes + class Home(TemplateView): template_name = "kfet/home.html" @@ -2029,87 +2029,54 @@ class JSONResponseMixin(object): return context -class JSONDetailView(JSONResponseMixin, - BaseDetailView): - """ - Returns a DetailView that renders a JSON - """ +class JSONDetailView(JSONResponseMixin, BaseDetailView): + """Returns a DetailView that renders a JSON.""" + def render_to_response(self, context): return self.render_to_json_response(context) -class HybridDetailView(JSONResponseMixin, - SingleObjectTemplateResponseMixin, - BaseDetailView): - """ - Returns a DetailView as an html page except if a JSON file is requested - by the GET method in which case it returns a JSON response. - """ - def render_to_response(self, context): - # Look for a 'format=json' GET argument - if self.request.GET.get('format') == 'json': - return self.render_to_json_response(context) - else: - return super(HybridDetailView, self).render_to_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 HybridListView(JSONResponseMixin, - MultipleObjectTemplateResponseMixin, - BaseListView): - """ - Returns a ListView as an html page except if a JSON file is requested - by the GET method in which case it returns a JSON response. - """ - def render_to_response(self, context): - # Look for a 'format=json' GET argument - if self.request.GET.get('format') == 'json': - return self.render_to_json_response(context) - else: - return super(HybridListView, self).render_to_response(context) +class SingleResumeStat(JSONDetailView): + """Manifest for a kind of a stat about an object. + Returns JSON whose payload is an array containing descriptions of a stat: + url to retrieve data, label, ... -class ObjectResumeStat(JSONDetailView): """ - Summarize all the stats of an object - Handles JSONResponse - """ - context_object_name = '' id_prefix = '' - # nombre de vues à résumer - nb_stat = 2 - # Le combienième est celui par defaut ? - # (entre 0 et nb_stat-1) nb_default = 0 - stat_labels = ['stat_1', 'stat_2'] - stat_urls = ['url_1', 'url_2'] - # sert à renverser les urls - # utile de le surcharger quand l'url prend d'autres arguments que l'id - def get_object_url_kwargs(self, **kwargs): - return {'pk': self.object.id} - - def url_kwargs(self, **kwargs): - return [{}] * self.nb_stat + stats = [] + url_stat = None def get_context_data(self, **kwargs): # On n'hérite pas object_id = self.object.id - url_kwargs = self.url_kwargs() context = {} - stats = {} - for i in range(self.nb_stat): - stats[i] = { - 'label': self.stat_labels[i], - 'btn': "btn_%s_%d_%d" % (self.id_prefix, - object_id, - i), - 'url': reverse(self.stat_urls[i], - kwargs=dict( - self.get_object_url_kwargs(), - **url_kwargs[i] - ), - ), - } - prefix = "%s_%d" % (self.id_prefix, object_id) + stats = [] + prefix = '{}_{}'.format(self.id_prefix, object_id) + for i, stat_def in enumerate(self.stats): + url_pk = getattr(self.object, self.pk_url_kwarg) + url_params_d = stat_def.get('url_params', {}) + if len(url_params_d) > 0: + url_params = '?{}'.format(urlencode(url_params_d)) + else: + url_params = '' + stats.append({ + 'label': stat_def['label'], + 'btn': 'btn_{}_{}'.format(prefix, i), + 'url': '{url}{params}'.format( + url=reverse(self.url_stat, args=[url_pk]), + params=url_params, + ), + }) context['id_prefix'] = prefix context['content_id'] = "content_%s" % prefix context['stats'] = stats @@ -2124,87 +2091,84 @@ class ObjectResumeStat(JSONDetailView): ID_PREFIX_ACC_BALANCE = "balance_acc" -# Un résumé de toutes les vues ArticleStatBalance -# REND DU JSON -class AccountStatBalanceAll(ObjectResumeStat): +class AccountStatBalanceList(PkUrlMixin, SingleResumeStat): + """Manifest for balance stats of an account.""" model = Account context_object_name = 'account' - trigramme_url_kwarg = 'trigramme' + pk_url_kwarg = 'trigramme' + url_stat = 'kfet.account.stat.balance' id_prefix = ID_PREFIX_ACC_BALANCE - nb_stat = 5 + 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 - stat_labels = ["Tout le temps", "1 an", "6 mois", "3 mois", "30 jours"] - stat_urls = ['kfet.account.stat.balance.anytime'] \ - + ['kfet.account.stat.balance.days'] * 4 - def get_object(self, **kwargs): - trigramme = self.kwargs.get(self.trigramme_url_kwarg) - return get_object_or_404(Account, trigramme=trigramme) - - def get_object_url_kwargs(self, **kwargs): - return {'trigramme': self.object.trigramme} - - def url_kwargs(self, **kwargs): - context_list = (super(AccountStatBalanceAll, self) - .url_kwargs(**kwargs)) - context_list[1] = {'nb_date': 365} - context_list[2] = {'nb_date': 183} - context_list[3] = {'nb_date': 90} - context_list[4] = {'nb_date': 30} - return context_list + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user != obj.user: + raise PermissionDenied + return obj @method_decorator(login_required) def dispatch(self, *args, **kwargs): - return super(AccountStatBalanceAll, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) -class AccountStatBalance(JSONDetailView): - """ - Returns a JSON containing the evolution a the personnal - balance of a trigramme between timezone.now() and `nb_days` - ago (specified to the view as an argument) - takes into account the Operations and the Transfers - does not takes into account the balance offset +class AccountStatBalance(PkUrlMixin, JSONDetailView): + """Datasets of balance of an account. + + Operations and Transfers are taken into account. + """ model = Account - trigramme_url_kwarg = 'trigramme' - nb_date_url_kwargs = 'nb_date' + pk_url_kwarg = 'trigramme' context_object_name = 'account' - id_prefix = "" - def get_object(self, **kwargs): - trigramme = self.kwargs.get(self.trigramme_url_kwarg) - return get_object_or_404(Account, trigramme=trigramme) - - def get_changes_list(self, **kwargs): + def get_changes_list(self, last_days=None, begin_date=None, end_date=None): account = self.object - nb_date = self.kwargs.get(self.nb_date_url_kwargs, None) - end_date = this_morning() - if nb_date is None: - begin_date = timezone.datetime(year=1980, month=1, day=1) - anytime = True - else: - begin_date = this_morning() \ - - timezone.timedelta(days=int(nb_date)) - anytime = False - # On récupère les opérations + + # prepare filters + if last_days is not None: + end_date = timezone.now() + begin_date = end_date - timezone.timedelta(days=last_days) + + # prepare querysets # TODO: retirer les opgroup dont tous les op sont annulées - opgroups = list(OperationGroup.objects - .filter(on_acc=account) - .filter(at__gte=begin_date) - .filter(at__lte=end_date)) - # On récupère les transferts reçus - received_transfers = list(Transfer.objects - .filter(to_acc=account) - .filter(canceled_at=None) - .filter(group__at__gte=begin_date) - .filter(group__at__lte=end_date)) - # On récupère les transferts émis - emitted_transfers = list(Transfer.objects - .filter(from_acc=account) - .filter(canceled_at=None) - .filter(group__at__gte=begin_date) - .filter(group__at__lte=end_date)) + opegroups = OperationGroup.objects.filter(on_acc=account) + recv_transfers = Transfer.objects.filter(to_acc=account, + canceled_at=None) + sent_transfers = Transfer.objects.filter(from_acc=account, + canceled_at=None) + + # apply filters + if begin_date is not None: + opegroups = opegroups.filter(at__gte=begin_date) + recv_transfers = recv_transfers.filter(group__at__gte=begin_date) + sent_transfers = sent_transfers.filter(group__at__gte=begin_date) + + if end_date is not None: + opegroups = opegroups.filter(at__lte=end_date) + recv_transfers = recv_transfers.filter(group__at__lte=end_date) + sent_transfers = sent_transfers.filter(group__at__lte=end_date) + # On transforme tout ça en une liste de dictionnaires sous la forme # {'at': date, # 'amount': changement de la balance (négatif si diminue la balance, @@ -2214,76 +2178,86 @@ class AccountStatBalance(JSONDetailView): # sera mis à jour lors d'une # autre passe) # } - actions = [ - # Maintenant (à changer si on gère autre chose que now) + + actions = [] + + actions.append({ + 'at': (begin_date or account.created_at).isoformat(), + 'amount': 0, + 'label': 'début', + 'balance': 0, + }) + actions.append({ + 'at': (end_date or timezone.now()).isoformat(), + 'amount': 0, + 'label': 'fin', + 'balance': 0, + }) + + actions += [ { - 'at': end_date.isoformat(), - 'amout': 0, - 'label': "actuel", + 'at': ope_grp.at.isoformat(), + 'amount': ope_grp.amount, + 'label': str(ope_grp), 'balance': 0, - } - ] + [ - { - 'at': op.at.isoformat(), - 'amount': op.amount, - 'label': str(op), - 'balance': 0, - } for op in opgroups + } for ope_grp in opegroups ] + [ { 'at': tr.group.at.isoformat(), 'amount': tr.amount, - 'label': "%d€: %s -> %s" % (tr.amount, - tr.from_acc.trigramme, - tr.to_acc.trigramme), + 'label': str(tr), 'balance': 0, - } for tr in received_transfers + } for tr in recv_transfers ] + [ { 'at': tr.group.at.isoformat(), 'amount': -tr.amount, - 'label': "%d€: %s -> %s" % (tr.amount, - tr.from_acc.trigramme, - tr.to_acc.trigramme), + 'label': str(tr), 'balance': 0, - } for tr in emitted_transfers + } for tr in sent_transfers ] - if not anytime: - actions += [ - # Date de début : - { - 'at': begin_date.isoformat(), - 'amount': 0, - 'label': "début", - 'balance': 0, - } - ] # Maintenant on trie la liste des actions par ordre du plus récent # an plus ancien et on met à jour la balance - actions = sorted(actions, key=lambda k: k['at'], reverse=True) - actions[0]['balance'] = account.balance - for i in range(len(actions)-1): - actions[i+1]['balance'] = actions[i]['balance'] \ - - actions[i+1]['amount'] + if len(actions) > 1: + actions = sorted(actions, key=lambda k: k['at'], reverse=True) + actions[0]['balance'] = account.balance + for i in range(len(actions)-1): + actions[i+1]['balance'] = \ + actions[i]['balance'] - actions[i+1]['amount'] return actions - def get_context_data(self, **kwargs): + def get_context_data(self, *args, **kwargs): context = {} - changes = self.get_changes_list() - nb_days = self.kwargs.get(self.nb_date_url_kwargs, None) - if nb_days is None: - nb_days_string = 'anytime' - else: - nb_days_string = str(int(nb_days)) - context['charts'] = [ { "color": "rgb(255, 99, 132)", - "label": "Balance", - "values": changes } ] + + last_days = self.request.GET.get('last_days', None) + if last_days is not None: + last_days = int(last_days) + begin_date = self.request.GET.get('begin_date', None) + end_date = self.request.GET.get('end_date', None) + + changes = self.get_changes_list( + last_days=last_days, + begin_date=begin_date, end_date=end_date, + ) + + context['charts'] = [{ + "color": "rgb(255, 99, 132)", + "label": "Balance", + "values": changes, + }] context['is_time_chart'] = True - context['min_date'] = changes[len(changes)-1]['at'] - context['max_date'] = changes[0]['at'] + if len(changes) > 0: + context['min_date'] = changes[-1]['at'] + context['max_date'] = changes[0]['at'] # TODO: offset return context + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user != obj.user: + raise PermissionDenied + return obj + @method_decorator(login_required) def dispatch(self, *args, **kwargs): return super(AccountStatBalance, self).dispatch(*args, **kwargs) @@ -2298,140 +2272,77 @@ ID_PREFIX_ACC_LAST_WEEKS = "last_weeks_acc" ID_PREFIX_ACC_LAST_MONTHS = "last_months_acc" -# Un résumé de toutes les vues ArticleStatLast -# NE REND PAS DE JSON -class AccountStatLastAll(ObjectResumeStat): +class AccountStatOperationList(PkUrlMixin, SingleResumeStat): + """Manifest for operations stats of an account.""" model = Account context_object_name = 'account' - trigramme_url_kwarg = 'trigramme' + pk_url_kwarg = 'trigramme' id_prefix = ID_PREFIX_ACC_LAST - nb_stat = 3 nb_default = 2 - stat_labels = ["Derniers mois", "Dernières semaines", "Derniers jours"] - stat_urls = ['kfet.account.stat.last.month', - 'kfet.account.stat.last.week', - 'kfet.account.stat.last.day'] + stats = last_stats_manifest(types=[Operation.PURCHASE]) + url_stat = 'kfet.account.stat.operation' - def get_object(self, **kwargs): - trigramme = self.kwargs.get(self.trigramme_url_kwarg) - return get_object_or_404(Account, trigramme=trigramme) - - def get_object_url_kwargs(self, **kwargs): - return {'trigramme': self.object.trigramme} + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user != obj.user: + raise PermissionDenied + return obj @method_decorator(login_required) def dispatch(self, *args, **kwargs): - return super(AccountStatLastAll, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) -class AccountStatLast(JSONDetailView): - """ - Returns a JSON containing the evolution a the personnal - consommation of a trigramme at the diffent dates specified - """ +class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView): + """Datasets of operations of an account.""" model = Account - trigramme_url_kwarg = 'trigramme' + pk_url_kwarg = 'trigramme' context_object_name = 'account' - end_date = timezone.now() id_prefix = "" - # doit rendre un dictionnaire des dates - # la première date correspond au début - # la dernière date est la fin de la dernière plage - def get_dates(self, **kwargs): - return {} - - # doit rendre un dictionnaire des labels - # le dernier label ne sera pas utilisé - def get_labels(self, **kwargs): - pass - - def get_object(self, **kwargs): - trigramme = self.kwargs.get(self.trigramme_url_kwarg) - return get_object_or_404(Account, trigramme=trigramme) - - def sort_operations(self, **kwargs): - # On récupère les dates - dates = self.get_dates() - # On ajoute la date de fin - extended_dates = dates.copy() - extended_dates[len(dates)+1] = self.end_date + def get_operations(self, scale, types=None): # On selectionne les opérations qui correspondent # à l'article en question et qui ne sont pas annulées # puis on choisi pour chaques intervalle les opérations # effectuées dans ces intervalles de temps all_operations = (Operation.objects - .filter(type='purchase') .filter(group__on_acc=self.object) .filter(canceled_at=None) ) - operations = {} - for i in dates: - operations[i] = (all_operations - .filter(group__at__gte=extended_dates[i]) - .filter(group__at__lte=extended_dates[i+1]) - ) - return operations + if types is not None: + all_operations = all_operations.filter(type__in=types) + chunks = self.chunkify_qs(all_operations, scale, field='group__at') + return chunks - def get_context_data(self, **kwargs): - context = {} - nb_ventes = {} - # On récupère les labels des dates - context['labels'] = self.get_labels().copy() + def get_context_data(self, *args, **kwargs): + old_ctx = super().get_context_data(*args, **kwargs) + context = {'labels': old_ctx['labels']} + scale = self.scale + + types = self.request.GET.get('types', None) + if types is not None: + types = ast.literal_eval(types) + + operations = self.get_operations(types=types, scale=scale) # On compte les opérations - operations = self.sort_operations() - for i in operations: - nb_ventes[i] = tot_ventes(operations[i]) - context['charts'] = [ { "color": "rgb(255, 99, 132)", - "label": "NB items achetés", - "values": nb_ventes } ] + nb_ventes = [] + for chunk in operations: + nb_ventes.append(tot_ventes(chunk)) + + context['charts'] = [{"color": "rgb(255, 99, 132)", + "label": "NB items achetés", + "values": nb_ventes}] return context + def get_object(self, *args, **kwargs): + obj = super().get_object(*args, **kwargs) + if self.request.user != obj.user: + raise PermissionDenied + return obj + @method_decorator(login_required) def dispatch(self, *args, **kwargs): - return super(AccountStatLast, self).dispatch(*args, **kwargs) - - -# Rend les achats pour ce compte des 7 derniers jours -# Aujourd'hui non compris -class AccountStatLastDay(AccountStatLast): - end_date = this_morning() - id_prefix = ID_PREFIX_ACC_LAST_DAYS - - def get_dates(self, **kwargs): - return lastdays(7) - - def get_labels(self, **kwargs): - days = lastdays(7) - return daynames(days) - - -# Rend les achats de ce compte des 7 dernières semaines -# La semaine en cours n'est pas comprise -class AccountStatLastWeek(AccountStatLast): - end_date = this_monday_morning() - id_prefix = ID_PREFIX_ACC_LAST_WEEKS - - def get_dates(self, **kwargs): - return lastweeks(7) - - def get_labels(self, **kwargs): - weeks = lastweeks(7) - return weeknames(weeks) - - -# Rend les achats de ce compte des 7 derniers mois -# Le mois en cours n'est pas compris -class AccountStatLastMonth(AccountStatLast): - end_date = this_monday_morning() - id_prefix = ID_PREFIX_ACC_LAST_MONTHS - - def get_dates(self, **kwargs): - return lastmonths(7) - - def get_labels(self, **kwargs): - months = lastmonths(7) - return monthnames(months) + return super().dispatch(*args, **kwargs) # ------------------------ @@ -2443,143 +2354,64 @@ ID_PREFIX_ART_LAST_WEEKS = "last_weeks_art" ID_PREFIX_ART_LAST_MONTHS = "last_months_art" -# Un résumé de toutes les vues ArticleStatLast -# NE REND PAS DE JSON -class ArticleStatLastAll(ObjectResumeStat): +class ArticleStatSalesList(SingleResumeStat): + """Manifest for sales stats of an article.""" model = Article context_object_name = 'article' id_prefix = ID_PREFIX_ART_LAST - nb_stat = 3 nb_default = 2 - stat_labels = ["Derniers mois", "Dernières semaines", "Derniers jours"] - stat_urls = ['kfet.article.stat.last.month', - 'kfet.article.stat.last.week', - 'kfet.article.stat.last.day'] + url_stat = 'kfet.article.stat.sales' + stats = last_stats_manifest() - @method_decorator(login_required) + @method_decorator(teamkfet_required) def dispatch(self, *args, **kwargs): - return super(ArticleStatLastAll, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) -class ArticleStatLast(JSONDetailView): - """ - Returns a JSON containing the consommation - of an article at the diffent dates precised - """ +class ArticleStatSales(ScaleMixin, JSONDetailView): + """Datasets of sales of an article.""" model = Article context_object_name = 'article' - end_date = timezone.now() - id_prefix = "" - def render_to_response(self, context): - # Look for a 'format=json' GET argument - if self.request.GET.get('format') == 'json': - return self.render_to_json_response(context) - else: - return super(ArticleStatLast, self).render_to_response(context) + def get_context_data(self, *args, **kwargs): + old_ctx = super().get_context_data(*args, **kwargs) + context = {'labels': old_ctx['labels']} + scale = self.scale - # doit rendre un dictionnaire des dates - # la première date correspond au début - # la dernière date est la fin de la dernière plage - def get_dates(self, **kwargs): - pass - - # doit rendre un dictionnaire des labels - # le dernier label ne sera pas utilisé - def get_labels(self, **kwargs): - pass - - def get_context_data(self, **kwargs): - context = {} - # On récupère les labels des dates - context['labels'] = self.get_labels().copy() - # On récupère les dates - dates = self.get_dates() - # On ajoute la date de fin - extended_dates = dates.copy() - extended_dates[len(dates)+1] = self.end_date # On selectionne les opérations qui correspondent # à l'article en question et qui ne sont pas annulées # puis on choisi pour chaques intervalle les opérations # effectuées dans ces intervalles de temps - all_operations = (Operation.objects - .filter(type='purchase') - .filter(article=self.object) - .filter(canceled_at=None) - ) - operations = {} - for i in dates: - operations[i] = (all_operations - .filter(group__at__gte=extended_dates[i]) - .filter(group__at__lte=extended_dates[i+1]) - ) + all_operations = ( + Operation.objects + .filter(type=Operation.PURCHASE, + article=self.object, + canceled_at=None, + ) + ) + chunks = self.chunkify_qs(all_operations, scale, field='group__at') # On compte les opérations - nb_ventes = {} - nb_accounts = {} - nb_liq = {} - for i in operations: - nb_ventes[i] = tot_ventes(operations[i]) - nb_liq[i] = tot_ventes( - operations[i] - .filter(group__on_acc__trigramme='LIQ') - ) - nb_accounts[i] = tot_ventes( - operations[i] - .exclude(group__on_acc__trigramme='LIQ') - ) - context['charts'] = [ { "color": "rgb(255, 99, 132)", - "label": "Toutes consommations", - "values": nb_ventes }, - { "color": "rgb(54, 162, 235)", - "label": "LIQ", - "values": nb_liq }, - { "color": "rgb(255, 205, 86)", - "label": "Comptes K-Fêt", - "values": nb_accounts } ] + nb_ventes = [] + nb_accounts = [] + nb_liq = [] + for qs in chunks: + nb_ventes.append( + tot_ventes(qs)) + nb_liq.append( + tot_ventes(qs.filter(group__on_acc__trigramme='LIQ'))) + nb_accounts.append( + tot_ventes(qs.exclude(group__on_acc__trigramme='LIQ'))) + context['charts'] = [{"color": "rgb(255, 99, 132)", + "label": "Toutes consommations", + "values": nb_ventes}, + {"color": "rgb(54, 162, 235)", + "label": "LIQ", + "values": nb_liq}, + {"color": "rgb(255, 205, 86)", + "label": "Comptes K-Fêt", + "values": nb_accounts}] return context - @method_decorator(login_required) + @method_decorator(teamkfet_required) def dispatch(self, *args, **kwargs): - return super(ArticleStatLast, self).dispatch(*args, **kwargs) - - -# Rend les ventes des 7 derniers jours -# Aujourd'hui non compris -class ArticleStatLastDay(ArticleStatLast): - end_date = this_morning() - id_prefix = ID_PREFIX_ART_LAST_DAYS - - def get_dates(self, **kwargs): - return lastdays(7) - - def get_labels(self, **kwargs): - days = lastdays(7) - return daynames(days) - - -# Rend les ventes de 7 dernières semaines -# La semaine en cours n'est pas comprise -class ArticleStatLastWeek(ArticleStatLast): - end_date = this_monday_morning() - id_prefix = ID_PREFIX_ART_LAST_WEEKS - - def get_dates(self, **kwargs): - return lastweeks(7) - - def get_labels(self, **kwargs): - weeks = lastweeks(7) - return weeknames(weeks) - - -# Rend les ventes des 7 derniers mois -# Le mois en cours n'est pas compris -class ArticleStatLastMonth(ArticleStatLast): - end_date = this_monday_morning() - id_prefix = ID_PREFIX_ART_LAST_MONTHS - - def get_dates(self, **kwargs): - return lastmonths(7) - - def get_labels(self, **kwargs): - months = lastmonths(7) - return monthnames(months) + return super().dispatch(*args, **kwargs) diff --git a/requirements.txt b/requirements.txt index 06f6c46e..ce081588 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ django-widget-tweaks==1.4.1 git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail ldap3 git+https://github.com/Aureplop/channels.git#egg=channels +python-dateutil