Compare commits
7 commits
Author | SHA1 | Date | |
---|---|---|---|
2707655815 | |||
b09e1b556b | |||
1bd01f9652 | |||
98b1705d81 | |||
442118fdc7 | |||
246ef996c1 | |||
e9e4597c96 |
48 changed files with 807 additions and 129 deletions
|
@ -8,3 +8,5 @@
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
|
media/
|
||||||
|
|
|
@ -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
3
.gitignore
vendored
|
@ -161,3 +161,6 @@ cython_debug/
|
||||||
|
|
||||||
# Drawio
|
# Drawio
|
||||||
.*.drawio.bkp
|
.*.drawio.bkp
|
||||||
|
|
||||||
|
# Django media
|
||||||
|
media/
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -17,6 +17,7 @@ RUN : \
|
||||||
gnupg \
|
gnupg \
|
||||||
wget \
|
wget \
|
||||||
gettext \
|
gettext \
|
||||||
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists* \
|
&& rm -rf /var/lib/apt/lists* \
|
||||||
&& :
|
&& :
|
||||||
|
|
||||||
|
|
|
@ -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>`
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
|
image: facturio
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
]
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
55
invoices/migrations/0004_invoicefile.py
Normal file
55
invoices/migrations/0004_invoicefile.py
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
22
invoices/migrations/0005_alter_invoicefile_file.py
Normal file
22
invoices/migrations/0005_alter_invoicefile_file.py
Normal 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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
23
invoices/tasks.py
Normal 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),
|
||||||
|
)
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
10
scripts/run-dramatiq.sh
Executable 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
10
scripts/run-scheduler.sh
Executable 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
|
|
@ -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 \
|
||||||
|
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|
|
@ -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',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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 (
|
||||||
|
self.subject_data.filter(subject=self)
|
||||||
|
.order_by(
|
||||||
'-created_date',
|
'-created_date',
|
||||||
).first()
|
)
|
||||||
|
.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)
|
||||||
|
|
||||||
|
|
|
@ -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
0
utils/__init__.py
Normal file
5
utils/apps.py
Normal file
5
utils/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class UtilsConfig(AppConfig):
|
||||||
|
name = 'utils'
|
0
utils/management/__init__.py
Normal file
0
utils/management/__init__.py
Normal file
0
utils/management/commands/__init__.py
Normal file
0
utils/management/commands/__init__.py
Normal file
98
utils/management/commands/runscheduler.py
Normal file
98
utils/management/commands/runscheduler.py
Normal 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
7
utils/tasks.py
Normal 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
222
uv.lock
|
@ -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"
|
||||||
|
|
Reference in a new issue