snapshot: preserve request status retry and i18n labels

This commit is contained in:
Md Bayazid Bostame
2026-03-25 20:42:01 +01:00
parent a8f7eadbc6
commit 197bd3c226
10 changed files with 851 additions and 583 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.5 on 2026-03-25 19:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0032_adminauditlog'),
]
operations = [
migrations.AddField(
model_name='offboardingrequest',
name='last_error',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='offboardingrequest',
name='processing_status',
field=models.CharField(choices=[('submitted', 'Eingereicht'), ('processing', 'In Bearbeitung'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen')], default='submitted', max_length=20),
),
migrations.AddField(
model_name='onboardingrequest',
name='last_error',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='onboardingrequest',
name='processing_status',
field=models.CharField(choices=[('submitted', 'Eingereicht'), ('processing', 'In Bearbeitung'), ('completed', 'Abgeschlossen'), ('failed', 'Fehlgeschlagen')], default='submitted', max_length=20),
),
]

View File

@@ -1,6 +1,7 @@
from django.conf import settings
from django.db import models
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
def _normalized_language_code(value: str | None) -> str:
@@ -50,6 +51,13 @@ class AdminAuditLog(models.Model):
class OnboardingRequest(models.Model):
STATUS_CHOICES = [
('submitted', _('Eingereicht')),
('processing', _('In Bearbeitung')),
('completed', _('Abgeschlossen')),
('failed', _('Fehlgeschlagen')),
]
full_name = models.CharField(max_length=255, verbose_name='Vorname und Nachname')
gender = models.CharField(
max_length=20,
@@ -112,6 +120,8 @@ class OnboardingRequest(models.Model):
generated_pdf_path = models.CharField(max_length=500, blank=True)
intro_pdf_path = models.CharField(max_length=500, blank=True)
processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted')
last_error = models.TextField(blank=True)
preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de')
created_at = models.DateTimeField(auto_now_add=True)
@@ -447,6 +457,8 @@ class SystemEmailConfig(models.Model):
class OffboardingRequest(models.Model):
STATUS_CHOICES = OnboardingRequest.STATUS_CHOICES
employee_profile = models.ForeignKey(EmployeeProfile, null=True, blank=True, on_delete=models.SET_NULL)
full_name = models.CharField(max_length=255, verbose_name='Vorname und Nachname')
work_email = models.EmailField(verbose_name='Dienstliche E-Mail-Adresse')
@@ -460,6 +472,8 @@ class OffboardingRequest(models.Model):
requested_by_name = models.CharField(max_length=255, blank=True, verbose_name='Name der anfordernden Person')
preferred_language = models.CharField(max_length=10, blank=True, default='de', db_default='de')
generated_pdf_path = models.CharField(max_length=500, blank=True)
processing_status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted')
last_error = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:

View File

@@ -1195,159 +1195,183 @@ def _generate_offboarding_pdf(request_obj: OffboardingRequest) -> Path:
@shared_task
def process_onboarding_request(onboarding_request_id: int) -> None:
request_obj = OnboardingRequest.objects.get(id=onboarding_request_id)
it_email, general_info_email, business_card_email, hr_works_email, key_email = _resolve_workflow_emails()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip()
request_obj.processing_status = 'processing'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
try:
it_email, general_info_email, business_card_email, hr_works_email, key_email = _resolve_workflow_emails()
salutation = (request_obj.get_gender_display() or '').strip()
display_name = f"{salutation} {request_obj.full_name}".strip()
first_name, last_name = _split_name(request_obj.full_name)
EmployeeProfile.objects.update_or_create(
work_email=request_obj.work_email,
defaults={
'full_name': request_obj.full_name,
'first_name': first_name,
'last_name': last_name,
'department': request_obj.department,
'job_title': request_obj.job_title,
},
)
pdf_path = _generate_onboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
request_obj.save(update_fields=['generated_pdf_path'])
email_context = {
'FULL_NAME': display_name,
'VORNAME': first_name,
'NACHNAME': last_name,
'DEPARTMENT': request_obj.department or '-',
'CONTRACT_START': request_obj.contract_start,
'EMAIL': request_obj.work_email,
'REQUESTED_BY': request_obj.onboarded_by_email or '-',
'BUSINESS_CARD_NAME': request_obj.business_card_name or display_name,
'BUSINESS_CARD_TITLE': request_obj.business_card_title or '-',
'BUSINESS_CARD_EMAIL': request_obj.business_card_email or request_obj.work_email,
'BUSINESS_CARD_PHONE': request_obj.business_card_phone or '-',
'PDF_LINK': settings.ONBOARDING_SHARED_PDF_LINK,
}
_send_templated_email(
template_key='onboarding_it',
context=email_context,
to=[it_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='onboarding_general_info',
context=email_context,
to=[general_info_email],
language_code=request_obj.preferred_language,
)
if request_obj.order_business_cards:
_send_templated_email(
template_key='onboarding_business_card',
context=email_context,
to=[business_card_email],
language_code=request_obj.preferred_language,
first_name, last_name = _split_name(request_obj.full_name)
EmployeeProfile.objects.update_or_create(
work_email=request_obj.work_email,
defaults={
'full_name': request_obj.full_name,
'first_name': first_name,
'last_name': last_name,
'department': request_obj.department,
'job_title': request_obj.job_title,
},
)
if 'HR Works' in request_obj.needed_accesses:
_send_templated_email(
template_key='onboarding_hr_works',
context=email_context,
to=[hr_works_email],
language_code=request_obj.preferred_language,
)
pdf_path = _generate_onboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
request_obj.save(update_fields=['generated_pdf_path'])
if 'Schlüssel' in request_obj.needed_devices:
_send_templated_email(
template_key='onboarding_key',
context=email_context,
to=[key_email],
language_code=request_obj.preferred_language,
)
email_context = {
'FULL_NAME': display_name,
'VORNAME': first_name,
'NACHNAME': last_name,
'DEPARTMENT': request_obj.department or '-',
'CONTRACT_START': request_obj.contract_start,
'EMAIL': request_obj.work_email,
'REQUESTED_BY': request_obj.onboarded_by_email or '-',
'BUSINESS_CARD_NAME': request_obj.business_card_name or display_name,
'BUSINESS_CARD_TITLE': request_obj.business_card_title or '-',
'BUSINESS_CARD_EMAIL': request_obj.business_card_email or request_obj.work_email,
'BUSINESS_CARD_PHONE': request_obj.business_card_phone or '-',
'PDF_LINK': settings.ONBOARDING_SHARED_PDF_LINK,
}
if request_obj.onboarded_by_email:
_send_templated_email(
template_key='onboarding_reference',
template_key='onboarding_it',
context=email_context,
to=[request_obj.onboarded_by_email],
to=[it_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='onboarding_general_info',
context=email_context,
to=[general_info_email],
language_code=request_obj.preferred_language,
)
_apply_notification_rules(
event_type='onboarding',
request_obj=request_obj,
context=email_context,
pdf_path=pdf_path,
)
if request_obj.order_business_cards:
_send_templated_email(
template_key='onboarding_business_card',
context=email_context,
to=[business_card_email],
language_code=request_obj.preferred_language,
)
_schedule_welcome_email(request_obj)
if 'HR Works' in request_obj.needed_accesses:
_send_templated_email(
template_key='onboarding_hr_works',
context=email_context,
to=[hr_works_email],
language_code=request_obj.preferred_language,
)
upload_to_nextcloud(pdf_path, Path(pdf_path).name)
if 'Schlüssel' in request_obj.needed_devices:
_send_templated_email(
template_key='onboarding_key',
context=email_context,
to=[key_email],
language_code=request_obj.preferred_language,
)
if request_obj.onboarded_by_email:
_send_templated_email(
template_key='onboarding_reference',
context=email_context,
to=[request_obj.onboarded_by_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_apply_notification_rules(
event_type='onboarding',
request_obj=request_obj,
context=email_context,
pdf_path=pdf_path,
)
_schedule_welcome_email(request_obj)
upload_to_nextcloud(pdf_path, Path(pdf_path).name)
request_obj.processing_status = 'completed'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
except Exception as exc:
request_obj.processing_status = 'failed'
request_obj.last_error = str(exc)
request_obj.save(update_fields=['processing_status', 'last_error'])
raise
@shared_task
def process_offboarding_request(offboarding_request_id: int) -> None:
request_obj = OffboardingRequest.objects.get(id=offboarding_request_id)
it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails()
request_obj.processing_status = 'processing'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
try:
it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails()
pdf_path = _generate_offboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
request_obj.save(update_fields=['generated_pdf_path'])
pdf_path = _generate_offboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
request_obj.save(update_fields=['generated_pdf_path'])
email_context = {
'FULL_NAME': request_obj.full_name,
'DEPARTMENT': request_obj.department or '-',
'LAST_WORKING_DAY': request_obj.last_working_day,
'REQUESTED_BY': request_obj.requested_by_email,
'EMAIL': request_obj.work_email,
}
email_context = {
'FULL_NAME': request_obj.full_name,
'DEPARTMENT': request_obj.department or '-',
'LAST_WORKING_DAY': request_obj.last_working_day,
'REQUESTED_BY': request_obj.requested_by_email,
'EMAIL': request_obj.work_email,
}
_send_templated_email(
template_key='offboarding_it',
context=email_context,
to=[it_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='offboarding_general_info',
context=email_context,
to=[general_info_email],
language_code=request_obj.preferred_language,
)
had_hr_works = OnboardingRequest.objects.filter(
work_email=request_obj.work_email,
needed_accesses__icontains='HR Works',
).exists()
if had_hr_works:
_send_templated_email(
template_key='offboarding_hr_works_disable',
template_key='offboarding_it',
context=email_context,
to=[hr_works_email],
to=[it_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='offboarding_general_info',
context=email_context,
to=[general_info_email],
language_code=request_obj.preferred_language,
)
_send_templated_email(
template_key='offboarding_reference',
context=email_context,
to=[request_obj.requested_by_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
had_hr_works = OnboardingRequest.objects.filter(
work_email=request_obj.work_email,
needed_accesses__icontains='HR Works',
).exists()
if had_hr_works:
_send_templated_email(
template_key='offboarding_hr_works_disable',
context=email_context,
to=[hr_works_email],
language_code=request_obj.preferred_language,
)
_apply_notification_rules(
event_type='offboarding',
request_obj=request_obj,
context=email_context,
pdf_path=pdf_path,
)
_send_templated_email(
template_key='offboarding_reference',
context=email_context,
to=[request_obj.requested_by_email],
attachments=[pdf_path],
language_code=request_obj.preferred_language,
)
upload_to_nextcloud(pdf_path, Path(pdf_path).name)
_apply_notification_rules(
event_type='offboarding',
request_obj=request_obj,
context=email_context,
pdf_path=pdf_path,
)
upload_to_nextcloud(pdf_path, Path(pdf_path).name)
request_obj.processing_status = 'completed'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
except Exception as exc:
request_obj.processing_status = 'failed'
request_obj.last_error = str(exc)
request_obj.save(update_fields=['processing_status', 'last_error'])
raise
@shared_task

View File

@@ -189,6 +189,8 @@ docker compose exec -T web python manage.py run_staging_e2e_check</code></pre>
<li>Use real PDF generation tests when changing PDF templates or intro/offboarding document logic.</li>
<li>Use the dedicated Release Checklist page as the final go/no-go runbook before shipping changes.</li>
<li>The automated bilingual smoke tests now cover DE/EN request language capture and English email-template rendering.</li>
<li>Onboarding and offboarding request objects now expose explicit processing state and last-error fields. Async tasks are responsible for transitioning <code>submitted → processing → completed/failed</code>.</li>
<li>The Requests Dashboard includes a retry action for failed requests. Retries reset the error text, set the request back to <code>submitted</code>, and enqueue the appropriate Celery task again.</li>
</ul>
<h2 id="deploy">12) Deployment and Release Checklist</h2>

View File

@@ -178,6 +178,7 @@
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
<li><strong>Request Status & Retry:</strong> onboarding and offboarding requests now carry explicit processing state (<code>submitted</code>, <code>processing</code>, <code>completed</code>, <code>failed</code>). Failed requests expose the last error and can be retried from the dashboard.</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>

View File

@@ -189,6 +189,10 @@
{% else %}
<span class="person-meta">{% trans "Noch nicht verfügbar" %}</span>
{% endif %}
<div class="person-meta" style="margin-top:8px;">{{ row.status }}</div>
{% if row.status_key == 'failed' and row.last_error %}
<div class="person-meta" style="margin-top:6px; color:#8e1e1e;">{{ row.last_error|truncatechars:140 }}</div>
{% endif %}
</td>
{% if request.user.is_staff %}
<td class="actions-cell intro-panel">
@@ -232,6 +236,12 @@
{% endif %}
</td>
<td class="actions-cell">
{% if row.status_key == 'failed' %}
<form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" onsubmit="return confirm('Eintrag erneut verarbeiten?');">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Erneut versuchen" %}</button>
</form>
{% endif %}
<form method="post" action="/requests/" class="inline-delete" onsubmit="return confirm('Eintrag wirklich löschen?');">
{% csrf_token %}
<button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}">{% trans "Löschen" %}</button>

View File

@@ -39,4 +39,5 @@ urlpatterns = [
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/retry/<str:kind>/<int:request_id>/', views.retry_request_from_dashboard, name='retry_request_from_dashboard'),
]

View File

@@ -147,6 +147,16 @@ def _form_field_labels(form_type: str) -> dict[str, str]:
return {}
def _request_status_label(status_key: str) -> str:
labels = {
'submitted': _('Eingereicht'),
'processing': _('In Bearbeitung'),
'completed': _('Abgeschlossen'),
'failed': _('Fehlgeschlagen'),
}
return labels.get(status_key, status_key)
def _translate_choice_list(choices):
return [(value, str(label)) for value, label in choices]
@@ -383,7 +393,9 @@ def requests_dashboard(request):
'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': _request_status_label(obj.processing_status),
'status_key': obj.processing_status,
'last_error': obj.last_error,
}
)
for obj in offboarding_items:
@@ -398,7 +410,9 @@ def requests_dashboard(request):
'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': _request_status_label(obj.processing_status),
'status_key': obj.processing_status,
'last_error': obj.last_error,
}
)
@@ -1682,3 +1696,29 @@ def delete_request_from_dashboard(request, kind: str, request_id: int):
_audit(request, 'request_deleted', target_type=kind, target_id=request_id, target_label=str(obj))
messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde gelöscht.')
return redirect('requests_dashboard')
@login_required
@user_passes_test(_is_staff)
@require_POST
def retry_request_from_dashboard(request, kind: str, request_id: int):
if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id)
obj.processing_status = 'submitted'
obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error'])
process_onboarding_request.delay(obj.id)
_audit(request, 'request_retried', target_type='onboarding', target_id=obj.id, target_label=obj.full_name)
elif kind == 'offboarding':
obj = get_object_or_404(OffboardingRequest, id=request_id)
obj.processing_status = 'submitted'
obj.last_error = ''
obj.save(update_fields=['processing_status', 'last_error'])
process_offboarding_request.delay(obj.id)
_audit(request, 'request_retried', target_type='offboarding', target_id=obj.id, target_label=obj.full_name)
else:
messages.error(request, f'Unbekannter Typ: {kind}')
return redirect('requests_dashboard')
messages.success(request, f'{kind.capitalize()}-Anfrage #{request_id} wurde erneut angestoßen.')
return redirect('requests_dashboard')