diff --git a/.env.example b/.env.example index 5685098..72f6410 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ DATABASE_HOST=postgres DATABASE_PORT=5432 DJANGO_ENV= + +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= diff --git a/README.md b/README.md index 6c52c87..73fe174 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,4 @@ 2. `docker compose up` ## Adding new backend packages - `docker compose run --rm backend uv add ` diff --git a/accounts/forms.py b/accounts/forms.py index b26120a..5db6f9f 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -19,8 +19,7 @@ class LoginForm(AuthenticationForm): class RegisterForm(UserCreationForm): class Meta: model = User - fields = UserCreationForm.Meta.fields + \ - ('first_name', 'last_name', 'email') + fields = ('username', 'first_name', 'last_name', 'email') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/accounts/views.py b/accounts/views.py index a44bd8a..700cb77 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -21,6 +21,11 @@ def auth_login(req: HttpRequest) -> HttpResponse: user = form.get_user() login(req, user) + user.email_user( + subject="You have just logged in!", + message="You have just logged in", + ) + redirect_url = getattr(req.GET, 'next', settings.LOGIN_REDIRECT_URL) return redirect(redirect_url) diff --git a/docker-compose.yml b/docker-compose.yml index ddc8495..3171f5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,24 @@ volumes: pg_data: +x-django: &x-django + build: + target: dev + extends: + file: docker-compose.build.yml + service: backend + volumes: + - .:/app + depends_on: + - postgres + environment: + - DJANGO_ENV=${DJANGO_ENV} + - DATABASE_HOST=postgres + - DATABASE_URL=psql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME} + - EMAIL_HOST_USER=${EMAIL_HOST_USER} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + + services: postgres: image: postgres:17 @@ -13,19 +31,18 @@ services: volumes: - pg_data:/var/lib/postgresql/data + backend: - build: - target: release - extends: - file: docker-compose.build.yml - service: backend - volumes: - - .:/app - depends_on: - - postgres + <<: *x-django ports: - "8000:8000" - environment: - - DJANGO_ENV=${DJANGO_ENV} - - DATABASE_HOST=postgres - - DATABASE_URL=psql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME} + + dramatiq: + <<: *x-django + command: ["/app/scripts/run-dramatiq.sh"] + + gotenberg: + image: gotenberg/gotenberg:8 + + redis: + image: redis:7.4-alpine diff --git a/facturio/settings/base.py b/facturio/settings/base.py index f843224..3cbb4f3 100644 --- a/facturio/settings/base.py +++ b/facturio/settings/base.py @@ -19,35 +19,48 @@ 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/ -# SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = env( 'SECRET_KEY', default='django-insecure-+l+g3)q1zz2bz7=mz4ys6lhu5uj+=ucj34flm^clo4vb3(wmdp', ) -# SECURITY WARNING: don't run with debug turned on in production! DEBUG = env.bool('DEBUG', default=True) ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', str, default=['*']) # Application definition - -INSTALLED_APPS = [ +DJANGO_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'crispy_forms', - 'crispy_bootstrap5', +] + +MY_APPS = [ 'accounts.apps.AccountConfig', 'subjects.apps.SubjectsConfig', 'invoices.apps.InvoicesConfig', ] +THIRD_PARTY_APPS = [ + 'django_dramatiq', + 'crispy_forms', + 'crispy_bootstrap5', + 'post_office', +] + +INSTALLED_APPS = [ + *DJANGO_APPS, + *MY_APPS, + *THIRD_PARTY_APPS, +] + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -109,13 +122,10 @@ AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE = 'cs' LANGUAGES = [('en', _('English')), ('cs', _('Czech'))] - LOCALE_PATHS = [BASE_DIR / 'locale'] TIME_ZONE = 'UTC' - USE_I18N = True - USE_TZ = True # Static files (CSS, JavaScript, Images) @@ -140,3 +150,34 @@ AUTH_USER_MODEL = 'accounts.User' CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5' CRISPY_TEMPLATE_PACK = 'bootstrap5' + +# Template to PDF rendering +GOTENBERG_API_URL = env.url('GOTENBERG_API_URL', 'http://gotenberg:3000') + +# Dramatiq +DRAMATIQ_BROKER = { + "BROKER": "dramatiq.brokers.redis.RedisBroker", + "OPTIONS": { + "url": "redis://redis:6379", + }, + "MIDDLEWARE": [ + "dramatiq.middleware.AgeLimit", + "dramatiq.middleware.TimeLimit", + "dramatiq.middleware.Callbacks", + "dramatiq.middleware.Retries", + "django_dramatiq.middleware.DbConnectionsMiddleware", + "django_dramatiq.middleware.AdminMiddleware", + ], +} + +# Email config +EMAIL_BACKEND = 'post_office.EmailBackend' + +EMAIL_HOST = env.str("EMAIL_HOST", "smtp.seznam.cz") +EMAIL_PORT = env.int("EMAIL_PORT", 465) +EMAIL_HOST_USER = env.str("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = env.str("EMAIL_HOST_PASSWORD") +EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", True) +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", False) + +DEFAULT_FROM_EMAIL = env.str("EMAIL_DEFAULT_FROM", "Facturio ") diff --git a/invoices/tasks.py b/invoices/tasks.py new file mode 100644 index 0000000..f0b0294 --- /dev/null +++ b/invoices/tasks.py @@ -0,0 +1,6 @@ +import dramatiq + +@dramatiq.actor() +def generate_pdf(url: str): + print(url) + pass diff --git a/invoices/views.py b/invoices/views.py index 6475748..d14207a 100644 --- a/invoices/views.py +++ b/invoices/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib.auth.decorators import login_required from django.db import transaction from django.http import HttpRequest @@ -8,6 +9,7 @@ from django.urls import reverse from . import forms from . import models +from . import tasks @login_required @@ -24,6 +26,9 @@ 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)), + ) return redirect(reverse('invoices:invoice', kwargs=dict(invoice_id=invoice.id))) else: transaction.set_rollback(True) @@ -46,7 +51,7 @@ def view_invoice(req: HttpRequest, invoice_id: int) -> HttpResponse: return render(req, 'invoices/view.html', dict(invoice=invoice)) -@login_required +# @login_required def print_invoice(req: HttpRequest, invoice_id: int): invoice = models.Invoice.objects.get(pk=invoice_id) return render(req, 'invoices/invoice.html', dict(invoice=invoice)) diff --git a/pyproject.toml b/pyproject.toml index 26adc56..e0dfaa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,10 @@ dependencies = [ "crispy-bootstrap5>=2024.10", "django>=5.1.6", "django-crispy-forms>=2.3", + "django-dramatiq>=0.13.0", "django-environ>=0.12.0", + "django-post-office>=3.9.1", + "dramatiq[redis]>=1.17.1", "psycopg[c]>=3.2.5", "uvicorn[standard]>=0.34.0", "wait-for-it>=2.3.0", diff --git a/scripts/run-dramatiq.sh b/scripts/run-dramatiq.sh new file mode 100755 index 0000000..18539a1 --- /dev/null +++ b/scripts/run-dramatiq.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -o nounset +set -o xtrace +set -o errexit +set -o pipefail + + +echo "Running dramatiq..." +./manage.py rundramatiq -p 2 -t 2 diff --git a/uv.lock b/uv.lock index c729d54..26bfd56 100644 --- a/uv.lock +++ b/uv.lock @@ -37,6 +37,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, ] +[[package]] +name = "bleach" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + [[package]] name = "certifi" version = "2025.1.31" @@ -159,6 +176,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/3b/5dc3faf8739d1ce7a73cedaff508b4af8f6aa1684120ded6185ca0c92734/django_crispy_forms-2.3-py3-none-any.whl", hash = "sha256:efc4c31e5202bbec6af70d383a35e12fc80ea769d464fb0e7fe21768bb138a20", size = 31411 }, ] +[[package]] +name = "django-dramatiq" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "dramatiq" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/89/bb40b3287acea4e7451ee66a82a75bdeb3814252759d2e39c08c4a02a5f8/django_dramatiq-0.13.0.tar.gz", hash = "sha256:1cdf723ec6a223761985c0855b92fe40cca85d364914df6d99d3034f0f6cee15", size = 15535 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/09/b19725f405621e41c119c6f4b43359c281ddd471794c215b754b8903f52c/django_dramatiq-0.13.0-py3-none-any.whl", hash = "sha256:855c590606c74bd478ad0a3a588667e3e73419e47e43ef2ae047dbebe19dff0b", size = 12376 }, +] + [[package]] name = "django-environ" version = "0.12.0" @@ -168,6 +198,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/b3/0a3bec4ecbfee960f39b1842c2f91e4754251e0a6ed443db9fe3f666ba8f/django_environ-0.12.0-py2.py3-none-any.whl", hash = "sha256:92fb346a158abda07ffe6eb23135ce92843af06ecf8753f43adf9d2366dcc0ca", size = 19957 }, ] +[[package]] +name = "django-post-office" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bleach", extra = ["css"] }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/e9/7369fb453269ded8d2fff58299fe653033702bd15a427bf35353d4606df3/django-post_office-3.9.1.tar.gz", hash = "sha256:3d8f502f7829e4cf83e830d9efd6909bb44690af6bc41c7e4fc5a85d7b04df10", size = 76624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/12/19fdf17fb1a0d5e4418798cf9ed66b8aee39da564e875ec025627e08abdf/django_post_office-3.9.1-py3-none-any.whl", hash = "sha256:0082c4dd17854f66077ef457cf868c9bc5b6247de99d5755c45ddea1c518ed6f", size = 85671 }, +] + +[[package]] +name = "dramatiq" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prometheus-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/7a/6792ddc64a77d22bfd97261b751a7a76cf2f9d62edc59aafb679ac48b77d/dramatiq-1.17.1.tar.gz", hash = "sha256:2675d2f57e0d82db3a7d2a60f1f9c536365349db78c7f8d80a63e4c54697647a", size = 99071 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/36/925c7afd5db4f1a3f00676b9c3c58f31ff7ae29a347282d86c8d429280a5/dramatiq-1.17.1-py3-none-any.whl", hash = "sha256:951cdc334478dff8e5150bb02a6f7a947d215ee24b5aedaf738eff20e17913df", size = 120382 }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] + [[package]] name = "facturio" version = "0.1.0" @@ -177,7 +237,10 @@ dependencies = [ { name = "crispy-bootstrap5" }, { name = "django" }, { name = "django-crispy-forms" }, + { name = "django-dramatiq" }, { name = "django-environ" }, + { name = "django-post-office" }, + { name = "dramatiq", extra = ["redis"] }, { name = "psycopg", extra = ["c"] }, { name = "uvicorn", extra = ["standard"] }, { name = "wait-for-it" }, @@ -194,7 +257,10 @@ requires-dist = [ { name = "crispy-bootstrap5", specifier = ">=2024.10" }, { name = "django", specifier = ">=5.1.6" }, { name = "django-crispy-forms", specifier = ">=2.3" }, + { name = "django-dramatiq", specifier = ">=0.13.0" }, { name = "django-environ", specifier = ">=0.12.0" }, + { name = "django-post-office", specifier = ">=3.9.1" }, + { name = "dramatiq", extras = ["redis"], specifier = ">=1.17.1" }, { name = "psycopg", extras = ["c"], specifier = ">=3.2.5" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "wait-for-it", specifier = ">=2.3.0" }, @@ -295,6 +361,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 }, ] +[[package]] +name = "prometheus-client" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/14/7d0f567991f3a9af8d1cd4f619040c93b68f09a02b6d0b6ab1b2d1ded5fe/prometheus_client-0.21.1.tar.gz", hash = "sha256:252505a722ac04b0456be05c05f75f45d760c2911ffc45f2a06bcaed9f3ae3fb", size = 78551 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/c2/ab7d37426c179ceb9aeb109a85cda8948bb269b7561a0be870cc656eefe4/prometheus_client-0.21.1-py3-none-any.whl", hash = "sha256:594b45c410d6f4f8888940fe80b5cc2521b305a1fafe1c58609ef715a001f301", size = 54682 }, +] + [[package]] name = "psycopg" version = "3.2.5" @@ -354,6 +429,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "redis" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502 }, +] + [[package]] name = "requests" version = "2.32.3" @@ -387,6 +471,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, ] +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -520,6 +616,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/e5/96b8e55271685ddbadc50ce8bc53aa2dff278fb7ac4c2e473df890def2dc/watchfiles-1.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:d6097538b0ae5c1b88c3b55afa245a66793a8fec7ada6755322e465fb1a0e8cc", size = 285216 }, ] +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 }, +] + [[package]] name = "websockets" version = "15.0"