Merge branch 'aureplop/fix_stats' into 'master'

Stats K-Fêt - Python side

See merge request !208
This commit is contained in:
Martin Pepin 2017-04-05 00:41:47 +02:00
commit fe66a6ef6b
12 changed files with 624 additions and 597 deletions

View file

@ -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)),

View file

@ -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(

View file

@ -106,6 +106,7 @@ textarea {
.panel-md-margin{
background-color: white;
overflow:hidden;
padding-left: 15px;
padding-right: 15px;
padding-bottom: 15px;

View file

@ -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 = $("<div>");
@ -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 = $("<canvas>");
var canvas = $("<canvas height='250'>");
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 = $("<div>",
@ -191,7 +193,7 @@
// constructor
(function () {
$.getJSON(url + "?format=json", initialize);
$.getJSON(url, {format: 'json'}, initialize);
})();
};
})(jQuery);

View file

@ -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
]

View file

@ -13,12 +13,17 @@
{% if account.user == request.user %}
<script type="text/javascript" src="{% static 'kfet/js/Chart.bundle.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/statistic.js' %}"></script>
<script>
jQuery(document).ready(function() {
var stat_last = new StatsGroup("{% url 'kfet.account.stat.last' trigramme=account.trigramme %}",
$("#stat_last"));
var stat_balance = new StatsGroup("{% url 'kfet.account.stat.balance' trigramme=account.trigramme %}",
$("#stat_balance"));
<script type="text/javascript">
$(document).ready(function() {
var stat_last = new StatsGroup(
"{% url 'kfet.account.stat.operation.list' trigramme=account.trigramme %}",
$("#stat_last"),
);
var stat_balance = new StatsGroup(
"{% url 'kfet.account.stat.balance.list' trigramme=account.trigramme %}",
$("#stat_balance"),
);
});
</script>
{% endif %}
@ -66,22 +71,22 @@
{% if account.user == request.user %}
<div class="content-right-block content-right-block-transparent">
<h2>Statistiques</h2>
<div class="row">
<div class="col-sm-12 nopadding">
<div class="panel-md-margin">
<h3>Ma balance</h3>
<div id="stat_balance" class"stat-graph"></div>
</div>
<div class="row">
<div class="col-sm-12 nopadding">
<div class="panel-md-margin">
<h3>Ma balance</h3>
<div id="stat_balance"></div>
</div>
</div><!-- /row -->
<div class="row">
<div class="col-sm-12 nopadding">
<div class="panel-md-margin">
<h3>Ma consommation</h3>
<div id="stat_last" class"stat-graph"></div>
</div>
</div>
</div><!-- /row -->
<div class="row">
<div class="col-sm-12 nopadding">
<div class="panel-md-margin">
<h3>Ma consommation</h3>
<div id="stat_last"></div>
</div>
</div><!-- /row -->
</div>
</div><!-- /row -->
</div>
{% endif %}
<div class="content-right-block">

View file

@ -1,5 +0,0 @@
{% if account.user == request.user %}
Mon compte
{% else %}
Informations du compte {{ account.trigramme }}
{% endif %}

View file

@ -1,6 +1,11 @@
{% extends 'kfet/base.html' %}
{% load staticfiles %}
{% block extra_head %}
<script type="text/javascript" src="{% static 'kfet/js/Chart.bundle.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/statistic.js' %}"></script>
{% endblock %}
{% block title %}Informations sur l'article {{ article }}{% endblock %}
{% block content-header-title %}Article - {{ article.name }}{% endblock %}
@ -82,27 +87,26 @@
</div>
<div class="content-right-block content-right-block-transparent">
<h2>Statistiques</h2>
<div class="row">
<div class="col-sm-12 nopadding">
<div class="panel-md-margin">
<h3>Ventes de {{ article.name }}</h3>
<div id="stat_last"></div>
</div>
<div class="row">
<div class="col-sm-12 nopadding">
<div class="panel-md-margin">
<h3>Ventes de {{ article.name }}</h3>
<div id="stat_last"></div>
</div>
</div><!-- /row -->
</div>
</div><!-- /row -->
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_head %}
<script type="text/javascript" src="{% static 'kfet/js/Chart.bundle.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/statistic.js' %}"></script>
<script>
jQuery(document).ready(function() {
var stat_last = new StatsGroup("{% url 'kfet.article.stat.last' article.id %}",
$("#stat_last"));
<script type="text/javascript">
$(document).ready(function() {
var stat_last = new StatsGroup(
"{% url 'kfet.article.stat.sales.list' article.id %}",
$("#stat_last"),
);
});
</script>
{% endblock %}

View file

@ -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, "/")

View file

@ -69,28 +69,19 @@ urlpatterns = [
name='kfet.account.negative'),
# Account - Statistics
url('^accounts/(?P<trigramme>.{3})/stat/last/$',
views.AccountStatLastAll.as_view(),
name = 'kfet.account.stat.last'),
url('^accounts/(?P<trigramme>.{3})/stat/last/month/$',
views.AccountStatLastMonth.as_view(),
name = 'kfet.account.stat.last.month'),
url('^accounts/(?P<trigramme>.{3})/stat/last/week/$',
views.AccountStatLastWeek.as_view(),
name = 'kfet.account.stat.last.week'),
url('^accounts/(?P<trigramme>.{3})/stat/last/day/$',
views.AccountStatLastDay.as_view(),
name = 'kfet.account.stat.last.day'),
url('^accounts/(?P<trigramme>.{3})/stat/operations/list$',
views.AccountStatOperationList.as_view(),
name='kfet.account.stat.operation.list'),
url('^accounts/(?P<trigramme>.{3})/stat/operations$',
views.AccountStatOperation.as_view(),
name='kfet.account.stat.operation'),
url('^accounts/(?P<trigramme>.{3})/stat/balance/$',
views.AccountStatBalanceAll.as_view(),
name = 'kfet.account.stat.balance'),
url('^accounts/(?P<trigramme>.{3})/stat/balance/d/(?P<nb_date>\d*)/$',
url(r'^accounts/(?P<trigramme>.{3})/stat/balance/list$',
views.AccountStatBalanceList.as_view(),
name='kfet.account.stat.balance.list'),
url(r'^accounts/(?P<trigramme>.{3})/stat/balance$',
views.AccountStatBalance.as_view(),
name = 'kfet.account.stat.balance.days'),
url('^accounts/(?P<trigramme>.{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<pk>\d+)/edit$',
teamkfet_required(views.ArticleUpdate.as_view()),
name = 'kfet.article.update'),
name='kfet.article.update'),
# Article - Statistics
url('^articles/(?P<pk>\d+)/stat/last/$',
views.ArticleStatLastAll.as_view(),
name = 'kfet.article.stat.last'),
url('^articles/(?P<pk>\d+)/stat/last/month/$',
views.ArticleStatLastMonth.as_view(),
name = 'kfet.article.stat.last.month'),
url('^articles/(?P<pk>\d+)/stat/last/week/$',
views.ArticleStatLastWeek.as_view(),
name = 'kfet.article.stat.last.week'),
url('^articles/(?P<pk>\d+)/stat/last/day/$',
views.ArticleStatLastDay.as_view(),
name = 'kfet.article.stat.last.day'),
url(r'^articles/(?P<pk>\d+)/stat/sales/list$',
views.ArticleStatSalesList.as_view(),
name='kfet.article.stat.sales.list'),
url(r'^articles/(?P<pk>\d+)/stat/sales$',
views.ArticleStatSales.as_view(),
name='kfet.article.stat.sales'),
# -----
# K-Psul urls

View file

@ -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)

View file

@ -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