Compare commits

...

7 Commits

Author SHA1 Message Date
SysAdmin Agent
b8fb35db7a Fix Bestätigung email: send synchronously for immediate error feedback (STI-77)
Some checks failed
CI/CD Pipeline / test (push) Has been cancelled
CI/CD Pipeline / deploy (push) Has been cancelled
Code Quality / quality (push) Has been cancelled
The Bestätigung email was sent via Celery task (fire-and-forget), so the UI
always showed "wird gesendet" even when the task failed silently in the worker.
Now sends synchronously from the web process (matching the working test email
pattern) with proper error display to the user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:48:30 +00:00
SysAdmin Agent
d7992558ee Fix Vorlagen preview: use click handler instead of Bootstrap tab event (STI-82)
The shown.bs.tab event never fired, leaving the preview spinner forever.
Switched to a direct click handler with setTimeout for reliability.
Also added explicit credentials and HTTP error handling to the fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:42:56 +00:00
SysAdmin Agent
31bf348136 fix: Add media_files volume to worker service (STI-84)
Celery worker was missing the media_files:/app/media volume mount,
causing DMS files saved by background tasks (email attachments,
Bestätigungsschreiben PDFs) to land in ephemeral container storage
instead of the persistent named volume. After any container restart,
these files were lost while DB records remained → Http404.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 21:36:22 +00:00
SysAdmin Agent
4e9fe816d5 Fix version display: show actual version instead of 'vunknown'
Root cause: Dockerfile build context is ./app/ but VERSION file is at
repo root, so it's excluded from the Docker image. The context processor
tried parent.parent.parent which resolves to / inside the container.

Fix:
- Context processor now checks APP_VERSION env var first, then tries
  multiple file paths (repo root for local dev, app/ dir for Docker)
- Dockerfile accepts APP_VERSION build arg and sets it as ENV
- compose.yml passes APP_VERSION build arg to all service builds

Note: Deploy script needs `export APP_VERSION=$(cat VERSION)` before
docker-compose build for the build arg to pick up the version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:11:52 +00:00
SysAdmin Agent
59e05856b4 Improve email test: prominent card, HTML email, 'An mich' button
- Move test email form to a standalone card at the top of the page
  (was buried at the bottom of SMTP settings)
- Add 'An mich' button that fills in the logged-in user's email
- Send HTML + plain text test email (multi-alternative) styled like
  actual Stiftung emails, instead of plain text only
- Include diagnostic info (SMTP server, sender, user) in test email

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:07:36 +00:00
SysAdmin Agent
0e129ae56a Fix Vorlagen editor: working preview tab and improved layout
- Fix preview bug: preview event handlers were never attached when
  Summernote failed to load (fallback returned early at line 240)
- Restructure layout with Bootstrap tabs (Editor | Vorschau) instead
  of stacked editor+hidden preview
- Preview loads automatically when switching to the Vorschau tab
- Editor content getter works in all modes (Summernote, code, fallback)
- Editor now uses full viewport height for more editing space
- Variables sidebar gets 3 cols (was 4) giving editor 9 cols (was 8)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:04:30 +00:00
SysAdmin Agent
4ef09750d6 Remove 'Abgeschlossen' from payment pipeline, make 'Überwiesen' the final step
The 'Abgeschlossen' column was redundant after 'Überwiesen' since no further
action occurs after a payment is transferred. The pipeline is now 4 stages:
Offen → Nachweis eingereicht → Freigegeben → Überwiesen.

Existing 'abgeschlossen' records are merged into the 'Überwiesen' column.
Financial reports and queries are unaffected as they already include both statuses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 20:59:42 +00:00
12 changed files with 345 additions and 235 deletions

View File

@@ -1,6 +1,8 @@
FROM python:3.12-slim FROM python:3.12-slim
ARG APP_VERSION=unknown
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 PYTHONUNBUFFERED=1 \
APP_VERSION=$APP_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev postgresql-client \ build-essential libpq-dev postgresql-client \

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
_VERSION = None _VERSION = None
@@ -6,9 +7,20 @@ _VERSION = None
def app_version(request): def app_version(request):
global _VERSION global _VERSION
if _VERSION is None: if _VERSION is None:
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION" # 1. Environment variable (set in Docker/deployment)
try: _VERSION = os.environ.get("APP_VERSION", "").strip()
_VERSION = version_file.read_text().strip() if not _VERSION:
except FileNotFoundError: # 2. Try VERSION file at common locations
_VERSION = "unknown" base = Path(__file__).resolve().parent.parent # app/
for candidate in [
base.parent / "VERSION", # repo root (local dev)
base / "VERSION", # app/ dir (Docker)
]:
try:
_VERSION = candidate.read_text().strip()
break
except FileNotFoundError:
continue
else:
_VERSION = "unknown"
return {"APP_VERSION": _VERSION} return {"APP_VERSION": _VERSION}

