snapshot: preserve app registry and branding domain foundation

This commit is contained in:
Md Bayazid Bostame
2026-03-26 11:59:06 +01:00
parent 51700cfa8b
commit c195efe339
23 changed files with 1122 additions and 561 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, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
@admin.register(EmployeeProfile)
@@ -25,6 +25,15 @@ class PortalBrandingAdmin(admin.ModelAdmin):
list_display = ('name', 'portal_title', 'company_name', 'support_email', 'default_language', 'updated_at')
@admin.register(PortalAppConfig)
class PortalAppConfigAdmin(admin.ModelAdmin):
list_display = ('key', 'section', 'sort_order', 'is_enabled', 'updated_at')
list_filter = ('section', 'is_enabled')
search_fields = ('key', 'title_override', 'title_override_en')
ordering = ('section', 'sort_order', 'key')
list_editable = ('section', 'sort_order', 'is_enabled')
@admin.register(OnboardingRequest)
class OnboardingRequestAdmin(admin.ModelAdmin):
list_display = ('id', 'full_name', 'work_email', 'department', 'contract_start', 'created_at')

View File

@@ -0,0 +1,255 @@
from __future__ import annotations
from dataclasses import dataclass
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from .models import PortalAppConfig
from .roles import user_has_capability
@dataclass(frozen=True)
class AppDefinition:
key: str
section: str
route_name: str
title: object
description: object
action_label: object
capability: str | None = None
accent: str = ''
accent_label: str = 'APP'
style_variant: str = ''
tags: tuple[object, ...] = ()
APP_DEFINITIONS: tuple[AppDefinition, ...] = (
AppDefinition(
key='onboarding',
section=PortalAppConfig.SECTION_APP,
route_name='onboarding_create',
title=_('Onboarding'),
description=_('Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen.'),
action_label=_('Onboarding starten'),
accent='ON',
tags=(_('Mehrschritt-Formular'), 'PDF', _('E-Mail Routing')),
style_variant='primary',
),
AppDefinition(
key='offboarding',
section=PortalAppConfig.SECTION_APP,
route_name='offboarding_create',
title=_('Offboarding'),
description=_('Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten.'),
action_label=_('Offboarding starten'),
accent='OFF',
tags=(_('Profile-Suche'), _('Hardware-Liste'), _('IT-Rückgabe')),
style_variant='red',
),
AppDefinition(
key='requests_dashboard',
section=PortalAppConfig.SECTION_APP,
route_name='requests_dashboard',
title=_('Anfragen Dashboard'),
description=_('Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen.'),
action_label=_('Dashboard öffnen'),
capability='access_requests_dashboard',
accent='APP',
tags=(_('Suche'), _('Status'), _('PDF Zugriff')),
),
AppDefinition(
key='branding',
section=PortalAppConfig.SECTION_PLATFORM,
route_name='portal_branding_page',
title=_('Branding'),
description=_('Logo, Portalname, Farben und PDF-Briefkopf verwalten.'),
action_label=_('Öffnen'),
capability='manage_product_branding',
),
AppDefinition(
key='app_registry',
section=PortalAppConfig.SECTION_PLATFORM,
route_name='portal_app_registry_page',
title=_('App Registry'),
description=_('Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten.'),
action_label=_('Öffnen'),
capability='manage_app_registry',
),
AppDefinition(
key='integrations',
section=PortalAppConfig.SECTION_ADMIN,
route_name='integrations_setup_page',
title=_('Integrationen'),
description=_('Nextcloud- und E-Mail-Setup.'),
action_label=_('Öffnen'),
capability='manage_integrations',
),
AppDefinition(
key='users',
section=PortalAppConfig.SECTION_ADMIN,
route_name='user_management_page',
title=_('Benutzer & Rollen'),
description=_('Benutzer anlegen, Rollen zuweisen und Zugriffe steuern.'),
action_label=_('Öffnen'),
capability='manage_users',
),
AppDefinition(
key='audit_log',
section=PortalAppConfig.SECTION_ADMIN,
route_name='audit_log_page',
title=_('Audit Log'),
description=_('Wichtige Admin-Aktionen nachvollziehen und prüfen.'),
action_label=_('Öffnen'),
capability='view_audit_log',
),
AppDefinition(
key='backups',
section=PortalAppConfig.SECTION_ADMIN,
route_name='backup_recovery_page',
title=_('Backup & Recovery'),
description=_('Backups erstellen und sicher verifizieren.'),
action_label=_('Öffnen'),
capability='manage_backups',
),
AppDefinition(
key='welcome_emails',
section=PortalAppConfig.SECTION_ADMIN,
route_name='welcome_emails_page',
title=_('Welcome E-Mails'),
description=_('Geplante Welcome Mails verwalten.'),
action_label=_('Öffnen'),
capability='manage_welcome_emails',
),
AppDefinition(
key='form_builder',
section=PortalAppConfig.SECTION_ADMIN,
route_name='form_builder_page',
title=_('Form Builder'),
description=_('Felder, Schritte und Optionen verwalten.'),
action_label=_('Öffnen'),
capability='manage_builders',
),
AppDefinition(
key='intro_builder',
section=PortalAppConfig.SECTION_ADMIN,
route_name='intro_builder_page',
title=_('Einweisungs-Builder'),
description=_('Checklistenpunkte für das Einweisungsprotokoll konfigurieren.'),
action_label=_('Öffnen'),
capability='manage_builders',
),
AppDefinition(
key='handbook',
section=PortalAppConfig.SECTION_ADMIN,
route_name='handbook_page',
title=_('Handbook'),
description=_('Project wiki and developer documentation in one place.'),
action_label=_('Öffnen'),
capability='view_docs',
),
AppDefinition(
key='django_admin',
section=PortalAppConfig.SECTION_ADMIN,
route_name='admin:index',
title=_('Django Admin'),
description=_('Vollständige Datenverwaltung.'),
action_label=_('Öffnen'),
capability='access_django_admin_link',
),
)
SECTION_META = {
PortalAppConfig.SECTION_APP: {
'title': _('Apps'),
'subtitle': _('Wählen Sie den gewünschten Prozess.'),
'css_class': 'section-head-primary',
'grid_class': 'apps-grid',
},
PortalAppConfig.SECTION_PLATFORM: {
'title': _('Platform Apps'),
'subtitle': _('Produktweite Konfiguration und Produktsteuerung.'),
'css_class': 'section-head-platform',
'grid_class': 'admin-grid',
},
PortalAppConfig.SECTION_ADMIN: {
'title': _('Admin Apps'),
'subtitle': _('Konfiguration, Tests und Steuerung.'),
'css_class': 'section-head-admin',
'grid_class': 'admin-grid',
},
}
def ensure_portal_app_configs() -> None:
for index, definition in enumerate(APP_DEFINITIONS):
PortalAppConfig.objects.get_or_create(
key=definition.key,
defaults={
'section': definition.section,
'sort_order': index,
'is_enabled': True,
},
)
def get_portal_app_registry_rows() -> list[dict[str, object]]:
ensure_portal_app_configs()
config_map = {config.key: config for config in PortalAppConfig.objects.all()}
rows: list[dict[str, object]] = []
for index, definition in enumerate(APP_DEFINITIONS):
config = config_map[definition.key]
rows.append(
{
'definition': definition,
'config': config,
'default_section': definition.section,
'default_sort_order': index,
}
)
return rows
def build_portal_app_sections(user) -> list[dict[str, object]]:
ensure_portal_app_configs()
config_map = {config.key: config for config in PortalAppConfig.objects.all()}
grouped: dict[str, list[dict[str, object]]] = {key: [] for key in SECTION_META}
for definition in APP_DEFINITIONS:
config = config_map.get(definition.key)
if not config or not config.is_enabled:
continue
if definition.capability and not user_has_capability(user, definition.capability):
continue
grouped[config.section].append(
{
'key': definition.key,
'href': reverse(definition.route_name),
'title': config.translated_title_override() or str(definition.title),
'description': config.translated_description_override() or str(definition.description),
'action_label': config.translated_action_label_override() or str(definition.action_label),
'accent': definition.accent,
'accent_label': definition.accent_label,
'style_variant': definition.style_variant,
'tags': [str(tag) for tag in definition.tags],
'sort_order': config.sort_order,
}
)
sections: list[dict[str, object]] = []
for section_key, meta in SECTION_META.items():
apps = sorted(grouped.get(section_key, []), key=lambda item: (item['sort_order'], item['title']))
if not apps:
continue
sections.append(
{
'key': section_key,
'title': str(meta['title']),
'subtitle': str(meta['subtitle']),
'css_class': meta['css_class'],
'grid_class': meta['grid_class'],
'apps': apps,
}
)
return sections

