diff --git a/kfet/static/kfet/css/history.css b/kfet/static/kfet/css/history.css index 976f5782..0e75741a 100644 --- a/kfet/static/kfet/css/history.css +++ b/kfet/static/kfet/css/history.css @@ -40,6 +40,11 @@ width:90px; } +#history .opegroup .infos { + text-align:center; + width:145px; +} + #history .opegroup .valid_by { padding-left:20px } @@ -67,6 +72,10 @@ text-align:right; } +#history .ope .glyphicon { + padding-left:15px; +} + #history .ope .infos2 { padding-left:15px; } @@ -84,11 +93,11 @@ color:#FFF; } -#history .ope.canceled, #history .transfer.canceled { +#history [canceled="true"] { color:#444; } -#history .ope.canceled::before, #history.transfer.canceled::before { +#history [canceled="true"]::before { position: absolute; content: ' '; width:100%; diff --git a/kfet/static/kfet/js/history.js b/kfet/static/kfet/js/history.js index 8559f050..41c2f28f 100644 --- a/kfet/static/kfet/js/history.js +++ b/kfet/static/kfet/js/history.js @@ -1,148 +1,215 @@ -function KHistory(options={}) { - $.extend(this, KHistory.default_options, options); +var cancelHistory = new Event("cancel_done"); - this.$container = $(this.container); +class KHistory { - this.reset = function() { - this.$container.html(''); - }; + static get default_options() { + return { + 'templates': { + 'purchase': '
', + 'specialope': '
', + 'opegroup': '
', + 'transfergroup': '
', + 'day': '
', + 'transfer': '
', + }, - this.addOpeGroup = function(opegroup) { - var $day = this._getOrCreateDay(opegroup['at']); - var $opegroup = this._opeGroupHtml(opegroup); + 'api_options': { + from: moment().subtract(1, 'days').format('YYYY-MM-DD HH:mm:ss'), + }, - $day.after($opegroup); + }; + } + + constructor(options) { + var all_options = $.extend({}, this.constructor.default_options, options); + this.api_options = all_options.api_options; - var trigramme = opegroup['on_acc_trigramme']; - var is_cof = opegroup['is_cof']; - for (var i=0; i this.update_data(data)); + + var templates = all_options.templates + if (all_options.no_trigramme) + templates['opegroup'] = + '
'; + + this.display = new ForestDisplay(this._$container, templates, this.data); + + this._init_events(); } - this._opeHtml = function(ope, is_cof, trigramme) { - var $ope_html = $(this.template_ope); - var parsed_amount = parseFloat(ope['amount']); - var amount = amountDisplay(parsed_amount, is_cof, trigramme); - var infos1 = '', infos2 = ''; + fetch(api_options) { + this.data.clear(); - if (ope['type'] == 'purchase') { - infos1 = ope['article_nb']; - infos2 = ope['article__name']; - } else { - infos1 = parsed_amount.toFixed(2)+'€'; - switch (ope['type']) { - case 'initial': - infos2 = 'Initial'; - break; - case 'withdraw': - infos2 = 'Retrait'; - break; - case 'deposit': - infos2 = 'Charge'; - break; - case 'edit': - infos2 = 'Édition'; - break; + $.extend(this.api_options, api_options); + + this.data.fromAPI(this.api_options) + .done( () => this.display_data() ); + + } + + display_data() { + this.display.clear(); + this.display.render(this.data); + var nb_opes = this._$container.find('.ope[canceled="false"]').length; + this._$nb_opes.text(nb_opes); + } + + _init_events() { + var that = this; + $(document).on('keydown', function(e) { + if (e.keyCode == 46 && that.selection) { + //DEL key ; we delete the selected operations (if any) + var to_cancel = that.selection.get_selected(); + + if (to_cancel['opes'].length > 0 || to_cancel['transfers'].length > 0) + that.cancel_operations(to_cancel); + } + }); + } + + cancel_operations(to_cancel) { + var that = this ; + var on_success = function() { + if (that.selection) + that.selection.reset() ; + $(that).trigger("cancel_done"); + } + + api_with_auth({ + url: Urls['kfet.kpsul.cancel_operations'](), + data: to_cancel, + on_success: on_success, + }) + } + + add_node(data) { + var node = this.data.get_or_create(data.modelname, data.content, 0); + this.display.add(node); + } + + update_node(modelname, id, update_data) { + var updated = this.data.update(modelname, id, update_data) + if (!updated) + return false; + + this.display.update(updated); + + return true; + } + + is_valid(opegroup) { + var options = this.api_options; + + if (options.from && dateUTCToParis(opegroup.at).isBefore(moment(options.from))) + return false; + + if (options.to && dateUTCToParis(opegroup.at).isAfter(moment(options.to))) + return false; + + if (options.transfersonly && opegroup.constructor.verbose_name == 'opegroup') + return false; + + if (options.opesonly && opegroup.constructor.verbose_name == 'transfergroup') + return false; + + if (options.accounts && options.accounts.length && + options.accounts.indexOf(opegroup.account_id) < 0) + return false; + + if (options.checkouts && options.checkouts.length && + (opegroup.modelname == 'transfergroup' || + options.checkouts.indexOf(opegroup.checkout_id) < 0)) + return false; + + return true; + } + + update_data(data) { + var opegroups = data['opegroups']; + var opes = data['opes']; + + for (let ope of opes) { + if (ope['cancellation']) { + var update_data = { + 'canceled_at': ope.canceled_at, + 'canceled_by': ope.canceled_by, + }; + if (ope.modelname === 'ope') { + this.update_node('purchase', ope.id, update_data) + || this.update_node('specialope', ope.id, update_data); + } else if (ope.modelname === 'transfer') { + this.update_node('transfer', ope.id, update_data); + } } } - $ope_html - .data('ope', ope['id']) - .find('.amount').text(amount).end() - .find('.infos1').text(infos1).end() - .find('.infos2').text(infos2).end(); + for (let opegroup of opegroups) { + if (opegroup['cancellation']) { + var update_data = { 'amount': opegroup.amount }; + this.update_node('opegroup', opegroup.id, update_data); + } - var addcost_for = ope['addcost_for__trigramme']; - if (addcost_for) { - var addcost_amount = parseFloat(ope['addcost_amount']); - $ope_html.find('.addcost').text('('+amountDisplay(addcost_amount, is_cof)+'UKF pour '+addcost_for+')'); + if (opegroup['add'] && this.is_valid(opegroup)) { + this.add_node(opegroup); + } } - if (ope['canceled_at']) - this.cancelOpe(ope, $ope_html); - - return $ope_html; + var nb_opes = this._$container.find('.ope[canceled="false"]').length; + $('#nb_opes').text(nb_opes); } - - this.cancelOpe = function(ope, $ope = null) { - if (!$ope) - $ope = this.findOpe(ope['id']); - - var cancel = 'Annulé'; - var canceled_at = dateUTCToParis(ope['canceled_at']); - if (ope['canceled_by__trigramme']) - cancel += ' par '+ope['canceled_by__trigramme']; - cancel += ' le '+canceled_at.format('DD/MM/YY à HH:mm:ss'); - - $ope.addClass('canceled').find('.canceled').text(cancel); - } - - this._opeGroupHtml = function(opegroup) { - var $opegroup_html = $(this.template_opegroup); - - var at = dateUTCToParis(opegroup['at']).format('HH:mm:ss'); - var trigramme = opegroup['on_acc__trigramme']; - var amount = amountDisplay( - parseFloat(opegroup['amount']), opegroup['is_cof'], trigramme); - var comment = opegroup['comment'] || ''; - - $opegroup_html - .data('opegroup', opegroup['id']) - .find('.time').text(at).end() - .find('.amount').text(amount).end() - .find('.comment').text(comment).end() - .find('.trigramme').text(trigramme).end(); - - if (!this.display_trigramme) - $opegroup_html.find('.trigramme').remove(); - - if (opegroup['valid_by__trigramme']) - $opegroup_html.find('.valid_by').text('Par '+opegroup['valid_by__trigramme']); - - return $opegroup_html; - } - - this._getOrCreateDay = function(date) { - var at = dateUTCToParis(date); - var at_ser = at.format('YYYY-MM-DD'); - var $day = this.$container.find('.day').filter(function() { - return $(this).data('date') == at_ser - }); - if ($day.length == 1) - return $day; - var $day = $(this.template_day).prependTo(this.$container); - return $day.data('date', at_ser).text(at.format('D MMMM')); - } - - this.findOpeGroup = function(id) { - return this.$container.find('.opegroup').filter(function() { - return $(this).data('opegroup') == id - }); - } - - this.findOpe = function(id) { - return this.$container.find('.ope').filter(function() { - return $(this).data('ope') == id - }); - } - - this.cancelOpeGroup = function(opegroup) { - var $opegroup = this.findOpeGroup(opegroup['id']); - var trigramme = $opegroup.find('.trigramme').text(); - var amount = amountDisplay( - parseFloat(opegroup['amount'], opegroup['is_cof'], trigramme)); - $opegroup.find('.amount').text(amount); - } - } -KHistory.default_options = { - container: '#history', - template_day: '
', - template_opegroup: '
', - template_ope: '
', - display_trigramme: true, +class KHistorySelection { + + constructor(history) { + this._$container = history._$container; + this._init(); + } + + _init() { + this._$container.selectable({ + filter: 'div.opegroup, div.ope', + selected: function(e, ui) { + $(ui.selected).each(function() { + if ($(this).hasClass('opegroup')) { + $(this).parent().find('.ope').addClass('ui-selected'); + } + }); + }, + unselected: function(e, ui) { + $(ui.unselected).each(function() { + if ($(this).hasClass('opegroup')) { + $(this).parent().find('.ope').removeClass('ui-selected'); + } + }); + }, + }); + } + + get_selected() { + var selected = {'transfers': [], 'opes': [],}; + this._$container.find('.ope.ui-selected').each(function() { + var [type, id] = $(this).parent().attr('id').split('-'); + + if (type === 'transfer') + selected['transfers'].push(id); + else + selected['opes'].push(id); + }); + + return selected; + } + + reset() { + this._$container.find('.ui-selected') + .removeClass('.ui-selected'); + } } diff --git a/kfet/static/kfet/js/kfet.api.js b/kfet/static/kfet/js/kfet.api.js index 78289fa3..ae0d9bd1 100644 --- a/kfet/static/kfet/js/kfet.api.js +++ b/kfet/static/kfet/js/kfet.api.js @@ -26,7 +26,18 @@ * Models without API support: * {@link Models.Statement}, * {@link Models.ArticleCategory}, - * {@link Models.Article}. + * {@link Models.Article}, + * {@link Models.HistoryGroup}, + * {@link Models.OperationGroup}, + * {@link Models.TransferGroup}, + * {@link Models.Operation}, + * {@link Models.Purchase}, + * {@link Models.SpecialOperation}, + * {@link Models.Transfer}. + *
+ * Implementations of ModelForest: + * {@link Models.ArticleList} + * {@link Models.OperationList} * * @namespace Models */ @@ -120,11 +131,10 @@ class ModelObject { } /** - * Returns a string value for the model, to use in comparisons - * @see {@link Models.TreeNode.compare|TreeNode.compare} + * Compares two ModelObject instances. */ - comparevalue() { - return this.id.toString(); + static compare(a, b) { + return a.id - b.id; } } @@ -505,15 +515,11 @@ class Article extends ModelObject { /** * Default values for Article model instances. - * @default { 'id': 0, 'name': '', 'price': 0, 'stock': 0, - * 'category': new ArticleCategory() } + * @default { 'id': 0, 'name': '', 'price': 0, 'stock': 0 } * @see {@link Models.ModelObject.default_data|ModelObject.default_data} */ static get default_data() { - return { - 'id': 0, 'name': '', 'price': 0, 'stock': 0, - 'category': new ArticleCategory(), - }; + return { 'id': 0, 'name': '', 'price': 0, 'stock': 0 }; } /** @@ -543,7 +549,364 @@ 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); + } +} + +/** + * Day model. Cannot be accessed through API. + * @extends Model.ModelObject + * @memberof Models + */ +class Day extends ModelObject { + + /** + * Properties associated to a day. + * @default ['id', 'date'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { return ['id', 'at', 'opegroups'] } + + /** + * Default values for Day model instances + * @default {'id': '', 'date': moment()} + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { return {'id': '', 'at': moment(), 'opegroups': []}; } + + /** + * Verbose name for Article model + * @default 'day' + */ + static get verbose_name() { return 'day'; } + + from(data) { + super.from(data); + this.id = this.at.format("YYYYMMDD"); + } + + /** + * @default {@link Formatters.DayFormatter} + */ + formatter() { + return DayFormatter; + } + + /** + * Comparison function between Day model instances. + * @see {@link Models.ModelObject.compare|ModelObject.compare} + */ + static compare (a, b) { + //Days are sorted by most recent first + if (a.at < b.at) return 1; + else if (a.at > b.at) return -1; + else return 0; + } + + //Parse date and round it + get at() { return this._at; } + set at(v) { this._at = dateUTCToParis(v).startOf('date'); } +} + +/** + * HistoryGroup model. Should not be used directly. + * @extends Models.ModelObject + * @memberof Models + */ +class HistoryGroup extends ModelObject { + + /** + * Properties assowiated to HistoryGroup instances. + * @default ['id', 'at', 'comment', 'valid_by'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { + return ['id', 'at', 'comment', 'valid_by', 'day']; + } + + /** + * Default values for HistoryGroup model instances + * @default { 'id': 0, 'at': moment(), 'comment': '', + 'valid_by': '' } + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return {'id': 0, 'at': moment(), 'comment': '', + 'valid_by': '', 'day': new Day()}; + } + + /** + * Comparison function between HistoryGroup model instances. + * @see {@link Models.ModelObject.compare|ModelObject.compare} + */ + static compare(a, b) { + //Groups are sorted by most recent first + if (a.at.isBefore(b.at)) return 1; + else if (a.at.isAfter(b.at)) return -1; + else return 0; + } + + // Parse the date to a moment() object + get at() { return this._at; } + set at(v) { this._at = dateUTCToParis(v); } +} + +/** + * OperationGroup model. Cannot be accessed through API. + * @extends Models.HistoryGroup + * @memberof Models + */ +class OperationGroup extends HistoryGroup { + + /** + * Properties associated with an opegroup. + * @default {@link Models.HistoryGroup.props|HistoryGroup.props} + + * ['amount', 'is_cof', 'trigramme'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { + return HistoryGroup.props.concat(['amount', 'is_cof', 'trigramme']); + } + + /** + * Default values for OperationGroup instances. + * @default {@link Models.HistoryGroup.default_data|HistoryGroup.default_data} + + * {'amount': 0, 'is_cof': false, 'trigramme': ''} + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return $.extend({}, HistoryGroup.default_data, + {'amount': 0, 'is_cof': false, 'trigramme': '', + 'opes': []}); + } + + /** + * Verbose name for OperationGroup model + * @default 'opegroup' + */ + static get verbose_name() { return 'opegroup'; } + + + /** + * @default {@link Formatters.OpegroupFormatter} + */ + formatter() { + return OpegroupFormatter; + } + + get amount() { return this._amount; } + set amount(v) { this._amount = floatCheck(v); } +} + +/** + * TransferGroup model. Cannot be accessed through API. + * @extends Models.HistoryGroup + * @memberof Models + */ +class TransferGroup extends HistoryGroup { + + /** + * Default values for OperationGroup instances. + * @default {@link Models.HistoryGroup.default_data|HistoryGroup.default_data} + + * {'transfers': []} + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return $.extend({}, HistoryGroup.default_data, + {'transfers': []}); + } + + /** + * Verbose name for TransferGroup model + * @default 'transfergroup' + */ + static get verbose_name() { return 'transfergroup'; } + + + /** + * @default {@link Formatters.TransferGroupFormatter} + */ + formatter() { + return TransferGroupFormatter; + } +} + +/** + * Operation model. Should not be used directly. + * @extends Models.ModelObject + * @memberof Models + */ +class Operation extends ModelObject { + + /** + * Properties associated to an operation. + * @default ['id', 'amount', 'canceled_at', 'canceled_by'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { + return ['id', 'amount', 'canceled_at', 'canceled_by', 'group']; + } + + /** + * Default values for Operation model instances + * @default {'id': '', 'amount': 0, 'canceled_at': undefined, 'canceled_by': '' } + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return {'id': '', 'amount': 0, 'canceled_at': undefined, 'canceled_by': '', + 'group': new HistoryGroup()}; + } + + get amount() { return this._amount; } + set amount(v) { this._amount = floatCheck(v); } + + get canceled_at() { return this._canceled_at; } + set canceled_at(v) { + if (v) + this._canceled_at = dateUTCToParis(v); + else + this._canceled_at = undefined; + } +} + +/** + * Purchase model. Cannot be accessed through API. + * @extends Models.Operation + * @memberof Models + */ +class Purchase extends Operation { + + /** + * Additional properties for purchases. + * @default {@link Models.Operation.props|Operation.props} + ['article_name', 'article_nb', + * 'addcost_amount', 'addcost_for'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { + return Operation.props.concat( + ['article_name', 'article_nb', 'addcost_amount', 'addcost_for'] + ); + } + + /** + * Default values for Operation model instances + * @default {@link Models.Operation.default_data|Operation.default_data} + { + * 'article_name': '', 'article_nb': 0, + * 'addcost_amount': 0, 'addcost_for': '' + * } + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return $.extend({}, Operation.default_data, { + 'article_name': '', 'article_nb': 0, + 'addcost_amount': 0, 'addcost_for': '' + }); + } + + /** + * Verbose name for Purchase model + * @default 'purchase' + */ + static get verbose_name() { return 'purchase'; } + + + /** + * @default {@link Formatters.PurchaseFormatter} + */ + formatter() { + return PurchaseFormatter; + } +} + +/** + * Special operation model. Cannot be accessed through API. + * @extends Models.Operation + * @memberof Models + */ +class SpecialOperation extends Operation { + + /** + * Additional properties for special operations. + * @default {@link Models.Operation.props|Operation.props} + ['type', 'is_checkout'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { + return Operation.props.concat(['type']); + } + + /** + * Verbose name for SpecialOperation model + * @default 'specialope' + */ + static get verbose_name() { return 'specialope'; } + + + /** + * Verbose names for operation types + * @type {Object} + */ + static get verbose_types() { + return { + 'deposit': 'Charge', + 'withdraw': 'Retrait', + 'edit': 'Édition', + 'initial': 'Initial', + }; + } + + /** + * Default values for SpecialOperation model instances + * @default {@link Models.Operation.default_data|Operation.default_data} + {'type': '', 'is_checkout': false} + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return $.extend({}, Operation.default_data, {'type': ''}); + } + + /** + * @default {@link Formatters.SpecialOpeFormatter} + */ + formatter() { + return SpecialOpeFormatter; + } +} + +/** + * Transfer model. Cannot be accessed through API. + * @exetends Models.Operation + * @memberof Models + */ +class Transfer extends Operation { + + /** + * Additional properties for transfers. + * @default {@link Models.Operation.props|Operation.props} + ['from_acc', 'to_acc'] + * @see {@link Models.ModelObject.props|ModelObject.props} + */ + static get props() { + return Operation.props.concat(['from_acc', 'to_acc']); + } + + /** + * Verbose name for Transfer model + * @default 'transfer' + */ + static get verbose_name() { return 'transfer'; } + + /** + * Default values for Transfer model instances + * @default {@link Models.Operation.default_data|Operation.default_data} + {'from_acc': '', 'to_acc': ''} + * @see {@link Models.ModelObject.default_data|ModelObject.default_data} + */ + static get default_data() { + return $.extend({}, Operation.default_data, {'from_acc': '', 'to_acc': ''}); + } + + /** + * @default {@link Formatters.TransferFormatter} + */ + formatter() { + return TransferFormatter; } } @@ -564,8 +927,15 @@ class ModelForest { * Creates empty instance and populates it with data if given * @param {Object[]} [datalist=[]] */ - constructor(datalist) { - this.from(datalist || []); + constructor(data) { + this.from(data || {}); + } + + /** + * Return true if instance is empty + */ + is_empty() { + return this.roots.length == 0; } /** @@ -589,37 +959,66 @@ class ModelForest { * @param {Object} data * @param {number} direction */ - get_or_create(data, direction) { - var struct_data = this.constructor.structure[data.modelname]; + get_or_create(modelname, data, direction) { + var struct = this.constructor.structure ; + var struct_data = struct[modelname]; var model = struct_data.model; - var existing = this.find(data.modelname, data.content.id); + var existing = this.find(modelname, data.id); if (existing) { return existing; } - var node = new model(data.content); + var node; + if (data instanceof ModelObject) + node = data; + else + node = new model(data); if (direction <= 0) { var parent_name = struct_data.parent; - var parent_data = data.parent; - var parent_struct = this.constructor.structure[parent_name]; - if (parent_data) { - var parent_node = this.get_or_create(parent_data, -1); - node[parent_name] = parent_node; - parent_node[parent_struct.children].push(node); - } else { + if (!parent_name) { this.roots.push(node); + return node; } + + var parent_modelname, parent_data; + // If index, we create it + if (struct_data.index) { + var new_parent = {} + for (let key of struct_data.index.fields) { + new_parent[key] = data[key]; + } + + // We create model in case there are some special fields + parent_data = new struct[struct_data.index.modelname].model(new_parent); + parent_modelname = struct_data.index.modelname; + + } else if (struct_data.related) { + var related = struct_data.related; + var field = data[related.modelname+'__'+related.field]; + + parent_data = this.related[related.modelname].find( (obj) => obj[related.field] === field); + parent_modelname = related.modelname; + + } else { + parent_data = data.parent.content ; + parent_modelname = data.parent.modelname ; + } + + var parent = this.get_or_create(parent_modelname, parent_data, -1); + var parent_childname = struct[parent_modelname].children; + node[parent_name] = parent ; + parent[parent_childname].push(node); } if (direction >= 0) { var child_name = struct_data.children; - var child_struct = this.constructor.structure[child_name]; if (data.children && data.children.length) { for (let child_data of data.children) { - var child = this.get_or_create(child_data, 1); - child[child_struct.parent] = node; + var child = this.get_or_create(child_data.modelname, child_data.content, 1); + var child_parent = struct[child_data.modelname]; + child[child_parent] = node; node[child_name].push(child); } } @@ -632,10 +1031,14 @@ class ModelForest { * Resets then populates the instance with the given data. * @param {Object[]} datalist */ - from(datalist) { + from(data) { this.roots = []; - for (let data of datalist) { - this.get_or_create(data, 0); + if (data.objects) { + this.related = data.related + for (let modelname in data.objects) { + for (let obj_data of data.objects[modelname]) + this.get_or_create(modelname, obj_data, 0); + } } } @@ -643,80 +1046,9 @@ class ModelForest { * Removes all Models.TreeNode from the tree. */ clear() { - this.from([]); + this.from({}); } - /** - * 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 - * @param {Object} [options] Options for element render method - */ - render_element(node, templates, options) { - var modelname = node.constructor.verbose_name; - var struct_data = this.constructor.structure[modelname]; - - var template = templates[modelname]; - var options = options || {} ; - - var $container = $('
'); - $container.attr('id', modelname+'-'+node.id); - - var $rendered = node.display($(template), options); - $container.append($rendered); - - var children = this.get_children(node); - - if (children && children.length) { - if (struct_data.child_sort) - children.sort(struct_data.child_sort); - else - children.sort(children[0].constructor.compare); - - for (let child of children) { - var $child = this.render_element(child, templates, options); - $container.append($child); - } - } - - return $container; - } - - add_to_container($container, node, templates, options) { - var struct = this.constructor.structure; - var existing = this.get_parent(node) ; - var first_missing = node; - - while (existing && !($container.find('#'+existing.modelname+'-'+existing.id))) { - first_missing = existing; - existing = this.get_parent(existing); - } - - var $to_insert = render_element(first_missing, templates, options); - var $insert_in = existing ? $container.find('#'+existing.modelname+'-'+existing.id) - : $container ; - $insert_in.prepend($to_insert); - } - - /** - * Display stored data in container. - * @param {jQuery} $container - * @param {Object} templates Templates to render each model - * @param {Object} [options] Options for element render method - */ - display($container, templates, options) { - if (this.constructor.root_sort) - this.roots.sort(this.constructor.root_sort); - else - this.roots.sort(this.roots[0].constructor.compare); - - for (let root of this.roots) { - $container.append(this.render_element(root, templates, options)); - } - - return $container; - } /** * Performs for each node (in a DFS order) the callback function @@ -767,6 +1099,22 @@ class ModelForest { return result; } + + update(modelname, id, update_data) { + var updated = null ; + + function callback(node) { + if (node.id == id) { + node.update(update_data); + updated = node ; + return true ; + } + } + + this.traverse(modelname, callback); + + return updated; + } } @@ -816,6 +1164,10 @@ class ArticleList extends APIModelForest { 'article': { 'model': Article, 'parent': 'category', + 'related': { + 'modelname': 'category', + 'field': 'id', + } }, 'category': { 'model': ArticleCategory, @@ -826,8 +1178,7 @@ class ArticleList extends APIModelForest { } /** - * Default url to get ArticlList data - * @abstract + * Default url to get ArticleList data * @default django-js-reverse('kfet.kpsul.articles_data') * @see {@link Models.APIModelForest.url_model|APIModelForest.url_model} */ @@ -836,6 +1187,200 @@ class ArticleList extends APIModelForest { } } +/** + * OperationList model. Can be accessed through API. + * @extends Models.APIModelList + * @memberof Models + */ +class OperationList extends APIModelForest { + /** + * Default structure for OperationList instances. + * @default [Operation, OperationGroup, Day] + * @see {@link Models.ModelList.models|ModelList.models} + */ + static get structure() { + return { + 'day': { + 'model': Day, + 'children': 'opegroups' + }, + 'opegroup': { + 'model': OperationGroup, + 'parent': 'day', + 'children': 'opes', + 'index': { + 'modelname': 'day', + 'fields': ["at"] + }, + }, + 'transfergroup': { + 'model': TransferGroup, + 'parent': 'day', + 'children': 'transfers', + 'index': { + 'modelname': 'day', + 'fields': ["at"] + }, + }, + 'purchase': { + 'model': Purchase, + 'parent': 'opegroup', + 'related': { + 'modelname': 'opegroup', + 'field': 'id', + }, + }, + 'specialope': { + 'model': SpecialOperation, + 'parent': 'opegroup', + 'related': { + 'modelname': 'opegroup', + 'field': 'id', + }, + }, + 'transfer': { + 'model': Transfer, + 'parent': 'transfergroup', + 'related': { + 'modelname': 'transfergroup', + 'field': 'id', + }, + }, + }; + } + + /** + * Default url to get OperationList data. + * @default django-js-reverse('kfet.history.json') + * @see {@link Models.APIModelList.url_model|APIModelList.url_model} + */ + static get url_model() { + return Urls['kfet.history.json'](); + } + + constructor() { + super(); + this.root_sort = Day; + } +} + + +class ForestDisplay { + + constructor($container, templates, data) { + this._templates = templates ; + this._$container = $container; + this.data = data || new ModelForest(); + } + + + /** + * 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 + * @param {Object} [options] Options for element render method + */ + render_element(node, options) { + var modelname = node.constructor.verbose_name; + var struct_data = this.data.constructor.structure[modelname]; + + var template = this._templates[modelname]; + var options = options || {} ; + + var $container = $('
'); + $container.attr('id', modelname+'-'+node.id); + + var $rendered = node.display($(template), options); + $container.append($rendered); + + var children = this.data.get_children(node); + + if (children && children.length) { + if (struct_data.child_sort) + children.sort(struct_data.child_sort); + else + children.sort(children[0].constructor.compare); + + var $child_div = $('
'); + for (let child of children) { + var $child = this.render_element(child, options); + $child_div.append($child); + } + $container.append($child_div); + } + + return $container; + } + + + /** + * Renders node and adds it to the container.
+ * Assumes that the inserted node is the 'youngest'. + * @param {Models.TreeNode} node + * @param {Object} [options] Options for element render method + */ + add(node, options) { + var struct = this.data.constructor.structure; + var existing = this.data.get_parent(node) ; + var first_missing = node; + + while (existing && !(this._$container.find('#'+existing.modelname+'-'+existing.id))) { + first_missing = existing; + existing = this.data.get_parent(existing); + } + + var $to_insert = this.render_element(first_missing, options); + if (existing) { + this._$container.find('#'+existing.constructor.verbose_name+'-'+existing.id+'>.children') + .prepend($to_insert); + } else { + this._$container.prepend($to_insert); + } + } + + + /** + * Displays stored data in container. + * @param {Object} [options] Options for element render method + */ + render(options) { + var forest = this.data ; + + if (forest.is_empty()) + return; + + if (forest.constructor.root_sort) + forest.roots.sort(forest.constructor.root_sort); + else + forest.roots.sort(forest.roots[0].constructor.compare); + + for (let root of forest.roots) { + this._$container.append(this.render_element(root, options)); + } + + return this._$container; + } + + /** + * Updates the display with same modelname and id as the given data. + * @param {Object} data + */ + update(data) { + var modelname = data.constructor.verbose_name ; + var $new_elt = data.display($(this._templates[modelname]), {}); + + var $to_replace = this._$container.find('#'+modelname+'-'+data.id+'>:first-child'); + $to_replace.replaceWith($new_elt); + } + + /** + * Clears all elements from container + */ + clear() { + this._$container.html(''); + } +} /* ---------- ---------- */ @@ -1094,7 +1639,6 @@ class ArticleCategoryFormatter extends Formatter { * @memberof Formatters * @extends Formatters.Formatter */ - class ArticleFormatter extends Formatter { /** @@ -1109,6 +1653,9 @@ class ArticleFormatter extends Formatter { return ['data_stock']; } + /** + * s.price converted to UKF. + */ static prop_price(s) { return amountToUKF(s.price, true); } @@ -1120,9 +1667,222 @@ class ArticleFormatter extends Formatter { }; } - static attr_data_stock(a) { - if (a.stock > 5) { return this._data_stock.ok; } - else if (a.stock >= -5) { return this._data_stock.low; } - else /* a.stock < -5 */ { return this._data_stock.neg; } + /** + * Value of data_stock attribute based on s.stock. + */ + static attr_data_stock(s) { + if (s.stock > 5) { return this._data_stock.ok; } + else if (s.stock >= -5) { return this._data_stock.low; } + else /* s.stock < -5 */{ return this._data_stock.neg; } + } +} + + +/** + * @extends Formatters.Formatter + * @memberof Formatters + * @todo don't display trigramme in account_read + */ +class HistoryGroupFormatter extends Formatter { + + /** + * a.time formatted as HH:mm:ss + */ + static prop_time(a) { + return a.at.format('HH:mm:ss'); + } + + /** + * valid_by property is displayed only if a.valid_by is nonempty. + */ + static prop_valid_by(a) { + if (a.valid_by) + return 'Par '+a.valid_by; + else + return ''; + } +} + +/** + * @memberof Formatters + * @extends Formatters.HistoryGroupFormatter + */ +class TransferGroupFormatter extends HistoryGroupFormatter { + + /** + * Properties renderable to html. + * @default {@link Models.TransferGroup.props} + ['infos', 'time'] + */ + static get props() { + return TransferGroup.props.concat(['infos', 'time']); + } + + /** + * Generic info for transfergroups. + */ + static prop_infos(a) { + //TODO find better thing to put here + return 'Transferts'; + } +} + +/** + * @memberof Formatters + * @extends Formatters.HistoryGroupFormatter + */ +class OpegroupFormatter extends HistoryGroupFormatter { + + /** + * Properties renderable to html. + * @default {@link Models.OperationGroup.props} + ['time'] + */ + static get props() { + return OperationGroup.props.concat(['time']); + } + + /** + * a.amount displayed according to a.is_cof and a.trigramme values. + */ + static prop_amount(a) { + return amountDisplay(a.amount, a.is_cof, a.trigramme); + } +} + +/** + * @extends Formatters.Formatter + * @memberof Formatters + */ +class DayFormatter extends Formatter { + + /** + * Properties renderable to html. + * @default {@link Models.Day.props} + */ + static get props() { + return ['date']; + } + + /** + * a.date formatted as D MMMM + */ + static prop_date(a) { + return a.at.format('D MMMM'); + } +} + +/** + * @extends Formatters.Formatter + * @memberof Formatters + */ +class OperationFormatter extends Formatter { + + /** + * Properties renderable to html. + * @default ['amount', 'infos1', 'infos2', 'canceled'] + */ + static get props() { + return ['amount', 'infos1', 'infos2', 'canceled']; + } + + static get attrs() { + return ['canceled']; + } + + /** + * a.amount displayed according to a.is_cof and a.trigramme values. + */ + static prop_amount(a) { + return amountDisplay(a.amount, a.group.is_cof, a.group.trigramme); + } + + /** + * canceled property is displayed iff a.canceled_at is defined. + */ + static prop_canceled(a) { + if (a.canceled_at) { + var cancel = 'Annulé'; + if (a.canceled_by) + cancel += ' par '+a.canceled_by; + + cancel += ' le '+a.canceled_at.format('DD/MM/YY à HH:mm:ss'); + return cancel ; + } else { + return ''; + } + } + + static attr_canceled(a) { + return a.canceled_at ? 'true' : 'false' ; + } +} + +/** + * @extends Formatters.OperationFormatter + * @memberof Formatters + */ +class PurchaseFormatter extends OperationFormatter { + + static get props() { + return OperationFormatter.props.concat(['addcost']); + } + + /** + * addcost property is displayed iff a.addcost_for is nonempty. + */ + static prop_addcost(a) { + if (a.addcost_for) { + return '('+amountDisplay(a.addcost_amount, a.is_cof) + +'UKF pour '+a.addcost_for+')'; + } else { + return ''; + } + } + + static prop_infos1(a) { + return a.article_nb; + } + + static prop_infos2(a) { + return a.article_name; + } +} + +/** + * @extends Formatters.OperationFormatter + * @memberof Formatters + */ +class SpecialOpeFormatter extends OperationFormatter { + + /** + * a.amount with two decimal places. + */ + static prop_infos1(a) { + return a.amount.toFixed(2)+'€'; + } + + static prop_infos2(a) { + return SpecialOperation.verbose_types[a.type] || '' ; + } +} + +/** + * @extends Formatters.OperationFormatter + * @memberof Formatters + */ +class TransferFormatter extends OperationFormatter { + + /** + * a.amount with two decimal places. + */ + static prop_amount(a) { + return a.amount.toFixed(2)+'€'; + } + + static prop_infos1(a) { + return a.from_acc; + } + + static prop_infos2(a) { + return a.to_acc; } } diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index 63f9b939..a7e893b6 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -25,6 +25,20 @@ String.prototype.isValidTri = function() { } +/** + * Checks if given argument is float ; + * if not, parses given argument to float value. + * @global + * @return {float} + */ +function floatCheck(v) { + if (typeof v === 'number') + return v; + return Number.parseFloat(v); +} + + + function intCheck(v) { return Number.parseInt(v); } @@ -340,11 +354,24 @@ function getErrorsHtml(data) { return content; } +function displayErrors(html) { + $.alert({ + title: 'Erreurs', + content: html, + backgroundDismiss: true, + animation: 'top', + closeAnimation: 'bottom', + keyboardEnabled: true, + }); +} + var authDialog = new UserDialog({ 'title': 'Authentification requise', 'content': '
', }); + +//Note/TODO: the returned ajax object can be improved by allowing chaining on errors 403/400 function api_with_auth(settings, password) { if (window.api_lock == 1) return false; @@ -357,7 +384,7 @@ function api_with_auth(settings, password) { var on_success = settings.on_success || $.noop ; var on_400 = settings.on_400 || $.noop ; - $.ajax({ + return $.ajax({ dataType: "json", url: url, method: "POST", @@ -392,3 +419,9 @@ function api_with_auth(settings, password) { window.api_lock = 0; }); } + +String.prototype.pluralize = function(count, irreg_plural) { + if (Math.abs(count) >= 2) + return irreg_plural ? irreg_plural : this+'s' ; + return this ; +} diff --git a/kfet/static/kfet/js/kpsul.js b/kfet/static/kfet/js/kpsul.js index e4623e44..e6fbd0c9 100644 --- a/kfet/static/kfet/js/kpsul.js +++ b/kfet/static/kfet/js/kpsul.js @@ -12,6 +12,11 @@ class KPsulManager { this.account_manager = new AccountManager(this); this.checkout_manager = new CheckoutManager(this); this.article_manager = new ArticleManager(this); + this.history = new KHistory({ + api_options: {'opesonly': true}, + }); + + this._init_events(); } reset(soft) { @@ -23,6 +28,7 @@ class KPsulManager { if (!soft) { this.checkout_manager.reset(); this.article_manager.reset_data(); + this.history.fetch(); } } @@ -37,6 +43,14 @@ class KPsulManager { return this; } + _init_events() { + var that = this ; + $(this.history).on("cancel_done", function(e) { + that.reset(true); + that.focus(); + }); + } + } @@ -414,14 +428,19 @@ class ArticleManager { this._$input = $('#article_autocomplete'); this._$nb = $('#article_number'); this._$stock = $('#article_stock'); - this.templates = {'category': '
', - 'article' : '
'} this.selected = new Article() ; - this.list = new ArticleList() ; - this.autocomplete = new ArticleAutocomplete(this); + this.data = new ArticleList() ; + var $container = $('#articles_data'); + var templates = { + 'category': '
', + 'article' : '
' + } ; + this.display = new ForestDisplay($container, templates, this.data); + this.autocomplete = new ArticleAutocomplete(this, $container); this._init_events(); + OperationWebSocket.add_handler(data => this.update_data(data)); } get nb() { @@ -429,7 +448,7 @@ class ArticleManager { } display_list() { - this.list.display(this._$container, this.templates) ; + this.display.render(this.data); } validate(article) { @@ -449,25 +468,18 @@ class ArticleManager { } reset_data() { - this._$container.html(''); - this.list.clear(); - this.list.fromAPI() + this.display.clear(); + this.data.clear(); + this.data.fromAPI() .done( () => this.display_list() ); } - get_article(id) { - return this.list.find('article', id); - } - update_data(data) { for (let article_dict of data.articles) { - var article = this.get_article(article_dict.id); - // For now, article additions are disregarded - if (article) { - article.stock = article_dict.stock; - this._$container.find('#article-'+article.id+' .stock') - .text(article.stock); + var updated = this.data.update('article', article_dict.id, article_dict); + if (updated) { + this.display.update(updated); } } } @@ -496,7 +508,7 @@ class ArticleManager { this._$container.on('click', '.article', function() { var id = $(this).parent().attr('id').split('-')[1]; - var article = that.list.find('article', id); + var article = that.data.find('article', id); if (article) that.validate(article); }); @@ -507,18 +519,26 @@ class ArticleManager { that.reset(); that.focus(); } - if (normalKeys.test(e.keyCode) || arrowKeys.test(e.KeyCode) || e.ctrlKey) { + + if (normalKeys.test(e.keyCode) || arrowKeys.test(e.keyCode) || e.ctrlKey) { if (e.ctrlKey && e.charCode == 65) that._$nb.val(''); return true ; } + if (that.constructor.check_nb(that.nb+e.key)) return true; + return false; }); } + //Note : this function may not be needed after the whole rework + get_article(id) { + return this.data.find('article', id) ; + } + focus() { if (this.is_empty()) this._$input.focus(); @@ -535,9 +555,9 @@ class ArticleManager { class ArticleAutocomplete { - constructor(article_manager) { + constructor(article_manager, $container) { this.manager = article_manager; - this._$container = article_manager._$container ; + this._$container = $container ; this._$input = $('#article_autocomplete'); this.showAll() ; @@ -549,11 +569,12 @@ class ArticleAutocomplete { // 8:Backspace|9:Tab|13:Enter|46:DEL|112-117:F1-6|119-123:F8-F12 var normalKeys = /^(8|9|13|46|112|113|114|115|116|117|119|120|121|122|123)$/; + var arrowKeys = /^(37|38|39|40)$/; this._$input .on('keydown', function(e) { var text = that._$input.val() ; - if (normalKeys.test(e.keyCode) || e.ctrlKey) { + if (normalKeys.test(e.keyCode) || arrowKeys.test(e.keyCode) || e.ctrlKey) { // For the backspace key, we suppose the cursor is at the very end if(e.keyCode == 8) { that.update(text.substring(0, text.length-1), true); @@ -571,7 +592,7 @@ class ArticleAutocomplete { update(prefix, backspace) { this.resetMatch(); - var article_list = this.manager.list ; + var article_list = this.manager.data ; var lower = prefix.toLowerCase() ; var that = this ; @@ -599,7 +620,7 @@ class ArticleAutocomplete { updateDisplay() { var that = this; - this.manager.list.traverse('category', function(category) { + this.manager.data.traverse('category', function(category) { var is_active = false; for (let article of category.articles) { if (that.matching.indexOf(article) != -1) { @@ -634,7 +655,7 @@ class ArticleAutocomplete { showAll() { var that = this; this.resetMatch(); - this.manager.list.traverse('article', function(article) { + this.manager.data.traverse('article', function(article) { that.matching.push(article); }); this.updateDisplay(); diff --git a/kfet/templates/kfet/account_read.html b/kfet/templates/kfet/account_read.html index b55f2b99..58f1f4a9 100644 --- a/kfet/templates/kfet/account_read.html +++ b/kfet/templates/kfet/account_read.html @@ -4,10 +4,16 @@ {% load l10n %} {% block extra_head %} + + + + + + {% if account.user == request.user %} @@ -94,33 +100,11 @@ $(document).ready(function() { diff --git a/kfet/templates/kfet/history.html b/kfet/templates/kfet/history.html index 19ed0332..2075cf30 100644 --- a/kfet/templates/kfet/history.html +++ b/kfet/templates/kfet/history.html @@ -7,6 +7,7 @@ + @@ -38,6 +39,9 @@
Comptes {{ filter_form.accounts }}
+
@@ -51,8 +55,8 @@

Opérations

- -
+
+
@@ -61,9 +65,9 @@ diff --git a/kfet/templates/kfet/kpsul.html b/kfet/templates/kfet/kpsul.html index d568c4f9..e94f5c6c 100644 --- a/kfet/templates/kfet/kpsul.html +++ b/kfet/templates/kfet/kpsul.html @@ -156,9 +156,6 @@ -
-
- @@ -199,21 +196,6 @@ $(document).ready(function() { $('#id_comment').val(''); } - // ----- - // Errors ajax - // ----- - - function displayErrors(html) { - $.alert({ - title: 'Erreurs', - content: html, - backgroundDismiss: true, - animation: 'top', - closeAnimation: 'bottom', - keyboardEnabled: true, - }); - } - // ----- // Perform operations // ----- @@ -246,33 +228,6 @@ $(document).ready(function() { performOperations(); }); - // ----- - // Cancel operations - // ----- - - var cancelButton = $('#cancel_operations'); - var cancelForm = $('#cancel_form'); - - function cancelOperations(opes_array) { - var data = { 'operations' : opes_array } - api_with_auth({ - url: Urls['kfet.kpsul.cancel_operations'](), - data: data, - on_success: function() { - coolReset(); - }, - on_400: function(response) { - displayErrors(getErrorsHtml(response)); - }, - next_focus: kpsul.account_manager, - }); - } - - // Event listeners - cancelButton.on('click', function() { - cancelOperations(); - }); - // ----- // Basket // ----- @@ -311,7 +266,7 @@ $(document).ready(function() { var article_basket_html = $(item_basket_default_html); article_basket_html .attr('data-opeindex', index) - .find('.number').text(nb).end() + .find('.number').text('('+nb+'/'+article.stock+')').end() .find('.name').text(article.name).end() .find('.amount').text(amountToUKF(amount_euro, kpsul.account_manager.account.is_cof)); basket_container.prepend(article_basket_html); @@ -634,29 +589,6 @@ $(document).ready(function() { .find('#id_form-'+opeindex+'-DELETE').prop('checked', !nb); } - // ----- - // History - // ----- - - var khistory = new KHistory(); - - function getHistory() { - var data = { - from: moment().subtract(1, 'days').format('YYYY-MM-DD HH:mm:ss'), - }; - $.ajax({ - dataType: "json", - url : "{% url 'kfet.history.json' %}", - method : "POST", - data : data, - }) - .done(function(data) { - for (var i=0; i 0) - cancelOperations(opes_to_cancel); - } - }); - - // ----- - // Synchronization - // ----- - - OperationWebSocket.add_handler(function(data) { - for (var i=0; i + - + + + + + + {% endblock %} {% block title %}Transferts{% endblock %} @@ -18,6 +24,8 @@
+
+
transferts
@@ -31,23 +39,8 @@

Liste des transferts

-
- {% for transfergroup in transfergroups %} -
- {{ transfergroup.at }} - {{ transfergroup.valid_by.trigramme }} - {{ transfergroup.comment }} -
- {% for transfer in transfergroup.transfers.all %} -
- {{ transfer.amount }} € - {{ transfer.from_acc.trigramme }} - - {{ transfer.to_acc.trigramme }} -
- {% endfor %} - {% endfor %} - +
+
@@ -56,88 +49,11 @@ diff --git a/kfet/urls.py b/kfet/urls.py index 198d5c1d..b3bd1f48 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -203,8 +203,6 @@ 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 diff --git a/kfet/views.py b/kfet/views.py index eae6b1f8..acd9f0a2 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -18,7 +18,7 @@ from django.contrib.auth.models import User, Permission, Group from django.http import JsonResponse, Http404 from django.forms import formset_factory from django.db import transaction -from django.db.models import F, Sum, Prefetch, Count +from django.db.models import Q, F, Sum, Prefetch, Count from django.db.models.functions import Coalesce from django.utils import timezone from django.utils.crypto import get_random_string @@ -1158,31 +1158,47 @@ def kpsul_perform_operations(request): websocket_data = {} websocket_data['opegroups'] = [{ 'add': True, - 'id': operationgroup.pk, - 'amount': operationgroup.amount, - 'checkout__name': operationgroup.checkout.name, - 'at': operationgroup.at, - 'is_cof': operationgroup.is_cof, - 'comment': operationgroup.comment, - 'valid_by__trigramme': (operationgroup.valid_by and - operationgroup.valid_by.trigramme or None), - 'on_acc__trigramme': operationgroup.on_acc.trigramme, - 'opes': [], + 'modelname': 'opegroup', + 'content': { + 'id': operationgroup.pk, + 'amount': operationgroup.amount, + 'at': operationgroup.at, + 'is_cof': operationgroup.is_cof, + 'comment': operationgroup.comment, + 'valid_by': (operationgroup.valid_by and + operationgroup.valid_by.trigramme or None), + 'trigramme': operationgroup.on_acc.trigramme, + # Used to filter websocket updates + 'account_id': operationgroup.on_acc.pk, + 'checkout_id': operationgroup.checkout.pk, + 'children': [], + }, }] - for operation in operations: + for ope in operations: ope_data = { - 'id': operation.pk, 'type': operation.type, - 'amount': operation.amount, - 'addcost_amount': operation.addcost_amount, - 'addcost_for__trigramme': ( - operation.addcost_for and addcost_for.trigramme or None), - 'article__name': ( - operation.article and operation.article.name or None), - 'article_nb': operation.article_nb, - 'group_id': operationgroup.pk, - 'canceled_by__trigramme': None, 'canceled_at': None, + 'content': { + 'id': ope.id, + 'amount': ope.amount, + 'canceled_at': None, + 'canceled_by': None, + }, } - websocket_data['opegroups'][0]['opes'].append(ope_data) + + if ope.type == Operation.PURCHASE: + ope_data['modelname'] = 'purchase' + ope_data['content'].update({ + 'article_name': ope.article.name, + 'article_nb': ope.article_nb, + 'addcost_amount': ope.addcost_amount, + 'addcost_for': + ope.addcost_for and ope.addcost_for.trigramme or None, + }) + else: + ope_data['modelname'] = 'specialope' + ope_data['content'].update({ + 'type': ope.type, + }) + websocket_data['opegroups'][0]['content']['children'].append(ope_data) # Need refresh from db cause we used update on queryset operationgroup.checkout.refresh_from_db() websocket_data['checkouts'] = [{ @@ -1205,37 +1221,63 @@ def kpsul_perform_operations(request): @teamkfet_required def kpsul_cancel_operations(request): # Pour la réponse - data = { 'canceled': [], 'warnings': {}, 'errors': {}} + data = {'canceled': {}, 'warnings': {}, 'errors': {}} # Checking if BAD REQUEST (opes_pk not int or not existing) try: # Set pour virer les doublons - opes_post = set(map(int, filter(None, request.POST.getlist('operations[]', [])))) + opes_post = ( + set(map(int, filter(None, request.POST.getlist('opes[]', [])))) + ) + transfers_post = ( + set(map(int, filter(None, request.POST.getlist('transfers[]', [])))) + ) except ValueError: return JsonResponse(data, status=400) + opes_all = ( Operation.objects .select_related('group', 'group__on_acc', 'group__on_acc__negative') .filter(pk__in=opes_post)) - opes_pk = [ ope.pk for ope in opes_all ] - opes_notexisting = [ ope for ope in opes_post if ope not in opes_pk ] - if opes_notexisting: - data['errors']['opes_notexisting'] = opes_notexisting + opes_pk = [ope.pk for ope in opes_all] + opes_notexisting = [ope for ope in opes_post if ope not in opes_pk] + + 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 or opes_notexisting: + if transfers_notexisting: + data['errors']['transfers_notexisting'] = transfers_notexisting + if opes_notexisting: + data['errors']['opes_notexisting'] = opes_notexisting return JsonResponse(data, status=400) - opes_already_canceled = [] # Déjà annulée - opes = [] # Pas déjà annulée + already_canceled = {} # Opération/Transfert déjà annulé + opes = [] # Pas déjà annulée + transfers = [] required_perms = set() - stop_all = False + + stop_all = False cancel_duration = kfet_config.cancel_duration - to_accounts_balances = defaultdict(lambda:0) # Modifs à faire sur les balances des comptes - to_groups_amounts = defaultdict(lambda:0) # ------ sur les montants des groupes d'opé - to_checkouts_balances = defaultdict(lambda:0) # ------ sur les balances de caisses - to_articles_stocks = defaultdict(lambda:0) # ------ sur les stocks d'articles + # Modifs à faire sur les balances des comptes + to_accounts_balances = defaultdict(lambda: 0) + # ------ sur les montants des groupes d'opé + to_groups_amounts = defaultdict(lambda: 0) + # ------ sur les balances de caisses + to_checkouts_balances = defaultdict(lambda: 0) + # ------ sur les stocks d'articles + to_articles_stocks = defaultdict(lambda: 0) + for ope in opes_all: if ope.canceled_at: # Opération déjà annulée, va pour un warning en Response - opes_already_canceled.append(ope.pk) + already_canceled['opes'].append(ope.pk) else: opes.append(ope.pk) # Si opé il y a plus de CANCEL_DURATION, permission requise @@ -1262,10 +1304,11 @@ def kpsul_cancel_operations(request): # par `.save()`, amount_error est recalculé automatiquement, # ce qui n'est pas le cas en faisant un update sur queryset # TODO ? : Maj les balance_old de relevés pour modifier l'erreur - last_statement = (CheckoutStatement.objects - .filter(checkout=ope.group.checkout) - .order_by('at') - .last()) + last_statement = \ + (CheckoutStatement.objects + .filter(checkout=ope.group.checkout) + .order_by('at') + .last()) if not last_statement or last_statement.at < ope.group.at: if ope.is_checkout: if ope.group.on_acc.is_cash: @@ -1281,23 +1324,41 @@ def kpsul_cancel_operations(request): # Note : si InventoryArticle est maj par .save(), stock_error # est recalculé automatiquement if ope.article and ope.article_nb: - last_stock = (InventoryArticle.objects + last_stock = ( + InventoryArticle.objects .select_related('inventory') .filter(article=ope.article) .order_by('inventory__at') - .last()) + .last() + ) if not last_stock or last_stock.inventory.at < ope.group.at: to_articles_stocks[ope.article] += ope.article_nb - if not opes: - data['warnings']['already_canceled'] = opes_already_canceled + for transfer in transfers_all: + if transfer.canceled_at: + # Transfert déjà annulé, va pour un warning en Response + already_canceled['transfers'].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 opes and not transfers: + data['warnings']['already_canceled'] = 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]) + amount=to_accounts_balances[account]) required_perms |= perms stop_all = stop_all or stop if stop: @@ -1317,6 +1378,10 @@ def kpsul_cancel_operations(request): with transaction.atomic(): (Operation.objects.filter(pk__in=opes) .update(canceled_by=canceled_by, canceled_at=canceled_at)) + + (Transfer.objects.filter(pk__in=transfers) + .update(canceled_by=canceled_by, canceled_at=canceled_at)) + for account in to_accounts_balances: ( Account.objects @@ -1329,20 +1394,22 @@ def kpsul_cancel_operations(request): account.update_negative() for checkout in to_checkouts_balances: Checkout.objects.filter(pk=checkout.pk).update( - balance = F('balance') + to_checkouts_balances[checkout]) + balance=F('balance') + to_checkouts_balances[checkout]) for group in to_groups_amounts: OperationGroup.objects.filter(pk=group.pk).update( - amount = F('amount') + to_groups_amounts[group]) + amount=F('amount') + to_groups_amounts[group]) for article in to_articles_stocks: Article.objects.filter(pk=article.pk).update( - stock = F('stock') + to_articles_stocks[article]) + stock=F('stock') + to_articles_stocks[article]) # Websocket data - websocket_data = { 'opegroups': [], 'opes': [], 'checkouts': [], 'articles': [] } + websocket_data = {'opegroups': [], 'opes': [], + 'checkouts': [], 'articles': []} # Need refresh from db cause we used update on querysets - opegroups_pk = [ opegroup.pk for opegroup in to_groups_amounts ] + opegroups_pk = [opegroup.pk for opegroup in to_groups_amounts] opegroups = (OperationGroup.objects - .values('id','amount','is_cof').filter(pk__in=opegroups_pk)) + .values('id', 'amount', 'is_cof') + .filter(pk__in=opegroups_pk)) for opegroup in opegroups: websocket_data['opegroups'].append({ 'cancellation': True, @@ -1350,24 +1417,35 @@ def kpsul_cancel_operations(request): 'amount': opegroup['amount'], 'is_cof': opegroup['is_cof'], }) - canceled_by__trigramme = canceled_by and canceled_by.trigramme or None + canceled_by = canceled_by and canceled_by.trigramme or None for ope in opes: websocket_data['opes'].append({ 'cancellation': True, + 'modelname': 'ope', 'id': ope, - 'canceled_by__trigramme': canceled_by__trigramme, + 'canceled_by': canceled_by, 'canceled_at': canceled_at, }) + for ope in transfers: + websocket_data['opes'].append({ + 'cancellation': True, + 'modelname': 'transfer', + 'id': ope, + 'canceled_by': canceled_by, + 'canceled_at': canceled_at, + }) + # Need refresh from db cause we used update on querysets - checkouts_pk = [ checkout.pk for checkout in to_checkouts_balances] + checkouts_pk = [checkout.pk for checkout in to_checkouts_balances] checkouts = (Checkout.objects - .values('id', 'balance').filter(pk__in=checkouts_pk)) + .values('id', 'balance') + .filter(pk__in=checkouts_pk)) for checkout in checkouts: websocket_data['checkouts'].append({ 'id': checkout['id'], 'balance': checkout['balance']}) # Need refresh from db cause we used update on querysets - articles_pk = [ article.pk for articles in to_articles_stocks] + articles_pk = [article.pk for articles in to_articles_stocks] articles = Article.objects.values('id', 'stock').filter(pk__in=articles_pk) for article in articles: websocket_data['articles'].append({ @@ -1375,83 +1453,169 @@ def kpsul_cancel_operations(request): 'stock': article['stock']}) consumers.KPsul.group_send('kfet.kpsul', websocket_data) - data['canceled'] = opes - if opes_already_canceled: - data['warnings']['already_canceled'] = opes_already_canceled + data['canceled']['opes'] = opes + data['canceled']['transfers'] = transfers + if already_canceled: + data['warnings']['already_canceled'] = already_canceled return JsonResponse(data) + @login_required def history_json(request): # Récupération des paramètres - from_date = request.POST.get('from', None) - to_date = request.POST.get('to', None) - limit = request.POST.get('limit', None); - checkouts = request.POST.getlist('checkouts[]', None) - accounts = request.POST.getlist('accounts[]', None) + from_date = request.GET.get('from', None) + to_date = request.GET.get('to', None) + checkouts = request.GET.getlist('checkouts[]', None) + accounts = request.GET.getlist('accounts[]', None) + transfers_only = request.GET.get('transfersonly', None) + opes_only = request.GET.get('opesonly', None) + + # Un non-membre de l'équipe n'a que accès à son historique + if not request.user.has_perm('kfet.is_team'): + accounts = [request.user.profile.account] # Construction de la requête (sur les opérations) pour le prefetch - queryset_prefetch = Operation.objects.select_related( - 'canceled_by__trigramme', 'addcost_for__trigramme', - 'article__name') + ope_queryset_prefetch = Operation.objects.select_related( + 'canceled_by', 'addcost_for', + 'article') + ope_prefetch = Prefetch('opes', + queryset=ope_queryset_prefetch) + + transfer_queryset_prefetch = Transfer.objects.select_related( + 'from_acc', 'to_acc', 'canceled_by') + + if accounts: + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc__id__in=accounts) | + Q(to_acc__id__in=accounts)) + + if not request.user.has_perm('kfet.is_team'): + acc = request.user.profile.account_kfet + transfer_queryset_prefetch = transfer_queryset_prefetch.filter( + Q(from_acc=acc) | Q(to_acc=acc)) + + transfer_prefetch = Prefetch('transfers', + queryset=transfer_queryset_prefetch, + to_attr='filtered_transfers') # Construction de la requête principale - opegroups = (OperationGroup.objects - .prefetch_related(Prefetch('opes', queryset = queryset_prefetch)) - .select_related('on_acc__trigramme', 'valid_by__trigramme') - .order_by('at') + opegroups = ( + OperationGroup.objects + .prefetch_related(ope_prefetch) + .select_related('on_acc__trigramme', + 'valid_by__trigramme') + .order_by('at') ) + + transfergroups = ( + TransferGroup.objects + .prefetch_related(transfer_prefetch) + .select_related('valid_by__trigramme') + .order_by('at') + ) + # Application des filtres if from_date: opegroups = opegroups.filter(at__gte=from_date) + transfergroups = transfergroups.filter(at__gte=from_date) if to_date: opegroups = opegroups.filter(at__lt=to_date) + transfergroups = transfergroups.filter(at__lt=to_date) if checkouts: opegroups = opegroups.filter(checkout_id__in=checkouts) + transfergroups = TransferGroup.objects.none() + if transfers_only: + opegroups = OperationGroup.objects.none() + if opes_only: + transfergroups = TransferGroup.objects.none() if accounts: opegroups = opegroups.filter(on_acc_id__in=accounts) - # Un non-membre de l'équipe n'a que accès à son historique - if not request.user.has_perm('kfet.is_team'): - opegroups = opegroups.filter(on_acc=request.user.profile.account_kfet) - if limit: - opegroups = opegroups[:limit] - # Construction de la réponse - opegroups_list = [] + related_data = defaultdict(list) + objects_data = defaultdict(list) for opegroup in opegroups: opegroup_dict = { - 'id' : opegroup.id, - 'amount' : opegroup.amount, - 'at' : opegroup.at, - 'checkout_id': opegroup.checkout_id, - 'is_cof' : opegroup.is_cof, - 'comment' : opegroup.comment, - 'opes' : [], - 'on_acc__trigramme': - opegroup.on_acc and opegroup.on_acc.trigramme or None, + 'id': opegroup.id, + 'amount': opegroup.amount, + 'at': opegroup.at, + 'is_cof': opegroup.is_cof, + 'comment': opegroup.comment, + 'trigramme': + opegroup.on_acc and opegroup.on_acc.trigramme or None, } if request.user.has_perm('kfet.is_team'): - opegroup_dict['valid_by__trigramme'] = ( + opegroup_dict['valid_by'] = ( opegroup.valid_by and opegroup.valid_by.trigramme or None) + for ope in opegroup.opes.all(): ope_dict = { - 'id' : ope.id, - 'type' : ope.type, - 'amount' : ope.amount, - 'article_nb' : ope.article_nb, - 'addcost_amount': ope.addcost_amount, - 'canceled_at' : ope.canceled_at, - 'article__name': - ope.article and ope.article.name or None, - 'addcost_for__trigramme': - ope.addcost_for and ope.addcost_for.trigramme or None, + 'id': ope.id, + 'amount': ope.amount, + 'canceled_at': ope.canceled_at, + 'is_cof': opegroup.is_cof, + 'trigramme': + opegroup.on_acc and opegroup.on_acc.trigramme or None, + 'opegroup__id': opegroup.id, } if request.user.has_perm('kfet.is_team'): - ope_dict['canceled_by__trigramme'] = ( + ope_dict['canceled_by'] = ( ope.canceled_by and ope.canceled_by.trigramme or None) - opegroup_dict['opes'].append(ope_dict) - opegroups_list.append(opegroup_dict) - return JsonResponse({ 'opegroups': opegroups_list }) + + if ope.type == Operation.PURCHASE: + ope_dict.update({ + 'article_name': ope.article.name, + 'article_nb': ope.article_nb, + 'addcost_amount': ope.addcost_amount, + 'addcost_for': + ope.addcost_for and ope.addcost_for.trigramme or None, + }) + objects_data['purchase'].append(ope_dict) + else: + ope_dict.update({ + 'type': ope.type, + }) + objects_data['specialope'].append(ope_dict) + + related_data['opegroup'].append(opegroup_dict) + + for transfergroup in transfergroups: + if transfergroup.filtered_transfers: + transfergroup_dict = { + 'id': transfergroup.id, + 'at': transfergroup.at, + 'comment': transfergroup.comment, + } + + if request.user.has_perm('kfet.is_team'): + transfergroup_dict['valid_by'] = ( + transfergroup.valid_by and + transfergroup.valid_by.trigramme or + None) + + for transfer in transfergroup.filtered_transfers: + transfer_dict = { + 'id': transfer.id, + 'amount': transfer.amount, + 'canceled_at': transfer.canceled_at, + 'from_acc': transfer.from_acc.trigramme, + 'to_acc': transfer.to_acc.trigramme, + 'transfergroup__id': transfergroup.id, + } + if request.user.has_perm('kfet.is_team'): + transfer_dict['canceled_by'] = ( + transfer.canceled_by and + transfer.canceled_by.trigramme or + None) + objects_data['transfer'].append(transfer_dict) + related_data['transfergroup'].append(transfergroup_dict) + + data = { + 'objects': objects_data, + 'related': related_data, + } + + return JsonResponse(data) @teamkfet_required @@ -1461,26 +1625,32 @@ def kpsul_articles_data(request): .filter(is_sold=True) .select_related('category')) articlelist = [] + categorylist = [] + # TODO: nice queryset, no duplicate categories for article in articles: articlelist.append({ - 'modelname': 'article', - 'content': { - 'id': article.id, - 'name': article.name, - 'price': article.price, - 'stock': article.stock, - }, - 'parent': { - 'modelname': 'category', - 'content': { - 'id': article.category.id, - 'name': article.category.name, - 'has_addcost': article.category.has_addcost, - }, - } + 'id': article.id, + 'name': article.name, + 'price': article.price, + 'stock': article.stock, + 'category__id': article.category.id, }) - return JsonResponse(articlelist, safe=False) + categorylist.append({ + 'id': article.category.id, + 'name': article.category.name, + 'has_addcost': article.category.has_addcost, + }) + + data = { + 'objects': { + 'article': articlelist, + }, + 'related': { + 'category': categorylist + } + } + return JsonResponse(data) @@ -1520,14 +1690,10 @@ class SettingsUpdate(SuccessMessageMixin, FormView): # Transfer views # ----- + @teamkfet_required def transfers(request): - transfergroups = (TransferGroup.objects - .prefetch_related('transfers') - .order_by('-at')) - return render(request, 'kfet/transfers.html', { - 'transfergroups': transfergroups, - }) + return render(request, 'kfet/transfers.html') @teamkfet_required def transfers_create(request): @@ -1535,20 +1701,24 @@ def transfers_create(request): return render(request, 'kfet/transfers_create.html', { 'transfer_formset': transfer_formset }) + @teamkfet_required def perform_transfers(request): - data = { 'errors': {}, 'transfers': [], 'transfergroup': 0 } + data = {'errors': {}, 'transfers': [], 'transfergroup': 0} # Checking transfer_formset transfer_formset = TransferFormSet(request.POST) if not transfer_formset.is_valid(): - return JsonResponse({ 'errors': list(transfer_formset.errors)}, status=400) + return JsonResponse({'errors': list(transfer_formset.errors)}, + status=400) - transfers = transfer_formset.save(commit = False) + transfers = transfer_formset.save(commit=False) # Initializing vars - required_perms = set(['kfet.add_transfer']) # Required perms to perform all transfers - to_accounts_balances = defaultdict(lambda:0) # For balances of accounts + # Required perms to perform all transfers + required_perms = set(['kfet.add_transfer']) + # For balances of accounts + to_accounts_balances = defaultdict(lambda: 0) for transfer in transfers: to_accounts_balances[transfer.from_acc] -= transfer.amount @@ -1560,7 +1730,7 @@ def perform_transfers(request): # Checking if ok on all accounts for account in to_accounts_balances: (perms, stop) = account.perms_to_perform_operation( - amount = to_accounts_balances[account]) + amount=to_accounts_balances[account]) required_perms |= perms stop_all = stop_all or stop if stop: @@ -1586,7 +1756,7 @@ def perform_transfers(request): # Updating balances accounts for account in to_accounts_balances: Account.objects.filter(pk=account.pk).update( - balance = F('balance') + to_accounts_balances[account]) + balance=F('balance') + to_accounts_balances[account]) account.refresh_from_db() if account.balance < 0: if hasattr(account, 'negative'): @@ -1595,10 +1765,10 @@ def perform_transfers(request): account.negative.save() else: negative = AccountNegative( - account = account, start = timezone.now()) + account=account, start=timezone.now()) negative.save() - elif (hasattr(account, 'negative') - and not account.negative.balance_offset): + elif (hasattr(account, 'negative') and + not account.negative.balance_offset): account.negative.delete() # Saving transfer group @@ -1611,106 +1781,31 @@ def perform_transfers(request): transfer.save() data['transfers'].append(transfer.pk) + # Websocket data + websocket_data = {} + websocket_data['opegroups'] = [{ + 'add': True, + 'modelname': 'transfergroup', + 'id': transfergroup.pk, + 'at': transfergroup.at, + 'comment': transfergroup.comment, + 'valid_by__trigramme': (transfergroup.valid_by and + transfergroup.valid_by.trigramme or None), + 'opes': [], + }] + for transfer in transfers: + ope_data = { + 'id': transfer.pk, + 'amount': transfer.amount, + 'from_acc': transfer.from_acc.trigramme, + 'to_acc': transfer.to_acc.trigramme, + 'canceled_by__trigramme': None, 'canceled_at': None, + } + websocket_data['opegroups'][0]['opes'].append(ope_data) + + consumers.KPsul.group_send('kfet.kpsul', websocket_data) 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 = kfet_config.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