Compare commits
7 Commits
7c7bd73404
...
b8fb35db7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8fb35db7a | ||
|
|
d7992558ee | ||
|
|
31bf348136 | ||
|
|
4e9fe816d5 | ||
|
|
59e05856b4 | ||
|
|
0e129ae56a | ||
|
|
4ef09750d6 |
@@ -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 \
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>✔ 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 • Raesfelder Str. 3 • 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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
compose.yml
20
compose.yml
@@ -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
|
||||
|
||||
@@ -79,6 +79,8 @@ services:
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
volumes:
|
||||
- media_files:/app/media
|
||||
command: ["celery", "-A", "core", "worker", "-l", "info"]
|
||||
|
||||
beat:
|
||||
|
||||
Reference in New Issue
Block a user