diff --git a/apps/treasury/apps.py b/apps/treasury/apps.py index 6e07a5f5..e2873ea2 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 6269138c..8692791c 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 4180d065..a342eeb1 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 7c78a8a4..1ecc04db 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 fa5ef0e4..d44cc414 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//', InvoiceUpdateView.as_view(), name='invoice_update'), path('invoice/render//', 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//', RemittanceUpdateView.as_view(), name='remittance_update'), diff --git a/apps/treasury/views.py b/apps/treasury/views.py index 981dbc76..90440566 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 1dc8ab2a..b6a8c120 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 e2d41826..67af6bee 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 f6e2a106..de43af22 100644 --- a/templates/treasury/invoice_form.html +++ b/templates/treasury/invoice_form.html @@ -6,22 +6,29 @@

{% trans "Invoices list" %}

{% 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 }} - {% for form in formset %} - {% if forloop.first %} - - - - - - - {% endif %} - + {# Fill initial data #} + {% for form in formset %} + {% if forloop.first %} + + + + + + + + + {% endif %} + + {# These fields are hidden but handled by the formset to link the id and the invoice id #} {{ form.invoice }} {{ form.id }} - - {% endfor %} - + + {% endfor %} +
{{ form.designation.label }}*{{ form.quantity.label }}*{{ form.amount.label }}*
{{ form.designation.label }}*{{ form.quantity.label }}*{{ form.amount.label }}*
{{ form.designation }} {{ form.quantity }} + {# Use custom input for amount, with the € symbol #}
+ {# Display buttons to add and remove products #}
@@ -49,43 +58,44 @@ {% endblock %} {% block extrajavascript %} -