Parcourir la source

Merge branch 'static_pdf_invoices' into django17_AbstractU_Polymorph

Fabs il y a 10 ans
Parent
commit
2ca9ab8f60

+ 2 - 0
.gitignore

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

+ 103 - 15
coin/billing/admin.py

@@ -1,8 +1,14 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 from django import forms
 from django import forms
 from django.contrib import admin
 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.filtering_queryset import LimitedAdminInlineMixin
 from coin.billing.models import Invoice, InvoiceDetail, Payment
 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
 from coin.offers.models import OfferSubscription
 import autocomplete_light
 import autocomplete_light
 
 
@@ -30,6 +36,32 @@ class InvoiceDetailInline(LimitedAdminInlineMixin, admin.StackedInline):
         return self.readonly_fields
         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):
 class PaymentInline(admin.StackedInline):
     model = Payment
     model = Payment
     extra = 0
     extra = 0
@@ -37,29 +69,85 @@ class PaymentInline(admin.StackedInline):
 
 
 
 
 class InvoiceAdmin(admin.ModelAdmin):
 class InvoiceAdmin(admin.ModelAdmin):
-    list_display = ('number', 'date', 'status', 'amount', 'member')
+    list_display = ('number', 'date', 'status', 'amount', 'member', 'validated')
     list_display_links = ('number', 'date')
     list_display_links = ('number', 'date')
-    inlines = [InvoiceDetailInline, PaymentInline]
     fields = (('number', 'date', 'status'),
     fields = (('number', 'date', 'status'),
        ('date_due'),
        ('date_due'),
        ('member'),
        ('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)
     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:
         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)
 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 -*-
 # -*- coding: utf-8 -*-
 import datetime
 import datetime
 import random
 import random
+import uuid
+import os
 from decimal import Decimal
 from decimal import Decimal
+
 from django.db import models
 from django.db import models
 from django.db.models.signals import post_save
 from django.db.models.signals import post_save
 from django.dispatch import receiver
 from django.dispatch import receiver
+
 from coin.offers.models import OfferSubscription
 from coin.offers.models import OfferSubscription
 from coin.members.models import Member
 from coin.members.models import Member
+from coin.html2pdf import render_as_pdf
+from coin.utils import private_files_storage
 
 
 
 
 def next_invoice_number():
 def next_invoice_number():
@@ -16,22 +22,25 @@ def next_invoice_number():
                               random.randrange(100, 999),
                               random.randrange(100, 999),
                               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):
 class Invoice(models.Model):
 
 
     INVOICES_STATUS_CHOICES = (
     INVOICES_STATUS_CHOICES = (
-        ('draft', u'Brouillon'),
         ('open', u'A payer'),
         ('open', u'A payer'),
         ('closed', u'Reglée'),
         ('closed', u'Reglée'),
         ('trouble', u'Litige')
         ('trouble', u'Litige')
     )
     )
 
 
+    validated = models.BooleanField(default=False, verbose_name='Validée')
     number = models.CharField(max_length=25,
     number = models.CharField(max_length=25,
                               default=next_invoice_number,
                               default=next_invoice_number,
                               unique=True,
                               unique=True,
                               verbose_name='Numéro')
                               verbose_name='Numéro')
     status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
     status = models.CharField(max_length=50, choices=INVOICES_STATUS_CHOICES,
-                              default='draft',
+                              default='open',
                               verbose_name='Statut')
                               verbose_name='Statut')
     date = models.DateField(default=datetime.date.today, null=True)
     date = models.DateField(default=datetime.date.today, null=True)
     date_due = models.DateField(
     date_due = models.DateField(
@@ -44,6 +53,9 @@ class Invoice(models.Model):
                                related_name='invoices',
                                related_name='invoices',
                                verbose_name='Membre',
                                verbose_name='Membre',
                                on_delete=models.SET_NULL)
                                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):
     def amount(self):
         "Calcul le montant de la facture en fonction des éléments de détails"
         "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"
         "Check if passed username (ex gmajax) is owner of the invoice"
         return (self.member.username == username)
         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):
     def __unicode__(self):
         return u'#%s %0.2f€ %s' % (self.number, self.amount(), self.date_due)
         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(
 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>.+)$', 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)
     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 -*-
 # -*- coding: utf-8 -*-
