Merge branch 'master' into aureplop/kpsul_js_refactor

This commit is contained in:
Aurélien Delobelle 2017-04-05 23:18:33 +02:00
commit e4ccd88dfd
24 changed files with 1134 additions and 737 deletions

View file

@ -87,7 +87,7 @@ urlpatterns = [
url(r'^jsreverse/$', cache_page(3600)(urls_js), name='js_reverse'),
]
if settings.DEBUG:
if 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns += [
url(r'^__debug__/', include(debug_toolbar.urls)),

View file

@ -233,6 +233,16 @@ class CheckoutStatementUpdateForm(forms.ModelForm):
model = CheckoutStatement
exclude = ['by', 'at', 'checkout', 'amount_error', 'amount_taken']
# -----
# Category
# -----
class CategoryForm(forms.ModelForm):
class Meta:
model = ArticleCategory
fields = ['name', 'has_addcost']
# -----
# Article forms
# -----
@ -463,7 +473,7 @@ class InventoryArticleForm(forms.Form):
queryset = Article.objects.all(),
widget = forms.HiddenInput(),
)
stock_new = forms.IntegerField(required = False)
stock_new = forms.IntegerField(required=False)
def __init__(self, *args, **kwargs):
super(InventoryArticleForm, self).__init__(*args, **kwargs)
@ -472,6 +482,7 @@ class InventoryArticleForm(forms.Form):
self.stock_old = kwargs['initial']['stock_old']
self.category = kwargs['initial']['category']
self.category_name = kwargs['initial']['category__name']
self.box_capacity = kwargs['initial']['box_capacity']
# -----
# 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

@ -338,13 +338,20 @@ class CheckoutStatement(models.Model):
balance=F('balance') - last_statement.balance_new + self.balance_new)
super(CheckoutStatement, self).save(*args, **kwargs)
@python_2_unicode_compatible
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):
return self.name
@python_2_unicode_compatible
class Article(models.Model):
name = models.CharField("nom", max_length = 45)
@ -491,24 +498,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

