snapshot: preserve handbook, bilingual phase 2, and logo updates
This commit is contained in:
33
.github/workflows/i18n.yml
vendored
Normal file
33
.github/workflows/i18n.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: i18n
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
compile-translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: backend
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install gettext
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Compile translations
|
||||||
|
run: django-admin compilemessages
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
COMPOSE ?= docker compose
|
||||||
|
|
||||||
|
.PHONY: i18n-extract-en i18n-extract-de i18n-compile i18n-update-en i18n-update-de
|
||||||
|
|
||||||
|
i18n-extract-en:
|
||||||
|
$(COMPOSE) exec -T web django-admin makemessages -l en
|
||||||
|
|
||||||
|
i18n-extract-de:
|
||||||
|
$(COMPOSE) exec -T web django-admin makemessages -l de
|
||||||
|
|
||||||
|
i18n-compile:
|
||||||
|
$(COMPOSE) exec -T web django-admin compilemessages
|
||||||
|
|
||||||
|
i18n-update-en: i18n-extract-en i18n-compile
|
||||||
|
|
||||||
|
i18n-update-de: i18n-extract-de i18n-compile
|
||||||
12
README.md
12
README.md
@@ -29,11 +29,21 @@ This project now uses Django's standard i18n workflow for long-term maintainabil
|
|||||||
- `docker compose exec -T web django-admin compilemessages`
|
- `docker compose exec -T web django-admin compilemessages`
|
||||||
- Add more languages the same way:
|
- Add more languages the same way:
|
||||||
- `docker compose exec -T web django-admin makemessages -l de`
|
- `docker compose exec -T web django-admin makemessages -l de`
|
||||||
|
- Convenience targets:
|
||||||
|
- `make i18n-update-en`
|
||||||
|
- `make i18n-update-de`
|
||||||
|
- `make i18n-compile`
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `gettext` is installed in the Docker image, so `compilemessages` works inside the container.
|
- `gettext` is installed in the Docker image, so `compilemessages` works inside the container.
|
||||||
- Translation files live under `backend/locale/`.
|
- Translation files live under `backend/locale/`.
|
||||||
- Core fixed UI is bilingual now; dynamic builder content and most PDF/email business text are not fully bilingual yet.
|
- Core fixed UI is bilingual now.
|
||||||
|
- Dynamic builder-driven content is now bilingual for:
|
||||||
|
- Form Builder option labels
|
||||||
|
- Form Builder field label/help-text overrides
|
||||||
|
- Intro Builder checklist item labels
|
||||||
|
- Most email template business text and several generated PDF text blocks are still not fully bilingual yet.
|
||||||
|
- CI now validates that translation catalogs compile successfully on push and pull request.
|
||||||
|
|
||||||
## Current implemented scope
|
## Current implemented scope
|
||||||
- Onboarding form with labels mapped from your CSV schema.
|
- Onboarding form with labels mapped from your CSV schema.
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ class OffboardingRequestAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(FormOption)
|
@admin.register(FormOption)
|
||||||
class FormOptionAdmin(admin.ModelAdmin):
|
class FormOptionAdmin(admin.ModelAdmin):
|
||||||
list_display = ('category', 'label', 'value', 'sort_order', 'is_active')
|
list_display = ('category', 'label', 'label_en', 'value', 'sort_order', 'is_active')
|
||||||
list_filter = ('category', 'is_active')
|
list_filter = ('category', 'is_active')
|
||||||
search_fields = ('label', 'value')
|
search_fields = ('label', 'label_en', 'value')
|
||||||
ordering = ('category', 'sort_order', 'label')
|
ordering = ('category', 'sort_order', 'label')
|
||||||
|
|
||||||
|
|
||||||
@@ -38,16 +38,16 @@ class FormOptionAdmin(admin.ModelAdmin):
|
|||||||
class FormFieldConfigAdmin(admin.ModelAdmin):
|
class FormFieldConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = ('form_type', 'field_name', 'page_key', 'sort_order', 'is_visible', 'is_required')
|
list_display = ('form_type', 'field_name', 'page_key', 'sort_order', 'is_visible', 'is_required')
|
||||||
list_filter = ('form_type', 'page_key', 'is_visible', 'is_required')
|
list_filter = ('form_type', 'page_key', 'is_visible', 'is_required')
|
||||||
search_fields = ('field_name', 'label_override', 'help_text_override')
|
search_fields = ('field_name', 'label_override', 'label_override_en', 'help_text_override', 'help_text_override_en')
|
||||||
ordering = ('form_type', 'sort_order', 'field_name')
|
ordering = ('form_type', 'sort_order', 'field_name')
|
||||||
list_editable = ('page_key', 'sort_order', 'is_visible', 'is_required')
|
list_editable = ('page_key', 'sort_order', 'is_visible', 'is_required')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(IntroChecklistItem)
|
@admin.register(IntroChecklistItem)
|
||||||
class IntroChecklistItemAdmin(admin.ModelAdmin):
|
class IntroChecklistItemAdmin(admin.ModelAdmin):
|
||||||
list_display = ('section', 'label', 'condition_field', 'condition_operator', 'condition_value', 'sort_order', 'is_active')
|
list_display = ('section', 'label', 'label_en', 'condition_field', 'condition_operator', 'condition_value', 'sort_order', 'is_active')
|
||||||
list_filter = ('section', 'condition_operator', 'is_active')
|
list_filter = ('section', 'condition_operator', 'is_active')
|
||||||
search_fields = ('label', 'condition_field', 'condition_value')
|
search_fields = ('label', 'label_en', 'condition_field', 'condition_value')
|
||||||
ordering = ('section', 'sort_order', 'label')
|
ordering = ('section', 'sort_order', 'label')
|
||||||
list_editable = ('sort_order', 'is_active')
|
list_editable = ('sort_order', 'is_active')
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
from .models import FormFieldConfig
|
from .models import FormFieldConfig
|
||||||
|
|
||||||
@@ -154,17 +155,20 @@ def apply_form_field_config(form_type: str, form) -> None:
|
|||||||
field_names = list(form.fields.keys())
|
field_names = list(form.fields.keys())
|
||||||
configs = _ensure_configs(form_type, field_names)
|
configs = _ensure_configs(form_type, field_names)
|
||||||
locked = LOCKED_FIELD_RULES.get(form_type, set())
|
locked = LOCKED_FIELD_RULES.get(form_type, set())
|
||||||
|
language_code = get_language()
|
||||||
|
|
||||||
for field_name, field in list(form.fields.items()):
|
for field_name, field in list(form.fields.items()):
|
||||||
cfg = configs.get(field_name)
|
cfg = configs.get(field_name)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cfg.label_override.strip():
|
translated_label = cfg.translated_label_override(language_code)
|
||||||
field.label = cfg.label_override.strip()
|
if translated_label:
|
||||||
|
field.label = translated_label
|
||||||
|
|
||||||
if cfg.help_text_override.strip():
|
translated_help_text = cfg.translated_help_text_override(language_code)
|
||||||
field.help_text = cfg.help_text_override.strip()
|
if translated_help_text:
|
||||||
|
field.help_text = translated_help_text
|
||||||
|
|
||||||
if field_name not in locked and cfg.is_required is not None:
|
if field_name not in locked and cfg.is_required is not None:
|
||||||
field.required = cfg.is_required
|
field.required = cfg.is_required
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
from .form_builder import apply_form_field_config
|
from .form_builder import apply_form_field_config
|
||||||
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest
|
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest
|
||||||
@@ -214,7 +215,8 @@ class OnboardingRequestForm(forms.ModelForm):
|
|||||||
options = FormOption.objects.filter(category=category, is_active=True).order_by('sort_order', 'label')
|
options = FormOption.objects.filter(category=category, is_active=True).order_by('sort_order', 'label')
|
||||||
if not options.exists():
|
if not options.exists():
|
||||||
return fallback
|
return fallback
|
||||||
return [(o.value or o.label, o.label) for o in options]
|
language_code = get_language()
|
||||||
|
return [(o.value or o.label, o.translated_label(language_code)) for o in options]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower()
|
self.requester_email = (kwargs.pop('requester_email', '') or '').strip().lower()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2026-03-24 10:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('workflows', '0028_onboardingintroductionsession_exported_pdf_path'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfieldconfig',
|
||||||
|
name='help_text_override_en',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formfieldconfig',
|
||||||
|
name='label_override_en',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='formoption',
|
||||||
|
name='label_en',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='introchecklistitem',
|
||||||
|
name='label_en',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
|
|
||||||
class EmployeeProfile(models.Model):
|
class EmployeeProfile(models.Model):
|
||||||
@@ -98,6 +99,7 @@ class FormOption(models.Model):
|
|||||||
|
|
||||||
category = models.CharField(max_length=40, choices=CATEGORY_CHOICES)
|
category = models.CharField(max_length=40, choices=CATEGORY_CHOICES)
|
||||||
label = models.CharField(max_length=255)
|
label = models.CharField(max_length=255)
|
||||||
|
label_en = models.CharField(max_length=255, blank=True)
|
||||||
value = models.CharField(max_length=255, blank=True)
|
value = models.CharField(max_length=255, blank=True)
|
||||||
sort_order = models.PositiveIntegerField(default=0)
|
sort_order = models.PositiveIntegerField(default=0)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
@@ -109,6 +111,12 @@ class FormOption(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.get_category_display()}: {self.label}"
|
return f"{self.get_category_display()}: {self.label}"
|
||||||
|
|
||||||
|
def translated_label(self, language_code: str | None = None) -> str:
|
||||||
|
lang = (language_code or get_language() or 'de').split('-')[0]
|
||||||
|
if lang == 'en' and self.label_en.strip():
|
||||||
|
return self.label_en.strip()
|
||||||
|
return self.label.strip()
|
||||||
|
|
||||||
|
|
||||||
class FormFieldConfig(models.Model):
|
class FormFieldConfig(models.Model):
|
||||||
PAGE_CHOICES = [
|
PAGE_CHOICES = [
|
||||||
@@ -130,7 +138,9 @@ class FormFieldConfig(models.Model):
|
|||||||
is_required = models.BooleanField(null=True, blank=True, default=None)
|
is_required = models.BooleanField(null=True, blank=True, default=None)
|
||||||
page_key = models.CharField(max_length=20, blank=True, default='', choices=PAGE_CHOICES)
|
page_key = models.CharField(max_length=20, blank=True, default='', choices=PAGE_CHOICES)
|
||||||
label_override = models.CharField(max_length=255, blank=True)
|
label_override = models.CharField(max_length=255, blank=True)
|
||||||
|
label_override_en = models.CharField(max_length=255, blank=True)
|
||||||
help_text_override = models.TextField(blank=True)
|
help_text_override = models.TextField(blank=True)
|
||||||
|
help_text_override_en = models.TextField(blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['form_type', 'sort_order', 'field_name']
|
ordering = ['form_type', 'sort_order', 'field_name']
|
||||||
@@ -141,6 +151,18 @@ class FormFieldConfig(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{self.get_form_type_display()}: {self.field_name}'
|
return f'{self.get_form_type_display()}: {self.field_name}'
|
||||||
|
|
||||||
|
def translated_label_override(self, language_code: str | None = None) -> str:
|
||||||
|
lang = (language_code or get_language() or 'de').split('-')[0]
|
||||||
|
if lang == 'en' and self.label_override_en.strip():
|
||||||
|
return self.label_override_en.strip()
|
||||||
|
return self.label_override.strip()
|
||||||
|
|
||||||
|
def translated_help_text_override(self, language_code: str | None = None) -> str:
|
||||||
|
lang = (language_code or get_language() or 'de').split('-')[0]
|
||||||
|
if lang == 'en' and self.help_text_override_en.strip():
|
||||||
|
return self.help_text_override_en.strip()
|
||||||
|
return self.help_text_override.strip()
|
||||||
|
|
||||||
|
|
||||||
class NotificationTemplate(models.Model):
|
class NotificationTemplate(models.Model):
|
||||||
TEMPLATE_CHOICES = [
|
TEMPLATE_CHOICES = [
|
||||||
@@ -249,6 +271,7 @@ class IntroChecklistItem(models.Model):
|
|||||||
|
|
||||||
section = models.CharField(max_length=30, choices=SECTION_CHOICES)
|
section = models.CharField(max_length=30, choices=SECTION_CHOICES)
|
||||||
label = models.CharField(max_length=255)
|
label = models.CharField(max_length=255)
|
||||||
|
label_en = models.CharField(max_length=255, blank=True)
|
||||||
sort_order = models.PositiveIntegerField(default=0)
|
sort_order = models.PositiveIntegerField(default=0)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
condition_field = models.CharField(max_length=80, blank=True)
|
condition_field = models.CharField(max_length=80, blank=True)
|
||||||
@@ -261,6 +284,12 @@ class IntroChecklistItem(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f'{self.get_section_display()}: {self.label}'
|
return f'{self.get_section_display()}: {self.label}'
|
||||||
|
|
||||||
|
def translated_label(self, language_code: str | None = None) -> str:
|
||||||
|
lang = (language_code or get_language() or 'de').split('-')[0]
|
||||||
|
if lang == 'en' and self.label_en.strip():
|
||||||
|
return self.label_en.strip()
|
||||||
|
return self.label.strip()
|
||||||
|
|
||||||
|
|
||||||
class OnboardingIntroductionSession(models.Model):
|
class OnboardingIntroductionSession(models.Model):
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ body {
|
|||||||
|
|
||||||
.add-option-form {
|
.add-option-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) minmax(180px, 1fr) auto;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
@@ -260,6 +260,7 @@ body {
|
|||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
padding: 7px 8px;
|
padding: 7px 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-table th {
|
.option-table th {
|
||||||
@@ -300,6 +301,8 @@ body {
|
|||||||
|
|
||||||
.options-actions {
|
.options-actions {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
|
|||||||
13
backend/workflows/static/workflows/img/tubco-logo.svg
Normal file
13
backend/workflows/static/workflows/img/tubco-logo.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="110" viewBox="0 0 420 110" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">TUBCO</title>
|
||||||
|
<desc id="desc">TUBCO wordmark</desc>
|
||||||
|
<rect width="420" height="110" fill="white"/>
|
||||||
|
<g transform="translate(8 12)">
|
||||||
|
<text x="0" y="46" font-family="Arial, Helvetica, sans-serif" font-size="58" font-weight="700" letter-spacing="-2">
|
||||||
|
<tspan fill="#000078">TUB</tspan><tspan fill="#c31924">CO</tspan>
|
||||||
|
</text>
|
||||||
|
<text x="2" y="82" font-family="Georgia, 'Times New Roman', serif" font-size="24" fill="#000078" letter-spacing="0.8">
|
||||||
|
TU BERLIN CORPORATE
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 663 B |
@@ -8,6 +8,7 @@ from celery import shared_task
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext as _, get_language
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from pypdf import PageObject, PdfReader, PdfWriter
|
from pypdf import PageObject, PdfReader, PdfWriter
|
||||||
from xhtml2pdf import pisa
|
from xhtml2pdf import pisa
|
||||||
@@ -294,7 +295,7 @@ def _matches_intro_condition(request_obj: OnboardingRequest, item: IntroChecklis
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _build_intro_sections_from_admin(request_obj: OnboardingRequest) -> dict[str, list[str]]:
|
def _build_intro_sections_from_admin(request_obj: OnboardingRequest, language_code: str | None = None) -> dict[str, list[str]]:
|
||||||
items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label'))
|
items = list(IntroChecklistItem.objects.filter(is_active=True).order_by('section', 'sort_order', 'label'))
|
||||||
if not items:
|
if not items:
|
||||||
return {}
|
return {}
|
||||||
@@ -304,11 +305,26 @@ def _build_intro_sections_from_admin(request_obj: OnboardingRequest) -> dict[str
|
|||||||
if item.section not in section_map:
|
if item.section not in section_map:
|
||||||
continue
|
continue
|
||||||
if _matches_intro_condition(request_obj, item):
|
if _matches_intro_condition(request_obj, item):
|
||||||
section_map[item.section].append(item.label)
|
section_map[item.section].append(item.translated_label(language_code))
|
||||||
return {key: values for key, values in section_map.items() if values}
|
return {key: values for key, values in section_map.items() if values}
|
||||||
|
|
||||||
|
|
||||||
def build_intro_sections_for_request(request_obj: OnboardingRequest) -> list[dict]:
|
def build_intro_sections_for_request(request_obj: OnboardingRequest, language_code: str | None = None) -> list[dict]:
|
||||||
|
lang = (language_code or get_language() or 'de').split('-')[0]
|
||||||
|
section_titles = {
|
||||||
|
'de': {
|
||||||
|
'workplace': 'Geräte und Arbeitsplatz',
|
||||||
|
'accounts': 'Konten und Berechtigungen',
|
||||||
|
'software': 'Software und Tools',
|
||||||
|
'process': 'Prozesse und Hinweise',
|
||||||
|
},
|
||||||
|
'en': {
|
||||||
|
'workplace': 'Devices and workplace',
|
||||||
|
'accounts': 'Accounts and permissions',
|
||||||
|
'software': 'Software and tools',
|
||||||
|
'process': 'Processes and notes',
|
||||||
|
},
|
||||||
|
}
|
||||||
devices = _split_multiline(request_obj.needed_devices)
|
devices = _split_multiline(request_obj.needed_devices)
|
||||||
software = _split_multiline(request_obj.needed_software)
|
software = _split_multiline(request_obj.needed_software)
|
||||||
accesses = _split_multiline(request_obj.needed_accesses)
|
accesses = _split_multiline(request_obj.needed_accesses)
|
||||||
@@ -354,12 +370,12 @@ def build_intro_sections_for_request(request_obj: OnboardingRequest) -> list[dic
|
|||||||
if request_obj.successor_name:
|
if request_obj.successor_name:
|
||||||
process_items.append(f'Übergabe-/Nachfolgekontext besprochen: {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)
|
custom_intro_items = _build_intro_sections_from_admin(request_obj, lang)
|
||||||
intro_sections_raw = [
|
intro_sections_raw = [
|
||||||
('workplace', 'Geräte und Arbeitsplatz', workplace_items),
|
('workplace', section_titles.get(lang, section_titles['de'])['workplace'], workplace_items),
|
||||||
('accounts', 'Konten und Berechtigungen', account_items),
|
('accounts', section_titles.get(lang, section_titles['de'])['accounts'], account_items),
|
||||||
('software', 'Software und Tools', software_items),
|
('software', section_titles.get(lang, section_titles['de'])['software'], software_items),
|
||||||
('process', 'Prozesse und Hinweise', process_items),
|
('process', section_titles.get(lang, section_titles['de'])['process'], process_items),
|
||||||
]
|
]
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
@@ -714,7 +730,7 @@ def _generate_onboarding_pdf(request_obj: OnboardingRequest) -> Path:
|
|||||||
return output_pdf
|
return output_pdf
|
||||||
|
|
||||||
|
|
||||||
def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest) -> Path:
|
def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest, language_code: str | None = None) -> Path:
|
||||||
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_{request_obj.id}')
|
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'
|
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'
|
temp_pdf = settings.PDF_OUTPUT_DIR / f'_temp_onboarding_intro_{safe_name}.pdf'
|
||||||
@@ -729,7 +745,7 @@ def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest) -> Path:
|
|||||||
'title': section['title'],
|
'title': section['title'],
|
||||||
'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2),
|
'rows': _chunk_list([item['label'] for item in section['items']], chunk_size=2),
|
||||||
}
|
}
|
||||||
for section in build_intro_sections_for_request(request_obj)
|
for section in build_intro_sections_for_request(request_obj, language_code=language_code)
|
||||||
]
|
]
|
||||||
|
|
||||||
requester_email = request_obj.onboarded_by_email or '-'
|
requester_email = request_obj.onboarded_by_email or '-'
|
||||||
@@ -755,7 +771,11 @@ def _generate_onboarding_intro_pdf(request_obj: OnboardingRequest) -> Path:
|
|||||||
return output_pdf
|
return output_pdf
|
||||||
|
|
||||||
|
|
||||||
def _generate_onboarding_intro_session_pdf(session: OnboardingIntroductionSession, admin_signature_name: str = '-') -> Path:
|
def _generate_onboarding_intro_session_pdf(
|
||||||
|
session: OnboardingIntroductionSession,
|
||||||
|
admin_signature_name: str = '-',
|
||||||
|
language_code: str | None = None,
|
||||||
|
) -> Path:
|
||||||
request_obj = session.onboarding_request
|
request_obj = session.onboarding_request
|
||||||
safe_name = _safe_filename_fragment(request_obj.full_name, fallback=f'onboarding_intro_session_{request_obj.id}')
|
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')
|
version = timezone.now().strftime('%Y%m%d%H%M%S')
|
||||||
@@ -768,7 +788,7 @@ def _generate_onboarding_intro_session_pdf(session: OnboardingIntroductionSessio
|
|||||||
salutation = (request_obj.get_gender_display() or '').strip()
|
salutation = (request_obj.get_gender_display() or '').strip()
|
||||||
display_name = f"{salutation} {request_obj.full_name}".strip() if salutation else request_obj.full_name
|
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)
|
raw_sections = build_intro_sections_for_request(request_obj, language_code=language_code)
|
||||||
checked_map = session.checklist_state or {}
|
checked_map = session.checklist_state or {}
|
||||||
exported_sections = []
|
exported_sections = []
|
||||||
checked_count = 0
|
checked_count = 0
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{% load static i18n %}
|
{% load static i18n %}
|
||||||
{% get_current_language as CURRENT_LANGUAGE %}
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ CURRENT_LANGUAGE }}">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -18,22 +17,13 @@
|
|||||||
input { width: 100%; padding: 10px; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; }
|
input { width: 100%; padding: 10px; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; }
|
||||||
.btn { width: 100%; }
|
.btn { width: 100%; }
|
||||||
.errorlist { color: #b91c1c; margin: 6px 0; }
|
.errorlist { color: #b91c1c; margin: 6px 0; }
|
||||||
.card-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
|
.card-head { display:block; }
|
||||||
.lang-switch { display:flex; gap:6px; }
|
|
||||||
.lang-btn { border:1px solid #d9e3f0; background:#f8fbff; color:#1f3a5f; border-radius:999px; padding:6px 10px; font-size:12px; font-weight:700; cursor:pointer; }
|
|
||||||
.lang-btn.active { background:#000078; border-color:#000078; color:#fff; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<img class="logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
|
|
||||||
<button class="lang-btn {% if CURRENT_LANGUAGE == 'de' %}active{% endif %}" type="submit" name="language" value="de">DE</button>
|
|
||||||
<button class="lang-btn {% if CURRENT_LANGUAGE == 'en' %}active{% endif %}" type="submit" name="language" value="en">EN</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<h1>{% trans "Anmeldung" %}</h1>
|
<h1>{% trans "Anmeldung" %}</h1>
|
||||||
<p>{% trans "Bitte melden Sie sich mit Ihrem Benutzerkonto an." %}</p>
|
<p>{% trans "Bitte melden Sie sich mit Ihrem Benutzerkonto an." %}</p>
|
||||||
|
|||||||
223
backend/workflows/templates/workflows/developer_handbook.html
Normal file
223
backend/workflows/templates/workflows/developer_handbook.html
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Developer Handbook</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: 1120px; margin: 0 auto; background: #fff; border: 1px solid #d7e0ea; border-radius: 14px; padding: 18px; }
|
||||||
|
.brand-logo { width: 190px; max-width: 100%; height: auto; margin: 0 0 10px; display: block; }
|
||||||
|
.top { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
h1 { margin: 0; color: #000078; font-size: 30px; }
|
||||||
|
.sub { margin: 8px 0 16px; color: #5f6f85; }
|
||||||
|
.toc { border: 1px solid #d7e0ea; border-radius: 10px; padding: 10px; background: #f7fbff; margin-bottom: 16px; }
|
||||||
|
.toc a { color: #0b4da2; text-decoration: none; margin-right: 10px; white-space: nowrap; }
|
||||||
|
h2 { margin: 20px 0 8px; color: #113a74; border-bottom: 1px solid #e1e8f2; padding-bottom: 4px; }
|
||||||
|
h3 { margin: 14px 0 6px; color: #183f77; }
|
||||||
|
ul { margin: 8px 0 12px 20px; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
code { background: #f1f5fb; border: 1px solid #dce6f3; border-radius: 6px; padding: 2px 6px; }
|
||||||
|
pre { background: #f7fbff; border: 1px solid #dce6f3; border-radius: 10px; padding: 10px; overflow-x: auto; }
|
||||||
|
.box { border: 1px solid #d7e0ea; border-radius: 10px; padding: 10px; background: #fcfdff; margin: 8px 0 12px; }
|
||||||
|
.note { border-left: 4px solid #000078; padding: 8px 10px; background: #f4f8ff; margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
|
<div class="top">
|
||||||
|
<h1>Developer Handbook</h1>
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a class="btn btn-secondary" href="/admin-tools/wiki/">Project Wiki</a>
|
||||||
|
<a class="btn btn-secondary" href="/">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="sub">Engineering runbook for development, deployment, maintenance, and extension of the TUBCO Onboarding & Offboarding Portal.</p>
|
||||||
|
|
||||||
|
<div class="toc">
|
||||||
|
<a href="#overview">Overview</a>
|
||||||
|
<a href="#structure">Structure</a>
|
||||||
|
<a href="#local">Local Dev</a>
|
||||||
|
<a href="#docker">Docker</a>
|
||||||
|
<a href="#db">Database</a>
|
||||||
|
<a href="#translations">Translations</a>
|
||||||
|
<a href="#pdf">PDF Pipeline</a>
|
||||||
|
<a href="#email">Email Pipeline</a>
|
||||||
|
<a href="#nextcloud">Nextcloud</a>
|
||||||
|
<a href="#builders">Builders</a>
|
||||||
|
<a href="#testing">Testing</a>
|
||||||
|
<a href="#deploy">Deployment</a>
|
||||||
|
<a href="#troubleshooting">Troubleshooting</a>
|
||||||
|
<a href="#security">Security</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="overview">1) Overview</h2>
|
||||||
|
<div class="box">
|
||||||
|
<p>This handbook is for developers and maintainers. It documents the actual engineering workflow of the standalone product repository.</p>
|
||||||
|
<ul>
|
||||||
|
<li>Repository: <code>tubco-onboarding-offboarding-portal</code></li>
|
||||||
|
<li>Main stack: Django + Celery + PostgreSQL + Redis + MailHog</li>
|
||||||
|
<li>Runtime mode: Docker Compose for local development and staging-style operation</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="structure">2) Repository Structure</h2>
|
||||||
|
<ul>
|
||||||
|
<li><code>/backend/config/</code>: Django settings, WSGI, URL config</li>
|
||||||
|
<li><code>/backend/workflows/</code>: application logic, views, models, tasks, templates, static assets</li>
|
||||||
|
<li><code>/backend/media/templates/</code>: PDF HTML templates and letterhead source files</li>
|
||||||
|
<li><code>/backend/media/pdfs/</code>: generated PDF outputs on host volume</li>
|
||||||
|
<li><code>/backend/locale/</code>: translation catalogs</li>
|
||||||
|
<li><code>/docker-compose.yml</code>: local runtime orchestration</li>
|
||||||
|
<li><code>/Makefile</code>: repeatable translation commands</li>
|
||||||
|
<li><code>/.github/workflows/i18n.yml</code>: translation compile validation in CI</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="local">3) Local Development Workflow</h2>
|
||||||
|
<h3>Start</h3>
|
||||||
|
<pre><code>cd /Users/bostame/Documents/tubco-onboarding-offboarding-portal
|
||||||
|
docker compose up -d --build</code></pre>
|
||||||
|
<h3>Main URLs</h3>
|
||||||
|
<ul>
|
||||||
|
<li>App: <code>http://127.0.0.1:8088/</code></li>
|
||||||
|
<li>MailHog: <code>http://127.0.0.1:8025/</code></li>
|
||||||
|
<li>Health check: <code>http://127.0.0.1:8088/healthz/</code></li>
|
||||||
|
</ul>
|
||||||
|
<h3>Bootstrap users</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Admin: <code>admin_test</code> / <code>admin12345</code></li>
|
||||||
|
<li>User: <code>user_test</code> / <code>user12345</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="docker">4) Docker Operations</h2>
|
||||||
|
<pre><code>docker compose up -d --build
|
||||||
|
docker compose restart web
|
||||||
|
docker compose restart worker
|
||||||
|
docker compose logs --no-color --tail=120 web
|
||||||
|
docker compose logs --no-color --tail=120 worker
|
||||||
|
docker compose down
|
||||||
|
docker compose down -v</code></pre>
|
||||||
|
<div class="note">
|
||||||
|
The source code is bind-mounted into the container. Most template/view/static changes only require a web restart, not a full rebuild. Image changes such as system packages require <code>docker compose up -d --build</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="db">5) Database and Migrations</h2>
|
||||||
|
<pre><code>docker compose exec -T web python manage.py makemigrations
|
||||||
|
docker compose exec -T web python manage.py migrate
|
||||||
|
docker compose exec -T web python manage.py check</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Never edit or remove historical migrations casually.</li>
|
||||||
|
<li>When adding fields to builder-driven models, preserve fallback behavior for existing rows.</li>
|
||||||
|
<li>Fresh boot sequence runs migrations automatically in <code>entrypoint-web.sh</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="translations">6) Translation Workflow</h2>
|
||||||
|
<h3>Standard Django i18n path</h3>
|
||||||
|
<pre><code>make i18n-update-en
|
||||||
|
make i18n-compile</code></pre>
|
||||||
|
<p>Equivalent raw commands:</p>
|
||||||
|
<pre><code>docker compose exec -T web django-admin makemessages -l en
|
||||||
|
docker compose exec -T web django-admin compilemessages</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li><code>gettext</code> is installed in the Docker image.</li>
|
||||||
|
<li>Do not use custom ad hoc <code>.mo</code> compilation anymore.</li>
|
||||||
|
<li>Phase 1 bilingual support covers fixed UI.</li>
|
||||||
|
<li>Phase 2 covers builder-driven labels and checklist items with explicit German and English fields.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="pdf">7) PDF Pipeline</h2>
|
||||||
|
<ul>
|
||||||
|
<li>PDF generation is HTML-to-PDF using <code>xhtml2pdf</code>.</li>
|
||||||
|
<li>Letterhead overlay is applied from <code>templates.pdf</code>.</li>
|
||||||
|
<li>Main logic lives in <code>backend/workflows/tasks.py</code>.</li>
|
||||||
|
<li>Key templates:
|
||||||
|
<ul>
|
||||||
|
<li><code>onboarding_template.html</code></li>
|
||||||
|
<li><code>offboarding_template.html</code></li>
|
||||||
|
<li><code>onboarding_intro_template.html</code></li>
|
||||||
|
<li><code>onboarding_intro_session_pdf.html</code></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="note">
|
||||||
|
xhtml2pdf is sensitive to layout complexity. Keep print templates conservative and verify every structural change with a real generated PDF.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="email">8) Email Pipeline</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Notification defaults are defined in <code>DEFAULT_NOTIFICATION_TEMPLATES</code> in <code>tasks.py</code>.</li>
|
||||||
|
<li>Admin-configured overrides live in <code>NotificationTemplate</code> and <code>NotificationRule</code>.</li>
|
||||||
|
<li>Mail sending uses Celery tasks and supports test mode redirection.</li>
|
||||||
|
<li>MailHog is the local verification path when test mode is active.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="nextcloud">9) Nextcloud Integration</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Configured from Admin Apps → Integrations.</li>
|
||||||
|
<li>Upload logic lives in <code>backend/workflows/services.py</code>.</li>
|
||||||
|
<li>Feature can be globally toggled without changing environment variables.</li>
|
||||||
|
<li>Failures should degrade gracefully and not block request persistence.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="builders">10) Builder Architecture</h2>
|
||||||
|
<h3>Form Builder</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Model: <code>FormFieldConfig</code> + <code>FormOption</code></li>
|
||||||
|
<li>Controls field order, visibility, required flags, option sets, and bilingual label/help-text overrides</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Intro Builder</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Model: <code>IntroChecklistItem</code></li>
|
||||||
|
<li>Controls additional checklist items used by intro PDF and live intro checklist</li>
|
||||||
|
<li>Now supports bilingual DE/EN labels</li>
|
||||||
|
</ul>
|
||||||
|
<div class="note">
|
||||||
|
Dynamic content should use explicit DE/EN fields with German fallback, not machine translation at runtime.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="testing">11) Testing and Validation</h2>
|
||||||
|
<pre><code>docker compose exec -T web python manage.py check
|
||||||
|
docker compose exec -T web python manage.py test
|
||||||
|
docker compose exec -T web python manage.py run_staging_e2e_check</code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Use <code>check</code> after model/view/template changes.</li>
|
||||||
|
<li>Use targeted shell checks for render validation when changing templates or routes.</li>
|
||||||
|
<li>Use real PDF generation tests when changing PDF templates or intro/offboarding document logic.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="deploy">12) Deployment and Release Checklist</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Run <code>manage.py check</code></li>
|
||||||
|
<li>Run tests or targeted verification</li>
|
||||||
|
<li>Run translation compile step</li>
|
||||||
|
<li>Generate at least one onboarding/offboarding PDF if PDF templates changed</li>
|
||||||
|
<li>Verify MailHog or SMTP path if email behavior changed</li>
|
||||||
|
<li>Verify Nextcloud upload if integration behavior changed</li>
|
||||||
|
<li>Update Project Wiki and Developer Handbook if architecture/workflow changed</li>
|
||||||
|
<li>Take a snapshot commit before major next-phase work</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h2 id="troubleshooting">13) Troubleshooting</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Page looks stale:</strong> restart <code>web</code> and hard-refresh browser</li>
|
||||||
|
<li><strong>Second request hangs:</strong> inspect web logs and verify health endpoint</li>
|
||||||
|
<li><strong>PDF looks unchanged:</strong> regenerate the PDF; browser may cache old file names unless the path changes</li>
|
||||||
|
<li><strong>Language switch not visible:</strong> verify translation catalog compiled and restart web</li>
|
||||||
|
<li><strong>Mail not visible:</strong> check MailHog on port <code>8025</code> and test/production mode toggle</li>
|
||||||
|
<li><strong>Nextcloud sync unclear:</strong> verify config in Integrations page and inspect service logs</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="security">14) Security and Maintenance Notes</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Containers run as non-root <code>app</code> user.</li>
|
||||||
|
<li>Keep secrets in <code>.env</code>, not in tracked files.</li>
|
||||||
|
<li>Avoid destructive git operations in a dirty repo.</li>
|
||||||
|
<li>Prefer standard framework workflows over custom one-off maintenance scripts.</li>
|
||||||
|
<li>When adding new features, document them in both the Project Wiki and this handbook if they change engineering workflow.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,7 +81,8 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="builder_action" value="add_option" />
|
<input type="hidden" name="builder_action" value="add_option" />
|
||||||
<input type="hidden" name="category" value="{{ selected_option_category }}" />
|
<input type="hidden" name="category" value="{{ selected_option_category }}" />
|
||||||
<input type="text" name="label" placeholder="Neuer Optionsname" required />
|
<input type="text" name="label" placeholder="Label (DE)" required />
|
||||||
|
<input type="text" name="label_en" placeholder="Label (EN, optional)" />
|
||||||
<input type="text" name="value" placeholder="Technischer Wert (optional)" />
|
<input type="text" name="value" placeholder="Technischer Wert (optional)" />
|
||||||
<button class="btn btn-primary" type="submit">Option hinzufügen</button>
|
<button class="btn btn-primary" type="submit">Option hinzufügen</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -93,7 +94,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Sortierung</th>
|
<th>Sortierung</th>
|
||||||
<th>Label</th>
|
<th>Label (DE)</th>
|
||||||
|
<th>Label (EN)</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
<th>Aktiv</th>
|
<th>Aktiv</th>
|
||||||
<th>Löschen</th>
|
<th>Löschen</th>
|
||||||
@@ -107,6 +109,7 @@
|
|||||||
<span class="drag-handle" title="Ziehen zum Sortieren">⋮⋮</span>
|
<span class="drag-handle" title="Ziehen zum Sortieren">⋮⋮</span>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
|
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
|
||||||
|
<td><input type="text" name="label_en_{{ item.id }}" value="{{ item.label_en }}" /></td>
|
||||||
<td><input type="text" name="value_{{ item.id }}" value="{{ item.value }}" /></td>
|
<td><input type="text" name="value_{{ item.id }}" value="{{ item.value }}" /></td>
|
||||||
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
|
<td><input type="checkbox" name="active_{{ item.id }}" {% if item.is_active %}checked{% endif %} /></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -114,7 +117,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="5">Keine Optionen in dieser Kategorie.</td></tr>
|
<tr><td colspan="6">Keine Optionen in dieser Kategorie.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -124,6 +127,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="options-panel">
|
||||||
|
<div class="options-head">
|
||||||
|
<h2>Feldtexte verwalten</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin-tools/form-builder/?form_type={{ form_type }}&option_category={{ selected_option_category }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="option-table-wrap">
|
||||||
|
<table class="option-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Feld</th>
|
||||||
|
<th>Label (DE)</th>
|
||||||
|
<th>Label (EN)</th>
|
||||||
|
<th>Hilfetext (DE)</th>
|
||||||
|
<th>Hilfetext (EN)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in field_text_items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="hidden" name="field_ids" value="{{ item.id }}" />
|
||||||
|
<strong>{{ item.field_name }}</strong>
|
||||||
|
</td>
|
||||||
|
<td><input type="text" name="label_override_{{ item.id }}" value="{{ item.label_override }}" placeholder="Fallback: Standardlabel" /></td>
|
||||||
|
<td><input type="text" name="label_override_en_{{ item.id }}" value="{{ item.label_override_en }}" placeholder="English label" /></td>
|
||||||
|
<td><input type="text" name="help_text_override_{{ item.id }}" value="{{ item.help_text_override }}" placeholder="Optionaler Hilfetext" /></td>
|
||||||
|
<td><input type="text" name="help_text_override_en_{{ item.id }}" value="{{ item.help_text_override_en }}" placeholder="Optional English help text" /></td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr><td colspan="5">Keine Feldkonfigurationen verfügbar.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="options-actions">
|
||||||
|
<button class="btn btn-primary" type="submit" name="builder_action" value="save_field_texts">Feldtexte speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{% static 'workflows/js/form_builder.js' %}"></script>
|
<script src="{% static 'workflows/js/form_builder.js' %}"></script>
|
||||||
|
|||||||
69
backend/workflows/templates/workflows/handbook.html
Normal file
69
backend/workflows/templates/workflows/handbook.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{% load static i18n %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Handbook</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: 1120px; margin: 0 auto; background: #fff; border: 1px solid #d7e0ea; border-radius: 14px; padding: 18px; }
|
||||||
|
.brand-logo { width: 190px; max-width: 100%; height: auto; margin: 0 0 10px; display: block; }
|
||||||
|
.top { display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
|
||||||
|
h1 { margin: 0; color: #000078; font-size: 30px; }
|
||||||
|
.sub { margin: 8px 0 18px; color: #5f6f85; max-width: 760px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(2, minmax(280px, 1fr)); gap: 14px; }
|
||||||
|
.card { border: 1px solid #d7e0ea; border-radius: 14px; background: #fcfdff; padding: 16px; }
|
||||||
|
.eyebrow { display: inline-block; padding: 5px 10px; border-radius: 999px; background: #eef4ff; color: #244a8f; border: 1px solid #d5e2f9; font-size: 12px; font-weight: 700; margin-bottom: 10px; }
|
||||||
|
h2 { margin: 0 0 8px; color: #113a74; }
|
||||||
|
p { margin: 0 0 14px; color: #5f6f85; }
|
||||||
|
ul { margin: 0 0 14px 18px; color: #334155; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
|
<div class="top">
|
||||||
|
<h1>Handbook</h1>
|
||||||
|
<a class="btn btn-secondary" href="/">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
<p class="sub">Single documentation entry point for both operational knowledge and long-term engineering knowledge.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="card">
|
||||||
|
<div class="eyebrow">Operations</div>
|
||||||
|
<h2>Project Wiki</h2>
|
||||||
|
<p>Operational and product-level documentation for onboarding, offboarding, PDFs, integrations, admin tools, and system behavior.</p>
|
||||||
|
<ul>
|
||||||
|
<li>workflow overview</li>
|
||||||
|
<li>admin tools and system behavior</li>
|
||||||
|
<li>integrations and operations</li>
|
||||||
|
<li>runbook and troubleshooting</li>
|
||||||
|
</ul>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-secondary" href="/admin-tools/wiki/">Open Project Wiki</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="eyebrow">Engineering</div>
|
||||||
|
<h2>Developer Handbook</h2>
|
||||||
|
<p>Engineering documentation for architecture, local setup, Docker, migrations, translations, deployment, testing, and long-term maintenance.</p>
|
||||||
|
<ul>
|
||||||
|
<li>repository and service structure</li>
|
||||||
|
<li>Docker and migration workflow</li>
|
||||||
|
<li>translation and builder architecture</li>
|
||||||
|
<li>deployment, security, and maintenance notes</li>
|
||||||
|
</ul>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-secondary" href="/admin-tools/developer-handbook/">Open Developer Handbook</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -56,12 +56,21 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
width: 210px;
|
width: 210px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-actions {
|
.quick-actions {
|
||||||
@@ -436,7 +445,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<div class="brand-wrap">
|
||||||
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
|
</div>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -549,9 +560,9 @@
|
|||||||
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a>
|
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a>
|
||||||
</section>
|
</section>
|
||||||
<section class="admin-card">
|
<section class="admin-card">
|
||||||
<h3>{% trans "Projekt Wiki" %}</h3>
|
<h3>{% trans "Handbook" %}</h3>
|
||||||
<p>{% trans "Dokumentation, Architektur und Runbook." %}</p>
|
<p>{% trans "Project wiki and developer documentation in one place." %}</p>
|
||||||
<a class="btn btn-secondary" href="/admin-tools/wiki/">{% trans "Öffnen" %}</a>
|
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
|
||||||
</section>
|
</section>
|
||||||
<section class="admin-card">
|
<section class="admin-card">
|
||||||
<h3>{% trans "Integrationen" %}</h3>
|
<h3>{% trans "Integrationen" %}</h3>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
</div>
|
</div>
|
||||||
<h1>Integrationen Setup</h1>
|
<h1>Integrationen Setup</h1>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,9 +66,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="label">Checklistenpunkt</label>
|
<label for="label">Checklistenpunkt (DE)</label>
|
||||||
<input id="label" name="label" placeholder="z. B. Nextcloud Ordnerstruktur erklärt" required />
|
<input id="label" name="label" placeholder="z. B. Nextcloud Ordnerstruktur erklärt" required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="label_en">Checklist item (EN)</label>
|
||||||
|
<input id="label_en" name="label_en" placeholder="e.g. Nextcloud folder structure explained" />
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-primary" type="submit">Punkt hinzufügen</button>
|
<button class="btn btn-primary" type="submit">Punkt hinzufügen</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +89,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Sortierung</th>
|
<th>Sortierung</th>
|
||||||
<th>Abschnitt</th>
|
<th>Abschnitt</th>
|
||||||
<th>Checklistenpunkt</th>
|
<th>Checklistenpunkt (DE)</th>
|
||||||
|
<th>Checklistenpunkt (EN)</th>
|
||||||
<th>Feld-Bedingung</th>
|
<th>Feld-Bedingung</th>
|
||||||
<th>Operator</th>
|
<th>Operator</th>
|
||||||
<th>Wert</th>
|
<th>Wert</th>
|
||||||
@@ -108,6 +113,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
|
<td><input type="text" name="label_{{ item.id }}" value="{{ item.label }}" required /></td>
|
||||||
|
<td><input type="text" name="label_en_{{ item.id }}" value="{{ item.label_en }}" /></td>
|
||||||
<td>
|
<td>
|
||||||
<select name="field_{{ item.id }}">
|
<select name="field_{{ item.id }}">
|
||||||
{% for value, label in condition_field_choices %}
|
{% for value, label in condition_field_choices %}
|
||||||
@@ -129,7 +135,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="8">Noch keine benutzerdefinierten Checklistenpunkte angelegt. Solange die Liste leer ist, nutzt das System die integrierten Standardpunkte.</td></tr>
|
<tr><td colspan="9">Noch keine benutzerdefinierten Checklistenpunkte angelegt. Solange die Liste leer ist, nutzt das System die integrierten Standardpunkte.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<div class="top-link" style="display:flex; gap:8px; align-items:center;">
|
<div class="top-link" style="display:flex; gap:8px; align-items:center;">
|
||||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch" style="display:flex; gap:6px;">
|
<form method="post" action="{% url 'set_language' %}" class="lang-switch" style="display:flex; gap:6px;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="top-wrap">
|
<div class="top-wrap">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<div class="top-link" style="display:flex; gap:8px; align-items:center;">
|
<div class="top-link" style="display:flex; gap:8px; align-items:center;">
|
||||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch" style="display:flex; gap:6px;">
|
<form method="post" action="{% url 'set_language' %}" class="lang-switch" style="display:flex; gap:6px;">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<a class="btn btn-secondary" href="/requests/">Zum Dashboard</a>
|
<a class="btn btn-secondary" href="/requests/">Zum Dashboard</a>
|
||||||
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<div class="top">
|
<div class="top">
|
||||||
<h1>Project Wiki</h1>
|
<h1>Project Wiki</h1>
|
||||||
<a class="btn btn-secondary" href="/">Back to Home</a>
|
<a class="btn btn-secondary" href="/">Back to Home</a>
|
||||||
@@ -165,7 +165,9 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><strong>Current scope:</strong> the core user interface supports German and English switching for the main fixed UI pages.</li>
|
<li><strong>Current scope:</strong> the core user interface supports German and English switching for the main fixed UI pages.</li>
|
||||||
<li><strong>Covered now:</strong> login, home, requests dashboard, onboarding form shell, offboarding form shell, and common status/messages in views.</li>
|
<li><strong>Covered now:</strong> login, home, requests dashboard, onboarding form shell, offboarding form shell, and common status/messages in views.</li>
|
||||||
<li><strong>Not fully bilingual yet:</strong> dynamic Form Builder content, intro-builder item labels, admin-configured email templates, and most generated PDF/business text remain primarily single-language.</li>
|
<li><strong>Phase 2 added:</strong> dynamic Form Builder option labels, field label/help-text overrides, and intro-builder checklist item labels now support German and English values.</li>
|
||||||
|
<li><strong>Editing path:</strong> these DE/EN values are maintained directly in the frontend builder pages, not only in Django admin.</li>
|
||||||
|
<li><strong>Not fully bilingual yet:</strong> admin-configured email templates and several generated PDF/business text blocks still remain primarily single-language.</li>
|
||||||
<li><strong>Implementation:</strong> Django i18n with locale middleware, translation catalogs, and a DE/EN language switch in the main UI.</li>
|
<li><strong>Implementation:</strong> Django i18n with locale middleware, translation catalogs, and a DE/EN language switch in the main UI.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -174,6 +176,8 @@
|
|||||||
<li>The long-term translation path uses Django's standard <code>makemessages</code> and <code>compilemessages</code> workflow.</li>
|
<li>The long-term translation path uses Django's standard <code>makemessages</code> and <code>compilemessages</code> workflow.</li>
|
||||||
<li><code>gettext</code> is installed in the Docker image so translations can be compiled inside the running container.</li>
|
<li><code>gettext</code> is installed in the Docker image so translations can be compiled inside the running container.</li>
|
||||||
<li>Translation catalogs live under <code>/backend/locale/</code>.</li>
|
<li>Translation catalogs live under <code>/backend/locale/</code>.</li>
|
||||||
|
<li>A root <code>Makefile</code> provides repeatable local commands for updating and compiling translations.</li>
|
||||||
|
<li>CI validates translation compilation on push and pull request via <code>.github/workflows/i18n.yml</code>.</li>
|
||||||
<li>The earlier ad hoc Python-based <code>.mo</code> compilation path should no longer be used for ongoing maintenance.</li>
|
<li>The earlier ad hoc Python-based <code>.mo</code> compilation path should no longer be used for ongoing maintenance.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@@ -877,7 +877,7 @@
|
|||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="brand-wrap">
|
<div class="brand-wrap">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
</div>
|
</div>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
<form method="post" action="{% url 'set_language' %}" class="lang-switch">
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<img class="brand-logo" src="https://tub.co/media/site/0856bfc615-1750234287/tubco-wortbildmarke.svg" alt="TUB/CO Logo" />
|
<img class="brand-logo" src="{% static 'workflows/img/tubco-logo.svg' %}" alt="TUB/CO Logo" />
|
||||||
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
<a class="btn btn-secondary" href="/">Zur Startseite</a>
|
||||||
</div>
|
</div>
|
||||||
<h1>Geplante Welcome E-Mails</h1>
|
<h1>Geplante Welcome E-Mails</h1>
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ urlpatterns = [
|
|||||||
path('admin-tools/welcome-emails/<int:schedule_id>/pause/', views.pause_welcome_email, name='pause_welcome_email'),
|
path('admin-tools/welcome-emails/<int:schedule_id>/pause/', views.pause_welcome_email, name='pause_welcome_email'),
|
||||||
path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'),
|
path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'),
|
||||||
path('admin-tools/welcome-emails/<int:schedule_id>/cancel/', views.cancel_welcome_email, name='cancel_welcome_email'),
|
path('admin-tools/welcome-emails/<int:schedule_id>/cancel/', views.cancel_welcome_email, name='cancel_welcome_email'),
|
||||||
|
path('admin-tools/handbook/', views.handbook_page, name='handbook_page'),
|
||||||
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/developer-handbook/', views.developer_handbook_page, name='developer_handbook_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('admin-tools/intro-builder/', views.intro_builder_page, name='intro_builder_page'),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from django.views.decorators.http import require_POST
|
|||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _, gettext_lazy
|
from django.utils.translation import gettext as _, gettext_lazy
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
from .forms import OffboardingRequestForm, OnboardingRequestForm
|
from .forms import OffboardingRequestForm, OnboardingRequestForm
|
||||||
from .form_builder import (
|
from .form_builder import (
|
||||||
@@ -124,6 +125,10 @@ def _form_field_labels(form_type: str) -> dict[str, str]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _translate_choice_list(choices):
|
||||||
|
return [(value, str(label)) for value, label in choices]
|
||||||
|
|
||||||
|
|
||||||
def _build_onboarding_layout(form) -> list[dict]:
|
def _build_onboarding_layout(form) -> list[dict]:
|
||||||
ordered_names = list(form.fields.keys())
|
ordered_names = list(form.fields.keys())
|
||||||
group_by_field = {}
|
group_by_field = {}
|
||||||
@@ -209,12 +214,24 @@ def home(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_passes_test(_is_staff)
|
||||||
|
def handbook_page(request):
|
||||||
|
return render(request, 'workflows/handbook.html')
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(_is_staff)
|
@user_passes_test(_is_staff)
|
||||||
def project_wiki_page(request):
|
def project_wiki_page(request):
|
||||||
return render(request, 'workflows/project_wiki.html')
|
return render(request, 'workflows/project_wiki.html')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@user_passes_test(_is_staff)
|
||||||
|
def developer_handbook_page(request):
|
||||||
|
return render(request, 'workflows/developer_handbook.html')
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def requests_dashboard(request):
|
def requests_dashboard(request):
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -416,7 +433,7 @@ def onboarding_success(request, request_id: int):
|
|||||||
@require_POST
|
@require_POST
|
||||||
def generate_onboarding_intro_pdf(request, request_id: int):
|
def generate_onboarding_intro_pdf(request, request_id: int):
|
||||||
obj = get_object_or_404(OnboardingRequest, id=request_id)
|
obj = get_object_or_404(OnboardingRequest, id=request_id)
|
||||||
pdf_path = _generate_onboarding_intro_pdf(obj)
|
pdf_path = _generate_onboarding_intro_pdf(obj, language_code=get_language())
|
||||||
obj.intro_pdf_path = str(pdf_path)
|
obj.intro_pdf_path = str(pdf_path)
|
||||||
obj.save(update_fields=['intro_pdf_path'])
|
obj.save(update_fields=['intro_pdf_path'])
|
||||||
messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.'))
|
messages.success(request, _('Einweisungs- und Übergabeprotokoll wurde erzeugt.'))
|
||||||
@@ -429,7 +446,11 @@ def generate_onboarding_intro_pdf(request, request_id: int):
|
|||||||
def generate_onboarding_intro_session_pdf(request, request_id: int):
|
def generate_onboarding_intro_session_pdf(request, request_id: int):
|
||||||
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
|
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
|
||||||
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
|
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))
|
pdf_path = _generate_onboarding_intro_session_pdf(
|
||||||
|
session,
|
||||||
|
admin_signature_name=_display_user_name(request.user),
|
||||||
|
language_code=get_language(),
|
||||||
|
)
|
||||||
session.exported_pdf_path = str(pdf_path)
|
session.exported_pdf_path = str(pdf_path)
|
||||||
session.save(update_fields=['exported_pdf_path'])
|
session.save(update_fields=['exported_pdf_path'])
|
||||||
messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.'))
|
messages.success(request, _('Einweisungsprotokoll aus Live-Status wurde erzeugt.'))
|
||||||
@@ -441,7 +462,7 @@ def generate_onboarding_intro_session_pdf(request, request_id: int):
|
|||||||
def onboarding_intro_session_page(request, request_id: int):
|
def onboarding_intro_session_page(request, request_id: int):
|
||||||
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
|
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
|
||||||
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
|
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
|
||||||
sections = build_intro_sections_for_request(onboarding)
|
sections = build_intro_sections_for_request(onboarding, language_code=get_language())
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
checked_ids = set(request.POST.getlist('checked_items'))
|
checked_ids = set(request.POST.getlist('checked_items'))
|
||||||
@@ -576,6 +597,7 @@ def offboarding_success(request, request_id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
@user_passes_test(_is_staff)
|
@user_passes_test(_is_staff)
|
||||||
def form_builder_page(request):
|
def form_builder_page(request):
|
||||||
|
language_code = get_language()
|
||||||
form_type = request.GET.get('form_type', 'onboarding')
|
form_type = request.GET.get('form_type', 'onboarding')
|
||||||
if form_type not in DEFAULT_FIELD_ORDER:
|
if form_type not in DEFAULT_FIELD_ORDER:
|
||||||
form_type = 'onboarding'
|
form_type = 'onboarding'
|
||||||
@@ -600,6 +622,7 @@ def form_builder_page(request):
|
|||||||
if action == 'add_option':
|
if action == 'add_option':
|
||||||
category = request.POST.get('category', '').strip()
|
category = request.POST.get('category', '').strip()
|
||||||
label = request.POST.get('label', '').strip()
|
label = request.POST.get('label', '').strip()
|
||||||
|
label_en = request.POST.get('label_en', '').strip()
|
||||||
value = request.POST.get('value', '').strip()
|
value = request.POST.get('value', '').strip()
|
||||||
if category not in option_categories:
|
if category not in option_categories:
|
||||||
messages.error(request, 'Ungültige Kategorie.')
|
messages.error(request, 'Ungültige Kategorie.')
|
||||||
@@ -612,6 +635,7 @@ def form_builder_page(request):
|
|||||||
FormOption.objects.create(
|
FormOption.objects.create(
|
||||||
category=category,
|
category=category,
|
||||||
label=label,
|
label=label,
|
||||||
|
label_en=label_en,
|
||||||
value=value or label,
|
value=value or label,
|
||||||
sort_order=(next_sort + 1) if next_sort is not None else 0,
|
sort_order=(next_sort + 1) if next_sort is not None else 0,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@@ -627,17 +651,31 @@ def form_builder_page(request):
|
|||||||
continue
|
continue
|
||||||
next_label = request.POST.get(f'label_{option.id}', '').strip() or option.label
|
next_label = request.POST.get(f'label_{option.id}', '').strip() or option.label
|
||||||
option.label = next_label
|
option.label = next_label
|
||||||
|
option.label_en = request.POST.get(f'label_en_{option.id}', '').strip()
|
||||||
option.value = request.POST.get(f'value_{option.id}', '').strip() or next_label
|
option.value = request.POST.get(f'value_{option.id}', '').strip() or next_label
|
||||||
option.is_active = request.POST.get(f'active_{option.id}') == 'on'
|
option.is_active = request.POST.get(f'active_{option.id}') == 'on'
|
||||||
option.sort_order = pos
|
option.sort_order = pos
|
||||||
try:
|
try:
|
||||||
option.save(update_fields=['label', 'value', 'is_active', 'sort_order'])
|
option.save(update_fields=['label', 'label_en', 'value', 'is_active', 'sort_order'])
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}')
|
messages.error(request, f'Doppelte Bezeichnung in Kategorie: {next_label}')
|
||||||
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}")
|
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option.category}")
|
||||||
option_category = option.category
|
option_category = option.category
|
||||||
messages.success(request, 'Optionen wurden gespeichert.')
|
messages.success(request, 'Optionen wurden gespeichert.')
|
||||||
|
|
||||||
|
elif action == 'save_field_texts':
|
||||||
|
field_ids = request.POST.getlist('field_ids')
|
||||||
|
for raw_id in field_ids:
|
||||||
|
cfg = FormFieldConfig.objects.filter(id=raw_id, form_type=form_type).first()
|
||||||
|
if not cfg:
|
||||||
|
continue
|
||||||
|
cfg.label_override = (request.POST.get(f'label_override_{cfg.id}') or '').strip()
|
||||||
|
cfg.label_override_en = (request.POST.get(f'label_override_en_{cfg.id}') or '').strip()
|
||||||
|
cfg.help_text_override = (request.POST.get(f'help_text_override_{cfg.id}') or '').strip()
|
||||||
|
cfg.help_text_override_en = (request.POST.get(f'help_text_override_en_{cfg.id}') or '').strip()
|
||||||
|
cfg.save(update_fields=['label_override', 'label_override_en', 'help_text_override', 'help_text_override_en'])
|
||||||
|
messages.success(request, 'Feldtexte wurden gespeichert.')
|
||||||
|
|
||||||
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}")
|
return redirect(f"/admin-tools/form-builder/?form_type={form_type}&option_category={option_category}")
|
||||||
|
|
||||||
default_names = list(DEFAULT_FIELD_ORDER.get(form_type, []))
|
default_names = list(DEFAULT_FIELD_ORDER.get(form_type, []))
|
||||||
@@ -676,7 +714,9 @@ def form_builder_page(request):
|
|||||||
column_by_key[page_key]['items'].append(
|
column_by_key[page_key]['items'].append(
|
||||||
{
|
{
|
||||||
'field_name': cfg.field_name,
|
'field_name': cfg.field_name,
|
||||||
'label': labels.get(cfg.field_name, cfg.field_name),
|
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
|
||||||
|
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
|
||||||
|
'label_en': cfg.label_override_en,
|
||||||
'is_visible': cfg.is_visible,
|
'is_visible': cfg.is_visible,
|
||||||
'is_required': cfg.is_required,
|
'is_required': cfg.is_required,
|
||||||
'locked': cfg.field_name in locked,
|
'locked': cfg.field_name in locked,
|
||||||
@@ -690,7 +730,9 @@ def form_builder_page(request):
|
|||||||
'items': [
|
'items': [
|
||||||
{
|
{
|
||||||
'field_name': cfg.field_name,
|
'field_name': cfg.field_name,
|
||||||
'label': labels.get(cfg.field_name, cfg.field_name),
|
'label': cfg.translated_label_override(language_code) or labels.get(cfg.field_name, cfg.field_name),
|
||||||
|
'label_de': cfg.label_override or labels.get(cfg.field_name, cfg.field_name),
|
||||||
|
'label_en': cfg.label_override_en,
|
||||||
'is_visible': cfg.is_visible,
|
'is_visible': cfg.is_visible,
|
||||||
'is_required': cfg.is_required,
|
'is_required': cfg.is_required,
|
||||||
'locked': cfg.field_name in locked,
|
'locked': cfg.field_name in locked,
|
||||||
@@ -707,9 +749,10 @@ def form_builder_page(request):
|
|||||||
'form_type': form_type,
|
'form_type': form_type,
|
||||||
'columns': columns,
|
'columns': columns,
|
||||||
'form_types': [('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')],
|
'form_types': [('onboarding', 'Onboarding'), ('offboarding', 'Offboarding')],
|
||||||
'option_categories': FormOption.CATEGORY_CHOICES,
|
'option_categories': _translate_choice_list(FormOption.CATEGORY_CHOICES),
|
||||||
'selected_option_category': option_category,
|
'selected_option_category': option_category,
|
||||||
'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'),
|
'option_items': FormOption.objects.filter(category=option_category).order_by('sort_order', 'label'),
|
||||||
|
'field_text_items': configs,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -732,6 +775,7 @@ def intro_builder_page(request):
|
|||||||
if action == 'add_item':
|
if action == 'add_item':
|
||||||
section = (request.POST.get('section') or '').strip()
|
section = (request.POST.get('section') or '').strip()
|
||||||
label = (request.POST.get('label') or '').strip()
|
label = (request.POST.get('label') or '').strip()
|
||||||
|
label_en = (request.POST.get('label_en') or '').strip()
|
||||||
if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}:
|
if section not in {k for k, _ in IntroChecklistItem.SECTION_CHOICES}:
|
||||||
messages.error(request, 'Ungültiger Abschnitt.')
|
messages.error(request, 'Ungültiger Abschnitt.')
|
||||||
return redirect('intro_builder_page')
|
return redirect('intro_builder_page')
|
||||||
@@ -744,6 +788,7 @@ def intro_builder_page(request):
|
|||||||
IntroChecklistItem.objects.create(
|
IntroChecklistItem.objects.create(
|
||||||
section=section,
|
section=section,
|
||||||
label=label,
|
label=label,
|
||||||
|
label_en=label_en,
|
||||||
sort_order=(next_sort + 1) if next_sort is not None else 0,
|
sort_order=(next_sort + 1) if next_sort is not None else 0,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
condition_operator='always',
|
condition_operator='always',
|
||||||
@@ -767,6 +812,7 @@ def intro_builder_page(request):
|
|||||||
operator = 'always'
|
operator = 'always'
|
||||||
item.section = section
|
item.section = section
|
||||||
item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label
|
item.label = (request.POST.get(f'label_{item.id}') or item.label).strip() or item.label
|
||||||
|
item.label_en = (request.POST.get(f'label_en_{item.id}') or '').strip()
|
||||||
item.is_active = request.POST.get(f'active_{item.id}') == 'on'
|
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_field = (request.POST.get(f'field_{item.id}') or '').strip()
|
||||||
item.condition_operator = operator
|
item.condition_operator = operator
|
||||||
@@ -776,6 +822,7 @@ def intro_builder_page(request):
|
|||||||
update_fields=[
|
update_fields=[
|
||||||
'section',
|
'section',
|
||||||
'label',
|
'label',
|
||||||
|
'label_en',
|
||||||
'is_active',
|
'is_active',
|
||||||
'condition_field',
|
'condition_field',
|
||||||
'condition_operator',
|
'condition_operator',
|
||||||
@@ -809,8 +856,8 @@ def intro_builder_page(request):
|
|||||||
'workflows/intro_builder.html',
|
'workflows/intro_builder.html',
|
||||||
{
|
{
|
||||||
'items': items,
|
'items': items,
|
||||||
'section_choices': IntroChecklistItem.SECTION_CHOICES,
|
'section_choices': _translate_choice_list(IntroChecklistItem.SECTION_CHOICES),
|
||||||
'operator_choices': IntroChecklistItem.OPERATOR_CHOICES,
|
'operator_choices': _translate_choice_list(IntroChecklistItem.OPERATOR_CHOICES),
|
||||||
'condition_field_choices': condition_field_choices,
|
'condition_field_choices': condition_field_choices,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user