View File

@@ -362,7 +362,6 @@ class DestinataerUnterstuetzung(models.Model):
("nachweis_eingereicht", "Nachweis eingereicht"), ("nachweis_eingereicht", "Nachweis eingereicht"),
("freigegeben", "Freigegeben"), ("freigegeben", "Freigegeben"),
("ausgezahlt", "Überwiesen"), ("ausgezahlt", "Überwiesen"),
("abgeschlossen", "Abgeschlossen"),
] ]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -485,7 +484,7 @@ class DestinataerUnterstuetzung(models.Model):
"in_bearbeitung": 3, "in_bearbeitung": 3,
"freigegeben": 3, "freigegeben": 3,
"ausgezahlt": 4, "ausgezahlt": 4,
"abgeschlossen": 5, "abgeschlossen": 4,
"storniert": 0, "storniert": 0,
} }
return stage_map.get(self.status, 1) return stage_map.get(self.status, 1)

View File

@@ -702,19 +702,16 @@ def send_onboarding_einladung(self, einladung_id, base_url=None):
raise self.retry(exc=exc) raise self.retry(exc=exc)
@shared_task(bind=True, max_retries=3, default_retry_delay=300) def _send_bestaetigung_sync(destinataer_id):
def send_bestaetigung(self, destinataer_id, base_url=None):
""" """
Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es Generiert ein Bestätigungsschreiben (PDF) für einen Destinatär und sendet es
per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt. per E-Mail. Das PDF wird zusätzlich im DMS unter Kontext "korrespondenz" abgelegt.
Args: Kann direkt (synchron) oder via Celery-Task aufgerufen werden.
destinataer_id: UUID des Destinatärs Bei Fehlern wird eine Exception geworfen (kein stilles Verschlucken).
base_url: Basis-URL der Anwendung (für Konsistenz mit anderen Tasks)
""" """
from decimal import Decimal from decimal import Decimal
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.template.loader import render_to_string
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.utils import timezone from django.utils import timezone
@@ -772,7 +769,7 @@ def send_bestaetigung(self, destinataer_id, base_url=None):
pdf_bytes = HTML(string=html_content).write_pdf() pdf_bytes = HTML(string=html_content).write_pdf()
except Exception as exc: except Exception as exc:
logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc) logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc)
raise self.retry(exc=exc) raise
# PDF im DMS ablegen # PDF im DMS ablegen
filename = ( filename = (
@@ -803,17 +800,23 @@ def send_bestaetigung(self, destinataer_id, base_url=None):
from_email = _get_smtp_from_email() from_email = _get_smtp_from_email()
to_email = destinataer.email to_email = destinataer.email
connection = _get_smtp_connection()
msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
if pdf_bytes:
msg.attach(filename, pdf_bytes, "application/pdf")
msg.send()
logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id)
return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email}
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_bestaetigung(self, destinataer_id, base_url=None):
"""Celery-Wrapper für _send_bestaetigung_sync (für asynchronen Aufruf)."""
try: try:
connection = _get_smtp_connection() return _send_bestaetigung_sync(destinataer_id)
msg = EmailMultiAlternatives(subject, "", from_email, [to_email], connection=connection)
msg.attach_alternative(html_body, "text/html")
if pdf_bytes:
msg.attach(filename, pdf_bytes, "application/pdf")
msg.send()
logger.info("Bestätigung gesendet an %s (Destinatär %s)", to_email, destinataer_id)
return {"status": "sent", "destinataer_id": str(destinataer_id), "email": to_email}
except Exception as exc: except Exception as exc:
logger.exception("send_bestaetigung: E-Mail-Versand fehlgeschlagen für %s: %s", to_email, exc) logger.exception("send_bestaetigung task fehlgeschlagen: %s", exc)
raise self.retry(exc=exc) raise self.retry(exc=exc)

View File

