snapshot: preserve branding foundation and platform owner split

This commit is contained in:
Md Bayazid Bostame
2026-03-26 11:43:54 +01:00
parent 8926d6860c
commit 51700cfa8b
22 changed files with 966 additions and 242 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ from django.conf import settings
from django import forms
from .emailing import send_system_email
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
@admin.register(EmployeeProfile)
@@ -20,6 +20,11 @@ class AdminAuditLogAdmin(admin.ModelAdmin):
ordering = ('-created_at', '-id')
@admin.register(PortalBranding)
class PortalBrandingAdmin(admin.ModelAdmin):
list_display = ('name', 'portal_title', 'company_name', 'support_email', 'default_language', 'updated_at')
@admin.register(OnboardingRequest)
class OnboardingRequestAdmin(admin.ModelAdmin):
list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at')

View File

@@ -0,0 +1,106 @@
from __future__ import annotations
from pathlib import Path
from django.conf import settings
from django.templatetags.static import static
from .models import PortalBranding
def get_portal_branding() -> PortalBranding:
branding, _ = PortalBranding.objects.get_or_create(
name='Default',
defaults={
'portal_title': 'TUBCO Onboarding & Offboarding Portal',
'company_name': 'TUBCO',
'support_email': 'info@tub.co',
'default_language': 'de',
'primary_color': '#000078',
'secondary_color': '#c0002b',
},
)
return branding
def get_portal_logo_url() -> str:
branding = get_portal_branding()
if branding.logo_image:
try:
return branding.logo_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:
try:
candidate = Path(branding.pdf_letterhead.path)
if candidate.exists():
return candidate
except (ValueError, NotImplementedError):
pass
return settings.PDF_TEMPLATES_DIR / 'templates.pdf'
def get_branding_context() -> dict[str, object]:
branding = get_portal_branding()
return {
'portal_branding': branding,
'portal_title': branding.portal_title,
'portal_company_name': branding.company_name,
'portal_support_email': branding.support_email,
'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_has_custom_logo': bool(branding.logo_image),
'portal_has_custom_letterhead': bool(branding.pdf_letterhead),
}
def get_branding_email_copy() -> dict[str, str]:
branding = get_portal_branding()
company_name = (branding.company_name or 'TUBCO').strip()
portal_title = (branding.portal_title or f'{company_name} Portal').strip()
return {
'company_name': company_name,
'portal_title': portal_title,
'support_email': (branding.support_email or '').strip(),
}
def get_default_notification_templates() -> dict[str, dict[str, str]]:
from copy import deepcopy
from .tasks import DEFAULT_NOTIFICATION_TEMPLATES
templates = deepcopy(DEFAULT_NOTIFICATION_TEMPLATES)
company_name = get_branding_email_copy()['company_name']
welcome = templates.get('onboarding_welcome')
if welcome:
welcome['subject'] = f'Willkommen bei {company_name}, {{ VORNAME }}'
welcome['subject_en'] = f'Welcome to {company_name}, {{ VORNAME }}'
welcome['body'] = (
'Hallo {{ FULL_NAME }},\n\n'
f'herzlich willkommen bei {company_name}.\n'
'Wir freuen uns sehr, dass du ab dem {{ CONTRACT_START }} unser Team in der Abteilung {{ DEPARTMENT }} verstärkst.\n\n'
'Deine dienstliche E-Mail-Adresse lautet: {{ EMAIL }}.\n'
'Im Anhang findest du deine Onboarding-Unterlagen als PDF.\n\n'
'Wenn du Fragen hast, melde dich gerne jederzeit.\n\n'
'Viele Grüße\n'
f'{company_name} IT'
)
welcome['body_en'] = (
'Hello {{ FULL_NAME }},\n\n'
f'welcome to {company_name}.\n'
'We are very happy that you will join our {{ DEPARTMENT }} team starting on {{ CONTRACT_START }}.\n\n'
'Your work email address is: {{ EMAIL }}.\n'
'You will find your onboarding documents attached as a PDF.\n\n'
'If you have any questions, feel free to contact us anytime.\n\n'
'Best regards,\n'
f'{company_name} IT'
)
return templates

View File

@@ -1,5 +1,8 @@
from .branding import get_branding_context
from .roles import template_role_context
def role_context(request):
return template_role_context(getattr(request, 'user', None))
context = template_role_context(getattr(request, 'user', None))
context.update(get_branding_context())
return context

View File

