From a772cea76069da61a6ae7dde6f9de71c52f15f7f Mon Sep 17 00:00:00 2001 From: Yohann D'ANELLO Date: Sat, 21 Mar 2020 16:49:18 +0100 Subject: [PATCH] Add products on billings --- apps/treasury/__init__.py | 4 ++ apps/treasury/admin.py | 16 ++++++ apps/treasury/forms.py | 29 ++++++++++ apps/treasury/models.py | 9 +++ apps/treasury/tables.py | 5 +- apps/treasury/views.py | 80 +++++++++++++++++++++++++-- static/js/dynamic-formset.js | 46 ++++++++++----- templates/treasury/billing_form.html | 80 +++++++++++++++++++++++++-- templates/treasury/billing_sample.tex | 6 +- 9 files changed, 245 insertions(+), 30 deletions(-) create mode 100644 apps/treasury/admin.py create mode 100644 apps/treasury/forms.py diff --git a/apps/treasury/__init__.py b/apps/treasury/__init__.py index e69de29b..c9c6150e 100644 --- a/apps/treasury/__init__.py +++ b/apps/treasury/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +default_app_config = 'treasury.apps.TreasuryConfig' diff --git a/apps/treasury/admin.py b/apps/treasury/admin.py new file mode 100644 index 00000000..2d2fbbef --- /dev/null +++ b/apps/treasury/admin.py @@ -0,0 +1,16 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib import admin + +from treasury.models import Billing, Product + + +@admin.register(Billing) +class BillingAdmin(admin.ModelAdmin): + list_display = ('id', 'name', 'subject', 'acquitted', ) + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ('designation', 'quantity', 'amount', ) diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py new file mode 100644 index 00000000..acf2eb7c --- /dev/null +++ b/apps/treasury/forms.py @@ -0,0 +1,29 @@ +# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from crispy_forms.helper import FormHelper +from django import forms + +from .models import Billing, Product + + +class BillingForm(forms.ModelForm): + class Meta: + model = Billing + fields = '__all__' + + +ProductFormSet = forms.inlineformset_factory( + Billing, + Product, + fields='__all__', + extra=1, +) + +class ProductFormSetHelper(FormHelper): + def __init__(self, form=None): + super().__init__(form) + self.form_tag = False + self.form_method = 'POST' + self.form_class = 'form-inline' + self.template = 'bootstrap4/table_inline_formset.html' \ No newline at end of file diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 81707f3f..7e3b5eba 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from django.db import models +from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ @@ -122,6 +123,14 @@ class Product(models.Model): verbose_name=_("Unit price") ) + @property + def amount_euros(self): + return self.amount / 100 + @property def total(self): return self.quantity * self.amount + + @property + def total_euros(self): + return self.total / 100 diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py index 30cbb2e7..af80562a 100644 --- a/apps/treasury/tables.py +++ b/apps/treasury/tables.py @@ -14,7 +14,10 @@ class BillingTable(tables.Table): args=[A("pk")], accessor="pk", text="", - attrs={'a': {'class': 'fa fa-file-pdf-o'}}) + attrs={ + 'a': {'class': 'fa fa-file-pdf-o'}, + 'td': {'data-turbolinks': 'false'} + }) class Meta: attrs = { diff --git a/apps/treasury/views.py b/apps/treasury/views.py index 1844167b..1fb4bcf0 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -6,15 +6,20 @@ import shutil import subprocess from tempfile import mkdtemp +from crispy_forms.helper import FormHelper from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Q from django.http import HttpResponse from django.template.loader import render_to_string +from django.urls import reverse_lazy from django.views.generic import CreateView, UpdateView from django.views.generic.base import View from django_tables2 import SingleTableView + from note_kfet.settings.base import BASE_DIR -from .models import Billing +from .forms import BillingForm, ProductFormSet, ProductFormSetHelper +from .models import Billing, Product from .tables import BillingTable @@ -23,8 +28,36 @@ class BillingCreateView(LoginRequiredMixin, CreateView): Create Billing """ model = Billing - fields = '__all__' - # form_class = ClubForm + form_class = BillingForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + form = context['form'] + form.helper = FormHelper() + form.helper.form_tag = False + form_set = ProductFormSet(instance=form.instance) + context['formset'] = form_set + context['helper'] = ProductFormSetHelper() + context['no_cache'] = True + + return context + + def form_valid(self, form): + ret = super().form_valid(form) + + formset = ProductFormSet(self.request.POST, instance=form.instance) + if formset.is_valid(): + for f in formset: + if f.is_valid() and f.instance.designation: + f.save() + f.instance.save() + else: + f.instance = None + + return ret + + def get_success_url(self): + return reverse_lazy('treasury:billing') class BillingListView(LoginRequiredMixin, SingleTableView): @@ -40,8 +73,42 @@ class BillingUpdateView(LoginRequiredMixin, UpdateView): Create Billing """ model = Billing - fields = '__all__' - # form_class = BillingForm + form_class = BillingForm + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + form = context['form'] + form.helper = FormHelper() + form.helper.form_tag = False + form_set = ProductFormSet(instance=form.instance) + context['formset'] = form_set + context['helper'] = ProductFormSetHelper() + context['no_cache'] = True + + return context + + def form_valid(self, form): + ret = super().form_valid(form) + + formset = ProductFormSet(self.request.POST, instance=form.instance) + saved = [] + if formset.is_valid(): + for f in formset: + if f.is_valid() and f.instance.designation: + if type(f.instance.pk) == 'number' and f.instance.pk <= 0: + f.instance.pk = None + f.save() + f.instance.save() + saved.append(f.instance.pk) + else: + f.instance = None + + Product.objects.filter(~Q(pk__in=saved), billing=form.instance).delete() + + return ret + + def get_success_url(self): + return reverse_lazy('treasury:billing') class BillingRenderView(LoginRequiredMixin, View): @@ -52,10 +119,11 @@ class BillingRenderView(LoginRequiredMixin, View): def get(self, request, **kwargs): pk = kwargs["pk"] billing = Billing.objects.get(pk=pk) + products = Product.objects.filter(billing=billing).all() billing.description = billing.description.replace("\n", "\\newline\n") billing.address = billing.address.replace("\n", "\\newline\n") - tex = render_to_string("treasury/billing_sample.tex", dict(obj=billing)) + tex = render_to_string("treasury/billing_sample.tex", dict(obj=billing, products=products)) try: os.mkdir(BASE_DIR + "/tmp") except FileExistsError: diff --git a/static/js/dynamic-formset.js b/static/js/dynamic-formset.js index 87edfaae..c6ff3328 100644 --- a/static/js/dynamic-formset.js +++ b/static/js/dynamic-formset.js @@ -1,5 +1,5 @@ /** - * jQuery Formset 1.3-pre + * jQuery Formset 1.5-pre * @author Stanislaus Madueke (stan DOT madueke AT gmail DOT com) * @requires jQuery 1.2.6 or later * @@ -55,19 +55,26 @@ insertDeleteLink = function(row) { var delCssSelector = $.trim(options.deleteCssClass).replace(/\s+/g, '.'), addCssSelector = $.trim(options.addCssClass).replace(/\s+/g, '.'); - if (row.is('TR')) { + + var delButtonHTML = '' + options.deleteText +''; + if (options.deleteContainerClass) { + // If we have a specific container for the remove button, + // place it as the last child of that container: + row.find('[class*="' + options.deleteContainerClass + '"]').append(delButtonHTML); + } else if (row.is('TR')) { // If the forms are laid out in table rows, insert // the remove button into the last table cell: - row.children(':last').append('' + options.deleteText + ''); + row.children('td:last').append(delButtonHTML); } else if (row.is('UL') || row.is('OL')) { // If they're laid out as an ordered/unordered list, // insert an
  • after the last list item: - row.append('
  • ' + options.deleteText +'
  • '); + row.append('
  • ' + delButtonHTML + '
  • '); } else { // Otherwise, just insert the remove button as the // last child element of the form's container: - row.append('' + options.deleteText +''); + row.append(delButtonHTML); } + // Check if we're under the minimum number of forms - not to display delete link at rendering if (!showDeleteLinks()){ row.find('a.' + delCssSelector).hide(); @@ -156,6 +163,7 @@ } else { // Otherwise, use the last form in the formset; this works much better if you've got // extra (>= 1) forms (thnaks to justhamade for pointing this out): + if (options.hideLastAddForm) $('.' + options.formCssClass + ':last').hide(); template = $('.' + options.formCssClass + ':last').clone(true).removeAttr('id'); template.find('input:hidden[id $= "-DELETE"]').remove(); // Clear all cloned fields, except those the user wants to keep (thanks to brunogola for the suggestion): @@ -173,21 +181,28 @@ // FIXME: Perhaps using $.data would be a better idea? options.formTemplate = template; - if ($$.is('TR')) { + var addButtonHTML = '' + options.addText + ''; + if (options.addContainerClass) { + // If we have a specific container for the "add" button, + // place it as the last child of that container: + var addContainer = $('[class*="' + options.addContainerClass + '"'); + addContainer.append(addButtonHTML); + addButton = addContainer.find('[class="' + options.addCssClass + '"]'); + } else if ($$.is('TR')) { // If forms are laid out as table rows, insert the // "add" button in a new table row: var numCols = $$.eq(0).children().length, // This is a bit of an assumption :| - buttonRow = $('' + options.addText + '') - .addClass(options.formCssClass + '-add'); + buttonRow = $('' + addButtonHTML + '').addClass(options.formCssClass + '-add'); $$.parent().append(buttonRow); - if (hideAddButton) buttonRow.hide(); addButton = buttonRow.find('a'); } else { // Otherwise, insert it immediately after the last form: - $$.filter(':last').after('' + options.addText + ''); + $$.filter(':last').after(addButtonHTML); addButton = $$.filter(':last').next(); - if (hideAddButton) addButton.hide(); } + + if (hideAddButton) addButton.hide(); + addButton.click(function() { var formCount = parseInt(totalForms.val()), row = options.formTemplate.clone(true).removeClass('formset-custom-template'), @@ -220,12 +235,15 @@ formTemplate: null, // The jQuery selection cloned to generate new form instances addText: 'add another', // Text for the add link deleteText: 'remove', // Text for the delete link - addCssClass: '', // CSS class applied to the add link - deleteCssClass: '', // CSS class applied to the delete link + addContainerClass: null, // Container CSS class for the add link + deleteContainerClass: null, // Container CSS class for the delete link + addCssClass: 'add-row', // CSS class applied to the add link + deleteCssClass: 'delete-row', // CSS class applied to the delete link formCssClass: 'dynamic-form', // CSS class applied to each form in a formset extraClasses: [], // Additional CSS classes, which will be applied to each form in turn keepFieldValues: '', // jQuery selector for fields whose values should be kept when the form is cloned added: null, // Function called each time a new form is added - removed: null // Function called each time a form is deleted + removed: null, // Function called each time a form is deleted + hideLastAddForm: false // When set to true, hide last empty add form (becomes visible when clicking on add button) }; })(jQuery); diff --git a/templates/treasury/billing_form.html b/templates/treasury/billing_form.html index d72e15c5..42f4201c 100644 --- a/templates/treasury/billing_form.html +++ b/templates/treasury/billing_form.html @@ -3,10 +3,78 @@ {% load i18n %} {% load crispy_forms_tags %} {% block content %} -

    {% trans "Billings list" %}

    -
    -{% csrf_token %} -{{form|crispy}} - -
    +

    {% trans "Billings list" %}

    +
    + {% csrf_token %} + {% crispy form %} + {{ formset.management_form }} + + {% for form in formset %} + {% if forloop.first %} + + + + + + + {% endif %} + + + + + {{ form.billing }} + {{ form.id }} + + {% endfor %} + +
    {{ form.designation.label }}*{{ form.quantity.label }}*{{ form.amount.label }}*
    {{ form.designation }}{{ form.quantity }} {{ form.amount }}
    + +
    + + +
    + +
    + +
    +
    + + +{% endblock %} + +{% block extrajavascript %} + + {% endblock %} diff --git a/templates/treasury/billing_sample.tex b/templates/treasury/billing_sample.tex index 5f477ed1..625d10ba 100644 --- a/templates/treasury/billing_sample.tex +++ b/templates/treasury/billing_sample.tex @@ -95,8 +95,8 @@ % Liste des produits facturés : Désignation, quantité, prix unitaire HT -{% for product in obj.products %} -\AjouterProduit{ {{product.designation|safe}} } { {{product.quantity|safe}} } { {{product.amount|safe}}} { {{product.total|safe}}} +{% for product in products %} +\AjouterProduit{ {{product.designation|safe}}} { {{product.quantity|safe}}} { {{product.amount_euros|safe}}} { {{product.total_euros|safe}}} {% endfor %} %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% @@ -112,7 +112,7 @@ \renewcommand{\headrulewidth}{0pt} \cfoot{ - \small{\MonNom ~--~ \MonAdresseRue \MonAdresseVille ~--~ Telephone : +33(0)6 89 88 56 50\newline + \small{\MonNom ~--~ \MonAdresseRue ~ \MonAdresseVille ~--~ Téléphone : +33(0)6 89 88 56 50\newline Site web : bde.ens-cachan.fr ~--~ E-mail : tresorerie.bde@lists.crans.org \newline Numéro SIRET : 399 485 838 00011 } }