Compare commits

..

7 commits

48 changed files with 807 additions and 129 deletions

View file

@ -8,3 +8,5 @@
# Translations # Translations
*.mo *.mo
*.pot *.pot
media/

View file

@ -5,3 +5,6 @@ DATABASE_HOST=postgres
DATABASE_PORT=5432 DATABASE_PORT=5432
DJANGO_ENV= DJANGO_ENV=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=

3
.gitignore vendored
View file

@ -161,3 +161,6 @@ cython_debug/
# Drawio # Drawio
.*.drawio.bkp .*.drawio.bkp
# Django media
media/

View file

@ -21,3 +21,4 @@ repos:
hooks: hooks:
- id: ruff - id: ruff
args: [--fix, --exit-non-zero-on-fix] args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format

View file

@ -17,6 +17,7 @@ RUN : \
gnupg \ gnupg \
wget \ wget \
gettext \ gettext \
curl \
&& rm -rf /var/lib/apt/lists* \ && rm -rf /var/lib/apt/lists* \
&& : && :

View file

@ -8,5 +8,4 @@
2. `docker compose up` 2. `docker compose up`
## Adding new backend packages ## Adding new backend packages
`docker compose run --rm backend uv add <package>` `docker compose run --rm backend uv add <package>`

View file

@ -19,8 +19,7 @@ class LoginForm(AuthenticationForm):
class RegisterForm(UserCreationForm): class RegisterForm(UserCreationForm):
class Meta: class Meta:
model = User model = User
fields = UserCreationForm.Meta.fields + \ fields = ('username', 'first_name', 'last_name', 'email')
('first_name', 'last_name', 'email')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View file

@ -27,14 +27,18 @@ class Migration(migrations.Migration):
), ),
), ),
( (
'password', models.CharField( 'password',
max_length=128, verbose_name='password', models.CharField(
max_length=128,
verbose_name='password',
), ),
), ),
( (
'last_login', 'last_login',
models.DateTimeField( models.DateTimeField(
blank=True, null=True, verbose_name='last login', blank=True,
null=True,
verbose_name='last login',
), ),
), ),
( (
@ -63,19 +67,25 @@ class Migration(migrations.Migration):
( (
'first_name', 'first_name',
models.CharField( models.CharField(
blank=True, max_length=150, verbose_name='first name', blank=True,
max_length=150,
verbose_name='first name',
), ),
), ),
( (
'last_name', 'last_name',
models.CharField( models.CharField(
blank=True, max_length=150, verbose_name='last name', blank=True,
max_length=150,
verbose_name='last name',
), ),
), ),
( (
'email', 'email',
models.EmailField( models.EmailField(
blank=True, max_length=254, verbose_name='email address', blank=True,
max_length=254,
verbose_name='email address',
), ),
), ),
( (
@ -97,7 +107,8 @@ class Migration(migrations.Migration):
( (
'date_joined', 'date_joined',
models.DateTimeField( models.DateTimeField(
default=django.utils.timezone.now, verbose_name='date joined', default=django.utils.timezone.now,
verbose_name='date joined',
), ),
), ),
( (

View file

@ -13,7 +13,8 @@ class Migration(migrations.Migration):
model_name='user', model_name='user',
name='email', name='email',
field=models.EmailField( field=models.EmailField(
max_length=254, verbose_name='email address', max_length=254,
verbose_name='email address',
), ),
), ),
migrations.AlterField( migrations.AlterField(

View file

@ -18,14 +18,18 @@ class Migration(migrations.Migration):
model_name='user', model_name='user',
name='customers', name='customers',
field=models.ManyToManyField( field=models.ManyToManyField(
blank=True, related_name='+', to='subjects.subject', blank=True,
related_name='+',
to='subjects.subject',
), ),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name='user',
name='suppliers', name='suppliers',
field=models.ManyToManyField( field=models.ManyToManyField(
blank=True, related_name='+', to='subjects.subject', blank=True,
related_name='+',
to='subjects.subject',
), ),
), ),
] ]

View file

@ -30,7 +30,9 @@ class Migration(migrations.Migration):
model_name='user', model_name='user',
name='customers', name='customers',
field=models.ManyToManyField( field=models.ManyToManyField(
blank=True, to='subjects.subject', verbose_name='customers', blank=True,
to='subjects.subject',
verbose_name='customers',
), ),
), ),
] ]

View file

@ -13,15 +13,21 @@ class User(AbstractUser):
email = models.EmailField(_('email address')) email = models.EmailField(_('email address'))
supplier = models.ForeignKey( supplier = models.ForeignKey(
Subject, models.PROTECT, _('supplier'), blank=True, null=True, Subject,
models.PROTECT,
_('supplier'),
blank=True,
null=True,
) )
customers = models.ManyToManyField( customers = models.ManyToManyField(
Subject, blank=True, verbose_name=_('customers'), Subject,
blank=True,
verbose_name=_('customers'),
) )
class Meta: class Meta:
verbose_name = _("user") verbose_name = _('user')
verbose_name_plural = _("users") verbose_name_plural = _('users')
def _get_m2m_ids(self, field: str): def _get_m2m_ids(self, field: str):
return list(getattr(self, field).values_list('id', flat=True)) return list(getattr(self, field).values_list('id', flat=True))

