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
ARG APP_VERSION=unknown
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
APP_VERSION=$APP_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev postgresql-client \

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path
_VERSION = None
@@ -6,9 +7,20 @@ _VERSION = None
def app_version(request):
global _VERSION
if _VERSION is None:
version_file = Path(__file__).resolve().parent.parent.parent / "VERSION"
try:
_VERSION = version_file.read_text().strip()
except FileNotFoundError:
_VERSION = "unknown"
# 1. Environment variable (set in Docker/deployment)
_VERSION = os.environ.get("APP_VERSION", "").strip()
if not _VERSION:
# 2. Try VERSION file at common locations
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}

View File

@@ -362,7 +362,6 @@ class DestinataerUnterstuetzung(models.Model):
("nachweis_eingereicht", "Nachweis eingereicht"),
("freigegeben", "Freigegeben"),
("ausgezahlt", "Überwiesen"),
("abgeschlossen", "Abgeschlossen"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -485,7 +484,7 @@ class DestinataerUnterstuetzung(models.Model):
"in_bearbeitung": 3,
"freigegeben": 3,
"ausgezahlt": 4,
"abgeschlossen": 5,
"abgeschlossen": 4,
"storniert": 0,
}
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)
@shared_task(bind=True, max_retries=3, default_retry_delay=300)
def send_bestaetigung(self, destinataer_id, base_url=None):
def _send_bestaetigung_sync(destinataer_id):
"""
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.
Args:
destinataer_id: UUID des Destinatärs
base_url: Basis-URL der Anwendung (für Konsistenz mit anderen Tasks)
Kann direkt (synchron) oder via Celery-Task aufgerufen werden.
Bei Fehlern wird eine Exception geworfen (kein stilles Verschlucken).
"""
from decimal import Decimal
from django.core.files.base import ContentFile
from django.template.loader import render_to_string
from django.core.mail import EmailMultiAlternatives
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()
except Exception as exc:
logger.error("send_bestaetigung: PDF-Generierung fehlgeschlagen: %s", exc)
raise self.retry(exc=exc)
raise
# PDF im DMS ablegen
filename = (
@@ -803,17 +800,23 @@ def send_bestaetigung(self, destinataer_id, base_url=None):
from_email = _get_smtp_from_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:
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}
return _send_bestaetigung_sync(destinataer_id)
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)

View File

