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

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
from django import forms from django import forms
from pathlib import Path 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 .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')] 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() self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower()
super().__init__(*args, **kwargs) 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' self.fields['full_name'].label = 'Name'
full_name_initial = (self.initial.get('full_name') or '').strip() 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'): 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'): 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.') 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 return cleaned
def save(self, commit=True): 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( gender = models.CharField(
max_length=20, max_length=20,
blank=True, blank=True,
choices=[('herr', 'Herr'), ('frau', 'Frau'), ('divers', 'Divers')], choices=[('herr', _('Herr')), ('frau', _('Frau')), ('divers', _('Divers'))],
verbose_name='Anrede', verbose_name='Anrede',
) )
job_title = models.CharField(max_length=255, blank=True, verbose_name='Berufsbezeichnung') job_title = models.CharField(max_length=255, blank=True, verbose_name='Berufsbezeichnung')
@@ -72,7 +72,7 @@ class OnboardingRequest(models.Model):
employment_type = models.CharField( employment_type = models.CharField(
max_length=20, max_length=20,
blank=True, blank=True,
choices=[('befristet', 'befristet'), ('unbefristet', 'unbefristet')], choices=[('befristet', _('befristet')), ('unbefristet', _('unbefristet'))],
verbose_name='Beschäftigungsverhältnis', verbose_name='Beschäftigungsverhältnis',
) )
employment_end_date = models.DateField(null=True, blank=True, verbose_name='Enddatum (nur bei befristet)') 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): class FormOption(models.Model):
CATEGORY_CHOICES = [ CATEGORY_CHOICES = [
('department', 'Abteilung'), ('department', _('Abteilung')),
('device', 'Geräte'), ('device', _('Geräte')),
('software', 'Software'), ('software', _('Software')),
('access', 'Zugänge'), ('access', _('Zugänge')),
('workspace_group', 'Workspace-Gruppen'), ('workspace_group', _('Workspace-Gruppen')),
('resource', 'Ressourcen'), ('resource', _('Ressourcen')),
('phone', 'Telefonnummern'), ('phone', _('Telefonnummern')),
] ]
category = models.CharField(max_length=40, choices=CATEGORY_CHOICES) category = models.CharField(max_length=40, choices=CATEGORY_CHOICES)
@@ -167,15 +167,15 @@ class FormOption(models.Model):
class FormFieldConfig(models.Model): class FormFieldConfig(models.Model):
PAGE_CHOICES = [ PAGE_CHOICES = [
('', 'Automatisch'), ('', _('Automatisch')),
('stammdaten', 'Stammdaten'), ('stammdaten', _('Stammdaten')),
('vertrag', 'Vertrag'), ('vertrag', _('Vertrag')),
('itsetup', 'IT-Setup'), ('itsetup', _('IT-Setup')),
('abschluss', 'Abschluss'), ('abschluss', _('Abschluss')),
] ]
FORM_CHOICES = [ FORM_CHOICES = [
('onboarding', 'Onboarding'), ('onboarding', _('Onboarding')),
('offboarding', 'Offboarding'), ('offboarding', _('Offboarding')),
] ]
form_type = models.CharField(max_length=20, choices=FORM_CHOICES) form_type = models.CharField(max_length=20, choices=FORM_CHOICES)
@@ -213,17 +213,17 @@ class FormFieldConfig(models.Model):
class NotificationTemplate(models.Model): class NotificationTemplate(models.Model):
TEMPLATE_CHOICES = [ TEMPLATE_CHOICES = [
('onboarding_it', 'Onboarding: IT'), ('onboarding_it', _('Onboarding: IT')),
('onboarding_general_info', 'Onboarding: Allgemeine Info'), ('onboarding_general_info', _('Onboarding: Allgemeine Info')),
('onboarding_business_card', 'Onboarding: Visitenkarte'), ('onboarding_business_card', _('Onboarding: Visitenkarte')),
('onboarding_hr_works', 'Onboarding: HR Works'), ('onboarding_hr_works', _('Onboarding: HR Works')),
('onboarding_key', 'Onboarding: Schlüssel'), ('onboarding_key', _('Onboarding: Schlüssel')),
('onboarding_reference', 'Onboarding: Referenz Anfordernde Person'), ('onboarding_reference', _('Onboarding: Referenz Anfordernde Person')),
('onboarding_welcome', 'Onboarding: Welcome E-Mail'), ('onboarding_welcome', _('Onboarding: Welcome E-Mail')),
('offboarding_it', 'Offboarding: IT'), ('offboarding_it', _('Offboarding: IT')),
('offboarding_general_info', 'Offboarding: Allgemeine Info'), ('offboarding_general_info', _('Offboarding: Allgemeine Info')),
('offboarding_hr_works_disable', 'Offboarding: HR Works Deaktivierung'), ('offboarding_hr_works_disable', _('Offboarding: HR Works Deaktivierung')),
('offboarding_reference', 'Offboarding: Referenz Anfordernde Person'), ('offboarding_reference', _('Offboarding: Referenz Anfordernde Person')),
] ]
key = models.CharField(max_length=60, choices=TEMPLATE_CHOICES, unique=True) key = models.CharField(max_length=60, choices=TEMPLATE_CHOICES, unique=True)
@@ -255,15 +255,15 @@ class NotificationTemplate(models.Model):
class NotificationRule(models.Model): class NotificationRule(models.Model):
EVENT_CHOICES = [ EVENT_CHOICES = [
('onboarding', 'Onboarding'), ('onboarding', _('Onboarding')),
('offboarding', 'Offboarding'), ('offboarding', _('Offboarding')),
] ]
OPERATOR_CHOICES = [ OPERATOR_CHOICES = [
('always', 'Immer'), ('always', _('Immer')),
('contains', 'Enthält'), ('contains', _('Enthält')),
('equals', 'Ist gleich'), ('equals', _('Ist gleich')),
('is_true', 'Ist aktiv/Ja'), ('is_true', _('Ist aktiv/Ja')),
('is_false', 'Ist inaktiv/Nein'), ('is_false', _('Ist inaktiv/Nein')),
] ]
name = models.CharField(max_length=120) name = models.CharField(max_length=120)
@@ -305,11 +305,11 @@ class NotificationRule(models.Model):
class ScheduledWelcomeEmail(models.Model): class ScheduledWelcomeEmail(models.Model):
STATUS_CHOICES = [ STATUS_CHOICES = [
('scheduled', 'Geplant'), ('scheduled', _('Geplant')),
('paused', 'Pausiert'), ('paused', _('Pausiert')),
('cancelled', 'Abgebrochen'), ('cancelled', _('Abgebrochen')),
('sent', 'Gesendet'), ('sent', _('Gesendet')),
('failed', 'Fehlgeschlagen'), ('failed', _('Fehlgeschlagen')),
] ]
onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE) onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE)
@@ -331,17 +331,17 @@ class ScheduledWelcomeEmail(models.Model):
class IntroChecklistItem(models.Model): class IntroChecklistItem(models.Model):
SECTION_CHOICES = [ SECTION_CHOICES = [
('workplace', 'Geräte und Arbeitsplatz'), ('workplace', _('Geräte und Arbeitsplatz')),
('accounts', 'Konten und Berechtigungen'), ('accounts', _('Konten und Berechtigungen')),
('software', 'Software und Tools'), ('software', _('Software und Tools')),
('process', 'Prozesse und Hinweise'), ('process', _('Prozesse und Hinweise')),
] ]
OPERATOR_CHOICES = [ OPERATOR_CHOICES = [
('always', 'Immer anzeigen'), ('always', _('Immer anzeigen')),
('contains', 'Enthält'), ('contains', _('Enthält')),
('equals', 'Ist gleich'), ('equals', _('Ist gleich')),
('is_true', 'Ist Ja / aktiv'), ('is_true', _('Ist Ja / aktiv')),
('is_false', 'Ist Nein / inaktiv'), ('is_false', _('Ist Nein / inaktiv')),
] ]
section = models.CharField(max_length=30, choices=SECTION_CHOICES) section = models.CharField(max_length=30, choices=SECTION_CHOICES)
@@ -368,8 +368,8 @@ class IntroChecklistItem(models.Model):
class OnboardingIntroductionSession(models.Model): class OnboardingIntroductionSession(models.Model):
STATUS_CHOICES = [ STATUS_CHOICES = [
('draft', 'Entwurf'), ('draft', _('Entwurf')),
('completed', 'Abgeschlossen'), ('completed', _('Abgeschlossen')),
] ]
onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE) 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_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)') 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)') 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_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_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') 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; } 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; } 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; } .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; } .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; } .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; } .switch { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }

