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

@@ -2,14 +2,14 @@ msgid ""
msgstr ""
"Project-Id-Version: tubco-portal\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-26 10:55+0000\n"
"POT-Creation-Date: 2026-03-26 11:02+0000\n"
"PO-Revision-Date: 2026-03-24 00:00+0000\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: workflows/app_registry.py:32 workflows/models.py:261 workflows/models.py:342
#: workflows/app_registry.py:32 workflows/models.py:273 workflows/models.py:354
#: workflows/templates/workflows/onboarding_form.html:25
#: workflows/templates/workflows/requests_dashboard.html:68
#: workflows/templates/workflows/requests_dashboard.html:131
@@ -36,7 +36,7 @@ msgstr "Multi-step form"
msgid "E-Mail Routing"
msgstr "Email routing"
#: workflows/app_registry.py:43 workflows/models.py:262 workflows/models.py:343
#: workflows/app_registry.py:43 workflows/models.py:274 workflows/models.py:355
#: workflows/templates/workflows/requests_dashboard.html:78
#: workflows/templates/workflows/requests_dashboard.html:132
msgid "Offboarding"
@@ -213,7 +213,7 @@ msgstr "Django Admin"
msgid "Vollständige Datenverwaltung."
msgstr "Full data management."
#: workflows/app_registry.py:165 workflows/models.py:68
#: workflows/app_registry.py:165 workflows/models.py:80
msgid "Apps"
msgstr "Apps"
@@ -221,7 +221,7 @@ msgstr "Apps"
msgid "Wählen Sie den gewünschten Prozess."
msgstr "Choose the desired process."
#: workflows/app_registry.py:171 workflows/models.py:69
#: workflows/app_registry.py:171 workflows/models.py:81
msgid "Platform Apps"
msgstr ""
@@ -231,7 +231,7 @@ msgstr ""
msgid "Produktweite Konfiguration und Produktsteuerung."
msgstr "Configuration, tests, and controls."
#: workflows/app_registry.py:177 workflows/models.py:70
#: workflows/app_registry.py:177 workflows/models.py:82
msgid "Admin Apps"
msgstr "Admin Apps"
@@ -338,307 +338,339 @@ msgstr "Invalid role."
msgid "Nur Platform Owner dürfen diese Rolle vergeben."
msgstr ""
#: workflows/forms.py:188
#: workflows/forms.py:195
msgid "Portal-Titel"
msgstr "Portal title"
#: workflows/forms.py:189
#: workflows/forms.py:196
msgid "Firmenname"
msgstr "Company name"
#: workflows/forms.py:190
#: workflows/forms.py:197
#, fuzzy
#| msgid "Firmenname"
msgid "Firmen-Domain"
msgstr "Company name"
#: workflows/forms.py:191
#: workflows/forms.py:198
msgid "Support-E-Mail"
msgstr "Support email"
#: workflows/forms.py:192
#: workflows/forms.py:199
msgid "Absender-Anzeigename"
msgstr ""
#: workflows/forms.py:200
msgid "Login-Untertitel"
msgstr ""
#: workflows/forms.py:201
msgid "Footer-Text DE"
msgstr ""
#: workflows/forms.py:202
msgid "Footer-Text EN"
msgstr ""
#: workflows/forms.py:203
msgid "Rechtlicher Hinweis DE"
msgstr ""
#: workflows/forms.py:204
msgid "Rechtlicher Hinweis EN"
msgstr ""
#: workflows/forms.py:205
msgid "Standardsprache"
msgstr "Default language"
#: workflows/forms.py:193
#: workflows/forms.py:206
msgid "Logo"
msgstr "Logo"
#: workflows/forms.py:194
#: workflows/forms.py:207
msgid "PDF-Briefkopf"
msgstr "PDF letterhead"
#: workflows/forms.py:195
#: workflows/forms.py:208
msgid "Favicon"
msgstr ""
#: workflows/forms.py:209
msgid "Primärfarbe"
msgstr "Primary color"
#: workflows/forms.py:196
#: workflows/forms.py:210
msgid "Sekundärfarbe"
msgstr "Secondary color"
#: workflows/forms.py:210
#: workflows/forms.py:227
msgid "Das Logo darf maximal 5 MB groß sein."
msgstr ""
#: workflows/forms.py:218
#: workflows/forms.py:235
msgid "Der PDF-Briefkopf darf maximal 10 MB groß sein."
msgstr ""
#: workflows/forms.py:357 workflows/forms.py:542
#: workflows/forms.py:243
msgid "Das Favicon darf maximal 2 MB groß sein."
msgstr ""
#: workflows/forms.py:382 workflows/forms.py:567
#, python-format
msgid "Bitte nutzen Sie das Format name@%(domain)s."
msgstr ""
#: workflows/forms.py:379 workflows/forms.py:556
#: workflows/forms.py:404 workflows/forms.py:581
#, python-format
msgid "Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse."
msgstr ""
#: workflows/forms.py:464
#: workflows/forms.py:489
#, python-format
msgid ""
"Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen "
"(frühestens %(date)s)."
msgstr ""
#: workflows/models.py:139 workflows/views.py:200
#: workflows/models.py:151 workflows/views.py:200
msgid "Eingereicht"
msgstr "Submitted"
#: workflows/models.py:140 workflows/views.py:201
#: workflows/models.py:152 workflows/views.py:201
msgid "In Bearbeitung"
msgstr "Processing"
#: workflows/models.py:141 workflows/models.py:456 workflows/views.py:202
#: workflows/models.py:153 workflows/models.py:468 workflows/views.py:202
msgid "Abgeschlossen"
msgstr "Completed"
#: workflows/models.py:142 workflows/models.py:396
#: workflows/models.py:154 workflows/models.py:408
#: workflows/templates/workflows/backup_recovery.html:70
#: workflows/templates/workflows/requests_dashboard.html:222
#: workflows/templates/workflows/welcome_emails.html:108 workflows/views.py:203
msgid "Fehlgeschlagen"
msgstr "Failed"
#: workflows/models.py:149
#: workflows/models.py:161
msgid "Herr"
msgstr ""
#: workflows/models.py:149
#: workflows/models.py:161
msgid "Frau"
msgstr ""
#: workflows/models.py:149
#: workflows/models.py:161
msgid "Divers"
msgstr ""
#: workflows/models.py:159
#: workflows/models.py:171
msgid "befristet"
msgstr ""
#: workflows/models.py:159
#: workflows/models.py:171
msgid "unbefristet"
msgstr ""
#: workflows/models.py:222
#: workflows/models.py:234
#: workflows/templates/workflows/onboarding_intro_session.html:28
#: workflows/templates/workflows/requests_dashboard.html:145
msgid "Abteilung"
msgstr "Department"
#: workflows/models.py:223
#: workflows/models.py:235
msgid "Geräte"
msgstr ""
#: workflows/models.py:224
#: workflows/models.py:236
msgid "Software"
msgstr ""
#: workflows/models.py:225
#: workflows/models.py:237
#, fuzzy
#| msgid "Vorgänge"
msgid "Zugänge"
msgstr "Requests"
#: workflows/models.py:226
#: workflows/models.py:238
msgid "Workspace-Gruppen"
msgstr ""
#: workflows/models.py:227
#: workflows/models.py:239
msgid "Ressourcen"
msgstr ""
#: workflows/models.py:228
#: workflows/models.py:240
msgid "Telefonnummern"
msgstr ""
#: workflows/models.py:254
#: workflows/models.py:266
msgid "Automatisch"
msgstr ""
#: workflows/models.py:255 workflows/views.py:95
#: workflows/models.py:267 workflows/views.py:95
msgid "Stammdaten"
msgstr "Master data"
#: workflows/models.py:256 workflows/views.py:96
#: workflows/models.py:268 workflows/views.py:96
msgid "Vertrag"
msgstr "Contract"
#: workflows/models.py:257 workflows/views.py:97
#: workflows/models.py:269 workflows/views.py:97
msgid "IT-Setup"
msgstr "IT setup"
#: workflows/models.py:258 workflows/views.py:98
#: workflows/models.py:270 workflows/views.py:98
msgid "Abschluss"
msgstr "Finish"
#: workflows/models.py:300
#: workflows/models.py:312
#, fuzzy
#| msgid "Onboarding"
msgid "Onboarding: IT"
msgstr "Onboarding"
#: workflows/models.py:301
#: workflows/models.py:313
#, fuzzy
#| msgid "Offboarding-Anfrage speichern"
msgid "Onboarding: Allgemeine Info"
msgstr "Save offboarding request"
#: workflows/models.py:302
#: workflows/models.py:314
#, fuzzy
#| msgid "Onboarding starten"
msgid "Onboarding: Visitenkarte"
msgstr "Start onboarding"
#: workflows/models.py:303
#: workflows/models.py:315
#, fuzzy
#| msgid "Onboarding"
msgid "Onboarding: HR Works"
msgstr "Onboarding"
#: workflows/models.py:304
#: workflows/models.py:316
#, fuzzy
#| msgid "Onboarding starten"
msgid "Onboarding: Schlüssel"
msgstr "Start onboarding"
#: workflows/models.py:305
#: workflows/models.py:317
msgid "Onboarding: Referenz Anfordernde Person"
msgstr ""
#: workflows/models.py:306
#: workflows/models.py:318
#, fuzzy
#| msgid "Welcome E-Mails"
msgid "Onboarding: Welcome E-Mail"
msgstr "Welcome Emails"
#: workflows/models.py:307
#: workflows/models.py:319
#, fuzzy
#| msgid "Offboarding"
msgid "Offboarding: IT"
msgstr "Offboarding"
#: workflows/models.py:308
#: workflows/models.py:320
#, fuzzy
#| msgid "Offboarding-Anfrage speichern"
msgid "Offboarding: Allgemeine Info"
msgstr "Save offboarding request"
#: workflows/models.py:309
#: workflows/models.py:321
#, fuzzy
#| msgid "Offboarding starten"
msgid "Offboarding: HR Works Deaktivierung"
msgstr "Start offboarding"
#: workflows/models.py:310
#: workflows/models.py:322
msgid "Offboarding: Referenz Anfordernde Person"
msgstr ""
#: workflows/models.py:346
#: workflows/models.py:358
msgid "Immer"
msgstr ""
#: workflows/models.py:347 workflows/models.py:425
#: workflows/models.py:359 workflows/models.py:437
msgid "Enthält"
msgstr ""
#: workflows/models.py:348 workflows/models.py:426
#: workflows/models.py:360 workflows/models.py:438
msgid "Ist gleich"
msgstr ""
#: workflows/models.py:349
#: workflows/models.py:361
msgid "Ist aktiv/Ja"
msgstr ""
#: workflows/models.py:350
#: workflows/models.py:362
#, fuzzy
#| msgid "inaktiv"
msgid "Ist inaktiv/Nein"
msgstr "inactive"
#: workflows/models.py:392
#: workflows/models.py:404
#: workflows/templates/workflows/welcome_emails.html:100
msgid "Geplant"
msgstr "Scheduled"
#: workflows/models.py:393
#: workflows/models.py:405
#: workflows/templates/workflows/welcome_emails.html:102
msgid "Pausiert"
msgstr "Paused"
#: workflows/models.py:394
#: workflows/models.py:406
#: workflows/templates/workflows/welcome_emails.html:104
msgid "Abgebrochen"
msgstr "Cancelled"
#: workflows/models.py:395
#: workflows/models.py:407
#: workflows/templates/workflows/welcome_emails.html:106
msgid "Gesendet"
msgstr "Sent"
#: workflows/models.py:418 workflows/tasks.py:576
#: workflows/models.py:430 workflows/tasks.py:576
msgid "Geräte und Arbeitsplatz"
msgstr "Devices and workplace"
#: workflows/models.py:419 workflows/tasks.py:577
#: workflows/models.py:431 workflows/tasks.py:577
msgid "Konten und Berechtigungen"
msgstr "Accounts and permissions"
#: workflows/models.py:420 workflows/tasks.py:578
#: workflows/models.py:432 workflows/tasks.py:578
msgid "Software und Tools"
msgstr "Software and tools"
#: workflows/models.py:421 workflows/tasks.py:579
#: workflows/models.py:433 workflows/tasks.py:579
msgid "Prozesse und Hinweise"
msgstr "Processes and notes"
#: workflows/models.py:424
#: workflows/models.py:436
msgid "Immer anzeigen"
msgstr "Always show"
#: workflows/models.py:427
#: workflows/models.py:439
msgid "Ist Ja / aktiv"
msgstr "Is yes / active"
#: workflows/models.py:428
#: workflows/models.py:440
msgid "Ist Nein / inaktiv"
msgstr "Is no / inactive"
#: workflows/models.py:455
#: workflows/models.py:467
msgid "Entwurf"
msgstr "Draft"
#: workflows/models.py:475
#: workflows/models.py:487
#, fuzzy
#| msgid "Nextcloud:"
msgid "Nextcloud"
msgstr "Nextcloud:"
#: workflows/models.py:476
#: workflows/models.py:488
msgid "S3"
msgstr ""
#: workflows/models.py:477
#: workflows/models.py:489
msgid "NFS"
msgstr ""
@@ -754,7 +786,6 @@ msgid "Anmeldung"
msgstr "Sign in"
#: workflows/templates/registration/login.html:20
#: workflows/templates/workflows/auth/login.html:18
msgid "Bitte melden Sie sich mit Ihrem Benutzerkonto an."
msgstr "Please sign in with your user account."
@@ -1173,28 +1204,28 @@ msgstr "Delete"
msgid "Noch keine Backup-Bundles vorhanden."
msgstr "No backup bundles available yet."
#: workflows/templates/workflows/base_shell.html:24
#: workflows/templates/workflows/base_shell.html:31
msgid "Bitte bestätigen"
msgstr ""
#: workflows/templates/workflows/base_shell.html:28
#: workflows/templates/workflows/base_shell.html:35
#: workflows/templates/workflows/welcome_emails.html:134
msgid "Abbrechen"
msgstr "Cancel"
#: workflows/templates/workflows/base_shell.html:29
#: workflows/templates/workflows/base_shell.html:36
msgid "Bestätigen"
msgstr ""
#: workflows/templates/workflows/base_shell.html:36
#: workflows/templates/workflows/base_shell.html:43
msgid "Bitte warten"
msgstr "Please wait"
#: workflows/templates/workflows/base_shell.html:37
#: workflows/templates/workflows/base_shell.html:44
msgid "Aktion läuft"
msgstr "Action in progress"
#: workflows/templates/workflows/base_shell.html:38
#: workflows/templates/workflows/base_shell.html:45
msgid "Die Aktion wird im aktuellen Tab ausgeführt."
msgstr "The action is running in the current tab."
@@ -1209,28 +1240,43 @@ msgid ""
"B. tub.co."
msgstr ""
#: workflows/templates/workflows/branding_settings.html:53
#: workflows/templates/workflows/branding_settings.html:41
msgid "Wird für ausgehende System-E-Mails als Anzeigename verwendet."
msgstr ""
#: workflows/templates/workflows/branding_settings.html:78
msgid "Erlaubte Formate: SVG, PNG, JPG, JPEG, WEBP. Maximal 5 MB."
msgstr ""
#: workflows/templates/workflows/branding_settings.html:56
#: workflows/templates/workflows/branding_settings.html:81
msgid "Aktuelles Logo:"
msgstr "Current logo:"
#: workflows/templates/workflows/branding_settings.html:56
#: workflows/templates/workflows/branding_settings.html:65
#: workflows/templates/workflows/branding_settings.html:81
#: workflows/templates/workflows/branding_settings.html:90
#: workflows/templates/workflows/branding_settings.html:99
msgid "öffnen"
msgstr "open"
#: workflows/templates/workflows/branding_settings.html:62
#: workflows/templates/workflows/branding_settings.html:87
msgid "Erlaubtes Format: PDF. Maximal 10 MB."
msgstr ""
#: workflows/templates/workflows/branding_settings.html:65
#: workflows/templates/workflows/branding_settings.html:90
msgid "Aktueller Briefkopf:"
msgstr "Current letterhead:"
#: workflows/templates/workflows/branding_settings.html:70
#: workflows/templates/workflows/branding_settings.html:96
msgid "Erlaubte Formate: ICO, PNG, SVG, WEBP. Maximal 2 MB."
msgstr ""
#: workflows/templates/workflows/branding_settings.html:99
#, fuzzy
#| msgid "Aktuelles Logo:"
msgid "Aktuelles Favicon:"
msgstr "Current logo:"
#: workflows/templates/workflows/branding_settings.html:104
msgid ""
"TUBCO bleibt als Standard erhalten, bis hier Werte geändert oder Dateien "
"hochgeladen werden."
@@ -1238,7 +1284,7 @@ msgstr ""
"TUBCO remains the default until values are changed or files are uploaded "
"here."
#: workflows/templates/workflows/branding_settings.html:71
#: workflows/templates/workflows/branding_settings.html:105
msgid "Branding speichern"
msgstr "Save branding"

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>