@@ -7,8 +7,8 @@ from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy
from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -129,12 +129,13 @@ class UserManagementCreateForm(forms.Form):
email = forms.EmailField(label=_('E-Mail-Adresse'))
role_key = forms.ChoiceField(label=_('Rolle'))
def __init__(self, *args, **kwargs):
def __init__(self, *args, include_product_owner: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.fields['role_key'].choices = [
(role_key, str(ROLE_LABELS[role_key]))
for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF)
]
self.include_product_owner = include_product_owner
role_order = [ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF]
if include_product_owner:
role_order = [ROLE_PLATFORM_OWNER] + role_order
self.fields['role_key'].choices = [(role_key, str(ROLE_LABELS[role_key])) for role_key in role_order]
def clean_username(self):
username = (self.cleaned_data.get('username') or '').strip()
@@ -150,6 +151,8 @@ class UserManagementCreateForm(forms.Form):
role_key = (self.cleaned_data.get('role_key') or '').strip()
if role_key not in ROLE_GROUP_NAMES:
raise forms.ValidationError(_('Ungültige Rolle.'))
if role_key == ROLE_PLATFORM_OWNER and not self.include_product_owner:
raise forms.ValidationError(_('Nur Platform Owner dürfen diese Rolle vergeben.'))
return role_key
def save(self):
@@ -166,6 +169,53 @@ class UserManagementCreateForm(forms.Form):
return user
class PortalBrandingForm(forms.ModelForm):
class Meta:
model = PortalBranding
fields = [
'portal_title',
'company_name',
'support_email',
'default_language',
'logo_image',
'pdf_letterhead',
'primary_color',
'secondary_color',
]
labels = {
'portal_title': gettext_lazy('Portal-Titel'),
'company_name': gettext_lazy('Firmenname'),
'support_email': gettext_lazy('Support-E-Mail'),
'default_language': gettext_lazy('Standardsprache'),
'logo_image': gettext_lazy('Logo'),
'pdf_letterhead': gettext_lazy('PDF-Briefkopf'),
'primary_color': gettext_lazy('Primärfarbe'),
'secondary_color': gettext_lazy('Sekundärfarbe'),
}
widgets = {
'primary_color': forms.TextInput(attrs={'type': 'color'}),
'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'}),
}
def clean_logo_image(self):
logo = self.cleaned_data.get('logo_image')
if not logo:
return logo
if getattr(logo, 'size', 0) > 5 * 1024 * 1024:
raise forms.ValidationError(_('Das Logo darf maximal 5 MB groß sein.'))
return logo
def clean_pdf_letterhead(self):
letterhead = self.cleaned_data.get('pdf_letterhead')
if not letterhead:
return letterhead
if getattr(letterhead, 'size', 0) > 10 * 1024 * 1024:
raise forms.ValidationError(_('Der PDF-Briefkopf darf maximal 10 MB groß sein.'))
return letterhead
class OnboardingRequestForm(forms.ModelForm):
first_name = forms.CharField(label='Vorname', required=False)
last_name = forms.CharField(label='Nachname', required=False)

View File

@@ -1,7 +1,7 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from workflows.roles import ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, ensure_role_groups
from workflows.roles import ROLE_PLATFORM_OWNER, ROLE_STAFF, assign_user_role, ensure_role_groups
DEFAULT_USERS = [
{
@@ -45,7 +45,7 @@ class Command(BaseCommand):
is_superuser=item['is_superuser'],
)
ensure_role_groups()
assign_user_role(user, ROLE_SUPER_ADMIN if item['username'] == 'admin_test' else ROLE_STAFF)
assign_user_role(user, ROLE_PLATFORM_OWNER if item['username'] == 'admin_test' else ROLE_STAFF)
self.stdout.write(f'created {user.username}')
self.stdout.write(self.style.SUCCESS('initial users created'))

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.5 on 2026-03-26 10:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0035_workflowconfig_remote_backup_enabled_and_more'),
]
operations = [
migrations.CreateModel(
name='PortalBranding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default', max_length=80, unique=True)),
('portal_title', models.CharField(default='TUBCO Onboarding & Offboarding Portal', max_length=255)),
('company_name', models.CharField(default='TUBCO', max_length=255)),
('support_email', models.EmailField(blank=True, default='info@tub.co', max_length=254)),
('default_language', models.CharField(choices=[('de', 'Deutsch'), ('en', 'English')], default='de', max_length=10)),
('logo_image', models.ImageField(blank=True, null=True, upload_to='branding/')),
('pdf_letterhead', models.FileField(blank=True, null=True, upload_to='branding/')),
('primary_color', models.CharField(blank=True, default='#000078', max_length=20)),
('secondary_color', models.CharField(blank=True, default='#c0002b', max_length=20)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Portal Branding',
'verbose_name_plural': 'Portal Branding',
},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.5 on 2026-03-26 10:25
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0036_portalbranding'),
]
operations = [
migrations.AlterField(
model_name='portalbranding',
name='logo_image',
field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['svg', 'png', 'jpg', 'jpeg', 'webp'])]),
),
migrations.AlterField(
model_name='portalbranding',
name='pdf_letterhead',
field=models.FileField(blank=True, null=True, upload_to='branding/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf'])]),
),
]