@@ -827,9 +827,9 @@ def bestaetigung_vorschau(request, pk):
def bestaetigung_versenden(request, pk): def bestaetigung_versenden(request, pk):
""" """
Sendet das Bestätigungsschreiben per E-Mail an den Destinatär. Sendet das Bestätigungsschreiben per E-Mail an den Destinatär.
POST-only (CSRF-geschützt). Startet asynchronen Celery-Task. POST-only (CSRF-geschützt). Sendet synchron für direktes Feedback.
""" """
from stiftung.tasks import send_bestaetigung from stiftung.tasks import _send_bestaetigung_sync
if request.method != "POST": if request.method != "POST":
return redirect("stiftung:destinataer_detail", pk=pk) return redirect("stiftung:destinataer_detail", pk=pk)
@@ -843,8 +843,24 @@ def bestaetigung_versenden(request, pk):
) )
return redirect("stiftung:destinataer_detail", pk=pk) return redirect("stiftung:destinataer_detail", pk=pk)
base_url = request.build_absolute_uri("/").rstrip("/") try:
send_bestaetigung.delay(str(destinataer.id), base_url=base_url) result = _send_bestaetigung_sync(str(destinataer.id))
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Bestätigung versenden fehlgeschlagen: %s", exc)
messages.error(
request,
f"Bestätigungsschreiben konnte nicht gesendet werden: {exc}",
)
return redirect("stiftung:destinataer_detail", pk=pk)
if result and result.get("status") == "skipped":
messages.warning(request, "Versand übersprungen: Keine E-Mail-Adresse hinterlegt.")
return redirect("stiftung:destinataer_detail", pk=pk)
if result and result.get("status") == "error":
messages.error(request, f"Fehler: {result.get('message', 'Unbekannter Fehler')}")
return redirect("stiftung:destinataer_detail", pk=pk)
log_action( log_action(
request, request,
@@ -857,7 +873,7 @@ def bestaetigung_versenden(request, pk):
messages.success( messages.success(
request, request,
f"Bestätigungsschreiben wird per E-Mail an {destinataer.email} gesendet.", f"Bestätigungsschreiben wurde erfolgreich an {destinataer.email} gesendet.",
) )
return redirect("stiftung:destinataer_detail", pk=pk) return redirect("stiftung:destinataer_detail", pk=pk)

View File

@@ -2042,7 +2042,7 @@ def email_settings(request):
} }
elif action == "test_smtp_send": elif action == "test_smtp_send":
from django.core.mail import EmailMessage, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils import timezone from django.utils import timezone
test_email = request.POST.get("test_email", "").strip() test_email = request.POST.get("test_email", "").strip()
@@ -2079,19 +2079,43 @@ def email_settings(request):
fail_silently=False, fail_silently=False,
) )
now = timezone.now().strftime("%d.%m.%Y %H:%M") now = timezone.now().strftime("%d.%m.%Y %H:%M")
msg = EmailMessage( text_body = (
f"Dies ist eine Test-E-Mail der Stiftungsverwaltung.\n\n"
f"Zeitpunkt: {now}\n"
f"SMTP-Server: {host}:{port}\n"
f"Absender: {from_email}\n"
f"Gesendet von: {request.user.get_full_name() or request.user.username}\n\n"
f"Wenn Sie diese E-Mail erhalten, funktioniert der E-Mail-Versand korrekt."
)
html_body = (
'<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"></head><body>'
'<div style="max-width:600px;margin:32px auto;font-family:Arial,sans-serif;'
'border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">'
'<div style="background:#1a3a5c;color:#fff;padding:28px 32px 20px;">'
'<h1 style="margin:0 0 4px;font-size:20px;">van Hees-Theyssen-Vogel\'sche Stiftung</h1>'
'<p style="margin:0;font-size:13px;opacity:0.8;">SMTP-Test</p></div>'
'<div style="padding:28px 32px;">'
'<p style="line-height:1.6;">Dies ist eine <strong>Test-E-Mail</strong> der Stiftungsverwaltung.</p>'
'<div style="background:#f0f6ff;border:1px solid #b0cce8;border-radius:6px;padding:16px 20px;margin:20px 0;">'
f'<p style="margin:0 0 8px;"><strong>Zeitpunkt:</strong> {now}</p>'
f'<p style="margin:0 0 8px;"><strong>SMTP-Server:</strong> {host}:{port}</p>'
f'<p style="margin:0 0 8px;"><strong>Absender:</strong> {from_email}</p>'
f'<p style="margin:0;"><strong>Gesendet von:</strong> {request.user.get_full_name() or request.user.username}</p>'
'</div>'
'<p style="line-height:1.6;color:#28a745;"><strong>&#10004; E-Mail-Versand funktioniert korrekt.</strong></p>'
'</div>'
'<div style="background:#f0f0f0;padding:16px 32px;font-size:12px;color:#777;border-top:1px solid #e0e0e0;">'
'van Hees-Theyssen-Vogel\'sche Stiftung &bull; Raesfelder Str. 3 &bull; 46499 Hamminkeln</div>'
'</div></body></html>'
)
msg = EmailMultiAlternatives(
subject=f"[vHTV-Stiftung] SMTP-Test ({now})", subject=f"[vHTV-Stiftung] SMTP-Test ({now})",
body=( body=text_body,
f"Dies ist eine Test-E-Mail der Stiftungsverwaltung.\n\n"
f"Zeitpunkt: {now}\n"
f"SMTP-Server: {host}:{port}\n"
f"Absender: {from_email}\n\n"
f"Wenn Sie diese E-Mail erhalten, funktioniert der E-Mail-Versand korrekt."
),
from_email=from_email, from_email=from_email,
to=[test_email], to=[test_email],
connection=connection, connection=connection,
) )
msg.attach_alternative(html_body, "text/html")
msg.send() msg.send()
test_result = { test_result = {
"success": True, "success": True,

View File

@@ -1813,7 +1813,7 @@ def batch_nachweis_aufforderung_senden(request):
@login_required @login_required
def zahlungs_pipeline(request): def zahlungs_pipeline(request):
"""2c: Zahlungs-Pipeline 5-Stufen-Kanban-Ansicht.""" """2c: Zahlungs-Pipeline 4-Stufen-Kanban-Ansicht."""
heute = date.today() heute = date.today()
destinataer_id = request.GET.get("destinataer", "") destinataer_id = request.GET.get("destinataer", "")
konto_id = request.GET.get("konto", "") konto_id = request.GET.get("konto", "")
@@ -1831,8 +1831,7 @@ def zahlungs_pipeline(request):
"offen": qs.filter(status__in=["geplant", "faellig"]).order_by("faellig_am"), "offen": qs.filter(status__in=["geplant", "faellig"]).order_by("faellig_am"),
"nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").order_by("faellig_am"), "nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").order_by("faellig_am"),
"freigegeben": qs.filter(status__in=["freigegeben", "in_bearbeitung"]).order_by("faellig_am"), "freigegeben": qs.filter(status__in=["freigegeben", "in_bearbeitung"]).order_by("faellig_am"),
"ueberwiesen": qs.filter(status="ausgezahlt").order_by("-ausgezahlt_am"), "ueberwiesen": qs.filter(status__in=["ausgezahlt", "abgeschlossen"]).order_by("-ausgezahlt_am"),
"abgeschlossen": qs.filter(status="abgeschlossen").order_by("-ausgezahlt_am"),
} }
stage_meta = { stage_meta = {
@@ -1840,7 +1839,6 @@ def zahlungs_pipeline(request):
"nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"), "nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"),
"freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"), "freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"),
"ueberwiesen": ("Überwiesen", "success", "fa-university"), "ueberwiesen": ("Überwiesen", "success", "fa-university"),
"abgeschlossen": ("Abgeschlossen", "dark", "fa-check-double"),
} }
pipeline_stages = [ pipeline_stages = [
@@ -1852,7 +1850,7 @@ def zahlungs_pipeline(request):
"zahlungen": list(pipeline[key]), "zahlungen": list(pipeline[key]),
"gesamt": pipeline[key].aggregate(s=Sum("betrag"))["s"] or Decimal("0"), "gesamt": pipeline[key].aggregate(s=Sum("betrag"))["s"] or Decimal("0"),
} }
for key in ["offen", "nachweis_eingereicht", "freigegeben", "ueberwiesen", "abgeschlossen"] for key in ["offen", "nachweis_eingereicht", "freigegeben", "ueberwiesen"]
] ]
destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname") destinataere = Destinataer.objects.filter(aktiv=True).order_by("nachname", "vorname")

