Commit d6091d11 authored by Levy--Falk Hugo's avatar Levy--Falk Hugo

Custom invoices.

parent 0527206e
......@@ -46,7 +46,7 @@ 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
from .models import Article, Paiement, Facture, Banque, CustomInvoice
from .payment_methods import balance
......@@ -131,24 +131,13 @@ class SelectClubArticleForm(Form):
self.fields['article'].queryset = Article.find_allowed_articles(user)
# TODO : change Facture to Invoice
class NewFactureFormPdf(Form):
class CustomInvoiceForm(FormRevMixin, ModelForm):
"""
Form used to create a custom PDF invoice.
Form used to create a custom invoice.
"""
paid = forms.BooleanField(label=_l("Paid"), required=False)
# TODO : change dest field to recipient
dest = forms.CharField(
required=True,
max_length=255,
label=_l("Recipient")
)
# TODO : change chambre field to address
chambre = forms.CharField(
required=False,
max_length=10,
label=_l("Address")
)
class Meta:
model = CustomInvoice
fields = '__all__'
class ArticleForm(FormRevMixin, ModelForm):
......
# -*- coding: utf-8 -*-
# Generated by Django 1.10.7 on 2018-07-21 20:01
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import re2o.field_permissions
import re2o.mixins
def reattribute_ids(apps, schema_editor):
Facture = apps.get_model('cotisations', 'Facture')
BaseInvoice = apps.get_model('cotisations', 'BaseInvoice')
for f in Facture.objects.all():
base = BaseInvoice.objects.create(id=f.pk, date=f.date)
f.baseinvoice_ptr = base
f.save()
class Migration(migrations.Migration):
dependencies = [
('cotisations', '0030_custom_payment'),
]
operations = [
migrations.CreateModel(
name='BaseInvoice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateTimeField(auto_now_add=True, verbose_name='Date')),
],
bases=(re2o.mixins.RevMixin, re2o.mixins.AclMixin, re2o.field_permissions.FieldPermissionModelMixin, models.Model),
),
migrations.CreateModel(
name='CustomInvoice',
fields=[
('baseinvoice_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='cotisations.BaseInvoice')),
('recipient', models.CharField(max_length=255, verbose_name='Recipient')),
('payment', models.CharField(max_length=255, verbose_name='Payment type')),
('address', models.CharField(max_length=255, verbose_name='Address')),
('paid', models.BooleanField(verbose_name='Paid')),
],
bases=('cotisations.baseinvoice',),
options={'permissions': (('view_custom_invoice', 'Can view a custom invoice'),)},
),
migrations.AddField(
model_name='facture',
name='baseinvoice_ptr',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='cotisations.BaseInvoice', null=True),
preserve_default=False,
),
migrations.RunPython(reattribute_ids),
migrations.AlterField(
model_name='vente',
name='facture',
field=models.ForeignKey(on_delete=models.CASCADE, verbose_name='Invoice', to='cotisations.BaseInvoice')
),
migrations.RemoveField(
model_name='facture',
name='id',
),
migrations.RemoveField(
model_name='facture',
name='date',
),
migrations.AlterField(
model_name='facture',
name='baseinvoice_ptr',
field=models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='cotisations.BaseInvoice'),
)
]
......@@ -55,8 +55,52 @@ from cotisations.utils import find_payment_method
from cotisations.validators import check_no_balance
class BaseInvoice(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model):
date = models.DateTimeField(
auto_now_add=True,
verbose_name=_l("Date")
)
# TODO : change prix to price
def prix(self):
"""
Returns: the raw price without the quantities.
Deprecated, use :total_price instead.
"""
price = Vente.objects.filter(
facture=self
).aggregate(models.Sum('prix'))['prix__sum']
return price
# TODO : change prix to price
def prix_total(self):
"""
Returns: the total price for an invoice. Sum all the articles' prices
and take the quantities into account.
"""
# TODO : change Vente to somethingelse
return Vente.objects.filter(
facture=self
).aggregate(
total=models.Sum(
models.F('prix')*models.F('number'),
output_field=models.FloatField()
)
)['total'] or 0
def name(self):
"""
Returns : a string with the name of all the articles in the invoice.
Used for reprensenting the invoice with a string.
"""
name = ' - '.join(Vente.objects.filter(
facture=self
).values_list('name', flat=True))
return name
# TODO : change facture to invoice
class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model):
class Facture(BaseInvoice):
"""
The model for an invoice. It reprensents the fact that a user paid for
something (it can be multiple article paid at once).
......@@ -92,10 +136,6 @@ class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model):
blank=True,
verbose_name=_l("Cheque number")
)
date = models.DateTimeField(
auto_now_add=True,
verbose_name=_l("Date")
)
# TODO : change name to validity for clarity
valid = models.BooleanField(
default=True,
......@@ -130,43 +170,6 @@ class Facture(RevMixin, AclMixin, FieldPermissionModelMixin, models.Model):
Usefull in history display"""
return self.vente_set.all()
# TODO : change prix to price
def prix(self):
"""
Returns: the raw price without the quantities.
Deprecated, use :total_price instead.
"""
price = Vente.objects.filter(
facture=self
).aggregate(models.Sum('prix'))['prix__sum']
return price
# TODO : change prix to price
def prix_total(self):
"""
Returns: the total price for an invoice. Sum all the articles' prices
and take the quantities into account.
"""
# TODO : change Vente to somethingelse
return Vente.objects.filter(
facture=self
).aggregate(
total=models.Sum(
models.F('prix')*models.F('number'),
output_field=models.FloatField()
)
)['total'] or 0
def name(self):
"""
Returns : a string with the name of all the articles in the invoice.
Used for reprensenting the invoice with a string.
"""
name = ' - '.join(Vente.objects.filter(
facture=self
).values_list('name', flat=True))
return name
def can_edit(self, user_request, *args, **kwargs):
if not user_request.has_perm('cotisations.change_facture'):
return False, _("You don't have the right to edit an invoice.")
......@@ -265,6 +268,28 @@ def facture_post_delete(**kwargs):
user.ldap_sync(base=False, access_refresh=True, mac_refresh=False)
class CustomInvoice(BaseInvoice):
class Meta:
permissions = (
('view_custom_invoice', _l("Can view a custom invoice")),
)
recipient = models.CharField(
max_length=255,
verbose_name=_l("Recipient")
)
payment = models.CharField(
max_length=255,
verbose_name=_l("Payment type")
)
address = models.CharField(
max_length=255,
verbose_name=_l("Address")
)
paid = models.BooleanField(
verbose_name="Paid"
)
# TODO : change Vente to Purchase
class Vente(RevMixin, AclMixin, models.Model):
"""
......@@ -288,7 +313,7 @@ class Vente(RevMixin, AclMixin, models.Model):
# TODO : change facture to invoice
facture = models.ForeignKey(
'Facture',
'BaseInvoice',
on_delete=models.CASCADE,
verbose_name=_l("Invoice")
)
......@@ -355,6 +380,10 @@ class Vente(RevMixin, AclMixin, models.Model):
cotisation_type defined (which means the article sold represents
a cotisation)
"""
try:
invoice = self.facture.facture
except Facture.DoesNotExist:
return
if not hasattr(self, 'cotisation') and self.type_cotisation:
cotisation = Cotisation(vente=self)
cotisation.type_cotisation = self.type_cotisation
......@@ -362,7 +391,7 @@ class Vente(RevMixin, AclMixin, models.Model):
end_cotisation = Cotisation.objects.filter(
vente__in=Vente.objects.filter(
facture__in=Facture.objects.filter(
user=self.facture.user
user=invoice.user
).exclude(valid=False))
).filter(
Q(type_cotisation='All') |
......@@ -371,9 +400,9 @@ class Vente(RevMixin, AclMixin, models.Model):
date_start__lt=date_start
).aggregate(Max('date_end'))['date_end__max']
elif self.type_cotisation == "Adhesion":
end_cotisation = self.facture.user.end_adhesion()
end_cotisation = invoice.user.end_adhesion()
else:
end_cotisation = self.facture.user.end_connexion()
end_cotisation = invoice.user.end_connexion()
date_start = date_start or timezone.now()
end_cotisation = end_cotisation or date_start
date_max = max(end_cotisation, date_start)
......@@ -445,6 +474,10 @@ def vente_post_save(**kwargs):
LDAP user when a purchase has been saved.
"""
purchase = kwargs['instance']
try:
purchase.facture.facture
except Facture.DoesNotExist:
return
if hasattr(purchase, 'cotisation'):
purchase.cotisation.vente = purchase
purchase.cotisation.save()
......@@ -462,8 +495,12 @@ def vente_post_delete(**kwargs):
Synchronise the LDAP user after a purchase has been deleted.
"""
purchase = kwargs['instance']
try:
invoice = purchase.facture.facture
except Facture.DoesNotExist:
return
if purchase.type_cotisation:
user = purchase.facture.user
user = invoice.user
user.ldap_sync(base=False, access_refresh=True, mac_refresh=False)
......
{% 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
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 %}
<div class="table-responsive">
{% if custom_invoice_list.paginator %}
{% include 'pagination.html' with list=custom_invoice_list %}
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th>
{% trans "Recipient" as tr_recip %}
{% include 'buttons/sort.html' with prefix='invoice' col='user' text=tr_user %}
</th>
<th>{% trans "Designation" %}</th>
<th>{% trans "Total price" %}</th>
<th>
{% trans "Payment method" as tr_payment_method %}
{% include 'buttons/sort.html' with prefix='invoice' col='payement' text=tr_payment_method %}
</th>
<th>
{% trans "Date" as tr_date %}
{% include 'buttons/sort.html' with prefix='invoice' col='date' text=tr_date %}
</th>
<th>
{% trans "Invoice id" as tr_invoice_id %}
{% include 'buttons/sort.html' with prefix='invoice' col='id' text=tr_invoice_id %}
</th>
<th>{% trans "Paid" %}</th>
<th></th>
<th></th>
</tr>
</thead>
{% for invoice in custom_invoice_list %}
<tr>
<td>{{ invoice.recipient }}</td>
<td>{{ invoice.name }}</td>
<td>{{ invoice.prix_total }}</td>
<td>{{ invoice.payment }}</td>
<td>{{ invoice.date }}</td>
<td>{{ invoice.id }}</td>
<td>{{ invoice.paid }}</td>
<td>
<div class="dropdown">
<button class="btn btn-default dropdown-toggle" type="button" id="editinvoice" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{% trans "Edit" %}<span class="caret"></span>
</button>
<ul class="dropdown-menu" aria-labelledby="editinvoice">
{% can_edit invoice %}
<li>
<a href="{% url 'cotisations:edit-custom-invoice' invoice.id %}">
<i class="fa fa-dollar-sign"></i> {% trans "Edit" %}
</a>
</li>
{% acl_end %}
{% can_delete invoice %}
<li>
<a href="{% url 'cotisations:del-custom-invoice' invoice.id %}">
<i class="fa fa-trash"></i> {% trans "Delete" %}
</a>
</li>
{% acl_end %}
<li>
<a href="{% url 'cotisations:history' 'custominvoice' invoice.id %}">
<i class="fa fa-history"></i> {% trans "Historique" %}
</a>
</li>
</ul>
</div>
</td>
<td>
<a class="btn btn-primary btn-sm" role="button" href="{% url 'cotisations:custom-invoice-pdf' invoice.id %}">
<i class="fa fa-file-pdf"></i> {% trans "PDF" %}
</a>
</td>
</tr>
{% endfor %}
</table>
{% if custom_invoice_list.paginator %}
{% include 'pagination.html' with list=custom_invoice_list %}
{% endif %}
</div>
{% 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 "Custom invoices" %}{% endblock %}
{% block content %}
<h2>{% trans "Custom invoices list" %}</h2>
{% can_create CustomInvoice %}
{% include "buttons/add.html" with href='cotisations:new-custom-invoice'%}
{% acl_end %}
{% include 'cotisations/aff_custom_invoice.html' with custom_invoice_list=custom_invoice_list %}
{% endblock %}
......@@ -28,7 +28,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
{% block sidebar %}
{% can_change Facture pdf %}
<a class="list-group-item list-group-item-success" href="{% url "cotisations:new-facture-pdf" %}">
<a class="list-group-item list-group-item-success" href="{% url "cotisations:new-custom-invoice" %}">
<i class="fa fa-plus"></i> {% trans "Create an invoice" %}
</a>
<a class="list-group-item list-group-item-warning" href="{% url "cotisations:control" %}">
......@@ -40,6 +40,11 @@ with this program; if not, write to the Free Software Foundation, Inc.,
<i class="fa fa-list-ul"></i> {% trans "Invoices" %}
</a>
{% acl_end %}
{% can_view_all CustomInvoice %}
<a class="list-group-item list-group-item-info" href="{% url "cotisations:index-custom-invoice" %}">
<i class="fa fa-list-ul"></i> {% trans "Custom invoices" %}
</a>
{% acl_end %}
{% can_view_all Article %}
<a class="list-group-item list-group-item-info" href="{% url "cotisations:index-article" %}">
<i class="fa fa-list-ul"></i> {% trans "Available articles" %}
......
......@@ -52,9 +52,29 @@ urlpatterns = [
name='facture-pdf'
),
url(
r'^new_facture_pdf/$',
views.new_facture_pdf,
name='new-facture-pdf'
r'^index_custom_invoice/$',
views.index_custom_invoice,
name='index-custom-invoice'
),
url(
r'^new_custom_invoice/$',
views.new_custom_invoice,
name='new-custom-invoice'
),
url(
r'^edit_custom_invoice/(?P<custominvoiceid>[0-9]+)$',
views.edit_custom_invoice,
name='edit-custom-invoice'
),
url(
r'^custom_invoice_pdf/(?P<custominvoiceid>[0-9]+)$',
views.custom_invoice_pdf,
name='custom-invoice-pdf',
),
url(
r'^del_custom_invoice/(?P<custominvoiceid>[0-9]+)$',
views.del_custom_invoice,
name='del-custom-invoice'
),
url(
r'^credit_solde/(?P<userid>[0-9]+)$',
......
......@@ -58,7 +58,15 @@ from re2o.acl import (
can_change,
)
from preferences.models import AssoOption, GeneralOption
from .models import Facture, Article, Vente, Paiement, Banque
from .models import (
Facture,
Article,
Vente,
Paiement,
Banque,
CustomInvoice,
BaseInvoice
)
from .forms import (
FactureForm,
ArticleForm,
......@@ -67,10 +75,10 @@ from .forms import (
DelPaiementForm,
BanqueForm,
DelBanqueForm,
NewFactureFormPdf,
SelectUserArticleForm,
SelectClubArticleForm,
RechargeForm
RechargeForm,
CustomInvoiceForm
)
from .tex import render_invoice
from .payment_methods.forms import payment_method_factory
......@@ -178,10 +186,10 @@ def new_facture(request, user, userid):
# TODO : change facture to invoice
@login_required
@can_change(Facture, 'pdf')
def new_facture_pdf(request):
@can_create(CustomInvoice)
def new_custom_invoice(request):
"""
View used to generate a custom PDF invoice. It's mainly used to
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.
"""
......@@ -190,7 +198,7 @@ def new_facture_pdf(request):
Q(type_user='All') | Q(type_user=request.user.class_name)
)
# Building the invocie form and the article formset
invoice_form = NewFactureFormPdf(request.POST or None)
invoice_form = CustomInvoiceForm(request.POST or None)
if request.user.is_class_club:
articles_formset = formset_factory(SelectClubArticleForm)(
request.POST or None,
......@@ -202,44 +210,31 @@ def new_facture_pdf(request):
form_kwargs={'user': request.user}
)
if invoice_form.is_valid() and articles_formset.is_valid():
# Get the article list and build an list out of it
# contiaining (article_name, article_price, quantity, total_price)
articles_info = []
for articles_form in articles_formset:
if articles_form.cleaned_data:
article = articles_form.cleaned_data['article']
quantity = articles_form.cleaned_data['quantity']
articles_info.append({
'name': article.name,
'price': article.prix,
'quantity': quantity,
'total_price': article.prix * quantity
})
paid = invoice_form.cleaned_data['paid']
recipient = invoice_form.cleaned_data['dest']
address = invoice_form.cleaned_data['chambre']
total_price = sum(a['total_price'] for a in articles_info)
new_invoice_instance = invoice_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=new_invoice_instance,
name=article.name,
prix=article.prix,
type_cotisation=article.type_cotisation,
duration=article.duration,
number=quantity
)
messages.success(
request,
_('The custom invoice was successfully created.')
)
return redirect(reverse('cotisations:index-custom-invoice'))
return render_invoice(request, {
'DATE': timezone.now(),
'recipient_name': recipient,
'address': address,
'article': articles_info,
'total': total_price,
'paid': paid,
'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)
})
return form({
'factureform': invoice_form,
'action_name': _("Create"),
'articlesformset': articles_formset,
'articles': articles
'articlelist': articles
}, 'cotisations/facture.html', request)
......@@ -292,7 +287,7 @@ def facture_pdf(request, facture, **_kwargs):
def edit_facture(request, facture, **_kwargs):
"""
View used to edit an existing invoice.
Articles can be added or remove to the invoice and quantity
Articles can be added or removed to the invoice and quantity
can be set as desired. This is also the view used to invalidate
an invoice.
"""
......@@ -347,6 +342,100 @@ def del_facture(request, facture, **_kwargs):
}, 'cotisations/delete.html', request)
@login_required
@can_edit(CustomInvoice)
def edit_custom_invoice(request, invoice, **kwargs):
# Building the invocie form and the article formset
invoice_form = CustomInvoiceForm(
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 invoice has been successfully edited.")
)
return redirect(reverse('cotisations:index-custom-invoice'))
return form({
'factureform': invoice_form,
'venteform': purchase_form
}, 'cotisations/edit_facture.html', request)
@login_required
@can_view(CustomInvoice)
def custom_invoice_pdf(request, invoice, **_kwargs):
"""
View used to generate a PDF file from an existing invoice 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': 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)
})
# TODO : change facture to invoice
@login_required
@can_delete(CustomInvoice)
def del_custom_invoice(request, invoice, **_kwargs):
"""
View used to delete an existing invocie.
"""
if request.method == "POST":
invoice.delete()
messages.success(
request,
_("The invoice has been successfully deleted.")
)
return redirect(reverse('cotisations:index-custom-invoice'))
return form({
'objet': invoice,
'objet_name'