Merge branch 'master' into aureplop/kfet_config

This commit is contained in:
Aurélien Delobelle 2017-04-08 18:36:28 +02:00
commit 784ba9bd10
29 changed files with 1331 additions and 833 deletions

View file

@ -18,7 +18,10 @@ class Tirage(models.Model):
fermeture = models.DateTimeField("Date et heure de fermerture du tirage") fermeture = models.DateTimeField("Date et heure de fermerture du tirage")
tokens = models.TextField("Graine(s) du tirage", blank=True) tokens = models.TextField("Graine(s) du tirage", blank=True)
active = models.BooleanField("Tirage actif", default=False) active = models.BooleanField("Tirage actif", default=False)
appear_catalogue = models.BooleanField("Tirage à afficher dans le catalogue", default=False) appear_catalogue = models.BooleanField(
"Tirage à afficher dans le catalogue",
default=False
)
enable_do_tirage = models.BooleanField("Le tirage peut être lancé", enable_do_tirage = models.BooleanField("Le tirage peut être lancé",
default=False) default=False)

View file

@ -1,22 +1,79 @@
# -*- coding: utf-8 -*- import json
"""
This file demonstrates writing tests using the unittest module. These will pass
when you run "manage.py test".
Replace this with more appropriate tests for your application. from django.test import TestCase, Client
""" from django.utils import timezone
from __future__ import division from .models import Tirage, Spectacle, Salle, CategorieSpectacle
from __future__ import print_function
from __future__ import unicode_literals
from django.test import TestCase class TestBdAViews(TestCase):
def setUp(self):
self.tirage = Tirage.objects.create(
title="Test tirage",
appear_catalogue=True,
ouverture=timezone.now(),
fermeture=timezone.now(),
)
self.category = CategorieSpectacle.objects.create(name="Category")
self.location = Salle.objects.create(name="here")
Spectacle.objects.bulk_create([
Spectacle(
title="foo", date=timezone.now(), location=self.location,
price=0, slots=42, tirage=self.tirage, listing=False,
category=self.category
),
Spectacle(
title="bar", date=timezone.now(), location=self.location,
price=1, slots=142, tirage=self.tirage, listing=False,
category=self.category
),
Spectacle(
title="baz", date=timezone.now(), location=self.location,
price=2, slots=242, tirage=self.tirage, listing=False,
category=self.category
),
])
def test_catalogue(self):
"""Test the catalogue JSON API"""
client = Client()
class SimpleTest(TestCase): # The `list` hooh
def test_basic_addition(self): resp = client.get("/bda/catalogue/list")
""" self.assertJSONEqual(
Tests that 1 + 1 always equals 2. resp.content.decode("utf-8"),
""" [{"id": self.tirage.id, "title": self.tirage.title}]
self.assertEqual(1 + 1, 2) )
# The `details` hook
resp = client.get(
"/bda/catalogue/details?id={}".format(self.tirage.id)
)
self.assertJSONEqual(
resp.content.decode("utf-8"),
{
"categories": [{
"id": self.category.id,
"name": self.category.name
}],
"locations": [{
"id": self.location.id,
"name": self.location.name
}],
}
)
# The `descriptions` hook
resp = client.get(
"/bda/catalogue/descriptions?id={}".format(self.tirage.id)
)
raw = resp.content.decode("utf-8")
try:
results = json.loads(raw)
except ValueError:
self.fail("Not valid JSON: {}".format(raw))
self.assertEqual(len(results), 3)
self.assertEqual(
{(s["title"], s["price"], s["slots"]) for s in results},
{("foo", 0, 42), ("bar", 1, 142), ("baz", 2, 242)}
)

View file

@ -22,7 +22,6 @@ from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from django.utils import timezone, formats from django.utils import timezone, formats
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.core.exceptions import ObjectDoesNotExist
from gestioncof.decorators import cof_required, buro_required from gestioncof.decorators import cof_required, buro_required
from bda.models import ( from bda.models import (
Spectacle, Participant, ChoixSpectacle, Attribution, Tirage, Spectacle, Participant, ChoixSpectacle, Attribution, Tirage,
@ -657,29 +656,35 @@ def catalogue(request, request_type):
if request_type == "list": if request_type == "list":
# Dans ce cas on retourne la liste des tirages et de leur id en JSON # Dans ce cas on retourne la liste des tirages et de leur id en JSON
data_return = list( data_return = list(
Tirage.objects.filter(appear_catalogue=True).values('id', 'title')) Tirage.objects.filter(appear_catalogue=True).values('id', 'title')
)
return JsonResponse(data_return, safe=False) return JsonResponse(data_return, safe=False)
if request_type == "details": if request_type == "details":
# Dans ce cas on retourne une liste des catégories et des salles # Dans ce cas on retourne une liste des catégories et des salles
tirage_id = request.GET.get('id', '') tirage_id = request.GET.get('id', None)
try: if tirage_id is None:
tirage = Tirage.objects.get(id=tirage_id)
except ObjectDoesNotExist:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Aucun tirage correspondant à l'id " "Missing GET parameter: id <int>"
+ tirage_id) )
try:
tirage = get_object_or_404(Tirage, id=int(tirage_id))
except ValueError: except ValueError:
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Mauvais format d'identifiant : " "Bad format: int expected for `id`"
+ tirage_id) )
shows = tirage.spectacle_set.values_list("id", flat=True)
categories = list( categories = list(
CategorieSpectacle.objects.filter( CategorieSpectacle.objects
spectacle__in=tirage.spectacle_set.all()) .filter(spectacle__in=shows)
.distinct().values('id', 'name')) .distinct()
.values('id', 'name')
)
locations = list( locations = list(
Salle.objects.filter( Salle.objects
spectacle__in=tirage.spectacle_set.all()) .filter(spectacle__in=shows)
.distinct().values('id', 'name')) .distinct()
.values('id', 'name')
)
data_return = {'categories': categories, 'locations': locations} data_return = {'categories': categories, 'locations': locations}
return JsonResponse(data_return, safe=False) return JsonResponse(data_return, safe=False)
if request_type == "descriptions": if request_type == "descriptions":
@ -687,33 +692,35 @@ def catalogue(request, request_type):
# à la salle spécifiées # à la salle spécifiées
tirage_id = request.GET.get('id', '') tirage_id = request.GET.get('id', '')
categories = request.GET.get('category', '[0]') categories = request.GET.get('category', '[]')
locations = request.GET.get('location', '[0]') locations = request.GET.get('location', '[]')
try: try:
category_id = json.loads(categories) tirage_id = int(tirage_id)
location_id = json.loads(locations) categories_id = json.loads(categories)
tirage = Tirage.objects.get(id=tirage_id) locations_id = json.loads(locations)
# Integers expected
shows_qs = tirage.spectacle_set if not all(isinstance(id, int) for id in categories_id):
if not(0 in category_id): raise ValueError
shows_qs = shows_qs.filter( if not all(isinstance(id, int) for id in locations_id):
category__id__in=category_id) raise ValueError
if not(0 in location_id):
shows_qs = shows_qs.filter(
location__id__in=location_id)
except ObjectDoesNotExist:
return HttpResponseBadRequest(
"Impossible de trouver des résultats correspondant "
"à ces caractéristiques : "
+ "id = " + tirage_id
+ ", catégories = " + categories
+ ", salles = " + locations)
except ValueError: # Contient JSONDecodeError except ValueError: # Contient JSONDecodeError
return HttpResponseBadRequest( return HttpResponseBadRequest(
"Impossible de parser les paramètres donnés : " "Parse error, please ensure the GET parameters have the "
+ "id = " + request.GET.get('id', '') "following types:\n"
+ ", catégories = " + request.GET.get('category', '[0]') "id: int, category: [int], location: [int]\n"
+ ", salles = " + request.GET.get('location', '[0]')) "Data received:\n"
"id = {}, category = {}, locations = {}"
.format(request.GET.get('id', ''),
request.GET.get('category', '[]'),
request.GET.get('location', '[]'))
)
tirage = get_object_or_404(Tirage, id=tirage_id)
shows_qs = tirage.spectacle_set
if categories_id:
shows_qs = shows_qs.filter(category__id__in=categories_id)
if locations_id:
shows_qs = shows_qs.filter(location__id__in=locations_id)
# On convertit les descriptions à envoyer en une liste facilement # On convertit les descriptions à envoyer en une liste facilement
# JSONifiable (il devrait y avoir un moyen plus efficace en # JSONifiable (il devrait y avoir un moyen plus efficace en

View file

