snapshot: preserve dashboard redesign and live protocol workflow state
This commit is contained in:
@@ -3,7 +3,7 @@ from django.conf import settings
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .emailing import send_system_email
|
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)
|
@admin.register(EmployeeProfile)
|
||||||
@@ -43,6 +43,23 @@ class FormFieldConfigAdmin(admin.ModelAdmin):
|
|||||||
list_editable = ('page_key', 'sort_order', 'is_visible', 'is_required')
|
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)
|
@admin.register(WorkflowConfig)
|
||||||
class WorkflowConfigAdmin(admin.ModelAdmin):
|
class WorkflowConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
backend/workflows/migrations/0026_introchecklistitem.py
Normal file
27
backend/workflows/migrations/0026_introchecklistitem.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -78,6 +78,7 @@ class OnboardingRequest(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
generated_pdf_path = models.CharField(max_length=500, blank=True)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -231,6 +232,56 @@ class ScheduledWelcomeEmail(models.Model):
|
|||||||
return f'Welcome #{self.id} | {self.recipient_email} | {self.status}'
|
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):
|
class WorkflowConfig(models.Model):
|
||||||
name = models.CharField(max_length=120, default='Default', unique=True)
|
name = models.CharField(max_length=120, default='Default', unique=True)
|
||||||
it_onboarding_email = models.EmailField(blank=True)
|
it_onboarding_email = models.EmailField(blank=True)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from jinja2 import Template
|
|||||||
from pypdf import PageObject, PdfReader, PdfWriter
|
from pypdf import PageObject, PdfReader, PdfWriter
|
||||||
from xhtml2pdf import pisa
|
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 .emailing import send_system_email
|
||||||
from .services import upload_to_nextcloud
|
from .services import upload_to_nextcloud
|
||||||
from .services import get_email_test_redirect, is_email_test_mode
|
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
|
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(
|
def _send_workflow_email(
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
@@ -609,6 +714,107 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
|
|||||||
return output_pdf
|
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:
|
def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
|
||||||
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'offboarding_{request_obj.id}')
|
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'
|
output_pdf = settings.PDF_OUTPUT_DIR / f'offboarding_letter_{safe_name}.pdf'
|
||||||
|
|||||||
@@ -383,6 +383,11 @@
|
|||||||
<p>Felder, Schritte und Optionen verwalten.</p>
|
<p>Felder, Schritte und Optionen verwalten.</p>
|
||||||
<a class="btn btn-secondary" href="/admin-tools/form-builder/">Öffnen</a>
|
<a class="btn btn-secondary" href="/admin-tools/form-builder/">Öffnen</a>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="admin-card">
|
||||||
|
<h3>Einweisungs-Builder</h3>
|
||||||
|
<p>Checklistenpunkte für das Einweisungsprotokoll konfigurieren.</p>
|
||||||
|
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">Öffnen</a>
|
||||||
|
</section>
|
||||||
<section class="admin-card">
|
<section class="admin-card">
|
||||||
<h3>Projekt Wiki</h3>
|
<h3>Projekt Wiki</h3>
|
||||||
<p>Dokumentation, Architektur und Runbook.</p>
|
<p>Dokumentation, Architektur und Runbook.</p>
|
||||||
|
|||||||
144
backend/workflows/templates/workflows/intro_builder.html
Normal file
144
backend/workflows/templates/workflows/intro_builder.html
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Einweisungs-Builder</title>
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #1b2b43; padding: 20px; }
|
||||||
|
.shell { max-width: 1180px; margin: 0 auto; background: #fff; border: 1px solid #d7e0ea; border-radius: 14px; padding: 18px; }
|
||||||
|
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.brand-logo { width: 190px; max-width: 100%; height: auto; display: block; }
|
||||||
|
h1 { margin: 0; color: #000078; font-size: 28px; }
|
||||||
|
.sub { margin: 8px 0 14px; color: #5f6f85; }
|
||||||
|
.flash { margin: 0 0 12px; padding: 10px; border-radius: 8px; border: 1px solid #dbe5f2; background: #f8fbff; }
|
||||||
|
.flash.error { border-color: #f4c7c7; background: #fff1f1; color: #8e1e1e; }
|
||||||
|
.flash.success { border-color: #bfe6c9; background: #edf9f1; color: #116634; }
|
||||||
|
.card { border: 1px solid #d7e0ea; border-radius: 12px; padding: 14px; background: #fcfdff; margin-bottom: 14px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1.1fr 1.4fr 1fr 1fr 1fr auto; gap: 10px; align-items: end; }
|
||||||
|
.field label { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.field input, .field select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; }
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #e2e8f0; padding: 8px; text-align: left; vertical-align: top; }
|
||||||
|
th { background: #f6f8fb; color: #334155; }
|
||||||
|
.mini { color: #64748b; font-size: 12px; }
|
||||||
|
.hint { margin-top: 6px; color: #5f6f85; font-size: 13px; }
|
||||||
|
.table-controls input[type="text"], .table-controls select { width: 100%; min-height: 36px; padding: 7px 9px; border: 1px solid #cfd9e8; border-radius: 8px; box-sizing: border-box; }
|
||||||
|
.table-controls input[type="checkbox"] { transform: scale(1.1); }
|
||||||
|
.actions { white-space: nowrap; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<div class="topbar">
|
||||||
|
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
||||||
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div>
|
||||||
|
<h1>Einweisungs-Builder</h1>
|
||||||
|
<p class="sub">Checklistenpunkte für das Einweisungs- und Übergabeprotokoll verwalten.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-secondary" href="/requests/">Zum Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="flash {% if message.tags == 'error' %}error{% else %}success{% endif %}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="card" method="post" action="/admin-tools/intro-builder/">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="builder_action" value="add_item" />
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="section">Abschnitt</label>
|
||||||
|
<select id="section" name="section">
|
||||||
|
{% for value, label in section_choices %}
|
||||||
|
<option value="{{ value }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="label">Checklistenpunkt</label>
|
||||||
|
<input id="label" name="label" placeholder="z. B. Nextcloud Ordnerstruktur erklärt" required />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" type="submit">Punkt hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">Bedingungen und Sortierung können anschließend in der Tabelle bearbeitet werden.</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="card" method="post" action="/admin-tools/intro-builder/">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="builder_action" value="save_items" />
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table-controls">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Sortierung</th>
|
||||||
|
<th>Abschnitt</th>
|
||||||
|
<th>Checklistenpunkt</th>
|
||||||
|
<th>Feld-Bedingung</th>
|
||||||
|
<th>Operator</th>
|
||||||
|
<th>Wert</th>
|
||||||
|
<th>Aktiv</th>
|
||||||
|
<th>Löschen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="hidden" name="item_ids" value="{{ item.id }}" />
|
||||||
|
{{ forloop.counter }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="section_{{ item.id }}">
|
||||||
|
{% for value, label in section_choices %}
|
||||||
|
<option value="{{ value }}" {% if item.section == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
|
||||||
|
<td>
|
||||||
|
<select name="field_{{ item.id }}">
|
||||||
|
{% for value, label in condition_field_choices %}
|
||||||
|
<option value="{{ value }}" {% if item.condition_field == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="operator_{{ item.id }}">
|
||||||
|
{% for value, label in operator_choices %}
|
||||||
|
<option value="{{ value }}" {% if item.condition_operator == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" name="value_{{ item.id }}" value="{{ item.condition_value }}" placeholder="z. B. HR Works" /></td>
|
||||||
|
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-secondary" type="submit" name="delete_item_id" value="{{ item.id }}" onclick="return confirm('Checklistenpunkt wirklich löschen?');">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="8">Noch keine benutzerdefinierten Checklistenpunkte angelegt. Solange die Liste leer ist, nutzt das System die integrierten Standardpunkte.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="hint">Reihenfolge folgt derzeit der Tabellenreihenfolge beim Speichern.</div>
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<button class="btn btn-primary" type="submit">Checkliste speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Einweisung durchführen</title>
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/buttons.css' %}" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand-blue: #000078;
|
||||||
|
--ink: #17253b;
|
||||||
|
--muted: #607087;
|
||||||
|
--line: #d7e0ea;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--soft: #f4f8ff;
|
||||||
|
--soft-strong: #eef4ff;
|
||||||
|
--ok-bg: #edf9f1;
|
||||||
|
--ok-ink: #116634;
|
||||||
|
--warn-bg: #fff8ea;
|
||||||
|
--warn-ink: #8a5a00;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: linear-gradient(180deg, #eef4ff, #f8fbff); color: var(--ink); padding: 24px; }
|
||||||
|
.shell { max-width: 1180px; margin: 0 auto; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; box-shadow: 0 18px 42px rgba(16,32,57,.10); overflow: hidden; }
|
||||||
|
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 18px 22px; border-bottom: 1px solid var(--line); background: #fff; }
|
||||||
|
.brand-logo { width: 210px; max-width: 100%; height: auto; display: block; }
|
||||||
|
.top-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.hero { padding: 20px 22px 18px; border-bottom: 1px solid var(--line); background: linear-gradient(135deg, rgba(0,0,120,.06), rgba(0,0,120,0) 48%), linear-gradient(180deg, #ffffff, #f8fbff); }
|
||||||
|
.hero h1 { margin: 0; font-size: 32px; line-height: 1.08; color: var(--brand-blue); }
|
||||||
|
.sub { margin: 8px 0 0; color: var(--muted); max-width: 780px; }
|
||||||
|
.content { padding: 20px 22px 24px; }
|
||||||
|
.flash { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; border: 1px solid #bfe6c9; background: var(--ok-bg); color: var(--ok-ink); }
|
||||||
|
.meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 16px; }
|
||||||
|
.card { border: 1px solid var(--line); border-radius: 14px; background: #fff; padding: 14px; }
|
||||||
|
.card h2 { margin: 0 0 10px; font-size: 16px; color: #1b3764; }
|
||||||
|
.progress-block { border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(180deg, #ffffff, #f9fbff); padding: 14px; margin-bottom: 16px; }
|
||||||
|
.progress-top { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.progress-label { font-size: 15px; font-weight: 700; color: #18345f; }
|
||||||
|
.progress-meta { color: var(--muted); font-size: 13px; }
|
||||||
|
.progress-bar { width: 100%; height: 12px; border-radius: 999px; overflow: hidden; background: #e7eefb; border: 1px solid #d6e1f5; }
|
||||||
|
.progress-fill { height: 100%; background: linear-gradient(90deg, #3056a3, #0f5fcf); }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: 170px 1fr; gap: 8px 10px; font-size: 14px; }
|
||||||
|
.meta-grid strong { color: #334155; }
|
||||||
|
.status-pill { display: inline-block; padding: 4px 10px; border-radius: 999px; border: 1px solid #d7e0ea; background: #f8fbff; color: #486183; font-size: 12px; font-weight: 700; }
|
||||||
|
.status-pill.done { background: var(--ok-bg); color: var(--ok-ink); border-color: #bfe6c9; }
|
||||||
|
.status-pill.draft { background: var(--warn-bg); color: var(--warn-ink); border-color: #f5d8a8; }
|
||||||
|
.section { border: 1px solid var(--line); border-radius: 14px; overflow: hidden; margin-bottom: 14px; background: #fff; }
|
||||||
|
.section-head { padding: 11px 14px; font-weight: 700; color: #1f376b; background: var(--soft-strong); border-bottom: 1px solid #d5e2f9; }
|
||||||
|
.items { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; padding: 6px 14px 10px; }
|
||||||
|
.item { display: flex; align-items: flex-start; gap: 12px; padding: 10px 0; border-bottom: 1px solid #eef3f8; }
|
||||||
|
.item:nth-last-child(-n+2) { border-bottom: 0; }
|
||||||
|
.item input { margin-top: 2px; width: 18px; height: 18px; accent-color: var(--brand-blue); }
|
||||||
|
.item span { line-height: 1.45; }
|
||||||
|
textarea { width: 100%; min-height: 150px; border: 1px solid #cfd9e8; border-radius: 10px; padding: 11px 12px; font: inherit; resize: vertical; }
|
||||||
|
.help { color: var(--muted); font-size: 13px; margin-top: 8px; }
|
||||||
|
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 14px; }
|
||||||
|
@media (max-width: 900px) { .meta, .items { grid-template-columns: 1fr; } .topbar { flex-direction: column; } .top-actions { justify-content: flex-start; } .meta-grid { grid-template-columns: 1fr; } .item:nth-last-child(-n+2) { border-bottom: 1px solid #eef3f8; } .item:last-child { border-bottom: 0; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<div class="topbar">
|
||||||
|
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
||||||
|
<div class="top-actions">
|
||||||
|
<a class="btn btn-secondary" href="/requests/">Zum Dashboard</a>
|
||||||
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Einweisung durchführen</h1>
|
||||||
|
<p class="sub">Einfache Live-Checkliste für das persönliche Onboarding-Gespräch. Punkte abhaken, Notizen ergänzen, als Entwurf speichern oder als abgeschlossen markieren.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="flash">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Mitarbeitende Person</h2>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<strong>Name</strong><span>{{ display_name|default:onboarding.full_name }}</span>
|
||||||
|
<strong>Abteilung</strong><span>{{ onboarding.department|default:"-" }}</span>
|
||||||
|
<strong>Berufsbezeichnung</strong><span>{{ onboarding.job_title|default:"-" }}</span>
|
||||||
|
<strong>Dienstliche E-Mail</strong><span>{{ onboarding.work_email|default:"-" }}</span>
|
||||||
|
<strong>Vertragsbeginn</strong><span>{{ onboarding.contract_start|default:"-" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sitzungsstatus</h2>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<strong>Status</strong>
|
||||||
|
<span><span class="status-pill {% if session.status == 'completed' %}done{% else %}draft{% endif %}">{{ session.get_status_display }}</span></span>
|
||||||
|
<strong>Abgeschlossen von</strong><span>{{ session.completed_by_name|default:"-" }}</span>
|
||||||
|
<strong>Abgeschlossen am</strong><span>{% if session.completed_at %}{{ session.completed_at|date:"Y-m-d H:i" }}{% else %}-{% endif %}</span>
|
||||||
|
<strong>Letzte Änderung</strong><span>{{ session.updated_at|date:"Y-m-d H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-block">
|
||||||
|
<div class="progress-top">
|
||||||
|
<div>
|
||||||
|
<div class="progress-label">Fortschritt der Einweisung</div>
|
||||||
|
<div class="progress-meta">{{ checked_count }} von {{ total_count }} Punkten erledigt</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-pill {% if session.status == 'completed' %}done{% else %}draft{% endif %}">{{ progress_percent }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress_percent }}%;"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for section in sections %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-head">{{ section.title }}</div>
|
||||||
|
<div class="items">
|
||||||
|
{% for item in section.items %}
|
||||||
|
<label class="item">
|
||||||
|
<input type="checkbox" name="checked_items" value="{{ item.id }}" {% if item.checked %}checked{% endif %} />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Notizen</h2>
|
||||||
|
<textarea id="notes" name="notes">{{ session.notes }}</textarea>
|
||||||
|
<div class="help">Diese Seite bleibt bewusst einfach: echte Web-Checkboxen, Notizen und ein klarer Entwurf/Abschluss-Status. Kein zusätzlicher komplexer PDF-Signatur-Workflow.</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary" type="submit" name="session_action" value="save">Als Entwurf speichern</button>
|
||||||
|
<button class="btn btn-primary" type="submit" name="session_action" value="complete">Als abgeschlossen markieren</button>
|
||||||
|
<button class="btn btn-secondary" type="submit" name="session_action" value="reset" onclick="return confirm('Einweisung wirklich zurücksetzen?');">Alles zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Live-Protokoll</h2>
|
||||||
|
<div class="help">Erzeugt das Live-Protokoll nur aus den aktuell gespeicherten Haken und Notizen.</div>
|
||||||
|
<div class="actions">
|
||||||
|
<form method="post" action="/requests/onboarding/{{ onboarding.id }}/intro-session/pdf/" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="btn btn-secondary" type="submit">Live-Protokoll erzeugen</button>
|
||||||
|
</form>
|
||||||
|
{% if session_pdf_url %}
|
||||||
|
<a class="btn btn-secondary" href="{{ session_pdf_url }}" target="_blank" rel="noopener">Live-Protokoll öffnen</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -98,6 +98,8 @@
|
|||||||
<li>Form saves request; requester identity is taken from logged-in user.</li>
|
<li>Form saves request; requester identity is taken from logged-in user.</li>
|
||||||
<li>Task <code>process_onboarding_request</code> runs in worker.</li>
|
<li>Task <code>process_onboarding_request</code> runs in worker.</li>
|
||||||
<li>PDF is generated using HTML template + letterhead overlay.</li>
|
<li>PDF is generated using HTML template + letterhead overlay.</li>
|
||||||
|
<li>Staff can additionally generate a separate <code>Einweisungs- und Übergabeprotokoll</code> from the Requests Dashboard for face-to-face employee introduction and sign-off.</li>
|
||||||
|
<li>Staff can also open a simple live <code>Einweisung durchführen</code> page with real web checkboxes, notes, and draft/completed tracking.</li>
|
||||||
<li>Default notification emails + optional rule-based emails are sent.</li>
|
<li>Default notification emails + optional rule-based emails are sent.</li>
|
||||||
<li>Welcome email job is scheduled (configurable delay).</li>
|
<li>Welcome email job is scheduled (configurable delay).</li>
|
||||||
<li>PDF is uploaded to Nextcloud if enabled.</li>
|
<li>PDF is uploaded to Nextcloud if enabled.</li>
|
||||||
@@ -134,11 +136,16 @@
|
|||||||
<h2 id="pdfs">7) PDF Engine</h2>
|
<h2 id="pdfs">7) PDF Engine</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Template source: <code>/backend/media/templates/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li>
|
<li>Template source: <code>/backend/media/templates/onboarding_template.html</code> and <code>offboarding_template.html</code>.</li>
|
||||||
|
<li>Additional onboarding intro template: <code>/backend/media/templates/onboarding_intro_template.html</code>.</li>
|
||||||
<li>Letterhead: <code>/backend/media/templates/templates.pdf</code>.</li>
|
<li>Letterhead: <code>/backend/media/templates/templates.pdf</code>.</li>
|
||||||
<li>Output folder: <code>/backend/media/pdfs/</code>.</li>
|
<li>Output folder: <code>/backend/media/pdfs/</code>.</li>
|
||||||
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li>
|
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</li>
|
||||||
<li>Conditional sections are hidden if no data is provided.</li>
|
<li>Conditional sections are hidden if no data is provided.</li>
|
||||||
<li>Offboarding fallback behavior: if no onboarding record is available, the PDF renders a manual onboarding review layout grouped into <code>Stammdaten</code>, <code>Vertrag</code>, <code>IT-Setup</code>, and <code>Abschluss</code>, plus checkbox grids for devices, software, accesses, workspace groups, resources, and additional IT items.</li>
|
<li>Offboarding fallback behavior: if no onboarding record is available, the PDF renders a manual onboarding review layout grouped into <code>Stammdaten</code>, <code>Vertrag</code>, <code>IT-Setup</code>, and <code>Abschluss</code>, plus checkbox grids for devices, software, accesses, workspace groups, resources, and additional IT items.</li>
|
||||||
|
<li>The onboarding intro PDF is a separate checklist-style document for live walkthroughs. It uses printable blank checkboxes and signature lines for both the employee and the trainer.</li>
|
||||||
|
<li>The onboarding intro PDF can now use admin-configured checklist items from the dedicated <code>Einweisungs-Builder</code>. If no custom items are configured, the system falls back to built-in defaults.</li>
|
||||||
|
<li>The live introduction page uses the same checklist structure as the intro PDF, so admins maintain one checklist definition and can use it both on screen and on paper.</li>
|
||||||
|
<li>The live introduction page shows progress in percent and can generate a separate PDF export of the saved live checklist state.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 id="integrations">8) Integrations</h2>
|
<h2 id="integrations">8) Integrations</h2>
|
||||||
@@ -157,9 +164,12 @@
|
|||||||
<h2 id="admin">9) Admin Apps (Home)</h2>
|
<h2 id="admin">9) Admin Apps (Home)</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
|
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
|
||||||
|
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
|
||||||
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li>
|
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules.</li>
|
||||||
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
|
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
|
||||||
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
|
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
|
||||||
|
<li><strong>Einweisungs- und Übergabeprotokoll:</strong> staff-only <code>PDF erzeugen</code>, <code>Neu erzeugen</code>, and <code>PDF öffnen</code> actions directly on onboarding rows in the Requests Dashboard.</li>
|
||||||
|
<li><strong>Einweisung durchführen:</strong> staff-only live checklist page opened from onboarding rows, with draft/completed status, notes, progress tracking, and a separate live-status PDF export.</li>
|
||||||
<li><strong>Project Wiki:</strong> this documentation page.</li>
|
<li><strong>Project Wiki:</strong> this documentation page.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -182,6 +192,7 @@
|
|||||||
<li>After template/form/task changes, restart web and worker containers.</li>
|
<li>After template/form/task changes, restart web and worker containers.</li>
|
||||||
<li>Run <code>python manage.py check</code> before release.</li>
|
<li>Run <code>python manage.py check</code> before release.</li>
|
||||||
<li>On a fresh database, the web container boot sequence now runs <code>python manage.py bootstrap_initial_users</code> right after migrations.</li>
|
<li>On a fresh database, the web container boot sequence now runs <code>python manage.py bootstrap_initial_users</code> right after migrations.</li>
|
||||||
|
<li>After adding or editing intro checklist items, regenerate the intro PDF from the Requests Dashboard to reflect the updated checklist.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Initial Bootstrap Users</h3>
|
<h3>Initial Bootstrap Users</h3>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,5 +30,9 @@ urlpatterns = [
|
|||||||
path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'),
|
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/', 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/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/<int:request_id>/intro-session/', views.onboarding_intro_session_page, name='onboarding_intro_session_page'),
|
||||||
|
path('requests/onboarding/<int:request_id>/intro-session/pdf/', views.generate_onboarding_intro_session_pdf, name='generate_onboarding_intro_session_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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,11 +25,14 @@ from .form_builder import (
|
|||||||
ONBOARDING_PAGE_ORDER,
|
ONBOARDING_PAGE_ORDER,
|
||||||
ensure_form_field_configs,
|
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 .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 (
|
||||||
DEFAULT_NOTIFICATION_TEMPLATES,
|
DEFAULT_NOTIFICATION_TEMPLATES,
|
||||||
|
_generate_onboarding_intro_pdf,
|
||||||
|
_generate_onboarding_intro_session_pdf,
|
||||||
|
build_intro_sections_for_request,
|
||||||
process_offboarding_request,
|
process_offboarding_request,
|
||||||
process_onboarding_request,
|
process_onboarding_request,
|
||||||
send_scheduled_welcome_email,
|
send_scheduled_welcome_email,
|
||||||
@@ -272,6 +275,9 @@ def requests_dashboard(request):
|
|||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
for obj in onboarding_items:
|
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(
|
rows.append(
|
||||||
{
|
{
|
||||||
'id': obj.id,
|
'id': obj.id,
|
||||||
@@ -281,6 +287,8 @@ def requests_dashboard(request):
|
|||||||
'work_email': obj.work_email,
|
'work_email': obj.work_email,
|
||||||
'created_at': obj.created_at,
|
'created_at': obj.created_at,
|
||||||
'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_session': intro_session,
|
||||||
'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung',
|
'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -294,6 +302,8 @@ def requests_dashboard(request):
|
|||||||
'work_email': obj.work_email,
|
'work_email': obj.work_email,
|
||||||
'created_at': obj.created_at,
|
'created_at': obj.created_at,
|
||||||
'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_session': None,
|
||||||
'status': 'PDF erstellt' if obj.generated_pdf_path else 'In Bearbeitung',
|
'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})
|
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
|
@login_required
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def offboarding_create(request):
|
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
|
@login_required
|
||||||
@user_passes_test(_is_staff)
|
@user_passes_test(_is_staff)
|
||||||
def integrations_setup_page(request):
|
def integrations_setup_page(request):
|
||||||
|
|||||||
Reference in New Issue
Block a user