View File

@@ -1,4 +1,5 @@
from django.conf import settings
from django.core.validators import FileExtensionValidator
from django.db import models
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
@@ -24,6 +25,40 @@ class EmployeeProfile(models.Model):
return f"{self.full_name} <{self.work_email}>"
class PortalBranding(models.Model):
name = models.CharField(max_length=80, default='Default', unique=True)
portal_title = models.CharField(max_length=255, default='TUBCO Onboarding & Offboarding Portal')
company_name = models.CharField(max_length=255, default='TUBCO')
support_email = models.EmailField(blank=True, default='info@tub.co')
default_language = models.CharField(
max_length=10,
choices=[('de', 'Deutsch'), ('en', 'English')],
default='de',
)
logo_image = models.FileField(
upload_to='branding/',
blank=True,
null=True,
validators=[FileExtensionValidator(allowed_extensions=['svg', 'png', 'jpg', 'jpeg', 'webp'])],
)
pdf_letterhead = models.FileField(
upload_to='branding/',
blank=True,
null=True,
validators=[FileExtensionValidator(allowed_extensions=['pdf'])],
)
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)
class Meta:
verbose_name = 'Portal Branding'
verbose_name_plural = 'Portal Branding'
def __str__(self) -> str:
return self.portal_title or self.company_name or self.name
class AdminAuditLog(models.Model):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,

View File

@@ -4,12 +4,14 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
ROLE_PLATFORM_OWNER = 'platform_owner'
ROLE_SUPER_ADMIN = 'super_admin'
ROLE_ADMIN = 'admin'
ROLE_IT_STAFF = 'it_staff'
ROLE_STAFF = 'staff'
ROLE_GROUP_NAMES = {
ROLE_PLATFORM_OWNER: 'Platform Owner',
ROLE_SUPER_ADMIN: 'Super Admin',
ROLE_ADMIN: 'Admin',
ROLE_IT_STAFF: 'IT Staff',
@@ -17,6 +19,7 @@ ROLE_GROUP_NAMES = {
}
ROLE_LABELS = {
ROLE_PLATFORM_OWNER: _('Platform Owner'),
ROLE_SUPER_ADMIN: _('Super Admin'),
ROLE_ADMIN: _('Admin'),
ROLE_IT_STAFF: _('IT Staff'),
@@ -24,19 +27,20 @@ ROLE_LABELS = {
}
CAPABILITIES = {
'manage_users': {ROLE_SUPER_ADMIN},
'access_requests_dashboard': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'run_intro_session': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'generate_intro_pdfs': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'retry_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'delete_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_integrations': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_welcome_emails': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_builders': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_audit_log': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_backups': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_docs': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'access_django_admin_link': {ROLE_SUPER_ADMIN},
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_product_branding': {ROLE_PLATFORM_OWNER},
'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'run_intro_session': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'generate_intro_pdfs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'retry_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'delete_requests': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_integrations': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_welcome_emails': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_builders': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_audit_log': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_backups': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_docs': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN},
'access_django_admin_link': {ROLE_PLATFORM_OWNER},
}
@@ -54,16 +58,17 @@ def assign_user_role(user, role_key: str) -> None:
user.groups.remove(*role_groups)
user.groups.add(Group.objects.get(name=ROLE_GROUP_NAMES[role_key]))
is_product_owner = role_key == ROLE_PLATFORM_OWNER
is_super_admin = role_key == ROLE_SUPER_ADMIN
user.is_staff = is_super_admin
user.is_superuser = is_super_admin
user.is_staff = is_product_owner or is_super_admin
user.is_superuser = is_product_owner
user.save(update_fields=['is_staff', 'is_superuser'])
def ensure_bootstrap_role_assignments() -> None:
user_model = get_user_model()
bootstrap_roles = {
'admin_test': ROLE_SUPER_ADMIN,
'admin_test': ROLE_PLATFORM_OWNER,
'user_test': ROLE_STAFF,
}
role_group_names = set(ROLE_GROUP_NAMES.values())
@@ -72,6 +77,12 @@ def ensure_bootstrap_role_assignments() -> None:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist:
continue
if role_key == ROLE_PLATFORM_OWNER and not any(
get_user_role_key(existing_user) == ROLE_PLATFORM_OWNER
for existing_user in user_model.objects.all()
):
assign_user_role(user, ROLE_PLATFORM_OWNER)
continue
if user.groups.filter(name__in=role_group_names).exists():
continue
assign_user_role(user, role_key)
@@ -81,15 +92,15 @@ def get_user_role_key(user) -> str:
if not getattr(user, 'is_authenticated', False):
return ROLE_STAFF
if getattr(user, 'is_superuser', False):
return ROLE_SUPER_ADMIN
return ROLE_PLATFORM_OWNER
group_names = set(user.groups.values_list('name', flat=True))
for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF):
for role_key in (ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF):
if ROLE_GROUP_NAMES[role_key] in group_names:
return role_key
if getattr(user, 'is_staff', False):
return ROLE_ADMIN
return ROLE_SUPER_ADMIN
return ROLE_STAFF
@@ -111,6 +122,7 @@ def template_role_context(user) -> dict[str, object]:
return {
'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]),
'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'),
'can_manage_users': user_has_capability(user, 'manage_users'),
'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'),
'can_run_intro_session': user_has_capability(user, 'run_intro_session'),

