Erste lauffähige Version
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# =============================================================================
|
||||
# Multi-Stage: builder → dev → runtime
|
||||
# Dev-Image enthält zusätzlich debugpy, ipython, django-debug-toolbar.
|
||||
# =============================================================================
|
||||
|
||||
# ---------- Stage 1: Build ----------------------------------------------------
|
||||
FROM python:3.12-slim-bookworm AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential libpq-dev gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
RUN pip wheel --wheel-dir=/wheels -r requirements.txt -r requirements-dev.txt
|
||||
|
||||
|
||||
# ---------- Common Runtime Base ----------------------------------------------
|
||||
FROM python:3.12-slim-bookworm AS runtime-base
|
||||
|
||||
ARG APP_UID=10001
|
||||
ARG APP_GID=10001
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
libreoffice libreoffice-writer \
|
||||
fonts-liberation fonts-dejavu \
|
||||
tini curl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/*
|
||||
|
||||
RUN groupadd -g ${APP_GID} app && \
|
||||
useradd -u ${APP_UID} -g ${APP_GID} -m -s /bin/bash app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /wheels /wheels
|
||||
|
||||
|
||||
# ---------- Stage 2a: Runtime (Production) ------------------------------------
|
||||
FROM runtime-base AS runtime
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-index --find-links=/wheels -r requirements.txt && \
|
||||
rm -rf /wheels
|
||||
|
||||
COPY --chown=app:app . /app/
|
||||
RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app
|
||||
USER app
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
|
||||
CMD ["gunicorn", "config.wsgi:application", \
|
||||
"--bind", "0.0.0.0:8000", \
|
||||
"--workers", "3", "--threads", "2", \
|
||||
"--worker-class", "gthread", "--worker-tmp-dir", "/tmp", \
|
||||
"--access-logfile", "-", "--error-logfile", "-", \
|
||||
"--timeout", "120"]
|
||||
|
||||
|
||||
# ---------- Stage 2b: Dev -----------------------------------------------------
|
||||
FROM runtime-base AS dev
|
||||
|
||||
# Dev-Tools für Container & VS Code
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git bash-completion vim less procps iputils-ping \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
RUN pip install --no-index --find-links=/wheels -r requirements.txt -r requirements-dev.txt && \
|
||||
rm -rf /wheels
|
||||
|
||||
# Code wird im Dev via Volume gemountet; nichts kopieren.
|
||||
RUN mkdir -p /app/staticfiles /app/media && chown -R app:app /app
|
||||
USER app
|
||||
EXPOSE 8000 5678
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/app/entrypoint.sh"]
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||
|
||||
app = Celery("serienbrief")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Basis-Settings. Werden von dev.py und production.py erweitert.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import environ
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
env = environ.Env(
|
||||
DJANGO_DEBUG=(bool, False),
|
||||
USE_X_FORWARDED_HOST=(bool, True),
|
||||
JOB_RETENTION_DAYS=(int, 30),
|
||||
)
|
||||
|
||||
# --- Core --------------------------------------------------------------------
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY", default="dev-insecure-change-me")
|
||||
DEBUG = env("DJANGO_DEBUG")
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])
|
||||
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
|
||||
|
||||
# Hinter dem äußeren Reverse-Proxy
|
||||
USE_X_FORWARDED_HOST = env("USE_X_FORWARDED_HOST")
|
||||
_proxy_header = env("SECURE_PROXY_SSL_HEADER", default="")
|
||||
if _proxy_header:
|
||||
name, value = _proxy_header.split(",", 1)
|
||||
SECURE_PROXY_SSL_HEADER = (name.strip(), value.strip())
|
||||
|
||||
# --- Apps --------------------------------------------------------------------
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# 3rd party
|
||||
"django_celery_beat",
|
||||
"django_celery_results",
|
||||
"django_htmx",
|
||||
"axes",
|
||||
# local
|
||||
"mailmerge",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_htmx.middleware.HtmxMiddleware",
|
||||
"axes.middleware.AxesMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# --- Database ----------------------------------------------------------------
|
||||
DATABASES = {
|
||||
"default": env.db_url(
|
||||
"DATABASE_URL", default="sqlite:///" + str(BASE_DIR / "db.sqlite3")
|
||||
),
|
||||
}
|
||||
|
||||
# --- Auth --------------------------------------------------------------------
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"OPTIONS": {"min_length": 12}},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
PASSWORD_HASHERS = [
|
||||
"django.contrib.auth.hashers.Argon2PasswordHasher",
|
||||
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"axes.backends.AxesStandaloneBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
|
||||
LOGIN_URL = "/accounts/login/"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/accounts/login/"
|
||||
|
||||
# django-axes – Brute-Force-Schutz
|
||||
AXES_FAILURE_LIMIT = 5
|
||||
AXES_COOLOFF_TIME = 1 # Stunde
|
||||
AXES_LOCKOUT_PARAMETERS = ["username", "ip_address"]
|
||||
|
||||
# --- I18N / TZ ---------------------------------------------------------------
|
||||
LANGUAGE_CODE = "de-at"
|
||||
TIME_ZONE = "Europe/Vienna"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# --- Static / Media ----------------------------------------------------------
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# --- Celery ------------------------------------------------------------------
|
||||
CELERY_BROKER_URL = env("CELERY_BROKER_URL", default="redis://redis:6379/0")
|
||||
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", default="django-db")
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
CELERY_TASK_TIME_LIMIT = 600 # 10 Minuten Hard-Timeout
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 540
|
||||
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
|
||||
# --- App ---------------------------------------------------------------------
|
||||
JOB_RETENTION_DAYS = env("JOB_RETENTION_DAYS")
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# --- Security Defaults -------------------------------------------------------
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
CSRF_COOKIE_HTTPONLY = False # Bleibt false, damit JS/HTMX-Forms funktionieren
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
@@ -0,0 +1,16 @@
|
||||
from .base import * # noqa: F401,F403
|
||||
from .base import INSTALLED_APPS, MIDDLEWARE
|
||||
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
# Debug-Toolbar nur lokal
|
||||
INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"]
|
||||
MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
|
||||
INTERNAL_IPS = ["127.0.0.1"]
|
||||
|
||||
# Im Dev keine Auto-Lockouts beim Testen
|
||||
AXES_ENABLED = False
|
||||
|
||||
# E-Mails nur in die Konsole
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
@@ -0,0 +1,32 @@
|
||||
from .base import * # noqa: F401,F403
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Strikte Security-Defaults – TLS macht der äußere Proxy.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_HSTS_SECONDS = 0 # HSTS setzt der äußere Proxy
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
|
||||
SECURE_HSTS_PRELOAD = False
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "{asctime} {levelname} {name} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {"handlers": ["console"], "level": "INFO"},
|
||||
"loggers": {
|
||||
"django.security": {"handlers": ["console"], "level": "WARNING", "propagate": False},
|
||||
"mailmerge": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.http import HttpResponse
|
||||
from django.urls import include, path
|
||||
|
||||
|
||||
def healthz(_request):
|
||||
return HttpResponse("ok", content_type="text/plain")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("healthz", healthz),
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/login/", auth_views.LoginView.as_view(), name="login"),
|
||||
path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||
path("", include("mailmerge.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
try:
|
||||
import debug_toolbar
|
||||
urlpatterns = [path("__debug__/", include(debug_toolbar.urls)), *urlpatterns]
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||
application = get_wsgi_application()
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
ROLE="${ROLE:-web}"
|
||||
|
||||
if [[ "$ROLE" == "web" ]]; then
|
||||
echo "[entrypoint] waiting for database..."
|
||||
python manage.py wait_for_db --timeout 60 || true
|
||||
|
||||
echo "[entrypoint] running migrations..."
|
||||
python manage.py migrate --noinput
|
||||
|
||||
if [[ "${DJANGO_DEBUG:-False}" != "True" ]]; then
|
||||
echo "[entrypoint] collecting static files..."
|
||||
python manage.py collectstatic --noinput --clear
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[entrypoint] starting role=$ROLE: $*"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import JobLogEntry, LetterTemplate, MailMergeJob
|
||||
|
||||
|
||||
@admin.register(LetterTemplate)
|
||||
class LetterTemplateAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "created_by", "created_at")
|
||||
search_fields = ("name",)
|
||||
readonly_fields = ("id", "placeholders", "created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(MailMergeJob)
|
||||
class MailMergeJobAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "template", "status", "processed_rows", "total_rows",
|
||||
"created_by", "created_at")
|
||||
list_filter = ("status",)
|
||||
readonly_fields = ("id", "created_at", "started_at", "finished_at",
|
||||
"processed_rows", "total_rows", "error_message")
|
||||
|
||||
|
||||
@admin.register(JobLogEntry)
|
||||
class JobLogEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ("job", "level", "timestamp")
|
||||
list_filter = ("level",)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MailmergeConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "mailmerge"
|
||||
verbose_name = "Serienbrief"
|
||||
@@ -0,0 +1,34 @@
|
||||
from django import forms
|
||||
|
||||
from .models import LetterTemplate, MailMergeJob
|
||||
|
||||
|
||||
class LetterTemplateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = LetterTemplate
|
||||
fields = ["name", "description", "file"]
|
||||
widgets = {
|
||||
"description": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def clean_file(self):
|
||||
f = self.cleaned_data["file"]
|
||||
if not f.name.lower().endswith(".docx"):
|
||||
raise forms.ValidationError("Nur .docx-Dateien sind erlaubt.")
|
||||
if f.size > 10 * 1024 * 1024:
|
||||
raise forms.ValidationError("Datei zu groß (max. 10 MB).")
|
||||
return f
|
||||
|
||||
|
||||
class MailMergeJobForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = MailMergeJob
|
||||
fields = ["template", "recipients_csv"]
|
||||
|
||||
def clean_recipients_csv(self):
|
||||
f = self.cleaned_data["recipients_csv"]
|
||||
if not f.name.lower().endswith(".csv"):
|
||||
raise forms.ValidationError("Nur .csv-Dateien sind erlaubt.")
|
||||
if f.size > 20 * 1024 * 1024:
|
||||
raise forms.ValidationError("Datei zu groß (max. 20 MB).")
|
||||
return f
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Wartet, bis die DB Connections annimmt."""
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import OperationalError, connections
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Wartet, bis die Standard-DB verfügbar ist."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--timeout", type=int, default=60)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
deadline = time.time() + opts["timeout"]
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
connections["default"].ensure_connection()
|
||||
self.stdout.write(self.style.SUCCESS("DB ist bereit."))
|
||||
return
|
||||
except OperationalError:
|
||||
self.stdout.write("warte auf DB...")
|
||||
time.sleep(2)
|
||||
self.stderr.write(self.style.ERROR("DB nach Timeout nicht erreichbar."))
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Datenmodell – Templates, Jobs, Log-Einträge.
|
||||
"""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
def template_upload_path(instance, filename: str) -> str:
|
||||
return f"templates/{instance.id}/{Path(filename).name}"
|
||||
|
||||
|
||||
def csv_upload_path(instance, filename: str) -> str:
|
||||
return f"jobs/{instance.id}/recipients/{Path(filename).name}"
|
||||
|
||||
|
||||
def result_upload_path(instance, filename: str) -> str:
|
||||
return f"jobs/{instance.id}/result/{Path(filename).name}"
|
||||
|
||||
|
||||
class LetterTemplate(models.Model):
|
||||
"""DOCX-Vorlage mit Jinja-Platzhaltern."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
file = models.FileField(upload_to=template_upload_path)
|
||||
placeholders = models.JSONField(default=list, blank=True,
|
||||
help_text="Aus dem DOCX extrahierte Variablen.")
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
|
||||
related_name="templates")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class MailMergeJob(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Wartet"
|
||||
RUNNING = "running", "Läuft"
|
||||
DONE = "done", "Fertig"
|
||||
FAILED = "failed", "Fehlgeschlagen"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
template = models.ForeignKey(LetterTemplate, on_delete=models.PROTECT,
|
||||
related_name="jobs")
|
||||
recipients_csv = models.FileField(upload_to=csv_upload_path)
|
||||
status = models.CharField(max_length=20, choices=Status.choices,
|
||||
default=Status.PENDING)
|
||||
result_pdf = models.FileField(upload_to=result_upload_path, null=True, blank=True)
|
||||
total_rows = models.PositiveIntegerField(default=0)
|
||||
processed_rows = models.PositiveIntegerField(default=0)
|
||||
error_message = models.TextField(blank=True)
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
|
||||
related_name="jobs")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Job {self.id} ({self.template.name})"
|
||||
|
||||
|
||||
class JobLogEntry(models.Model):
|
||||
class Level(models.TextChoices):
|
||||
INFO = "info", "Info"
|
||||
WARNING = "warning", "Warnung"
|
||||
ERROR = "error", "Fehler"
|
||||
|
||||
job = models.ForeignKey(MailMergeJob, on_delete=models.CASCADE, related_name="logs")
|
||||
level = models.CharField(max_length=10, choices=Level.choices, default=Level.INFO)
|
||||
message = models.TextField()
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["timestamp"]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
DOCX-Rendering und PDF-Konvertierung.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docxtpl import DocxTemplate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
|
||||
def extract_placeholders(docx_path: Path) -> list[str]:
|
||||
"""Liest die Jinja-Platzhalter aus einem DOCX und gibt sie sortiert zurück."""
|
||||
doc = Document(str(docx_path))
|
||||
found: set[str] = set()
|
||||
for para in doc.paragraphs:
|
||||
for m in PLACEHOLDER_RE.finditer(para.text):
|
||||
found.add(m.group(1))
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
for cell in row.cells:
|
||||
for para in cell.paragraphs:
|
||||
for m in PLACEHOLDER_RE.finditer(para.text):
|
||||
found.add(m.group(1))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def render_docx(template_path: Path, context: dict, out_path: Path) -> Path:
|
||||
"""Füllt das DOCX-Template mit Kontext und schreibt das Ergebnis."""
|
||||
tpl = DocxTemplate(str(template_path))
|
||||
tpl.render(context)
|
||||
tpl.save(str(out_path))
|
||||
return out_path
|
||||
|
||||
|
||||
def docx_to_pdf(docx_path: Path, out_dir: Path) -> Path:
|
||||
"""Konvertiert DOCX nach PDF mit LibreOffice headless.
|
||||
|
||||
LibreOffice braucht ein eigenes Profilverzeichnis, sonst kollidieren
|
||||
parallele Worker.
|
||||
"""
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="lo-profile-") as profile_dir:
|
||||
cmd = [
|
||||
"soffice",
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--norestore",
|
||||
"--nolockcheck",
|
||||
f"-env:UserInstallation=file://{profile_dir}",
|
||||
"--convert-to", "pdf",
|
||||
"--outdir", str(out_dir),
|
||||
str(docx_path),
|
||||
]
|
||||
logger.info("LibreOffice convert: %s", " ".join(cmd))
|
||||
result = subprocess.run( # noqa: S603
|
||||
cmd, capture_output=True, text=True, timeout=120, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"LibreOffice-Konvertierung fehlgeschlagen: {result.stderr}"
|
||||
)
|
||||
pdf_path = out_dir / (docx_path.stem + ".pdf")
|
||||
if not pdf_path.exists():
|
||||
raise FileNotFoundError(f"PDF nicht gefunden: {pdf_path}")
|
||||
return pdf_path
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pypdf import PdfWriter
|
||||
|
||||
|
||||
def merge_pdfs(pdfs: list[Path], out_path: Path) -> Path:
|
||||
writer = PdfWriter()
|
||||
for pdf in pdfs:
|
||||
writer.append(str(pdf))
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out_path.open("wb") as f:
|
||||
writer.write(f)
|
||||
writer.close()
|
||||
return out_path
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Celery-Tasks für die PDF-Erzeugung.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from celery import shared_task
|
||||
from django.core.files import File
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import JobLogEntry, MailMergeJob
|
||||
from .services.docx_renderer import docx_to_pdf, render_docx
|
||||
from .services.pdf_merge import merge_pdfs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _log(job: MailMergeJob, level: str, msg: str) -> None:
|
||||
JobLogEntry.objects.create(job=job, level=level, message=msg)
|
||||
logger.log(getattr(logging, level.upper(), logging.INFO), "[job %s] %s", job.id, msg)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def run_mailmerge(self, job_id: str) -> str:
|
||||
job = MailMergeJob.objects.select_related("template").get(pk=job_id)
|
||||
job.status = MailMergeJob.Status.RUNNING
|
||||
job.started_at = timezone.now()
|
||||
job.save(update_fields=["status", "started_at"])
|
||||
_log(job, "info", f"Job gestartet (task={self.request.id}).")
|
||||
|
||||
try:
|
||||
# CSV einlesen
|
||||
raw = job.recipients_csv.read().decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(raw))
|
||||
rows = list(reader)
|
||||
job.total_rows = len(rows)
|
||||
job.save(update_fields=["total_rows"])
|
||||
_log(job, "info", f"{len(rows)} Empfänger gefunden.")
|
||||
|
||||
if not rows:
|
||||
raise ValueError("CSV enthält keine Datenzeilen.")
|
||||
|
||||
# CSV-Felder vs. Template-Platzhalter prüfen
|
||||
csv_fields = set(reader.fieldnames or [])
|
||||
placeholders = set(job.template.placeholders or [])
|
||||
missing = placeholders - csv_fields
|
||||
if missing:
|
||||
_log(job, "warning",
|
||||
f"CSV fehlen Spalten: {', '.join(sorted(missing))}")
|
||||
|
||||
# Jeden Brief rendern, alle zu einem PDF zusammenführen
|
||||
with tempfile.TemporaryDirectory(prefix="mailmerge-") as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
pdfs: list[Path] = []
|
||||
template_path = Path(job.template.file.path)
|
||||
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
docx_out = tmp / f"letter_{idx:05d}.docx"
|
||||
render_docx(template_path, row, docx_out)
|
||||
pdf = docx_to_pdf(docx_out, tmp / "pdf")
|
||||
pdfs.append(pdf)
|
||||
job.processed_rows = idx
|
||||
job.save(update_fields=["processed_rows"])
|
||||
|
||||
merged = tmp / f"serienbrief_{job.id}.pdf"
|
||||
merge_pdfs(pdfs, merged)
|
||||
with merged.open("rb") as f:
|
||||
job.result_pdf.save(merged.name, File(f), save=False)
|
||||
|
||||
job.status = MailMergeJob.Status.DONE
|
||||
job.finished_at = timezone.now()
|
||||
job.save()
|
||||
_log(job, "info", "Job erfolgreich abgeschlossen.")
|
||||
return str(job.id)
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
job.status = MailMergeJob.Status.FAILED
|
||||
job.error_message = str(exc)
|
||||
job.finished_at = timezone.now()
|
||||
job.save()
|
||||
_log(job, "error", f"Job fehlgeschlagen: {exc}")
|
||||
raise
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.dashboard, name="dashboard"),
|
||||
path("templates/new/", views.template_upload, name="template-upload"),
|
||||
path("templates/<uuid:pk>/", views.template_detail, name="template-detail"),
|
||||
path("jobs/new/", views.job_create, name="job-create"),
|
||||
path("jobs/<uuid:pk>/", views.job_detail, name="job-detail"),
|
||||
path("jobs/<uuid:pk>/download/", views.job_download, name="job-download"),
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import FileResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .forms import LetterTemplateForm, MailMergeJobForm
|
||||
from .models import LetterTemplate, MailMergeJob
|
||||
from .services.docx_renderer import extract_placeholders
|
||||
from .tasks import run_mailmerge
|
||||
|
||||
|
||||
@login_required
|
||||
def dashboard(request):
|
||||
templates = LetterTemplate.objects.all()[:20]
|
||||
jobs = MailMergeJob.objects.select_related("template")[:20]
|
||||
return render(request, "mailmerge/dashboard.html",
|
||||
{"templates": templates, "jobs": jobs})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def template_upload(request):
|
||||
form = LetterTemplateForm(request.POST or None, request.FILES or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
tpl = form.save(commit=False)
|
||||
tpl.created_by = request.user
|
||||
tpl.save()
|
||||
# Platzhalter extrahieren – Datei liegt jetzt auf Disk
|
||||
tpl.placeholders = extract_placeholders(Path(tpl.file.path))
|
||||
tpl.save(update_fields=["placeholders"])
|
||||
return redirect(reverse("template-detail", args=[tpl.id]))
|
||||
return render(request, "mailmerge/template_form.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
def template_detail(request, pk):
|
||||
tpl = get_object_or_404(LetterTemplate, pk=pk)
|
||||
return render(request, "mailmerge/template_detail.html", {"template": tpl})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def job_create(request):
|
||||
form = MailMergeJobForm(request.POST or None, request.FILES or None)
|
||||
if request.method == "POST" and form.is_valid():
|
||||
job = form.save(commit=False)
|
||||
job.created_by = request.user
|
||||
job.save()
|
||||
run_mailmerge.delay(str(job.id))
|
||||
return redirect(reverse("job-detail", args=[job.id]))
|
||||
return render(request, "mailmerge/job_form.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
def job_detail(request, pk):
|
||||
job = get_object_or_404(
|
||||
MailMergeJob.objects.select_related("template"), pk=pk
|
||||
)
|
||||
logs = job.logs.all()
|
||||
# HTMX partials: nur das Status-Fragment ausliefern
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "mailmerge/_job_status.html",
|
||||
{"job": job, "logs": logs})
|
||||
return render(request, "mailmerge/job_detail.html",
|
||||
{"job": job, "logs": logs})
|
||||
|
||||
|
||||
@login_required
|
||||
def job_download(request, pk):
|
||||
"""PDF-Download – via X-Accel-Redirect in Production, direkter Stream im Dev."""
|
||||
job = get_object_or_404(MailMergeJob, pk=pk)
|
||||
if not job.result_pdf:
|
||||
raise Http404("Kein Ergebnis-PDF vorhanden.")
|
||||
|
||||
# In Dev (settings.DEBUG) direkt streamen
|
||||
from django.conf import settings
|
||||
if settings.DEBUG:
|
||||
return FileResponse(job.result_pdf.open("rb"),
|
||||
as_attachment=True,
|
||||
filename=Path(job.result_pdf.name).name)
|
||||
|
||||
# In Production: Nginx serviert die Datei via internem Mount.
|
||||
response = HttpResponse()
|
||||
response["Content-Type"] = "application/pdf"
|
||||
response["Content-Disposition"] = (
|
||||
f'attachment; filename="{Path(job.result_pdf.name).name}"'
|
||||
)
|
||||
# Pfad relativ zu MEDIA_ROOT, gemappt auf /protected-media/
|
||||
relative = job.result_pdf.name
|
||||
response["X-Accel-Redirect"] = f"/protected-media/{relative}"
|
||||
return response
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django management entry point."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Django konnte nicht importiert werden. Ist die venv aktiv?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
extend-exclude = ["migrations", "staticfiles", "media"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B", "DJ", "S"]
|
||||
ignore = ["E501", "S101"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"**/tests/**" = ["S"]
|
||||
"**/settings/**" = ["S105", "S106"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings.dev"
|
||||
python_files = ["test_*.py", "*_test.py", "tests.py"]
|
||||
addopts = "--reuse-db -ra"
|
||||
@@ -0,0 +1,8 @@
|
||||
# Nur im dev-Build installieren
|
||||
debugpy==1.8.7
|
||||
django-debug-toolbar==4.4.6
|
||||
ipython==8.29.0
|
||||
ruff==0.7.4
|
||||
pytest==8.3.3
|
||||
pytest-django==4.9.0
|
||||
factory-boy==3.3.1
|
||||
@@ -0,0 +1,15 @@
|
||||
Django==5.1.4
|
||||
django-environ==0.11.2
|
||||
django-celery-beat==2.7.0
|
||||
django-celery-results==2.5.1
|
||||
psycopg[binary]==3.2.3
|
||||
gunicorn==23.0.0
|
||||
celery[redis]==5.4.0
|
||||
redis==5.2.0
|
||||
docxtpl==0.19.0
|
||||
python-docx==1.1.2
|
||||
pypdf==5.1.0
|
||||
argon2-cffi==23.1.0
|
||||
django-axes==7.0.1
|
||||
django-htmx==1.21.0
|
||||
python-dateutil==2.9.0.post0
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Serienbrief{% endblock %}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 960px; margin: 2rem auto; padding: 0 1rem; color: #222; }
|
||||
nav { display: flex; gap: 1rem; padding-bottom: 1rem; border-bottom: 1px solid #ddd; margin-bottom: 1.5rem; }
|
||||
nav a { text-decoration: none; color: #0366d6; }
|
||||
h1, h2 { color: #111; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #eee; }
|
||||
.status-pending { color: #888; }
|
||||
.status-running { color: #d97706; }
|
||||
.status-done { color: #059669; }
|
||||
.status-failed { color: #dc2626; }
|
||||
.btn { display: inline-block; padding: 0.4rem 0.8rem; background: #0366d6; color: white; border-radius: 4px; text-decoration: none; border: none; cursor: pointer; }
|
||||
.messages li { padding: 0.5rem 1rem; background: #fef3c7; border-left: 4px solid #f59e0b; margin: 0.5rem 0; list-style: none; }
|
||||
.log { font-family: monospace; font-size: 0.85rem; background: #f6f8fa; padding: 0.75rem; border-radius: 4px; }
|
||||
.log .level-error { color: #dc2626; }
|
||||
.log .level-warning { color: #d97706; }
|
||||
form p { margin: 0.75rem 0; }
|
||||
label { display: block; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="{% url 'dashboard' %}">Übersicht</a>
|
||||
<a href="{% url 'template-upload' %}">Neue Vorlage</a>
|
||||
<a href="{% url 'job-create' %}">Neuer Serienbrief</a>
|
||||
<span style="margin-left:auto">
|
||||
{% if user.is_authenticated %}
|
||||
{{ user.username }} · <a href="{% url 'logout' %}">Abmelden</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</nav>
|
||||
{% if messages %}<ul class="messages">{% for m in messages %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
<div hx-get="{% url 'job-detail' job.id %}" hx-trigger="every 2s" hx-swap="outerHTML">
|
||||
<p><strong>Vorlage:</strong> {{ job.template.name }}</p>
|
||||
<p><strong>Status:</strong>
|
||||
<span class="status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||
</p>
|
||||
<p><strong>Fortschritt:</strong> {{ job.processed_rows }} / {{ job.total_rows }}</p>
|
||||
|
||||
{% if job.status == "done" %}
|
||||
<p><a class="btn" href="{% url 'job-download' job.id %}">PDF herunterladen</a></p>
|
||||
{% endif %}
|
||||
{% if job.error_message %}
|
||||
<p style="color:#dc2626"><strong>Fehler:</strong> {{ job.error_message }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Log</h3>
|
||||
<div class="log">
|
||||
{% for entry in logs %}
|
||||
<div class="level-{{ entry.level }}">
|
||||
[{{ entry.timestamp|date:"H:i:s" }}] {{ entry.level|upper }} – {{ entry.message }}
|
||||
</div>
|
||||
{% empty %}
|
||||
<em>Keine Einträge.</em>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Übersicht</h1>
|
||||
|
||||
<h2>Vorlagen</h2>
|
||||
<a class="btn" href="{% url 'template-upload' %}">Neue Vorlage hochladen</a>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Platzhalter</th><th>Erstellt</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in templates %}
|
||||
<tr>
|
||||
<td><a href="{% url 'template-detail' t.id %}">{{ t.name }}</a></td>
|
||||
<td>{{ t.placeholders|join:", " }}</td>
|
||||
<td>{{ t.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3"><em>Noch keine Vorlagen vorhanden.</em></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Aufträge</h2>
|
||||
<a class="btn" href="{% url 'job-create' %}">Neuen Serienbrief erstellen</a>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Vorlage</th><th>Status</th><th>Fortschritt</th><th>Erstellt</th></tr></thead>
|
||||
<tbody>
|
||||
{% for j in jobs %}
|
||||
<tr>
|
||||
<td><a href="{% url 'job-detail' j.id %}">{{ j.id|stringformat:"s"|slice:":8" }}…</a></td>
|
||||
<td>{{ j.template.name }}</td>
|
||||
<td class="status-{{ j.status }}">{{ j.get_status_display }}</td>
|
||||
<td>{{ j.processed_rows }} / {{ j.total_rows }}</td>
|
||||
<td>{{ j.created_at|date:"d.m.Y H:i" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5"><em>Noch keine Aufträge.</em></td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Auftrag {{ job.id|stringformat:"s"|slice:":8" }}…</h1>
|
||||
|
||||
<div hx-get="{% url 'job-detail' job.id %}"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="outerHTML">
|
||||
{% include "mailmerge/_job_status.html" %}
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Neuer Serienbrief</h1>
|
||||
<p>Vorlage und Empfänger-CSV auswählen. Die Spaltennamen der CSV müssen mit den Platzhaltern der Vorlage übereinstimmen (erste Zeile = Spaltennamen).</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn">Erstellen und starten</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>{{ template.name }}</h1>
|
||||
<p>{{ template.description }}</p>
|
||||
<p><strong>Datei:</strong> {{ template.file.name }}</p>
|
||||
<p><strong>Erkannte Platzhalter:</strong></p>
|
||||
<ul>
|
||||
{% for p in template.placeholders %}<li><code>{{ p }}</code></li>{% empty %}<li><em>Keine gefunden.</em></li>{% endfor %}
|
||||
</ul>
|
||||
<a class="btn" href="{% url 'job-create' %}">Serienbrief mit dieser Vorlage erstellen</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Neue Vorlage hochladen</h1>
|
||||
<p>DOCX-Datei mit Platzhaltern wie <code>{{ vorname }}</code>, <code>{{ nachname }}</code>, … Die Spaltennamen der späteren CSV müssen mit den Platzhaltern übereinstimmen.</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn">Hochladen</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Anmelden{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Anmelden</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit" class="btn">Anmelden</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user