snapshot: preserve current onboarding offboarding portal state
This commit is contained in:
@@ -7,5 +7,6 @@ until nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
python manage.py bootstrap_initial_users
|
||||||
python manage.py collectstatic --noinput
|
python manage.py collectstatic --noinput
|
||||||
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 --access-logfile - --error-logfile -
|
exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 --timeout 120 --access-logfile - --error-logfile -
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ class OffboardingRequestForm(forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
prefill_profile = kwargs.pop('prefill_profile', None)
|
prefill_profile = kwargs.pop('prefill_profile', None)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['full_name'].label = 'Vorname und Nachname'
|
||||||
|
self.fields['work_email'].help_text = ''
|
||||||
if prefill_profile:
|
if prefill_profile:
|
||||||
self.fields['full_name'].initial = prefill_profile.full_name
|
self.fields['full_name'].initial = prefill_profile.full_name
|
||||||
self.fields['work_email'].initial = prefill_profile.work_email
|
self.fields['work_email'].initial = prefill_profile.work_email
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -16,6 +16,16 @@ from .models import EmployeeProfile, NotificationRule, NotificationTemplate, Off
|
|||||||
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
|
||||||
|
from .forms import (
|
||||||
|
ACCESS_CHOICES,
|
||||||
|
DEVICE_CHOICES,
|
||||||
|
HARDWARE_EXTRA_CHOICES,
|
||||||
|
OnboardingRequestForm,
|
||||||
|
RESOURCE_CHOICES,
|
||||||
|
SOFTWARE_CHOICES,
|
||||||
|
SOFTWARE_EXTRA_CHOICES,
|
||||||
|
WORKSPACE_GROUP_CHOICES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_NOTIFICATION_TEMPLATES = {
|
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()]
|
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]:
|
def _resolve_workflow_emails() -> tuple[str, str, str, str, str]:
|
||||||
config = WorkflowConfig.objects.order_by('id').first()
|
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)
|
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()
|
successor_name = (request_obj.successor_name or '').strip()
|
||||||
additional_notes = (request_obj.additional_notes or '').strip()
|
additional_notes = (request_obj.additional_notes or '').strip()
|
||||||
phone_number = (request_obj.phone_number 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 = {
|
context = {
|
||||||
'VORNAME': first_name,
|
'VORNAME': first_name,
|
||||||
'NACHNAME': last_name,
|
'NACHNAME': last_name,
|
||||||
|
'DISPLAY_NAME': display_name or request_obj.full_name,
|
||||||
'ANREDE': gender,
|
'ANREDE': gender,
|
||||||
'BERUFSBEZEICHNUNG': request_obj.job_title or 'N/A',
|
'BERUFSBEZEICHNUNG': request_obj.job_title or 'N/A',
|
||||||
'ABTEILUNG': request_obj.department 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')
|
.order_by('-created_at')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
has_onboarding_data = latest_onboarding is not None
|
||||||
onboarding_hardware = _split_multiline(latest_onboarding.needed_devices) if latest_onboarding else []
|
onboarding_hardware = _split_multiline(latest_onboarding.needed_devices) if latest_onboarding else []
|
||||||
selected_set = {item.lower() for item in onboarding_hardware}
|
selected_set = {item.lower() for item in onboarding_hardware}
|
||||||
hardware_catalog = [
|
hardware_catalog = [
|
||||||
@@ -568,8 +651,17 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
|
|||||||
'NOTES': request_obj.notes or '-',
|
'NOTES': request_obj.notes or '-',
|
||||||
'REQUESTED_BY': requester_email,
|
'REQUESTED_BY': requester_email,
|
||||||
'REQUESTED_BY_NAME': requester_name,
|
'REQUESTED_BY_NAME': requester_name,
|
||||||
|
'HAS_ONBOARDING_DATA': has_onboarding_data,
|
||||||
'ONBOARDING_HARDWARE': onboarding_hardware,
|
'ONBOARDING_HARDWARE': onboarding_hardware,
|
||||||
'HARDWARE_CHECKLIST': checklist,
|
'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)
|
html = _render_html(template_path, context)
|
||||||
|
|||||||
@@ -62,5 +62,59 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -108,7 +108,8 @@
|
|||||||
<li>User opens <code>/offboarding/new/</code> and can search existing profile first.</li>
|
<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>Form saves request with requester name/email from logged-in user.</li>
|
||||||
<li>Task <code>process_offboarding_request</code> runs in worker.</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>Notification emails are sent (default + rules).</li>
|
||||||
<li>PDF upload to Nextcloud runs if enabled.</li>
|
<li>PDF upload to Nextcloud runs if enabled.</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -137,6 +138,7 @@
|
|||||||
<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>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 id="integrations">8) Integrations</h2>
|
<h2 id="integrations">8) Integrations</h2>
|
||||||
@@ -179,6 +181,14 @@
|
|||||||
<li>Use Docker Compose for web + worker + db + redis services.</li>
|
<li>Use Docker Compose for web + worker + db + redis services.</li>
|
||||||
<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>
|
||||||
|
</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>
|
</ul>
|
||||||
|
|
||||||
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
|
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
|
||||||
@@ -222,6 +232,12 @@
|
|||||||
<li>Check SMTP settings and worker logs.</li>
|
<li>Check SMTP settings and worker logs.</li>
|
||||||
</ul>
|
</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>
|
<h3>Nextcloud upload missing</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Verify Nextcloud is enabled.</li>
|
<li>Verify Nextcloud is enabled.</li>
|
||||||
@@ -239,7 +255,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="note">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user