snapshot: preserve current onboarding offboarding portal state

This commit is contained in:
Md Bayazid Bostame
2026-03-19 12:37:13 +01:00
parent 581ddffd54
commit 3bf43921ff
6 changed files with 215 additions and 2 deletions

View File

@@ -388,6 +388,8 @@ class OffboardingRequestForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
prefill_profile = kwargs.pop('prefill_profile', None)
super().__init__(*args, **kwargs)
self.fields['full_name'].label = 'Vorname und Nachname'
self.fields['work_email'].help_text = ''
if prefill_profile:
self.fields['full_name'].initial = prefill_profile.full_name
self.fields['work_email'].initial = prefill_profile.work_email

View File

@@ -0,0 +1,48 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
DEFAULT_USERS = [
{
'username': 'admin_test',
'email': 'admin_test@tub.co',
'password': 'admin12345',
'first_name': 'Admin',
'last_name': 'Test',
'is_staff': True,
'is_superuser': True,
},
{
'username': 'user_test',
'email': 'user_test@tub.co',
'password': 'user12345',
'first_name': 'Normal',
'last_name': 'User',
'is_staff': False,
'is_superuser': False,
},
]
class Command(BaseCommand):
help = 'Create initial test/admin users when the database is empty.'
def handle(self, *args, **options):
user_model = get_user_model()
if user_model.objects.exists():
self.stdout.write('bootstrap skipped: users already exist')
return
for item in DEFAULT_USERS:
user = user_model.objects.create_user(
username=item['username'],
email=item['email'],
password=item['password'],
first_name=item['first_name'],
last_name=item['last_name'],
is_staff=item['is_staff'],
is_superuser=item['is_superuser'],
)
self.stdout.write(f'created {user.username}')
self.stdout.write(self.style.SUCCESS('initial users created'))

View File

@@ -16,6 +16,16 @@ from .models import EmployeeProfile, NotificationRule, NotificationTemplate, Off
from .emailing import send_system_email
from .services import upload_to_nextcloud
from .services import get_email_test_redirect, is_email_test_mode
from .forms import (
ACCESS_CHOICES,
DEVICE_CHOICES,
HARDWARE_EXTRA_CHOICES,
OnboardingRequestForm,
RESOURCE_CHOICES,
SOFTWARE_CHOICES,
SOFTWARE_EXTRA_CHOICES,
WORKSPACE_GROUP_CHOICES,
)
DEFAULT_NOTIFICATION_TEMPLATES = {
@@ -179,6 +189,76 @@ def _split_multiline(text: str) -> list[str]:
return [line.strip() for line in (text or '').split('\n') if line.strip()]
def _chunk_choice_labels(choices: list[tuple[str, str]], chunk_size: int = 3) -> list[list[str]]:
labels = [label for _, label in choices]
return _chunk_list(labels, chunk_size=chunk_size)
MANUAL_ONBOARDING_FIELD_SECTIONS = [
(
'Stammdaten',
[
'gender',
'first_name',
'last_name',
'job_title',
'department',
'work_email',
'order_business_cards',
'business_card_name',
'business_card_title',
'business_card_email',
'business_card_phone',
],
),
(
'Vertrag',
[
'contract_start',
'employment_type',
'employment_end_date',
'handover_date',
],
),
(
'IT-Setup',
[
'group_mailboxes_required_choice',
'group_mailboxes',
'additional_hardware_needed_choice',
'additional_hardware_other',
'additional_software_needed_choice',
'additional_software',
'additional_access_needed_choice',
'additional_access_text',
'successor_required_choice',
'successor_name',
'inherit_phone_number_choice',
'phone_number_choice',
],
),
(
'Abschluss',
[
'additional_notes',
'signature_image',
'agreement_confirm',
],
),
]
def _manual_onboarding_field_sections() -> list[dict]:
fields = OnboardingRequestForm.base_fields
sections = []
for title, field_names in MANUAL_ONBOARDING_FIELD_SECTIONS:
labels = [str(fields[name].label or name) for name in field_names if name in fields]
if not labels:
continue
sections.append({'title': title, 'rows': _chunk_list(labels, chunk_size=2)})
return sections
def _resolve_workflow_emails() -> tuple[str, str, str, str, str]:
config = WorkflowConfig.objects.order_by('id').first()
it_email = (config.it_onboarding_email if config and config.it_onboarding_email else settings.IT_ONBOARDING_NOTIFICATION_EMAIL)
@@ -448,10 +528,12 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
successor_name = (request_obj.successor_name or '').strip()
additional_notes = (request_obj.additional_notes or '').strip()
phone_number = (request_obj.phone_number or '').strip()
display_name = f"{gender} {first_name} {last_name}".strip() if gender and gender != '-' else f"{first_name} {last_name}".strip()
context = {
'VORNAME': first_name,
'NACHNAME': last_name,
'DISPLAY_NAME': display_name or request_obj.full_name,
'ANREDE': gender,
'BERUFSBEZEICHNUNG': request_obj.job_title or 'N/A',
'ABTEILUNG': request_obj.department or 'N/A',
@@ -539,6 +621,7 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
.order_by('-created_at')
.first()
)
has_onboarding_data = latest_onboarding is not None
onboarding_hardware = _split_multiline(latest_onboarding.needed_devices) if latest_onboarding else []
selected_set = {item.lower() for item in onboarding_hardware}
hardware_catalog = [
@@ -568,8 +651,17 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
'NOTES': request_obj.notes or '-',
'REQUESTED_BY': requester_email,
'REQUESTED_BY_NAME': requester_name,
'HAS_ONBOARDING_DATA': has_onboarding_data,
'ONBOARDING_HARDWARE': onboarding_hardware,
'HARDWARE_CHECKLIST': checklist,
'MANUAL_FIELD_SECTIONS': _manual_onboarding_field_sections(),
'MANUAL_DEVICES': _chunk_choice_labels(DEVICE_CHOICES),
'MANUAL_SOFTWARE': _chunk_choice_labels(SOFTWARE_CHOICES),
'MANUAL_ACCESSES': _chunk_choice_labels(ACCESS_CHOICES),
'MANUAL_WORKSPACE_GROUPS': _chunk_choice_labels(WORKSPACE_GROUP_CHOICES),
'MANUAL_RESOURCES': _chunk_choice_labels(RESOURCE_CHOICES),
'MANUAL_EXTRA_HARDWARE': _chunk_choice_labels(HARDWARE_EXTRA_CHOICES),
'MANUAL_EXTRA_SOFTWARE': _chunk_choice_labels(SOFTWARE_EXTRA_CHOICES),
}
html = _render_html(template_path, context)

View File

@@ -62,5 +62,59 @@
</form>
</div>
</div>
<script>
(function () {
function byName(name) {
return document.querySelector('[name="' + name + '"]');
}
function slugifyForEmail(value) {
const map = { 'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss' };
const lower = (value || '').toLowerCase();
const mapped = lower.replace(/[äöüß]/g, function (m) { return map[m] || m; });
return mapped
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '.')
.replace(/^\.+|\.+$/g, '')
.replace(/\.{2,}/g, '.');
}
function extractLastName(fullName) {
const parts = (fullName || '').trim().split(/\s+/).filter(Boolean);
return parts.length ? parts[parts.length - 1] : '';
}
const fullName = byName('full_name');
const workEmail = byName('work_email');
if (!fullName || !workEmail) return;
let lastSuggested = '';
let userEditedEmail = false;
function suggestEmail() {
const lastName = extractLastName(fullName.value);
const slug = slugifyForEmail(lastName);
if (!slug) return;
const suggestion = slug + '@tub.co';
const current = (workEmail.value || '').trim();
if (!userEditedEmail || current === '' || current === lastSuggested) {
workEmail.value = suggestion;
lastSuggested = suggestion;
}
}
workEmail.addEventListener('input', function () {
const current = (workEmail.value || '').trim();
userEditedEmail = current !== '' && current !== lastSuggested;
});
fullName.addEventListener('input', suggestEmail);
fullName.addEventListener('change', suggestEmail);
fullName.addEventListener('blur', suggestEmail);
suggestEmail();
}());
</script>
</body>
</html>

