Merge branch 'master' of git.eleves.ens.fr:cof-geek/gestioCOF

This commit is contained in:
Martin Pépin 2016-09-24 19:05:21 +02:00
commit 32a5f476a9
16 changed files with 484 additions and 64 deletions

View file

@ -0,0 +1 @@
default_app_config = 'kfet.apps.KFetConfig'

14
kfet/apps.py Normal file
View file

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from django.apps import AppConfig
class KFetConfig(AppConfig):
name = 'kfet'
verbose_name = "Application K-Fêt"
def ready(self):
import kfet.signals

View file

@ -18,7 +18,7 @@ class KFetBackend(object):
return None
try:
password_sha256 = hashlib.sha256(password.encode()).hexdigest()
password_sha256 = hashlib.sha256(password.encode('utf-8')).hexdigest()
account = Account.objects.get(password=password_sha256)
user = account.cofprofile.user
except Account.DoesNotExist:

View file

@ -8,7 +8,7 @@ from channels.routing import route, route_class
from kfet import consumers
channel_routing = [
route_class(consumers.KPsul, path=r"^/ws/k-fet/k-psul/$"),
route_class(consumers.KPsul, path=r"^/gestion/ws/k-fet/k-psul/$"),
#route("websocket.connect", ws_kpsul_history_connect),
#route('websocket.receive', ws_message)
]

16
kfet/signals.py Normal file
View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import (absolute_import, division,
print_function, unicode_literals)
from builtins import *
from django.contrib import messages
from django.contrib.auth.signals import user_logged_in
from django.core.urlresolvers import reverse
from django.dispatch import receiver
@receiver(user_logged_in)
def messages_on_login(sender, request, user, **kwargs):
if (not user.username == 'kfet_genericteam'
and user.has_perm('kfet.is_team')):
messages.info(request, '<a href="%s">Connexion en utilisateur partagé ?</a>' % reverse('kfet.login.genericteam'), extra_tags='safe')

View file

@ -263,3 +263,81 @@ textarea {
display:block;
padding:5px 20px;
}
/*
* Messages
*/
.messages .alert {
padding:10px 15px;
margin:0;
border:0;
border-radius:0;
}
.messages .alert-dismissible {
padding-right:35px;
}
.messages .alert .close {
top:0;
right:0;
}
.messages .alert-info {
color:inherit;
background-color:#ccc;
}
.messages .alert-error {
color:inherit;
background-color:rgba(200,16,46,0.2);
}
.messages .alert-success {
color:#333;
}
/*
* Help
*/
.help {
display:none;
position:fixed;
top:50px;
left:0;
right:0;
bottom:0;
overflow:auto;
background:rgba(51,51,51,0.3);
z-index:500;
}
.help-box {
margin-top:30px;
padding-top:1px;
padding-bottom:15px;
background:rgba(51,51,51,0.7);
color:#fff;
}
@media (max-width:768px) {
.help-box {
margin:20px 15px;
}
}
.help h2 {
padding:0 15px 20px;
border-bottom:1px solid #999;
text-align:center;
}
.help .row > div {
padding-right:0;
}
.help h4 {
margin:15px 0;
}

View file

@ -319,6 +319,11 @@ input[type=number]::-webkit-outer-spin-button {
padding-left:20px;
}
#articles_data .article:hover {
background:rgba(200,16,46,0.3);
cursor:pointer;
}
/* Second part - Left - bottom */
.kpsul_middle_left_bottom {

View file

@ -66,8 +66,11 @@ function getErrorsHtml(data) {
content += '</ul>';
}
if ('negative' in data['errors']) {
var url_base = "{% url 'kfet.account.update' LIQ}";
url_base = base_url(0, url_base.length-8);
if (window.location.pathname.startsWith('/gestion/')) {
var url_base = '/gestion/k-fet/accounts/';
} else {
var url_base = '/k-fet/accounts/';
}
for (var i=0; i<data['errors']['negative'].length; i++) {
content += '<a class="btn btn-primary" href="'+url_base+data['errors']['negative'][i]+'/edit" target="_blank" style="width:100%">Autorisation de négatif requise pour '+data['errors']['negative'][i]+'</a>';
}
@ -110,4 +113,3 @@ function requestAuth(data, callback, focus_next = null) {
}
});
}