View File

@@ -14,6 +14,7 @@ def get_portal_branding() -> PortalBranding:
defaults={
'portal_title': 'TUBCO Onboarding & Offboarding Portal',
'company_name': 'TUBCO',
'company_domain': 'tub.co',
'support_email': 'info@tub.co',
'default_language': 'de',
'primary_color': '#000078',
@@ -23,6 +24,12 @@ def get_portal_branding() -> PortalBranding:
return branding
def get_company_email_domain() -> str:
branding = get_portal_branding()
domain = (branding.company_domain or '').strip().lower().lstrip('@')
return domain or 'tub.co'
def get_portal_logo_url() -> str:
branding = get_portal_branding()
if branding.logo_image:
@@ -51,6 +58,7 @@ def get_branding_context() -> dict[str, object]:
'portal_branding': branding,
'portal_title': branding.portal_title,
'portal_company_name': branding.company_name,
'portal_email_domain': get_company_email_domain(),
'portal_support_email': branding.support_email,
'portal_default_language': branding.default_language,
'portal_primary_color': branding.primary_color,
@@ -67,6 +75,7 @@ def get_branding_email_copy() -> dict[str, str]:
portal_title = (branding.portal_title or f'{company_name} Portal').strip()
return {
'company_name': company_name,
'company_domain': get_company_email_domain(),
'portal_title': portal_title,
'support_email': (branding.support_email or '').strip(),
}
@@ -78,7 +87,9 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]:
from .tasks import DEFAULT_NOTIFICATION_TEMPLATES
templates = deepcopy(DEFAULT_NOTIFICATION_TEMPLATES)
company_name = get_branding_email_copy()['company_name']
branding_copy = get_branding_email_copy()
company_name = branding_copy['company_name']
support_email = branding_copy['support_email'] or f"it@{branding_copy['company_domain']}"
welcome = templates.get('onboarding_welcome')
if welcome:
welcome['subject'] = f'Willkommen bei {company_name}, {{ VORNAME }}'
@@ -89,7 +100,7 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]:
'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'
f'Wenn du Fragen hast, melde dich gerne jederzeit unter {support_email}.\n\n'
'Viele Grüße\n'
f'{company_name} IT'
)
@@ -99,7 +110,7 @@ def get_default_notification_templates() -> dict[str, dict[str, str]]:
'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'
f'If you have any questions, feel free to contact {support_email}.\n\n'
'Best regards,\n'
f'{company_name} IT'
)

