snapshot: preserve branding foundation and platform owner split
This commit is contained in:
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
|
||||
106
backend/workflows/branding.py
Normal file
106
backend/workflows/branding.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
33
backend/workflows/migrations/0036_portalbranding.py
Normal file
33
backend/workflows/migrations/0036_portalbranding.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'])]),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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')
|
||||
|
||||
70
backend/workflows/templates/workflows/branding_settings.html
Normal file
70
backend/workflows/templates/workflows/branding_settings.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user