View file

@ -38,6 +38,7 @@
<li>
<a href="{% url "kfet.account.create.fromclipper" clipper.username %}">
{{ clipper|highlight_clipper:q }}
</a>
</li>
{% endfor %}
{% endif %}

View file

@ -59,7 +59,7 @@
<td>{{ neg.account.name }}</td>
<td class="text-right">{{ neg.account.balance|floatformat:2 }}€</td>
<td class="text-right">
{% if neg.account.balance_offset %}
{% if neg.balance_offset %}
{{ neg.account.real_balance|floatformat:2 }}€
{% endif %}
</td>

View file

@ -40,5 +40,13 @@
{% block content %}{% endblock %}
{% include "kfet/base_footer.html" %}
</div>
<div class="help">
<div class="help-box col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2">
<div class="help-content">
<h2>Aide</h2>
{% block help %}{% endblock %}
</div>
</div>
</div>
</body>
</html>

View file

@ -1,7 +1,16 @@
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
<div class="row messages">
{% for message in messages %}
<div class="col-sm-12 item">
<div class="alert alert-{{ message.level_tag }} alert-dismissible fade in{% if message.tags %} {{ message.tags }}{% endif %}">
<button type="button" class="close" data-dismiss="alert"><span aria-hidden="true">&times;</span></button>
{% if 'safe' in message.tags %}
{{ message|safe }}
{% else %}
{{ message }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}

View file