@@ -827,9 +827,9 @@ def bestaetigung_vorschau(request, pk):
def bestaetigung_versenden(request, pk):
"""
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":
return redirect("stiftung:destinataer_detail", pk=pk)
@@ -843,8 +843,24 @@ def bestaetigung_versenden(request, pk):
)
return redirect("stiftung:destinataer_detail", pk=pk)
base_url = request.build_absolute_uri("/").rstrip("/")
send_bestaetigung.delay(str(destinataer.id), base_url=base_url)
try:
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(
request,
@@ -857,7 +873,7 @@ def bestaetigung_versenden(request, pk):
messages.success(
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)

View File

@@ -2042,7 +2042,7 @@ def email_settings(request):
}
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
test_email = request.POST.get("test_email", "").strip()
@@ -2079,19 +2079,43 @@ def email_settings(request):
fail_silently=False,
)
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})",
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."
),
body=text_body,
from_email=from_email,
to=[test_email],
connection=connection,
)
msg.attach_alternative(html_body, "text/html")
msg.send()
test_result = {
"success": True,

View File

@@ -1813,7 +1813,7 @@ def batch_nachweis_aufforderung_senden(request):
@login_required
def zahlungs_pipeline(request):
"""2c: Zahlungs-Pipeline 5-Stufen-Kanban-Ansicht."""
"""2c: Zahlungs-Pipeline 4-Stufen-Kanban-Ansicht."""
heute = date.today()
destinataer_id = request.GET.get("destinataer", "")
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"),
"nachweis_eingereicht": qs.filter(status="nachweis_eingereicht").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"),
"abgeschlossen": qs.filter(status="abgeschlossen").order_by("-ausgezahlt_am"),
"ueberwiesen": qs.filter(status__in=["ausgezahlt", "abgeschlossen"]).order_by("-ausgezahlt_am"),
}
stage_meta = {
@@ -1840,7 +1839,6 @@ def zahlungs_pipeline(request):
"nachweis_eingereicht": ("Nachweis eingereicht", "info", "fa-file-alt"),
"freigegeben": ("Freigegeben (4-Augen)", "warning", "fa-shield-alt"),
"ueberwiesen": ("Überwiesen", "success", "fa-university"),
"abgeschlossen": ("Abgeschlossen", "dark", "fa-check-double"),
}
pipeline_stages = [
@@ -1852,7 +1850,7 @@ def zahlungs_pipeline(request):
"zahlungen": list(pipeline[key]),
"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")

View File

@@ -23,6 +23,46 @@
</div>
{% 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 -->
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
@@ -178,24 +218,6 @@
</button>
</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>
</div>
</div>

View File

@@ -9,13 +9,13 @@
<style>
.preview-frame {
width: 100%;
height: 580px;
height: 75vh;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #fff;
}
.var-list {
max-height: 400px;
max-height: 55vh;
overflow-y: auto;
}
.var-item {
@@ -31,7 +31,7 @@
}
.code-editor-textarea {
width: 100%;
height: 520px;
height: 70vh;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.5;
@@ -50,6 +50,11 @@
border-color: #86b7fe;
box-shadow: 0 0 0 .25rem rgba(13,110,253,.25);
}
#tab-vorschau .preview-loading {
text-align: center;
padding: 80px 0;
color: #6c757d;
}
</style>
{% endblock %}
@@ -66,7 +71,7 @@
</div>
<div class="d-flex gap-2">
<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>
{% if hat_original %}
<form method="post" action="{% url 'stiftung:vorlage_zuruecksetzen' pk=vorlage.pk %}"
@@ -82,83 +87,93 @@
</div>
</div>
<div class="row">
<!-- Editor (links) -->
<div class="col-lg-8">
<form method="post" id="editor-form">
{% csrf_token %}
<div class="d-flex gap-2 mb-2 justify-content-end">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-preview">
<i class="fas fa-eye me-1"></i>Vorschau
</button>
<button type="submit" class="btn btn-sm btn-primary">
<i class="fas fa-save me-1"></i>Speichern
</button>
</div>
<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>
<!-- Tabs: Editor | Vorschau -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-editor" type="button" role="tab">
<i class="fas fa-code me-1"></i>Editor
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-vorschau" type="button" role="tab" id="btn-tab-vorschau">
<i class="fas fa-eye me-1"></i>Vorschau
</button>
</li>
<li class="nav-item ms-auto">
<button type="submit" form="editor-form" class="btn btn-sm btn-primary my-1">
<i class="fas fa-save me-1"></i>Speichern
</button>
</li>
</ul>
<!-- Vorschau-Bereich (initial versteckt) -->
<div id="preview-area" class="mt-3" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-2">
<strong>Vorschau <span class="badge bg-secondary">Beispieldaten</span></strong>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-close-preview">
<i class="fas fa-times me-1"></i>Vorschau schließen
</button>
<div class="tab-content">
<!-- Editor Tab -->
<div class="tab-pane fade show active" id="tab-editor" role="tabpanel">
<div class="row">
<div class="{% if variablen %}col-lg-9{% else %}col-12{% endif %}">
<form method="post" id="editor-form">
{% 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>
<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>
<!-- Seitenleiste (rechts) -->
<div class="col-lg-4">
{% if variablen %}
<div class="card shadow mb-3">
<div class="card-header py-2">
<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>
<!-- Vorschau Tab -->
<div class="tab-pane fade" id="tab-vorschau" role="tabpanel">
<div class="preview-loading" id="preview-loading">
<i class="fas fa-spinner fa-spin fa-2x mb-2 d-block"></i>
Vorschau wird geladen...
</div>
<iframe id="preview-frame" class="preview-frame" title="Vorschau" style="display:none"></iframe>
</div>
</div>
{% endblock %}
@@ -180,15 +195,22 @@
var useCodeEditor = {{ use_code_editor|yesno:"true,false" }};
var editor = document.getElementById('code-editor');
var summernoteActive = false;
// Code-Editor-Modus: Plain textarea fuer vollstaendige HTML-Dokumente
// (Serienbrief-Vorlagen mit DOCTYPE, Template-Tags usw.)
// Returns current HTML content regardless of editor mode
function getEditorContent() {
if (summernoteActive) {
return $('#code-editor').summernote('code');
}
return editor.value;
}
// ---- Editor setup ----
if (useCodeEditor) {
// Plain textarea for full HTML documents (Serienbrief)
if (initialContent) {
editor.value = initialContent;
}
// Tab-Taste einfügen statt Fokus wechseln
editor.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
@@ -198,8 +220,7 @@
this.selectionStart = this.selectionEnd = start + 4;
}
});
// Variablen einfügen bei Klick
// Variable insertion for code editor
document.querySelectorAll('.var-item').forEach(function(row) {
row.addEventListener('click', function() {
var varName = this.getAttribute('data-var');
@@ -210,101 +231,108 @@
editor.focus();
});
});
// Vorschau
var previewArea = document.getElementById('preview-area');
var previewFrame = document.getElementById('preview-frame');
document.getElementById('btn-preview').addEventListener('click', function() {
var formData = new FormData();
formData.append('html_inhalt', editor.value);
formData.append('csrfmiddlewaretoken', document.querySelector('[name=csrfmiddlewaretoken]').value);
fetch('{% url "stiftung:vorlage_vorschau" pk=vorlage.pk %}', { method: 'POST', body: formData })
.then(function(r) { return r.text(); })
.then(function(html) {
previewFrame.srcdoc = html;
previewArea.style.display = 'block';
previewArea.scrollIntoView({behavior: 'smooth'});
})
.catch(function(err) { alert('Vorschau fehlgeschlagen: ' + err); });
});
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);
} else if (typeof $ !== 'undefined' && typeof $.fn.summernote !== 'undefined') {
// Summernote WYSIWYG for HTML fragment templates
$('#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
const previewArea = document.getElementById('preview-area');
const previewFrame = document.getElementById('preview-frame');
const btnPreview = document.getElementById('btn-preview');
const btnClosePreview = document.getElementById('btn-close-preview');
// Variable insertion for Summernote
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);
$('#code-editor').summernote('focus');
$('#code-editor').summernote('insertText', placeholder);
});
});
btnPreview.addEventListener('click', function() {
const content = $('#code-editor').summernote('code');
const formData = new FormData();
// Sync Summernote to textarea on form submit
document.getElementById('editor-form').addEventListener('submit', function() {
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('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 %}', {
method: 'POST',
body: formData,
credentials: 'same-origin',
})
.then(r => r.text())
.then(html => {
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function(html) {
previewFrame.srcdoc = html;
previewArea.style.display = 'block';
previewArea.scrollIntoView({behavior: 'smooth'});
previewFrame.style.display = 'block';
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() {
previewArea.style.display = 'none';
});
// 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;
// Load preview when clicking the Vorschau tab (direct click — more reliable than Bootstrap tab events)
document.getElementById('btn-tab-vorschau').addEventListener('click', function() {
// Small delay so the tab pane is visible before we load
setTimeout(loadPreview, 100);
});
})();
</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">
<i class="fas fa-university"></i>
</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 %}
<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>

View File

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

View File

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