snapshot: preserve extended branding layer and branding UI polish

This commit is contained in:
Md Bayazid Bostame
2026-03-26 12:29:26 +01:00
parent c195efe339
commit 007d4e329a
14 changed files with 525 additions and 141 deletions

View File

@@ -22,7 +22,7 @@ class AdminAuditLogAdmin(admin.ModelAdmin):
@admin.register(PortalBranding)
class PortalBrandingAdmin(admin.ModelAdmin):
list_display = ('name', 'portal_title', 'company_name', 'support_email', 'default_language', 'updated_at')
list_display = ('name', 'portal_title', 'company_name', 'company_domain', 'support_email', 'default_language', 'updated_at')
@admin.register(PortalAppConfig)

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
from pathlib import Path
from email.utils import formataddr
from django.conf import settings
from django.templatetags.static import static
from django.utils.translation import get_language
from .models import PortalBranding
@@ -16,6 +18,12 @@ def get_portal_branding() -> PortalBranding:
'company_name': 'TUBCO',
'company_domain': 'tub.co',
'support_email': 'info@tub.co',
'sender_display_name': 'TUBCO',
'login_subtitle': 'Bitte melden Sie sich mit Ihrem Benutzerkonto an.',
'footer_text': 'TUBCO Onboarding & Offboarding Portal',
'footer_text_en': 'TUBCO Onboarding & Offboarding Portal',
'legal_notice': '',
'legal_notice_en': '',
'default_language': 'de',
'primary_color': '#000078',
'secondary_color': '#c0002b',
@@ -40,6 +48,16 @@ def get_portal_logo_url() -> str:
return static('workflows/img/tubco-logo.svg')
def get_portal_favicon_url() -> str:
branding = get_portal_branding()
if branding.favicon_image:
try:
return branding.favicon_image.url
except ValueError:
pass
return static('workflows/img/tubco-logo.svg')
def get_portal_letterhead_path() -> Path:
branding = get_portal_branding()
if branding.pdf_letterhead:
@@ -54,18 +72,31 @@ def get_portal_letterhead_path() -> Path:
def get_branding_context() -> dict[str, object]:
branding = get_portal_branding()
lang = (get_language() or branding.default_language or 'de').split('-')[0]
footer_text = (branding.footer_text_en or '').strip() if lang == 'en' else ''
legal_notice = (branding.legal_notice_en or '').strip() if lang == 'en' else ''
if not footer_text:
footer_text = (branding.footer_text or branding.portal_title).strip()
if not legal_notice:
legal_notice = (branding.legal_notice or '').strip()
return {
'portal_branding': branding,
'portal_title': branding.portal_title,
'portal_company_name': branding.company_name,
'portal_email_domain': get_company_email_domain(),
'portal_support_email': branding.support_email,
'portal_sender_display_name': branding.sender_display_name or branding.company_name,
'portal_login_subtitle': branding.login_subtitle,
'portal_footer_text': footer_text,
'portal_legal_notice': legal_notice,
'portal_default_language': branding.default_language,
'portal_primary_color': branding.primary_color,
'portal_secondary_color': branding.secondary_color,
'portal_logo_url': get_portal_logo_url(),
'portal_favicon_url': get_portal_favicon_url(),
'portal_has_custom_logo': bool(branding.logo_image),
'portal_has_custom_letterhead': bool(branding.pdf_letterhead),
'portal_has_custom_favicon': bool(branding.favicon_image),
}
@@ -78,9 +109,20 @@ def get_branding_email_copy() -> dict[str, str]:
'company_domain': get_company_email_domain(),
'portal_title': portal_title,
'support_email': (branding.support_email or '').strip(),
'sender_display_name': (branding.sender_display_name or company_name).strip(),
}
def get_branded_from_email(email_address: str | None) -> str | None:
address = (email_address or '').strip()
if not address:
return None
display_name = (get_branding_email_copy()['sender_display_name'] or '').strip()
if not display_name:
return address
return formataddr((display_name, address))
def get_default_notification_templates() -> dict[str, dict[str, str]]:
from copy import deepcopy

View File

