diff --git a/.dockerignore b/.dockerignore index 5aae374..bcdeca7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,5 @@ # Translations *.mo *.pot + +media/ diff --git a/.gitignore b/.gitignore index b929545..61ee413 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,6 @@ cython_debug/ # Drawio .*.drawio.bkp + +# Django media +media/ diff --git a/facturio/settings/base.py b/facturio/settings/base.py index 3cbb4f3..c4b243d 100644 --- a/facturio/settings/base.py +++ b/facturio/settings/base.py @@ -19,7 +19,6 @@ env = Env() # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent.parent -INTERNAL_BASE_URL = "http://backend:8000" # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -32,6 +31,8 @@ DEBUG = env.bool('DEBUG', default=True) ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', str, default=['*']) +MEDIA_ROOT = BASE_DIR / 'media' + # Application definition DJANGO_APPS = [ 'django.contrib.admin', @@ -152,7 +153,8 @@ CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5' CRISPY_TEMPLATE_PACK = 'bootstrap5' # Template to PDF rendering -GOTENBERG_API_URL = env.url('GOTENBERG_API_URL', 'http://gotenberg:3000') +GOTENBERG_API_URL = env.str('GOTENBERG_API_URL', 'http://gotenberg:3000') +INTERNAL_API_URL = env.str('INTERNAL_API_URL', "http://backend:8000") # Dramatiq DRAMATIQ_BROKER = { diff --git a/invoices/admin.py b/invoices/admin.py index e6f087c..b35cb16 100644 --- a/invoices/admin.py +++ b/invoices/admin.py @@ -2,6 +2,14 @@ from django.contrib import admin from . import models +class InvoiceFileInline(admin.TabularInline): + model = models.InvoiceFile + extra = 0 + readonly_fields = [ + 'created_date', + 'file', + ] + can_delete = False class InvoiceItemInline(admin.TabularInline): model = models.InvoiceItem @@ -18,4 +26,7 @@ class InvoiceItemInline(admin.TabularInline): @admin.register(models.Invoice) class InvoiceAdmin(admin.ModelAdmin): list_display = ['__str__', 'invoice_date'] - inlines = [InvoiceItemInline] + inlines = [ + InvoiceItemInline, + InvoiceFileInline, + ] diff --git a/invoices/migrations/0004_invoicefile.py b/invoices/migrations/0004_invoicefile.py new file mode 100644 index 0000000..a430f34 --- /dev/null +++ b/invoices/migrations/0004_invoicefile.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-03 13:39 +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0003_alter_invoice_customer_alter_invoice_customer_data_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='InvoiceFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(blank=True, null=True, upload_to='invoice-files', verbose_name='File')), + ('created_date', models.DateTimeField(auto_now=True, verbose_name='Created Date')), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='invoices.invoice')), + ], + options={ + 'verbose_name': 'Invoice File', + 'verbose_name_plural': 'Invoice Files', + }, + ), + ] diff --git a/invoices/migrations/0005_alter_invoicefile_file.py b/invoices/migrations/0005_alter_invoicefile_file.py new file mode 100644 index 0000000..90a379f --- /dev/null +++ b/invoices/migrations/0005_alter_invoicefile_file.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-03 13:50 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ('invoices', '0004_invoicefile'), + ] + + operations = [ + migrations.AlterField( + model_name='invoicefile', + name='file', + field=models.FileField(blank=True, null=True, upload_to='invoice-files/%Y/%m/%d/', verbose_name='File'), + ), + ] diff --git a/invoices/models.py b/invoices/models.py index 7a4072d..404161c 100644 --- a/invoices/models.py +++ b/invoices/models.py @@ -65,3 +65,19 @@ class InvoiceItem(models.Model): def __str__(self): return f'{self.id} -> {self.invoice.id}' + + +class InvoiceFile(models.Model): + class Meta: + verbose_name = _('Invoice File') + verbose_name_plural = _('Invoice Files') + + invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='files') + file = models.FileField(_('File'), upload_to='invoice-files/%Y/%m/%d/', null=True, blank=True) + created_date = models.DateTimeField(_('Created Date'), auto_now=True) + + def __str__(self): + return self.file.name + + def get_file_name(self, ext: str) -> str: + return f"{self.invoice.id}-{self.created_date.strftime('%H%M%S')}.{ext}" diff --git a/invoices/tasks.py b/invoices/tasks.py index f0b0294..db17b3f 100644 --- a/invoices/tasks.py +++ b/invoices/tasks.py @@ -1,6 +1,20 @@ import dramatiq +from django.conf import settings +from django.core.files.base import ContentFile +from django.urls import reverse +from gotenberg_client import GotenbergClient + +from invoices.models import Invoice + @dramatiq.actor() -def generate_pdf(url: str): - print(url) - pass +def generate_pdf(invoice_id: int): + invoice = Invoice.objects.get(pk=invoice_id) + print_url = settings.INTERNAL_API_URL + reverse('invoices:print_invoice', kwargs=dict(invoice_id=invoice.id)) + + invoice_file = invoice.files.create() + + with GotenbergClient(settings.GOTENBERG_API_URL) as client: + with client.chromium.url_to_pdf() as task: + res = task.url(print_url).run() + invoice_file.file.save(invoice_file.get_file_name('pdf'), ContentFile(res.content)) diff --git a/invoices/views.py b/invoices/views.py index d14207a..d129fda 100644 --- a/invoices/views.py +++ b/invoices/views.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import transaction from django.http import HttpRequest @@ -27,7 +26,7 @@ def home(req: HttpRequest) -> HttpResponse: if formset.is_valid(): formset.save() tasks.generate_pdf.send( - settings.INTERNAL_BASE_URL + reverse('invoices:print_invoice', kwargs=dict(invoice_id=invoice.id)), + invoice.id, ) return redirect(reverse('invoices:invoice', kwargs=dict(invoice_id=invoice.id))) else: diff --git a/pyproject.toml b/pyproject.toml index e0dfaa9..9ca9c27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "django-environ>=0.12.0", "django-post-office>=3.9.1", "dramatiq[redis]>=1.17.1", + "gotenberg-client>=0.9.0", "psycopg[c]>=3.2.5", "uvicorn[standard]>=0.34.0", "wait-for-it>=2.3.0", diff --git a/uv.lock b/uv.lock index 26bfd56..1455b32 100644 --- a/uv.lock +++ b/uv.lock @@ -241,6 +241,7 @@ dependencies = [ { name = "django-environ" }, { name = "django-post-office" }, { name = "dramatiq", extra = ["redis"] }, + { name = "gotenberg-client" }, { name = "psycopg", extra = ["c"] }, { name = "uvicorn", extra = ["standard"] }, { name = "wait-for-it" }, @@ -261,6 +262,7 @@ requires-dist = [ { name = "django-environ", specifier = ">=0.12.0" }, { name = "django-post-office", specifier = ">=3.9.1" }, { name = "dramatiq", extras = ["redis"], specifier = ">=1.17.1" }, + { name = "gotenberg-client", specifier = ">=0.9.0" }, { name = "psycopg", extras = ["c"], specifier = ">=3.2.5" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "wait-for-it", specifier = ">=2.3.0" }, @@ -278,6 +280,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, ] +[[package]] +name = "gotenberg-client" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/7f/2bb2ab55a3f00d859649094327e7e3a71776957f40fdf79a53ad68960afe/gotenberg_client-0.9.0.tar.gz", hash = "sha256:bd3c1ed42b74d470a7e192118276f3d91b558b90aa7c0035afbcac04f42179bb", size = 419242 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/74/536bf5f66571971e9a7b5babece1c008386c16ee5b7ad6c4a3689f43a999/gotenberg_client-0.9.0-py3-none-any.whl", hash = "sha256:18955453e6b2a29a5015e95d645efff3ef6d000a663bd1550b2c92e9055bdd5b", size = 32696 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -287,6 +301,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, ] +[[package]] +name = "h2" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957 }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357 }, +] + +[[package]] +name = "httpcore" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, +] + [[package]] name = "httptools" version = "0.6.4" @@ -309,6 +358,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, +] + [[package]] name = "identify" version = "2.6.7"