diff --git a/apps/treasury/apps.py b/apps/treasury/apps.py index 6e07a5f56bf18b78c12bbdf15436111645c73b20..e2873ea2d8cba8cefcb88feb19750790e639d425 100644 --- a/apps/treasury/apps.py +++ b/apps/treasury/apps.py @@ -5,7 +5,6 @@ from django.apps import AppConfig from django.db.models import Q from django.db.models.signals import post_save, post_migrate from django.utils.translation import gettext_lazy as _ -from note.models import NoteSpecial class TreasuryConfig(AppConfig): @@ -18,7 +17,7 @@ class TreasuryConfig(AppConfig): """ from . import signals - from note.models import SpecialTransaction + from note.models import SpecialTransaction, NoteSpecial from treasury.models import SpecialTransactionProxy post_save.connect(signals.save_special_transaction, sender=SpecialTransaction) diff --git a/apps/treasury/forms.py b/apps/treasury/forms.py index 6269138cba89ae8248468f22dc9b0a3d5862caf9..8692791c957dfe3f2a7ba08699b0830bf4ad25e8 100644 --- a/apps/treasury/forms.py +++ b/apps/treasury/forms.py @@ -12,6 +12,11 @@ from .models import Invoice, Product, Remittance, SpecialTransactionProxy class InvoiceForm(forms.ModelForm): + """ + Create and generate invoices. + """ + + # Django forms don't support date fields. We have to add it manually date = forms.DateField( initial=datetime.date.today, widget=forms.TextInput(attrs={'type': 'date'}) @@ -25,6 +30,8 @@ class InvoiceForm(forms.ModelForm): exclude = ('bde', ) +# Add a subform per product in the invoice form, and manage correctly the link between the invoice and +# its products. The FormSet will search automatically the ForeignKey in the Product model. ProductFormSet = forms.inlineformset_factory( Invoice, Product, @@ -34,6 +41,10 @@ ProductFormSet = forms.inlineformset_factory( class ProductFormSetHelper(FormHelper): + """ + Specify some template informations for the product form. + """ + def __init__(self, form=None): super().__init__(form) self.form_tag = False @@ -43,24 +54,33 @@ class ProductFormSetHelper(FormHelper): class RemittanceForm(forms.ModelForm): + """ + Create remittances. + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() + # We can't update the type of the remittance once created. if self.instance.pk: self.fields["remittance_type"].disabled = True self.fields["remittance_type"].required = False + # We display the submit button iff the remittance is open, + # the close button iff it is open and has a linked transaction if not self.instance.closed: self.helper.add_input(Submit('submit', _("Submit"), attr={'class': 'btn btn-block btn-primary'})) if self.instance.transactions: self.helper.add_input(Submit("close", _("Close"), css_class='btn btn-success')) else: + # If the remittance is closed, we can't change anything self.fields["comment"].disabled = True self.fields["comment"].required = False def clean(self): + # We can't update anything if the remittance is already closed. if self.instance.closed: self.add_error("comment", _("Remittance is already closed.")) @@ -69,6 +89,7 @@ class RemittanceForm(forms.ModelForm): if self.instance.pk and cleaned_data.get("remittance_type") != self.instance.remittance_type: self.add_error("remittance_type", _("You can't change the type of the remittance.")) + # The close button is manually handled if "close" in self.data: self.instance.closed = True self.cleaned_data["closed"] = True @@ -81,6 +102,11 @@ class RemittanceForm(forms.ModelForm): class LinkTransactionToRemittanceForm(forms.ModelForm): + """ + Attach a special transaction to a remittance. + """ + + # Since we use a proxy model for special transactions, we add manually the fields related to the transaction last_name = forms.CharField(label=_("Last name")) first_name = forms.Field(label=_("First name")) @@ -92,21 +118,34 @@ class LinkTransactionToRemittanceForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() + # Add submit button self.helper.add_input(Submit('submit', _("Submit"), attr={'class': 'btn btn-block btn-primary'})) def clean_last_name(self): + """ + Replace the first name in the information of the transaction. + """ self.instance.transaction.last_name = self.data.get("last_name") self.instance.transaction.clean() def clean_first_name(self): + """ + Replace the last name in the information of the transaction. + """ self.instance.transaction.first_name = self.data.get("first_name") self.instance.transaction.clean() def clean_bank(self): + """ + Replace the bank in the information of the transaction. + """ self.instance.transaction.bank = self.data.get("bank") self.instance.transaction.clean() def clean_amount(self): + """ + Replace the amount of the transaction. + """ self.instance.transaction.amount = self.data.get("amount") self.instance.transaction.clean() diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 4180d065b0d3c04d5244a24f2c6f4f5903a9cc6d..a342eeb1e2b257a364398d2a0ab2f62e9fab4bd9 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -10,7 +10,7 @@ from note.models import NoteSpecial, SpecialTransaction class Invoice(models.Model): """ - An invoice model that can generate a true invoice + An invoice model that can generates a true invoice. """ id = models.PositiveIntegerField( @@ -62,7 +62,7 @@ class Invoice(models.Model): class Product(models.Model): """ - Product that appear on an invoice. + Product that appears on an invoice. """ invoice = models.ForeignKey( @@ -138,18 +138,28 @@ class Remittance(models.Model): @property def transactions(self): + """ + :return: Transactions linked to this remittance. + """ + if not self.pk: + return SpecialTransaction.objects.none() return SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self) def count(self): + """ + Linked transactions count. + """ return self.transactions.count() @property def amount(self): + """ + Total amount of the remittance. + """ return sum(transaction.total for transaction in self.transactions.all()) - def save(self, force_insert=False, force_update=False, using=None, - update_fields=None): - + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + # Check if all transactions have the right type. if self.transactions.filter(~Q(source=self.remittance_type.note)).exists(): raise ValidationError("All transactions in a remittance must have the same type") diff --git a/apps/treasury/tables.py b/apps/treasury/tables.py index 7c78a8a45609487f799b65cde08180888fbad4e6..1ecc04db196ff8e94f45f60bbd5996a49c03e9e5 100644 --- a/apps/treasury/tables.py +++ b/apps/treasury/tables.py @@ -11,6 +11,9 @@ from .models import Invoice, Remittance class InvoiceTable(tables.Table): + """ + List all invoices. + """ id = tables.LinkColumn("treasury:invoice_update", args=[A("pk")], text=lambda record: _("Invoice #{:d}").format(record.id), ) @@ -35,6 +38,10 @@ class InvoiceTable(tables.Table): class RemittanceTable(tables.Table): + """ + List all remittances. + """ + count = tables.Column(verbose_name=_("Transaction count")) amount = tables.Column(verbose_name=_("Amount")) @@ -60,6 +67,11 @@ class RemittanceTable(tables.Table): class SpecialTransactionTable(tables.Table): + """ + List special credit transactions that are (or not, following the queryset) attached to a remittance. + """ + + # Display add and remove buttons. Use the `exclude` field to select what is needed. remittance_add = tables.LinkColumn("treasury:link_transaction", verbose_name=_("Remittance"), args=[A("specialtransactionproxy.pk")], diff --git a/apps/treasury/urls.py b/apps/treasury/urls.py index fa5ef0e40555e988ed7065e90f449e7f3875b288..d44cc4145921fd63d3c645d67c39612e2103a54c 100644 --- a/apps/treasury/urls.py +++ b/apps/treasury/urls.py @@ -8,11 +8,13 @@ from .views import InvoiceCreateView, InvoiceListView, InvoiceUpdateView, Invoic app_name = 'treasury' urlpatterns = [ + # Invoice app paths path('invoice/', InvoiceListView.as_view(), name='invoice_list'), path('invoice/create/', InvoiceCreateView.as_view(), name='invoice_create'), path('invoice/<int:pk>/', InvoiceUpdateView.as_view(), name='invoice_update'), path('invoice/render/<int:pk>/', InvoiceRenderView.as_view(), name='invoice_render'), + # Remittance app paths path('remittance/', RemittanceListView.as_view(), name='remittance_list'), path('remittance/create/', RemittanceCreateView.as_view(), name='remittance_create'), path('remittance/<int:pk>/', RemittanceUpdateView.as_view(), name='remittance_update'), diff --git a/apps/treasury/views.py b/apps/treasury/views.py index 981dbc76ca999e4efddfbc32dcca7f04ff599ea0..904405661ed5ceb87c25737d40624a87401817a4 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -21,7 +21,7 @@ from note.models import SpecialTransaction, NoteSpecial from note_kfet.settings.base import BASE_DIR from .forms import InvoiceForm, ProductFormSet, ProductFormSetHelper, RemittanceForm, LinkTransactionToRemittanceForm -from .models import Invoice, Product, Remittance, SpecialTransactionProxy, RemittanceType +from .models import Invoice, Product, Remittance, SpecialTransactionProxy from .tables import InvoiceTable, RemittanceTable, SpecialTransactionTable @@ -34,9 +34,12 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + form = context['form'] form.helper = FormHelper() + # Remove form tag on the generation of the form in the template (already present on the template) form.helper.form_tag = False + # The formset handles the set of the products form_set = ProductFormSet(instance=form.instance) context['formset'] = form_set context['helper'] = ProductFormSetHelper() @@ -48,6 +51,8 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView): ret = super().form_valid(form) kwargs = {} + + # The user type amounts in cents. We convert it in euros. for key in self.request.POST: value = self.request.POST[key] if key.endswith("amount") and value: @@ -55,9 +60,11 @@ class InvoiceCreateView(LoginRequiredMixin, CreateView): elif value: kwargs[key] = value + # For each product, we save it formset = ProductFormSet(kwargs, instance=form.instance) if formset.is_valid(): for f in formset: + # We don't save the product if the designation is not entered, ie. if the line is empty if f.is_valid() and f.instance.designation: f.save() f.instance.save() @@ -87,10 +94,14 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + form = context['form'] form.helper = FormHelper() + # Remove form tag on the generation of the form in the template (already present on the template) form.helper.form_tag = False + # Fill the intial value for the date field, with the initial date of the model instance form.fields['date'].initial = form.instance.date + # The formset handles the set of the products form_set = ProductFormSet(instance=form.instance) context['formset'] = form_set context['helper'] = ProductFormSetHelper() @@ -102,6 +113,7 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView): ret = super().form_valid(form) kwargs = {} + # The user type amounts in cents. We convert it in euros. for key in self.request.POST: value = self.request.POST[key] if key.endswith("amount") and value: @@ -111,14 +123,17 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView): formset = ProductFormSet(kwargs, instance=form.instance) saved = [] + # For each product, we save it if formset.is_valid(): for f in formset: + # We don't save the product if the designation is not entered, ie. if the line is empty if f.is_valid() and f.instance.designation: f.save() f.instance.save() saved.append(f.instance.pk) else: f.instance = None + # Remove old products that weren't given in the form Product.objects.filter(~Q(pk__in=saved), invoice=form.instance).delete() return ret @@ -129,7 +144,7 @@ class InvoiceUpdateView(LoginRequiredMixin, UpdateView): class InvoiceRenderView(LoginRequiredMixin, View): """ - Render Invoice as generated PDF + Render Invoice as a generated PDF with the given information and a LaTeX template """ def get(self, request, **kwargs): @@ -137,6 +152,7 @@ class InvoiceRenderView(LoginRequiredMixin, View): invoice = Invoice.objects.get(pk=pk) products = Product.objects.filter(invoice=invoice).all() + # Informations of the BDE. Should be updated when the school will move. invoice.place = "Cachan" invoice.my_name = "BDE ENS Cachan" invoice.my_address_street = "61 avenue du Président Wilson" @@ -147,13 +163,17 @@ class InvoiceRenderView(LoginRequiredMixin, View): invoice.rib_key = 14 invoice.bic = "SOGEFRPP" + # Replace line breaks with the LaTeX equivalent invoice.description = invoice.description.replace("\r", "").replace("\n", "\\\\ ") invoice.address = invoice.address.replace("\r", "").replace("\n", "\\\\ ") + # Fill the template with the information tex = render_to_string("treasury/invoice_sample.tex", dict(obj=invoice, products=products)) + try: os.mkdir(BASE_DIR + "/tmp") except FileExistsError: pass + # We render the file in a temporary directory tmp_dir = mkdtemp(prefix=BASE_DIR + "/tmp/") try: @@ -161,21 +181,27 @@ class InvoiceRenderView(LoginRequiredMixin, View): f.write(tex.encode("UTF-8")) del tex + # The file has to be rendered twice for _ in range(2): error = subprocess.Popen( ["pdflatex", "invoice-{}.tex".format(pk)], cwd=tmp_dir, + stdin=open(os.devnull, "r"), + stderr=open(os.devnull, "wb"), + stdout=open(os.devnull, "wb"), ).wait() if error: raise IOError("An error attempted while generating a invoice (code=" + str(error) + ")") + # Display the generated pdf as a HTTP Response pdf = open("{}/invoice-{}.pdf".format(tmp_dir, pk), 'rb').read() response = HttpResponse(pdf, content_type="application/pdf") response['Content-Disposition'] = "inline;filename=invoice-{:d}.pdf".format(pk) except IOError as e: raise e finally: + # Delete all temporary files shutil.rmtree(tmp_dir) return response @@ -211,6 +237,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView): ctx["opened_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=False).all()) ctx["closed_remittances"] = RemittanceTable(data=Remittance.objects.filter(closed=True).reverse().all()) + ctx["special_transactions_no_remittance"] = SpecialTransactionTable( data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), specialtransactionproxy__remittance=None).all(), @@ -246,6 +273,10 @@ class RemittanceUpdateView(LoginRequiredMixin, UpdateView): class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView): + """ + Attach a special transaction to a remittance + """ + model = SpecialTransactionProxy form_class = LinkTransactionToRemittanceForm @@ -267,10 +298,15 @@ class LinkTransactionToRemittanceView(LoginRequiredMixin, UpdateView): class UnlinkTransactionToRemittanceView(LoginRequiredMixin, View): + """ + Unlink a special transaction and its remittance + """ + def get(self, *args, **kwargs): pk = kwargs["pk"] transaction = SpecialTransactionProxy.objects.get(pk=pk) + # The remittance must be open (or inexistant) if transaction.remittance and transaction.remittance.closed: raise ValidationError("Remittance is already closed.") diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 1dc8ab2aa1c83be4ad0711d2884960ca5f3be45f..b6a8c1202f4818fc342f9423b7bcead9f6770217 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -1004,7 +1004,7 @@ msgid "Transfers with opened remittances" msgstr "" #: templates/treasury/remittance_list.html:48 -msgid "There is no transaction without an opened linked remittance." +msgid "There is no transaction with an opened linked remittance." msgstr "" #: templates/treasury/remittance_list.html:54 diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index e2d418264c132b578a5fdfb3c6940206f22d5a33..67af6beeac1bcf6e5c4cd6f83f6d46db953320ab 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -1003,11 +1003,11 @@ msgstr "Il n'y a pas de transactions sans remise associée." #: templates/treasury/remittance_list.html:43 msgid "Transfers with opened remittances" -msgstr "Transactions avec remise associée ouverte" +msgstr "Transactions associées à une remise ouverte" #: templates/treasury/remittance_list.html:48 -msgid "There is no transaction without an opened linked remittance." -msgstr "Il n'y a pas de transaction sans remise ouverte associée." +msgid "There is no transaction with an opened linked remittance." +msgstr "Il n'y a pas de transaction associée à une remise ouverte." #: templates/treasury/remittance_list.html:54 msgid "Closed remittances" diff --git a/templates/treasury/invoice_form.html b/templates/treasury/invoice_form.html index f6e2a10670337cf3834477613d756098ad4358ca..de43af22baa88641f20e93147622b7a42ad5f62a 100644 --- a/templates/treasury/invoice_form.html +++ b/templates/treasury/invoice_form.html @@ -6,22 +6,29 @@ <p><a class="btn btn-default" href="{% url 'treasury:invoice_list' %}">{% trans "Invoices list" %}</a></p> <form method="post" action=""> {% csrf_token %} + {# Render the invoice form #} {% crispy form %} + {# The next part concerns the product formset #} + {# Generate some hidden fields that manage the number of products, and make easier the parsing #} {{ formset.management_form }} <table class="table table-condensed table-striped"> - {% for form in formset %} - {% if forloop.first %} - <thead><tr> - <th>{{ form.designation.label }}<span class="asteriskField">*</span></th> - <th>{{ form.quantity.label }}<span class="asteriskField">*</span></th> - <th>{{ form.amount.label }}<span class="asteriskField">*</span></th> - </tr></thead> - <tbody id="form_body" > - {% endif %} - <tr class="row-formset"> + {# Fill initial data #} + {% for form in formset %} + {% if forloop.first %} + <thead> + <tr> + <th>{{ form.designation.label }}<span class="asteriskField">*</span></th> + <th>{{ form.quantity.label }}<span class="asteriskField">*</span></th> + <th>{{ form.amount.label }}<span class="asteriskField">*</span></th> + </tr> + </thead> + <tbody id="form_body"> + {% endif %} + <tr class="row-formset"> <td>{{ form.designation }}</td> <td>{{ form.quantity }} </td> <td> + {# Use custom input for amount, with the € symbol #} <div class="input-group"> <input type="number" name="product_set-{{ forloop.counter0 }}-amount" min="0" step="0.01" id="id_product_set-{{ forloop.counter0 }}-amount" @@ -31,13 +38,15 @@ </div> </div> </td> + {# These fields are hidden but handled by the formset to link the id and the invoice id #} {{ form.invoice }} {{ form.id }} - </tr> - {% endfor %} - </tbody> + </tr> + {% endfor %} + </tbody> </table> + {# Display buttons to add and remove products #} <div class="btn-group btn-block" role="group"> <button type="button" id="add_more" class="btn btn-primary">{% trans "Add product" %}</button> <button type="button" id="remove_one" class="btn btn-danger">{% trans "Remove product" %}</button> @@ -49,43 +58,44 @@ </form> <div id="empty_form" style="display: none;"> + {# Hidden div that store an empty product form, to be copied into new forms #} <table class='no_error'> <tbody id="for_real"> - <tr class="row-formset"> - <td>{{ formset.empty_form.designation }}</td> - <td>{{ formset.empty_form.quantity }} </td> - <td> - <div class="input-group"> - <input type="number" name="product_set-__prefix__-amount" min="0" step="0.01" - id="id_product_set-__prefix__-amount"> - <div class="input-group-append"> - <span class="input-group-text">€</span> - </div> + <tr class="row-formset"> + <td>{{ formset.empty_form.designation }}</td> + <td>{{ formset.empty_form.quantity }} </td> + <td> + <div class="input-group"> + <input type="number" name="product_set-__prefix__-amount" min="0" step="0.01" + id="id_product_set-__prefix__-amount"> + <div class="input-group-append"> + <span class="input-group-text">€</span> </div> - </td> - {{ formset.empty_form.invoice }} - {{ formset.empty_form.id }} - </tr> + </div> + </td> + {{ formset.empty_form.invoice }} + {{ formset.empty_form.id }} + </tr> </tbody> </table> </div> {% endblock %} {% block extrajavascript %} - <script src="{% static 'js/dynamic-formset.js' %}"></script> <script> + {# Script that handles add and remove lines #} IDS = {}; $("#id_product_set-TOTAL_FORMS").val($(".row-formset").length - 1); - $('#add_more').click(function() { + $('#add_more').click(function () { var form_idx = $('#id_product_set-TOTAL_FORMS').val(); $('#form_body').append($('#for_real').html().replace(/__prefix__/g, form_idx)); $('#id_product_set-TOTAL_FORMS').val(parseInt(form_idx) + 1); $('#id_product_set-' + parseInt(form_idx) + '-id').val(IDS[parseInt(form_idx)]); }); - $('#remove_one').click(function() { + $('#remove_one').click(function () { let form_idx = $('#id_product_set-TOTAL_FORMS').val(); if (form_idx > 0) { IDS[parseInt(form_idx) - 1] = $('#id_product_set-' + (parseInt(form_idx) - 1) + '-id').val(); diff --git a/templates/treasury/remittance_list.html b/templates/treasury/remittance_list.html index f56c0c7901b2a3496322d26c55a7771ed9c38e8e..8bc634e4b35e029a95d57369dd24d0c50c0aeb5e 100644 --- a/templates/treasury/remittance_list.html +++ b/templates/treasury/remittance_list.html @@ -45,7 +45,7 @@ {% render_table special_transactions_with_remittance %} {% else %} <div class="alert alert-warning"> - {% trans "There is no transaction without an opened linked remittance." %} + {% trans "There is no transaction with an opened linked remittance." %} </div> {% endif %}