From 37dbfd2fbf3d41590d12d08b9a50d00fbb7fcd27 Mon Sep 17 00:00:00 2001 From: Hugo LEVY-FALK Date: Mon, 31 Dec 2018 23:58:37 +0100 Subject: [PATCH] Add Cost Estimates --- cotisations/admin.py | 8 +- cotisations/forms.py | 14 +- cotisations/migrations/0037_costestimate.py | 28 +++ .../migrations/0038_auto_20181231_1657.py | 31 +++ cotisations/models.py | 54 ++++- .../cotisations/aff_cost_estimate.html | 101 +++++++++ .../templates/cotisations/edit_facture.html | 4 + .../templates/cotisations/factures.tex | 10 + .../cotisations/index_cost_estimate.html | 36 ++++ .../templates/cotisations/sidebar.html | 5 + cotisations/tex.py | 3 +- cotisations/urls.py | 30 +++ cotisations/views.py | 196 +++++++++++++++++- 13 files changed, 511 insertions(+), 9 deletions(-) create mode 100644 cotisations/migrations/0037_costestimate.py create mode 100644 cotisations/migrations/0038_auto_20181231_1657.py create mode 100644 cotisations/templates/cotisations/aff_cost_estimate.html create mode 100644 cotisations/templates/cotisations/index_cost_estimate.html diff --git a/cotisations/admin.py b/cotisations/admin.py index afe4621c..4b47ccc8 100644 --- a/cotisations/admin.py +++ b/cotisations/admin.py @@ -30,7 +30,7 @@ from django.contrib import admin from reversion.admin import VersionAdmin from .models import Facture, Article, Banque, Paiement, Cotisation, Vente -from .models import CustomInvoice +from .models import CustomInvoice, CostEstimate class FactureAdmin(VersionAdmin): @@ -38,6 +38,11 @@ class FactureAdmin(VersionAdmin): pass +class CostEstimateAdmin(VersionAdmin): + """Admin class for cost estimates.""" + pass + + class CustomInvoiceAdmin(VersionAdmin): """Admin class for custom invoices.""" pass @@ -76,3 +81,4 @@ admin.site.register(Paiement, PaiementAdmin) admin.site.register(Vente, VenteAdmin) admin.site.register(Cotisation, CotisationAdmin) admin.site.register(CustomInvoice, CustomInvoiceAdmin) +admin.site.register(CostEstimate, CostEstimateAdmin) diff --git a/cotisations/forms.py b/cotisations/forms.py index 56e90c58..57bd7355 100644 --- a/cotisations/forms.py +++ b/cotisations/forms.py @@ -46,7 +46,10 @@ from django.shortcuts import get_object_or_404 from re2o.field_permissions import FieldPermissionFormMixin from re2o.mixins import FormRevMixin -from .models import Article, Paiement, Facture, Banque, CustomInvoice, Vente +from .models import ( + Article, Paiement, Facture, Banque, + CustomInvoice, Vente, CostEstimate +) from .payment_methods import balance @@ -153,6 +156,15 @@ class CustomInvoiceForm(FormRevMixin, ModelForm): fields = '__all__' +class CostEstimateForm(FormRevMixin, ModelForm): + """ + Form used to create a cost estimate. + """ + class Meta: + model = CostEstimate + exclude = ['paid', 'final_invoice'] + + class ArticleForm(FormRevMixin, ModelForm): """ Form used to create an article. diff --git a/cotisations/migrations/0037_costestimate.py b/cotisations/migrations/0037_costestimate.py new file mode 100644 index 00000000..3d97f3f3 --- /dev/null +++ b/cotisations/migrations/0037_costestimate.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-12-29 21:03 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0036_custominvoice_remark'), + ] + + operations = [ + migrations.CreateModel( + name='CostEstimate', + fields=[ + ('custominvoice_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='cotisations.CustomInvoice')), + ('validity', models.DurationField(verbose_name='Period of validity')), + ('final_invoice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='origin_cost_estimate', to='cotisations.CustomInvoice')), + ], + options={ + 'permissions': (('view_costestimate', 'Can view a cost estimate object'),), + }, + bases=('cotisations.custominvoice',), + ), + ] diff --git a/cotisations/migrations/0038_auto_20181231_1657.py b/cotisations/migrations/0038_auto_20181231_1657.py new file mode 100644 index 00000000..a9415bf0 --- /dev/null +++ b/cotisations/migrations/0038_auto_20181231_1657.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2018-12-31 22:57 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('cotisations', '0037_costestimate'), + ] + + operations = [ + migrations.AlterField( + model_name='costestimate', + name='final_invoice', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_cost_estimate', to='cotisations.CustomInvoice'), + ), + migrations.AlterField( + model_name='costestimate', + name='validity', + field=models.DurationField(help_text='DD HH:MM:SS', verbose_name='Period of validity'), + ), + migrations.AlterField( + model_name='custominvoice', + name='paid', + field=models.BooleanField(default=False, verbose_name='Paid'), + ), + ] diff --git a/cotisations/models.py b/cotisations/models.py index 979b444a..623db068 100644 --- a/cotisations/models.py +++ b/cotisations/models.py @@ -284,7 +284,8 @@ class CustomInvoice(BaseInvoice): verbose_name=_("Address") ) paid = models.BooleanField( - verbose_name=_("Paid") + verbose_name=_("Paid"), + default=False ) remark = models.TextField( verbose_name=_("Remark"), @@ -293,6 +294,57 @@ class CustomInvoice(BaseInvoice): ) +class CostEstimate(CustomInvoice): + class Meta: + permissions = ( + ('view_costestimate', _("Can view a cost estimate object")), + ) + validity = models.DurationField( + verbose_name=_("Period of validity"), + help_text="DD HH:MM:SS" + ) + final_invoice = models.ForeignKey( + CustomInvoice, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="origin_cost_estimate", + primary_key=False + ) + + def create_invoice(self): + """Create a CustomInvoice from the CostEstimate.""" + if self.final_invoice is not None: + return self.final_invoice + invoice = CustomInvoice() + invoice.recipient = self.recipient + invoice.payment = self.payment + invoice.address = self.address + invoice.paid = False + invoice.remark = self.remark + invoice.date = timezone.now() + invoice.save() + self.final_invoice = invoice + self.save() + for sale in self.vente_set.all(): + Vente.objects.create( + facture=invoice, + name=sale.name, + prix=sale.prix, + number=sale.number, + ) + return invoice + + def can_delete(self, user_request, *args, **kwargs): + if not user_request.has_perm('cotisations.delete_costestimate'): + return False, _("You don't have the right " + "to delete a cost estimate.") + if self.final_invoice is not None: + return False, _("The cost estimate has an " + "invoice and cannot be deleted.") + return True, None + + # TODO : change Vente to Purchase class Vente(RevMixin, AclMixin, models.Model): """ diff --git a/cotisations/templates/cotisations/aff_cost_estimate.html b/cotisations/templates/cotisations/aff_cost_estimate.html new file mode 100644 index 00000000..d4a3f60d --- /dev/null +++ b/cotisations/templates/cotisations/aff_cost_estimate.html @@ -0,0 +1,101 @@ +{% comment %} +Re2o est un logiciel d'administration développé initiallement au rezometz. Il +se veut agnostique au réseau considéré, de manière à être installable en +quelques clics. + +Copyright © 2018 Hugo Levy-Falk + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +{% endcomment %} +{% load i18n %} +{% load acl %} +{% load logs_extra %} +{% load design %} + +
+ {% if cost_estimate_list.paginator %} + {% include 'pagination.html' with list=cost_estimate_list%} + {% endif %} + + + + + + + + + + + + + + + + + {% for estimate in cost_estimate_list %} + + + + + + + + + + + + {% endfor %} +
+ {% trans "Recipient" as tr_recip %} + {% include 'buttons/sort.html' with prefix='invoice' col='user' text=tr_user %} + {% trans "Designation" %}{% trans "Total price" %} + {% trans "Payment method" as tr_payment_method %} + {% include 'buttons/sort.html' with prefix='invoice' col='payement' text=tr_payment_method %} + + {% trans "Date" as tr_date %} + {% include 'buttons/sort.html' with prefix='invoice' col='date' text=tr_date %} + + {% trans "Validity" as tr_validity %} + {% include 'buttons/sort.html' with prefix='invoice' col='validity' text=tr_validity %} + + {% trans "Cost estimate ID" as tr_estimate_id %} + {% include 'buttons/sort.html' with prefix='invoice' col='id' text=tr_estimate_id %} + + {% trans "Invoice created" as tr_invoice_created%} + {% include 'buttons/sort.html' with prefix='invoice' col='paid' text=tr_invoice_created %} +
{{ estimate.recipient }}{{ estimate.name }}{{ estimate.prix_total }}{{ estimate.payment }}{{ estimate.date }}{{ estimate.validity }}{{ estimate.id }} + {% if estimate.final_invoice %} + + {% else %} + ' + {% endif %} + + {% can_edit estimate %} + {% include 'buttons/edit.html' with href='cotisations:edit-cost-estimate' id=estimate.id %} + {% acl_end %} + {% history_button estimate %} + {% include 'buttons/suppr.html' with href='cotisations:del-cost-estimate' id=estimate.id %} + + + + + {% trans "PDF" %} + +
+ + {% if custom_invoice_list.paginator %} + {% include 'pagination.html' with list=custom_invoice_list %} + {% endif %} +
diff --git a/cotisations/templates/cotisations/edit_facture.html b/cotisations/templates/cotisations/edit_facture.html index a00084f6..c7a6975c 100644 --- a/cotisations/templates/cotisations/edit_facture.html +++ b/cotisations/templates/cotisations/edit_facture.html @@ -35,7 +35,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% csrf_token %} + {% if title %} +