View File

@@ -362,6 +362,8 @@
gap: 10px;
align-items: flex-end;
flex-wrap: wrap;
position: relative;
padding-left: 16px;
}
.section-head h2 {
@@ -376,6 +378,32 @@
font-size: 13px;
}
.section-divider {
height: 1px;
margin: 24px 0 14px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(0, 0, 120, 0.18), rgba(0, 0, 120, 0.05) 40%, rgba(140, 29, 29, 0.10));
}
.section-head::before {
content: "";
position: absolute;
left: 0;
top: 2px;
width: 4px;
height: 34px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(0, 0, 120, 0.95), rgba(0, 0, 120, 0.30));
}
.section-head-platform::before {
background: linear-gradient(180deg, rgba(140, 29, 29, 0.90), rgba(140, 29, 29, 0.28));
}
.section-head-admin::before {
background: linear-gradient(180deg, rgba(159, 118, 33, 0.92), rgba(159, 118, 33, 0.28));
}
.apps-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -13,6 +13,7 @@ from jinja2 import Template
from pypdf import PageObject, PdfReader, PdfWriter
from xhtml2pdf import pisa
from .branding import get_default_notification_templates, get_portal_letterhead_path
from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig
from .emailing import send_system_email
from .services import upload_to_nextcloud
@@ -678,7 +679,7 @@ def _render_notification_template(template_key: str, context: dict, language_cod
subject_template = db_template.translated_subject_template(lang)
body_template = db_template.translated_body_template(lang)
else:
fallback = DEFAULT_NOTIFICATION_TEMPLATES[template_key]
fallback = get_default_notification_templates()[template_key]
subject_template = fallback.get(f'subject_{lang}', '') or fallback['subject']
body_template = fallback.get(f'body_{lang}', '') or fallback['body']
@@ -865,7 +866,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_{safe_name}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_template.html'
letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf'
letterhead_path = get_portal_letterhead_path()
devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software)
@@ -998,7 +999,7 @@ def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html'
letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf'
letterhead_path = get_portal_letterhead_path()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
@@ -1048,7 +1049,7 @@ def _generate_onboarding_intro_session_pdf(
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html'
letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf'
letterhead_path = get_portal_letterhead_path()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
@@ -1109,7 +1110,7 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_offboarding_{safe_name}.pdf'
template_path = settings.PDF_TEMPLATES_DIR / 'offboarding_template.html'
letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf'
letterhead_path = get_portal_letterhead_path()
latest_onboarding = (
OnboardingRequest.objects.filter(work_email=request_obj.work_email)
.order_by('-created_at')

View File

@@ -0,0 +1,70 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Branding" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}
<h1>{% trans "Branding" %}</h1>
<p class="sub">{% trans "Portalname, Firmenauftritt, Logo und PDF-Briefkopf zentral verwalten." %}</p>
{% include 'workflows/includes/messages.html' %}
<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.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>
<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>
<button class="btn btn-primary" type="submit">{% trans "Branding speichern" %}</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -17,7 +17,7 @@
<a class="btn btn-secondary" href="/admin-tools/wiki/">Project Wiki</a>
</div>
</div>
<p class="sub">Engineering runbook for development, deployment, maintenance, and extension of the TUBCO Onboarding & Offboarding Portal.</p>
<p class="sub">Engineering runbook for development, deployment, maintenance, and extension of the current company portal deployment.</p>
<div class="toc">
<a href="#overview">Overview</a>
@@ -133,7 +133,7 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<h2 id="pdf">7) PDF Pipeline</h2>
<ul>
<li>PDF generation is HTML-to-PDF using <code>xhtml2pdf</code>.</li>
<li>Letterhead overlay is applied from <code>templates.pdf</code>.</li>
<li>Letterhead overlay defaults to <code>templates.pdf</code>, but can now be replaced from Admin Apps → <code>Branding</code>.</li>
<li>Main logic lives in <code>backend/workflows/tasks.py</code>.</li>
<li>Fixed PDF labels/headings are rendered from task-level DE/EN text dictionaries, not hard-coded directly in request processing logic.</li>
<li>PDF language follows the normalized request <code>preferred_language</code>, with German fallback.</li>
@@ -171,7 +171,16 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<li>Do not point remote backup at the same Nextcloud directory used for normal onboarding/offboarding document uploads.</li>
</ul>
<h2 id="builders">10) Builder Architecture</h2>
<h2 id="branding">10) Branding</h2>
<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>Shared header/logo rendering now uses the branding context processor instead of hardcoded TUBCO asset references.</li>
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
</ul>
<h2 id="builders">11) Builder Architecture</h2>
<h3>Form Builder</h3>
<ul>
<li>Model: <code>FormFieldConfig</code> + <code>FormOption</code></li>

