diff --git a/backend/workflows/admin.py b/backend/workflows/admin.py index f39cf78..62ebabc 100644 --- a/backend/workflows/admin.py +++ b/backend/workflows/admin.py @@ -3,7 +3,7 @@ from django.conf import settings from django import forms from .emailing import send_system_email -from .models import EmployeeProfile, FormFieldConfig, FormOption, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig +from .models import EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig @admin.register(EmployeeProfile) @@ -43,6 +43,23 @@ class FormFieldConfigAdmin(admin.ModelAdmin): list_editable = ('page_key', 'sort_order', 'is_visible', 'is_required') +@admin.register(IntroChecklistItem) +class IntroChecklistItemAdmin(admin.ModelAdmin): + list_display = ('section', 'label', 'condition_field', 'condition_operator', 'condition_value', 'sort_order', 'is_active') + list_filter = ('section', 'condition_operator', 'is_active') + search_fields = ('label', 'condition_field', 'condition_value') + ordering = ('section', 'sort_order', 'label') + list_editable = ('sort_order', 'is_active') + + +@admin.register(OnboardingIntroductionSession) +class OnboardingIntroductionSessionAdmin(admin.ModelAdmin): + list_display = ('onboarding_request', 'status', 'completed_by_name', 'completed_at', 'updated_at') + list_filter = ('status', 'completed_at', 'updated_at') + search_fields = ('onboarding_request__full_name', 'onboarding_request__work_email', 'completed_by_name') + ordering = ('-updated_at', '-id') + + @admin.register(WorkflowConfig) class WorkflowConfigAdmin(admin.ModelAdmin): list_display = ( diff --git a/backend/workflows/migrations/0025_onboardingrequest_intro_pdf_path.py b/backend/workflows/migrations/0025_onboardingrequest_intro_pdf_path.py new file mode 100644 index 0000000..d8f09f7 --- /dev/null +++ b/backend/workflows/migrations/0025_onboardingrequest_intro_pdf_path.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0024_workflowconfig_welcome_email_delay_days_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingrequest', + name='intro_pdf_path', + field=models.CharField(blank=True, max_length=500), + ), + ] diff --git a/backend/workflows/migrations/0026_introchecklistitem.py b/backend/workflows/migrations/0026_introchecklistitem.py new file mode 100644 index 0000000..30abc79 --- /dev/null +++ b/backend/workflows/migrations/0026_introchecklistitem.py @@ -0,0 +1,27 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0025_onboardingrequest_intro_pdf_path'), + ] + + operations = [ + migrations.CreateModel( + name='IntroChecklistItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('section', models.CharField(choices=[('workplace', 'Geräte und Arbeitsplatz'), ('accounts', 'Konten und Berechtigungen'), ('software', 'Software und Tools'), ('process', 'Prozesse und Hinweise')], max_length=30)), + ('label', models.CharField(max_length=255)), + ('sort_order', models.PositiveIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('condition_field', models.CharField(blank=True, max_length=80)), + ('condition_operator', models.CharField(choices=[('always', 'Immer anzeigen'), ('contains', 'Enthält'), ('equals', 'Ist gleich'), ('is_true', 'Ist Ja / aktiv'), ('is_false', 'Ist Nein / inaktiv')], default='always', max_length=20)), + ('condition_value', models.CharField(blank=True, max_length=255)), + ], + options={ + 'ordering': ['section', 'sort_order', 'label'], + }, + ), + ] diff --git a/backend/workflows/migrations/0027_onboardingintroductionsession.py b/backend/workflows/migrations/0027_onboardingintroductionsession.py new file mode 100644 index 0000000..151ed0d --- /dev/null +++ b/backend/workflows/migrations/0027_onboardingintroductionsession.py @@ -0,0 +1,26 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0026_introchecklistitem'), + ] + + operations = [ + migrations.CreateModel( + name='OnboardingIntroductionSession', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('checklist_state', models.JSONField(blank=True, default=dict)), + ('notes', models.TextField(blank=True)), + ('status', models.CharField(choices=[('draft', 'Entwurf'), ('completed', 'Abgeschlossen')], default='draft', max_length=20)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('completed_by_name', models.CharField(blank=True, max_length=255)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('onboarding_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='workflows.onboardingrequest')), + ], + ), + ] diff --git a/backend/workflows/migrations/0028_onboardingintroductionsession_exported_pdf_path.py b/backend/workflows/migrations/0028_onboardingintroductionsession_exported_pdf_path.py new file mode 100644 index 0000000..01dfb7c --- /dev/null +++ b/backend/workflows/migrations/0028_onboardingintroductionsession_exported_pdf_path.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workflows', '0027_onboardingintroductionsession'), + ] + + operations = [ + migrations.AddField( + model_name='onboardingintroductionsession', + name='exported_pdf_path', + field=models.CharField(blank=True, max_length=500), + ), + ] diff --git a/backend/workflows/models.py b/backend/workflows/models.py index fdfa341..77d1266 100644 --- a/backend/workflows/models.py +++ b/backend/workflows/models.py @@ -78,6 +78,7 @@ class OnboardingRequest(models.Model): ) generated_pdf_path = models.CharField(max_length=500, blank=True) + intro_pdf_path = models.CharField(max_length=500, blank=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self) -> str: @@ -231,6 +232,56 @@ class ScheduledWelcomeEmail(models.Model): return f'Welcome #{self.id} | {self.recipient_email} | {self.status}' +class IntroChecklistItem(models.Model): + SECTION_CHOICES = [ + ('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'), + ] + + section = models.CharField(max_length=30, choices=SECTION_CHOICES) + label = models.CharField(max_length=255) + sort_order = models.PositiveIntegerField(default=0) + is_active = models.BooleanField(default=True) + condition_field = models.CharField(max_length=80, blank=True) + condition_operator = models.CharField(max_length=20, choices=OPERATOR_CHOICES, default='always') + condition_value = models.CharField(max_length=255, blank=True) + + class Meta: + ordering = ['section', 'sort_order', 'label'] + + def __str__(self) -> str: + return f'{self.get_section_display()}: {self.label}' + + +class OnboardingIntroductionSession(models.Model): + STATUS_CHOICES = [ + ('draft', 'Entwurf'), + ('completed', 'Abgeschlossen'), + ] + + onboarding_request = models.OneToOneField(OnboardingRequest, on_delete=models.CASCADE) + checklist_state = models.JSONField(default=dict, blank=True) + notes = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + completed_at = models.DateTimeField(null=True, blank=True) + completed_by_name = models.CharField(max_length=255, blank=True) + exported_pdf_path = models.CharField(max_length=500, blank=True) + updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f'Einweisung #{self.id} | {self.onboarding_request.full_name} | {self.status}' + + class WorkflowConfig(models.Model): name = models.CharField(max_length=120, default='Default', unique=True) it_onboarding_email = models.EmailField(blank=True) diff --git a/backend/workflows/tasks.py b/backend/workflows/tasks.py index 4ca8193..5f36375 100644 --- a/backend/workflows/tasks.py +++ b/backend/workflows/tasks.py @@ -12,7 +12,7 @@ from jinja2 import Template from pypdf import PageObject, PdfReader, PdfWriter from xhtml2pdf import pisa -from .models import EmployeeProfile, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .models import EmployeeProfile, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig from .emailing import send_system_email from .services import upload_to_nextcloud from .services import get_email_test_redirect, is_email_test_mode @@ -269,6 +269,111 @@ def _resolve_workflow_emails() -> tuple[str, str, str, str, str]: return it_email, general_info_email, business_card_email, hr_works_email, key_email +def _matches_intro_condition(request_obj: OnboardingRequest, item: IntroChecklistItem) -> bool: + operator = (item.condition_operator or 'always').strip() + field_name = (item.condition_field or '').strip() + expected = (item.condition_value or '').strip() + + if operator == 'always' or not field_name: + return True + + raw_value = getattr(request_obj, field_name, '') + if raw_value is None: + raw_value = '' + + if operator == 'is_true': + return bool(raw_value) + if operator == 'is_false': + return not bool(raw_value) + + text_value = str(raw_value).strip() + if operator == 'equals': + return text_value.lower() == expected.lower() + if operator == 'contains': + return expected.lower() in text_value.lower() + return True + + +def _build_intro_sections_from_admin(request_obj: OnboardingRequest) -> dict[str, list[str]]: + items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label')) + if not items: + return {} + + section_map = {key: [] for key, _label in IntroChecklistItem.SECTION_CHOICES} + for item in items: + if item.section not in section_map: + continue + if _matches_intro_condition(request_obj, item): + section_map[item.section].append(item.label) + return {key: values for key, values in section_map.items() if values} + + +def build_intro_sections_for_request(request_obj: OnboardingRequest) -> list[dict]: + 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: + workplace_items.append(f'{item} übergeben und Grundfunktionen erklärt') + for item in resources: + workplace_items.append(f'{item} gezeigt bzw. Nutzung erklärt') + if request_obj.phone_number: + workplace_items.append(f'Telefonnummer / Direktwahl erklärt: {request_obj.phone_number}') + if not workplace_items: + workplace_items.append('Arbeitsplatz, Geräte und allgemeine Nutzung besprochen') + + account_items = [f'{item} Zugang erklärt' for item in accesses] + account_items.extend([f'{item} Gruppe / Berechtigung erläutert' for item in groups]) + if request_obj.work_email: + account_items.insert(0, f'Dienstliche E-Mail-Adresse erläutert: {request_obj.work_email}') + if group_mailboxes: + account_items.extend([f'Gruppenpostfach erklärt: {item}' for item in group_mailboxes]) + if not account_items: + account_items.append('Zugänge, Konten und Anmeldelogik besprochen') + + software_items = [f'{item} Einführung durchgeführt' for item in software] + software_items.extend([f'{item} zusätzlich besprochen' for item in extra_software]) + if not software_items: + software_items.append('Benötigte Standardsoftware und tägliche Nutzung erklärt') + + process_items = [ + '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} als zusätzliche Ausstattung besprochen' for item in extra_hardware]) + if request_obj.additional_access_text: + process_items.extend([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'Übergabe-/Nachfolgekontext besprochen: {request_obj.successor_name}') + + custom_intro_items = _build_intro_sections_from_admin(request_obj) + intro_sections_raw = [ + ('workplace', 'Geräte und Arbeitsplatz', workplace_items), + ('accounts', 'Konten und Berechtigungen', account_items), + ('software', 'Software und Tools', software_items), + ('process', 'Prozesse und Hinweise', process_items), + ] + + sections = [] + for key, title, default_items in intro_sections_raw: + merged_items = list(default_items) + merged_items.extend(custom_intro_items.get(key, [])) + section_items = [] + for idx, label in enumerate(merged_items, start=1): + section_items.append({'id': f'{key}_{idx}', 'label': label}) + if section_items: + sections.append({'key': key, 'title': title, 'items': section_items}) + return sections + + def _send_workflow_email( subject: str, body: str, @@ -609,6 +714,107 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path: return output_pdf +def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest) -> Path: + safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_{request_obj.id}') + output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_{safe_name}.pdf' + temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf' + + template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_template.html' + letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + + salutation = (request_obj.get_gender_display() or '').strip() + display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name + intro_sections = [ + { + 'title': section['title'], + 'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2), + } + for section in build_intro_sections_for_request(request_obj) + ] + + requester_email = request_obj.onboarded_by_email or '-' + requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-' + + context = { + 'DISPLAY_NAME': display_name, + 'ABTEILUNG': request_obj.department or '-', + 'BERUFSBEZEICHNUNG': request_obj.job_title or '-', + 'VERTRAGSBEGINN': request_obj.contract_start, + 'EMAIL': request_obj.work_email or '-', + 'REQUESTED_BY_NAME': requester_name, + 'REQUESTED_BY_EMAIL': requester_email, + 'INTRO_SECTIONS': intro_sections, + } + + html = _render_html(template_path, context) + _generate_content_pdf(html, temp_pdf) + _overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf) + + if temp_pdf.exists(): + temp_pdf.unlink(missing_ok=True) + return output_pdf + + +def _generate_onboarding_intro_session_pdf(session: OnboardingIntroductionSession, admin_signature_name: str = '-') -> Path: + request_obj = session.onboarding_request + safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_session_{request_obj.id}') + version = timezone.now().strftime('%Y%m%d%H%M%S') + output_pdf = settings.PDF_OUTPUT_DIR / f'onboarding_intro_session_{safe_name}_{version}.pdf' + temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_session_{safe_name}_{version}.pdf' + + template_path = settings.PDF_TEMPLATES_DIR / 'onboarding_intro_session_pdf.html' + letterhead_path = settings.PDF_TEMPLATES_DIR / 'templates.pdf' + + salutation = (request_obj.get_gender_display() or '').strip() + display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name + + raw_sections = build_intro_sections_for_request(request_obj) + checked_map = session.checklist_state or {} + exported_sections = [] + checked_count = 0 + total_count = 0 + for section in raw_sections: + checked_items = [] + for item in section['items']: + checked = bool(checked_map.get(item['id'])) + total_count += 1 + if checked: + checked_count += 1 + checked_items.append({'label': item['label']}) + if checked_items: + exported_sections.append({ + 'title': section['title'], + 'rows': [checked_items[i:i + 2] for i in range(0, len(checked_items), 2)], + }) + + requester_email = request_obj.onboarded_by_email or '-' + requester_name = request_obj.onboarded_by_name or _resolve_user_display_name(request_obj.onboarded_by_email) or '-' + + context = { + 'DISPLAY_NAME': display_name, + 'ABTEILUNG': request_obj.department or '-', + 'BERUFSBEZEICHNUNG': request_obj.job_title or '-', + 'VERTRAGSBEGINN': request_obj.contract_start, + 'EMAIL': request_obj.work_email or '-', + 'REQUESTED_BY_NAME': requester_name, + 'REQUESTED_BY_EMAIL': requester_email, + 'SESSION_STATUS': session.get_status_display(), + 'SESSION_COMPLETED_BY': session.completed_by_name or '-', + 'SESSION_COMPLETED_AT': session.completed_at or '-', + 'SESSION_UPDATED_AT': session.updated_at, + 'SESSION_NOTES': session.notes or '-', + 'INTRO_SECTIONS': exported_sections, + } + + html = _render_html(template_path, context) + _generate_content_pdf(html, temp_pdf) + _overlay_with_letterhead(temp_pdf, letterhead_path, output_pdf) + + if temp_pdf.exists(): + temp_pdf.unlink(missing_ok=True) + return output_pdf + + def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path: safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'offboarding_{request_obj.id}') output_pdf = settings.PDF_OUTPUT_DIR / f'offboarding_letter_{safe_name}.pdf' diff --git a/backend/workflows/templates/workflows/home.html b/backend/workflows/templates/workflows/home.html index a16ec25..ccac5c6 100644 --- a/backend/workflows/templates/workflows/home.html +++ b/backend/workflows/templates/workflows/home.html @@ -383,6 +383,11 @@