View File

@@ -108,7 +108,8 @@
<li>User opens <code>/offboarding/new/</code> and can search existing profile first.</li>
<li>Form saves request with requester name/email from logged-in user.</li>
<li>Task <code>process_offboarding_request</code> runs in worker.</li>
<li>PDF is generated (hardware section can be derived from latest onboarding request).</li>
<li>PDF is generated. If a previous onboarding request exists, the hardware section can be derived from that onboarding data.</li>
<li>If no saved onboarding request exists for that person, the offboarding PDF automatically falls back to a manual onboarding reference sheet with grouped onboarding fields and printable checkbox lists.</li>
<li>Notification emails are sent (default + rules).</li>
<li>PDF upload to Nextcloud runs if enabled.</li>
</ol>
@@ -137,6 +138,7 @@
<li>Output folder: <code>/backend/media/pdfs/</code>.</li>
<li>Signature images are embedded for compatibility with xhtml2pdf rendering.</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>
</ul>
<h2 id="integrations">8) Integrations</h2>
@@ -179,6 +181,14 @@
<li>Use Docker Compose for web + worker + db + redis services.</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>On a fresh database, the web container boot sequence now runs <code>python manage.py bootstrap_initial_users</code> right after migrations.</li>
</ul>
<h3>Initial Bootstrap Users</h3>
<ul>
<li>Admin test user: <code>admin_test</code> / <code>admin12345</code></li>
<li>Normal test user: <code>user_test</code> / <code>user12345</code></li>
<li>The bootstrap step only creates these accounts when the user table is empty, so later restarts do not overwrite real users.</li>
</ul>
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
@@ -222,6 +232,12 @@
<li>Check SMTP settings and worker logs.</li>
</ul>
<h3>Login fails after fresh stack start</h3>
<ul>
<li>The system should bootstrap the initial users automatically after migrations on an empty database.</li>
<li>If login still fails, inspect web logs for <code>bootstrap_initial_users</code> and confirm the database volume was initialized correctly.</li>
</ul>
<h3>Nextcloud upload missing</h3>
<ul>
<li>Verify Nextcloud is enabled.</li>
@@ -239,7 +255,7 @@
</ul>
<div class="note">
Last updated for current system behavior as of March 10, 2026.
Last updated for current system behavior as of March 19, 2026.
</div>
</div>
</body>