View file

@ -21,6 +21,11 @@ def auth_login(req: HttpRequest) -> HttpResponse:
user = form.get_user() user = form.get_user()
login(req, 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) redirect_url = getattr(req.GET, 'next', settings.LOGIN_REDIRECT_URL)
return redirect(redirect_url) return redirect(redirect_url)

View file

@ -1,5 +1,6 @@
services: services:
backend: backend:
image: facturio
build: build:
context: . context: .
args: args:

View file

@ -1,6 +1,24 @@
volumes: volumes:
pg_data: 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: services:
postgres: postgres:
image: postgres:17 image: postgres:17
@ -13,19 +31,33 @@ services:
volumes: volumes:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
backend: backend:
build: <<: *x-django
target: release healthcheck:
extends: test: ["CMD", "curl", "--fail", "http://localhost:8000/healthz/"]
file: docker-compose.build.yml interval: 5s
service: backend timeout: 2s
volumes: retries: 10
- .:/app start_interval: 10s
depends_on:
- postgres
ports: ports:
- "8000:8000" - "8000:8000"
environment:
- DJANGO_ENV=${DJANGO_ENV} dramatiq:
- DATABASE_HOST=postgres <<: *x-django
- DATABASE_URL=psql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME} command: ["/app/scripts/run-dramatiq.sh"]
depends_on:
backend:
condition: service_healthy
scheduler:
<<: *x-django
command: [ "/app/scripts/run-scheduler.sh" ]
depends_on:
- dramatiq
gotenberg:
image: gotenberg/gotenberg:8
redis:
image: redis:7.4-alpine

View file

@ -1,11 +1,3 @@
"""
ASGI config for facturio project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
"""
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application

View file