@ -84,7 +84,7 @@ urlpatterns = [
url(r'^k-fet/', include('kfet.urls')), url(r'^k-fet/', include('kfet.urls')),
] ]
if settings.DEBUG: if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
urlpatterns += [ urlpatterns += [
url(r'^__debug__/', include(debug_toolbar.urls)), url(r'^__debug__/', include(debug_toolbar.urls)),

View file

@ -239,6 +239,16 @@ class CheckoutStatementUpdateForm(forms.ModelForm):
model = CheckoutStatement model = CheckoutStatement
exclude = ['by', 'at', 'checkout', 'amount_error', 'amount_taken'] exclude = ['by', 'at', 'checkout', 'amount_error', 'amount_taken']
# -----
# Category
# -----
class CategoryForm(forms.ModelForm):
class Meta:
model = ArticleCategory
fields = ['name', 'has_addcost']
# ----- # -----
# Article forms # Article forms
# ----- # -----
@ -480,6 +490,7 @@ class InventoryArticleForm(forms.Form):
self.stock_old = kwargs['initial']['stock_old'] self.stock_old = kwargs['initial']['stock_old']
self.category = kwargs['initial']['category'] self.category = kwargs['initial']['category']
self.category_name = kwargs['initial']['category__name'] self.category_name = kwargs['initial']['category__name']
self.box_capacity = kwargs['initial']['box_capacity']
# ----- # -----
# Order forms # Order forms

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('kfet', '0051_verbose_names'),
]
operations = [
migrations.AddField(
model_name='articlecategory',
name='has_addcost',
field=models.BooleanField(default=True, help_text="Si oui et qu'une majoration est active, celle-ci sera appliquée aux articles de cette catégorie.", verbose_name='majorée'),
),
migrations.AlterField(
model_name='articlecategory',
name='name',
field=models.CharField(max_length=45, verbose_name='nom'),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('kfet', '0052_category_addcost'),
]
operations = [
migrations.AlterField(
model_name='account',
name='created_at',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View file

@ -37,7 +37,7 @@ class Account(models.Model):
max_digits = 6, decimal_places = 2, max_digits = 6, decimal_places = 2,
default = 0) default = 0)
is_frozen = models.BooleanField("est gelé", default = False) is_frozen = models.BooleanField("est gelé", default = False)
created_at = models.DateTimeField(auto_now_add = True, null = True) created_at = models.DateTimeField(default=timezone.now)
# Optional # Optional
PROMO_CHOICES = [(r,r) for r in range(1980, date.today().year+1)] PROMO_CHOICES = [(r,r) for r in range(1980, date.today().year+1)]
promo = models.IntegerField( promo = models.IntegerField(
@ -334,13 +334,20 @@ class CheckoutStatement(models.Model):
balance=F('balance') - last_statement.balance_new + self.balance_new) balance=F('balance') - last_statement.balance_new + self.balance_new)
super(CheckoutStatement, self).save(*args, **kwargs) super(CheckoutStatement, self).save(*args, **kwargs)
@python_2_unicode_compatible @python_2_unicode_compatible
class ArticleCategory(models.Model): class ArticleCategory(models.Model):
name = models.CharField(max_length = 45) name = models.CharField("nom", max_length=45)
has_addcost = models.BooleanField("majorée", default=True,
help_text="Si oui et qu'une majoration "
"est active, celle-ci sera "
"appliquée aux articles de "
"cette catégorie.")
def __str__(self): def __str__(self):
return self.name return self.name
@python_2_unicode_compatible @python_2_unicode_compatible
class Article(models.Model): class Article(models.Model):
name = models.CharField("nom", max_length = 45) name = models.CharField("nom", max_length = 45)
@ -487,6 +494,7 @@ class TransferGroup(models.Model):
related_name = "+", related_name = "+",
blank = True, null = True, default = None) blank = True, null = True, default = None)
class Transfer(models.Model): class Transfer(models.Model):
group = models.ForeignKey( group = models.ForeignKey(
TransferGroup, on_delete=models.PROTECT, TransferGroup, on_delete=models.PROTECT,
@ -506,6 +514,10 @@ class Transfer(models.Model):
canceled_at = models.DateTimeField( 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): class OperationGroup(models.Model):
on_acc = models.ForeignKey( on_acc = models.ForeignKey(
Account, on_delete = models.PROTECT, Account, on_delete = models.PROTECT,

View file

@ -32,6 +32,7 @@ textarea {
.table { .table {
margin-bottom:0; margin-bottom:0;
border-bottom:1px solid #ddd;
} }
.table { .table {
@ -85,6 +86,16 @@ textarea {
color:#FFF; color:#FFF;
} }
.buttons .nav-pills > li > a {
border-radius:0;
border:1px solid rgba(200,16,46,0.9);
}
.buttons .nav-pills > li.active > a {
background-color:rgba(200,16,46,0.9);
background-clip:padding-box;
}
.row-page-header { .row-page-header {
background-color:rgba(200,16,46,1); background-color:rgba(200,16,46,1);
color:#FFF; color:#FFF;
@ -105,6 +116,7 @@ textarea {
.panel-md-margin{ .panel-md-margin{
background-color: white; background-color: white;
overflow:hidden;
padding-left: 15px; padding-left: 15px;
padding-right: 15px; padding-right: 15px;
padding-bottom: 15px; padding-bottom: 15px;
@ -230,6 +242,9 @@ textarea {
height:28px; height:28px;
margin:3px 0px; margin:3px 0px;
} }
.content-center .auth-form {
margin:15px;
}
/* /*
* Pages formulaires seuls * Pages formulaires seuls
@ -549,3 +564,18 @@ thead .tooltip {
.help-block { .help-block {
padding-top: 15px; padding-top: 15px;
} }
/* Inventaires */
.inventory_modified {
background:rgba(236,100,0,0.15);
}
.stock_diff {
padding-left: 5px;
color:#C8102E;
}
.inventory_update {
display:none;
}

View file

@ -28,6 +28,7 @@
.jconfirm .jconfirm-box .content { .jconfirm .jconfirm-box .content {
border-bottom:1px solid #ddd; border-bottom:1px solid #ddd;
padding:5px 10px;
} }
.jconfirm .jconfirm-box input { .jconfirm .jconfirm-box input {

View file

@ -22,16 +22,18 @@
return array; return array;
} }
function handleTimeChart (dict) { function handleTimeChart (data) {
// reads the balance data and put it into chartjs formatting // 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++) { for (var i = 0; i < data.length; i++) {
var source = data[i]; var source = data[i];
data[i] = { x: new Date(source.at), chart_data[i] = {
x: new Date(source.at),
y: source.balance, y: source.balance,
label: source.label } label: source.label,
} }
return data; }
return chart_data;
} }
function showStats () { function showStats () {
@ -42,8 +44,7 @@
$(this).addClass("focus"); $(this).addClass("focus");
// loads data and shows it // loads data and shows it
$.getJSON(this.stats_target_url + "?format=json", $.getJSON(this.stats_target_url, {format: 'json'}, displayStats);
displayStats);
} }
function displayStats (data) { function displayStats (data) {
@ -78,6 +79,7 @@
var chart_options = var chart_options =
{ {
responsive: true, responsive: true,
maintainAspectRatio: false,
tooltips: { tooltips: {
mode: 'index', mode: 'index',
intersect: false, intersect: false,
@ -130,7 +132,7 @@
type: 'line', type: 'line',
options: chart_options, options: chart_options,
data: { data: {
labels: dictToArray(data.labels, 1), labels: (data.labels || []).slice(1),
datasets: chart_datasets, datasets: chart_datasets,
} }
}; };
@ -138,15 +140,15 @@
// saves the previous charts to be destroyed // saves the previous charts to be destroyed
var prev_chart = content.children(); var prev_chart = content.children();
// clean
prev_chart.remove();
// creates a blank canvas element and attach it to the DOM // creates a blank canvas element and attach it to the DOM
var canvas = $("<canvas>"); var canvas = $("<canvas height='250'>");
content.append(canvas); content.append(canvas);
// create the chart // create the chart
var chart = new Chart(canvas, chart_model); var chart = new Chart(canvas, chart_model);
// clean
prev_chart.remove();
} }
// initialize the interface // initialize the interface
@ -158,7 +160,7 @@
"aria-label": "select-period"}); "aria-label": "select-period"});
var to_click; var to_click;
var context = dictToArray(data.stats); var context = data.stats;
for (var i = 0; i < context.length; i++) { for (var i = 0; i < context.length; i++) {
// creates the button // creates the button
@ -191,7 +193,7 @@
// constructor // constructor
(function () { (function () {
$.getJSON(url + "?format=json", initialize); $.getJSON(url, {format: 'json'}, initialize);
})(); })();
}; };
})(jQuery); })(jQuery);

View file

@ -1,98 +1,155 @@
# -*- coding: utf-8 -*- # -*- 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.utils import timezone
from django.db.models import Sum from django.db.models import Sum
KFET_WAKES_UP_AT = 7 KFET_WAKES_UP_AT = time(7, 0)
# 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
# donne le nom des semaines une liste de dates def kfet_day(year, month, day, start_at=KFET_WAKES_UP_AT):
# dans un dico ordonné """datetime wrapper with time offset."""
def weeknames(dates): return datetime.combine(date(year, month, day), start_at)
names = {}
for i in dates:
names[i] = dates[i].strftime("Semaine %W")
return names
# donne le nom des mois d'une liste de dates def to_kfet_day(dt, start_at=KFET_WAKES_UP_AT):
# dans un dico ordonné kfet_dt = kfet_day(year=dt.year, month=dt.month, day=dt.day)
def monthnames(dates): if dt.time() < start_at:
names = {} kfet_dt -= timedelta(days=1)
for i in dates: return kfet_dt
names[i] = dates[i].strftime("%B")
return names
# rend les dates des nb derniers jours class Scale(object):
# dans l'ordre chronologique name = None
# aujourd'hui compris step = None
# nb = 1 : rend hier
def lastdays(nb): def __init__(self, n_steps=0, begin=None, end=None,
morning = this_morning() last=False, std_chunk=True):
days = {} self.std_chunk = std_chunk
for i in range(1, nb+1): if last:
days[i] = morning - timezone.timedelta(days=nb - i + 1) end = timezone.now()
return days
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): class DayScale(Scale):
monday_morning = this_monday_morning() name = 'day'
mondays = {} step = timedelta(days=1)
for i in range(1, nb+1): label_fmt = '%A'
mondays[i] = monday_morning \
- timezone.timedelta(days=7*(nb - i + 1)) @classmethod
return mondays def get_chunk_start(cls, dt):
return to_kfet_day(dt)
def lastmonths(nb): class WeekScale(Scale):
first_month_day = this_first_month_day() name = 'week'
first_days = {} step = timedelta(days=7)
this_year = first_month_day.year label_fmt = 'Semaine %W'
this_month = first_month_day.month
for i in range(1, nb+1): @classmethod
month = ((this_month - 1 - (nb - i)) % 12) + 1 def get_chunk_start(cls, dt):
year = this_year + (nb - i) // 12 dt_kfet = to_kfet_day(dt)
first_days[i] = timezone.datetime(year=year, offset = timedelta(days=dt_kfet.weekday())
month=month, return dt_kfet - offset
day=1,
hour=KFET_WAKES_UP_AT)
return first_days
def this_first_month_day(): class MonthScale(Scale):
now = timezone.now() name = 'month'
first_day = timezone.datetime(year=now.year, step = relativedelta(months=1)
month=now.month, label_fmt = '%B'
day=1,
hour=KFET_WAKES_UP_AT) @classmethod
return first_day def get_chunk_start(cls, dt):
return to_kfet_day(dt).replace(day=1)
def this_monday_morning(): def stat_manifest(scales_def=None, scale_args=None, scale_prefix=None,
now = timezone.now() **other_url_params):
monday = now - timezone.timedelta(days=now.isoweekday()-1) if scale_prefix is None:
monday_morning = timezone.datetime(year=monday.year, scale_prefix = 'scale_'
month=monday.month, if scales_def is None:
day=monday.day, scales_def = []
hour=KFET_WAKES_UP_AT) if scale_args is None:
return monday_morning 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(): def last_stats_manifest(scales_def=None, scale_args=None, scale_prefix=None,
now = timezone.now() **url_params):
morning = timezone.datetime(year=now.year, scales_def = [
month=now.month, ('Derniers mois', MonthScale, ),
day=now.day, ('Dernières semaines', WeekScale, ),
hour=KFET_WAKES_UP_AT) ('Derniers jours', DayScale, ),
return morning ]
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 # Étant donné un queryset d'operations
@ -100,3 +157,78 @@ def this_morning():
def tot_ventes(queryset): def tot_ventes(queryset):
res = queryset.aggregate(Sum('article_nb'))['article_nb__sum'] res = queryset.aggregate(Sum('article_nb'))['article_nb__sum']
return res and res or 0 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 %} {% 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/Chart.bundle.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/statistic.js' %}"></script> <script type="text/javascript" src="{% static 'kfet/js/statistic.js' %}"></script>
<script>
jQuery(document).ready(function() { <script type="text/javascript">
var stat_last = new StatsGroup("{% url 'kfet.account.stat.last' trigramme=account.trigramme %}", $(document).ready(function() {
$("#stat_last")); var stat_last = new StatsGroup(
var stat_balance = new StatsGroup("{% url 'kfet.account.stat.balance' trigramme=account.trigramme %}", "{% url 'kfet.account.stat.operation.list' trigramme=account.trigramme %}",
$("#stat_balance")); $("#stat_last"),
);
var stat_balance = new StatsGroup(
"{% url 'kfet.account.stat.balance.list' trigramme=account.trigramme %}",
$("#stat_balance"),
);
}); });
</script> </script>
{% endif %} {% endif %}
@ -51,8 +56,22 @@
<div class="col-sm-8 col-md-9 col-content-right"> <div class="col-sm-8 col-md-9 col-content-right">
{% include "kfet/base_messages.html" %} {% include "kfet/base_messages.html" %}
<div class="content-right"> <div class="content-right">
{% if addcosts %}
<div class="content-right-block"> <div class="content-right-block">
<div class="col-sm-12 nopadding">
{% if account.user == request.user %}
<div class='tab-content'>
<div class="tab-pane fade in active" id="tab_stats">
<h2>Statistiques</h2>
<div class="panel-md-margin">
<h3>Ma balance</h3>
<div id="stat_balance"></div>
<h3>Ma consommation</h3>
<div id="stat_last"></div>
</div>
</div>
<div class="tab-pane fade" id="tab_history">
{% endif %}
{% if addcosts %}
<h2>Gagné des majorations</h2> <h2>Gagné des majorations</h2>
<div> <div>
<ul> <ul>
@ -61,35 +80,16 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
</div>
{% endif %} {% endif %}
{% 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>
</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>
{% endif %}
<div class="content-right-block">
<h2>Historique</h2> <h2>Historique</h2>
<div id="history"> <div id="history"></div>
</div> {% if account.user == request.user %}
</div>
</div> </div>
</div><!-- tab-content -->
{% endif %}
</div><!-- col-sm-12 -->
</div><!-- content-right-block -->
</div><!-- content-right-->
</div> </div>
</div> </div>

View file

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

View file

@ -16,6 +16,9 @@
<a class="btn btn-primary btn-lg" href="{% url 'kfet.article.create' %}"> <a class="btn btn-primary btn-lg" href="{% url 'kfet.article.create' %}">
Nouvel article Nouvel article
</a> </a>
<a class="btn btn-primary btn-lg" href="{% url 'kfet.category' %}">
Catégories
</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,11 @@
{% extends 'kfet/base.html' %} {% extends 'kfet/base.html' %}
{% load staticfiles %} {% 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 title %}Informations sur l'article {{ article }}{% endblock %}
{% block content-header-title %}Article - {{ article.name }}{% endblock %} {% block content-header-title %}Article - {{ article.name }}{% endblock %}
@ -94,15 +99,14 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_head %} <script type="text/javascript">
<script type="text/javascript" src="{% static 'kfet/js/Chart.bundle.js' %}"></script> $(document).ready(function() {
<script type="text/javascript" src="{% static 'kfet/js/statistic.js' %}"></script> var stat_last = new StatsGroup(
<script> "{% url 'kfet.article.stat.sales.list' article.id %}",
jQuery(document).ready(function() { $("#stat_last"),
var stat_last = new StatsGroup("{% url 'kfet.article.stat.last' article.id %}", );
$("#stat_last"));
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -12,7 +12,7 @@
<div class="row form-only"> <div class="row form-only">
<div class="col-sm-12 col-md-8 col-md-offset-2"> <div class="col-sm-12 col-md-8 col-md-offset-2">
<div class="content-form"> <div class="content-form">
<form submit="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
{% include 'kfet/form_snippet.html' with form=form %} {% include 'kfet/form_snippet.html' with form=form %}
{% if not perms.kfet.change_article %} {% if not perms.kfet.change_article %}

View file

@ -0,0 +1,53 @@
{% extends 'kfet/base.html' %}
{% block title %}Categories d'articles{% endblock %}
{% block content-header-title %}Categories d'articles{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-4 col-md-3 col-content-left">
<div class="content-left">
<div class="content-left-top">
<div class="line line-big">{{ categories|length }}</div>
<div class="line line-bigsub">catégorie{{ categories|length|pluralize }}</div>
</div>
</div>
</div>
<div class="col-sm-8 col-md-9 col-content-right">
{% include 'kfet/base_messages.html' %}
<div class="content-right">
<div class="content-right-block">
<h2>Liste des catégories</h2>
<div class="table-responsive">
<table class="table table-condensed">
<thead>
<tr>
<td></td>
<td>Nom</td>
<td class="text-right">Nombre d'articles</td>
<td class="text-right">Peut être majorée</td>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td class="text-center">
<a href="{% url 'kfet.category.update' category.pk %}">
<span class="glyphicon glyphicon-cog"></span>
</a>
</td>
<td>{{ category.name }}</td>
<td class="text-right">{{ category.articles.all|length }}</td>
<td class="text-right">{{ category.has_addcost | yesno:"Oui,Non"}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends 'kfet/base.html' %}
{% block title %}Édition de la catégorie {{ category.name }}{% endblock %}
{% block content-header-title %}Catégorie {{ category.name }} - Édition{% endblock %}
{% block content %}
{% include "kfet/base_messages.html" %}
<div class="row form-only">
<div class="col-sm-12 col-md-8 col-md-offset-2">
<div class="content-form">
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% include 'kfet/form_snippet.html' with form=form %}
{% if not perms.kfet.edit_articlecategory %}
{% include 'kfet/form_authentication_snippet.html' %}
{% endif %}
{% include 'kfet/form_submit_snippet.html' with value="Enregistrer"%}
<form>
</div>
</div>
</div>
{% endblock %}

View file

@ -23,6 +23,7 @@
<div class="column-row"> <div class="column-row">
<div class="column-sm-1 column-md-2 column-lg-3"> <div class="column-sm-1 column-md-2 column-lg-3">
<div class="unbreakable carte-inverted"> <div class="unbreakable carte-inverted">
{% if pressions %}
<h3>Pressions du moment</h3> <h3>Pressions du moment</h3>
<ul class="carte"> <ul class="carte">
{% for article in pressions %} {% for article in pressions %}
@ -33,6 +34,7 @@
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %}
</div><!-- endblock unbreakable --> </div><!-- endblock unbreakable -->
{% for article in articles %} {% for article in articles %}
{% ifchanged article.category %} {% ifchanged article.category %}
@ -49,7 +51,7 @@
<span class="carte-label">{{ article.name }}</span> <span class="carte-label">{{ article.name }}</span>
<span class="carte-ukf">{{ article.price | ukf:False}} UKF</span> <span class="carte-ukf">{{ article.price | ukf:False}} UKF</span>
</li> </li>
{% if foorloop.last %} {% if forloop.last %}
</ul> </ul>
</div><!-- endblock unbreakable --> </div><!-- endblock unbreakable -->
{% endif %} {% endif %}

View file

@ -1,4 +1,11 @@
{% extends 'kfet/base.html' %} {% extends 'kfet/base.html' %}
{% load staticfiles %}
{% load widget_tweaks %}
{% block extra_head %}
<script type="text/javascript" src="{% static 'kfet/js/reconnecting-websocket.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-confirm.js' %}"></script>
{% endblock %}
{% block title %}Nouvel inventaire{% endblock %} {% block title %}Nouvel inventaire{% endblock %}
{% block content-header-title %}Nouvel inventaire{% endblock %} {% block content-header-title %}Nouvel inventaire{% endblock %}
@ -6,38 +13,194 @@
{% block content %} {% block content %}
{% include 'kfet/base_messages.html' %} {% include 'kfet/base_messages.html' %}
<div class="content-center">
<form action="" method="post"> <div>
<table> <form id='inventoryform' action="" method="post">
<table class="table text-center">
<thead> <thead>
<tr> <tr>
<td>Article</td> <td>Article</td>
<td>Théo.</td> <td>Quantité par caisse</td>
<td>Réel</td> <td>Stock Théorique</td>
<td>Caisses en réserve</td>
<td>Caisses en arrière</td>
<td>Vrac</td>
<td>Stock total</td>
<td>Compte terminé</td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for form in formset %} {% for form in formset %}
{% ifchanged form.category %} {% ifchanged form.category %}
<tr> <tr class='section'>
<td colspan="3">{{ form.category_name }}</td> <td>{{ form.category_name }}</td>
<td colspan="7"></td>
</tr> </tr>
{% endifchanged %} {% endifchanged %}
<tr> <tr>
{{ form.article }} {{ form.article }}
<td>{{ form.name }}</td> <td class='name'>{{ form.name }}</td>
<td>{{ form.stock_old }}</td> <td class='box_capacity'>{{ form.box_capacity }}</td>
<td>{{ form.stock_new }}</td> <td><span class='current_stock'>{{ form.stock_old }}</span><span class='stock_diff'></span></td>
<td class='box_cellar'>
<div class='col-md-2'></div>
<div class='col-md-8'>
<input type='number' class='form-control' step='1'>
</div>
</td>
<td class='box_bar'>
<div class='col-md-offset-2 col-md-8'><input type='number' class='form-control' step='1'></div>
</td>
<td class='misc'>
<div class='col-md-offset-2 col-md-8'><input type='number' class='form-control' step='1'></div>
</td>
<td class='stock_new'>
<div class='col-md-offset-2 col-md-8'>{{ form.stock_new | attr:"readonly"| add_class:"form-control" }}</div>
<div class='col-md-2 inventory_update'><button type='button' class='btn-sm btn-primary'>MàJ</button></div>
</td>
<td class='finished'><input type='checkbox' class='form_control'></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if not perms.kfet.add_inventory %}
<input type="password" name="KFETPASSWORD">
{% endif %}
{% csrf_token %}
{{ formset.management_form }} {{ formset.management_form }}
<input type="submit" value="Enregistrer" class="btn btn-primary btn-lg"> {% if not perms.kfet.add_inventory %}
<div class='auth-form form-horizontal'>
{% include "kfet/form_authentication_snippet.html" %}
</div>
{% endif %}
<input type="submit" value="Enregistrer" class="btn btn-primary btn-lg btn-block">
{% csrf_token %}
</form> </form>
</div>
</div>
<script type="text/javascript">
$(document).ready(function() {
'use strict';
var conflicts = new Set();
/**
* Autofill new stock from other inputs
*/
$('input[type="number"]').on('input', function() {
var $line = $(this).closest('tr');
var box_capacity = +$line.find('.box_capacity').text();
var box_cellar = $line.find('.box_cellar input').val();
var box_bar = $line.find('.box_bar input').val();
var misc = $line.find('.misc input').val();
if (box_cellar || box_bar || misc)
$line.find('.stock_new input').val(
box_capacity*((+box_cellar) +(+box_bar))+(+misc));
else
$line.find('.stock_new input').val('');
});
/*
* Remove warning and update stock
*/
function update_stock($line, update_count) {
$line.removeClass('inventory_modified');
$line.find('.inventory_update').hide();
var old_stock = +$line.find('.current_stock').text()
var stock_diff = +$line.find('.stock_diff').text();
$line.find('.current_stock').text(old_stock + stock_diff);
$line.find('.stock_diff').text('');
if ($line.find('.stock_new input').val() && update_count) {
var old_misc = +$line.find('.misc input').val();
$line.find('.misc input').val(old_misc + stock_diff)
.trigger('input');
}
var id = $line.find('input[type="hidden"]').val();
conflicts.delete(parseInt(id));
}
$('.finished input').change(function() {
var $line = $(this).closest('tr');
update_stock($line, false);
});
$('.inventory_update button').click(function() {
var $line = $(this).closest('tr');
update_stock($line, true);
});
/**
* Websocket
*/
OperationWebSocket.add_handler(function(data) {
for (let article of data['articles']) {
var $line = $('input[value="'+article.id+'"]').parent();
if ($line.find('.finished input').is(":checked")) {
conflicts.add(article.id);
//Display warning
$line.addClass('inventory_modified');
//Realigning input and displaying update button
$line.find('.inventory_update').show();
//Displaying stock changes
var stock = $line.find('.current_stock').text();
$line.find('.stock_diff').text(article.stock - stock);
} else {
// If we haven't counted the article yet, we simply update the expected stock
$line.find('.current_stock').text(article.stock);
}
}
});
$('input[type="submit"]').on("click", function(e) {
e.preventDefault();
if (conflicts.size) {
content = '';
content += "Conflits possibles :"
content += '<ul>';
for (let id of conflicts) {
var $line = $('input[value="'+id+'"]').closest('tr');
var name = $line.find('.name').text();
var stock_diff = $line.find('.stock_diff').text();
content += '<li>'+name+' ('+stock_diff+')</li>';
}
content += '</ul>'
} else {
// Prevent erroneous enter key confirmations
// Kinda complicated to filter if click or enter key...
content="Voulez-vous confirmer l'inventaire ?";
}
$.confirm({
title: "Confirmer l'inventaire",
content: content,
backgroundDismiss: true,
animation: 'top',
closeAnimation: 'bottom',
keyboardEnabled: true,
confirm: function() {
$('#inventoryform').submit();
},
onOpen: function() {
var that = this;
this.$content.find('input').on('keydown', function(e) {
if (e.keyCode == 13)
that.$confirmButton.click();
});
},
});
});
});
</script>
{% endblock %} {% endblock %}

View file

@ -647,7 +647,7 @@ $(document).ready(function() {
}); });
$after.after(article_html); $after.after(article_html);
// Pour l'autocomplétion // Pour l'autocomplétion
articlesList.push([article['name'],article['id'],article['category_id'],article['price'], article['stock']]); articlesList.push([article['name'],article['id'],article['category_id'],article['price'], article['stock'],article['category__has_addcost']]);
} }
function getArticles() { function getArticles() {
@ -831,12 +831,15 @@ $(document).ready(function() {
while (i<articlesList.length && id != articlesList[i][1]) i++; while (i<articlesList.length && id != articlesList[i][1]) i++;
article_data = articlesList[i]; article_data = articlesList[i];
var amount_euro = - article_data[3] * nb ; var amount_euro = - article_data[3] * nb ;
if (settings['addcost_for'] && settings['addcost_amount'] && account_data['trigramme'] != settings['addcost_for']) if (settings['addcost_for']
&& settings['addcost_amount']
&& account_data['trigramme'] != settings['addcost_for']
&& article_data[5])
amount_euro -= settings['addcost_amount'] * nb; amount_euro -= settings['addcost_amount'] * nb;
var reduc_divisor = 1; var reduc_divisor = 1;
if (account_data['is_cof']) if (account_data['is_cof'])
reduc_divisor = 1 + settings['subvention_cof'] / 100; reduc_divisor = 1 + settings['subvention_cof'] / 100;
return amount_euro / reduc_divisor; return (amount_euro / reduc_divisor).toFixed(2);
} }
function addPurchase(id, nb) { function addPurchase(id, nb) {
@ -850,7 +853,7 @@ $(document).ready(function() {
} }
}); });
if (!existing) { if (!existing) {
var amount_euro = amountEuroPurchase(id, nb).toFixed(2); var amount_euro = amountEuroPurchase(id, nb);
var index = addPurchaseToFormset(article_data[1], nb, amount_euro); var index = addPurchaseToFormset(article_data[1], nb, amount_euro);
article_basket_html = $(item_basket_default_html); article_basket_html = $(item_basket_default_html);
article_basket_html article_basket_html

View file

@ -36,6 +36,12 @@
</div> </div>
</div> </div>
<div class="buttons"> <div class="buttons">
{% if account.user == request.user %}
<ul class='nav nav-pills nav-justified'>
<li class="active"><a data-toggle="pill" href="#tab_stats">Statistiques</a></li>
<li><a data-toggle="pill" href="#tab_history">Historique</a></li>
</ul>
{% endif %}
<a class="btn btn-primary btn-lg" href="{% url 'kfet.account.update' account.trigramme %}"> <a class="btn btn-primary btn-lg" href="{% url 'kfet.account.update' account.trigramme %}">
Modifier Modifier
</a> </a>

View file

@ -66,11 +66,13 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if not perms.kfet.add_order %}
<input type="password" name="KFETPASSWORD">
{% endif %}
{{ formset.management_form }} {{ formset.management_form }}
<input type="submit" class="btn btn-primary btn-lg btn-block" value="Envoyer"> {% if not perms.kfet.add_inventory %}
<div class='auth-form form-horizontal'>
{% include "kfet/form_authentication_snippet.html" %}
</div>
{% endif %}
<input type="submit" value="Enregistrer" class="btn btn-primary btn-lg btn-block">
</form> </form>
</div> </div>
</div> </div>

View file

@ -42,11 +42,13 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if not perms.kfet.order_to_inventory %}
<input type="password" name="KFETPASSWORD">
{% endif %}
{{ formset.management_form }} {{ formset.management_form }}
<input type="submit" class="btn btn-primary btn-lg btn-block" value="Enregistrer"> {% if not perms.kfet.add_inventory %}
<div class='auth-form form-horizontal'>
{% include "kfet/form_authentication_snippet.html" %}
</div>
{% endif %}
<input type="submit" value="Enregistrer" class="btn btn-primary btn-lg btn-block">
</form> </form>
</div> </div>
</div> </div>