@ -19,8 +19,59 @@
{% block content-header %}{% endblock %}
{% block help %}
<div class="row">
<div class="col-md-6">
<div class="block">
<h4>Opérations</h4>
<div class="row">
<div class="col-xs-3"><b>F3</b></div>
<div class="col-xs-9">Charge</div>
</div>
<div class="row">
<div class="col-xs-3"><b>Shift + F3</b></div>
<div class="col-xs-9">Retrait</div>
</div>
<div class="row">
<div class="col-xs-3"><b>F8</b></div>
<div class="col-xs-9">Edition</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="block">
<h4>Général</h4>
<div class="row">
<div class="col-xs-3"><b>F1</b></div>
<div class="col-xs-9">Reset</div>
</div>
<div class="row">
<div class="col-xs-3"><b>F2</b></div>
<div class="col-xs-9">Reset compte</div>
</div>
<div class="row">
<div class="col-xs-3"><b>Shift + F2</b></div>
<div class="col-xs-9">Reset panier</div>
</div>
<div class="row">
<div class="col-xs-3"><b>F9</b></div>
<div class="col-xs-9">Majoration</div>
</div>
<div class="row">
<div class="col-xs-3"><b>F10</b></div>
<div class="col-xs-9">Hard reset</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block content %}
{% include 'kfet/base_messages.html' %}
<div class="row kpsul_top">
<div class="col-sm-8">
<div class="row" id="account">
@ -69,7 +120,7 @@
<button role="button" class="btn" id="ask_addcost">Major.</button>
</div>
<div id="article_selection">
<input type="text" id="article_autocomplete">
<input type="text" id="article_autocomplete" autocomplete="off">
<input type="number" id="article_number" step="1" min="1">
<input type="hidden" id="article_id" value="">
</div>
@ -198,8 +249,9 @@ $(document).ready(function() {
}
if (account_data['id'] == 0) {
var trigramme = triInput.val().toUpperCase();
var url_base = '{% url 'kfet.account.create' %}'
if (isValidTrigramme(trigramme)) {
buttons += '<a href="/k-fet/accounts/new?trigramme='+trigramme+'" class="btn btn-primary" target="_blank" title="Créer"><span class="glyphicon glyphicon-plus"></span></a>';
buttons += '<a href="'+url_base+'?trigramme='+trigramme+'" class="btn btn-primary" target="_blank" title="Créer"><span class="glyphicon glyphicon-plus"></span></a>';
}
}
account_container.find('.buttons').html(buttons);
@ -504,17 +556,17 @@ $(document).ready(function() {
function addArticle(article) {
var article_html = $(article_default_html);
article_html.attr('data-article', article['id']);
article_html.attr('data-category', article['category_id']);
article_html.attr('id', 'data-article-'+article['id']);
article_html.addClass('data-category-'+article['category_id']);
for (var elem in article) {
article_html.find('.'+elem).text(article[elem])
}
article_html.find('.price').text(amountToUKF(article['price'], false));
var category_html = articles_container
.find('.category[data-category='+article['category_id']+']');
.find('#data-category-'+article['category_id']);
if (category_html.length == 0) {
category_html = $(article_category_default_html);
category_html.attr('data-category', article['category_id']);
category_html.attr('id', 'data-category-'+article['category_id']);
category_html.find('td').text(article['category__name']);
var added = false;
articles_container.find('.category').each(function() {
@ -526,16 +578,14 @@ $(document).ready(function() {
});
if (!added) articles_container.append(category_html);
}
var added = false;
var $after = articles_container.find('#data-category-'+article['category_id']);
articles_container
.find('.article[data-category='+article['category_id']+']').each(function() {
if (article['name'].toLowerCase < $('.name', this).text().toLowerCase()) {
$(this).before(article_html);
added = true;
.find('.article.data-category-'+article['category_id']).each(function() {
if (article['name'].toLowerCase < $('.name', this).text().toLowerCase())
return false;
}
$after = $(this);
});
if (!added) articles_container.find('.category[data-category='+article['category_id']+']').after(article_html);
$after.after(article_html);
// Pour l'autocomplétion
articlesList.push([article['name'],article['id'],article['category_id'],article['price']]);
}
@ -597,17 +647,17 @@ $(document).ready(function() {
var categories_to_display = [];
for (var i=0; i<articlesList.length; i++) {
if (array.indexOf(articlesList[i]) > -1) {
articles_container.find('[data-article='+articlesList[i][1]+']').show();
articles_container.find('#data-article-'+articlesList[i][1]).show();
if (categories_to_display.indexOf(articlesList[i][2]) == -1)
categories_to_display.push(articlesList[i][2]);
} else {
articles_container.find('[data-article='+articlesList[i][1]+']').hide();
articles_container.find('#data-article-'+articlesList[i][1]).hide();
}
}
articles_container.find('.category').hide();
for (var i=0; i<categories_to_display.length; i++) {
articles_container
.find('.category[data-category='+categories_to_display[i]+']')
.find('#data-category-'+categories_to_display[i])
.show();
}
}
@ -635,7 +685,13 @@ $(document).ready(function() {
return false;
}
articleSelect.on('keypress', function(e) {
// A utiliser après la sélection d'un article
function goToArticleNb() {
articleNb.val('1');
articleNb.focus().select();
}
articleSelect.on('keydown', function(e) {
var text = articleSelect.val();
// Comportement normal pour ces touches
if (normalKeys.test(e.keyCode) || e.ctrlKey) {
@ -648,16 +704,28 @@ $(document).ready(function() {
articleSelect.val('');
}
return true;
} else if (e.charCode !== 0) {
if (updateMatchedArticles(text+e.key)) {
articleNb.val('1');
articleNb.focus().select();
}
return false;
}
if (updateMatchedArticles(text+e.key))
goToArticleNb();
return false;
});
function getArticleId($article) {
return $article.attr('id').split('-')[2];
}
function getArticleName($article) {
return $article.find('.name').text();
}
// Sélection des articles à la souris/tactile
articles_container.on('click', '.article', function() {
articleId.val(getArticleId($(this)));
articleSelect.val(getArticleName($(this)));
displayMatchedArticles(articlesList);
goToArticleNb();
});
function is_nb_ok(nb) {
return /^[0-9]+$/.test(nb) && nb > 0 && nb <= 24;
}
@ -1088,7 +1156,9 @@ $(document).ready(function() {
websocket_msg_default = {'opegroups':[],'opes':[],'checkouts':[],'articles':[]}
var websocket_protocol = window.location.protocol == 'https:' ? 'wss' : 'ws';
socket = new ReconnectingWebSocket(websocket_protocol+"://" + window.location.host + "/ws/k-fet/k-psul/");
var location_host = window.location.host;
var location_url = window.location.pathname.startsWith('/gestion/') ? location_host + '/gestion' : location_host;
socket = new ReconnectingWebSocket(websocket_protocol+"://" + location_url + "/ws/k-fet/k-psul/");
socket.onmessage = function(e) {
data = $.extend({}, websocket_msg_default, JSON.parse(e.data));
@ -1139,7 +1209,7 @@ $(document).ready(function() {
function hardReset(give_tri_focus=true) {
coolReset(give_tri_focus);
resetCheckout();
checkoutInput.trigger('change');
resetArticles();
khistory.reset();
resetSettings();
@ -1163,6 +1233,16 @@ $(document).ready(function() {
$(document).on('keydown', function(e) {
switch (e.keyCode) {
case 27:
// Escape - Hide help
$('.help').hide('fast');
return false;
case 72:
if (e.ctrlKey) {
// Ctrl+H - Display help
$('.help').show('fast');
}
return false;
case 112:
// F1 - Cool reset
coolReset();

View file

@ -1,4 +1,13 @@
{% extends 'kfet/base.html' %}
{% load staticfiles %}
{% block extra_head %}
<link rel="stylesheet" style="text/css" href="{% static 'kfet/css/jquery-ui.min.css' %}">
<script type="text/javascript" src="{% static 'kfet/js/js.cookie.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-ui.min.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/jquery-confirm.js' %}"></script>
<script type="text/javascript" src="{% static 'kfet/js/kfet.js' %}"></script>
{% endblock %}
{% block title %}Transferts{% endblock %}
{% block content-header-title %}Transferts{% endblock %}
@ -24,13 +33,13 @@
<h2>Liste des transferts</h2>
<div id="history">
{% for transfergroup in transfergroups %}
<div class="opegroup">
<div class="opegroup transfergroup" data-transfergroup="{{ transfergroup.pk }}">
<span>{{ transfergroup.at }}</span>
<span>{{ transfergroup.valid_by.trigramme }}</span>
<span>{{ transfergroup.comment }}</span>
</div>
{% for transfer in transfergroup.transfers.all %}
<div class="ope transfer">
<div class="ope transfer{% if transfer.canceled_at %} canceled{% endif %}" data-transfer="{{ transfer.pk }}" data-transfergroup="{{ transfergroup.pk }}">
<span class="amount">{{ transfer.amount }} €</span>
<span class="from_acc">{{ transfer.from_acc.trigramme }}</span>
<span class="glyphicon glyphicon-arrow-right"></span>
@ -44,4 +53,93 @@
</div>
</div>
<script type="text/javascript">
$(document).ready(function() {
lock = 0;
function displayErrors(html) {
$.alert({
title: 'Erreurs',
content: html,
backgroundDismiss: true,
animation: 'top',
closeAnimation: 'bottom',
keyboardEnabled: true,
});
}
function cancelTransfers(transfers_array, password = '') {
if (lock == 1)
return false
lock = 1;
var data = { 'transfers' : transfers_array }
$.ajax({
dataType: "json",
url : "{% url 'kfet.transfers.cancel' %}",
method : "POST",
data : data,
beforeSend: function ($xhr) {
$xhr.setRequestHeader("X-CSRFToken", csrftoken);
if (password != '')
$xhr.setRequestHeader("KFetPassword", password);
},
})
.done(function(data) {
for (var i=0; i<data['canceled'].length; i++) {
$('#history').find('.transfer[data-transfer='+data['canceled'][i]+']')
.addClass('canceled');
}
$('#history').find('.ui-selected').removeClass('ui-selected');
lock = 0;
})
.fail(function($xhr) {
var data = $xhr.responseJSON;
switch ($xhr.status) {
case 403:
requestAuth(data, function(password) {
cancelTransfers(transfers_array, password);
});
break;
case 400:
displayErrors(getErrorsHtml(data));
break;
}
lock = 0;
});
}
$('#history').selectable({
filter: 'div.transfergroup, div.transfer',
selected: function(e, ui) {
$(ui.selected).each(function() {
if ($(this).hasClass('transfergroup')) {
var transfergroup = $(this).attr('data-transfergroup');
$(this).siblings('.ope').filter(function() {
return $(this).attr('data-transfergroup') == transfergroup
}).addClass('ui-selected');
}
});
},
});
$(document).on('keydown', function (e) {
if (e.keyCode == 46) {
// DEL (Suppr)
var transfers_to_cancel = [];
$('#history').find('.transfer.ui-selected').each(function () {
transfers_to_cancel.push($(this).attr('data-transfer'));
});
if (transfers_to_cancel.length > 0)
cancelTransfers(transfers_to_cancel);
}
});
});
</script>
{% endblock %}

View file

@ -170,6 +170,8 @@ urlpatterns = [
name = 'kfet.transfers.create'),
url(r'^transfers/perform$', views.perform_transfers,
name = 'kfet.transfers.perform'),
url(r'^transfers/cancel$', views.cancel_transfers,
name = 'kfet.transfers.cancel'),
# -----
# Inventories urls

View file

@ -219,7 +219,7 @@ def account_form_set_readonly_fields(user_form, cof_form):
def get_account_create_forms(request=None, username=None, login_clipper=None):
user = None
clipper = None
if login_clipper and not username:
if login_clipper and (login_clipper == username or not username):
# à partir d'un clipper
# le user associé à ce clipper ne devrait pas encore exister
clipper = get_object_or_404(Clipper, username = login_clipper)
@ -399,7 +399,7 @@ def account_update(request, trigramme):
if (request.user.has_perm('kfet.change_account_password')
and pwd_form.is_valid()):
pwd = pwd_form.cleaned_data['pwd1']
pwd_sha256 = hashlib.sha256(pwd.encode()).hexdigest()
pwd_sha256 = hashlib.sha256(pwd.encode('utf-8')).hexdigest()
Account.objects.filter(pk=account.pk).update(
password = pwd_sha256)
messages.success(request, 'Mot de passe mis à jour')
@ -796,7 +796,15 @@ def kpsul(request):
data = {}
data['operationgroup_form'] = KPsulOperationGroupForm()
data['trigramme_form'] = KPsulAccountForm()
data['checkout_form'] = KPsulCheckoutForm()
initial = {}
try:
checkout = Checkout.objects.filter(
is_protected=False, valid_from__lte=timezone.now(),
valid_to__gte=timezone.now()).get()
initial['checkout'] = checkout
except (Checkout.DoesNotExist, Checkout.MultipleObjectsReturned):
pass
data['checkout_form'] = KPsulCheckoutForm(initial=initial)
operation_formset = KPsulOperationFormSet(queryset=Operation.objects.none())
data['operation_formset'] = operation_formset
return render(request, 'kfet/kpsul.html', data)
@ -827,28 +835,27 @@ def kpsul_checkout_data(request):
pk = request.POST.get('pk', 0)
if not pk:
pk = 0
try:
data = (Checkout.objects
.annotate(
last_statement_by_first_name=F('statements__by__cofprofile__user__first_name'),
last_statement_by_last_name=F('statements__by__cofprofile__user__last_name'),
last_statement_by_trigramme=F('statements__by__trigramme'),
last_statement_balance=F('statements__balance_new'),
last_statement_at=F('statements__at'))
.values(
'id', 'name', 'balance', 'valid_from', 'valid_to',
'last_statement_balance', 'last_statement_at',
'last_statement_by_trigramme', 'last_statement_by_last_name',
'last_statement_by_first_name')
.select_related(
'statements'
'statements__by',
'statements__by__cofprofile__user')
.filter(pk=pk)
.order_by('statements__at')
.last())
except Checkout.DoesNotExist:
raise http404
data = (Checkout.objects
.annotate(
last_statement_by_first_name=F('statements__by__cofprofile__user__first_name'),
last_statement_by_last_name=F('statements__by__cofprofile__user__last_name'),
last_statement_by_trigramme=F('statements__by__trigramme'),
last_statement_balance=F('statements__balance_new'),
last_statement_at=F('statements__at'))
.values(
'id', 'name', 'balance', 'valid_from', 'valid_to',
'last_statement_balance', 'last_statement_at',
'last_statement_by_trigramme', 'last_statement_by_last_name',
'last_statement_by_first_name')
.select_related(
'statements'
'statements__by',
'statements__by__cofprofile__user')
.filter(pk=pk)
.order_by('statements__at')
.last())
if data is None:
raise Http404
return JsonResponse(data)
@teamkfet_required
@ -1402,7 +1409,7 @@ def perform_transfers(request):
transfers = transfer_formset.save(commit = False)
# Initializing vars
required_perms = set('kfet.add_transfer') # Required perms to perform all transfers
required_perms = set(['kfet.add_transfer']) # Required perms to perform all transfers
to_accounts_balances = defaultdict(lambda:0) # For balances of accounts
for transfer in transfers:
@ -1468,6 +1475,105 @@ def perform_transfers(request):
return JsonResponse(data)
@teamkfet_required
def cancel_transfers(request):
# Pour la réponse
data = { 'canceled': [], 'warnings': {}, 'errors': {}}
# Checking if BAD REQUEST (transfers_pk not int or not existing)
try:
# Set pour virer les doublons
transfers_post = set(map(int, filter(None, request.POST.getlist('transfers[]', []))))
except ValueError:
return JsonResponse(data, status=400)
transfers_all = (
Transfer.objects
.select_related('group', 'from_acc', 'from_acc__negative',
'to_acc', 'to_acc__negative')
.filter(pk__in=transfers_post))
transfers_pk = [ transfer.pk for transfer in transfers_all ]
transfers_notexisting = [ transfer for transfer in transfers_post
if transfer not in transfers_pk ]
if transfers_notexisting:
data['errors']['transfers_notexisting'] = transfers_notexisting
return JsonResponse(data, status=400)
transfers_already_canceled = [] # Déjà annulée
transfers = [] # Pas déjà annulée
required_perms = set()
stop_all = False
cancel_duration = Settings.CANCEL_DURATION()
to_accounts_balances = defaultdict(lambda:0) # Modifs à faire sur les balances des comptes
for transfer in transfers_all:
if transfer.canceled_at:
# Transfert déjà annulé, va pour un warning en Response
transfers_already_canceled.append(transfer.pk)
else:
transfers.append(transfer.pk)
# Si transfer il y a plus de CANCEL_DURATION, permission requise
if transfer.group.at + cancel_duration < timezone.now():
required_perms.add('kfet.cancel_old_operations')
# Calcul de toutes modifs à faire en cas de validation
# Pour les balances de comptes
to_accounts_balances[transfer.from_acc] += transfer.amount
to_accounts_balances[transfer.to_acc] += -transfer.amount
if not transfers:
data['warnings']['already_canceled'] = transfers_already_canceled
return JsonResponse(data)
negative_accounts = []
# Checking permissions or stop
for account in to_accounts_balances:
(perms, stop) = account.perms_to_perform_operation(
amount = to_accounts_balances[account])
required_perms |= perms
stop_all = stop_all or stop
if stop:
negative_accounts.append(account.trigramme)
print(required_perms)
print(request.user.get_all_permissions())
if stop_all or not request.user.has_perms(required_perms):
missing_perms = get_missing_perms(required_perms, request.user)
if missing_perms:
data['errors']['missing_perms'] = missing_perms
if stop_all:
data['errors']['negative'] = negative_accounts
return JsonResponse(data, status=403)
canceled_by = required_perms and request.user.profile.account_kfet or None
canceled_at = timezone.now()
with transaction.atomic():
(Transfer.objects.filter(pk__in=transfers)
.update(canceled_by=canceled_by, canceled_at=canceled_at))
for account in to_accounts_balances:
Account.objects.filter(pk=account.pk).update(
balance = F('balance') + to_accounts_balances[account])
account.refresh_from_db()
if account.balance < 0:
if hasattr(account, 'negative'):
if not account.negative.start:
account.negative.start = timezone.now()
account.negative.save()
else:
negative = AccountNegative(
account = account, start = timezone.now())
negative.save()
elif (hasattr(account, 'negative')
and not account.negative.balance_offset):
account.negative.delete()
data['canceled'] = transfers
if transfers_already_canceled:
data['warnings']['already_canceled'] = transfers_already_canceled
return JsonResponse(data)
class InventoryList(ListView):
queryset = (Inventory.objects
.select_related('by', 'order')