Felder, Schritte und Optionen verwalten.

Öffnen +
+

Einweisungs-Builder

+

Checklistenpunkte für das Einweisungsprotokoll konfigurieren.

+ Öffnen +

Projekt Wiki

Dokumentation, Architektur und Runbook.

diff --git a/backend/workflows/templates/workflows/intro_builder.html b/backend/workflows/templates/workflows/intro_builder.html new file mode 100644 index 0000000..e8b0d29 --- /dev/null +++ b/backend/workflows/templates/workflows/intro_builder.html @@ -0,0 +1,144 @@ +{% load static %} + + + + + + Einweisungs-Builder + + + + +
+ + +
+
+

Einweisungs-Builder

+

Checklistenpunkte für das Einweisungs- und Übergabeprotokoll verwalten.

+
+ Zum Dashboard +
+ + {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + +
+ {% csrf_token %} + +
+
+ + +
+
+ + +
+
+ +
+
+
Bedingungen und Sortierung können anschließend in der Tabelle bearbeitet werden.
+
+ +
+ {% csrf_token %} + +
+ + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
SortierungAbschnittChecklistenpunktFeld-BedingungOperatorWertAktivLöschen
+ + {{ forloop.counter }} + + + + + + + + +
Noch keine benutzerdefinierten Checklistenpunkte angelegt. Solange die Liste leer ist, nutzt das System die integrierten Standardpunkte.
+
+
Reihenfolge folgt derzeit der Tabellenreihenfolge beim Speichern.
+
+ +
+
+
+ + diff --git a/backend/workflows/templates/workflows/onboarding_intro_session.html b/backend/workflows/templates/workflows/onboarding_intro_session.html new file mode 100644 index 0000000..9eb34ff --- /dev/null +++ b/backend/workflows/templates/workflows/onboarding_intro_session.html @@ -0,0 +1,161 @@ +{% load static %} + + + + + + Einweisung durchführen + + + + +
+ + +
+

Einweisung durchführen

+

Einfache Live-Checkliste für das persönliche Onboarding-Gespräch. Punkte abhaken, Notizen ergänzen, als Entwurf speichern oder als abgeschlossen markieren.

+
+ +
+ {% if messages %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + +
+
+

Mitarbeitende Person

+
+ Name{{ display_name|default:onboarding.full_name }} + Abteilung{{ onboarding.department|default:"-" }} + Berufsbezeichnung{{ onboarding.job_title|default:"-" }} + Dienstliche E-Mail{{ onboarding.work_email|default:"-" }} + Vertragsbeginn{{ onboarding.contract_start|default:"-" }} +
+
+
+

Sitzungsstatus

+
+ Status + {{ session.get_status_display }} + Abgeschlossen von{{ session.completed_by_name|default:"-" }} + Abgeschlossen am{% if session.completed_at %}{{ session.completed_at|date:"Y-m-d H:i" }}{% else %}-{% endif %} + Letzte Änderung{{ session.updated_at|date:"Y-m-d H:i" }} +
+
+
+ +
+
+
+
Fortschritt der Einweisung
+
{{ checked_count }} von {{ total_count }} Punkten erledigt
+
+
{{ progress_percent }}%
+
+
+
+ +
+ {% csrf_token %} + {% for section in sections %} +
+
{{ section.title }}
+
+ {% for item in section.items %} + + {% endfor %} +
+
+ {% endfor %} + +
+

Notizen

+ +
Diese Seite bleibt bewusst einfach: echte Web-Checkboxen, Notizen und ein klarer Entwurf/Abschluss-Status. Kein zusätzlicher komplexer PDF-Signatur-Workflow.
+
+ + + +
+
+
+ +
+

Live-Protokoll

+
Erzeugt das Live-Protokoll nur aus den aktuell gespeicherten Haken und Notizen.
+
+
+ {% csrf_token %} + +
+ {% if session_pdf_url %} + Live-Protokoll öffnen + {% endif %} +
+
+
+
+ + diff --git a/backend/workflows/templates/workflows/project_wiki.html b/backend/workflows/templates/workflows/project_wiki.html index 7e62962..2fd9e5f 100644 --- a/backend/workflows/templates/workflows/project_wiki.html +++ b/backend/workflows/templates/workflows/project_wiki.html @@ -98,6 +98,8 @@
  • Form saves request; requester identity is taken from logged-in user.
  • Task process_onboarding_request runs in worker.
  • PDF is generated using HTML template + letterhead overlay.
  • +
  • Staff can additionally generate a separate Einweisungs- und Übergabeprotokoll from the Requests Dashboard for face-to-face employee introduction and sign-off.
  • +
  • Staff can also open a simple live Einweisung durchführen page with real web checkboxes, notes, and draft/completed tracking.
  • Default notification emails + optional rule-based emails are sent.
  • Welcome email job is scheduled (configurable delay).
  • PDF is uploaded to Nextcloud if enabled.
  • @@ -134,11 +136,16 @@

    7) PDF Engine

    8) Integrations

    @@ -157,9 +164,12 @@

    9) Admin Apps (Home)

    @@ -182,6 +192,7 @@
  • After template/form/task changes, restart web and worker containers.
  • Run python manage.py check before release.
  • On a fresh database, the web container boot sequence now runs python manage.py bootstrap_initial_users right after migrations.
  • +
  • After adding or editing intro checklist items, regenerate the intro PDF from the Requests Dashboard to reflect the updated checklist.
  • Initial Bootstrap Users

    diff --git a/backend/workflows/templates/workflows/requests_dashboard.html b/backend/workflows/templates/workflows/requests_dashboard.html index abc429e..5868873 100644 --- a/backend/workflows/templates/workflows/requests_dashboard.html +++ b/backend/workflows/templates/workflows/requests_dashboard.html @@ -7,141 +7,1077 @@ Anfragen Dashboard
    - -
    - Zur Startseite +
    +
    + +
    +
    -

    Anfragen Dashboard

    -

    Neueste Onboarding- und Offboarding-Vorgänge inklusive PDF-Status.

    + +
    +
    +
    + Operations Console +

    Anfragen Dashboard

    +

    Steuert Onboarding- und Offboarding-Prozesse an einem Ort. Die Oberfläche priorisiert jetzt Kennzahlen, Aktivität und direkte Aktionen in der Vorgangsliste.

    +
    + Onboarding + Offboarding + PDFs + Live-Protokolle + Suche + Bulk-Aktionen +
    +
    +
    +
    +
    +

    Aktivitätsverlauf

    +

    Die letzten 14 Tage in einer kompakten Ansicht über alle Onboarding- und Offboarding-Vorgänge.

    +
    + 14 Tage +
    +
    + {% for point in chart_points %} +
    +
    {{ point.total }}
    +
    +
    +
    +
    {{ point.label }}
    +
    + {% endfor %} +
    +
    +
    +
    + {% if messages %} {% for message in messages %}
    {{ message }}
    {% endfor %} {% endif %} -
    -
    {{ onboarding_total }}
    Onboarding gesamt
    -
    {{ offboarding_total }}
    Offboarding gesamt
    -
    {{ combined_total }}
    Gesamtvorgänge
    -
    -
    -

    Aktivität der letzten 14 Tage (Onboarding + Offboarding)

    -
    - {% for p in chart_points %} -
    -
    -
    {{ p.total }}
    -
    {{ p.label }}
    + +
    +
    +
    +
    +

    Onboarding

    +
    {{ onboarding_total }}
    +
    +
    ON
    - {% endfor %} -
    -
    - +
    Alle erfassten Onboarding-Vorgänge im aktuellen System.
    + +
    +
    +
    +

    Offboarding

    +
    {{ offboarding_total }}
    +
    +
    OFF
    +
    +
    Austritte und Rückgaben in derselben Prozessübersicht.
    +
    +
    +
    +
    +

    Gesamtbestand

    +
    {{ combined_total }}
    +
    +
    Σ
    +
    +
    Alle Vorgänge, durchsuchbar und mit Dokumenten verknüpft.
    +
    +
    +
    +
    +

    Aktivität 14 Tage

    +
    {{ chart_points|length }}
    +
    +
    D
    +
    +
    Zeitraum des visuellen Aktivitätsverlaufs in dieser Übersicht.
    +
    +
    - {% if request.user.is_staff %} -
    - {% csrf_token %} -
    - - 0 ausgewählt -
    - {% endif %} - - - - - {% if request.user.is_staff %}{% endif %} - - - - - - - {% if request.user.is_staff %}{% endif %} - - - - {% for row in rows %} - - {% if request.user.is_staff %} - - {% endif %} - - - - - - - {% if request.user.is_staff %} - - {% endif %} - - {% empty %} - - {% endfor %} - -
    TypNameE-MailErstelltStatusPDFAktion
    - - {{ row.kind }}{{ row.name }}{{ row.work_email }}{{ row.created_at|date:"Y-m-d H:i" }} - {% if row.pdf_url %} - {{ row.status }} - {% else %} - {{ row.status }} +
    +
    +
    +
    +

    Vorgänge

    +

    Dokumente, Status und Einweisungsaktionen in einer verdichteten Arbeitsansicht.

    +
    +
    {{ rows|length }} Einträge sichtbar
    +
    +
    +
    +
    + + +
    + + {% if search_query %} + Zurücksetzen + {% endif %} +
    + +
    Datensätze können direkt in der Tabelle gefiltert, geöffnet, geprüft oder gelöscht werden.
    +
    + {% if request.user.is_staff %} +
    +
    + {% csrf_token %} +
    + 0 ausgewählt + +
    +
    +
    {% endif %} -
    - {% if row.pdf_url %} - PDF öffnen - {% else %} - - - {% endif %} - - -
    Noch keine Vorgänge vorhanden.
    - {% if request.user.is_staff %} - - {% endif %} - -
    +
    + +
    + + + + {% if request.user.is_staff %}{% endif %} + + + + + + + {% if request.user.is_staff %}{% endif %} + {% if request.user.is_staff %}{% endif %} + + + + {% for row in rows %} + + {% if request.user.is_staff %} + + {% endif %} + + + + + + + {% if request.user.is_staff %} + + + {% endif %} + + {% empty %} + + + + {% endfor %} + +
    TypPersonE-MailErstelltStatusDokumentEinweisungAktion
    + + + {{ row.kind }} + +
    +
    {{ row.name }}
    +
    {{ row.kind }} Vorgang
    +
    +
    + {{ row.work_email }} + {{ row.created_at|date:"Y-m-d H:i" }} + {{ row.status }} + + {% if row.pdf_url %} + PDF öffnen + {% else %} + Noch nicht verfügbar + {% endif %} + + {% if row.kind_slug == 'onboarding' %} +
    + Einweisung +
    +
    +
    Live-Protokoll
    +
    + Einweisung öffnen + {% if row.intro_session and row.intro_session.exported_pdf_url %} + Live-Protokoll öffnen + {% endif %} +
    +
    +
    +
    Standard-Einweisungs-PDF
    +
    + {% if row.intro_pdf_url %} +
    + {% csrf_token %} + +
    + Standard-PDF öffnen + {% else %} +
    + {% csrf_token %} + +
    + {% endif %} +
    +
    +
    + {% if row.intro_session %} +
    Status: {{ row.intro_session.get_status_display }}
    + {% endif %} +
    + {% else %} + Nicht relevant + {% endif %} +
    +
    + {% csrf_token %} + +
    +
    Noch keine Vorgänge vorhanden.
    +
    + + + diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index c8225d5..7a6897d 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -30,5 +30,9 @@ urlpatterns = [ path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'), path('admin-tools/form-builder/', views.form_builder_page, name='form_builder_page'), path('admin-tools/form-builder/save-order/', views.form_builder_save_order, name='form_builder_save_order'), + path('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'), + path('requests/onboarding//intro-session/', views.onboarding_intro_session_page, name='onboarding_intro_session_page'), + path('requests/onboarding//intro-session/pdf/', views.generate_onboarding_intro_session_pdf, name='generate_onboarding_intro_session_pdf'), + path('requests/onboarding//intro-pdf/generate/', views.generate_onboarding_intro_pdf, name='generate_onboarding_intro_pdf'), path('requests/delete///', views.delete_request_from_dashboard, name='delete_request_from_dashboard'), ] diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 466bd2f..cbd7b88 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -25,11 +25,14 @@ from .form_builder import ( ONBOARDING_PAGE_ORDER, ensure_form_field_configs, ) -from .models import EmployeeProfile, FormFieldConfig, FormOption, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, WorkflowConfig +from .models import EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, 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 ( DEFAULT_NOTIFICATION_TEMPLATES, + _generate_onboarding_intro_pdf, + _generate_onboarding_intro_session_pdf, + build_intro_sections_for_request, process_offboarding_request, process_onboarding_request, send_scheduled_welcome_email, @@ -272,6 +275,9 @@ def requests_dashboard(request): rows = [] for obj in onboarding_items: + intro_session = OnboardingIntroductionSession.objects.filter(onboarding_request=obj).first() + if intro_session and intro_session.exported_pdf_path: + intro_session.exported_pdf_url = f"/media/pdfs/{Path(intro_session.exported_pdf_path).name}" rows.append( { 'id': obj.id, @@ -281,6 +287,8 @@ def requests_dashboard(request): 'work_email': obj.work_email, 'created_at': obj.created_at, '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': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung', } ) @@ -294,6 +302,8 @@ def requests_dashboard(request): 'work_email': obj.work_email, 'created_at': obj.created_at, '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': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung', } ) @@ -400,6 +410,101 @@ def onboarding_success(request, request_id: int): return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url}) +@login_required +@user_passes_test(_is_staff) +@require_POST +def generate_onboarding_intro_pdf(request, request_id: int): + obj = get_object_or_404(OnboardingRequest, id=request_id) + pdf_path = _generate_onboarding_intro_pdf(obj) + obj.intro_pdf_path = str(pdf_path) + obj.save(update_fields=['intro_pdf_path']) + messages.success(request, 'Einweisungs- und Übergabeprotokoll wurde erzeugt.') + return redirect('requests_dashboard') + + +@login_required +@user_passes_test(_is_staff) +@require_POST +def generate_onboarding_intro_session_pdf(request, request_id: int): + onboarding = get_object_or_404(OnboardingRequest, id=request_id) + session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) + pdf_path = _generate_onboarding_intro_session_pdf(session, admin_signature_name=_display_user_name(request.user)) + session.exported_pdf_path = str(pdf_path) + session.save(update_fields=['exported_pdf_path']) + messages.success(request, 'Einweisungsprotokoll aus Live-Status wurde erzeugt.') + return redirect('onboarding_intro_session_page', request_id=request_id) + + +@login_required +@user_passes_test(_is_staff) +def onboarding_intro_session_page(request, request_id: int): + onboarding = get_object_or_404(OnboardingRequest, id=request_id) + session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding) + sections = build_intro_sections_for_request(onboarding) + + if request.method == 'POST': + checked_ids = set(request.POST.getlist('checked_items')) + checklist_state = {} + for section in sections: + for item in section['items']: + checklist_state[item['id']] = item['id'] in checked_ids + + action = (request.POST.get('session_action') or 'save').strip() + session.checklist_state = checklist_state + session.notes = (request.POST.get('notes') or '').strip() + if action == 'reset': + session.checklist_state = {} + session.notes = '' + session.status = 'draft' + session.completed_at = None + session.completed_by_name = '' + session.exported_pdf_path = '' + session.save(update_fields=['checklist_state', 'notes', 'status', 'completed_at', 'completed_by_name', 'exported_pdf_path']) + messages.success(request, 'Einweisung wurde zurückgesetzt.') + return redirect('onboarding_intro_session_page', request_id=request_id) + if action == 'complete': + session.status = 'completed' + session.completed_at = timezone.now() + session.completed_by_name = _display_user_name(request.user) + messages.success(request, 'Einweisung wurde als abgeschlossen gespeichert.') + else: + session.status = 'draft' + session.completed_at = None + session.completed_by_name = '' + messages.success(request, 'Einweisung wurde als Entwurf gespeichert.') + session.save() + return redirect('onboarding_intro_session_page', request_id=request_id) + + checked_map = session.checklist_state or {} + checked_count = 0 + total_count = 0 + for section in sections: + for item in section['items']: + item['checked'] = bool(checked_map.get(item['id'])) + total_count += 1 + if item['checked']: + checked_count += 1 + + salutation = (onboarding.get_gender_display() or '').strip() + display_name = f"{salutation} {onboarding.full_name}".strip() if salutation else onboarding.full_name + progress_percent = int((checked_count / total_count) * 100) if total_count else 0 + + return render( + request, + 'workflows/onboarding_intro_session.html', + { + 'onboarding': onboarding, + 'session': session, + 'display_name': display_name, + 'sections': sections, + 'checked_count': checked_count, + 'total_count': total_count, + 'progress_percent': progress_percent, + 'session_pdf_url': f"/media/pdfs/{Path(session.exported_pdf_path).name}" if session.exported_pdf_path else None, + }, + ) + + @login_required @ensure_csrf_cookie def offboarding_create(request): @@ -608,6 +713,108 @@ def form_builder_page(request): ) +@login_required +@user_passes_test(_is_staff) +def intro_builder_page(request): + if request.method == 'POST': + delete_id = (request.POST.get('delete_item_id') or '').strip() + if delete_id: + item = IntroChecklistItem.objects.filter(id=delete_id).first() + if item: + item.delete() + messages.success(request, 'Checklistenpunkt wurde gelöscht.') + else: + messages.error(request, 'Checklistenpunkt nicht gefunden.') + return redirect('intro_builder_page') + + action = (request.POST.get('builder_action') or '').strip() + if action == 'add_item': + section = (request.POST.get('section') or '').strip() + label = (request.POST.get('label') or '').strip() + if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}: + messages.error(request, 'Ungültiger Abschnitt.') + return redirect('intro_builder_page') + if not label: + messages.error(request, 'Bitte eine Bezeichnung für den Checklistenpunkt angeben.') + return redirect('intro_builder_page') + next_sort = ( + IntroChecklistItem.objects.filter(section=section).order_by('-sort_order').values_list('sort_order', flat=True).first() + ) + IntroChecklistItem.objects.create( + section=section, + label=label, + sort_order=(next_sort + 1) if next_sort is not None else 0, + is_active=True, + condition_operator='always', + ) + messages.success(request, 'Checklistenpunkt wurde hinzugefügt.') + return redirect('intro_builder_page') + + if action == 'save_items': + item_ids = request.POST.getlist('item_ids') + valid_sections = {k for k, _ in IntroChecklistItem.SECTION_CHOICES} + valid_ops = {k for k, _ in IntroChecklistItem.OPERATOR_CHOICES} + for pos, raw_id in enumerate(item_ids): + item = IntroChecklistItem.objects.filter(id=raw_id).first() + if not item: + continue + section = (request.POST.get(f'section_{item.id}') or item.section).strip() + if section not in valid_sections: + section = item.section + operator = (request.POST.get(f'operator_{item.id}') or item.condition_operator).strip() + if operator not in valid_ops: + operator = 'always' + item.section = section + item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label + item.is_active = request.POST.get(f'active_{item.id}') == 'on' + item.condition_field = (request.POST.get(f'field_{item.id}') or '').strip() + item.condition_operator = operator + item.condition_value = (request.POST.get(f'value_{item.id}') or '').strip() + item.sort_order = pos + item.save( + update_fields=[ + 'section', + 'label', + 'is_active', + 'condition_field', + 'condition_operator', + 'condition_value', + 'sort_order', + ] + ) + messages.success(request, 'Einweisungs-Checkliste wurde gespeichert.') + return redirect('intro_builder_page') + + condition_field_choices = [ + ('', 'Keine Bedingung'), + ('needed_devices', 'Benötigte Geräte und Gegenstände'), + ('needed_software', 'Benötigte Software'), + ('needed_accesses', 'Benötigte Zugänge'), + ('needed_workspace_groups', 'Benötigte Gruppen im Workspace'), + ('needed_resources', 'Benötigte Ressourcen'), + ('additional_hardware', 'Zusätzliche Hardware'), + ('additional_software', 'Zusätzliche Software'), + ('additional_access_text', 'Weitere Zugänge (Freitext)'), + ('group_mailboxes_required', 'Gruppenpostfächer erforderlich'), + ('order_business_cards', 'Visitenkarten bestellt'), + ('phone_number', 'Direktwahl vorhanden'), + ('successor_name', 'Nachfolge vorhanden'), + ('department', 'Abteilung'), + ] + + items = list(IntroChecklistItem.objects.all().order_by('section', 'sort_order', 'label')) + return render( + request, + 'workflows/intro_builder.html', + { + 'items': items, + 'section_choices': IntroChecklistItem.SECTION_CHOICES, + 'operator_choices': IntroChecklistItem.OPERATOR_CHOICES, + 'condition_field_choices': condition_field_choices, + }, + ) + + @login_required @user_passes_test(_is_staff) def integrations_setup_page(request):