View File

@@ -1,7 +1,7 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "TUBCO Onboarding & Offboarding Portal" %}{% endblock %}
{% block title %}{{ portal_title }}{% endblock %}
@@ -12,7 +12,7 @@
{% block shell_body %}
<div class="topbar">
<div class="brand-wrap">
<a class="app-brand" href="/"><img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" /></a>
<a class="app-brand" href="/"><img class="brand-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" /></a>
</div>
<div class="quick-actions">
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
@@ -32,7 +32,7 @@
<div class="hero-grid">
<div class="hero-card">
<span class="eyebrow">{% trans "Operations Console" %}</span>
<h1>{% trans "TUBCO Onboarding & Offboarding Portal" %}</h1>
<h1>{{ portal_title }}</h1>
<p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p>
<div class="status-row">
<span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {{ role_label }}</span>
@@ -51,7 +51,7 @@
<main class="main">
{% include 'workflows/includes/messages.html' %}
<div class="section-head">
<div class="section-head section-head-primary">
<h2>{% trans "Apps" %}</h2>
<p>{% trans "Wählen Sie den gewünschten Prozess." %}</p>
</div>
@@ -107,8 +107,24 @@
{% endif %}
</div>
{% if can_manage_product_branding %}
<div class="section-divider" aria-hidden="true"></div>
<div class="section-head section-head-platform">
<h2>{% trans "Platform Apps" %}</h2>
<p>{% trans "Produktweite Konfiguration und Produktsteuerung." %}</p>
</div>
<div class="admin-grid">
<section class="admin-card">
<h3>{% trans "Branding" %}</h3>
<p>{% trans "Logo, Portalname, Farben und PDF-Briefkopf verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/branding/">{% trans "Öffnen" %}</a>
</section>
</div>
{% endif %}
{% if can_manage_users or can_manage_integrations or can_view_audit_log or can_manage_backups or can_manage_welcome_emails or can_manage_builders or can_view_docs or can_access_django_admin_link %}
<div class="section-head">
<div class="section-divider" aria-hidden="true"></div>
<div class="section-head section-head-admin">
<h2>{% trans "Admin Apps" %}</h2>
<p>{% trans "Konfiguration, Tests und Steuerung." %}</p>
</div>

View File

@@ -1,8 +1,8 @@
{% load static i18n %}
{% load i18n %}
{% get_current_language as CURRENT_LANGUAGE %}
<div class="app-header{% if header_inside_shell %} app-header-in-shell{% endif %}">
<a class="app-brand" href="/" aria-label="{% trans 'Zur Startseite' %}">
<img class="app-brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
<img class="app-brand-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" />
</a>
<div class="app-header-actions">
{% if header_show_lang %}

View File

@@ -120,7 +120,7 @@
<ul>
<li>Template source: <code>/backend/media/templates/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li>
<li>Additional onboarding intro template: <code>/backend/media/templates/onboarding_intro_template.html</code>.</li>
<li>Letterhead: <code>/backend/media/templates/templates.pdf</code>.</li>
<li>Letterhead defaults to <code>/backend/media/templates/templates.pdf</code>, but can be overridden from Admin Apps → <code>Branding</code>.</li>
<li>Output folder: <code>/backend/media/pdfs/</code>.</li>
<li>Fixed PDF labels and notes are rendered through task-level DE/EN text maps and follow the request language with German fallback.</li>
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li>
@@ -178,6 +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>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>
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
@@ -207,6 +208,7 @@
<li>Remote backup target configuration lives under Admin Apps → <code>Integrationen</code><code>Backup-Ziel</code>.</li>
<li>Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.</li>
<li>Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.</li>
<li>Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → <code>Branding</code>.</li>
</ul>
<h3>Deployment Notes</h3>

View File

@@ -12,7 +12,7 @@
{% block shell_body %}
<div class="topbar">
<div class="brand-wrap">
<a class="app-brand" href="/"><img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" /></a>
<a class="app-brand" href="/"><img class="brand-logo" src="{{ portal_logo_url }}" alt="{{ portal_company_name }} Logo" /></a>
</div>
<div class="quick-actions">
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
@@ -300,7 +300,7 @@
</section>
</section>
<div class="footer-bar">
<div class="footer-note">{% trans "TUBCO Onboarding & Offboarding Portal" %}</div>
<div class="footer-note">{{ portal_title }}</div>
</div>
{% endblock %}

View File

@@ -12,7 +12,7 @@
<div class="toolbar">
<div>
<h1>{% trans "Benutzer & Rollen" %}</h1>
<p class="sub">{% trans "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." %}</p>
<p class="sub">{% trans "Platform Owner und Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." %}</p>
</div>
</div>
@@ -137,7 +137,7 @@
</tbody>
</table>
</div>
<p class="hint">{% trans "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst deaktivieren oder auf eine niedrigere Rolle setzen." %}</p>
<p class="hint">{% trans "Hinweis: Der letzte aktive Platform Owner oder Super Admin kann sich hier nicht selbst entfernen oder auf eine niedrigere Rolle setzen." %}</p>
</section>
<section class="card">

View File

@@ -30,6 +30,8 @@ urlpatterns = [
path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'),
path('admin-tools/welcome-emails/<int:schedule_id>/cancel/', views.cancel_welcome_email, name='cancel_welcome_email'),
path('admin-tools/handbook/', views.handbook_page, name='handbook_page'),
path('admin-tools/branding/', views.portal_branding_page, name='portal_branding_page'),
path('admin-tools/branding/save/', views.save_portal_branding, name='save_portal_branding'),
path('admin-tools/users/', views.user_management_page, name='user_management_page'),
path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'),
path('admin-tools/users/<int:user_id>/update/', views.update_user_from_admin, name='update_user_from_admin'),

View File

@@ -25,7 +25,8 @@ from django.utils.translation import get_language, override
from django.urls import reverse
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
from .forms import OffboardingRequestForm, OnboardingRequestForm, UserManagementCreateForm
from .branding import get_branding_email_copy, get_default_notification_templates
from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, UserManagementCreateForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES,
@@ -34,12 +35,11 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER,
ensure_form_field_configs,
)
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .emailing import send_system_email
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import (
DEFAULT_NOTIFICATION_TEMPLATES,
_generate_onboarding_intro_pdf,
_generate_onboarding_intro_session_pdf,
build_intro_sections_for_request,
@@ -341,6 +341,7 @@ def home(request):
def _user_management_rows():
user_model = get_user_model()
role_order = {
ROLE_PLATFORM_OWNER: 0,
ROLE_SUPER_ADMIN: 0,
'admin': 1,
'it_staff': 2,
@@ -372,19 +373,30 @@ def _render_user_management(request, create_form=None, status_code: int = 200):
row.action_label = _audit_action_label(row.action)
role_key = (row.details or {}).get('role')
row.role_label = str(ROLE_LABELS[role_key]) if role_key in ROLE_LABELS else role_key
include_product_owner = get_user_role_key(request.user) == ROLE_PLATFORM_OWNER
return render(
request,
'workflows/user_management.html',
{
'create_form': create_form or UserManagementCreateForm(),
'create_form': create_form or UserManagementCreateForm(include_product_owner=include_product_owner),
'rows': _user_management_rows(),
'role_choices': [(key, str(ROLE_LABELS[key])) for key in ROLE_GROUP_NAMES],
'role_choices': [
(key, str(ROLE_LABELS[key]))
for key in ROLE_GROUP_NAMES
if include_product_owner or key != ROLE_PLATFORM_OWNER
],
'include_product_owner': include_product_owner,
'recent_user_events': recent_user_events,
},
status=status_code,
)
def _platform_owner_user_count() -> int:
user_model = get_user_model()
return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_PLATFORM_OWNER and user.is_active)
def _super_admin_user_count() -> int:
user_model = get_user_model()
return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active)
@@ -404,6 +416,20 @@ def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is
return False
def _would_remove_last_platform_owner(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool:
if get_user_role_key(user) != ROLE_PLATFORM_OWNER or not user.is_active:
return False
if _platform_owner_user_count() > 1:
return False
if deleting:
return True
if new_role_key is not None and new_role_key != ROLE_PLATFORM_OWNER:
return True
if new_is_active is not None and not new_is_active:
return True
return False
def _send_user_access_email(request, target_user, *, invitation: bool) -> None:
email = (target_user.email or '').strip()
if not email:
@@ -413,17 +439,19 @@ def _send_user_access_email(request, target_user, *, invitation: bool) -> None:
token = default_token_generator.make_token(target_user)
reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
reset_url = request.build_absolute_uri(reset_path)
branding_copy = get_branding_email_copy()
if invitation:
subject = _('Zugangseinladung für %(username)s') % {'username': target_user.username}
body = _(
'Hallo %(name)s,\n\n'
'für Sie wurde ein Benutzerkonto im TUBCO Onboarding- und Offboarding-Portal angelegt.\n'
'für Sie wurde ein Benutzerkonto im %(portal_title)s angelegt.\n'
'Bitte öffnen Sie den folgenden Link, um Ihr Passwort zu setzen:\n'
'%(url)s\n\n'
'Wenn Sie diese Einladung nicht erwartet haben, melden Sie sich bitte bei Ihrem Administrator.'
) % {
'name': _display_user_name(target_user),
'portal_title': branding_copy['portal_title'],
'url': reset_url,
}
else:
@@ -447,10 +475,67 @@ def user_management_page(request):
return _render_user_management(request)
@_require_capability('manage_product_branding')
def portal_branding_page(request):
branding, created = PortalBranding.objects.get_or_create(name='Default')
form = PortalBrandingForm(instance=branding)
return render(
request,
'workflows/branding_settings.html',
{
'form': form,
'branding': branding,
},
)
@_require_capability('manage_product_branding')
@require_POST
def save_portal_branding(request):
branding, created = PortalBranding.objects.get_or_create(name='Default')
form = PortalBrandingForm(request.POST, request.FILES, instance=branding)
if not form.is_valid():
messages.error(request, _('Branding konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
return render(
request,
'workflows/branding_settings.html',
{
'form': form,
'branding': branding,
},
status=400,
)
branding = form.save()
_audit(
request,
'portal_branding_saved',
target_type='portal_branding',
target_id=branding.id,
target_label=branding.portal_title,
details={
'company_name': branding.company_name,
'support_email': branding.support_email,
'default_language': branding.default_language,
'has_custom_logo': bool(branding.logo_image),
'has_custom_letterhead': bool(branding.pdf_letterhead),
},
)
messages.success(request, _('Portal-Branding wurde gespeichert.'))
return render(
request,
'workflows/branding_settings.html',
{
'form': PortalBrandingForm(instance=branding),
'branding': branding,
},
)
@_require_capability('manage_users')
@require_POST
def create_user_from_admin(request):
form = UserManagementCreateForm(request.POST)
form = UserManagementCreateForm(request.POST, include_product_owner=(get_user_role_key(request.user) == ROLE_PLATFORM_OWNER))
if not form.is_valid():
messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.'))
return _render_user_management(request, create_form=form, status_code=400)
@@ -481,10 +566,20 @@ def update_user_from_admin(request, user_id: int):
if role_key not in ROLE_GROUP_NAMES:
messages.error(request, _('Ungültige Rolle.'))
return redirect('user_management_page')
if role_key == ROLE_PLATFORM_OWNER and get_user_role_key(request.user) != ROLE_PLATFORM_OWNER:
messages.error(request, _('Nur Platform Owner dürfen diese Rolle vergeben.'))
return redirect('user_management_page')
if target_user == request.user and (role_key != ROLE_SUPER_ADMIN or not is_active):
current_role = get_user_role_key(request.user)
if target_user == request.user and current_role == ROLE_PLATFORM_OWNER and (role_key != ROLE_PLATFORM_OWNER or not is_active):
messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst sperren oder herabstufen.'))
return redirect('user_management_page')
if target_user == request.user and current_role == ROLE_SUPER_ADMIN and (role_key != ROLE_SUPER_ADMIN or not is_active):
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.'))
return redirect('user_management_page')
if _would_remove_last_platform_owner(target_user, new_role_key=role_key, new_is_active=is_active):
messages.error(request, _('Der letzte aktive Platform Owner kann nicht deaktiviert oder herabgestuft werden.'))
return redirect('user_management_page')
if _would_remove_last_super_admin(target_user, new_role_key=role_key, new_is_active=is_active):
messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.'))
return redirect('user_management_page')
@@ -535,9 +630,16 @@ def delete_user_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
current_role = get_user_role_key(request.user)
if target_user == request.user and current_role == ROLE_PLATFORM_OWNER:
messages.error(request, _('Der aktuell angemeldete Platform Owner kann sich hier nicht selbst löschen.'))
return redirect('user_management_page')
if target_user == request.user:
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.'))
return redirect('user_management_page')
if _would_remove_last_platform_owner(target_user, deleting=True):
messages.error(request, _('Der letzte aktive Platform Owner kann nicht gelöscht werden.'))
return redirect('user_management_page')
if _would_remove_last_super_admin(target_user, deleting=True):
messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.'))
return redirect('user_management_page')
@@ -1584,11 +1686,11 @@ def welcome_emails_page(request):
rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200]
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
welcome_template = NotificationTemplate.objects.filter(key='onboarding_welcome').first()
default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {})
default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip()
default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip()
default_subject_en = (default_welcome.get('subject_en') or 'Welcome to TUB/CO, {{ FULL_NAME }}').strip()
default_body_en = (default_welcome.get('body_en') or 'Hello {{ FULL_NAME }}, welcome to TUB/CO.').strip()
default_welcome = get_default_notification_templates().get('onboarding_welcome', {})
default_subject = (default_welcome.get('subject') or '').strip()
default_body = (default_welcome.get('body') or '').strip()
default_subject_en = (default_welcome.get('subject_en') or '').strip()
default_body_en = (default_welcome.get('body_en') or '').strip()
subject_value = (welcome_template.subject_template if welcome_template else '').strip() or default_subject
body_value = (welcome_template.body_template if welcome_template else '').strip() or default_body
subject_value_en = (welcome_template.subject_template_en if welcome_template else '').strip() or default_subject_en
@@ -1650,11 +1752,11 @@ def save_welcome_email_settings(request):
subject_en = request.POST.get('welcome_subject_en')
body_en = request.POST.get('welcome_body_en')
if subject is not None or body is not None or subject_en is not None or body_en is not None:
default_welcome = DEFAULT_NOTIFICATION_TEMPLATES.get('onboarding_welcome', {})
default_subject = (default_welcome.get('subject') or 'Willkommen bei TUB/CO, {{ FULL_NAME }}').strip()
default_body = (default_welcome.get('body') or 'Hallo {{ FULL_NAME }}, willkommen bei TUB/CO.').strip()
default_subject_en = (default_welcome.get('subject_en') or 'Welcome to TUB/CO, {{ FULL_NAME }}').strip()
default_body_en = (default_welcome.get('body_en') or 'Hello {{ FULL_NAME }}, welcome to TUB/CO.').strip()
default_welcome = get_default_notification_templates().get('onboarding_welcome', {})
default_subject = (default_welcome.get('subject') or '').strip()
default_body = (default_welcome.get('body') or '').strip()
default_subject_en = (default_welcome.get('subject_en') or '').strip()
default_body_en = (default_welcome.get('body_en') or '').strip()
subject_clean = (subject or '').strip() or default_subject
body_clean = (body or '').strip() or default_body
subject_clean_en = (subject_en or '').strip() or default_subject_en