View File

@@ -23,6 +23,46 @@
</div> </div>
{% endif %} {% endif %}
<!-- Test-E-Mail senden (prominent) -->
<div class="card mb-4 border-primary">
<div class="card-header bg-primary text-white">
<h3 class="card-title mb-0">
<i class="fas fa-paper-plane me-1"></i>
Test-E-Mail senden
</h3>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Sendet eine echte Test-E-Mail (HTML + Text) an die angegebene Adresse, um den vollständigen Versandweg zu prüfen.
</p>
<form method="post">
{% csrf_token %}
<div class="input-group">
<input type="email"
class="form-control"
id="test_email_top"
name="test_email"
placeholder="empfaenger@example.de"
value="{{ request.POST.test_email|default:'' }}"
required>
<button type="submit" name="action" value="test_smtp_send" class="btn btn-primary">
<i class="fas fa-paper-plane me-1"></i> Senden
</button>
{% if request.user.email %}
<button type="button" class="btn btn-outline-secondary" onclick="document.getElementById('test_email_top').value='{{ request.user.email }}'" title="Eigene E-Mail-Adresse einfügen">
<i class="fas fa-user me-1"></i> An mich
</button>
{% endif %}
</div>
{% if request.user.email %}
<div class="form-text">Ihre Adresse: <code>{{ request.user.email }}</code></div>
{% else %}
<div class="form-text text-warning"><i class="fas fa-exclamation-triangle me-1"></i>Kein E-Mail in Ihrem Benutzerprofil hinterlegt. Bitte Adresse manuell eingeben.</div>
{% endif %}
</form>
</div>
</div>
<!-- IMAP Section --> <!-- IMAP Section -->
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
@@ -178,24 +218,6 @@
</button> </button>
</div> </div>
<hr>
<div class="mb-3">
<label for="test_email" class="form-label">
<strong>Test-E-Mail senden</strong>
</label>
<div class="form-text mb-1">Sendet eine echte Test-E-Mail an die angegebene Adresse, um den vollständigen Versandweg zu prüfen.</div>
<div class="input-group">
<input type="email"
class="form-control"
id="test_email"
name="test_email"
placeholder="empfaenger@example.de"
value="{{ request.POST.test_email|default:'' }}">
<button type="submit" name="action" value="test_smtp_send" class="btn btn-outline-success">
<i class="fas fa-paper-plane me-1"></i> Test senden
</button>
</div>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -9,13 +9,13 @@
<style> <style>
.preview-frame { .preview-frame {
width: 100%; width: 100%;
height: 580px; height: 75vh;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;
} }
.var-list { .var-list {
max-height: 400px; max-height: 55vh;
overflow-y: auto; overflow-y: auto;
} }
.var-item { .var-item {
@@ -31,7 +31,7 @@
} }
.code-editor-textarea { .code-editor-textarea {
width: 100%; width: 100%;
height: 520px; height: 70vh;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
@@ -50,6 +50,11 @@
border-color: #86b7fe; border-color: #86b7fe;
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25); box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
} }
#tab-vorschau .preview-loading {
text-align: center;
padding: 80px 0;
color: #6c757d;
}
</style> </style>
{% endblock %} {% endblock %}
@@ -66,7 +71,7 @@
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'stiftung:vorlagen_liste' %}" class="btn btn-sm btn-outline-secondary"> <a href="{% url 'stiftung:vorlagen_liste' %}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>Zurück zur Liste <i class="fas fa-arrow-left me-1"></i>Zurück
</a> </a>
{% if hat_original %} {% if hat_original %}
<form method="post" action="{% url 'stiftung:vorlage_zuruecksetzen' pk=vorlage.pk %}" <form method="post" action="{% url 'stiftung:vorlage_zuruecksetzen' pk=vorlage.pk %}"
@@ -82,83 +87,93 @@
</div> </div>
</div> </div>
<div class="row"> <!-- Tabs: Editor | Vorschau -->
<!-- Editor (links) --> <ul class="nav nav-tabs mb-3" role="tablist">
<div class="col-lg-8"> <li class="nav-item">
<form method="post" id="editor-form"> <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-editor" type="button" role="tab">
{% csrf_token %} <i class="fas fa-code me-1"></i>Editor
<div class="d-flex gap-2 mb-2 justify-content-end"> </button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-preview"> </li>
<i class="fas fa-eye me-1"></i>Vorschau <li class="nav-item">
</button> <button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-vorschau" type="button" role="tab" id="btn-tab-vorschau">
<button type="submit" class="btn btn-sm btn-primary"> <i class="fas fa-eye me-1"></i>Vorschau
<i class="fas fa-save me-1"></i>Speichern </button>
</button> </li>
</div> <li class="nav-item ms-auto">
<textarea name="html_inhalt" id="code-editor"{% if use_code_editor %} class="code-editor-textarea"{% endif %}>{{ vorlage.html_inhalt }}</textarea> <button type="submit" form="editor-form" class="btn btn-sm btn-primary my-1">
<script type="application/json" id="vorlage-html-inhalt">{{ html_inhalt_json }}</script> <i class="fas fa-save me-1"></i>Speichern
</form> </button>
</li>
</ul>
<!-- Vorschau-Bereich (initial versteckt) --> <div class="tab-content">
<div id="preview-area" class="mt-3" style="display:none"> <!-- Editor Tab -->
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="tab-pane fade show active" id="tab-editor" role="tabpanel">
<strong>Vorschau <span class="badge bg-secondary">Beispieldaten</span></strong> <div class="row">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-close-preview"> <div class="{% if variablen %}col-lg-9{% else %}col-12{% endif %}">
<i class="fas fa-times me-1"></i>Vorschau schließen <form method="post" id="editor-form">
</button> {% csrf_token %}
<textarea name="html_inhalt" id="code-editor"{% if use_code_editor %} class="code-editor-textarea"{% endif %}>{{ vorlage.html_inhalt }}</textarea>
<script type="application/json" id="vorlage-html-inhalt">{{ html_inhalt_json }}</script>
</form>
</div> </div>
<iframe id="preview-frame" class="preview-frame" title="Vorschau"></iframe> {% if variablen %}
<div class="col-lg-3">
<div class="card shadow mb-3">
<div class="card-header py-2">
<h6 class="m-0 fw-bold text-primary small">
<i class="fas fa-code me-1"></i>Variablen
</h6>
<small class="text-muted">Klick zum Einfügen</small>
</div>
<div class="card-body p-0">
<div class="var-list">
<table class="table table-sm mb-0">
<tbody>
{% for var, beschreibung in variablen.items %}
<tr class="var-item" data-var="{{ var }}">
<td class="ps-3 py-1">
<code>{% templatetag openvariable %} {{ var }} {% templatetag closevariable %}</code>
</td>
<td class="pe-3 py-1 text-muted small">{{ beschreibung }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="card shadow">
<div class="card-header py-2">
<h6 class="m-0 fw-bold text-secondary small">
<i class="fas fa-info-circle me-1"></i>Info
</h6>
</div>
<div class="card-body small">
<dl class="mb-0">
<dt>Zuletzt bearbeitet</dt>
<dd class="text-muted">{{ vorlage.zuletzt_bearbeitet_am|date:"d.m.Y H:i" }}</dd>
{% if vorlage.zuletzt_bearbeitet_von %}
<dt>Bearbeitet von</dt>
<dd class="text-muted">{{ vorlage.zuletzt_bearbeitet_von.get_full_name|default:vorlage.zuletzt_bearbeitet_von.username }}</dd>
{% endif %}
<dt>Erstellt am</dt>
<dd class="text-muted mb-0">{{ vorlage.erstellt_am|date:"d.m.Y" }}</dd>
</dl>
</div>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
<!-- Seitenleiste (rechts) --> <!-- Vorschau Tab -->
<div class="col-lg-4"> <div class="tab-pane fade" id="tab-vorschau" role="tabpanel">
{% if variablen %} <div class="preview-loading" id="preview-loading">
<div class="card shadow mb-3"> <i class="fas fa-spinner fa-spin fa-2x mb-2 d-block"></i>
<div class="card-header py-2"> Vorschau wird geladen...
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-code me-1"></i>Verfügbare Variablen
</h6>
<small class="text-muted">Klick zum Einfügen</small>
</div>
<div class="card-body p-0">
<div class="var-list">
<table class="table table-sm mb-0">
<tbody>
{% for var, beschreibung in variablen.items %}
<tr class="var-item" data-var="{{ var }}">
<td class="ps-3 py-1">
<code>{% templatetag openvariable %} {{ var }} {% templatetag closevariable %}</code>
</td>
<td class="pe-3 py-1 text-muted small">{{ beschreibung }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="card shadow">
<div class="card-header py-2">
<h6 class="m-0 font-weight-bold text-secondary">
<i class="fas fa-info-circle me-1"></i>Info
</h6>
</div>
<div class="card-body small">
<dl class="mb-0">
<dt>Zuletzt bearbeitet</dt>
<dd class="text-muted">{{ vorlage.zuletzt_bearbeitet_am|date:"d.m.Y H:i" }}</dd>
{% if vorlage.zuletzt_bearbeitet_von %}
<dt>Bearbeitet von</dt>
<dd class="text-muted">{{ vorlage.zuletzt_bearbeitet_von.get_full_name|default:vorlage.zuletzt_bearbeitet_von.username }}</dd>
{% endif %}
<dt>Erstellt am</dt>
<dd class="text-muted mb-0">{{ vorlage.erstellt_am|date:"d.m.Y" }}</dd>
</dl>
</div>
</div> </div>
<iframe id="preview-frame" class="preview-frame" title="Vorschau" style="display:none"></iframe>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -180,15 +195,22 @@
var useCodeEditor = {{ use_code_editor|yesno:"true,false" }}; var useCodeEditor = {{ use_code_editor|yesno:"true,false" }};
var editor = document.getElementById('code-editor'); var editor = document.getElementById('code-editor');
var summernoteActive = false;
// Code-Editor-Modus: Plain textarea fuer vollstaendige HTML-Dokumente // Returns current HTML content regardless of editor mode
// (Serienbrief-Vorlagen mit DOCTYPE, Template-Tags usw.) function getEditorContent() {
if (summernoteActive) {
return $('#code-editor').summernote('code');
}
return editor.value;
}
// ---- Editor setup ----
if (useCodeEditor) { if (useCodeEditor) {
// Plain textarea for full HTML documents (Serienbrief)
if (initialContent) { if (initialContent) {
editor.value = initialContent; editor.value = initialContent;
} }
// Tab-Taste einfügen statt Fokus wechseln
editor.addEventListener('keydown', function(e) { editor.addEventListener('keydown', function(e) {
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
@@ -198,8 +220,7 @@
this.selectionStart = this.selectionEnd = start + 4; this.selectionStart = this.selectionEnd = start + 4;
} }
}); });
// Variable insertion for code editor
// Variablen einfügen bei Klick
document.querySelectorAll('.var-item').forEach(function(row) { document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() { row.addEventListener('click', function() {
var varName = this.getAttribute('data-var'); var varName = this.getAttribute('data-var');
@@ -210,101 +231,108 @@
editor.focus(); editor.focus();
}); });
}); });
} else if (typeof $ !== 'undefined' && typeof $.fn.summernote !== 'undefined') {
// Vorschau // Summernote WYSIWYG for HTML fragment templates
var previewArea = document.getElementById('preview-area'); $('#code-editor').summernote({
var previewFrame = document.getElementById('preview-frame'); lang: 'de-DE',
document.getElementById('btn-preview').addEventListener('click', function() { height: 520,
var formData = new FormData(); toolbar: [
formData.append('html_inhalt', editor.value); ['style', ['bold', 'italic', 'underline', 'strikethrough', 'clear']],
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value); ['para', ['style', 'ul', 'ol', 'paragraph']],
fetch('{% url "stiftung:vorlage_vorschau" pk=vorlage.pk %}', { method: 'POST', body: formData }) ['table', ['table']],
.then(function(r) { return r.text(); }) ['insert', ['link', 'hr']],
.then(function(html) { ['view', ['fullscreen', 'codeview', 'undo', 'redo']],
previewFrame.srcdoc = html; ],
previewArea.style.display = 'block'; callbacks: {
previewArea.scrollIntoView({behavior: 'smooth'}); onInit: function() {
}) if (initialContent) {
.catch(function(err) { alert('Vorschau fehlgeschlagen: ' + err); }); $('#code-editor').summernote('code', initialContent);
}); }
document.getElementById('btn-close-preview').addEventListener('click', function() {
previewArea.style.display = 'none';
});
return; // Skip Summernote initialization
}
if (typeof $ === 'undefined' || typeof $.fn.summernote === 'undefined') {
// Fallback: Summernote nicht geladen — Textarea sichtbar lassen
if (editor) { editor.style.height = '520px'; editor.style.fontFamily = 'monospace'; editor.style.fontSize = '13px'; }
return;
}
// Summernote initialisieren (für HTML-Fragment-Vorlagen: E-Mail, PDF-Fragmente)
$('#code-editor').summernote({
lang: 'de-DE',
height: 520,
toolbar: [
['style', ['bold', 'italic', 'underline', 'strikethrough', 'clear']],
['para', ['style', 'ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'hr']],
['view', ['fullscreen', 'codeview', 'undo', 'redo']],
],
callbacks: {
onInit: function() {
if (initialContent) {
$('#code-editor').summernote('code', initialContent);
} }
} }
}
});
// Variablen einfügen bei Klick
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
const varName = this.getAttribute('data-var');
const placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
$('#code-editor').summernote('focus');
$('#code-editor').summernote('insertText', placeholder);
}); });
}); summernoteActive = true;
// Vorschau // Variable insertion for Summernote
const previewArea = document.getElementById('preview-area'); document.querySelectorAll('.var-item').forEach(function(row) {
const previewFrame = document.getElementById('preview-frame'); row.addEventListener('click', function() {
const btnPreview = document.getElementById('btn-preview'); var varName = this.getAttribute('data-var');
const btnClosePreview = document.getElementById('btn-close-preview'); var placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
$('#code-editor').summernote('focus');
$('#code-editor').summernote('insertText', placeholder);
});
});
btnPreview.addEventListener('click', function() { // Sync Summernote to textarea on form submit
const content = $('#code-editor').summernote('code'); document.getElementById('editor-form').addEventListener('submit', function() {
const formData = new FormData(); document.querySelector('textarea[name=html_inhalt]').value = getEditorContent();
});
} else {
// Fallback: Summernote not loaded — style textarea as code editor
if (editor) {
editor.style.height = '70vh';
editor.style.fontFamily = "'SFMono-Regular', Consolas, monospace";
editor.style.fontSize = '13px';
editor.style.padding = '12px';
editor.style.background = '#f8f9fa';
}
if (initialContent) {
editor.value = initialContent;
}
// Variable insertion for plain textarea fallback
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
var varName = this.getAttribute('data-var');
var placeholder = String.fromCharCode(123,123) + ' ' + varName + ' ' + String.fromCharCode(125,125);
var start = editor.selectionStart;
editor.value = editor.value.substring(0, start) + placeholder + editor.value.substring(editor.selectionEnd);
editor.selectionStart = editor.selectionEnd = start + placeholder.length;
editor.focus();
});
});
}
// ---- Preview (always set up, independent of editor mode) ----
var previewFrame = document.getElementById('preview-frame');
var previewLoading = document.getElementById('preview-loading');
function loadPreview() {
var content = getEditorContent();
var csrfEl = document.querySelector('[name=csrfmiddlewaretoken]');
if (!csrfEl) {
previewLoading.innerHTML = '<i class="fas fa-exclamation-triangle text-danger fa-2x mb-2 d-block"></i>CSRF-Token nicht gefunden.';
return;
}
var formData = new FormData();
formData.append('html_inhalt', content); formData.append('html_inhalt', content);
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value); formData.append('csrfmiddlewaretoken', csrfEl.value);
previewLoading.style.display = 'block';
previewFrame.style.display = 'none';
fetch('{% url "stiftung:vorlage_vorschau" pk=vorlage.pk %}', { fetch('{% url "stiftung:vorlage_vorschau" pk=vorlage.pk %}', {
method: 'POST', method: 'POST',
body: formData, body: formData,
credentials: 'same-origin',
}) })
.then(r => r.text()) .then(function(r) {
.then(html => { if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function(html) {
previewFrame.srcdoc = html; previewFrame.srcdoc = html;
previewArea.style.display = 'block'; previewFrame.style.display = 'block';
previewArea.scrollIntoView({behavior: 'smooth'}); previewLoading.style.display = 'none';
}) })
.catch(err => alert('Vorschau fehlgeschlagen: ' + err)); .catch(function(err) {
}); previewLoading.innerHTML = '<i class="fas fa-exclamation-triangle text-danger fa-2x mb-2 d-block"></i>Vorschau fehlgeschlagen: ' + err;
});
}
btnClosePreview.addEventListener('click', function() { // Load preview when clicking the Vorschau tab (direct click — more reliable than Bootstrap tab events)
previewArea.style.display = 'none'; document.getElementById('btn-tab-vorschau').addEventListener('click', function() {
}); // Small delay so the tab pane is visible before we load
setTimeout(loadPreview, 100);
// Formular-Submit: Summernote-Inhalt in Textarea schreiben
document.getElementById('editor-form').addEventListener('submit', function() {
// Summernote schreibt den Inhalt automatisch in die Textarea beim Submit
// Sicherheitshalber explizit synchronisieren:
const content = $('#code-editor').summernote('code');
document.querySelector('textarea[name=html_inhalt]').value = content;
}); });
})(); })();
</script> </script>

