Browse Source

Merge branch 'static_pdf_invoices' into django17_AbstractU_Polymorph

Fabs 10 years ago
parent
commit
2ca9ab8f60

+ 2 - 0
.gitignore

@@ -8,5 +8,7 @@ coin/settings_local.py
 .pydevproject
 .settings/*
 /static
+/media
+/smedia
 .idea
 venv

+ 103 - 15
coin/billing/admin.py

@@ -1,8 +1,14 @@
 # -*- coding: utf-8 -*-
 from django import forms
 from django.contrib import admin
+from django.contrib import messages
+from django.http import HttpResponseRedirect
+from django.conf.urls import url
+from django.contrib.admin.util import flatten_fieldsets
+
 from coin.filtering_queryset import LimitedAdminInlineMixin
 from coin.billing.models import Invoice, InvoiceDetail, Payment
+from coin.billing.utils import get_invoice_from_id_or_number
 from coin.offers.models import OfferSubscription
 import autocomplete_light
 
@@ -30,6 +36,32 @@ class InvoiceDetailInline(LimitedAdminInlineMixin, admin.StackedInline):
         return self.readonly_fields
 
 
+class InvoiceDetailInlineReadOnly(admin.StackedInline):
+    """
+    Lorsque la facture est validée, il n'est plus possible de la modifier
+    Ce inline est donc identique à InvoiceDetailInline, mais tous
+    les champs sont en lecture seule
+    """
+    model = InvoiceDetail
+    extra = 0
+    fields = InvoiceDetailInline.fields
+    can_delete = False
+
+    def has_add_permission(self, request):
+        return False
+
+    def get_readonly_fields(self, request, obj=None):
+        if self.declared_fieldsets:
+            result = flatten_fieldsets(self.declared_fieldsets)
+        else:
+            result = list(set(
+                    [field.name for field in self.opts.local_fields] +
+                    [field.name for field in self.opts.local_many_to_many]
+                ))
+            result.remove('id')
+        return result
+
+
 class PaymentInline(admin.StackedInline):
     model = Payment
     extra = 0
@@ -37,29 +69,85 @@ class PaymentInline(admin.StackedInline):
 
 
 class InvoiceAdmin(admin.ModelAdmin):
-    list_display = ('number', 'date', 'status', 'amount', 'member')
+    list_display = ('number', 'date', 'status', 'amount', 'member', 'validated')
     list_display_links = ('number', 'date')
-    inlines = [InvoiceDetailInline, PaymentInline]
     fields = (('number', 'date', 'status'),
        ('date_due'),
        ('member'),
-       ('amount','amount_paid'))
-    readonly_fields = ('amount','amount_paid')
+       ('amount','amount_paid'),
+       ('validated', 'pdf'))
+    readonly_fields = ('amount','amount_paid','validated','pdf')
     form = autocomplete_light.modelform_factory(Invoice)
 
-    def get_formsets(self, request, obj=None):
+    def get_readonly_fields(self, request, obj=None):
+        """
+        Si la facture est validée, passe tous les champs en readonly
+        """
+        if obj and obj.validated:
+            if self.declared_fieldsets:
+                return flatten_fieldsets(self.declared_fieldsets)
+            else:
+                return list(set(
+                    [field.name for field in self.opts.local_fields] +
+                    [field.name for field in self.opts.local_many_to_many]
+                ))
+        return self.readonly_fields
+
+    def get_inline_instances(self, request, obj=None):
         """
-        Lorsque l'on est en création d'objet (obj=None) alors ne renvoi pas les
-        formsets des inlines.
-        Cela permet de ne pas afficher les champs détails de facture et paiement
-        tant que la facture n'a pas été enregistré.
-        Cette subtilité permet de s'assurer que le select "Abonnement" de
-        InvoiceDetail est bien filtré avec le member de la facture
+        Renvoi les inlines selon le context :
+        * Si création, alors ne renvoi aucun inline
+        * Si modification, renvoi InvoiceDetail et PaymentInline
+        * Si facture validée, renvoi InvoiceDetail en ReadOnly et PaymentInline
         """
-        if obj:
-            for _ in super(InvoiceAdmin, self).get_formsets(request, obj):
-                yield _
+        inlines = []
+        inline_instances = []
+
+        if obj is not None:
+            if obj.validated:
+                inlines = [InvoiceDetailInlineReadOnly]
+            else:
+                inlines = [InvoiceDetailInline]
+
+            inlines += [PaymentInline]
+
+        for inline_class in inlines:
+            inline = inline_class(self.model, self.admin_site)
+
+            if request:
+                if not (inline.has_add_permission(request) or
+                        inline.has_change_permission(request) or
+                        inline.has_delete_permission(request)):
+                    continue
+                if not inline.has_add_permission(request):
+                    inline.max_num = 0
+            inline_instances.append(inline)
+
+        return inline_instances
+
+    def get_urls(self):
+        """
+        Custom admin urls
+        """
+        urls = super(InvoiceAdmin, self).get_urls()
+        my_urls = [
+            url(r'^validate/(?P<id>.+)$', self.admin_site.admin_view(self.validate_view),
+                name='invoice_validate'),
+        ]
+        return my_urls + urls
+
+    def validate_view(self, request, id):
+        """
+        Vue appelée lorsque l'admin souhaite valider une facture (et générer le pdf)
+        """
+        #TODO : Add better perm here
+        if request.user.is_superuser:
+            invoice = get_invoice_from_id_or_number(id)
+            invoice.validate()
+            messages.success(request, 'La facture a été validée.')
         else:
-            pass
+            messages.error(request, 'Vous n\'avez pas l\'autorisation de valider une facture.')
+
+        return HttpResponseRedirect(request.META["HTTP_REFERER"])
 
 admin.site.register(Invoice, InvoiceAdmin)

+ 26 - 0
coin/billing/migrations/0003_auto_20140920_2342.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0002_auto_20140919_2158'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='invoice',
+            name='pdf',
+            field=models.FileField(upload_to=b'invoices', null=True, verbose_name='Facture en PDF', blank=True),
+            preserve_default=True,
+        ),
+        migrations.AddField(
+            model_name='invoice',
+            name='validated',
+            field=models.BooleanField(default=False, verbose_name=b'Facture valid\xc3\xa9e'),
+            preserve_default=True,
+        ),
+    ]

+ 26 - 0
coin/billing/migrations/0004_auto_20140921_1454.py

@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import coin.billing.models
+import coin.utils
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('billing', '0003_auto_20140920_2342'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoice',
+            name='pdf',
+            field=models.FileField(storage=coin.utils.private_files_storage, upload_to=coin.billing.models.invoice_pdf_filename, null=True, verbose_name='PDF', blank=True),
+        ),
+        migrations.AlterField(
+            model_name='invoice',
+            name='validated',
+            field=models.BooleanField(default=False, verbose_name=b'Valid\xc3\xa9e'),
+        ),
+    ]

+ 32 - 2
coin/billing/models.py

@@ -1,12 +1,18 @@
 # -*- coding: utf-8 -*-
 import datetime
 import random
+import uuid
+import os
 from decimal import Decimal
+
 from django.db import models
 from django.db.models.signals import post_save
 from django.dispatch import receiver
+
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
+from coin.html2pdf import render_as_pdf
+from coin.utils import private_files_storage
 
 
 def next_invoice_number():
@@ -16,22 +22,25 @@ def next_invoice_number():
                               random.randrange(100, 999),
                               random.randrange(100, 999))
 
+def invoice_pdf_filename(instance, filename):
+    "Nom du fichier pdf à stocker pour les factures"
+    return u'invoices/%d_%s_%s.pdf' % (getattr(instance,'member.id',0), instance.number, uuid.uuid4())
 
 class Invoice(models.Model):
 
     INVOICES_STATUS_CHOICES = (
-        ('draft', u'Brouillon'),
         ('open', u'A payer'),
         ('closed', u'Reglée'),
         ('trouble', u'Litige')
     )
 
+    validated = models.BooleanField(default=False, verbose_name='Validée')
     number = models.CharField(max_length=25,
                               default=next_invoice_number,
                               unique=True,
                               verbose_name='Numéro')
     status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
-                              default='draft',
+                              default='open',
                               verbose_name='Statut')
     date = models.DateField(default=datetime.date.today, null=True)
     date_due = models.DateField(
@@ -44,6 +53,9 @@ class Invoice(models.Model):
                                related_name='invoices',
                                verbose_name='Membre',
                                on_delete=models.SET_NULL)
+    pdf = models.FileField(storage=private_files_storage, upload_to=invoice_pdf_filename,
+                           null=True, blank=True,
+                           verbose_name=u'PDF')
 
     def amount(self):
         "Calcul le montant de la facture en fonction des éléments de détails"
@@ -75,6 +87,24 @@ class Invoice(models.Model):
         "Check if passed username (ex gmajax) is owner of the invoice"
         return (self.member.username == username)
 
+    def generate_pdf(self):
+        "Make and store a pdf file for the invoice"
+        pdf_file = render_as_pdf('billing/invoice_pdf.html', {"invoice": self})
+        self.pdf.save(u'%s.pdf' % self.number, pdf_file)
+
+    def validate(self):
+        """
+        Switch invoice to validate mode. This set to False the draft field
+        and generate the pdf
+        """
+        if not self.is_pdf_exists():
+            self.validated = True
+            self.save()
+            self.generate_pdf()
+
+    def is_pdf_exists(self):
+        return self.validated and bool(self.pdf) and private_files_storage.exists(self.pdf.name)
+
     def __unicode__(self):
         return u'#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
 

+ 3 - 1
coin/billing/urls.py

@@ -4,7 +4,9 @@ from coin.billing import views
 
 urlpatterns = patterns(
     '',
-    url(r'^invoice/(?P<id>.+).pdf$', views.invoice_pdf, name="invoice_pdf"),
+    url(r'^invoice/(?P<id>.+)/pdf$', views.invoice_pdf, name="invoice_pdf"),
     url(r'^invoice/(?P<id>.+)$', views.invoice, name="invoice"),
+    # url(r'^invoice/(?P<id>.+)/validate$', views.invoice_validate, name="invoice_validate"),
+    
     url('invoice/create_all_members_invoices_for_a_period', views.gen_invoices)
 )

+ 8 - 9
coin/billing/views.py

@@ -1,8 +1,12 @@
 # -*- coding: utf-8 -*-
-from django.http import HttpResponse
+from django.http import HttpResponse, HttpResponseRedirect
 from django.template import RequestContext
 from django.shortcuts import render, render_to_response
 from django.core.exceptions import PermissionDenied
+from django.contrib import messages
+
+from sendfile import sendfile
+
 from coin.billing.models import Invoice
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
@@ -18,21 +22,16 @@ def invoice_pdf(request, id):
     Renvoi une facture générée en format pdf
     id peut être soit la pk d'une facture, soit le numero de facture
     """
-
     invoice = get_invoice_from_id_or_number(id)
 
     if not invoice.has_owner(request.user.username)\
        and not request.user.is_superuser:
         raise PermissionDenied
 
-    pdf = render_as_pdf('billing/invoice_pdf.html', {"invoice": invoice})
+    pdf_filename = 'Facture_%s.pdf' % invoice.number
 
-    response = HttpResponse(content_type='application/pdf')
-    #response['Content-Disposition'] = 'attachment; filename="facture.pdf"'
-
-    response.write(pdf)
-
-    return response
+    return sendfile(request, invoice.pdf.path,
+                    attachment=True, attachment_filename=pdf_filename)
 
 def invoice(request, id):
     """

+ 8 - 8
coin/html2pdf.py

@@ -1,10 +1,12 @@
 # -*- coding: utf-8 -*-
 import os
 import re
-from django.conf import settings
-from tempfile import NamedTemporaryFile
 from xhtml2pdf import pisa
+from tempfile import NamedTemporaryFile
+
+from django.conf import settings
 from django.template import loader, Context
+from django.core.files import File
 
 
 def link_callback(uri, rel):
@@ -42,16 +44,14 @@ def link_callback(uri, rel):
 def render_as_pdf(template, context):
     """
     Génére le template indiqué avec les données du context en HTML et le
-    converti en PDF via le module xhtml2pdf
+    converti en PDF via le module xhtml2pdf.
+    Renvoi un objet de type File
     """
     template = loader.get_template(template)
     html = template.render(Context(context))
     file = NamedTemporaryFile()
 
     pisaStatus = pisa.CreatePDF(html, dest=file, link_callback=link_callback)
+    file.flush()
 
-    file.seek(0)
-    pdf = file.read()
-    file.close()
-
-    return pdf
+    return File(open(file.name))

+ 10 - 0
coin/settings.py

@@ -111,6 +111,16 @@ STATICFILES_FINDERS = (
     #'django.contrib.staticfiles.finders.DefaultStorageFinder',
 )
 
+# Location of private files. (Like invoices)
+# In production, this location should not be publicly accessible through
+# the web server
+PRIVATE_FILES_ROOT = os.path.join(BASE_DIR, 'smedia/')
+
+# Backend to use when sending private files to client
+# In production, must be sendfile.backends.xsendfile with Apache xsend file mod
+# https://github.com/johnsensible/django-sendfile
+SENDFILE_BACKEND = 'sendfile.backends.development'
+
 # Make this unique, and don't share it with anybody.
 SECRET_KEY = '!qy_)gao6q)57#mz1s-d$5^+dp1nt=lk1d19&9bb3co37vn)!3'
 

+ 5 - 1
coin/templates/admin/billing/invoice/change_form.html

@@ -3,7 +3,11 @@
 {% block object-tools %}
 {% if change %}{% if not is_popup %}
 <ul class="object-tools">
-   <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger en PDF</a></li>
+    {% if not original.validated %}
+        <li><a href="{% url 'admin:invoice_validate' id=object_id %}">Valider la facture</a></li>
+    {% elif original.is_pdf_exists %}
+        <li><a href="{% url 'billing:invoice_pdf' id=object_id %}">Télécharger le PDF</a></li>
+    {% endif %}
 </ul>
 {% endif %}{% endif %}
 {% endblock %}

+ 4 - 0
coin/urls.py

@@ -1,4 +1,6 @@
+from django.conf import settings
 from django.conf.urls import patterns, include, url
+from django.conf.urls.static import static
 from django.contrib.staticfiles.urls import staticfiles_urlpatterns
 
 import autocomplete_light
@@ -29,3 +31,5 @@ urlpatterns = patterns(
 )
 
 urlpatterns += staticfiles_urlpatterns()
+urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+

+ 5 - 0
coin/utils.py

@@ -6,8 +6,13 @@ import base64
 import html2text
 from django.core.mail import EmailMultiAlternatives
 from django.template import TemplateDoesNotExist
+from django.core.files.storage import FileSystemStorage
+from django.conf import settings
 
 
+# Stockage des fichiers privés (comme les factures par exemple)
+private_files_storage = FileSystemStorage(location=settings.PRIVATE_FILES_ROOT)
+
 def str_or_none(obj):
     return str(obj) if obj else None
 

+ 1 - 0
requirements.txt

@@ -8,6 +8,7 @@ reportlab==2.5
 django-activelink==0.4
 html2text
 django-polymorphic==0.5.6
+django-sendfile==0.3.6
 -e git+https://github.com/jmacul2/django-postgresql-netfields@2d6e597c3d65ba8b0e1f6e3183869216e990e915#egg=django-netfields
 -e git+https://github.com/chrisglass/xhtml2pdf@a5d37ffd0ccb0603bdf668198de0f21766816104#egg=xhtml2pdf-master
 -e git+https://github.com/jlaine/django-ldapdb@1c4f9f29e52176f4367a1dffec2ecd2e123e2e7a#egg=django-ldapdb