-from django.http import HttpResponse
+from django.http import HttpResponse, HttpResponseRedirect
 from django.template import RequestContext
 from django.template import RequestContext
 from django.shortcuts import render, render_to_response
 from django.shortcuts import render, render_to_response
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
+from django.contrib import messages
+
+from sendfile import sendfile
+
 from coin.billing.models import Invoice
 from coin.billing.models import Invoice
 from coin.members.models import Member
 from coin.members.models import Member
 from coin.html2pdf import render_as_pdf
 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
     Renvoi une facture générée en format pdf
     id peut être soit la pk d'une facture, soit le numero de facture
     id peut être soit la pk d'une facture, soit le numero de facture
     """
     """
-
     invoice = get_invoice_from_id_or_number(id)
     invoice = get_invoice_from_id_or_number(id)
 
 
     if not invoice.has_owner(request.user.username)\
     if not invoice.has_owner(request.user.username)\
        and not request.user.is_superuser:
        and not request.user.is_superuser:
         raise PermissionDenied
         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):
 def invoice(request, id):
     """
     """

+ 8 - 8
coin/html2pdf.py

@@ -1,10 +1,12 @@
 # -*- coding: utf-8 -*-
 # -*- coding: utf-8 -*-
 import os
 import os
 import re
 import re
-from django.conf import settings
-from tempfile import NamedTemporaryFile
 from xhtml2pdf import pisa
 from xhtml2pdf import pisa
+from tempfile import NamedTemporaryFile
+
+from django.conf import settings
 from django.template import loader, Context
 from django.template import loader, Context
+from django.core.files import File
 
 
 
 
 def link_callback(uri, rel):
 def link_callback(uri, rel):
@@ -42,16 +44,14 @@ def link_callback(uri, rel):
 def render_as_pdf(template, context):
 def render_as_pdf(template, context):
     """
     """
     Génére le template indiqué avec les données du context en HTML et le
     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)
     template = loader.get_template(template)
     html = template.render(Context(context))
     html = template.render(Context(context))
     file = NamedTemporaryFile()
     file = NamedTemporaryFile()
 
 
     pisaStatus = pisa.CreatePDF(html, dest=file, link_callback=link_callback)
     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',
     #'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.
 # Make this unique, and don't share it with anybody.
 SECRET_KEY = '!qy_)gao6q)57#mz1s-d$5^+dp1nt=lk1d19&9bb3co37vn)!3'
 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 %}
 {% block object-tools %}
 {% if change %}{% if not is_popup %}
 {% if change %}{% if not is_popup %}
 <ul class="object-tools">
 <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>
 </ul>
 {% endif %}{% endif %}
 {% endif %}{% endif %}
 {% endblock %}
 {% 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 import patterns, include, url
+from django.conf.urls.static import static
 from django.contrib.staticfiles.urls import staticfiles_urlpatterns
 from django.contrib.staticfiles.urls import staticfiles_urlpatterns
 
 
 import autocomplete_light
 import autocomplete_light
@@ -29,3 +31,5 @@ urlpatterns = patterns(
 )
 )
 
 
 urlpatterns += staticfiles_urlpatterns()
 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
 import html2text
 from django.core.mail import EmailMultiAlternatives
 from django.core.mail import EmailMultiAlternatives
 from django.template import TemplateDoesNotExist
 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):
 def str_or_none(obj):
     return str(obj) if obj else None
     return str(obj) if obj else None
 
 

+ 1 - 0
requirements.txt

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