View File

@@ -130,14 +130,6 @@
<a href="{% url 'stiftung:unterstuetzung_mark_paid' pk=z.pk %}" class="btn btn-xs btn-outline-success" style="font-size:0.7rem;padding:2px 6px;" title="Als überwiesen markieren"> <a href="{% url 'stiftung:unterstuetzung_mark_paid' pk=z.pk %}" class="btn btn-xs btn-outline-success" style="font-size:0.7rem;padding:2px 6px;" title="Als überwiesen markieren">
<i class="fas fa-university"></i> <i class="fas fa-university"></i>
</a> </a>
{% elif stage.key == 'ueberwiesen' %}
<form method="post" action="{% url 'stiftung:unterstuetzung_abschliessen' pk=z.pk %}">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'stiftung:zahlungs_pipeline' %}">
<button type="submit" class="btn btn-xs btn-outline-dark" style="font-size:0.7rem;padding:2px 6px;" title="Abschließen">
<i class="fas fa-check-double"></i>
</button>
</form>
{% endif %} {% endif %}
<a href="{% url 'stiftung:unterstuetzung_detail' pk=z.pk %}" class="btn btn-xs btn-outline-secondary" style="font-size:0.7rem;padding:2px 6px;" title="Details"> <a href="{% url 'stiftung:unterstuetzung_detail' pk=z.pk %}" class="btn btn-xs btn-outline-secondary" style="font-size:0.7rem;padding:2px 6px;" title="Details">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>