View File

@@ -6,6 +6,7 @@ from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, Set
from django.utils import timezone
from django.utils.translation import get_language, gettext as _, gettext_lazy
from .branding import get_company_email_domain
from .form_builder import apply_form_field_config
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
@@ -175,6 +176,7 @@ class PortalBrandingForm(forms.ModelForm):
fields = [
'portal_title',
'company_name',
'company_domain',
'support_email',
'default_language',
'logo_image',
@@ -185,6 +187,7 @@ class PortalBrandingForm(forms.ModelForm):
labels = {
'portal_title': gettext_lazy('Portal-Titel'),
'company_name': gettext_lazy('Firmenname'),
'company_domain': gettext_lazy('Firmen-Domain'),
'support_email': gettext_lazy('Support-E-Mail'),
'default_language': gettext_lazy('Standardsprache'),
'logo_image': gettext_lazy('Logo'),
@@ -343,6 +346,7 @@ class OnboardingRequestForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower()
super().__init__(*args, **kwargs)
self.email_domain = get_company_email_domain()
config = WorkflowConfig.objects.order_by('id').first()
self.handover_lead_days = max(0, int(getattr(config, 'device_handover_lead_days', 5) or 5))
@@ -350,6 +354,7 @@ class OnboardingRequestForm(forms.ModelForm):
self.fields['handover_date'].widget.attrs['min'] = minimum_handover_date.isoformat()
self.fields['full_name'].label = 'Name'
self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain}
full_name_initial = (self.initial.get('full_name') or '').strip()
if full_name_initial and not self.initial.get('first_name') and not self.initial.get('last_name'):
name_parts = full_name_initial.split()
@@ -369,8 +374,9 @@ class OnboardingRequestForm(forms.ModelForm):
value = (self.cleaned_data.get('work_email') or '').strip().lower()
if not value:
return value
if not value.endswith('@tub.co'):
raise forms.ValidationError('Bitte verwenden Sie eine @tub.co E-Mail-Adresse.')
expected_suffix = f'@{self.email_domain}'
if self.email_domain and not value.endswith(expected_suffix):
raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain})
return value
def clean_signature_image(self):
@@ -531,11 +537,21 @@ class OffboardingRequestForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
prefill_profile = kwargs.pop('prefill_profile', None)
super().__init__(*args, **kwargs)
self.email_domain = get_company_email_domain()
self.fields['full_name'].label = 'Vorname und Nachname'
self.fields['work_email'].help_text = ''
self.fields['work_email'].help_text = _('Bitte nutzen Sie das Format name@%(domain)s.') % {'domain': self.email_domain}
if prefill_profile:
self.fields['full_name'].initial = prefill_profile.full_name
self.fields['work_email'].initial = prefill_profile.work_email
self.fields['department'].initial = prefill_profile.department
self.fields['job_title'].initial = prefill_profile.job_title
apply_form_field_config('offboarding', self)
def clean_work_email(self):
value = (self.cleaned_data.get('work_email') or '').strip().lower()
if not value:
return value
expected_suffix = f'@{self.email_domain}'
if self.email_domain and not value.endswith(expected_suffix):
raise forms.ValidationError(_('Bitte verwenden Sie eine @%(domain)s E-Mail-Adresse.') % {'domain': self.email_domain})
return value

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.1.5 on 2026-03-26 10:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0037_alter_portalbranding_logo_image_and_more'),
]
operations = [
migrations.CreateModel(
name='PortalAppConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=80, unique=True)),
('section', models.CharField(choices=[('app', 'Apps'), ('platform', 'Platform Apps'), ('admin', 'Admin Apps')], default='app', max_length=20)),
('sort_order', models.PositiveIntegerField(default=0)),
('is_enabled', models.BooleanField(default=True)),
('title_override', models.CharField(blank=True, max_length=255)),
('title_override_en', models.CharField(blank=True, max_length=255)),
('description_override', models.TextField(blank=True)),
('description_override_en', models.TextField(blank=True)),
('action_label_override', models.CharField(blank=True, max_length=255)),
('action_label_override_en', models.CharField(blank=True, max_length=255)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Portal App',
'verbose_name_plural': 'Portal Apps',
'ordering': ['section', 'sort_order', 'key'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2026-03-26 10:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0038_portalappconfig'),
]
operations = [
migrations.AddField(
model_name='portalbranding',
name='company_domain',
field=models.CharField(blank=True, default='tub.co', max_length=120),
),
]

View File

@@ -29,6 +29,7 @@ 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')
company_domain = models.CharField(max_length=120, blank=True, default='tub.co')
support_email = models.EmailField(blank=True, default='info@tub.co')
default_language = models.CharField(
max_length=10,
@@ -59,6 +60,54 @@ class PortalBranding(models.Model):
return self.portal_title or self.company_name or self.name
class PortalAppConfig(models.Model):
SECTION_APP = 'app'
SECTION_PLATFORM = 'platform'
SECTION_ADMIN = 'admin'
SECTION_CHOICES = [
(SECTION_APP, _('Apps')),
(SECTION_PLATFORM, _('Platform Apps')),
(SECTION_ADMIN, _('Admin Apps')),
]
key = models.CharField(max_length=80, unique=True)
section = models.CharField(max_length=20, choices=SECTION_CHOICES, default=SECTION_APP)
sort_order = models.PositiveIntegerField(default=0)
is_enabled = models.BooleanField(default=True)
title_override = models.CharField(max_length=255, blank=True)
title_override_en = models.CharField(max_length=255, blank=True)
description_override = models.TextField(blank=True)
description_override_en = models.TextField(blank=True)
action_label_override = models.CharField(max_length=255, blank=True)
action_label_override_en = models.CharField(max_length=255, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['section', 'sort_order', 'key']
verbose_name = 'Portal App'
verbose_name_plural = 'Portal Apps'
def __str__(self) -> str:
return self.key
def _translated_value(self, field_name: str, language_code: str | None = None) -> str:
lang = (language_code or get_language() or 'de').split('-')[0]
if lang == 'en':
english_value = (getattr(self, f'{field_name}_en', '') or '').strip()
if english_value:
return english_value
return (getattr(self, field_name, '') or '').strip()
def translated_title_override(self, language_code: str | None = None) -> str:
return self._translated_value('title_override', language_code)
def translated_description_override(self, language_code: str | None = None) -> str:
return self._translated_value('description_override', language_code)
def translated_action_label_override(self, language_code: str | None = None) -> str:
return self._translated_value('action_label_override', language_code)
class AdminAuditLog(models.Model):
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,

View File

@@ -29,6 +29,7 @@ ROLE_LABELS = {
CAPABILITIES = {
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_product_branding': {ROLE_PLATFORM_OWNER},
'manage_app_registry': {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},
@@ -123,6 +124,7 @@ def template_role_context(user) -> dict[str, object]:
'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]),
'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'),
'can_manage_app_registry': user_has_capability(user, 'manage_app_registry'),
'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

@@ -35,6 +35,12 @@ textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo,
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { border: 1px solid #dce5f1; padding: 8px; text-align: left; vertical-align: top; }
th { background: #f6f9ff; color: #334155; }
.app-registry-wrap textarea { min-height: 86px; }
.app-registry-table input[type="text"],
.app-registry-table input[type="number"],
.app-registry-table select,
.app-registry-table textarea { min-width: 160px; }
.app-registry-table td { background: rgba(255,255,255,0.9); }
.template-block { border: 1px solid #d8e3f0; border-radius: 10px; background: #fff; padding: 10px; margin-top: 10px; }
.template-title, .rule-title { margin: 0 0 8px; color: #24344e; font-weight: 700; font-size: 14px; }
.rule-card { margin-top: 12px; border: 1px solid #d8e3f0; border-radius: 12px; padding: 10px; background: #fff; }

View File

@@ -22,6 +22,8 @@
const fullName = byName('full_name');
const workEmail = byName('work_email');
const form = fullName ? fullName.closest('form') : null;
const emailDomain = (((form && form.dataset.emailDomain) || 'tub.co') + '').replace(/^@+/, '').trim();
if (!fullName || !workEmail) return;
let lastSuggested = '';
@@ -31,7 +33,7 @@
const lastName = extractLastName(fullName.value);
const slug = slugifyForEmail(lastName);
if (!slug) return;
const suggestion = slug + '@tub.co';
const suggestion = slug + '@' + emailDomain;
const current = (workEmail.value || '').trim();
if (!userEditedEmail || current === '' || current === lastSuggested) {
workEmail.value = suggestion;

View File

@@ -5,6 +5,7 @@
const btnNext = document.getElementById('btn-next');
const btnSubmit = document.getElementById('btn-submit');
const form = document.getElementById('onboarding-form');
const emailDomain = ((form && form.dataset.emailDomain) || 'tub.co').replace(/^@+/, '').trim();
let current = 0;
form.setAttribute('novalidate', 'novalidate');
@@ -82,7 +83,7 @@
function suggestEmail() {
const slug = slugifyForEmail(lastName.value);
if (!slug) return;
const suggestion = slug + '@tub.co';
const suggestion = slug + '@' + emailDomain;
if (!userEditedEmail || workEmail.value === '' || workEmail.value === lastSuggested) {
workEmail.value = suggestion;
lastSuggested = suggestion;

View File

@@ -101,7 +101,7 @@ DEFAULT_NOTIFICATION_TEMPLATES = {
'Vertragsbeginn: {{ CONTRACT_START }}\n'
'E-Mail-Adresse: {{ EMAIL }}\n\n'
'{% if PDF_LINK %}In 2 Minuten findest du alle Infos über den Mitarbeiter als PDF unter diesem Link: {{ PDF_LINK }}\n\n{% endif %}'
'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei der it@tub.co melden!\n\n'
'Falls du noch irgendwelche anderen Informationen benötigen solltest, kannst du dich bei {{ SUPPORT_EMAIL }} melden!\n\n'
'Vielen Dank und schöne Grüße,\n'
'Die IT.'
),
@@ -114,7 +114,7 @@ DEFAULT_NOTIFICATION_TEMPLATES = {
'Contract start: {{ CONTRACT_START }}\n'
'Email address: {{ EMAIL }}\n\n'
'{% if PDF_LINK %}You will find the employee PDF here in about 2 minutes: {{ PDF_LINK }}\n\n{% endif %}'
'If you need any other information, please contact it@tub.co.\n\n'
'If you need any other information, please contact {{ SUPPORT_EMAIL }}.\n\n'
'Thank you and best regards,\n'
'IT'
),
@@ -1176,6 +1176,7 @@ def process_onboarding_request(onboarding_request_id: int) -> None:
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
try:
branding_copy = get_branding_email_copy()
it_email, general_info_email, business_card_email, hr_works_email, key_email = _resolve_workflow_emails()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip()
@@ -1204,6 +1205,7 @@ def process_onboarding_request(onboarding_request_id: int) -> None:
'CONTRACT_START': request_obj.contract_start,
'EMAIL': request_obj.work_email,
'REQUESTED_BY': request_obj.onboarded_by_email or '-',
'SUPPORT_EMAIL': branding_copy['support_email'] or f"it@{branding_copy['company_domain']}",
'BUSINESS_CARD_NAME': request_obj.business_card_name or display_name,
'BUSINESS_CARD_TITLE': request_obj.business_card_title or '-',
'BUSINESS_CARD_EMAIL': request_obj.business_card_email or request_obj.work_email,
@@ -1285,6 +1287,7 @@ def process_offboarding_request(offboarding_request_id: int) -> None:
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
try:
branding_copy = get_branding_email_copy()
it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails()
pdf_path = _generate_offboarding_pdf(request_obj)
@@ -1297,6 +1300,7 @@ def process_offboarding_request(offboarding_request_id: int) -> None:
'LAST_WORKING_DAY': request_obj.last_working_day,
'REQUESTED_BY': request_obj.requested_by_email,
'EMAIL': request_obj.work_email,
'SUPPORT_EMAIL': branding_copy['support_email'] or f"it@{branding_copy['company_domain']}",
}
_send_templated_email(

View File

@@ -0,0 +1,83 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "App Registry" %}{% 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 "App Registry" %}</h1>
<p class="sub">{% trans "Apps zentral steuern, für Kunden vorbereiten und ohne Template-Eingriffe auf der Landing Page ausspielen." %}</p>
{% include 'workflows/includes/messages.html' %}
<section class="card">
<div class="toolbar">
<div class="hint">{% trans "Sicherheit bleibt codebasiert: Sichtbarkeit und Reihenfolge sind hier steuerbar, Berechtigungen weiterhin über Rollen und Capabilities." %}</div>
<span class="badge scheduled">{% trans "Produktkern" %}</span>
</div>
<form method="post" action="{% url 'save_portal_app_registry' %}" class="stack-form">
{% csrf_token %}
<div class="table-wrap app-registry-wrap">
<table class="app-registry-table">
<thead>
<tr>
<th>{% trans "Key" %}</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Bereich" %}</th>
<th>{% trans "Reihenfolge" %}</th>
<th>{% trans "Titel DE" %}</th>
<th>{% trans "Titel EN" %}</th>
<th>{% trans "Aktion DE" %}</th>
<th>{% trans "Aktion EN" %}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
<div><strong>{{ row.config.key }}</strong></div>
<div class="mini">{{ row.definition.title }}</div>
</td>
<td class="select-col">
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} />
</td>
<td>
<select name="section__{{ row.config.key }}">
{% for value, label in section_choices %}
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</td>
<td>
<input type="number" name="sort_order__{{ row.config.key }}" value="{{ row.config.sort_order }}" min="0" step="1" />
</td>
<td>
<input type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" />
<textarea name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea>
</td>
<td>
<input type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" />
<textarea name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea>
</td>
<td>
<input type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" />
</td>
<td>
<input type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" />
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="toolbar" style="margin-top:1rem;">
<div class="hint">{% trans "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter Apps oder Admin Apps einordnen." %}</div>
<button class="btn btn-primary" type="submit">{% trans "App Registry speichern" %}</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -26,6 +26,11 @@
<label for="{{ form.company_name.id_for_label }}">{{ form.company_name.label }}</label>
{{ form.company_name }}
</div>
<div class="field">
<label for="{{ form.company_domain.id_for_label }}">{{ form.company_domain.label }}</label>
{{ form.company_domain }}
<div class="hint">{% trans "Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co." %}</div>
</div>
<div class="field">
<label for="{{ form.support_email.id_for_label }}">{{ form.support_email.label }}</label>
{{ form.support_email }}

View File

@@ -102,12 +102,13 @@ docker compose exec -T web python manage.py check</code></pre>
<h3>Role and Permission Model</h3>
<ul>
<li>Stable Django group names: <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, <code>Staff</code>.</li>
<li>Stable Django group names: <code>Platform Owner</code>, <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, <code>Staff</code>.</li>
<li>Groups are created automatically through a <code>post_migrate</code> hook in <code>workflows.signals</code>.</li>
<li>Capability checks are centralized in <code>workflows.roles.CAPABILITIES</code>.</li>
<li>Use <code>_require_capability(...)</code> in views instead of flat <code>is_staff</code> checks.</li>
<li>Templates receive permission flags from <code>workflows.context_processors.role_context</code>.</li>
<li>Super-admin-only user management lives at <code>/admin-tools/users/</code> and is the preferred path for normal role assignment, account activation, invitation mail dispatch, password-reset mail dispatch, and controlled user deletion.</li>
<li><code>Platform Owner</code> is the product-level role. Company roles remain <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, and <code>Staff</code>.</li>
<li>User management lives at <code>/admin-tools/users/</code> and is the preferred path for normal role assignment, account activation, invitation mail dispatch, password-reset mail dispatch, and controlled user deletion.</li>
<li>Backward-compatibility rule: authenticated legacy users with <code>is_staff=True</code> but no explicit role group currently fall back to the <code>Admin</code> capability set.</li>
<li><code>superuser</code> accounts resolve to <code>Super Admin</code>.</li>
<li>When adding a new operational page or action, define the capability in <code>roles.py</code>, gate the view, and hide the UI affordance when the capability is absent.</li>
@@ -180,6 +181,15 @@ docker compose exec -T web django-admin compilemessages</code></pre>
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
</ul>
<h2 id="app-registry">10b) App Registry</h2>
<ul>
<li>Registry definitions live in <code>workflows/app_registry.py</code>.</li>
<li>DB overrides live in <code>PortalAppConfig</code>.</li>
<li>The landing page now renders from registry data instead of hardcoded cards.</li>
<li>Security remains code-based: app visibility/order is configurable, but access still depends on role capabilities in <code>roles.py</code>.</li>
<li>Management UI: <code>/admin-tools/apps/</code> for <code>Platform Owner</code>.</li>
</ul>
<h2 id="builders">11) Builder Architecture</h2>
<h3>Form Builder</h3>
<ul>

View File

@@ -51,147 +51,44 @@
<main class="main">
{% include 'workflows/includes/messages.html' %}
<div class="section-head section-head-primary">
<h2>{% trans "Apps" %}</h2>
<p>{% trans "Wählen Sie den gewünschten Prozess." %}</p>
</div>
<div class="apps-grid">
<section class="app-card primary">
<div>
<div class="top-line"><div class="accent">ON</div></div>
<h3 class="app-title">{% trans "Onboarding" %}</h3>
<p class="app-text">{% trans "Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen." %}</p>
<div class="tag-row">
<span class="tag">{% trans "Mehrschritt-Formular" %}</span>
<span class="tag">PDF</span>
<span class="tag">{% trans "E-Mail Routing" %}</span>
</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/onboarding/new/">{% trans "Onboarding starten" %}</a>
</div>
</section>
<section class="app-card red">
<div>
<div class="top-line"><div class="accent red">OFF</div></div>
<h3 class="app-title">{% trans "Offboarding" %}</h3>
<p class="app-text">{% trans "Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten." %}</p>
<div class="tag-row">
<span class="tag">{% trans "Profile-Suche" %}</span>
<span class="tag">{% trans "Hardware-Liste" %}</span>
<span class="tag">{% trans "IT-Rückgabe" %}</span>
</div>
</div>
<div class="card-actions">
<a class="btn btn-primary" href="/offboarding/new/">{% trans "Offboarding starten" %}</a>
</div>
</section>
{% if can_access_requests_dashboard %}
<section class="app-card">
<div>
<div class="top-line"><div class="accent">APP</div></div>
<h3 class="app-title">{% trans "Anfragen Dashboard" %}</h3>
<p class="app-text">{% trans "Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen." %}</p>
<div class="tag-row">
<span class="tag">{% trans "Suche" %}</span>
<span class="tag">{% trans "Status" %}</span>
<span class="tag">{% trans "PDF Zugriff" %}</span>
</div>
</div>
<div class="card-actions">
<a class="btn btn-secondary" href="/requests/">{% trans "Dashboard öffnen" %}</a>
</div>
</section>
{% endif %}
</div>
{% if can_manage_product_branding %}
{% for section in portal_app_sections %}
{% if not forloop.first %}
<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-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 class="section-head {{ section.css_class }}">
<h2>{{ section.title }}</h2>
<p>{{ section.subtitle }}</p>
</div>
<div class="admin-grid">
{% if can_manage_integrations %}
<div class="{{ section.grid_class }}">
{% for app in section.apps %}
{% if section.key == 'app' %}
<section class="app-card{% if app.style_variant %} {{ app.style_variant }}{% endif %}">
<div>
<div class="top-line"><div class="accent{% if app.style_variant == 'red' %} red{% endif %}">{{ app.accent }}</div></div>
<h3 class="app-title">{{ app.title }}</h3>
<p class="app-text">{{ app.description }}</p>
{% if app.tags %}
<div class="tag-row">
{% for tag in app.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-actions">
<a class="btn {% if app.style_variant == 'primary' or app.style_variant == 'red' %}btn-primary{% else %}btn-secondary{% endif %}" href="{{ app.href }}">{{ app.action_label }}</a>
</div>
</section>
{% else %}
<section class="admin-card">
<h3>{% trans "Integrationen" %}</h3>
<p>{% trans "Nextcloud- und E-Mail-Setup." %}</p>
<a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_users %}
<section class="admin-card">
<h3>{% trans "Benutzer & Rollen" %}</h3>
<p>{% trans "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." %}</p>
<a class="btn btn-secondary" href="/admin-tools/users/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_view_audit_log %}
<section class="admin-card">
<h3>{% trans "Audit Log" %}</h3>
<p>{% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}</p>
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_backups %}
<section class="admin-card">
<h3>{% trans "Backup & Recovery" %}</h3>
<p>{% trans "Backups erstellen und sicher verifizieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/backups/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_welcome_emails %}
<section class="admin-card">
<h3>{% trans "Welcome E-Mails" %}</h3>
<p>{% trans "Geplante Welcome Mails verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_builders %}
<section class="admin-card">
<h3>{% trans "Form Builder" %}</h3>
<p>{% trans "Felder, Schritte und Optionen verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/form-builder/">{% trans "Öffnen" %}</a>
</section>
<section class="admin-card">
<h3>{% trans "Einweisungs-Builder" %}</h3>
<p>{% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_view_docs %}
<section class="admin-card">
<h3>{% trans "Handbook" %}</h3>
<p>{% trans "Project wiki and developer documentation in one place." %}</p>
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_access_django_admin_link %}
<section class="admin-card">
<h3>{% trans "Django Admin" %}</h3>
<p>{% trans "Vollständige Datenverwaltung." %}</p>
<a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a>
<h3>{{ app.title }}</h3>
<p>{{ app.description }}</p>
<a class="btn btn-secondary" href="{{ app.href }}">{{ app.action_label }}</a>
</section>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
<div class="footer-note">
{% trans "Tipp: Die letzten Vorgänge sehen Sie jederzeit im Anfragen Dashboard." %}

View File

@@ -28,7 +28,7 @@
<form method="get" action="/offboarding/new/">
<div class="field">
<label for="q">{% trans "Mitarbeitende suchen (Name oder E-Mail)" %}</label>
<input id="q" name="q" value="{{ search_query }}" placeholder="{% trans "z. B. max.mustermann@tub.co" %}" />
<input id="q" name="q" value="{{ search_query }}" placeholder="{% blocktrans trimmed with domain=portal_email_domain %}z. B. max.mustermann@{{ domain }}{% endblocktrans %}" />
</div>
<button class="btn btn-primary" type="submit">{% trans "Suchen" %}</button>
</form>
@@ -47,7 +47,7 @@
</div>
<div class="card">
<form method="post">
<form method="post" data-email-domain="{{ portal_email_domain }}">
{% csrf_token %}
<div class="grid">
{% for field in form.visible_fields %}
@@ -71,4 +71,3 @@
{% block extra_scripts %}
<script src="{% static 'workflows/js/offboarding_form.js' %}"></script>
{% endblock %}

View File

@@ -42,7 +42,7 @@
<div class="error-banner">{% trans "Bitte prüfen Sie die markierten Felder. Ungültige Eingaben wurden erkannt." %}</div>
{% endif %}
<form method="post" id="onboarding-form" enctype="multipart/form-data">
<form method="post" id="onboarding-form" enctype="multipart/form-data" data-email-domain="{{ portal_email_domain }}">
{% csrf_token %}
{% for section in onboarding_sections %}
@@ -165,4 +165,3 @@
{% block extra_scripts %}
<script src="{% static 'workflows/js/onboarding_form.js' %}"></script>
{% endblock %}

View File

@@ -179,6 +179,7 @@
<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>App Registry:</strong> platform-level registry for enabling, ordering, and relabeling landing-page apps without editing the home template.</li>
<li><strong>Benutzer &amp; Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, and deleting accounts when appropriate.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<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>

View File

@@ -32,6 +32,8 @@ urlpatterns = [
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/apps/', views.portal_app_registry_page, name='portal_app_registry_page'),
path('admin-tools/apps/save/', views.save_portal_app_registry, name='save_portal_app_registry'),
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

@@ -24,8 +24,9 @@ from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language, override
from django.urls import reverse
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
from .branding import get_branding_email_copy, get_default_notification_templates
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates
from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, UserManagementCreateForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
@@ -35,7 +36,7 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER,
ensure_form_field_configs,
)
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .emailing import send_system_email
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
@@ -244,6 +245,7 @@ def _audit_action_label(action: str) -> str:
'backup_verified': _('Backup verifiziert'),
'backup_deleted': _('Backup gelöscht'),
'backup_settings_saved': _('Backup-Einstellungen gespeichert'),
'portal_app_registry_saved': _('App-Registry gespeichert'),
}
return labels.get(action, action.replace('_', ' ').strip().capitalize())
@@ -334,10 +336,57 @@ def home(request):
'email_test_mode': is_email_test_mode(),
'workflow_config': config,
'role_label': get_user_role_label(request.user),
'portal_app_sections': build_portal_app_sections(request.user),
},
)
@_require_capability('manage_app_registry')
def portal_app_registry_page(request):
return render(
request,
'workflows/app_registry.html',
{
'rows': get_portal_app_registry_rows(),
'section_choices': _translate_choice_list(PortalAppConfig.SECTION_CHOICES),
},
)
@_require_capability('manage_app_registry')
@require_POST
def save_portal_app_registry(request):
rows = get_portal_app_registry_rows()
for row in rows:
config = row['config']
key = config.key
config.section = (request.POST.get(f'section__{key}') or config.section).strip()
if config.section not in dict(PortalAppConfig.SECTION_CHOICES):
config.section = row['default_section']
config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on'
try:
config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order'])
except ValueError:
config.sort_order = row['default_sort_order']
config.title_override = (request.POST.get(f'title_override__{key}') or '').strip()
config.title_override_en = (request.POST.get(f'title_override_en__{key}') or '').strip()
config.description_override = (request.POST.get(f'description_override__{key}') or '').strip()
config.description_override_en = (request.POST.get(f'description_override_en__{key}') or '').strip()
config.action_label_override = (request.POST.get(f'action_label_override__{key}') or '').strip()
config.action_label_override_en = (request.POST.get(f'action_label_override_en__{key}') or '').strip()
config.save()
_audit(
request,
'portal_app_registry_saved',
target_type='portal_app_registry',
target_label='Portal App Registry',
details={'updated_apps': len(rows)},
)
messages.success(request, _('App-Registry gespeichert.'))
return redirect('portal_app_registry_page')
def _user_management_rows():
user_model = get_user_model()
role_order = {
@@ -1170,6 +1219,7 @@ def onboarding_create(request):
'legal_text': legal_text,
'saved': request.GET.get('saved') == '1',
'saved_request_id': request.GET.get('id', ''),
'portal_email_domain': get_company_email_domain(),
},
)
@@ -1331,7 +1381,8 @@ def offboarding_create(request):
if selected_profile:
obj.employee_profile = selected_profile
requester_email = (request.user.email or '').strip().lower()
if requester_email and requester_email.endswith('@tub.co'):
company_suffix = f"@{get_company_email_domain()}"
if requester_email and requester_email.endswith(company_suffix):
obj.requested_by_email = requester_email
else:
obj.requested_by_email = settings.DEFAULT_FROM_EMAIL
@@ -1353,6 +1404,7 @@ def offboarding_create(request):
'search_query': search_query,
'saved': request.GET.get('saved') == '1',
'saved_request_id': request.GET.get('id', ''),
'portal_email_domain': get_company_email_domain(),
},
)