snapshot: preserve integrations controls and status UX cleanup

This commit is contained in:
Md Bayazid Bostame
2026-03-26 00:06:43 +01:00
parent 197bd3c226
commit e0231a6cca
15 changed files with 1591 additions and 392 deletions

View File

@@ -1,9 +1,11 @@
from django import forms
from pathlib import Path
from django.utils.translation import get_language
from datetime import timedelta
from django.utils import timezone
from django.utils.translation import get_language, gettext as _
from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -222,6 +224,11 @@ class OnboardingRequestForm(forms.ModelForm):
self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower()
super().__init__(*args, **kwargs)
config = WorkflowConfig.objects.order_by('id').first()
self.handover_lead_days = max(0, int(getattr(config, 'device_handover_lead_days', 5) or 5))
minimum_handover_date = timezone.localdate() + timedelta(days=self.handover_lead_days)
self.fields['handover_date'].widget.attrs['min'] = minimum_handover_date.isoformat()
self.fields['full_name'].label = 'Name'
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'):
@@ -322,6 +329,18 @@ class OnboardingRequestForm(forms.ModelForm):
if has('successor_name') and cleaned.get('successor_required_choice') == 'ja' and not cleaned.get('successor_name'):
self.add_error('successor_name', 'Bitte Name der Vorgängerperson eintragen.')
handover_date = cleaned.get('handover_date')
if has('handover_date') and handover_date:
minimum_date = timezone.localdate() + timedelta(days=getattr(self, 'handover_lead_days', 5))
if handover_date < minimum_date:
self.add_error(
'handover_date',
_('Das Übergabedatum muss mindestens %(days)s Tage in der Zukunft liegen (frühestens %(date)s).') % {
'days': getattr(self, 'handover_lead_days', 5),
'date': minimum_date.strftime('%Y-%m-%d'),
},
)
return cleaned
def save(self, commit=True):

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2026-03-25 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0033_offboardingrequest_last_error_and_more'),
]
operations = [
migrations.AddField(
model_name='workflowconfig',
name='device_handover_lead_days',
field=models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)'),
),
]

View File

@@ -62,7 +62,7 @@ class OnboardingRequest(models.Model):
gender = models.CharField(
max_length=20,
blank=True,
choices=[('herr', 'Herr'), ('frau', 'Frau'), ('divers', 'Divers')],
choices=[('herr', _('Herr')), ('frau', _('Frau')), ('divers', _('Divers'))],
verbose_name='Anrede',
)
job_title = models.CharField(max_length=255, blank=True, verbose_name='Berufsbezeichnung')
@@ -72,7 +72,7 @@ class OnboardingRequest(models.Model):
employment_type = models.CharField(
max_length=20,
blank=True,
choices=[('befristet', 'befristet'), ('unbefristet', 'unbefristet')],
choices=[('befristet', _('befristet')), ('unbefristet', _('unbefristet'))],
verbose_name='Beschäftigungsverhältnis',
)
employment_end_date = models.DateField(null=True, blank=True, verbose_name='Enddatum (nur bei befristet)')
@@ -135,13 +135,13 @@ class OnboardingRequest(models.Model):
class FormOption(models.Model):
CATEGORY_CHOICES = [
('department', 'Abteilung'),
('device', 'Geräte'),
('software', 'Software'),
('access', 'Zugänge'),
('workspace_group', 'Workspace-Gruppen'),
('resource', 'Ressourcen'),
('phone', 'Telefonnummern'),
('department', _('Abteilung')),
('device', _('Geräte')),
('software', _('Software')),
('access', _('Zugänge')),
('workspace_group', _('Workspace-Gruppen')),
('resource', _('Ressourcen')),
('phone', _('Telefonnummern')),
]
category = models.CharField(max_length=40, choices=CATEGORY_CHOICES)
@@ -167,15 +167,15 @@ class FormOption(models.Model):
class FormFieldConfig(models.Model):
PAGE_CHOICES = [
('', 'Automatisch'),
('stammdaten', 'Stammdaten'),
('vertrag', 'Vertrag'),
('itsetup', 'IT-Setup'),
('abschluss', 'Abschluss'),
('', _('Automatisch')),
('stammdaten', _('Stammdaten')),
('vertrag', _('Vertrag')),
('itsetup', _('IT-Setup')),
('abschluss', _('Abschluss')),
]
FORM_CHOICES = [
('onboarding', 'Onboarding'),
('offboarding', 'Offboarding'),
('onboarding', _('Onboarding')),
('offboarding', _('Offboarding')),
]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES)
@@ -213,17 +213,17 @@ class FormFieldConfig(models.Model):
class NotificationTemplate(models.Model):
TEMPLATE_CHOICES = [
('onboarding_it', 'Onboarding: IT'),
('onboarding_general_info', 'Onboarding: Allgemeine Info'),
('onboarding_business_card', 'Onboarding: Visitenkarte'),
('onboarding_hr_works', 'Onboarding: HR Works'),
('onboarding_key', 'Onboarding: Schlüssel'),
('onboarding_reference', 'Onboarding: Referenz Anfordernde Person'),
('onboarding_welcome', 'Onboarding: Welcome E-Mail'),
('offboarding_it', 'Offboarding: IT'),
('offboarding_general_info', 'Offboarding: Allgemeine Info'),
('offboarding_hr_works_disable', 'Offboarding: HR Works Deaktivierung'),
('offboarding_reference', 'Offboarding: Referenz Anfordernde Person'),
('onboarding_it', _('Onboarding: IT')),
('onboarding_general_info', _('Onboarding: Allgemeine Info')),
('onboarding_business_card', _('Onboarding: Visitenkarte')),
('onboarding_hr_works', _('Onboarding: HR Works')),
('onboarding_key', _('Onboarding: Schlüssel')),
('onboarding_reference', _('Onboarding: Referenz Anfordernde Person')),
('onboarding_welcome', _('Onboarding: Welcome E-Mail')),
('offboarding_it', _('Offboarding: IT')),
('offboarding_general_info', _('Offboarding: Allgemeine Info')),
('offboarding_hr_works_disable', _('Offboarding: HR Works Deaktivierung')),
('offboarding_reference', _('Offboarding: Referenz Anfordernde Person')),
]
key = models.CharField(max_length=60, choices=TEMPLATE_CHOICES, unique=True)
@@ -255,15 +255,15 @@ class NotificationTemplate(models.Model):
class NotificationRule(models.Model):
EVENT_CHOICES = [
('onboarding', 'Onboarding'),
('offboarding', 'Offboarding'),
('onboarding', _('Onboarding')),
('offboarding', _('Offboarding')),
]
OPERATOR_CHOICES = [
('always', 'Immer'),
('contains', 'Enthält'),
('equals', 'Ist gleich'),
('is_true', 'Ist aktiv/Ja'),
('is_false', 'Ist inaktiv/Nein'),
('always', _('Immer')),
('contains', _('Enthält')),
('equals', _('Ist gleich')),
('is_true', _('Ist aktiv/Ja')),
('is_false', _('Ist inaktiv/Nein')),
]
name = models.CharField(max_length=120)
@@ -305,11 +305,11 @@ class NotificationRule(models.Model):
class ScheduledWelcomeEmail(models.Model):
STATUS_CHOICES = [
('scheduled', 'Geplant'),
('paused', 'Pausiert'),
('cancelled', 'Abgebrochen'),
('sent', 'Gesendet'),
('failed', 'Fehlgeschlagen'),
('scheduled', _('Geplant')),
('paused', _('Pausiert')),
('cancelled', _('Abgebrochen')),
('sent', _('Gesendet')),
('failed', _('Fehlgeschlagen')),
]
onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE)
@@ -331,17 +331,17 @@ class ScheduledWelcomeEmail(models.Model):
class IntroChecklistItem(models.Model):
SECTION_CHOICES = [
('workplace', 'Geräte und Arbeitsplatz'),
('accounts', 'Konten und Berechtigungen'),
('software', 'Software und Tools'),
('process', 'Prozesse und Hinweise'),
('workplace', _('Geräte und Arbeitsplatz')),
('accounts', _('Konten und Berechtigungen')),
('software', _('Software und Tools')),
('process', _('Prozesse und Hinweise')),
]
OPERATOR_CHOICES = [
('always', 'Immer anzeigen'),
('contains', 'Enthält'),
('equals', 'Ist gleich'),
('is_true', 'Ist Ja / aktiv'),
('is_false', 'Ist Nein / inaktiv'),
('always', _('Immer anzeigen')),
('contains', _('Enthält')),
('equals', _('Ist gleich')),
('is_true', _('Ist Ja / aktiv')),
('is_false', _('Ist Nein / inaktiv')),
]
section = models.CharField(max_length=30, choices=SECTION_CHOICES)
@@ -368,8 +368,8 @@ class IntroChecklistItem(models.Model):
class OnboardingIntroductionSession(models.Model):
STATUS_CHOICES = [
('draft', 'Entwurf'),
('completed', 'Abgeschlossen'),
('draft', _('Entwurf')),
('completed', _('Abgeschlossen')),
]
onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE)
@@ -412,6 +412,7 @@ class WorkflowConfig(models.Model):
nextcloud_password_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Passwort (Override)')
nextcloud_directory_override = models.CharField(max_length=255, blank=True, verbose_name='Nextcloud Verzeichnis (Override)')
sync_interval_seconds = models.PositiveIntegerField(default=60, verbose_name='Sync-Intervall (Sekunden)')
device_handover_lead_days = models.PositiveIntegerField(default=5, verbose_name='Vorlauf Geräteübergabe (Tage)')
welcome_email_delay_days = models.PositiveIntegerField(default=5, verbose_name='Welcome E-Mail Verzögerung (Tage)')
welcome_sender_email = models.EmailField(blank=True, verbose_name='Welcome E-Mail Absender')
welcome_include_pdf = models.BooleanField(default=True, verbose_name='Welcome E-Mail mit PDF-Anhang')