View File

@@ -25,7 +25,10 @@ services:
image: redis:7-alpine image: redis:7-alpine
web: web:
build: ./app build:
context: ./app
args:
APP_VERSION: ${APP_VERSION:-unknown}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -62,7 +65,10 @@ services:
command: ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] command: ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]
worker: worker:
build: ./app build:
context: ./app
args:
APP_VERSION: ${APP_VERSION:-unknown}
environment: environment:
- POSTGRES_DB=${POSTGRES_DB} - POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER} - POSTGRES_USER=${POSTGRES_USER}
@@ -88,7 +94,10 @@ services:
command: ["celery", "-A", "core", "worker", "-l", "info"] command: ["celery", "-A", "core", "worker", "-l", "info"]
beat: beat:
build: ./app build:
context: ./app
args:
APP_VERSION: ${APP_VERSION:-unknown}
environment: environment:
- POSTGRES_DB=${POSTGRES_DB} - POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER} - POSTGRES_USER=${POSTGRES_USER}
@@ -114,7 +123,10 @@ services:
command: ["celery", "-A", "core", "beat", "-l", "info"] command: ["celery", "-A", "core", "beat", "-l", "info"]
mcp: mcp:
build: ./app build:
context: ./app
args:
APP_VERSION: ${APP_VERSION:-unknown}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

View File

@@ -79,6 +79,8 @@ services:
depends_on: depends_on:
- redis - redis
- db - db
volumes:
- media_files:/app/media
command: ["celery", "-A", "core", "worker", "-l", "info"] command: ["celery", "-A", "core", "worker", "-l", "info"]
beat: beat: