eo_facture: add attached files to Factur-X invoices (#83897)

This commit is contained in:
Benjamin Dauvergne 2024-02-20 16:15:36 +01:00
parent 1ad965ffa1
commit 0c29acb6e2
5 changed files with 123 additions and 7 deletions

View File

@ -375,9 +375,13 @@ def index(facture):
return format_html('{0}', ordinal(facture.index()))
class PieceJointeInline(admin.TabularInline):
model = models.PieceJointe
class FactureAdmin(LookupAllowed, admin.ModelAdmin):
form = forms.FactureForm
inlines = [LigneInline, PaymentInline]
inlines = [LigneInline, PieceJointeInline, PaymentInline]
list_display = [
'column_code',
index,

View File

@ -14,6 +14,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import logging
import os
import subprocess
@ -35,11 +36,11 @@ def to_pdfa(pdf_bytes: bytes, icc_profile: str = DEFAULT_ICC_PROFILE):
input_fd.flush()
args = [
'gs',
'-dPDFA',
'-dPDFA=3',
'-dBATCH',
'-dNOPAUSE',
'-sICCProfile=' + icc_profile,
'-sProcessColorModel=DeviceCMYK',
'-sProcessColorModel=DeviceRGB',
'-sDEVICE=pdfwrite',
'-dPDFACompatibilityPolicy=2',
# ghostscript needs percent character in output filename to be quoted
@ -68,7 +69,10 @@ def add_facturx_from_bytes(
attachments: typing.Sequence[tuple[str, bytes]] = (),
):
pdfa_bytes = to_pdfa(pdf_bytes)
attachments_dict = {filename: {'filedata': content} for filename, content in attachments}
attachments_dict = {
filename: {'filedata': content, 'modification_datetime': datetime.datetime.now()}
for filename, content in attachments
}
facturx_xml = str(render_to_string(template, facturx_context)).encode('utf-8')
with tempfile.NamedTemporaryFile(prefix='invoice-facturx-', suffix='.pdf') as f:
f.write(pdfa_bytes)

View File

@ -0,0 +1,38 @@
# Generated by Django 3.2.23 on 2024-02-20 15:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('eo_facture', '0022_auto_20240111_1328'),
]
operations = [
migrations.CreateModel(
name='PieceJointe',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('nom', models.TextField(blank=True, verbose_name='Nom')),
('fichier', models.FileField(upload_to='', verbose_name='Fichier')),
(
'facture',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='pieces_jointes',
to='eo_facture.facture',
verbose_name='Facture',
),
),
],
options={
'verbose_name': 'Pièce jointe',
'verbose_name_plural': 'Pièces jointes',
'ordering': ('nom',),
},
),
]

View File

@ -20,12 +20,14 @@ import functools
import hashlib
import itertools
import os.path
import urllib.parse
from collections import defaultdict
from decimal import ROUND_HALF_UP, Decimal
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
@ -35,7 +37,7 @@ from django.db.models.signals import post_delete, post_save
from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from weasyprint import HTML
from weasyprint import HTML, default_url_fetcher
from eo_gestion.utils import percentage_str
@ -57,6 +59,21 @@ def today():
return now().date()
def my_url_fetcher(url):
parsed = urllib.parse.urlparse(url)
if (path := parsed.path).startswith('/static/'):
result = finders.find(path.split('/static/', 1)[1])
ext = path.rsplit('.', 1)[1]
# the facture.html template refers only to .css and .png files, add
# more mime-types if other types are used in the future
mime_types = {
'css': 'text/css',
'png': 'image/png',
}
return {'file_obj': open(result, 'rb'), 'mime_type': mime_types.get(ext, 'application/octet-stream')}
return default_url_fetcher(url)
class Client(models.Model):
nom = models.CharField(max_length=255, unique=True)
notes_privees = models.TextField(verbose_name='Notes privées', blank=True)
@ -596,7 +613,7 @@ class Facture(models.Model):
with open(cache_filepath, 'rb') as fd:
return fd.read()
html = HTML(string=html_content)
html = HTML(string=html_content, url_fetcher=my_url_fetcher)
pdf = html.write_pdf()
if cache_filepath:
@ -626,8 +643,14 @@ class Facture(models.Model):
'taux_tva': self.taux_tva or (self.contrat and self.contrat.tva) or self.client.tva or 0,
'annulation_code': self.annulation.code() if self.annulation else None,
}
attachments = []
for piece_jointe in self.pieces_jointes.all():
with piece_jointe.fichier.open() as fd:
attachments.append(
(piece_jointe.nom or os.path.basename(piece_jointe.fichier.name), fd.read())
)
return facturx.add_facturx_from_bytes(
self.pdf(template_name=template_name, base_uri=base_uri), facturx_ctx
self.pdf(template_name=template_name, base_uri=base_uri), facturx_ctx, attachments=attachments
)
def cancel(self, creator):
@ -829,6 +852,23 @@ class Payment(models.Model):
ordering = ('-ligne_banque_pop__date_valeur',)
class PieceJointe(models.Model):
facture = models.ForeignKey(
Facture, related_name='pieces_jointes', on_delete=models.CASCADE, verbose_name='Facture'
)
nom = models.TextField(blank=True, verbose_name='Nom')
fichier = models.FileField(verbose_name='Fichier')
def clean(self):
if not self.nom:
self.nom = os.path.basename(self.fichier.name)
class Meta:
verbose_name = 'Pièce jointe'
verbose_name_plural = 'Pièces jointes'
ordering = ('nom',)
def update_paid(sender, instance, raw=False, **kwargs):
if raw:
return

View File

@ -14,14 +14,21 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import io
import pypdf
import pytest
from django.contrib.auth import get_user_model
from webtest import Upload
from eo_gestion.eo_facture.models import Client, Contrat
from eo_gestion.eo_redmine.models import Project
User = get_user_model()
# https://stackoverflow.com/questions/2438800/what-is-the-smallest-legal-zip-jar-file
MINIMAL_ZIP_FILE = b'PK\x05\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
class TestLoggedIn:
@pytest.fixture
@ -118,3 +125,26 @@ class TestLoggedIn:
response = response.click('plus de 12 mois')
assert len(response.pyquery('tbody tr')) == 1
def test_facture_avec_piece_jointe_facturx(self, app):
response = app.get('/eo_facture/contrat/add/')
response.form['client'].force_value('1')
response.form['intitule'] = 'Contrat 1'
response = response.form.submit('_continue').follow()
response = response.click('Ajouter une facture', index=0)
response.form.set('proforma', False)
response.form.set('pieces_jointes-0-nom', 'pj1.zip')
response.form.set('pieces_jointes-0-fichier', Upload('pj1.zip', MINIMAL_ZIP_FILE, 'application/zip'))
response.form.set('pieces_jointes-1-nom', 'pj2.zip')
response.form.set('pieces_jointes-1-fichier', Upload('pj2.zip', MINIMAL_ZIP_FILE, 'application/zip'))
response = response.form.submit('_continue').follow()
facture = app.get(response.request.path.split('change')[0] + 'view_pdf/xxx.pdf?facturx')
pdf_reader = pypdf.PdfReader(io.BytesIO(facture.content))
assert [(k, v) for k, v in pdf_reader.attachments.items() if k != 'factur-x.xml'] == [
('pj1.zip', [MINIMAL_ZIP_FILE]),
('pj2.zip', [MINIMAL_ZIP_FILE]),
]