70
kfet/tests.py Normal file
View file

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
from unittest.mock import patch
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'), name='kfet.account.negative'),
# Account - Statistics # Account - Statistics
url('^accounts/(?P<trigramme>.{3})/stat/last/$', url(r'^accounts/(?P<trigramme>.{3})/stat/operations/list$',
views.AccountStatLastAll.as_view(), views.AccountStatOperationList.as_view(),
name = 'kfet.account.stat.last'), name='kfet.account.stat.operation.list'),
url('^accounts/(?P<trigramme>.{3})/stat/last/month/$', url(r'^accounts/(?P<trigramme>.{3})/stat/operations$',
views.AccountStatLastMonth.as_view(), views.AccountStatOperation.as_view(),
name = 'kfet.account.stat.last.month'), name='kfet.account.stat.operation'),
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/balance/$', url(r'^accounts/(?P<trigramme>.{3})/stat/balance/list$',
views.AccountStatBalanceAll.as_view(), 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'), name='kfet.account.stat.balance'),
url('^accounts/(?P<trigramme>.{3})/stat/balance/d/(?P<nb_date>\d*)/$',
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'),
# ----- # -----
# Checkout urls # Checkout urls
@ -134,6 +125,14 @@ urlpatterns = [
# Article urls # Article urls
# ----- # -----
# Category - General
url('^categories/$',
teamkfet_required(views.CategoryList.as_view()),
name='kfet.category'),
# Category - Update
url('^categories/(?P<pk>\d+)/edit$',
teamkfet_required(views.CategoryUpdate.as_view()),
name='kfet.category.update'),
# Article - General # Article - General
url('^articles/$', url('^articles/$',
teamkfet_required(views.ArticleList.as_view()), teamkfet_required(views.ArticleList.as_view()),
@ -151,18 +150,12 @@ urlpatterns = [
teamkfet_required(views.ArticleUpdate.as_view()), teamkfet_required(views.ArticleUpdate.as_view()),
name='kfet.article.update'), name='kfet.article.update'),
# Article - Statistics # Article - Statistics
url('^articles/(?P<pk>\d+)/stat/last/$', url(r'^articles/(?P<pk>\d+)/stat/sales/list$',
views.ArticleStatLastAll.as_view(), views.ArticleStatSalesList.as_view(),
name = 'kfet.article.stat.last'), name='kfet.article.stat.sales.list'),
url('^articles/(?P<pk>\d+)/stat/last/month/$', url(r'^articles/(?P<pk>\d+)/stat/sales$',
views.ArticleStatLastMonth.as_view(), views.ArticleStatSales.as_view(),
name = 'kfet.article.stat.last.month'), name='kfet.article.stat.sales'),
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'),
# ----- # -----
# K-Psul urls # K-Psul urls

View file

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import ast
from urllib.parse import urlencode
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.cache import cache from django.core.cache import cache
from django.views.generic import ListView, DetailView, TemplateView from django.views.generic import ListView, DetailView, TemplateView
from django.views.generic.list import BaseListView, MultipleObjectTemplateResponseMixin from django.views.generic.detail import BaseDetailView
from django.views.generic.detail import BaseDetailView, SingleObjectTemplateResponseMixin from django.views.generic.edit import CreateView, UpdateView
from django.views.generic.edit import CreateView, UpdateView, DeleteView, FormView
from django.core.urlresolvers import reverse, reverse_lazy from django.core.urlresolvers import reverse, reverse_lazy
from django.contrib import messages from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
@ -29,7 +31,7 @@ from kfet.models import (
Account, Checkout, Article, AccountNegative, Account, Checkout, Article, AccountNegative,
CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory, CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory,
InventoryArticle, Order, OrderArticle, Operation, OperationGroup, InventoryArticle, Order, OrderArticle, Operation, OperationGroup,
TransferGroup, Transfer) TransferGroup, Transfer, ArticleCategory)
from kfet.forms import ( from kfet.forms import (
AccountTriForm, AccountBalanceForm, AccountNoTriForm, UserForm, CofForm, AccountTriForm, AccountBalanceForm, AccountNoTriForm, UserForm, CofForm,
UserRestrictTeamForm, UserGroupForm, AccountForm, CofRestrictForm, UserRestrictTeamForm, UserGroupForm, AccountForm, CofRestrictForm,
@ -39,7 +41,7 @@ from kfet.forms import (
KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm, KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm,
KPsulOperationFormSet, AddcostForm, FilterHistoryForm, KPsulOperationFormSet, AddcostForm, FilterHistoryForm,
TransferFormSet, InventoryArticleForm, OrderArticleForm, TransferFormSet, InventoryArticleForm, OrderArticleForm,
OrderArticleToInventoryForm, KFetConfigForm OrderArticleToInventoryForm, CategoryForm, KFetConfigForm
) )
from collections import defaultdict from collections import defaultdict
from kfet import consumers from kfet import consumers
@ -48,10 +50,8 @@ from decimal import Decimal
import django_cas_ng import django_cas_ng
import heapq import heapq
import statistics import statistics
from .statistic import daynames, monthnames, weeknames, \ from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes
lastdays, lastweeks, lastmonths, \
this_morning, this_monday_morning, this_first_month_day, \
tot_ventes
class Home(TemplateView): class Home(TemplateView):
template_name = "kfet/home.html" template_name = "kfet/home.html"
@ -724,12 +724,44 @@ class CheckoutStatementUpdate(SuccessMessageMixin, UpdateView):
form.instance.amount_taken = getAmountTaken(form.instance) form.instance.amount_taken = getAmountTaken(form.instance)
return super(CheckoutStatementUpdate, self).form_valid(form) return super(CheckoutStatementUpdate, self).form_valid(form)
# -----
# Category views
# -----
# Category - General
class CategoryList(ListView):
queryset = (ArticleCategory.objects
.prefetch_related('articles')
.order_by('name'))
template_name = 'kfet/category.html'
context_object_name = 'categories'
# Category - Update
class CategoryUpdate(SuccessMessageMixin, UpdateView):
model = ArticleCategory
template_name = 'kfet/category_update.html'
form_class = CategoryForm
success_url = reverse_lazy('kfet.category')
success_message = "Informations mises à jour pour la catégorie : %(name)s"
# Surcharge de la validation
def form_valid(self, form):
# Checking permission
if not self.request.user.has_perm('kfet.change_articlecategory'):
form.add_error(None, 'Permission refusée')
return self.form_invalid(form)
# Updating
return super(CategoryUpdate, self).form_valid(form)
# ----- # -----
# Article views # Article views
# ----- # -----
# Article - General
# Article - General
class ArticleList(ListView): class ArticleList(ListView):
queryset = (Article.objects queryset = (Article.objects
.select_related('category') .select_related('category')
@ -740,8 +772,8 @@ class ArticleList(ListView):
template_name = 'kfet/article.html' template_name = 'kfet/article.html'
context_object_name = 'articles' context_object_name = 'articles'
# Article - Create
# Article - Create
class ArticleCreate(SuccessMessageMixin, CreateView): class ArticleCreate(SuccessMessageMixin, CreateView):
model = Article model = Article
template_name = 'kfet/article_create.html' template_name = 'kfet/article_create.html'
@ -785,8 +817,8 @@ class ArticleCreate(SuccessMessageMixin, CreateView):
# Creating # Creating
return super(ArticleCreate, self).form_valid(form) return super(ArticleCreate, self).form_valid(form)
# Article - Read
# Article - Read
class ArticleRead(DetailView): class ArticleRead(DetailView):
model = Article model = Article
template_name = 'kfet/article_read.html' template_name = 'kfet/article_read.html'
@ -806,8 +838,8 @@ class ArticleRead(DetailView):
context['supplierarts'] = supplierarts context['supplierarts'] = supplierarts
return context return context
# Article - Update
# Article - Update
class ArticleUpdate(SuccessMessageMixin, UpdateView): class ArticleUpdate(SuccessMessageMixin, UpdateView):
model = Article model = Article
template_name = 'kfet/article_update.html' template_name = 'kfet/article_update.html'
@ -935,7 +967,7 @@ def kpsul_update_addcost(request):
data = { data = {
'errors': { 'errors': {
'missing_perms': get_missing_perms(required_perms, 'missing_perms': get_missing_perms(required_perms,
request.user), request.user)
} }
} }
return JsonResponse(data, status=403) return JsonResponse(data, status=403)
@ -959,13 +991,16 @@ def kpsul_update_addcost(request):
def get_missing_perms(required_perms, user): def get_missing_perms(required_perms, user):
missing_perms_codenames = [(perm.split('.'))[1] missing_perms_codenames = [(perm.split('.'))[1]
for perm in required_perms if not user.has_perm(perm)] for perm in required_perms
if not user.has_perm(perm)]
missing_perms = list( missing_perms = list(
Permission.objects Permission.objects
.filter(codename__in=missing_perms_codenames) .filter(codename__in=missing_perms_codenames)
.values_list('name', flat=True)) .values_list('name', flat=True)
)
return missing_perms return missing_perms
@teamkfet_required @teamkfet_required
def kpsul_perform_operations(request): def kpsul_perform_operations(request):
# Initializing response data # Initializing response data
@ -1002,24 +1037,26 @@ def kpsul_perform_operations(request):
to_addcost_for_balance = 0 # For balance of addcost_for to_addcost_for_balance = 0 # For balance of addcost_for
to_checkout_balance = 0 # For balance of selected checkout to_checkout_balance = 0 # For balance of selected checkout
to_articles_stocks = defaultdict(lambda: 0) # For stocks articles to_articles_stocks = defaultdict(lambda: 0) # For stocks articles
is_addcost = (addcost_for and addcost_amount is_addcost = all((addcost_for, addcost_amount,
and addcost_for != operationgroup.on_acc) addcost_for != operationgroup.on_acc))
need_comment = operationgroup.on_acc.need_comment need_comment = operationgroup.on_acc.need_comment
# Filling data of each operations + operationgroup + calculating other stuffs # Filling data of each operations
# + operationgroup + calculating other stuffs
for operation in operations: for operation in operations:
if operation.type == Operation.PURCHASE: if operation.type == Operation.PURCHASE:
operation.amount = - operation.article.price * operation.article_nb operation.amount = - operation.article.price * operation.article_nb
if is_addcost: if is_addcost & operation.article.category.has_addcost:
operation.addcost_for = addcost_for operation.addcost_for = addcost_for
operation.addcost_amount = addcost_amount * operation.article_nb operation.addcost_amount = addcost_amount \
* operation.article_nb
operation.amount -= operation.addcost_amount operation.amount -= operation.addcost_amount
to_addcost_for_balance += operation.addcost_amount to_addcost_for_balance += operation.addcost_amount
if operationgroup.on_acc.is_cash: if operationgroup.on_acc.is_cash:
to_checkout_balance += -operation.amount to_checkout_balance += -operation.amount
if operationgroup.on_acc.is_cof: if operationgroup.on_acc.is_cof:
if is_addcost: if is_addcost and operation.article.category.has_addcost:
operation.addcost_amount = operation.addcost_amount / cof_grant_divisor operation.addcost_amount /= cof_grant_divisor
operation.amount = operation.amount / cof_grant_divisor operation.amount = operation.amount / cof_grant_divisor
to_articles_stocks[operation.article] -= operation.article_nb to_articles_stocks[operation.article] -= operation.article_nb
else: else:
@ -1036,8 +1073,10 @@ def kpsul_perform_operations(request):
if operationgroup.on_acc.is_cof: if operationgroup.on_acc.is_cof:
to_addcost_for_balance = to_addcost_for_balance / cof_grant_divisor to_addcost_for_balance = to_addcost_for_balance / cof_grant_divisor
(perms, stop) = operationgroup.on_acc.perms_to_perform_operation( (perms, stop) = (operationgroup.on_acc
.perms_to_perform_operation(
amount=operationgroup.amount) amount=operationgroup.amount)
)
required_perms |= perms required_perms |= perms
if need_comment: if need_comment:
@ -1079,8 +1118,8 @@ def kpsul_perform_operations(request):
negative = AccountNegative( negative = AccountNegative(
account=operationgroup.on_acc, start=timezone.now()) account=operationgroup.on_acc, start=timezone.now())
negative.save() negative.save()
elif (hasattr(operationgroup.on_acc, 'negative') elif (hasattr(operationgroup.on_acc, 'negative') and
and not operationgroup.on_acc.negative.balance_offset): not operationgroup.on_acc.negative.balance_offset):
operationgroup.on_acc.negative.delete() operationgroup.on_acc.negative.delete()
# Updating checkout's balance # Updating checkout's balance
@ -1125,10 +1164,13 @@ def kpsul_perform_operations(request):
}] }]
for operation in operations: for operation in operations:
ope_data = { ope_data = {
'id': operation.pk, 'type': operation.type, 'amount': operation.amount, 'id': operation.pk, 'type': operation.type,
'amount': operation.amount,
'addcost_amount': operation.addcost_amount, 'addcost_amount': operation.addcost_amount,
'addcost_for__trigramme': operation.addcost_for and addcost_for.trigramme or None, 'addcost_for__trigramme': (
'article__name': operation.article and operation.article.name or None, operation.addcost_for and addcost_for.trigramme or None),
'article__name': (
operation.article and operation.article.name or None),
'article_nb': operation.article_nb, 'article_nb': operation.article_nb,
'group_id': operationgroup.pk, 'group_id': operationgroup.pk,
'canceled_by__trigramme': None, 'canceled_at': None, 'canceled_by__trigramme': None, 'canceled_at': None,
@ -1152,6 +1194,7 @@ def kpsul_perform_operations(request):
consumers.KPsul.group_send('kfet.kpsul', websocket_data) consumers.KPsul.group_send('kfet.kpsul', websocket_data)
return JsonResponse(data) return JsonResponse(data)
@teamkfet_required @teamkfet_required
def kpsul_cancel_operations(request): def kpsul_cancel_operations(request):
# Pour la réponse # Pour la réponse
@ -1400,7 +1443,8 @@ def history_json(request):
def kpsul_articles_data(request): def kpsul_articles_data(request):
articles = ( articles = (
Article.objects Article.objects
.values('id', 'name', 'price', 'stock', 'category_id', 'category__name') .values('id', 'name', 'price', 'stock', 'category_id',
'category__name', 'category__has_addcost')
.filter(is_sold=True)) .filter(is_sold=True))
return JsonResponse({ 'articles': list(articles) }) return JsonResponse({ 'articles': list(articles) })
@ -1655,7 +1699,8 @@ def inventory_create(request):
'stock_old': article.stock, 'stock_old': article.stock,
'name' : article.name, 'name' : article.name,
'category' : article.category_id, 'category' : article.category_id,
'category__name': article.category.name 'category__name': article.category.name,
'box_capacity': article.box_capacity or 0,
}) })
cls_formset = formset_factory( cls_formset = formset_factory(
@ -2029,87 +2074,54 @@ class JSONResponseMixin(object):
return context return context
class JSONDetailView(JSONResponseMixin, class JSONDetailView(JSONResponseMixin, BaseDetailView):
BaseDetailView): """Returns a DetailView that renders a JSON."""
"""
Returns a DetailView that renders a JSON
"""
def render_to_response(self, context): def render_to_response(self, context):
return self.render_to_json_response(context) return self.render_to_json_response(context)
class HybridDetailView(JSONResponseMixin,
SingleObjectTemplateResponseMixin, class PkUrlMixin(object):
BaseDetailView):
""" def get_object(self, *args, **kwargs):
Returns a DetailView as an html page except if a JSON file is requested get_by = self.kwargs.get(self.pk_url_kwarg)
by the GET method in which case it returns a JSON response. return get_object_or_404(self.model, **{self.pk_url_kwarg: get_by})
"""
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 HybridListView(JSONResponseMixin, class SingleResumeStat(JSONDetailView):
MultipleObjectTemplateResponseMixin, """Manifest for a kind of a stat about an object.
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)
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 = '' 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 nb_default = 0
stat_labels = ['stat_1', 'stat_2']
stat_urls = ['url_1', 'url_2']
# sert à renverser les urls stats = []
# utile de le surcharger quand l'url prend d'autres arguments que l'id url_stat = None
def get_object_url_kwargs(self, **kwargs):
return {'pk': self.object.id}
def url_kwargs(self, **kwargs):
return [{}] * self.nb_stat
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
# On n'hérite pas # On n'hérite pas
object_id = self.object.id object_id = self.object.id
url_kwargs = self.url_kwargs()
context = {} context = {}
stats = {} stats = []
for i in range(self.nb_stat): prefix = '{}_{}'.format(self.id_prefix, object_id)
stats[i] = { for i, stat_def in enumerate(self.stats):
'label': self.stat_labels[i], url_pk = getattr(self.object, self.pk_url_kwarg)
'btn': "btn_%s_%d_%d" % (self.id_prefix, url_params_d = stat_def.get('url_params', {})
object_id, if len(url_params_d) > 0:
i), url_params = '?{}'.format(urlencode(url_params_d))
'url': reverse(self.stat_urls[i], else:
kwargs=dict( url_params = ''
self.get_object_url_kwargs(), stats.append({
**url_kwargs[i] 'label': stat_def['label'],
'btn': 'btn_{}_{}'.format(prefix, i),
'url': '{url}{params}'.format(
url=reverse(self.url_stat, args=[url_pk]),
params=url_params,
), ),
), })
}
prefix = "%s_%d" % (self.id_prefix, object_id)
context['id_prefix'] = prefix context['id_prefix'] = prefix
context['content_id'] = "content_%s" % prefix context['content_id'] = "content_%s" % prefix
context['stats'] = stats context['stats'] = stats
@ -2124,87 +2136,84 @@ class ObjectResumeStat(JSONDetailView):
ID_PREFIX_ACC_BALANCE = "balance_acc" ID_PREFIX_ACC_BALANCE = "balance_acc"
# Un résumé de toutes les vues ArticleStatBalance class AccountStatBalanceList(PkUrlMixin, SingleResumeStat):
# REND DU JSON """Manifest for balance stats of an account."""
class AccountStatBalanceAll(ObjectResumeStat):
model = Account model = Account
context_object_name = '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 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 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): def get_object(self, *args, **kwargs):
trigramme = self.kwargs.get(self.trigramme_url_kwarg) obj = super().get_object(*args, **kwargs)
return get_object_or_404(Account, trigramme=trigramme) if self.request.user != obj.user:
raise PermissionDenied
def get_object_url_kwargs(self, **kwargs): return obj
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
@method_decorator(login_required) @method_decorator(login_required)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(AccountStatBalanceAll, self).dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class AccountStatBalance(JSONDetailView): class AccountStatBalance(PkUrlMixin, JSONDetailView):
""" """Datasets of balance of an account.
Returns a JSON containing the evolution a the personnal
balance of a trigramme between timezone.now() and `nb_days` Operations and Transfers are taken into account.
ago (specified to the view as an argument)
takes into account the Operations and the Transfers
does not takes into account the balance offset
""" """
model = Account model = Account
trigramme_url_kwarg = 'trigramme' pk_url_kwarg = 'trigramme'
nb_date_url_kwargs = 'nb_date'
context_object_name = 'account' context_object_name = 'account'
id_prefix = ""
def get_object(self, **kwargs): def get_changes_list(self, last_days=None, begin_date=None, end_date=None):
trigramme = self.kwargs.get(self.trigramme_url_kwarg)
return get_object_or_404(Account, trigramme=trigramme)
def get_changes_list(self, **kwargs):
account = self.object account = self.object
nb_date = self.kwargs.get(self.nb_date_url_kwargs, None)
end_date = this_morning() # prepare filters
if nb_date is None: if last_days is not None:
begin_date = timezone.datetime(year=1980, month=1, day=1) end_date = timezone.now()
anytime = True begin_date = end_date - timezone.timedelta(days=last_days)
else:
begin_date = this_morning() \ # prepare querysets
- timezone.timedelta(days=int(nb_date))
anytime = False
# On récupère les opérations
# TODO: retirer les opgroup dont tous les op sont annulées # TODO: retirer les opgroup dont tous les op sont annulées
opgroups = list(OperationGroup.objects opegroups = OperationGroup.objects.filter(on_acc=account)
.filter(on_acc=account) recv_transfers = Transfer.objects.filter(to_acc=account,
.filter(at__gte=begin_date) canceled_at=None)
.filter(at__lte=end_date)) sent_transfers = Transfer.objects.filter(from_acc=account,
# On récupère les transferts reçus canceled_at=None)
received_transfers = list(Transfer.objects
.filter(to_acc=account) # apply filters
.filter(canceled_at=None) if begin_date is not None:
.filter(group__at__gte=begin_date) opegroups = opegroups.filter(at__gte=begin_date)
.filter(group__at__lte=end_date)) recv_transfers = recv_transfers.filter(group__at__gte=begin_date)
# On récupère les transferts émis sent_transfers = sent_transfers.filter(group__at__gte=begin_date)
emitted_transfers = list(Transfer.objects
.filter(from_acc=account) if end_date is not None:
.filter(canceled_at=None) opegroups = opegroups.filter(at__lte=end_date)
.filter(group__at__gte=begin_date) recv_transfers = recv_transfers.filter(group__at__lte=end_date)
.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 # On transforme tout ça en une liste de dictionnaires sous la forme
# {'at': date, # {'at': date,
# 'amount': changement de la balance (négatif si diminue la balance, # 'amount': changement de la balance (négatif si diminue la balance,
@ -2214,76 +2223,86 @@ class AccountStatBalance(JSONDetailView):
# sera mis à jour lors d'une # sera mis à jour lors d'une
# autre passe) # autre passe)
# } # }
actions = [
# Maintenant (à changer si on gère autre chose que now) actions = []
{
'at': end_date.isoformat(), actions.append({
'amout': 0, 'at': (begin_date or account.created_at).isoformat(),
'label': "actuel", 'amount': 0,
'label': 'début',
'balance': 0, 'balance': 0,
} })
] + [ actions.append({
{ 'at': (end_date or timezone.now()).isoformat(),
'at': op.at.isoformat(), 'amount': 0,
'amount': op.amount, 'label': 'fin',
'label': str(op),
'balance': 0, 'balance': 0,
} for op in opgroups })
actions += [
{
'at': ope_grp.at.isoformat(),
'amount': ope_grp.amount,
'label': str(ope_grp),
'balance': 0,
} for ope_grp in opegroups
] + [ ] + [
{ {
'at': tr.group.at.isoformat(), 'at': tr.group.at.isoformat(),
'amount': tr.amount, 'amount': tr.amount,
'label': "%d€: %s -> %s" % (tr.amount, 'label': str(tr),
tr.from_acc.trigramme,
tr.to_acc.trigramme),
'balance': 0, 'balance': 0,
} for tr in received_transfers } for tr in recv_transfers
] + [ ] + [
{ {
'at': tr.group.at.isoformat(), 'at': tr.group.at.isoformat(),
'amount': -tr.amount, 'amount': -tr.amount,
'label': "%d€: %s -> %s" % (tr.amount, 'label': str(tr),
tr.from_acc.trigramme,
tr.to_acc.trigramme),
'balance': 0, '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 # Maintenant on trie la liste des actions par ordre du plus récent
# an plus ancien et on met à jour la balance # an plus ancien et on met à jour la balance
if len(actions) > 1:
actions = sorted(actions, key=lambda k: k['at'], reverse=True) actions = sorted(actions, key=lambda k: k['at'], reverse=True)
actions[0]['balance'] = account.balance actions[0]['balance'] = account.balance
for i in range(len(actions)-1): for i in range(len(actions)-1):
actions[i+1]['balance'] = actions[i]['balance'] \ actions[i+1]['balance'] = \
- actions[i+1]['amount'] actions[i]['balance'] - actions[i+1]['amount']
return actions return actions
def get_context_data(self, **kwargs): def get_context_data(self, *args, **kwargs):
context = {} context = {}
changes = self.get_changes_list()
nb_days = self.kwargs.get(self.nb_date_url_kwargs, None) last_days = self.request.GET.get('last_days', None)
if nb_days is None: if last_days is not None:
nb_days_string = 'anytime' last_days = int(last_days)
else: begin_date = self.request.GET.get('begin_date', None)
nb_days_string = str(int(nb_days)) end_date = self.request.GET.get('end_date', None)
context['charts'] = [ { "color": "rgb(255, 99, 132)",
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", "label": "Balance",
"values": changes } ] "values": changes,
}]
context['is_time_chart'] = True context['is_time_chart'] = True
context['min_date'] = changes[len(changes)-1]['at'] if len(changes) > 0:
context['min_date'] = changes[-1]['at']
context['max_date'] = changes[0]['at'] context['max_date'] = changes[0]['at']
# TODO: offset # TODO: offset
return context return context
def get_object(self, *args, **kwargs):
obj = super().get_object(*args, **kwargs)
if self.request.user != obj.user:
raise PermissionDenied
return obj
@method_decorator(login_required) @method_decorator(login_required)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(AccountStatBalance, self).dispatch(*args, **kwargs) return super(AccountStatBalance, self).dispatch(*args, **kwargs)
@ -2298,140 +2317,77 @@ ID_PREFIX_ACC_LAST_WEEKS = "last_weeks_acc"
ID_PREFIX_ACC_LAST_MONTHS = "last_months_acc" ID_PREFIX_ACC_LAST_MONTHS = "last_months_acc"
# Un résumé de toutes les vues ArticleStatLast class AccountStatOperationList(PkUrlMixin, SingleResumeStat):
# NE REND PAS DE JSON """Manifest for operations stats of an account."""
class AccountStatLastAll(ObjectResumeStat):
model = Account model = Account
context_object_name = 'account' context_object_name = 'account'
trigramme_url_kwarg = 'trigramme' pk_url_kwarg = 'trigramme'
id_prefix = ID_PREFIX_ACC_LAST id_prefix = ID_PREFIX_ACC_LAST
nb_stat = 3
nb_default = 2 nb_default = 2
stat_labels = ["Derniers mois", "Dernières semaines", "Derniers jours"] stats = last_stats_manifest(types=[Operation.PURCHASE])
stat_urls = ['kfet.account.stat.last.month', url_stat = 'kfet.account.stat.operation'
'kfet.account.stat.last.week',
'kfet.account.stat.last.day']
def get_object(self, **kwargs): def get_object(self, *args, **kwargs):
trigramme = self.kwargs.get(self.trigramme_url_kwarg) obj = super().get_object(*args, **kwargs)
return get_object_or_404(Account, trigramme=trigramme) if self.request.user != obj.user:
raise PermissionDenied
def get_object_url_kwargs(self, **kwargs): return obj
return {'trigramme': self.object.trigramme}
@method_decorator(login_required) @method_decorator(login_required)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(AccountStatLastAll, self).dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class AccountStatLast(JSONDetailView): class AccountStatOperation(ScaleMixin, PkUrlMixin, JSONDetailView):
""" """Datasets of operations of an account."""
Returns a JSON containing the evolution a the personnal
consommation of a trigramme at the diffent dates specified
"""
model = Account model = Account
trigramme_url_kwarg = 'trigramme' pk_url_kwarg = 'trigramme'
context_object_name = 'account' context_object_name = 'account'
end_date = timezone.now()
id_prefix = "" id_prefix = ""
# doit rendre un dictionnaire des dates def get_operations(self, scale, types=None):
# 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
# On selectionne les opérations qui correspondent # On selectionne les opérations qui correspondent
# à l'article en question et qui ne sont pas annulées # à l'article en question et qui ne sont pas annulées
# puis on choisi pour chaques intervalle les opérations # puis on choisi pour chaques intervalle les opérations
# effectuées dans ces intervalles de temps # effectuées dans ces intervalles de temps
all_operations = (Operation.objects all_operations = (Operation.objects
.filter(type='purchase')
.filter(group__on_acc=self.object) .filter(group__on_acc=self.object)
.filter(canceled_at=None) .filter(canceled_at=None)
) )
operations = {} if types is not None:
for i in dates: all_operations = all_operations.filter(type__in=types)
operations[i] = (all_operations chunks = self.chunkify_qs(all_operations, scale, field='group__at')
.filter(group__at__gte=extended_dates[i]) return chunks
.filter(group__at__lte=extended_dates[i+1])
)
return operations
def get_context_data(self, **kwargs): def get_context_data(self, *args, **kwargs):
context = {} old_ctx = super().get_context_data(*args, **kwargs)
nb_ventes = {} context = {'labels': old_ctx['labels']}
# On récupère les labels des dates scale = self.scale
context['labels'] = self.get_labels().copy()
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 # On compte les opérations
operations = self.sort_operations() nb_ventes = []
for i in operations: for chunk in operations:
nb_ventes[i] = tot_ventes(operations[i]) nb_ventes.append(tot_ventes(chunk))
context['charts'] = [{"color": "rgb(255, 99, 132)", context['charts'] = [{"color": "rgb(255, 99, 132)",
"label": "NB items achetés", "label": "NB items achetés",
"values": nb_ventes}] "values": nb_ventes}]
return context 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) @method_decorator(login_required)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(AccountStatLast, self).dispatch(*args, **kwargs) return super().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)
# ------------------------ # ------------------------
@ -2443,90 +2399,53 @@ ID_PREFIX_ART_LAST_WEEKS = "last_weeks_art"
ID_PREFIX_ART_LAST_MONTHS = "last_months_art" ID_PREFIX_ART_LAST_MONTHS = "last_months_art"
# Un résumé de toutes les vues ArticleStatLast class ArticleStatSalesList(SingleResumeStat):
# NE REND PAS DE JSON """Manifest for sales stats of an article."""
class ArticleStatLastAll(ObjectResumeStat):
model = Article model = Article
context_object_name = 'article' context_object_name = 'article'
id_prefix = ID_PREFIX_ART_LAST id_prefix = ID_PREFIX_ART_LAST
nb_stat = 3
nb_default = 2 nb_default = 2
stat_labels = ["Derniers mois", "Dernières semaines", "Derniers jours"] url_stat = 'kfet.article.stat.sales'
stat_urls = ['kfet.article.stat.last.month', stats = last_stats_manifest()
'kfet.article.stat.last.week',
'kfet.article.stat.last.day']
@method_decorator(login_required) @method_decorator(teamkfet_required)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(ArticleStatLastAll, self).dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)
class ArticleStatLast(JSONDetailView): class ArticleStatSales(ScaleMixin, JSONDetailView):
""" """Datasets of sales of an article."""
Returns a JSON containing the consommation
of an article at the diffent dates precised
"""
model = Article model = Article
context_object_name = 'article' context_object_name = 'article'
end_date = timezone.now()
id_prefix = ""
def render_to_response(self, context): def get_context_data(self, *args, **kwargs):
# Look for a 'format=json' GET argument old_ctx = super().get_context_data(*args, **kwargs)
if self.request.GET.get('format') == 'json': context = {'labels': old_ctx['labels']}
return self.render_to_json_response(context) scale = self.scale
else:
return super(ArticleStatLast, self).render_to_response(context)
# 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 # On selectionne les opérations qui correspondent
# à l'article en question et qui ne sont pas annulées # à l'article en question et qui ne sont pas annulées
# puis on choisi pour chaques intervalle les opérations # puis on choisi pour chaques intervalle les opérations
# effectuées dans ces intervalles de temps # effectuées dans ces intervalles de temps
all_operations = (Operation.objects all_operations = (
.filter(type='purchase') Operation.objects
.filter(article=self.object) .filter(type=Operation.PURCHASE,
.filter(canceled_at=None) article=self.object,
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])
) )
chunks = self.chunkify_qs(all_operations, scale, field='group__at')
# On compte les opérations # On compte les opérations
nb_ventes = {} nb_ventes = []
nb_accounts = {} nb_accounts = []
nb_liq = {} nb_liq = []
for i in operations: for qs in chunks:
nb_ventes[i] = tot_ventes(operations[i]) nb_ventes.append(
nb_liq[i] = tot_ventes( tot_ventes(qs))
operations[i] nb_liq.append(
.filter(group__on_acc__trigramme='LIQ') tot_ventes(qs.filter(group__on_acc__trigramme='LIQ')))
) nb_accounts.append(
nb_accounts[i] = tot_ventes( tot_ventes(qs.exclude(group__on_acc__trigramme='LIQ')))
operations[i]
.exclude(group__on_acc__trigramme='LIQ')
)
context['charts'] = [{"color": "rgb(255, 99, 132)", context['charts'] = [{"color": "rgb(255, 99, 132)",
"label": "Toutes consommations", "label": "Toutes consommations",
"values": nb_ventes}, "values": nb_ventes},
@ -2538,48 +2457,6 @@ class ArticleStatLast(JSONDetailView):
"values": nb_accounts}] "values": nb_accounts}]
return context return context
@method_decorator(login_required) @method_decorator(teamkfet_required)
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
return super(ArticleStatLast, self).dispatch(*args, **kwargs) return super().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)

View file

@ -21,3 +21,4 @@ django-widget-tweaks==1.4.1
git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail git+https://git.eleves.ens.fr/cof-geek/django_custommail.git#egg=django_custommail
ldap3 ldap3
git+https://github.com/Aureplop/channels.git#egg=channels git+https://github.com/Aureplop/channels.git#egg=channels
python-dateutil