@ -1,14 +1,3 @@
"""
Django settings for facturio project.
Generated by 'django-admin startproject' using Django 5.0.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
from pathlib import Path from pathlib import Path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -19,33 +8,50 @@ env = Env()
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # 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 = env(
'SECRET_KEY', default='django-insecure-+l+g3)q1zz2bz7=mz4ys6lhu5uj+=ucj34flm^clo4vb3(wmdp', '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) DEBUG = env.bool('DEBUG', default=True)
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', str, default=['*']) ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', str, default=['*'])
# Application definition MEDIA_ROOT = BASE_DIR / 'media'
INSTALLED_APPS = [ # Application definition
DJANGO_APPS = [
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'crispy_forms', ]
'crispy_bootstrap5',
MY_APPS = [
'accounts.apps.AccountConfig', 'accounts.apps.AccountConfig',
'subjects.apps.SubjectsConfig', 'subjects.apps.SubjectsConfig',
'invoices.apps.InvoicesConfig', 'invoices.apps.InvoicesConfig',
'utils.apps.UtilsConfig',
]
THIRD_PARTY_APPS = [
'django_dramatiq',
'crispy_forms',
'crispy_bootstrap5',
'post_office',
'django_apscheduler',
]
INSTALLED_APPS = [
*DJANGO_APPS,
*MY_APPS,
*THIRD_PARTY_APPS,
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -109,13 +115,10 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'cs' LANGUAGE_CODE = 'cs'
LANGUAGES = [('en', _('English')), ('cs', _('Czech'))] LANGUAGES = [('en', _('English')), ('cs', _('Czech'))]
LOCALE_PATHS = [BASE_DIR / 'locale'] LOCALE_PATHS = [BASE_DIR / 'locale']
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
@ -140,3 +143,35 @@ AUTH_USER_MODEL = 'accounts.User'
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5' CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
CRISPY_TEMPLATE_PACK = 'bootstrap5' CRISPY_TEMPLATE_PACK = 'bootstrap5'
# Template to PDF rendering
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 = {
'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 <no-reply@kropcloud.net>')

View file

@ -2,6 +2,7 @@ from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin from django.contrib import admin
from django.http import HttpRequest from django.http import HttpRequest
from django.http import HttpResponse from django.http import HttpResponse
from django.http import JsonResponse
from django.shortcuts import render from django.shortcuts import render
from django.urls import include from django.urls import include
from django.urls import path from django.urls import path
@ -11,7 +12,7 @@ def landing_page(req: HttpRequest) -> HttpResponse:
return render(req, 'facturio/index.html') return render(req, 'facturio/index.html')
urlpatterns = i18n_patterns( app_patterns = i18n_patterns(
path('', landing_page, name='main-page'), path('', landing_page, name='main-page'),
path('accounts/', include('accounts.urls')), path('accounts/', include('accounts.urls')),
path('subjects/', include('subjects.urls')), path('subjects/', include('subjects.urls')),
@ -19,3 +20,6 @@ urlpatterns = i18n_patterns(
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
prefix_default_language=False, prefix_default_language=False,
) )
urlpatterns = [
path('healthz/', lambda _: JsonResponse(dict(status='Alive'))),
] + app_patterns

View file

@ -1,11 +1,3 @@
"""
WSGI config for facturio project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
"""
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application

View file

@ -3,6 +3,16 @@ from django.contrib import admin
from . import models from . import models
class InvoiceFileInline(admin.TabularInline):
model = models.InvoiceFile
extra = 0
readonly_fields = [
'created_date',
'file',
]
can_delete = False
class InvoiceItemInline(admin.TabularInline): class InvoiceItemInline(admin.TabularInline):
model = models.InvoiceItem model = models.InvoiceItem
extra = 0 extra = 0
@ -18,4 +28,7 @@ class InvoiceItemInline(admin.TabularInline):
@admin.register(models.Invoice) @admin.register(models.Invoice)
class InvoiceAdmin(admin.ModelAdmin): class InvoiceAdmin(admin.ModelAdmin):
list_display = ['__str__', 'invoice_date'] list_display = ['__str__', 'invoice_date']
inlines = [InvoiceItemInline] inlines = [
InvoiceItemInline,
InvoiceFileInline,
]

View file

@ -10,7 +10,7 @@ from subjects import models as subject_models
class InvoiceItemForm(forms.ModelForm): class InvoiceItemForm(forms.ModelForm):
class Meta: class Meta:
model = models.InvoiceItem model = models.InvoiceItem
fields = ["amount", "amount_unit", "description", "price_for_amount"] fields = ['amount', 'amount_unit', 'description', 'price_for_amount']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -19,9 +19,14 @@ class InvoiceItemForm(forms.ModelForm):
InvoiceItemFormSet = forms.inlineformset_factory( InvoiceItemFormSet = forms.inlineformset_factory(
models.Invoice, models.InvoiceItem, form=InvoiceItemForm, models.Invoice,
fields=["description", "amount", "amount_unit", "price_for_amount"], models.InvoiceItem,
extra=3, can_delete=False, validate_min=True, min_num=1, form=InvoiceItemForm,
fields=['description', 'amount', 'amount_unit', 'price_for_amount'],
extra=3,
can_delete=False,
validate_min=True,
min_num=1,
) )
@ -39,12 +44,12 @@ class CreateInvoiceForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self._user.supplier: if self._user.supplier:
self.fields["supplier"].queryset = subject_models.Subject.objects.filter( self.fields['supplier'].queryset = subject_models.Subject.objects.filter(
id=self._user.supplier.id, id=self._user.supplier.id,
) )
else: else:
self.fields["supplier"].queryset = subject_models.Subject.objects.none() self.fields['supplier'].queryset = subject_models.Subject.objects.none()
self.fields["customer"].queryset = self._user.customers self.fields['customer'].queryset = self._user.customers
self.helper = helper.FormHelper() self.helper = helper.FormHelper()
self.helper.form_method = 'post' self.helper.form_method = 'post'

View file

@ -88,25 +88,31 @@ class Migration(migrations.Migration):
( (
'amount', 'amount',
models.DecimalField( models.DecimalField(
decimal_places=3, max_digits=8, verbose_name='Amount', decimal_places=3,
max_digits=8,
verbose_name='Amount',
), ),
), ),
( (
'amount_unit', 'amount_unit',
models.CharField( models.CharField(
max_length=32, verbose_name='Amount unit', max_length=32,
verbose_name='Amount unit',
), ),
), ),
( (
'description', 'description',
models.CharField( models.CharField(
max_length=64, verbose_name='Description', max_length=64,
verbose_name='Description',
), ),
), ),
( (
'price_for_amount', 'price_for_amount',
models.DecimalField( models.DecimalField(
decimal_places=3, max_digits=8, verbose_name='Price for amount', decimal_places=3,
max_digits=8,
verbose_name='Price for amount',
), ),
), ),
( (

View file

@ -5,17 +5,17 @@ from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("invoices", "0001_initial"), ('invoices', '0001_initial'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name="invoice", model_name='invoice',
name="invoice_date", name='invoice_date',
field=models.DateField( field=models.DateField(
default=django.utils.timezone.now, verbose_name="Invoice date", default=django.utils.timezone.now,
verbose_name='Invoice date',
), ),
), ),
] ]

View file

@ -5,7 +5,6 @@ from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('invoices', '0002_alter_invoice_invoice_date'), ('invoices', '0002_alter_invoice_invoice_date'),
('subjects', '0005_alter_subjectdata_subject'), ('subjects', '0005_alter_subjectdata_subject'),
@ -15,21 +14,41 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='invoice', model_name='invoice',
name='customer', name='customer',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='subjects.subject', verbose_name='Customer'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
to='subjects.subject',
verbose_name='Customer',
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='invoice', model_name='invoice',
name='customer_data', name='customer_data',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='subjects.subjectdata', verbose_name='Customer data'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
to='subjects.subjectdata',
verbose_name='Customer data',
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='invoice', model_name='invoice',
name='supplier', name='supplier',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='subjects.subject', verbose_name='Supplier'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
to='subjects.subject',
verbose_name='Supplier',
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='invoice', model_name='invoice',
name='supplier_data', name='supplier_data',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='subjects.subjectdata', verbose_name='Supplier data'), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='+',
to='subjects.subjectdata',
verbose_name='Supplier data',
),
), ),
] ]

View file

@ -0,0 +1,55 @@
# 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',
},
),
]

View file

@ -0,0 +1,22 @@
# 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',
),
),
]

View file

@ -17,17 +17,29 @@ class Invoice(models.Model):
user = models.ForeignKey(UserModel, models.CASCADE) user = models.ForeignKey(UserModel, models.CASCADE)
supplier = models.ForeignKey( supplier = models.ForeignKey(
subject_models.Subject, on_delete=models.CASCADE, related_name='+', verbose_name=_('Supplier'), subject_models.Subject,
on_delete=models.CASCADE,
related_name='+',
verbose_name=_('Supplier'),
) )
supplier_data = models.ForeignKey( supplier_data = models.ForeignKey(
subject_models.SubjectData, on_delete=models.CASCADE, related_name='+', verbose_name=_('Supplier data'), subject_models.SubjectData,
on_delete=models.CASCADE,
related_name='+',
verbose_name=_('Supplier data'),
) )
customer = models.ForeignKey( customer = models.ForeignKey(
subject_models.Subject, on_delete=models.CASCADE, related_name='+', verbose_name=_('Customer'), subject_models.Subject,
on_delete=models.CASCADE,
related_name='+',
verbose_name=_('Customer'),
) )
customer_data = models.ForeignKey( customer_data = models.ForeignKey(
subject_models.SubjectData, on_delete=models.CASCADE, related_name='+', verbose_name=_('Customer data'), subject_models.SubjectData,
on_delete=models.CASCADE,
related_name='+',
verbose_name=_('Customer data'),
) )
invoice_date = models.DateField(_('Invoice date'), default=timezone.now) invoice_date = models.DateField(_('Invoice date'), default=timezone.now)
due_date = models.DateField(_('Due date')) due_date = models.DateField(_('Due date'))
@ -42,7 +54,7 @@ class Invoice(models.Model):
return total return total
def custom_id(self) -> str: def custom_id(self) -> str:
return f"{self.invoice_date.year}-{self.id:04}" return f'{self.invoice_date.year}-{self.id:04}'
class InvoiceItem(models.Model): class InvoiceItem(models.Model):
@ -51,13 +63,17 @@ class InvoiceItem(models.Model):
verbose_name_plural = _('Invoice Items') verbose_name_plural = _('Invoice Items')
invoice = models.ForeignKey( invoice = models.ForeignKey(
Invoice, on_delete=models.CASCADE, related_name='items', Invoice,
on_delete=models.CASCADE,
related_name='items',
) )
amount = models.DecimalField(_('Amount'), decimal_places=3, max_digits=8) amount = models.DecimalField(_('Amount'), decimal_places=3, max_digits=8)
amount_unit = models.CharField(_('Amount unit'), max_length=32) amount_unit = models.CharField(_('Amount unit'), max_length=32)
description = models.CharField(_('Description'), max_length=64) description = models.CharField(_('Description'), max_length=64)
price_for_amount = models.DecimalField( price_for_amount = models.DecimalField(
_('Price for amount'), decimal_places=3, max_digits=8, _('Price for amount'),
decimal_places=3,
max_digits=8,
) )
def total_price(self) -> decimal.Decimal: def total_price(self) -> decimal.Decimal:
@ -65,3 +81,24 @@ class InvoiceItem(models.Model):
def __str__(self): def __str__(self):
return f'{self.id} -> {self.invoice.id}' 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}'

23
invoices/tasks.py Normal file
View file

@ -0,0 +1,23 @@
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(invoice_id: int):
invoice = Invoice.objects.get(pk=invoice_id)
print_url = f'{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),
)

View file

@ -8,6 +8,7 @@ from django.urls import reverse
from . import forms from . import forms
from . import models from . import models
from . import tasks
@login_required @login_required
@ -20,22 +21,36 @@ def home(req: HttpRequest) -> HttpResponse:
with transaction.atomic(): with transaction.atomic():
invoice = form.save() invoice = form.save()
formset = forms.InvoiceItemFormSet( formset = forms.InvoiceItemFormSet(
data=req.POST, instance=invoice, data=req.POST,
instance=invoice,
) )
if formset.is_valid(): if formset.is_valid():
formset.save() formset.save()
return redirect(reverse('invoices:invoice', kwargs=dict(invoice_id=invoice.id))) tasks.generate_pdf.send(
invoice.id,
)
return redirect(
reverse('invoices:invoice', kwargs=dict(invoice_id=invoice.id)),
)
else: else:
transaction.set_rollback(True) transaction.set_rollback(True)
else: else:
formset = forms.InvoiceItemFormSet(data=req.POST) formset = forms.InvoiceItemFormSet(data=req.POST)
return render(req, 'invoices/index.html', dict(form=form, formset=formset, invoices=user_invoices)) return render(
req,
'invoices/index.html',
dict(form=form, formset=formset, invoices=user_invoices),
)
elif req.method == 'GET': elif req.method == 'GET':
form = forms.CreateInvoiceForm(current_user=req.user) form = forms.CreateInvoiceForm(current_user=req.user)
formset = forms.InvoiceItemFormSet() formset = forms.InvoiceItemFormSet()
return render(req, 'invoices/index.html', dict(form=form, formset=formset, invoices=user_invoices)) return render(
req,
'invoices/index.html',
dict(form=form, formset=formset, invoices=user_invoices),
)
return HttpResponse(status=405) return HttpResponse(status=405)
@ -46,7 +61,7 @@ def view_invoice(req: HttpRequest, invoice_id: int) -> HttpResponse:
return render(req, 'invoices/view.html', dict(invoice=invoice)) return render(req, 'invoices/view.html', dict(invoice=invoice))
@login_required # @login_required
def print_invoice(req: HttpRequest, invoice_id: int): def print_invoice(req: HttpRequest, invoice_id: int):
invoice = models.Invoice.objects.get(pk=invoice_id) invoice = models.Invoice.objects.get(pk=invoice_id)
return render(req, 'invoices/invoice.html', dict(invoice=invoice)) return render(req, 'invoices/invoice.html', dict(invoice=invoice))

View file

@ -1,5 +1,4 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os import os
import sys import sys

View file

@ -8,8 +8,13 @@ dependencies = [
"ares-util>=0.3.0", "ares-util>=0.3.0",
"crispy-bootstrap5>=2024.10", "crispy-bootstrap5>=2024.10",
"django>=5.1.6", "django>=5.1.6",
"django-apscheduler>=0.7.0",
"django-crispy-forms>=2.3", "django-crispy-forms>=2.3",
"django-dramatiq>=0.13.0",
"django-environ>=0.12.0", "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", "psycopg[c]>=3.2.5",
"uvicorn[standard]>=0.34.0", "uvicorn[standard]>=0.34.0",
"wait-for-it>=2.3.0", "wait-for-it>=2.3.0",

10
scripts/run-dramatiq.sh Executable file
View file

@ -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

10
scripts/run-scheduler.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -o nounset
set -o xtrace
set -o errexit
set -o pipefail
echo "Running scheduler..."
./manage.py runscheduler

View file

@ -11,8 +11,8 @@ echo "Compiling messages..."
echo "Migrating..." echo "Migrating..."
./manage.py migrate --no-input ./manage.py migrate --no-input
#echo "Checking for errors..." echo "Checking for errors..."
#./manage.py check --deploy --fail-level WARNING ./manage.py check --deploy --fail-level WARNING
uvicorn \ uvicorn \
--host 0.0.0.0 \ --host 0.0.0.0 \

View file

@ -56,7 +56,7 @@ class SelectSubjectForm(forms.ModelForm):
self._user = kwargs.pop('current_user', None) self._user = kwargs.pop('current_user', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["supplier"].queryset = models.Subject.objects.exclude( self.fields['supplier'].queryset = models.Subject.objects.exclude(
id__in=self._user.get_customers(), id__in=self._user.get_customers(),
) )

View file

@ -25,7 +25,10 @@ class Migration(migrations.Migration):
( (
'vat_id', 'vat_id',
models.CharField( models.CharField(
blank=True, max_length=12, null=True, verbose_name='vat_id', blank=True,
max_length=12,
null=True,
verbose_name='vat_id',
), ),
), ),
('street', models.CharField(max_length=64, verbose_name='street')), ('street', models.CharField(max_length=64, verbose_name='street')),
@ -34,7 +37,10 @@ class Migration(migrations.Migration):
( (
'city_part', 'city_part',
models.CharField( models.CharField(
blank=True, max_length=64, null=True, verbose_name='city_part', blank=True,
max_length=64,
null=True,
verbose_name='city_part',
), ),
), ),
], ],

View file

@ -18,14 +18,20 @@ class Migration(migrations.Migration):
model_name='subject', model_name='subject',
name='city_part', name='city_part',
field=models.CharField( field=models.CharField(
blank=True, max_length=64, null=True, verbose_name='City part', blank=True,
max_length=64,
null=True,
verbose_name='City part',
), ),
), ),
migrations.AlterField( migrations.AlterField(
model_name='subject', model_name='subject',
name='id', name='id',
field=models.CharField( field=models.CharField(
max_length=8, primary_key=True, serialize=False, verbose_name='CIN', max_length=8,
primary_key=True,
serialize=False,
verbose_name='CIN',
), ),
), ),
migrations.AlterField( migrations.AlterField(
@ -42,7 +48,10 @@ class Migration(migrations.Migration):
model_name='subject', model_name='subject',
name='vat_id', name='vat_id',
field=models.CharField( field=models.CharField(
blank=True, max_length=12, null=True, verbose_name='VAT ID', blank=True,
max_length=12,
null=True,
verbose_name='VAT ID',
), ),
), ),
migrations.AlterField( migrations.AlterField(

View file

@ -49,13 +49,17 @@ class Migration(migrations.Migration):
( (
'city_part', 'city_part',
models.CharField( models.CharField(
blank=True, max_length=64, null=True, verbose_name='City part', blank=True,
max_length=64,
null=True,
verbose_name='City part',
), ),
), ),
( (
'created_date', 'created_date',
models.DateTimeField( models.DateTimeField(
auto_now_add=True, verbose_name='Created date', auto_now_add=True,
verbose_name='Created date',
), ),
), ),
( (

View file

@ -5,19 +5,18 @@ from django.db import models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("subjects", "0004_remove_subject_city_remove_subject_city_part_and_more"), ('subjects', '0004_remove_subject_city_remove_subject_city_part_and_more'),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name="subjectdata", model_name='subjectdata',
name="subject", name='subject',
field=models.ForeignKey( field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="subject_data", related_name='subject_data',
to="subjects.subject", to='subjects.subject',
), ),
), ),
] ]

View file

@ -9,13 +9,20 @@ class Subject(models.Model):
id = models.CharField(_('CIN'), max_length=8, primary_key=True) id = models.CharField(_('CIN'), max_length=8, primary_key=True)
vat_id = models.CharField( vat_id = models.CharField(
_('VAT ID'), max_length=12, null=True, blank=True, _('VAT ID'),
max_length=12,
null=True,
blank=True,
) )
def get_latest_data(self): def get_latest_data(self):
return self.subject_data.filter(subject=self).order_by( return (
'-created_date', self.subject_data.filter(subject=self)
).first() .order_by(
'-created_date',
)
.first()
)
def __str__(self): def __str__(self):
return f'{self.id} - {self.get_latest_data().name}' return f'{self.id} - {self.get_latest_data().name}'
@ -27,14 +34,19 @@ class SubjectData(models.Model):
verbose_name_plural = _('Subject Datas') verbose_name_plural = _('Subject Datas')
subject = models.ForeignKey( subject = models.ForeignKey(
Subject, on_delete=models.CASCADE, related_name='subject_data', Subject,
on_delete=models.CASCADE,
related_name='subject_data',
) )
name = models.CharField(_('Name'), max_length=128) name = models.CharField(_('Name'), max_length=128)
street = models.CharField(_('Street'), max_length=64) street = models.CharField(_('Street'), max_length=64)
zip_code = models.CharField(_('Zip Code'), max_length=6) zip_code = models.CharField(_('Zip Code'), max_length=6)
city = models.CharField(_('City'), max_length=64) city = models.CharField(_('City'), max_length=64)
city_part = models.CharField( city_part = models.CharField(
_('City part'), max_length=64, null=True, blank=True, _('City part'),
max_length=64,
null=True,
blank=True,
) )
created_date = models.DateTimeField(_('Created date'), auto_now_add=True) created_date = models.DateTimeField(_('Created date'), auto_now_add=True)

View file

@ -18,7 +18,9 @@ def build_address(street: str, zip_code: int | str, city: str, city_part: str) -
def main_page(req: HttpRequest) -> HttpResponse: def main_page(req: HttpRequest) -> HttpResponse:
if req.method == 'POST': if req.method == 'POST':
select_subject_form = forms.SelectSubjectForm( select_subject_form = forms.SelectSubjectForm(
data=req.POST, instance=req.user, current_user=req.user, data=req.POST,
instance=req.user,
current_user=req.user,
) )
if select_subject_form.is_valid(): if select_subject_form.is_valid():
select_subject_form.save() select_subject_form.save()
@ -29,7 +31,8 @@ def main_page(req: HttpRequest) -> HttpResponse:
id=req.user.supplier.id if req.user.supplier else None, id=req.user.supplier.id if req.user.supplier else None,
) )
select_subject_form = forms.SelectSubjectForm( select_subject_form = forms.SelectSubjectForm(
instance=req.user, current_user=req.user, instance=req.user,
current_user=req.user,
) )
return render( return render(
req, req,
@ -54,7 +57,8 @@ def create_subject(req: HttpRequest) -> HttpResponse:
ares_legal_data = ares_data['legal'] ares_legal_data = ares_data['legal']
subject = models.Subject.objects.create( subject = models.Subject.objects.create(
id=ares_legal_data['company_id'], vat_id=ares_legal_data['company_vat_id'], id=ares_legal_data['company_id'],
vat_id=ares_legal_data['company_vat_id'],
) )
models.SubjectData.objects.create( models.SubjectData.objects.create(
subject=subject, subject=subject,

0
utils/__init__.py Normal file
View file

5
utils/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UtilsConfig(AppConfig):
name = 'utils'

View file

View file

View file

@ -0,0 +1,98 @@
import logging
import signal
from copy import deepcopy
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from django.conf import settings
from django.core.management.base import BaseCommand
from django_apscheduler import util
from django_apscheduler.jobstores import DjangoJobStore
from django_apscheduler.models import DjangoJobExecution
from django_dramatiq.models import Task
logger = logging.getLogger(__name__)
DEFAULT_JOB_KWARGS = {
'max_instances': 1,
'replace_existing': True,
}
DAY_SEC = 24 * 60 * 60
PERIODIC_JOBS = [
{
'task': 'utils.tasks:send_queued_mail_task.send',
'trigger': IntervalTrigger(seconds=30),
},
]
# The `close_old_connections` decorator ensures that database connections that have become
# unusable or are obsolete are closed before and after your job has run.
# You should use it to wrap any jobs that you schedule that access the Django database in any way.
@util.close_old_connections
def delete_old_job_executions(max_age: int = 7 * DAY_SEC) -> None:
"""
This job deletes APScheduler job execution entries older than `max_age` from the database.
It helps to prevent the database from filling up with old historical records that are no
longer useful.
:param max_age: The maximum length of time to retain historical job execution records.
Defaults to 7 days.
"""
DjangoJobExecution.objects.delete_old_job_executions(max_age)
Task.tasks.delete_old_tasks(max_age)
class Command(BaseCommand):
help = 'Runs APScheduler.' # noqa: A003
scheduler: BlockingScheduler = None
def prepare_scheduler(self):
self.stdout.write(self.style.NOTICE('Preparing scheduler'))
self.scheduler = BlockingScheduler(timezone=settings.TIME_ZONE)
self.scheduler.add_jobstore(DjangoJobStore(), 'default')
def add_jobs(self):
self.scheduler.add_job(
delete_old_job_executions,
trigger=CronTrigger(
day_of_week='mon',
hour='00',
minute='00',
), # Midnight on Monday, before the start of the next work week.
id='delete_old_job_executions',
max_instances=1,
replace_existing=True,
)
self.stdout.write("Added weekly job: 'delete_old_job_executions'.")
for job in PERIODIC_JOBS:
kwargs = DEFAULT_JOB_KWARGS | deepcopy(job)
task = kwargs.pop('task')
if 'id' not in kwargs:
kwargs['id'] = task
self.scheduler.add_job(task, **kwargs)
self.stdout.write(f'Added job: {task}')
def handle_shutdown(self, *args, **kwargs): # noqa: ARG001
self.stdout.write(self.style.NOTICE('Stopping scheduler...'))
self.scheduler.shutdown()
self.stdout.write(self.style.NOTICE('Scheduler shut down successfully!'))
def handle(self, *args, **options):
self.prepare_scheduler()
self.add_jobs()
signal.signal(signal.SIGTERM, self.handle_shutdown)
try:
self.stdout.write(self.style.NOTICE('Starting scheduler...'))
self.scheduler.start()
except KeyboardInterrupt:
self.handle_shutdown()

7
utils/tasks.py Normal file
View file

@ -0,0 +1,7 @@
import dramatiq
from django.core.management import call_command
@dramatiq.actor()
def send_queued_mail_task():
call_command('send_queued_mail')

222
uv.lock
View file

@ -16,6 +16,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
] ]
[[package]]
name = "apscheduler"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004 },
]
[[package]] [[package]]
name = "ares-util" name = "ares-util"
version = "0.3.0" version = "0.3.0"
@ -37,6 +49,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828 }, { 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]] [[package]]
name = "certifi" name = "certifi"
version = "2025.1.31" version = "2025.1.31"
@ -147,6 +176,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/6f/d2c216d00975e2604b10940937b0ba6b2c2d9b3cc0cc633e414ae3f14b2e/Django-5.1.6-py3-none-any.whl", hash = "sha256:8d203400bc2952fbfb287c2bbda630297d654920c72a73cc82a9ad7926feaad5", size = 8277066 }, { url = "https://files.pythonhosted.org/packages/75/6f/d2c216d00975e2604b10940937b0ba6b2c2d9b3cc0cc633e414ae3f14b2e/Django-5.1.6-py3-none-any.whl", hash = "sha256:8d203400bc2952fbfb287c2bbda630297d654920c72a73cc82a9ad7926feaad5", size = 8277066 },
] ]
[[package]]
name = "django-apscheduler"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "apscheduler" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/6b/873899c2da113187b74f0cccdf4c16660e07bfbbcae72621c4758e0958bf/django_apscheduler-0.7.0.tar.gz", hash = "sha256:30d61a2ba98615922fc2c9782f84bba342ec0c5ed63384d686d71ea90a1a4318", size = 473051 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/19/c3d2dea21a6afdc93689b9f769ff3694cac810e4a09c24ab423dd1613e6c/django_apscheduler-0.7.0-py3-none-any.whl", hash = "sha256:869d489775420245c9455d55e35f663c856a33ebfc996d92938f786ffb8730ce", size = 24690 },
]
[[package]] [[package]]
name = "django-crispy-forms" name = "django-crispy-forms"
version = "2.3" version = "2.3"
@ -159,6 +201,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 }, { 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]] [[package]]
name = "django-environ" name = "django-environ"
version = "0.12.0" version = "0.12.0"
@ -168,6 +223,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 }, { 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]] [[package]]
name = "facturio" name = "facturio"
version = "0.1.0" version = "0.1.0"
@ -176,8 +261,13 @@ dependencies = [
{ name = "ares-util" }, { name = "ares-util" },
{ name = "crispy-bootstrap5" }, { name = "crispy-bootstrap5" },
{ name = "django" }, { name = "django" },
{ name = "django-apscheduler" },
{ name = "django-crispy-forms" }, { name = "django-crispy-forms" },
{ name = "django-dramatiq" },
{ name = "django-environ" }, { name = "django-environ" },
{ name = "django-post-office" },
{ name = "dramatiq", extra = ["redis"] },
{ name = "gotenberg-client" },
{ name = "psycopg", extra = ["c"] }, { name = "psycopg", extra = ["c"] },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "wait-for-it" }, { name = "wait-for-it" },
@ -193,8 +283,13 @@ requires-dist = [
{ name = "ares-util", specifier = ">=0.3.0" }, { name = "ares-util", specifier = ">=0.3.0" },
{ name = "crispy-bootstrap5", specifier = ">=2024.10" }, { name = "crispy-bootstrap5", specifier = ">=2024.10" },
{ name = "django", specifier = ">=5.1.6" }, { name = "django", specifier = ">=5.1.6" },
{ name = "django-apscheduler", specifier = ">=0.7.0" },
{ name = "django-crispy-forms", specifier = ">=2.3" }, { name = "django-crispy-forms", specifier = ">=2.3" },
{ name = "django-dramatiq", specifier = ">=0.13.0" },
{ name = "django-environ", specifier = ">=0.12.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 = "gotenberg-client", specifier = ">=0.9.0" },
{ name = "psycopg", extras = ["c"], specifier = ">=3.2.5" }, { name = "psycopg", extras = ["c"], specifier = ">=3.2.5" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
{ name = "wait-for-it", specifier = ">=2.3.0" }, { name = "wait-for-it", specifier = ">=2.3.0" },
@ -212,6 +307,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.14.0" version = "0.14.0"
@ -221,6 +328,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, { 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]] [[package]]
name = "httptools" name = "httptools"
version = "0.6.4" version = "0.6.4"
@ -243,6 +385,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 }, { 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]] [[package]]
name = "identify" name = "identify"
version = "2.6.7" version = "2.6.7"
@ -295,6 +466,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 }, { 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]] [[package]]
name = "psycopg" name = "psycopg"
version = "3.2.5" version = "3.2.5"
@ -354,6 +534,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 }, { 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]] [[package]]
name = "requests" name = "requests"
version = "2.32.3" version = "2.32.3"
@ -387,6 +576,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@ -405,6 +606,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 },
] ]
[[package]]
name = "tzlocal"
version = "5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/cc/11360404b20a6340b9b4ed39a3338c4af47bc63f87f6cea94dbcbde07029/tzlocal-5.3.tar.gz", hash = "sha256:2fafbfc07e9d8b49ade18f898d6bcd37ae88ce3ad6486842a2e4f03af68323d2", size = 30480 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/9f/1c0b69d3abf4c65acac051ad696b8aea55afbb746dea8017baab53febb5e/tzlocal-5.3-py3-none-any.whl", hash = "sha256:3814135a1bb29763c6e4f08fd6e41dbb435c7a60bfbb03270211bcc537187d8c", size = 17920 },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.3.0" version = "2.3.0"
@ -520,6 +733,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 }, { 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]] [[package]]
name = "websockets" name = "websockets"
version = "15.0" version = "15.0"