@@ -1,6 +1,7 @@
from django.conf import settings
from django.core.mail import EmailMessage, get_connection
from .branding import get_branded_from_email
from .models import SystemEmailConfig, WorkflowConfig
@@ -66,7 +67,7 @@ def send_system_email(
msg = EmailMessage(
subject=subject,
body=body,
from_email=(from_email or smtp['from_email']),
from_email=get_branded_from_email(from_email or smtp['from_email']) or (from_email or smtp['from_email']),
to=to,
connection=connection,
)

View File

@@ -178,9 +178,16 @@ class PortalBrandingForm(forms.ModelForm):
'company_name',
'company_domain',
'support_email',
'sender_display_name',
'login_subtitle',
'footer_text',
'footer_text_en',
'legal_notice',
'legal_notice_en',
'default_language',
'logo_image',
'pdf_letterhead',
'favicon_image',
'primary_color',
'secondary_color',
]
@@ -189,9 +196,16 @@ class PortalBrandingForm(forms.ModelForm):
'company_name': gettext_lazy('Firmenname'),
'company_domain': gettext_lazy('Firmen-Domain'),
'support_email': gettext_lazy('Support-E-Mail'),
'sender_display_name': gettext_lazy('Absender-Anzeigename'),
'login_subtitle': gettext_lazy('Login-Untertitel'),
'footer_text': gettext_lazy('Footer-Text DE'),
'footer_text_en': gettext_lazy('Footer-Text EN'),
'legal_notice': gettext_lazy('Rechtlicher Hinweis DE'),
'legal_notice_en': gettext_lazy('Rechtlicher Hinweis EN'),
'default_language': gettext_lazy('Standardsprache'),
'logo_image': gettext_lazy('Logo'),
'pdf_letterhead': gettext_lazy('PDF-Briefkopf'),
'favicon_image': gettext_lazy('Favicon'),
'primary_color': gettext_lazy('Primärfarbe'),
'secondary_color': gettext_lazy('Sekundärfarbe'),
}
@@ -200,6 +214,9 @@ class PortalBrandingForm(forms.ModelForm):
'secondary_color': forms.TextInput(attrs={'type': 'color'}),
'logo_image': forms.ClearableFileInput(attrs={'accept': '.svg,.png,.jpg,.jpeg,.webp'}),
'pdf_letterhead': forms.ClearableFileInput(attrs={'accept': '.pdf'}),
'favicon_image': forms.ClearableFileInput(attrs={'accept': '.ico,.png,.svg,.webp'}),
'legal_notice': forms.Textarea(attrs={'rows': 3}),
'legal_notice_en': forms.Textarea(attrs={'rows': 3}),
}
def clean_logo_image(self):
@@ -218,6 +235,14 @@ class PortalBrandingForm(forms.ModelForm):
raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.'))
return letterhead
def clean_favicon_image(self):
favicon = self.cleaned_data.get('favicon_image')
if not favicon:
return favicon
if getattr(favicon, 'size', 0) > 2 * 1024 * 1024:
raise forms.ValidationError(_('Das Favicon darf maximal 2 MB groß sein.'))
return favicon
class OnboardingRequestForm(forms.ModelForm):
first_name = forms.CharField(label='Vorname', required=False)

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.1.5 on 2026-03-26 11:02
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0039_portalbranding_company_domain'),
]
operations = [
migrations.AddField(
model_name='portalbranding',
name='favicon_image',
field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['ico', 'png', 'svg', 'webp'])]),
),
migrations.AddField(
model_name='portalbranding',
name='footer_text',
field=models.CharField(blank=True, default='TUBCO Onboarding & Offboarding Portal', max_length=255),
),
migrations.AddField(
model_name='portalbranding',
name='footer_text_en',
field=models.CharField(blank=True, default='TUBCO Onboarding & Offboarding Portal', max_length=255),
),
migrations.AddField(
model_name='portalbranding',
name='legal_notice',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='portalbranding',
name='legal_notice_en',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='portalbranding',
name='login_subtitle',
field=models.CharField(blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.', max_length=255),
),
migrations.AddField(
model_name='portalbranding',
name='sender_display_name',
field=models.CharField(blank=True, default='TUBCO', max_length=255),
),
]

View File