View File

@@ -11,6 +11,16 @@ label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; fon
input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; }
textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
.actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; }
.toggle-row { margin-top: 10px; display: inline-flex; align-items: center; gap: 10px; padding: 8px 10px 8px 12px; border: 1px solid #d8e3f0; border-radius: 999px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,249,255,0.94)); box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 6px 14px rgba(16, 32, 57, 0.05); }
.toggle-copy { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; font-size: 12px; font-weight: 700; color: #30445f; }
.toggle-label, .toggle-value { white-space: nowrap; }
.switch-toggle { display: inline-flex; align-items: center; justify-content: center; border: 0; background: rgba(255,255,255,0.72); padding: 3px; border-radius: 999px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,0.95); }
.switch-track { position: relative; width: 52px; height: 28px; border-radius: 999px; border: 1px solid rgba(16, 32, 57, 0.12); background: linear-gradient(180deg, #dfe6ef, #cfd7e2); transition: background 0.18s ease, border-color 0.18s ease; box-shadow: inset 0 1px 2px rgba(16, 32, 57, 0.08), inset 0 -1px 0 rgba(255,255,255,0.4); }
.switch-thumb { position: absolute; top: 2px; left: 2px; width: 22px; height: 22px; border-radius: 50%; background: linear-gradient(180deg, #ffffff, #f1f5fb); box-shadow: 0 2px 6px rgba(16, 32, 57, 0.22), inset 0 1px 0 rgba(255,255,255,0.95); transition: transform 0.18s ease; }
.switch-toggle.on .switch-track { background: linear-gradient(180deg, #2caf57, #248b44); border-color: #1f7a3b; }
.switch-toggle.on .switch-thumb { transform: translateX(24px); }
.switch-toggle.off .switch-track { background: linear-gradient(180deg, #d55252, #b83939); border-color: #9d3030; }
.switch-toggle:focus-visible .switch-track, .switch-toggle:hover .switch-track { box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.12); }
.hint { margin-top: 6px; color: #64748b; font-size: 12px; }
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.switch { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }

View File

@@ -150,6 +150,12 @@
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.status-switch-form {
margin: 0;
display: inline-flex;
}
.status-pill {
@@ -160,6 +166,125 @@
padding: 8px 12px;
font-size: 12px;
font-weight: 700;
min-height: 38px;
display: inline-flex;
align-items: center;
}
.status-pill-neutral {
background:
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,249,255,0.94));
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.9),
0 6px 14px rgba(16, 32, 57, 0.05);
}
.status-pill-switch {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 8px 8px 12px;
min-height: 38px;
background:
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,249,255,0.94));
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.9),
0 6px 14px rgba(16, 32, 57, 0.05);
}
.status-switch-copy {
display: inline-flex;
flex-direction: row;
align-items: flex-start;
gap: 4px;
min-width: 0;
line-height: 1;
}
.status-switch-label {
font-size: 12px;
font-weight: 700;
letter-spacing: normal;
text-transform: none;
opacity: 1;
}
.status-switch-value {
font-size: 12px;
font-weight: 700;
letter-spacing: normal;
}
.status-switch-label,
.status-switch-value,
.status-pill-switch .status-switch-copy {
white-space: nowrap;
}
.switch-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: rgba(255,255,255,0.72);
padding: 3px;
border-radius: 999px;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.95);
}
.switch-track {
position: relative;
width: 52px;
height: 28px;
border-radius: 999px;
border: 1px solid rgba(16, 32, 57, 0.12);
background: linear-gradient(180deg, #dfe6ef, #cfd7e2);
transition: background 0.18s ease, border-color 0.18s ease;
box-shadow:
inset 0 1px 2px rgba(16, 32, 57, 0.08),
inset 0 -1px 0 rgba(255,255,255,0.4);
}
.switch-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 22px;
height: 22px;
border-radius: 50%;
background: linear-gradient(180deg, #ffffff, #f1f5fb);
box-shadow:
0 2px 6px rgba(16, 32, 57, 0.22),
inset 0 1px 0 rgba(255,255,255,0.95);
transition: transform 0.18s ease;
}
.switch-toggle.on .switch-track {
background: linear-gradient(180deg, #2caf57, #248b44);
border-color: #1f7a3b;
}
.switch-toggle.on .switch-thumb {
transform: translateX(24px);
}
.switch-toggle.off .switch-track {
background: linear-gradient(180deg, #d55252, #b83939);
border-color: #9d3030;
}
.switch-toggle:focus-visible .switch-track,
.switch-toggle:hover .switch-track {
box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.12);
}
.status-pill-switch.ok .status-switch-value {
color: var(--ok-ink);
}
.status-pill-switch.warn .status-switch-value {
color: var(--warn-ink);
}
.status-pill.ok {

View File

@@ -8,7 +8,7 @@ from celery import shared_task
from django.contrib.auth import get_user_model
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext as _, get_language
from django.utils.translation import gettext as _, get_language, override
from jinja2 import Template
from pypdf import PageObject, PdfReader, PdfWriter
from xhtml2pdf import pisa
@@ -570,88 +570,64 @@ def _build_intro_sections_from_admin(request_obj: OnboardingRequest, language_co
def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]:
lang = _normalized_lang(language_code or get_language())
section_titles = {
'de': {
'workplace': 'Geräte und Arbeitsplatz',
'accounts': 'Konten und Berechtigungen',
'software': 'Software und Tools',
'process': 'Prozesse und Hinweise',
},
'en': {
'workplace': 'Devices and workplace',
'accounts': 'Accounts and permissions',
'software': 'Software and tools',
'process': 'Processes and notes',
},
}
devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software)
accesses = _split_multiline(request_obj.needed_accesses)
groups = _split_multiline(request_obj.needed_workspace_groups)
resources = _split_multiline(request_obj.needed_resources)
extra_hardware = _split_multiline(request_obj.additional_hardware)
extra_software = _split_multiline(request_obj.additional_software)
group_mailboxes = _split_multiline(request_obj.group_mailboxes)
with override(lang):
section_titles = {
'workplace': _('Geräte und Arbeitsplatz'),
'accounts': _('Konten und Berechtigungen'),
'software': _('Software und Tools'),
'process': _('Prozesse und Hinweise'),
}
devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software)
accesses = _split_multiline(request_obj.needed_accesses)
groups = _split_multiline(request_obj.needed_workspace_groups)
resources = _split_multiline(request_obj.needed_resources)
extra_hardware = _split_multiline(request_obj.additional_hardware)
extra_software = _split_multiline(request_obj.additional_software)
group_mailboxes = _split_multiline(request_obj.group_mailboxes)
workplace_items = []
for item in devices:
if lang == 'en':
workplace_items.append(f'{item} handed over and basic functions explained')
else:
workplace_items.append(f'{item} übergeben und Grundfunktionen erklärt')
for item in resources:
if lang == 'en':
workplace_items.append(f'{item} shown or usage explained')
else:
workplace_items.append(f'{item} gezeigt bzw. Nutzung erklärt')
if request_obj.phone_number:
if lang == 'en':
workplace_items.append(f'Phone number / direct extension explained: {request_obj.phone_number}')
else:
workplace_items.append(f'Telefonnummer / Direktwahl erklärt: {request_obj.phone_number}')
if not workplace_items:
workplace_items.append('Workplace, devices, and general usage reviewed' if lang == 'en' else 'Arbeitsplatz, Geräte und allgemeine Nutzung besprochen')
workplace_items = []
for item in devices:
workplace_items.append(_('%(item)s übergeben und Grundfunktionen erklärt') % {'item': item})
for item in resources:
workplace_items.append(_('%(item)s gezeigt bzw. Nutzung erklärt') % {'item': item})
if request_obj.phone_number:
workplace_items.append(_('Telefonnummer / Direktwahl erklärt: %(value)s') % {'value': request_obj.phone_number})
if not workplace_items:
workplace_items.append(_('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen'))
account_items = [f'{item} access explained' if lang == 'en' else f'{item} Zugang erklärt' for item in accesses]
account_items.extend([f'{item} group / permission explained' if lang == 'en' else f'{item} Gruppe / Berechtigung erläutert' for item in groups])
if request_obj.work_email:
account_items.insert(0, f'Work email address explained: {request_obj.work_email}' if lang == 'en' else f'Dienstliche E-Mail-Adresse erläutert: {request_obj.work_email}')
if group_mailboxes:
account_items.extend([f'Group mailbox explained: {item}' if lang == 'en' else f'Gruppenpostfach erklärt: {item}' for item in group_mailboxes])
if not account_items:
account_items.append('Accesses, accounts, and login logic reviewed' if lang == 'en' else 'Zugänge, Konten und Anmeldelogik besprochen')
account_items = [_('%(item)s Zugang erklärt') % {'item': item} for item in accesses]
account_items.extend([_('%(item)s Gruppe / Berechtigung erläutert') % {'item': item} for item in groups])
if request_obj.work_email:
account_items.insert(0, _('Dienstliche E-Mail-Adresse erläutert: %(value)s') % {'value': request_obj.work_email})
if group_mailboxes:
account_items.extend([_('Gruppenpostfach erklärt: %(item)s') % {'item': item} for item in group_mailboxes])
if not account_items:
account_items.append(_('Zugänge, Konten und Anmeldelogik besprochen'))
software_items = [f'{item} introduction completed' if lang == 'en' else f'{item} Einführung durchgeführt' for item in software]
software_items.extend([f'{item} discussed additionally' if lang == 'en' else f'{item} zusätzlich besprochen' for item in extra_software])
if not software_items:
software_items.append('Required standard software and daily usage explained' if lang == 'en' else 'Benötigte Standardsoftware und tägliche Nutzung erklärt')
software_items = [_('%(item)s Einführung durchgeführt') % {'item': item} for item in software]
software_items.extend([_('%(item)s zusätzlich besprochen') % {'item': item} for item in extra_software])
if not software_items:
software_items.append(_('Benötigte Standardsoftware und tägliche Nutzung erklärt'))
process_items = (
[
'Password rules and secure handling reviewed',
'File storage, Nextcloud, and sharing explained',
'Communication channels and support process explained',
process_items = [
_('Passwortregeln und sicherer Umgang besprochen'),
_('Dateiablage, Nextcloud und Freigaben erklärt'),
_('Kommunikationswege und Support-Prozess erklärt'),
]
if lang == 'en'
else [
'Passwortregeln und sicherer Umgang besprochen',
'Dateiablage, Nextcloud und Freigaben erklärt',
'Kommunikationswege und Support-Prozess erklärt',
]
)
if extra_hardware:
process_items.extend([f'{item} discussed as additional equipment' if lang == 'en' else f'{item} als zusätzliche Ausstattung besprochen' for item in extra_hardware])
if request_obj.additional_access_text:
process_items.extend([f'Additional access discussed: {item}' if lang == 'en' else f'Zusätzlicher Zugang besprochen: {item}' for item in _split_multiline(request_obj.additional_access_text)])
if request_obj.successor_name:
process_items.append(f'Handover / successor context reviewed: {request_obj.successor_name}' if lang == 'en' else f'Übergabe-/Nachfolgekontext besprochen: {request_obj.successor_name}')
if extra_hardware:
process_items.extend([_('%(item)s als zusätzliche Ausstattung besprochen') % {'item': item} for item in extra_hardware])
if request_obj.additional_access_text:
process_items.extend([_('Zusätzlicher Zugang besprochen: %(item)s') % {'item': item} for item in _split_multiline(request_obj.additional_access_text)])
if request_obj.successor_name:
process_items.append(_('Übergabe-/Nachfolgekontext besprochen: %(value)s') % {'value': request_obj.successor_name})
custom_intro_items = _build_intro_sections_from_admin(request_obj, lang)
intro_sections_raw = [
('workplace', section_titles.get(lang, section_titles['de'])['workplace'], workplace_items),
('accounts', section_titles.get(lang, section_titles['de'])['accounts'], account_items),
('software', section_titles.get(lang, section_titles['de'])['software'], software_items),
('process', section_titles.get(lang, section_titles['de'])['process'], process_items),
('workplace', section_titles['workplace'], workplace_items),
('accounts', section_titles['accounts'], account_items),
('software', section_titles['software'], software_items),
('process', section_titles['process'], process_items),
]
sections = []

View File

@@ -67,14 +67,28 @@
<td>
{% if row.target_label %}
{{ row.target_label }}
{% if row.target_id %}<div class="hint">#{{ row.target_id }}</div>{% endif %}
{% elif row.target_id %}
#{{ row.target_id }}
<span class="hint">ID {{ row.target_id }}</span>
{% else %}
-
{% endif %}
</td>
<td><code>{{ row.details|default:"{}" }}</code></td>
<td>
{% if row.details.request_labels %}
<div class="hint">{% trans "Betroffene Vorgänge" %}</div>
<ul class="hint" style="margin:6px 0 10px 18px; padding:0;">
{% for label in row.details.request_labels %}
<li>{{ label }}</li>
{% endfor %}
</ul>
{% endif %}
{% if row.details.deleted_count %}<div class="hint">{% trans "Gelöscht" %}: {{ row.details.deleted_count }}</div>{% endif %}
{% if row.details.invalid_count %}<div class="hint">{% trans "Ungültig" %}: {{ row.details.invalid_count }}</div>{% endif %}
{% if row.details.result %}<div class="hint">{% trans "Ergebnis" %}: {{ row.details.result }}</div>{% endif %}
{% if not row.details.request_labels and not row.details.deleted_count and not row.details.invalid_count and not row.details.result %}
<code>{{ row.details|default:"{}" }}</code>
{% endif %}
</td>
</tr>
{% empty %}
<tr>

View File

@@ -35,14 +35,14 @@
<h1>{% trans "TUBCO Onboarding & Offboarding Portal" %}</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">{% trans "Rolle:" %} {% if request.user.is_staff %}{% trans "Admin" %}{% else %}{% trans "Mitarbeiter" %}{% endif %}</span>
<span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {% if request.user.is_staff %}{% trans "Admin" %}{% else %}{% trans "Mitarbeiter" %}{% endif %}</span>
<span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}">
{% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}
</span>
<span class="status-pill {% if email_test_mode %}warn{% else %}ok{% endif %}">
{% trans "E-Mail:" %} {% if email_test_mode %}{% trans "Testmodus" %}{% else %}{% trans "Produktion" %}{% endif %}
</span>
<span class="status-pill">{% trans "PDF + E-Mail Workflow bereit" %}</span>
<span class="status-pill status-pill-neutral">{% trans "PDF + E-Mail Workflow bereit" %}</span>
</div>
</div>
</div>
@@ -116,6 +116,21 @@
</div>
<div class="admin-grid">
<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>
<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>
<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>
<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>
@@ -131,64 +146,10 @@
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section>
<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>
<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>
<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>
<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>
</section>
<section class="admin-card">
<h3>SMTP Einstellungen</h3>
<p>Server und Absender in der Backend-UI.</p>
<a class="btn btn-secondary" href="/admin/workflows/systememailconfig/">Öffnen</a>
</section>
<section class="admin-card">
<h3>Nextcloud schalten</h3>
<p>Aktiv/Inaktiv direkt umschalten.</p>
<form method="post" action="/admin-tools/nextcloud/toggle/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">
{% if nextcloud_enabled %}Deaktivieren{% else %}Aktivieren{% endif %}
</button>
</form>
</section>
<section class="admin-card">
<h3>E-Mail Modus</h3>
<p>Zwischen Testmodus und Produktion wechseln.</p>
<form method="post" action="/admin-tools/email-mode/toggle/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">
Auf {% if email_test_mode %}Produktion{% else %}Testmodus{% endif %}
</button>
</form>
</section>
<section class="admin-card">
<h3>Verbindungstests</h3>
<p>Testupload und Testmail auslösen.</p>
<div class="card-actions">
<form method="post" action="/test/nextcloud/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">Nextcloud-Test</button>
</form>
<form method="post" action="/test/email/" style="display:inline;">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">SMTP-Test</button>
</form>
</div>
</section>
</div>
{% endif %}

View File

@@ -18,6 +18,7 @@
<a class="tab {% if kind == 'nextcloud' %}active{% endif %}" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Setup Nextcloud" %}</a>
<a class="tab {% if kind == 'mail' %}active{% endif %}" href="/admin-tools/integrations/?kind=mail">{% trans "Setup Mail" %}</a>
<a class="tab {% if kind == 'emails' %}active{% endif %}" href="/admin-tools/integrations/?kind=emails">{% trans "E-Mail Routing & Vorlagen" %}</a>
<a class="tab {% if kind == 'rules' %}active{% endif %}" href="/admin-tools/integrations/?kind=rules">{% trans "Workflow-Regeln" %}</a>
</div>
{% if messages %}
@@ -29,6 +30,7 @@
{% if kind == 'nextcloud' %}
<form class="card" method="post" action="/admin-tools/integrations/save-nextcloud/">
{% csrf_token %}
<input type="hidden" name="next" value="/admin-tools/integrations/?kind=nextcloud" />
<div class="grid">
<div>
<label for="nc_base">NEXTCLOUD_BASE_URL</label>
@@ -53,14 +55,32 @@
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Nextcloud speichern" %}</button>
<button class="btn btn-secondary" type="submit" formaction="/test/nextcloud/">{% trans "Nextcloud-Test starten" %}</button>
</div>
<div class="toggle-row">
<span class="toggle-copy">
<span class="toggle-label">{% trans "Status:" %}</span>
<span class="toggle-value">{% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}</span>
</span>
<button class="switch-toggle {% if nextcloud_enabled %}on{% else %}off{% endif %}" type="submit" form="toggle-nextcloud-form" aria-label="{% trans 'Nextcloud schalten' %}">
<span class="switch-track">
<span class="switch-thumb"></span>
</span>
</button>
</div>
<div class="hint">{% trans "Schaltet den produktiven Nextcloud-Upload sofort für alle nachfolgenden Vorgänge ein oder aus." %}</div>
<div class="hint">{% trans "Leeres Passwortfeld lässt das bestehende Passwort unverändert." %}</div>
</form>
<form id="toggle-nextcloud-form" method="post" action="/admin-tools/nextcloud/toggle/">
{% csrf_token %}
<input type="hidden" name="next" value="/admin-tools/integrations/?kind=nextcloud" />
</form>
{% endif %}
{% if kind == 'mail' %}
<form class="card" method="post" action="/admin-tools/integrations/save-mail/">
{% csrf_token %}
<input type="hidden" name="next" value="/admin-tools/integrations/?kind=mail" />
<div class="grid">
<div>
<label for="imap_server">IMAP_SERVER</label>
@@ -86,6 +106,10 @@
<label for="email_password">PASSWORD</label>
<input id="email_password" name="email_password" type="password" placeholder="Leer lassen = unverändert" />
</div>
<div>
<label for="from_email">{% trans "Absenderadresse" %}</label>
<input id="from_email" name="from_email" value="{% if system_email_config %}{{ system_email_config.from_email }}{% endif %}" />
</div>
</div>
<div class="check-row">
<label><input type="checkbox" name="smtp_use_ssl" {% if workflow_config.smtp_use_ssl %}checked{% endif %} /> {% trans "SMTP SSL" %}</label>
@@ -93,9 +117,26 @@
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Mail speichern" %}</button>
<button class="btn btn-secondary" type="submit" formaction="/test/email/">{% trans "SMTP-Test starten" %}</button>
</div>
<div class="toggle-row">
<span class="toggle-copy">
<span class="toggle-label">{% trans "Status:" %}</span>
<span class="toggle-value">{% if email_test_mode %}{% trans "Testmodus" %}{% else %}{% trans "Produktion" %}{% endif %}</span>
</span>
<button class="switch-toggle {% if email_test_mode %}off{% else %}on{% endif %}" type="submit" form="toggle-email-mode-form" aria-label="{% trans 'E-Mail Modus schalten' %}">
<span class="switch-track">
<span class="switch-thumb"></span>
</span>
</button>
</div>
<div class="hint">{% trans "Im Testmodus werden Systemmails umgeleitet. In Produktion werden sie an die echten Empfänger gesendet." %}</div>
<div class="hint">{% trans "Leeres Passwortfeld lässt das bestehende Passwort unverändert." %}</div>
</form>
<form id="toggle-email-mode-form" method="post" action="/admin-tools/email-mode/toggle/">
{% csrf_token %}
<input type="hidden" name="next" value="/admin-tools/integrations/?kind=mail" />
</form>
{% endif %}
{% if kind == 'emails' %}
@@ -302,5 +343,28 @@
</div>
</form>
{% endif %}
{% endblock %}
{% if kind == 'rules' %}
<form class="card" method="post" action="/admin-tools/integrations/save-workflow-rules/">
{% csrf_token %}
<div class="grid">
<div>
<label for="device_handover_lead_days">{% trans "Vorlauf Hardware-Übergabe (Tage)" %}</label>
<input
id="device_handover_lead_days"
name="device_handover_lead_days"
type="number"
min="0"
step="1"
value="{{ workflow_config.device_handover_lead_days }}"
/>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Workflow-Regeln speichern" %}</button>
</div>
<div class="hint">{% trans "Steuert den Mindestvorlauf für das gewünschte Übergabedatum der Geräte im Onboarding-Formular." %}</div>
</form>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,134 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Request Timeline" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
<style>
.timeline-summary-grid { display:grid; grid-template-columns:repeat(5, minmax(0,1fr)); gap:16px; margin-bottom:20px; }
.timeline-stat { border:1px solid #d9e3f8; border-radius:20px; padding:16px 18px; background:linear-gradient(180deg,#ffffff 0%,#f7faff 100%); box-shadow:0 18px 40px rgba(23,39,90,.08); }
.timeline-stat label { display:block; margin-bottom:6px; font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#7081a5; }
.timeline-stat strong { display:block; font-size:18px; line-height:1.35; color:#20345f; }
.timeline-list { position:relative; display:grid; gap:18px; padding-left:28px; }
.timeline-list::before { content:""; position:absolute; top:6px; bottom:6px; left:11px; width:2px; background:linear-gradient(180deg,#cad8f8 0%,#e8eefc 100%); }
.timeline-item { position:relative; border:1px solid #d9e3f8; border-radius:20px; padding:18px 20px 18px 22px; background:#fff; box-shadow:0 18px 40px rgba(23,39,90,.08); }
.timeline-item::before { content:""; position:absolute; top:22px; left:-24px; width:16px; height:16px; border-radius:999px; background:#1e2bb8; box-shadow:0 0 0 4px #eef3ff; }
.timeline-item[data-kind="document"]::before { background:#1f7a45; }
.timeline-item[data-kind="audit"]::before { background:#6a5acd; }
.timeline-item[data-kind="session"]::before { background:#b86b12; }
.timeline-item[data-kind="email"]::before { background:#c2354e; }
.timeline-item[data-kind="milestone"]::before { background:#0d7c88; }
.timeline-head { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:8px; }
.timeline-title-wrap { display:grid; gap:8px; }
.timeline-kind { display:inline-flex; align-items:center; width:max-content; padding:6px 10px; border-radius:999px; font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; background:#eef3ff; color:#27407a; }
.timeline-item[data-kind="document"] .timeline-kind { background:#edf8f0; color:#20623c; }
.timeline-item[data-kind="audit"] .timeline-kind { background:#f2efff; color:#5b49aa; }
.timeline-item[data-kind="session"] .timeline-kind { background:#fff2df; color:#8a560a; }
.timeline-item[data-kind="email"] .timeline-kind { background:#ffe8ee; color:#9f2749; }
.timeline-item[data-kind="milestone"] .timeline-kind { background:#e8fbfd; color:#0e6a72; }
.timeline-stamp { font-size:13px; color:#6b7a9b; white-space:nowrap; }
.timeline-title { margin:0; font-size:20px; color:#20345f; }
.timeline-summary { margin:0; font-size:15px; color:#22324d; line-height:1.55; }
.timeline-meta { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
.timeline-chip { display:inline-flex; align-items:center; padding:6px 10px; border-radius:999px; background:#f7faff; border:1px solid #d8e1f5; color:#4d6087; font-size:12px; }
.timeline-actions { margin-top:14px; }
.timeline-details { margin-top:14px; padding-top:14px; border-top:1px dashed #d7e0f5; display:grid; gap:8px; }
.timeline-detail-row { display:grid; grid-template-columns:160px 1fr; gap:12px; font-size:13px; }
.timeline-detail-row strong { color:#566886; }
.timeline-detail-list { margin:0; padding-left:18px; color:#4f617f; }
@media (max-width: 1160px) { .timeline-summary-grid { grid-template-columns:repeat(3, minmax(0,1fr)); } }
@media (max-width: 820px) { .timeline-summary-grid { grid-template-columns:repeat(2, minmax(0,1fr)); } }
@media (max-width: 700px) { .timeline-summary-grid { grid-template-columns:1fr; } .timeline-head { flex-direction:column; } .timeline-stamp { white-space:normal; } .timeline-detail-row { grid-template-columns:1fr; } }
</style>
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_dashboard=1 header_inside_shell=1 %}
<div class="toolbar">
<div>
<h1>{% trans "Request Timeline" %}</h1>
<p class="sub">{{ request_label }}</p>
</div>
<div class="actions">
<a class="btn btn-secondary" href="/requests/">{% trans "Zum Dashboard" %}</a>
</div>
</div>
<div class="card">
<div class="timeline-summary-grid">
<div class="timeline-stat">
<label>{% trans "Typ" %}</label>
<strong>{{ request_kind|capfirst }}</strong>
</div>
<div class="timeline-stat">
<label>{% trans "Name" %}</label>
<strong>{{ request_obj.full_name }}</strong>
</div>
<div class="timeline-stat">
<label>{% trans "Status" %}</label>
<strong>{{ request_obj.get_processing_status_display }}</strong>
</div>
<div class="timeline-stat">
<label>{% trans "E-Mail" %}</label>
<strong>{{ request_obj.work_email }}</strong>
</div>
<div class="timeline-stat">
<label>{% trans "Hardware-Übergabetermin" %}</label>
<strong>{% if handover_date %}{{ handover_date|date:"Y-m-d" }}{% else %}-{% endif %}</strong>
</div>
</div>
<div class="timeline-list">
{% for row in timeline_rows %}
<article class="timeline-item" data-kind="{{ row.kind }}">
<div class="timeline-head">
<div class="timeline-title-wrap">
<span class="timeline-kind">{{ row.kind }}</span>
<h2 class="timeline-title">{{ row.title }}</h2>
</div>
<div class="timeline-stamp">{{ row.created_at|date:"Y-m-d H:i:s" }}</div>
</div>
<p class="timeline-summary">{{ row.summary }}</p>
{% if row.meta %}
<div class="timeline-meta">
<span class="timeline-chip">{{ row.meta }}</span>
</div>
{% endif %}
{% if row.url %}
<div class="timeline-actions">
<a class="btn btn-secondary" href="{{ row.url }}" target="_blank" rel="noopener">{% trans "PDF öffnen" %}</a>
</div>
{% endif %}
{% if row.details %}
<div class="timeline-details">
{% for key, value in row.details.items %}
<div class="timeline-detail-row">
<strong>{{ key }}</strong>
<div>
{% if key == 'request_labels' and value %}
<ul class="timeline-detail-list">
{% for item in value %}
<li>{{ item }}</li>
{% endfor %}
</ul>
{% else %}
{{ value }}
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
</article>
{% empty %}
<div class="empty-state">{% trans "Noch keine Timeline-Einträge vorhanden." %}</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -134,7 +134,6 @@
{% endif %}
</div>
</form>
<div class="search-help">{% trans "Datensätze können direkt in der Tabelle gefiltert, geöffnet, geprüft oder gelöscht werden." %}</div>
</div>
{% if request.user.is_staff %}
<div class="control-stack">
@@ -157,7 +156,6 @@
<th>{% trans "Typ" %}</th>
<th>{% trans "Person" %}</th>
<th>{% trans "E-Mail" %}</th>
<th>{% trans "Erstellt" %}</th>
<th>{% trans "Dokument" %}</th>
{% if request.user.is_staff %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if request.user.is_staff %}<th>{% trans "Aktion" %}</th>{% endif %}
@@ -182,16 +180,17 @@
<td>
<a class="mail-link" href="mailto:{{ row.work_email }}">{{ row.work_email }}</a>
</td>
<td>{{ row.created_at|date:"Y-m-d H:i" }}</td>
<td>
{% if row.pdf_url %}
<a class="doc-link" href="{{ row.pdf_url }}" target="_blank" rel="noopener">{% trans "PDF öffnen" %}</a>
{% else %}
<span class="person-meta">{% trans "Noch nicht verfügbar" %}</span>
{% endif %}
<div class="person-meta" style="margin-top:8px;">{{ row.status }}</div>
{% if row.status_key == 'failed' and row.last_error %}
{% if row.status_key == 'failed' %}
<div class="person-meta" style="margin-top:8px; color:#8e1e1e;">{% trans "Fehlgeschlagen" %}</div>
{% if row.last_error %}
<div class="person-meta" style="margin-top:6px; color:#8e1e1e;">{{ row.last_error|truncatechars:140 }}</div>
{% endif %}
{% endif %}
</td>
{% if request.user.is_staff %}
@@ -236,6 +235,7 @@
{% endif %}
</td>
<td class="actions-cell">
<a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a>
{% if row.status_key == 'failed' %}
<form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" onsubmit="return confirm('Eintrag erneut verarbeiten?');">
{% csrf_token %}

View File

@@ -20,6 +20,7 @@ urlpatterns = [
path('admin-tools/integrations/save-mail/', views.save_mail_settings, name='save_mail_settings'),
path('admin-tools/integrations/save-emails/', views.save_email_routing_settings, name='save_email_routing_settings'),
path('admin-tools/integrations/save-rules/', views.save_notification_rules, name='save_notification_rules'),
path('admin-tools/integrations/save-workflow-rules/', views.save_workflow_rules, name='save_workflow_rules'),
path('admin-tools/welcome-emails/', views.welcome_emails_page, name='welcome_emails_page'),
path('admin-tools/welcome-emails/settings/', views.save_welcome_email_settings, name='save_welcome_email_settings'),
path('admin-tools/welcome-emails/bulk-action/', views.bulk_welcome_email_action, name='bulk_welcome_email_action'),
@@ -40,4 +41,5 @@ urlpatterns = [
path('requests/onboarding/<int:request_id>/intro-pdf/generate/', views.generate_onboarding_intro_pdf, name='generate_onboarding_intro_pdf'),
path('requests/delete/<str:kind>/<int:request_id>/', views.delete_request_from_dashboard, name='delete_request_from_dashboard'),
path('requests/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
path('requests/timeline/<str:kind>/<int:request_id>/', views.request_timeline_page, name='request_timeline_page'),
]

View File

@@ -16,7 +16,7 @@ from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils import timezone
from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language
from django.utils.translation import get_language, override
from .forms import OffboardingRequestForm, OnboardingRequestForm
from .form_builder import (
@@ -27,7 +27,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, ScheduledWelcomeEmail, WorkflowConfig
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .emailing import send_system_email
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import (
@@ -40,6 +40,16 @@ from .tasks import (
send_scheduled_welcome_email,
)
def _redirect_back(request, fallback: str):
target = (request.POST.get('next') or request.GET.get('next') or '').strip()
if target.startswith('/'):
return redirect(target)
referer = (request.META.get('HTTP_REFERER') or '').strip()
if referer.startswith('http://127.0.0.1') or referer.startswith('http://localhost') or referer.startswith('/'):
return redirect(referer)
return redirect(fallback)
ONBOARDING_GROUPS = {
'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'],
'employment-end-box': ['employment_end_date'],
@@ -147,16 +157,70 @@ def _form_field_labels(form_type: str) -> dict[str, str]:
return {}
def _request_status_label(status_key: str) -> str:
labels = {
'submitted': _('Eingereicht'),
'processing': _('In Bearbeitung'),
'completed': _('Abgeschlossen'),
'failed': _('Fehlgeschlagen'),
}
def _request_target_label(obj, kind: str | None = None) -> str:
request_kind = (kind or '').strip()
if not request_kind:
request_kind = 'onboarding' if isinstance(obj, OnboardingRequest) else 'offboarding'
name = (getattr(obj, 'full_name', '') or '').strip() or f'#{getattr(obj, "id", "?")}'
email = (getattr(obj, 'work_email', '') or '').strip()
created_at = getattr(obj, 'created_at', None)
date_label = created_at.strftime('%Y-%m-%d') if created_at else ''
parts = [request_kind.capitalize(), name]
if email:
parts.append(f'<{email}>')
if date_label:
parts.append(date_label)
return ' | '.join(parts)
def _request_status_label(status_key: str, language_code: str | None = None) -> str:
lang = ((language_code or 'de').split('-')[0] or 'de').lower()
with override(lang):
labels = {
'submitted': _('Eingereicht'),
'processing': _('In Bearbeitung'),
'completed': _('Abgeschlossen'),
'failed': _('Fehlgeschlagen'),
}
return labels.get(status_key, status_key)
def _audit_action_label(action: str) -> str:
labels = {
'requests_deleted': _('Vorgänge gelöscht'),
'request_deleted': _('Vorgang gelöscht'),
'request_retried': _('Vorgang erneut angestoßen'),
'intro_pdf_generated': _('Einweisungs-PDF erzeugt'),
'intro_live_pdf_generated': _('Live-Protokoll erzeugt'),
'intro_session_reset': _('Einweisung zurückgesetzt'),
'intro_session_saved': _('Einweisung als Entwurf gespeichert'),
'intro_session_completed': _('Einweisung abgeschlossen'),
'form_option_deleted': _('Formularoption gelöscht'),
'form_options_saved': _('Formularoptionen gespeichert'),
'form_field_texts_saved': _('Feldtexte gespeichert'),
'form_layout_saved': _('Formularlayout gespeichert'),
'intro_checklist_item_deleted': _('Einweisungs-Checkpunkt gelöscht'),
'intro_checklist_item_added': _('Einweisungs-Checkpunkt hinzugefügt'),
'intro_checklist_saved': _('Einweisungs-Checkliste gespeichert'),
'welcome_email_triggered_now': _('Welcome E-Mail sofort ausgelöst'),
'welcome_email_settings_saved': _('Welcome E-Mail Einstellungen gespeichert'),
'welcome_email_bulk_action': _('Welcome E-Mail Sammelaktion ausgeführt'),
'welcome_email_paused': _('Welcome E-Mail pausiert'),
'welcome_email_resumed': _('Welcome E-Mail fortgesetzt'),
'welcome_email_cancelled': _('Welcome E-Mail abgebrochen'),
'smtp_test_sent': _('SMTP-Test gesendet'),
'nextcloud_test_upload': _('Nextcloud-Testupload ausgeführt'),
'nextcloud_mode_toggled': _('Nextcloud-Modus umgeschaltet'),
'email_mode_toggled': _('E-Mail-Modus umgeschaltet'),
'integrations_saved': _('Integrationen gespeichert'),
'nextcloud_settings_saved': _('Nextcloud-Einstellungen gespeichert'),
'mail_settings_saved': _('Mail-Einstellungen gespeichert'),
'email_routing_saved': _('E-Mail-Routing gespeichert'),
'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'),
}
return labels.get(action, action.replace('_', ' ').strip().capitalize())
def _translate_choice_list(choices):
return [(value, str(label)) for value, label in choices]
@@ -311,6 +375,124 @@ def audit_log_page(request):
)
@login_required
@user_passes_test(_is_staff)
def request_timeline_page(request, kind: str, request_id: int):
if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id)
elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id)
else:
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')
request_label = _request_target_label(obj, kind)
audit_rows = list(
AdminAuditLog.objects.select_related('actor')
.filter(target_type__in=[kind, 'request'])
.filter(Q(target_id=request_id) | Q(target_label__icontains=(obj.full_name or '').strip()))
.order_by('-created_at', '-id')[:200]
)
timeline_rows = [
{
'created_at': obj.created_at,
'kind': 'system',
'title': _('Anfrage erstellt'),
'summary': request_label,
'meta': _('Status: %(status)s') % {'status': obj.get_processing_status_display()},
}
]
contract_start = getattr(obj, 'contract_start', None)
if contract_start:
timeline_rows.append(
{
'created_at': timezone.make_aware(timezone.datetime.combine(contract_start, timezone.datetime.min.time())),
'kind': 'milestone',
'title': _('Vertragsbeginn'),
'summary': str(contract_start),
'meta': _('Geplanter Start'),
}
)
handover_date = getattr(obj, 'handover_date', None)
if handover_date:
timeline_rows.append(
{
'created_at': timezone.make_aware(timezone.datetime.combine(handover_date, timezone.datetime.min.time())),
'kind': 'milestone',
'title': _('Geräteübergabe / Hardware-Abholung'),
'summary': str(handover_date),
'meta': _('Geplanter Hardware-Termin'),
}
)
if getattr(obj, 'generated_pdf_path', ''):
timeline_rows.append(
{
'created_at': obj.created_at,
'kind': 'document',
'title': _('PDF verfügbar'),
'summary': Path(obj.generated_pdf_path).name,
'meta': '',
'url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}",
}
)
for row in audit_rows:
timeline_rows.append(
{
'created_at': row.created_at,
'kind': 'audit',
'title': _audit_action_label(row.action),
'summary': row.target_label or row.target_type or '-',
'meta': row.actor_display or '-',
'details': row.details,
}
)
if kind == 'onboarding':
intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first()
if intro_session:
timeline_rows.append(
{
'created_at': intro_session.updated_at,
'kind': 'session',
'title': _('Einweisungssitzung'),
'summary': intro_session.get_status_display(),
'meta': intro_session.completed_by_name or '-',
'url': (f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" if intro_session.exported_pdf_path else ''),
}
)
welcome_email = ScheduledWelcomeEmail.objects.filter(onboarding_request=obj).first()
if welcome_email:
timeline_rows.append(
{
'created_at': welcome_email.updated_at,
'kind': 'email',
'title': _('Welcome E-Mail'),
'summary': welcome_email.get_status_display(),
'meta': welcome_email.recipient_email,
}
)
timeline_rows.sort(key=lambda item: item['created_at'])
return render(
request,
'workflows/request_timeline.html',
{
'request_kind': kind,
'request_obj': obj,
'request_label': request_label,
'timeline_rows': timeline_rows,
'contract_start': getattr(obj, 'contract_start', None),
'handover_date': getattr(obj, 'handover_date', None),
},
)
@login_required
def requests_dashboard(request):
if request.method == 'POST':
@@ -329,6 +511,7 @@ def requests_dashboard(request):
deleted_count = 0
invalid_count = 0
deleted_labels = []
for token in selected:
try:
kind, raw_id = token.split(':', 1)
@@ -349,6 +532,7 @@ def requests_dashboard(request):
obj = model.objects.filter(id=request_id).first()
if not obj:
continue
deleted_labels.append(_request_target_label(obj, kind))
obj.delete()
deleted_count += 1
@@ -358,7 +542,12 @@ def requests_dashboard(request):
'requests_deleted',
target_type='request',
target_label='Dashboard bulk/single delete',
details={'deleted_count': deleted_count, 'invalid_count': invalid_count, 'selected': selected},
details={
'deleted_count': deleted_count,
'invalid_count': invalid_count,
'selected': selected,
'request_labels': deleted_labels,
},
)
messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count})
if invalid_count:
@@ -376,6 +565,12 @@ def requests_dashboard(request):
onboarding_items = onboarding_qs[:50]
offboarding_items = offboarding_qs[:50]
language_code = (
request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
or getattr(request, 'LANGUAGE_CODE', '')
or get_language()
or 'de'
).split('-')[0].lower()
rows = []
for obj in onboarding_items:
@@ -393,7 +588,7 @@ def requests_dashboard(request):
'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': f"/media/pdfs/{Path(obj.intro_pdf_path).name}" if obj.intro_pdf_path else None,
'intro_session': intro_session,
'status': _request_status_label(obj.processing_status),
'status': _request_status_label(obj.processing_status, language_code),
'status_key': obj.processing_status,
'last_error': obj.last_error,
}
@@ -410,7 +605,7 @@ def requests_dashboard(request):
'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': None,
'intro_session': None,
'status': _request_status_label(obj.processing_status),
'status': _request_status_label(obj.processing_status, language_code),
'status_key': obj.processing_status,
'last_error': obj.last_error,
}
@@ -997,14 +1192,21 @@ def intro_builder_page(request):
def integrations_setup_page(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
kind = (request.GET.get('kind') or 'nextcloud').strip().lower()
if kind not in {'nextcloud', 'mail', 'emails'}:
if kind not in {'nextcloud', 'mail', 'emails', 'rules'}:
kind = 'nextcloud'
templates = list(NotificationTemplate.objects.all().order_by('key'))
system_email_config = (
SystemEmailConfig.objects.filter(is_active=True).order_by('-updated_at').first()
or SystemEmailConfig.objects.filter(name='Default SMTP').first()
)
return render(
request,
'workflows/integrations_setup.html',
{
'workflow_config': config,
'system_email_config': system_email_config,
'nextcloud_enabled': is_nextcloud_enabled(),
'email_test_mode': is_email_test_mode(),
'kind': kind,
'templates': templates,
'notification_rules': NotificationRule.objects.all().order_by('event_type', 'sort_order', 'id'),
@@ -1388,7 +1590,7 @@ def send_test_email(request):
)
_audit(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()})
messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).')
return redirect('home')
return _redirect_back(request, 'home')
@login_required
@@ -1421,7 +1623,7 @@ def nextcloud_test_upload(request):
if temp_path and temp_path.exists():
temp_path.unlink(missing_ok=True)
return redirect('home')
return _redirect_back(request, 'home')
@login_required
@@ -1436,7 +1638,7 @@ def toggle_nextcloud_enabled(request):
state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert'
messages.success(request, f'Nextcloud Upload wurde {state}.')
return redirect('home')
return _redirect_back(request, 'home')
@login_required
@@ -1451,7 +1653,7 @@ def toggle_email_mode(request):
state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus'
messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.')
return redirect('home')
return _redirect_back(request, 'home')
@login_required
@@ -1519,6 +1721,35 @@ def save_nextcloud_settings(request):
return redirect('/admin-tools/integrations/?kind=nextcloud')
@login_required
@user_passes_test(_is_staff)
@require_POST
def save_workflow_rules(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
try:
handover_lead_days = int(
request.POST.get(
'device_handover_lead_days',
config.device_handover_lead_days or 5,
)
)
except ValueError:
messages.error(request, 'Ungültige Zahl beim Hardware-Vorlauf.')
return redirect('/admin-tools/integrations/?kind=rules')
config.device_handover_lead_days = max(0, handover_lead_days)
config.save(update_fields=['device_handover_lead_days'])
_audit(
request,
'workflow_rules_saved',
target_type='workflow_config',
target_label='workflow_rules',
details={'device_handover_lead_days': config.device_handover_lead_days},
)
messages.success(request, 'Workflow-Regeln wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=rules')
@login_required
@user_passes_test(_is_staff)
@require_POST
@@ -1543,6 +1774,18 @@ def save_mail_settings(request):
config.email_password = email_password
config.save()
smtp_cfg, _ = SystemEmailConfig.objects.get_or_create(name='Default SMTP')
SystemEmailConfig.objects.exclude(id=smtp_cfg.id).update(is_active=False)
smtp_cfg.is_active = True
smtp_cfg.host = config.smtp_server
smtp_cfg.port = config.smtp_port
smtp_cfg.username = config.email_account
if email_password:
smtp_cfg.password = email_password
smtp_cfg.use_ssl = config.smtp_use_ssl
smtp_cfg.use_tls = config.smtp_use_tls
smtp_cfg.from_email = request.POST.get('from_email', '').strip()
smtp_cfg.save()
_audit(request, 'mail_settings_saved', target_type='workflow_config', target_label='mail')
messages.success(request, 'Mail-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=mail')
@@ -1692,8 +1935,9 @@ def delete_request_from_dashboard(request, kind: str, request_id: int):
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')
target_label = _request_target_label(obj, kind)
obj.delete()
_audit(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=str(obj))
_audit(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=target_label)
messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.')
return redirect('requests_dashboard')
@@ -1708,14 +1952,14 @@ def retry_request_from_dashboard(request, kind: str, request_id: int):
obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error'])
process_onboarding_request.delay(obj.id)
_audit(request, 'request_retried', target_type='onboarding', target_id=obj.id, target_label=obj.full_name)
_audit(request, 'request_retried', target_type='onboarding', target_id=obj.id, target_label=_request_target_label(obj, 'onboarding'))
elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id)
obj.processing_status = 'submitted'
obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error'])
process_offboarding_request.delay(obj.id)
_audit(request, 'request_retried', target_type='offboarding', target_id=obj.id, target_label=obj.full_name)
_audit(request, 'request_retried', target_type='offboarding', target_id=obj.id, target_label=_request_target_label(obj, 'offboarding'))
else:
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')