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
|
||||
|
||||
python manage.py migrate --noinput
|
||||
python manage.py bootstrap_initial_users
|
||||
python manage.py collectstatic --noinput
|
||||
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):
|
||||
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
|
||||
|
||||
@@ -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 .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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user