View File

@@ -150,6 +150,12 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
}
.status-switch-form {
margin: 0;
display: inline-flex;
} }
.status-pill { .status-pill {
@@ -160,6 +166,125 @@
padding: 8px 12px; padding: 8px 12px;
font-size: 12px; font-size: 12px;
font-weight: 700; 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 { .status-pill.ok {

View File

@@ -8,7 +8,7 @@ from celery import shared_task
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.conf import settings from django.conf import settings
from django.utils import timezone 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 jinja2 import Template
from pypdf import PageObject, PdfReader, PdfWriter from pypdf import PageObject, PdfReader, PdfWriter
from xhtml2pdf import pisa from xhtml2pdf import pisa
@@ -570,19 +570,12 @@ 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]: def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]:
lang = _normalized_lang(language_code or get_language()) lang = _normalized_lang(language_code or get_language())
with override(lang):
section_titles = { section_titles = {
'de': { 'workplace': _('Geräte und Arbeitsplatz'),
'workplace': 'Geräte und Arbeitsplatz', 'accounts': _('Konten und Berechtigungen'),
'accounts': 'Konten und Berechtigungen', 'software': _('Software und Tools'),
'software': 'Software und Tools', 'process': _('Prozesse und Hinweise'),
'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) devices = _split_multiline(request_obj.needed_devices)
software = _split_multiline(request_obj.needed_software) software = _split_multiline(request_obj.needed_software)
@@ -595,63 +588,46 @@ def build_intro_sections_for_request(request_obj: OnboardingRequest, language_co
workplace_items = [] workplace_items = []
for item in devices: for item in devices:
if lang == 'en': workplace_items.append(_('%(item)s übergeben und Grundfunktionen erklärt') % {'item': item})
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: for item in resources:
if lang == 'en': workplace_items.append(_('%(item)s gezeigt bzw. Nutzung erklärt') % {'item': item})
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 request_obj.phone_number:
if lang == 'en': workplace_items.append(_('Telefonnummer / Direktwahl erklärt: %(value)s') % {'value': request_obj.phone_number})
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: 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.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 = [_('%(item)s Zugang erklärt') % {'item': item} 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]) account_items.extend([_('%(item)s Gruppe / Berechtigung erläutert') % {'item': item} for item in groups])
if request_obj.work_email: 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}') account_items.insert(0, _('Dienstliche E-Mail-Adresse erläutert: %(value)s') % {'value': request_obj.work_email})
if group_mailboxes: 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]) account_items.extend([_('Gruppenpostfach erklärt: %(item)s') % {'item': item} for item in group_mailboxes])
if not account_items: 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.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 = [_('%(item)s Einführung durchgeführt') % {'item': item} 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]) software_items.extend([_('%(item)s zusätzlich besprochen') % {'item': item} for item in extra_software])
if not software_items: 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.append(_('Benötigte Standardsoftware und tägliche Nutzung erklärt'))
process_items = ( process_items = [
[ _('Passwortregeln und sicherer Umgang besprochen'),
'Password rules and secure handling reviewed', _('Dateiablage, Nextcloud und Freigaben erklärt'),
'File storage, Nextcloud, and sharing explained', _('Kommunikationswege und Support-Prozess erklärt'),
'Communication channels and support process explained',
] ]
if lang == 'en'
else [
'Passwortregeln und sicherer Umgang besprochen',
'Dateiablage, Nextcloud und Freigaben erklärt',
'Kommunikationswege und Support-Prozess erklärt',
]
)
if extra_hardware: 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]) process_items.extend([_('%(item)s als zusätzliche Ausstattung besprochen') % {'item': item} for item in extra_hardware])
if request_obj.additional_access_text: 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)]) 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: 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}') process_items.append(_('Übergabe-/Nachfolgekontext besprochen: %(value)s') % {'value': request_obj.successor_name})
custom_intro_items = _build_intro_sections_from_admin(request_obj, lang) custom_intro_items = _build_intro_sections_from_admin(request_obj, lang)
intro_sections_raw = [ intro_sections_raw = [
('workplace', section_titles.get(lang, section_titles['de'])['workplace'], workplace_items), ('workplace', section_titles['workplace'], workplace_items),
('accounts', section_titles.get(lang, section_titles['de'])['accounts'], account_items), ('accounts', section_titles['accounts'], account_items),
('software', section_titles.get(lang, section_titles['de'])['software'], software_items), ('software', section_titles['software'], software_items),
('process', section_titles.get(lang, section_titles['de'])['process'], process_items), ('process', section_titles['process'], process_items),
] ]
sections = [] sections = []