@ -32,6 +32,7 @@ textarea {
.table {
margin-bottom:0;
border-bottom:1px solid #ddd;
}
.table {
@ -105,6 +106,7 @@ textarea {
.panel-md-margin{
background-color: white;
overflow:hidden;
padding-left: 15px;
padding-right: 15px;
padding-bottom: 15px;
@ -230,6 +232,9 @@ textarea {
height:28px;
margin:3px 0px;
}
.content-center .auth-form {
margin:15px;
}
/*
* Pages formulaires seuls
@ -549,3 +554,18 @@ thead .tooltip {
.help-block {
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 {
border-bottom:1px solid #ddd;
padding:5px 10px;
}
.jconfirm .jconfirm-box input {

View file

@ -454,7 +454,7 @@ class ArticleCategory extends ModelObject {
* @see {@link Models.ModelObject.props|ModelObject.props}
*/
static get props() {
return ['id', 'name'];
return ['id', 'name', 'has_addcost'];
}
/**
@ -463,7 +463,7 @@ class ArticleCategory extends ModelObject {
* @see {@link Models.ModelObject.default_data|ModelObject.default_data}
*/
static get default_data() {
return {'id': 0, 'name': ''};
return {'id': 0, 'name': '', 'has_addcost': true};
}
/**
@ -531,7 +531,7 @@ class Article extends ModelObject {
set price(v) { this._price = floatCheck(v); }
is_low_stock(nb) {
return (-5 <= this.stock - nb && this.stock - nb <= 5);
return (-5 <= this.stock - nb && this.stock - nb <= 5);
}
}
@ -541,12 +541,12 @@ class Article extends ModelObject {
* @memberof Models
*/
class TreeNode {
constructor(type, content) {
this.modelname = type;
this.content = content;
this.parent = null;
this.children = [];
this.children = [];
}
}
@ -557,7 +557,7 @@ class TreeNode {
*/
class ModelForest {
/**
/**
* Dictionary associating types to classes
* @abstract
* @type {Object}
@ -585,7 +585,7 @@ class ModelForest {
this.from(datalist || []);
}
/**
/**
* Fetches an object from the instance data, or creates it if
* it does not exist yet.<br>
* If direction >= 0, parent objects are created recursively.
@ -609,7 +609,7 @@ class ModelForest {
if (direction <= 0) {
if (data.parent) {
var parent = this.get_or_create(data.parent, -1);
var parent = this.get_or_create(data.parent, -1);
node.parent = parent;
parent.children.push(node);
} else {
@ -628,7 +628,7 @@ class ModelForest {
return node ;
}
/**
/**
* Resets then populates the instance with the given data.
* @param {Object[]} datalist
*/
@ -639,7 +639,7 @@ class ModelForest {
}
}
/**
/**
* Removes all Models.TreeNode from the tree.
*/
clear() {
@ -647,7 +647,7 @@ class ModelForest {
}
/**
* Renders a node (and all its offspring) and returns the
* Renders a node (and all its offspring) and returns the
* corresponding jQuery object.
* @param {Models.TreeNode} node
* @param {Object} templates Templates to render each model
@ -773,7 +773,7 @@ class ModelForest {
* @memberof Models
*/
class APIModelForest extends ModelForest {
/**
* Request url to fill the model.
* @abstract
@ -782,7 +782,7 @@ class APIModelForest extends ModelForest {
static get url_model() {}
/**
* Fills the instance with distant data. It sends a GET HTTP request to
* Fills the instance with distant data. It sends a GET HTTP request to
* {@link Models.APIModelForest#url_model}.
* @param {object} [api_options] Additional data appended to the request.
*/
@ -810,7 +810,7 @@ class ArticleList extends APIModelForest {
* @default <tt>{'article': Article,
'category': ArticleCategory}</tt>
*/
static get models() {
static get models() {
return {'article': Article,
'category': ArticleCategory};
}
@ -1114,7 +1114,7 @@ class ArticleFormatter extends Formatter {
static get _data_stock() {
return {
'default': '', 'low': 'low',
'default': '', 'low': 'low',
'ok': 'ok', 'neg': 'neg',
};
}

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

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

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

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

@ -1,4 +1,11 @@
{% 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 content-header-title %}Nouvel inventaire{% endblock %}
@ -6,38 +13,194 @@
{% block content %}
{% include 'kfet/base_messages.html' %}
<form action="" method="post">
<table>
<thead>
<tr>
<td>Article</td>
<td>Théo.</td>
<td>Réel</td>
</tr>
</thead>
<tbody>
{% for form in formset %}
{% ifchanged form.category %}
<div class="content-center">
<div>
<form id='inventoryform' action="" method="post">
<table class="table text-center">
<thead>
<tr>
<td colspan="3">{{ form.category_name }}</td>
<td>Article</td>
<td>Quantité par caisse</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>
{% endifchanged %}
<tr>
{{ form.article }}
<td>{{ form.name }}</td>
<td>{{ form.stock_old }}</td>
<td>{{ form.stock_new }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not perms.kfet.add_inventory %}
<input type="password" name="KFETPASSWORD">
{% endif %}
{% csrf_token %}
{{ formset.management_form }}
<input type="submit" value="Enregistrer" class="btn btn-primary btn-lg">
</form>
</thead>
<tbody>
{% for form in formset %}
{% ifchanged form.category %}
<tr class='section'>
<td>{{ form.category_name }}</td>
<td colspan="7"></td>
</tr>
{% endifchanged %}
<tr>
{{ form.article }}
<td class='name'>{{ form.name }}</td>
<td class='box_capacity'>{{ form.box_capacity }}</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>
{% endfor %}
</tbody>
</table>
{{ formset.management_form }}
{% 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>
</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 %}

View file

@ -283,7 +283,10 @@ $(document).ready(function() {
function amountEuroPurchase(article, nb) {
var amount_euro = - article.price * nb ;
if (Config.get('addcost_for') && Config.get('addcost_amount') && kpsul.account_manager.account.trigramme != Config.get('addcost_for'))
if (Config.get('addcost_for')
&& Config.get('addcost_amount')
&& kpsul.account_manager.account.trigramme != Config.get('addcost_for')
&& article.category.has_addcost)
amount_euro -= Config.get('addcost_amount') * nb;
var reduc_divisor = 1;
if (kpsul.account_manager.account.is_cof)
@ -327,7 +330,7 @@ $(document).ready(function() {
.attr('data-opeindex', index)
.find('.number').text(amount+"€").end()
.find('.name').text('Charge').end()
.find('.amount').text(amountToUKF(amount, kpsul.account_manager.account.is_cof, false));
.find('.amount').text(amountToUKF(amount, kpsul.account_manager.account.is_cof));
basket_container.prepend(deposit_basket_html);
updateBasketRel();
}
@ -340,7 +343,7 @@ $(document).ready(function() {
.attr('data-opeindex', index)
.find('.number').text(amount+"€").end()
.find('.name').text('Édition').end()
.find('.amount').text(amountToUKF(amount, kpsul.account_manager.account.is_cof, false));
.find('.amount').text(amountToUKF(amount, kpsul.account_manager.account.is_cof));
basket_container.prepend(deposit_basket_html);
updateBasketRel();
}
@ -353,7 +356,7 @@ $(document).ready(function() {
.attr('data-opeindex', index)
.find('.number').text(amount+"€").end()
.find('.name').text('Retrait').end()
.find('.amount').text(amountToUKF(amount, kpsul.account_manager.account.is_cof, false));
.find('.amount').text(amountToUKF(amount, kpsul.account_manager.account.is_cof));
basket_container.prepend(withdraw_basket_html);
updateBasketRel();
}
@ -768,7 +771,7 @@ $(document).ready(function() {
}
kpsul.article_manager.update_data(data);
if (data['addcost']) {
Config.set('addcost_for', data['addcost']['for']);
Config.set('addcost_amount', data['addcost']['amount']);

View file

@ -66,11 +66,13 @@
{% endfor %}
</tbody>
</table>
{% if not perms.kfet.add_order %}
<input type="password" name="KFETPASSWORD">
{% endif %}
{{ 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>
</div>
</div>

View file

@ -42,11 +42,13 @@
{% endfor %}
</tbody>
</table>
{% if not perms.kfet.order_to_inventory %}
<input type="password" name="KFETPASSWORD">
{% endif %}
{{ 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>
</div>
</div>

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

@ -8,7 +8,7 @@ from kfet.decorators import teamkfet_required
urlpatterns = [
url(r'^$', views.Home.as_view(),
name = 'kfet.home'),
name='kfet.home'),
url(r'^login/genericteam$', views.login_genericteam,
name='kfet.login.genericteam'),
url(r'^history$', views.history,
@ -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(r'^accounts/(?P<trigramme>.{3})/stat/operations/list$',
views.AccountStatOperationList.as_view(),
name='kfet.account.stat.operation.list'),
url(r'^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
@ -134,6 +125,14 @@ urlpatterns = [
# 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
url('^articles/$',
teamkfet_required(views.ArticleList.as_view()),
@ -149,20 +148,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

File diff suppressed because it is too large Load diff

View file

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