{{title}}

+ {% else %}

{% trans "Edit the invoice" %}

+ {% endif %} {% massive_bootstrap_form factureform 'user' %} {{ venteform.management_form }}

{% trans "Articles" %}

diff --git a/cotisations/templates/cotisations/factures.tex b/cotisations/templates/cotisations/factures.tex index 11e490d7..2cfd4f46 100644 --- a/cotisations/templates/cotisations/factures.tex +++ b/cotisations/templates/cotisations/factures.tex @@ -75,8 +75,12 @@ {\bf Pour :} {{recipient_name|safe}} & {\bf Date :} {{DATE}} \\ {\bf Adresse :} {% if address is None %}Aucune adresse renseignée{% else %}{{address}}{% endif %} & \\ {% if fid is not None %} + {% if is_estimate %} + {\bf Devis n\textsuperscript{o} :} {{ fid }} & \\ + {% else %} {\bf Facture n\textsuperscript{o} :} {{ fid }} & \\ {% endif %} + {% endif %} \end{tabular*} \\ @@ -104,9 +108,11 @@ \begin{tabular}{|l|r|} \hline \textbf{Total} & {{total|floatformat:2}} \euro \\ + {% if not is_estimate %} \textbf{Votre règlement} & {% if paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro \\ \doublehline \textbf{À PAYER} & {% if not paid %}{{total|floatformat:2}}{% else %} 00,00 {% endif %} \euro\\ + {% endif %} \hline \end{tabular} @@ -119,6 +125,10 @@ \textbf{Remarque} & {{remark|safe}} \\ \hline {% endif %} + {% if end_validity %} + \textbf{Validité} & Jusqu'au {{end_validity}} \\ + \hline + {% endif %} \end{tabularx} diff --git a/cotisations/templates/cotisations/index_cost_estimate.html b/cotisations/templates/cotisations/index_cost_estimate.html new file mode 100644 index 00000000..a0b3a661 --- /dev/null +++ b/cotisations/templates/cotisations/index_cost_estimate.html @@ -0,0 +1,36 @@ +{% extends "cotisations/sidebar.html" %} +{% comment %} +Re2o est un logiciel d'administration développé initiallement au rezometz. Il +se veut agnostique au réseau considéré, de manière à être installable en +quelques clics. + +Copyright © 2017 Gabriel Détraz +Copyright © 2017 Goulven Kermarec +Copyright © 2017 Augustin Lemesle + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +{% endcomment %} +{% load acl %} +{% load i18n %} + +{% block title %}{% trans "Cost estimates" %}{% endblock %} + +{% block content %} +

{% trans "Cost estimates list" %}

+{% can_create CostEstimate %} +{% include "buttons/add.html" with href='cotisations:new-cost-estimate'%} +{% acl_end %} +{% include 'cotisations/aff_cost_estimate.html' %} +{% endblock %} diff --git a/cotisations/templates/cotisations/sidebar.html b/cotisations/templates/cotisations/sidebar.html index 4f077fad..c3240a9a 100644 --- a/cotisations/templates/cotisations/sidebar.html +++ b/cotisations/templates/cotisations/sidebar.html @@ -45,6 +45,11 @@ with this program; if not, write to the Free Software Foundation, Inc., {% trans "Custom invoices" %} {% acl_end %} + {% can_view_all CostEstimate %} + + {% trans "Cost estimate" %} + + {% acl_end %} {% can_view_all Article %} {% trans "Available articles" %} diff --git a/cotisations/tex.py b/cotisations/tex.py index d6c0ae5f..4d3715af 100644 --- a/cotisations/tex.py +++ b/cotisations/tex.py @@ -49,8 +49,9 @@ def render_invoice(_request, ctx={}): Render an invoice using some available information such as the current date, the user, the articles, the prices, ... """ + is_estimate = ctx.get('is_estimate', False) filename = '_'.join([ - 'invoice', + 'cost_estimate' if is_estimate else 'invoice', slugify(ctx.get('asso_name', "")), slugify(ctx.get('recipient_name', "")), str(ctx.get('DATE', datetime.now()).year), diff --git a/cotisations/urls.py b/cotisations/urls.py index edc448fe..45032fe2 100644 --- a/cotisations/urls.py +++ b/cotisations/urls.py @@ -51,11 +51,41 @@ urlpatterns = [ views.facture_pdf, name='facture-pdf' ), + url( + r'^new_cost_estimate/$', + views.new_cost_estimate, + name='new-cost-estimate' + ), + url( + r'^index_cost_estimate/$', + views.index_cost_estimate, + name='index-cost-estimate' + ), + url( + r'^cost_estimate_pdf/(?P[0-9]+)$', + views.cost_estimate_pdf, + name='cost-estimate-pdf', + ), url( r'^index_custom_invoice/$', views.index_custom_invoice, name='index-custom-invoice' ), + url( + r'^edit_cost_estimate/(?P[0-9]+)$', + views.edit_cost_estimate, + name='edit-cost-estimate' + ), + url( + r'^cost_estimate_to_invoice/(?P[0-9]+)$', + views.cost_estimate_to_invoice, + name='cost-estimate-to-invoice' + ), + url( + r'^del_cost_estimate/(?P[0-9]+)$', + views.del_cost_estimate, + name='del-cost-estimate' + ), url( r'^new_custom_invoice/$', views.new_custom_invoice, diff --git a/cotisations/views.py b/cotisations/views.py index ec746bb7..d4805dc2 100644 --- a/cotisations/views.py +++ b/cotisations/views.py @@ -68,7 +68,8 @@ from .models import ( Paiement, Banque, CustomInvoice, - BaseInvoice + BaseInvoice, + CostEstimate ) from .forms import ( FactureForm, @@ -81,7 +82,8 @@ from .forms import ( SelectArticleForm, RechargeForm, CustomInvoiceForm, - DiscountForm + DiscountForm, + CostEstimateForm, ) from .tex import render_invoice, escape_chars from .payment_methods.forms import payment_method_factory @@ -179,7 +181,58 @@ def new_facture(request, user, userid): ) -# TODO : change facture to invoice +@login_required +@can_create(CostEstimate) +def new_cost_estimate(request): + """ + View used to generate a custom invoice. It's mainly used to + get invoices that are not taken into account, for the administrative + point of view. + """ + # The template needs the list of articles (for the JS part) + articles = Article.objects.filter( + Q(type_user='All') | Q(type_user=request.user.class_name) + ) + # Building the invocie form and the article formset + cost_estimate_form = CostEstimateForm(request.POST or None) + + articles_formset = formset_factory(SelectArticleForm)( + request.POST or None, + form_kwargs={'user': request.user} + ) + discount_form = DiscountForm(request.POST or None) + + if cost_estimate_form.is_valid() and articles_formset.is_valid() and discount_form.is_valid(): + cost_estimate_instance = cost_estimate_form.save() + for art_item in articles_formset: + if art_item.cleaned_data: + article = art_item.cleaned_data['article'] + quantity = art_item.cleaned_data['quantity'] + Vente.objects.create( + facture=cost_estimate_instance, + name=article.name, + prix=article.prix, + type_cotisation=article.type_cotisation, + duration=article.duration, + number=quantity + ) + discount_form.apply_to_invoice(cost_estimate_instance) + messages.success( + request, + _("The cost estimate was created.") + ) + return redirect(reverse('cotisations:index-cost-estimate')) + + return form({ + 'factureform': cost_estimate_form, + 'action_name': _("Confirm"), + 'articlesformset': articles_formset, + 'articlelist': articles, + 'discount_form': discount_form, + 'title': _("Cost estimate"), + }, 'cotisations/facture.html', request) + + @login_required @can_create(CustomInvoice) def new_custom_invoice(request): @@ -336,6 +389,55 @@ def del_facture(request, facture, **_kwargs): }, 'cotisations/delete.html', request) +@login_required +@can_edit(CostEstimate) +def edit_cost_estimate(request, invoice, **kwargs): + # Building the invocie form and the article formset + invoice_form = CostEstimateForm( + request.POST or None, + instance=invoice + ) + purchases_objects = Vente.objects.filter(facture=invoice) + purchase_form_set = modelformset_factory( + Vente, + fields=('name', 'number'), + extra=0, + max_num=len(purchases_objects) + ) + purchase_form = purchase_form_set( + request.POST or None, + queryset=purchases_objects + ) + if invoice_form.is_valid() and purchase_form.is_valid(): + if invoice_form.changed_data: + invoice_form.save() + purchase_form.save() + messages.success( + request, + _("The cost estimate was edited.") + ) + return redirect(reverse('cotisations:index-cost-estimate')) + + return form({ + 'factureform': invoice_form, + 'venteform': purchase_form, + 'title': "Edit the cost estimate" + }, 'cotisations/edit_facture.html', request) + + +@login_required +@can_edit(CostEstimate) +@can_create(CustomInvoice) +def cost_estimate_to_invoice(request, cost_estimate, **_kwargs): + """Create a custom invoice from a cos estimate""" + cost_estimate.create_invoice() + messages.success( + request, + _("An invoice was successfully created from your cost estimate.") + ) + return redirect(reverse('cotisations:index-custom-invoice')) + + @login_required @can_edit(CustomInvoice) def edit_custom_invoice(request, invoice, **kwargs): @@ -371,6 +473,68 @@ def edit_custom_invoice(request, invoice, **kwargs): }, 'cotisations/edit_facture.html', request) +@login_required +@can_view(CostEstimate) +def cost_estimate_pdf(request, invoice, **_kwargs): + """ + View used to generate a PDF file from an existing cost estimate in database + Creates a line for each Purchase (thus article sold) and generate the + invoice with the total price, the payment method, the address and the + legal information for the user. + """ + # TODO : change vente to purchase + purchases_objects = Vente.objects.all().filter(facture=invoice) + # Get the article list and build an list out of it + # contiaining (article_name, article_price, quantity, total_price) + purchases_info = [] + for purchase in purchases_objects: + purchases_info.append({ + 'name': escape_chars(purchase.name), + 'price': purchase.prix, + 'quantity': purchase.number, + 'total_price': purchase.prix_total + }) + return render_invoice(request, { + 'paid': invoice.paid, + 'fid': invoice.id, + 'DATE': invoice.date, + 'recipient_name': invoice.recipient, + 'address': invoice.address, + 'article': purchases_info, + 'total': invoice.prix_total(), + 'asso_name': AssoOption.get_cached_value('name'), + 'line1': AssoOption.get_cached_value('adresse1'), + 'line2': AssoOption.get_cached_value('adresse2'), + 'siret': AssoOption.get_cached_value('siret'), + 'email': AssoOption.get_cached_value('contact'), + 'phone': AssoOption.get_cached_value('telephone'), + 'tpl_path': os.path.join(settings.BASE_DIR, LOGO_PATH), + 'payment_method': invoice.payment, + 'remark': invoice.remark, + 'end_validity': invoice.date + invoice.validity, + 'is_estimate': True, + }) + + +@login_required +@can_delete(CostEstimate) +def del_cost_estimate(request, estimate, **_kwargs): + """ + View used to delete an existing invocie. + """ + if request.method == "POST": + estimate.delete() + messages.success( + request, + _("The cost estimate was deleted.") + ) + return redirect(reverse('cotisations:index-cost-estimate')) + return form({ + 'objet': estimate, + 'objet_name': _("Cost Estimate") + }, 'cotisations/delete.html', request) + + @login_required @can_view(CustomInvoice) def custom_invoice_pdf(request, invoice, **_kwargs): @@ -412,7 +576,6 @@ def custom_invoice_pdf(request, invoice, **_kwargs): }) -# TODO : change facture to invoice @login_required @can_delete(CustomInvoice) def del_custom_invoice(request, invoice, **_kwargs): @@ -763,12 +926,35 @@ def index_banque(request): }) +@login_required +@can_view_all(CustomInvoice) +def index_cost_estimate(request): + """View used to display every custom invoice.""" + pagination_number = GeneralOption.get_cached_value('pagination_number') + cost_estimate_list = CostEstimate.objects.prefetch_related('vente_set') + cost_estimate_list = SortTable.sort( + cost_estimate_list, + request.GET.get('col'), + request.GET.get('order'), + SortTable.COTISATIONS_CUSTOM + ) + cost_estimate_list = re2o_paginator( + request, + cost_estimate_list, + pagination_number, + ) + return render(request, 'cotisations/index_cost_estimate.html', { + 'cost_estimate_list': cost_estimate_list + }) + + @login_required @can_view_all(CustomInvoice) def index_custom_invoice(request): """View used to display every custom invoice.""" pagination_number = GeneralOption.get_cached_value('pagination_number') - custom_invoice_list = CustomInvoice.objects.prefetch_related('vente_set') + cost_estimate_ids = [i for i, in CostEstimate.objects.values_list('id')] + custom_invoice_list = CustomInvoice.objects.prefetch_related('vente_set').exclude(id__in=cost_estimate_ids) custom_invoice_list = SortTable.sort( custom_invoice_list, request.GET.get('col'),