@@ -31,6 +31,12 @@ class PortalBranding(models.Model):
company_name = models.CharField(max_length=255, default='TUBCO')
company_domain = models.CharField(max_length=120, blank=True, default='tub.co')
support_email = models.EmailField(blank=True, default='info@tub.co')
sender_display_name = models.CharField(max_length=255, blank=True, default='TUBCO')
login_subtitle = models.CharField(max_length=255, blank=True, default='Bitte melden Sie sich mit Ihrem Benutzerkonto an.')
footer_text = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal')
footer_text_en = models.CharField(max_length=255, blank=True, default='TUBCO Onboarding & Offboarding Portal')
legal_notice = models.TextField(blank=True, default='')
legal_notice_en = models.TextField(blank=True, default='')
default_language = models.CharField(
max_length=10,
choices=[('de', 'Deutsch'), ('en', 'English')],
@@ -48,6 +54,12 @@ class PortalBranding(models.Model):
null=True,
validators=[FileExtensionValidator(allowed_extensions=['pdf'])],
)
favicon_image = models.FileField(
upload_to='branding/',
blank=True,
null=True,
validators=[FileExtensionValidator(allowed_extensions=['ico', 'png', 'svg', 'webp'])],
)
primary_color = models.CharField(max_length=20, blank=True, default='#000078')
secondary_color = models.CharField(max_length=20, blank=True, default='#c0002b')
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -7,6 +7,30 @@ h1 { margin: 12px 0 6px; color: #000078; }
.app-messages { margin-bottom: 12px; }
.card { border: 1px solid #d8e3f0; border-radius: 12px; background: #fbfdff; padding: 12px; margin-bottom: 14px; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 10px; }
.branding-sections { display: grid; gap: 14px; }
.branding-block { border: 1px solid #dce5f1; border-radius: 16px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.94)); padding: 14px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); }
.branding-block-head { margin-bottom: 12px; }
.branding-block-head h2 { margin: 0; color: #17345e; font-size: 18px; }
.branding-block-head p { margin: 4px 0 0; color: #60738d; font-size: 13px; }
.lang-pairs { align-items: start; }
.lang-block { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.82); padding: 12px; }
.lang-block h3 { margin: 0 0 10px; color: #223b63; font-size: 15px; }
.branding-preview { max-width: 460px; margin-left: auto; border: 1px solid #dce5f1; border-radius: 18px; background:
radial-gradient(circle at top right, rgba(59,112,234,0.10), transparent 30%),
linear-gradient(180deg, #f9fbff, #eef4ff);
padding: 10px; }
.branding-preview-shell { border: 1px solid rgba(210, 221, 236, 0.95); border-radius: 18px; overflow: hidden; background: linear-gradient(180deg, rgba(255,255,255,0.99), rgba(247,250,255,0.96)); box-shadow: 0 8px 22px rgba(16, 32, 57, 0.05); }
.branding-preview-header { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-bottom: 1px solid rgba(217, 227, 238, 0.9); }
.branding-preview-logo { width: 64px; max-width: 100%; height: auto; display: block; object-fit: contain; filter: saturate(1.02); }
.branding-preview-copy { display: grid; gap: 2px; min-width: 0; }
.branding-preview-copy strong { color: #18335b; font-size: 13px; line-height: 1.2; }
.branding-preview-copy span { color: #61738d; font-size: 12px; line-height: 1.3; }
.branding-preview-band { display: flex; gap: 8px; padding: 10px 12px; }
.branding-preview-chip { display: inline-flex; align-items: center; justify-content: center; min-width: 104px; padding: 5px 10px; border-radius: 999px; color: #fff; font-size: 10px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: #000078; box-shadow: inset 0 1px 0 rgba(255,255,255,0.16); }
.branding-preview-chip-secondary { background: #c0002b; }
.branding-preview-footer { padding: 0 12px 12px; }
.branding-preview-footer-main { color: #20385f; font-size: 11px; font-weight: 700; line-height: 1.35; }
.branding-preview-footer-legal { margin-top: 4px; color: #6c7f99; font-size: 10px; line-height: 1.4; }
.backup-grid { grid-template-columns: minmax(280px, 720px); }
label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; }
input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); }
@@ -56,8 +80,13 @@ th { background: #f6f9ff; color: #334155; }
.bulk-note { color: #64748b; font-size: 12px; }
.field label { display: block; font-weight: 600; margin-bottom: 6px; }
.field input, .field select { min-height: 40px; }
.field-full { grid-column: 1 / -1; }
.mini { color: #64748b; font-size: 12px; }
.table-controls input[type="text"], .table-controls select { width: 100%; min-height: 36px; padding: 7px 9px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; }
.table-controls input[type="checkbox"] { transform: scale(1.1); width: auto; }
.actions { white-space: nowrap; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
@media (max-width: 760px) {
.grid { grid-template-columns: 1fr; }
.branding-preview-header { flex-direction: column; align-items: flex-start; }
.branding-preview-band { flex-wrap: wrap; }
}

View File

@@ -122,6 +122,25 @@
margin-right: auto !important;
}
.app-site-footer {
width: min(var(--app-shell-width), 100%);
margin: 14px auto 0;
padding: 0 10px 18px;
color: #5f728d;
text-align: center;
}
.app-site-footer-main {
font-size: 13px;
font-weight: 700;
}
.app-site-footer-legal {
margin-top: 4px;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 900px) {
.app-header,
.app-header-in-shell {

View File

@@ -15,7 +15,7 @@
<section class="login-shell-body">
<div class="login-card">
<h1>{% trans "Anmeldung" %}</h1>
<p>{% trans "Bitte melden Sie sich mit Ihrem Benutzerkonto an." %}</p>
<p>{{ portal_login_subtitle }}</p>
<form method="post" action="/accounts/login/">
{% csrf_token %}

View File

@@ -6,6 +6,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{% endblock %}</title>
<link rel="icon" href="{{ portal_favicon_url }}" />
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
<link rel="stylesheet" href="{% static 'workflows/css/app_chrome.css' %}" />
{% block extra_css %}{% endblock %}
@@ -17,6 +18,12 @@
{% block shell_header %}{% endblock %}
{% block shell_body %}{% endblock %}
</div>
{% if portal_footer_text or portal_legal_notice %}
<div class="app-site-footer">
{% if portal_footer_text %}<div class="app-site-footer-main">{{ portal_footer_text }}</div>{% endif %}
{% if portal_legal_notice %}<div class="app-site-footer-legal">{{ portal_legal_notice }}</div>{% endif %}
</div>
{% endif %}
<div class="confirm-modal" id="app-confirm-modal" hidden aria-hidden="true">
<div class="confirm-backdrop" data-confirm-close="1"></div>
<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="app-confirm-title" aria-describedby="app-confirm-message">

View File

@@ -17,54 +17,150 @@
<section class="card">
<form method="post" action="{% url 'save_portal_branding' %}" enctype="multipart/form-data" class="stack-form">
{% csrf_token %}
<div class="grid two">
<div class="field">
<label for="{{ form.portal_title.id_for_label }}">{{ form.portal_title.label }}</label>
{{ form.portal_title }}
</div>
<div class="field">
<label for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
{{ form.company_name }}
</div>
<div class="field">
<label for="{{ form.company_domain.id_for_label }}">{{ form.company_domain.label }}</label>
{{ form.company_domain }}
<div class="hint">{% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}</div>
</div>
<div class="field">
<label for="{{ form.support_email.id_for_label }}">{{ form.support_email.label }}</label>
{{ form.support_email }}
</div>
<div class="field">
<label for="{{ form.default_language.id_for_label }}">{{ form.default_language.label }}</label>
{{ form.default_language }}
</div>
<div class="field">
<label for="{{ form.primary_color.id_for_label }}">{{ form.primary_color.label }}</label>
{{ form.primary_color }}
</div>
<div class="field">
<label for="{{ form.secondary_color.id_for_label }}">{{ form.secondary_color.label }}</label>
{{ form.secondary_color }}
</div>
<div class="field">
<label for="{{ form.logo_image.id_for_label }}">{{ form.logo_image.label }}</label>
{{ form.logo_image }}
<div class="hint">{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}</div>
{% for error in form.logo_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.logo_image %}
<div class="hint">{% trans "Aktuelles Logo:" %} <a href="{{ branding.logo_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
<div class="field">
<label for="{{ form.pdf_letterhead.id_for_label }}">{{ form.pdf_letterhead.label }}</label>
{{ form.pdf_letterhead }}
<div class="hint">{% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}</div>
{% for error in form.pdf_letterhead.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.pdf_letterhead %}
<div class="hint">{% trans "Aktueller Briefkopf:" %} <a href="{{ branding.pdf_letterhead.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
<div class="branding-sections">
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Identität" %}</h2>
<p>{% trans "Titel, Firmenname und zentrale Spracheinstellungen." %}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.portal_title.id_for_label }}">{{ form.portal_title.label }}</label>
{{ form.portal_title }}
</div>
<div class="field">
<label for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
{{ form.company_name }}
</div>
<div class="field">
<label for="{{ form.company_domain.id_for_label }}">{{ form.company_domain.label }}</label>
{{ form.company_domain }}
<div class="hint">{% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}</div>
</div>
<div class="field">
<label for="{{ form.default_language.id_for_label }}">{{ form.default_language.label }}</label>
{{ form.default_language }}
</div>
<div class="field field-full">
<label for="{{ form.login_subtitle.id_for_label }}">{{ form.login_subtitle.label }}</label>
{{ form.login_subtitle }}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Farben & Erscheinungsbild" %}</h2>
<p>{% trans "Zentrale visuelle Markenwerte und Browser-Icon." %}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.primary_color.id_for_label }}">{{ form.primary_color.label }}</label>
{{ form.primary_color }}
</div>
<div class="field">
<label for="{{ form.secondary_color.id_for_label }}">{{ form.secondary_color.label }}</label>
{{ form.secondary_color }}
</div>
<div class="field">
<label for="{{ form.logo_image.id_for_label }}">{{ form.logo_image.label }}</label>
{{ form.logo_image }}
<div class="hint">{% trans "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB." %}</div>
{% for error in form.logo_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.logo_image %}
<div class="hint">{% trans "Aktuelles Logo:" %} <a href="{{ branding.logo_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
<div class="field">
<label for="{{ form.favicon_image.id_for_label }}">{{ form.favicon_image.label }}</label>
{{ form.favicon_image }}
<div class="hint">{% trans "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB." %}</div>
{% for error in form.favicon_image.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.favicon_image %}
<div class="hint">{% trans "Aktuelles Favicon:" %} <a href="{{ branding.favicon_image.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
<div class="field field-full">
<div class="branding-preview" id="branding-preview" data-default-logo="{{ portal_logo_url }}">
<div class="branding-preview-shell">
<div class="branding-preview-header">
<img class="branding-preview-logo" id="branding-preview-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" />
<div class="branding-preview-copy">
<strong id="branding-preview-company">{{ branding.company_name }}</strong>
<span id="branding-preview-title">{{ branding.portal_title }}</span>
</div>
</div>
<div class="branding-preview-band">
<span class="branding-preview-chip" id="branding-preview-primary">{% trans "Primärfarbe" %}</span>
<span class="branding-preview-chip branding-preview-chip-secondary" id="branding-preview-secondary">{% trans "Sekundärfarbe" %}</span>
</div>
<div class="branding-preview-footer">
<div class="branding-preview-footer-main" id="branding-preview-footer">{{ branding.footer_text|default:branding.portal_title }}</div>
<div class="branding-preview-footer-legal" id="branding-preview-legal">{{ branding.legal_notice }}</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Kommunikation" %}</h2>
<p>{% trans "Absender, Support und PDF-Branding für ausgehende Kommunikation." %}</p>
</div>
<div class="grid two">
<div class="field">
<label for="{{ form.support_email.id_for_label }}">{{ form.support_email.label }}</label>
{{ form.support_email }}
</div>
<div class="field">
<label for="{{ form.sender_display_name.id_for_label }}">{{ form.sender_display_name.label }}</label>
{{ form.sender_display_name }}
<div class="hint">{% trans "Wird für ausgehende System-E-Mails als Anzeigename verwendet." %}</div>
</div>
<div class="field field-full">
<label for="{{ form.pdf_letterhead.id_for_label }}">{{ form.pdf_letterhead.label }}</label>
{{ form.pdf_letterhead }}
<div class="hint">{% trans "Erlaubtes Format: PDF. Maximal 10 MB." %}</div>
{% for error in form.pdf_letterhead.errors %}<div class="hint">{{ error }}</div>{% endfor %}
{% if branding.pdf_letterhead %}
<div class="hint">{% trans "Aktueller Briefkopf:" %} <a href="{{ branding.pdf_letterhead.url }}" target="_blank" rel="noopener">{% trans "öffnen" %}</a></div>
{% endif %}
</div>
</div>
</section>
<section class="branding-block">
<div class="branding-block-head">
<h2>{% trans "Footer & Rechtliches" %}</h2>
<p>{% trans "Gemeinsame Footer-Texte und rechtliche Hinweise für die Shell." %}</p>
</div>
<div class="grid two lang-pairs">
<div class="lang-block">
<h3>{% trans "Deutsch" %}</h3>
<div class="field">
<label for="{{ form.footer_text.id_for_label }}">{{ form.footer_text.label }}</label>
{{ form.footer_text }}
</div>
<div class="field">
<label for="{{ form.legal_notice.id_for_label }}">{{ form.legal_notice.label }}</label>
{{ form.legal_notice }}
</div>
</div>
<div class="lang-block">
<h3>{% trans "English" %}</h3>
<div class="field">
<label for="{{ form.footer_text_en.id_for_label }}">{{ form.footer_text_en.label }}</label>
{{ form.footer_text_en }}
</div>
<div class="field">
<label for="{{ form.legal_notice_en.id_for_label }}">{{ form.legal_notice_en.label }}</label>
{{ form.legal_notice_en }}
</div>
</div>
</div>
</section>
</div>
<div class="toolbar" style="margin-top:1.25rem;">
<div class="hint">{% trans "TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien hochgeladen werden." %}</div>
@@ -73,3 +169,59 @@
</form>
</section>
{% endblock %}
{% block extra_scripts %}
<script>
(() => {
const byId = (id) => document.getElementById(id);
const title = byId('{{ form.portal_title.id_for_label }}');
const company = byId('{{ form.company_name.id_for_label }}');
const footer = byId('{{ form.footer_text.id_for_label }}');
const legal = byId('{{ form.legal_notice.id_for_label }}');
const primary = byId('{{ form.primary_color.id_for_label }}');
const secondary = byId('{{ form.secondary_color.id_for_label }}');
const logo = byId('{{ form.logo_image.id_for_label }}');
const previewLogo = byId('branding-preview-logo');
const previewTitle = byId('branding-preview-title');
const previewCompany = byId('branding-preview-company');
const previewFooter = byId('branding-preview-footer');
const previewLegal = byId('branding-preview-legal');
const previewPrimary = byId('branding-preview-primary');
const previewSecondary = byId('branding-preview-secondary');
const preview = byId('branding-preview');
if (!preview) return;
const defaultLogo = preview.dataset.defaultLogo || '';
function syncPreview() {
if (previewTitle && title) previewTitle.textContent = title.value || '{{ branding.portal_title|escapejs }}';
if (previewCompany && company) previewCompany.textContent = company.value || '{{ branding.company_name|escapejs }}';
if (previewFooter && footer) previewFooter.textContent = footer.value || '{{ branding.footer_text|default:branding.portal_title|escapejs }}';
if (previewLegal && legal) previewLegal.textContent = legal.value || '{{ branding.legal_notice|escapejs }}';
if (previewPrimary && primary) previewPrimary.style.background = primary.value || '#000078';
if (previewSecondary && secondary) previewSecondary.style.background = secondary.value || '#c0002b';
}
[title, company, footer, legal, primary, secondary].forEach((input) => {
if (input) input.addEventListener('input', syncPreview);
});
if (logo && previewLogo) {
logo.addEventListener('change', () => {
const file = logo.files && logo.files[0];
if (!file) {
previewLogo.src = defaultLogo;
return;
}
const reader = new FileReader();
reader.onload = (event) => {
previewLogo.src = event.target.result;
};
reader.readAsDataURL(file);
});
}
syncPreview();
})();
</script>
{% endblock %}

View File

@@ -176,8 +176,10 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<ul>
<li>Portal-level branding is stored in the singleton model <code>PortalBranding</code>.</li>
<li>Configured from Admin Apps → <code>Branding</code>.</li>
<li>Current scope: portal title, company name, support email, default language, logo, PDF letterhead, and primary/secondary colors.</li>
<li>Current scope: portal title, company name, company domain, support email, sender display name, login subtitle, footer/legal text, logo, favicon, PDF letterhead, and primary/secondary colors.</li>
<li>Shared header/logo rendering now uses the branding context processor instead of hardcoded TUBCO asset references.</li>
<li>The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require <code>@tub.co</code> code changes.</li>
<li>Outgoing system mail sender names are now branded through the same layer.</li>
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
</ul>

View File

@@ -178,7 +178,7 @@
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li>
<li><strong>Branding:</strong> portal title, company name, logo, support email, default language, PDF letterhead, and basic brand colors.</li>
<li><strong>Branding:</strong> portal title, company name, company domain, support email, sender display name, logo, favicon, default language, PDF letterhead, footer/legal text, and basic brand colors.</li>
<li><strong>App Registry:</strong> platform-level registry for enabling, ordering, and relabeling landing-page apps without editing the home template.</li>
<li><strong>Benutzer &amp; Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, and deleting accounts when appropriate.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>