eo_facture: add attached files to Factur-X invoices (#83897)
This commit is contained in:
parent
1ad965ffa1
commit
0c29acb6e2
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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]),
|
||||
]
|
||||
|
|
Loading…
Reference in New Issue