View File

@@ -67,14 +67,28 @@
<td> <td>
{% if row.target_label %} {% if row.target_label %}
{{ row.target_label }} {{ row.target_label }}
{% if row.target_id %}<div class="hint">#{{ row.target_id }}</div>{% endif %}
{% elif row.target_id %} {% elif row.target_id %}
#{{ row.target_id }} <span class="hint">ID {{ row.target_id }}</span>
{% else %} {% else %}
- -
{% endif %} {% endif %}
</td> </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> </tr>
{% empty %} {% empty %}
<tr> <tr>

View File

@@ -35,14 +35,14 @@
<h1>{% trans "TUBCO Onboarding & Offboarding Portal" %}</h1> <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> <p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p>
<div class="status-row"> <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 %}"> <span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}">
{% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %} {% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}
</span> </span>
<span class="status-pill {% if email_test_mode %}warn{% else %}ok{% endif %}"> <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 %} {% trans "E-Mail:" %} {% if email_test_mode %}{% trans "Testmodus" %}{% else %}{% trans "Produktion" %}{% endif %}
</span> </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> </div>
</div> </div>
@@ -116,6 +116,21 @@
</div> </div>
<div class="admin-grid"> <div class="admin-grid">
<section class="admin-card"> <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> <h3>{% trans "Form Builder" %}</h3>
<p>{% trans "Felder, Schritte und Optionen verwalten." %}</p> <p>{% trans "Felder, Schritte und Optionen verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/form-builder/">{% trans "Öffnen" %}</a> <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> <a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section> </section>
<section class="admin-card"> <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> <h3>{% trans "Django Admin" %}</h3>
<p>{% trans "Vollständige Datenverwaltung." %}</p> <p>{% trans "Vollständige Datenverwaltung." %}</p>
<a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a> <a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a>
</section> </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> </div>
{% endif %} {% 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 == '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 == '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 == '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> </div>
{% if messages %} {% if messages %}
@@ -29,6 +30,7 @@
{% if kind == 'nextcloud' %} {% if kind == 'nextcloud' %}
<form class="card" method="post" action="/admin-tools/integrations/save-nextcloud/"> <form class="card" method="post" action="/admin-tools/integrations/save-nextcloud/">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="/admin-tools/integrations/?kind=nextcloud" />
<div class="grid"> <div class="grid">
<div> <div>
<label for="nc_base">NEXTCLOUD_BASE_URL</label> <label for="nc_base">NEXTCLOUD_BASE_URL</label>
@@ -53,14 +55,32 @@
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Nextcloud speichern" %}</button> <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>
<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> <div class="hint">{% trans "Leeres Passwortfeld lässt das bestehende Passwort unverändert." %}</div>
</form> </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 %} {% endif %}
{% if kind == 'mail' %} {% if kind == 'mail' %}
<form class="card" method="post" action="/admin-tools/integrations/save-mail/"> <form class="card" method="post" action="/admin-tools/integrations/save-mail/">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="/admin-tools/integrations/?kind=mail" />
<div class="grid"> <div class="grid">
<div> <div>
<label for="imap_server">IMAP_SERVER</label> <label for="imap_server">IMAP_SERVER</label>
@@ -86,6 +106,10 @@
<label for="email_password">PASSWORD</label> <label for="email_password">PASSWORD</label>
<input id="email_password" name="email_password" type="password" placeholder="Leer lassen = unverändert" /> <input id="email_password" name="email_password" type="password" placeholder="Leer lassen = unverändert" />
</div> </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>
<div class="check-row"> <div class="check-row">
<label><input type="checkbox" name="smtp_use_ssl" {% if workflow_config.smtp_use_ssl %}checked{% endif %} /> {% trans "SMTP SSL" %}</label> <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>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Mail speichern" %}</button> <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>
<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> <div class="hint">{% trans "Leeres Passwortfeld lässt das bestehende Passwort unverändert." %}</div>
</form> </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 %} {% endif %}
{% if kind == 'emails' %} {% if kind == 'emails' %}
@@ -302,5 +343,28 @@
</div> </div>
</form> </form>
{% endif %} {% 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 %} {% endif %}
</div> </div>
</form> </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> </div>
{% if request.user.is_staff %} {% if request.user.is_staff %}
<div class="control-stack"> <div class="control-stack">
@@ -157,7 +156,6 @@
<th>{% trans "Typ" %}</th> <th>{% trans "Typ" %}</th>
<th>{% trans "Person" %}</th> <th>{% trans "Person" %}</th>
<th>{% trans "E-Mail" %}</th> <th>{% trans "E-Mail" %}</th>
<th>{% trans "Erstellt" %}</th>
<th>{% trans "Dokument" %}</th> <th>{% trans "Dokument" %}</th>
{% if request.user.is_staff %}<th>{% trans "Einweisung" %}</th>{% endif %} {% if request.user.is_staff %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if request.user.is_staff %}<th>{% trans "Aktion" %}</th>{% endif %} {% if request.user.is_staff %}<th>{% trans "Aktion" %}</th>{% endif %}
@@ -182,17 +180,18 @@
<td> <td>
<a class="mail-link" href="mailto:{{ row.work_email }}">{{ row.work_email }}</a> <a class="mail-link" href="mailto:{{ row.work_email }}">{{ row.work_email }}</a>
</td> </td>
<td>{{ row.created_at|date:"Y-m-d H:i" }}</td>
<td> <td>
{% if row.pdf_url %} {% if row.pdf_url %}
<a class="doc-link" href="{{ row.pdf_url }}" target="_blank" rel="noopener">{% trans "PDF öffnen" %}</a> <a class="doc-link" href="{{ row.pdf_url }}" target="_blank" rel="noopener">{% trans "PDF öffnen" %}</a>
{% else %} {% else %}
<span class="person-meta">{% trans "Noch nicht verfügbar" %}</span> <span class="person-meta">{% trans "Noch nicht verfügbar" %}</span>
{% endif %} {% endif %}
<div class="person-meta" style="margin-top:8px;">{{ row.status }}</div> {% if row.status_key == 'failed' %}
{% if row.status_key == 'failed' and row.last_error %} <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> <div class="person-meta" style="margin-top:6px; color:#8e1e1e;">{{ row.last_error|truncatechars:140 }}</div>
{% endif %} {% endif %}
{% endif %}
</td> </td>
{% if request.user.is_staff %} {% if request.user.is_staff %}
<td class="actions-cell intro-panel"> <td class="actions-cell intro-panel">
@@ -236,6 +235,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="actions-cell"> <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' %} {% 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?');"> <form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" onsubmit="return confirm('Eintrag erneut verarbeiten?');">
{% csrf_token %} {% 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-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-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-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/', 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/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'), 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/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/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/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.views.decorators.csrf import ensure_csrf_cookie
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _, gettext_lazy 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 .forms import OffboardingRequestForm, OnboardingRequestForm
from .form_builder import ( from .form_builder import (
@@ -27,7 +27,7 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER, ONBOARDING_PAGE_ORDER,
ensure_form_field_configs, 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 .emailing import send_system_email
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import ( from .tasks import (
@@ -40,6 +40,16 @@ from .tasks import (
send_scheduled_welcome_email, 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 = { ONBOARDING_GROUPS = {
'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'], 'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'],
'employment-end-box': ['employment_end_date'], 'employment-end-box': ['employment_end_date'],
@@ -147,7 +157,25 @@ def _form_field_labels(form_type: str) -> dict[str, str]:
return {} return {}
def _request_status_label(status_key: str) -> str: 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 = { labels = {
'submitted': _('Eingereicht'), 'submitted': _('Eingereicht'),
'processing': _('In Bearbeitung'), 'processing': _('In Bearbeitung'),
@@ -157,6 +185,42 @@ def _request_status_label(status_key: str) -> str:
return labels.get(status_key, status_key) 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): def _translate_choice_list(choices):
return [(value, str(label)) for value, label in 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 @login_required
def requests_dashboard(request): def requests_dashboard(request):
if request.method == 'POST': if request.method == 'POST':
@@ -329,6 +511,7 @@ def requests_dashboard(request):
deleted_count = 0 deleted_count = 0
invalid_count = 0 invalid_count = 0
deleted_labels = []
for token in selected: for token in selected:
try: try:
kind, raw_id = token.split(':', 1) kind, raw_id = token.split(':', 1)
@@ -349,6 +532,7 @@ def requests_dashboard(request):
obj = model.objects.filter(id=request_id).first() obj = model.objects.filter(id=request_id).first()
if not obj: if not obj:
continue continue
deleted_labels.append(_request_target_label(obj, kind))
obj.delete() obj.delete()
deleted_count += 1 deleted_count += 1
@@ -358,7 +542,12 @@ def requests_dashboard(request):
'requests_deleted', 'requests_deleted',
target_type='request', target_type='request',
target_label='Dashboard bulk/single delete', 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}) messages.success(request, _('%(count)s Eintrag/Einträge gelöscht.') % {'count': deleted_count})
if invalid_count: if invalid_count:
@@ -376,6 +565,12 @@ def requests_dashboard(request):
onboarding_items = onboarding_qs[:50] onboarding_items = onboarding_qs[:50]
offboarding_items = offboarding_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 = [] rows = []
for obj in onboarding_items: 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, '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_pdf_url': f"/media/pdfs/{Path(obj.intro_pdf_path).name}" if obj.intro_pdf_path else None,
'intro_session': intro_session, '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, 'status_key': obj.processing_status,
'last_error': obj.last_error, '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, 'pdf_url': f"/media/pdfs/{Path(obj.generated_pdf_path).name}" if obj.generated_pdf_path else None,
'intro_pdf_url': None, 'intro_pdf_url': None,
'intro_session': 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, 'status_key': obj.processing_status,
'last_error': obj.last_error, 'last_error': obj.last_error,
} }
@@ -997,14 +1192,21 @@ def intro_builder_page(request):
def integrations_setup_page(request): def integrations_setup_page(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
kind = (request.GET.get('kind') or 'nextcloud').strip().lower() 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' kind = 'nextcloud'
templates = list(NotificationTemplate.objects.all().order_by('key')) 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( return render(
request, request,
'workflows/integrations_setup.html', 'workflows/integrations_setup.html',
{ {
'workflow_config': config, 'workflow_config': config,
'system_email_config': system_email_config,
'nextcloud_enabled': is_nextcloud_enabled(),
'email_test_mode': is_email_test_mode(),
'kind': kind, 'kind': kind,
'templates': templates, 'templates': templates,
'notification_rules': NotificationRule.objects.all().order_by('event_type', 'sort_order', 'id'), '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()}) _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}).') messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).')
return redirect('home') return _redirect_back(request, 'home')
@login_required @login_required
@@ -1421,7 +1623,7 @@ def nextcloud_test_upload(request):
if temp_path and temp_path.exists(): if temp_path and temp_path.exists():
temp_path.unlink(missing_ok=True) temp_path.unlink(missing_ok=True)
return redirect('home') return _redirect_back(request, 'home')
@login_required @login_required
@@ -1436,7 +1638,7 @@ def toggle_nextcloud_enabled(request):
state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert' state = 'aktiviert' if config.nextcloud_enabled_override else 'deaktiviert'
messages.success(request, f'Nextcloud Upload wurde {state}.') messages.success(request, f'Nextcloud Upload wurde {state}.')
return redirect('home') return _redirect_back(request, 'home')
@login_required @login_required
@@ -1451,7 +1653,7 @@ def toggle_email_mode(request):
state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus' state = 'Testmodus (Umleitung)' if config.email_test_mode_override else 'Produktionsmodus'
messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.') messages.success(request, f'E-Mail-Modus wurde auf {state} gesetzt.')
return redirect('home') return _redirect_back(request, 'home')
@login_required @login_required
@@ -1519,6 +1721,35 @@ def save_nextcloud_settings(request):
return redirect('/admin-tools/integrations/?kind=nextcloud') 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 @login_required
@user_passes_test(_is_staff) @user_passes_test(_is_staff)
@require_POST @require_POST
@@ -1543,6 +1774,18 @@ def save_mail_settings(request):
config.email_password = email_password config.email_password = email_password
config.save() 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') _audit(request, 'mail_settings_saved', target_type='workflow_config', target_label='mail')
messages.success(request, 'Mail-Einstellungen wurden gespeichert.') messages.success(request, 'Mail-Einstellungen wurden gespeichert.')
return redirect('/admin-tools/integrations/?kind=mail') 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}') messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard') return redirect('requests_dashboard')
target_label = _request_target_label(obj, kind)
obj.delete() 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.') messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.')
return redirect('requests_dashboard') return redirect('requests_dashboard')
@@ -1708,14 +1952,14 @@ def retry_request_from_dashboard(request, kind: str, request_id: int):
obj.last_error = '' obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error']) obj.save(update_fields=['processing_status', 'last_error'])
process_onboarding_request.delay(obj.id) 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': elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id) obj = get_object_or_404(OffboardingRequest, id=request_id)
obj.processing_status = 'submitted' obj.processing_status = 'submitted'
obj.last_error = '' obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error']) obj.save(update_fields=['processing_status', 'last_error'])
process_offboarding_request.delay(obj.id) 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: else:
messages.error(request, f'Unbekannter Typ: {kind}') messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard') return redirect('requests_dashboard')