diff --git a/backend/workflows/management/commands/export_portal_deployment_config.py b/backend/workflows/management/commands/export_portal_deployment_config.py
new file mode 100644
index 0000000..3e4d4cf
--- /dev/null
+++ b/backend/workflows/management/commands/export_portal_deployment_config.py
@@ -0,0 +1,22 @@
+import json
+
+from django.core.management.base import BaseCommand
+
+from workflows.portal_config_sync import export_portal_deployment_config_payload, write_portal_deployment_config_export
+
+
+class Command(BaseCommand):
+ help = 'Export branding and company configuration to JSON for environment sync.'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--output', help='Write the export to a file instead of stdout.')
+
+ def handle(self, *args, **options):
+ output = options.get('output')
+ if output:
+ path = write_portal_deployment_config_export(output)
+ self.stdout.write(self.style.SUCCESS(f'Exported portal deployment config to {path}'))
+ return
+
+ payload = export_portal_deployment_config_payload()
+ self.stdout.write(json.dumps(payload, indent=2, default=str))
diff --git a/backend/workflows/management/commands/import_portal_deployment_config.py b/backend/workflows/management/commands/import_portal_deployment_config.py
new file mode 100644
index 0000000..c41070f
--- /dev/null
+++ b/backend/workflows/management/commands/import_portal_deployment_config.py
@@ -0,0 +1,23 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from workflows.portal_config_sync import import_portal_deployment_config_payload, load_portal_deployment_config_payload
+
+
+class Command(BaseCommand):
+ help = 'Import branding and company configuration JSON for environment sync.'
+
+ def add_arguments(self, parser):
+ parser.add_argument('input', help='Path to a previously exported deployment config JSON file.')
+ parser.add_argument('--dry-run', action='store_true', help='Validate and report without saving.')
+
+ def handle(self, *args, **options):
+ try:
+ payload = load_portal_deployment_config_payload(options['input'])
+ summary = import_portal_deployment_config_payload(payload, dry_run=options['dry_run'])
+ except (OSError, ValueError) as exc:
+ raise CommandError(str(exc)) from exc
+
+ mode = 'Dry run' if options['dry_run'] else 'Import'
+ self.stdout.write(
+ self.style.SUCCESS(f"{mode} complete. updated_fields={summary['updated_fields']}")
+ )
diff --git a/backend/workflows/portal_config_sync.py b/backend/workflows/portal_config_sync.py
new file mode 100644
index 0000000..2e82dce
--- /dev/null
+++ b/backend/workflows/portal_config_sync.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from django.core.serializers.json import DjangoJSONEncoder
+from django.db import transaction
+from django.utils import timezone
+
+from .branding import get_portal_branding, get_portal_company_config
+
+BRANDING_FIELDS = (
+ 'name',
+ 'portal_title',
+ 'company_name',
+ 'company_domain',
+ 'support_email',
+ 'sender_display_name',
+ 'login_subtitle',
+ 'footer_text',
+ 'footer_text_en',
+ 'legal_notice',
+ 'legal_notice_en',
+ 'default_language',
+ 'primary_color',
+ 'secondary_color',
+)
+
+COMPANY_FIELDS = (
+ 'name',
+ 'legal_company_name',
+ 'street_address',
+ 'postal_code',
+ 'city',
+ 'country',
+ 'website_url',
+ 'imprint_url',
+ 'privacy_url',
+ 'hr_contact_email',
+ 'it_contact_email',
+ 'operations_contact_email',
+ 'phone_number',
+ 'vat_id',
+ 'registration_number',
+)
+
+FORMAT_VERSION = 1
+
+
+def export_portal_deployment_config_payload() -> dict[str, object]:
+ branding = get_portal_branding()
+ company_config = get_portal_company_config()
+ return {
+ 'format': 'portal_deployment_config',
+ 'version': FORMAT_VERSION,
+ 'generated_at': timezone.now(),
+ 'branding': {field: getattr(branding, field) for field in BRANDING_FIELDS},
+ 'company_config': {field: getattr(company_config, field) for field in COMPANY_FIELDS},
+ 'notes': {
+ 'file_fields_not_included': ['logo_image', 'pdf_letterhead', 'favicon_image'],
+ },
+ }
+
+
+def write_portal_deployment_config_export(path: str | Path) -> Path:
+ payload = export_portal_deployment_config_payload()
+ output_path = Path(path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(json.dumps(payload, indent=2, cls=DjangoJSONEncoder) + '\n', encoding='utf-8')
+ return output_path
+
+
+def load_portal_deployment_config_payload(path: str | Path) -> dict[str, object]:
+ payload = json.loads(Path(path).read_text(encoding='utf-8'))
+ if payload.get('format') != 'portal_deployment_config':
+ raise ValueError('Unsupported payload format.')
+ if payload.get('version') != FORMAT_VERSION:
+ raise ValueError(f"Unsupported payload version: {payload.get('version')}")
+ return payload
+
+
+def _validate_mapping(payload: dict[str, object], key: str, expected_fields: tuple[str, ...]) -> dict[str, str]:
+ value = payload.get(key)
+ if not isinstance(value, dict):
+ raise ValueError(f'{key} must be an object.')
+ fields = set(value.keys())
+ expected = set(expected_fields)
+ missing = expected.difference(fields)
+ extra = fields.difference(expected)
+ if missing:
+ raise ValueError(f'Missing {key} fields: {", ".join(sorted(missing))}')
+ if extra:
+ raise ValueError(f'Unexpected {key} fields: {", ".join(sorted(extra))}')
+
+ normalized: dict[str, str] = {}
+ for field in expected_fields:
+ field_value = value[field]
+ if field_value is None:
+ field_value = ''
+ if not isinstance(field_value, str):
+ raise ValueError(f'Invalid {key}.{field}: expected string')
+ normalized[field] = field_value
+ return normalized
+
+
+def validate_portal_deployment_config_payload(payload: dict[str, object]) -> dict[str, dict[str, str]]:
+ return {
+ 'branding': _validate_mapping(payload, 'branding', BRANDING_FIELDS),
+ 'company_config': _validate_mapping(payload, 'company_config', COMPANY_FIELDS),
+ }
+
+
+@transaction.atomic
+def import_portal_deployment_config_payload(payload: dict[str, object], dry_run: bool = False) -> dict[str, int]:
+ validated = validate_portal_deployment_config_payload(payload)
+ branding = get_portal_branding()
+ company_config = get_portal_company_config()
+ updated = 0
+
+ for field, value in validated['branding'].items():
+ if getattr(branding, field) != value:
+ setattr(branding, field, value)
+ updated += 1
+
+ for field, value in validated['company_config'].items():
+ if getattr(company_config, field) != value:
+ setattr(company_config, field, value)
+ updated += 1
+
+ if not dry_run:
+ branding.save(update_fields=list(BRANDING_FIELDS))
+ company_config.save(update_fields=list(COMPANY_FIELDS))
+ else:
+ transaction.set_rollback(True)
+
+ return {
+ 'updated_fields': updated,
+ 'dry_run': 1 if dry_run else 0,
+ }
diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html
index 851216d..e3b3c98 100644
--- a/backend/workflows/templates/workflows/developer_handbook.html
+++ b/backend/workflows/templates/workflows/developer_handbook.html
@@ -235,6 +235,12 @@ docker compose exec -T web django-admin compilemessages
The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require @tub.co code changes.
Outgoing system mail sender names are now branded through the same layer.
User invitation emails and welcome-template fallbacks also use the configured branding defaults.
+ Environment sync for text/color/company metadata is explicit:
+ docker compose exec -T web python manage.py export_portal_deployment_config --output /tmp/portal-deployment-config.json
+docker compose exec -T web python manage.py import_portal_deployment_config /tmp/portal-deployment-config.json --dry-run
+docker compose exec -T web python manage.py import_portal_deployment_config /tmp/portal-deployment-config.json
+
+ Uploaded assets such as logo, favicon, and PDF letterhead are intentionally not included in this sync payload.
12b) App Registry
diff --git a/backend/workflows/tests/test_portal_deployment_config_sync.py b/backend/workflows/tests/test_portal_deployment_config_sync.py
new file mode 100644
index 0000000..fa96fe4
--- /dev/null
+++ b/backend/workflows/tests/test_portal_deployment_config_sync.py
@@ -0,0 +1,172 @@
+import json
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from django.core.management import call_command
+from django.core.management.base import CommandError
+from django.test import TestCase
+
+from workflows.branding import get_portal_branding, get_portal_company_config
+
+
+class PortalDeploymentConfigSyncTests(TestCase):
+ def test_export_writes_branding_and_company_config(self):
+ with TemporaryDirectory() as tmpdir:
+ output_path = Path(tmpdir) / 'portal-deployment-config.json'
+ call_command('export_portal_deployment_config', '--output', str(output_path))
+ payload = json.loads(output_path.read_text(encoding='utf-8'))
+
+ self.assertEqual(payload['format'], 'portal_deployment_config')
+ self.assertEqual(payload['version'], 1)
+ self.assertIn('branding', payload)
+ self.assertIn('company_config', payload)
+ self.assertIn('notes', payload)
+
+ def test_import_updates_branding_and_company_config(self):
+ branding = get_portal_branding()
+ company_config = get_portal_company_config()
+ payload = {
+ 'format': 'portal_deployment_config',
+ 'version': 1,
+ 'branding': {
+ 'name': branding.name,
+ 'portal_title': 'Workdock Test',
+ 'company_name': branding.company_name,
+ 'company_domain': 'example.org',
+ 'support_email': 'support@example.org',
+ 'sender_display_name': branding.sender_display_name,
+ 'login_subtitle': branding.login_subtitle,
+ 'footer_text': branding.footer_text,
+ 'footer_text_en': branding.footer_text_en,
+ 'legal_notice': branding.legal_notice,
+ 'legal_notice_en': branding.legal_notice_en,
+ 'default_language': 'en',
+ 'primary_color': '#112233',
+ 'secondary_color': '#445566',
+ },
+ 'company_config': {
+ 'name': company_config.name,
+ 'legal_company_name': 'Example GmbH',
+ 'street_address': 'Example Street 1',
+ 'postal_code': company_config.postal_code,
+ 'city': 'Berlin',
+ 'country': company_config.country,
+ 'website_url': 'https://example.org',
+ 'imprint_url': company_config.imprint_url,
+ 'privacy_url': company_config.privacy_url,
+ 'hr_contact_email': 'hr@example.org',
+ 'it_contact_email': company_config.it_contact_email,
+ 'operations_contact_email': company_config.operations_contact_email,
+ 'phone_number': company_config.phone_number,
+ 'vat_id': company_config.vat_id,
+ 'registration_number': company_config.registration_number,
+ },
+ }
+ with TemporaryDirectory() as tmpdir:
+ input_path = Path(tmpdir) / 'portal-deployment-config.json'
+ input_path.write_text(json.dumps(payload), encoding='utf-8')
+ call_command('import_portal_deployment_config', str(input_path))
+
+ branding.refresh_from_db()
+ company_config.refresh_from_db()
+ self.assertEqual(branding.portal_title, 'Workdock Test')
+ self.assertEqual(branding.company_domain, 'example.org')
+ self.assertEqual(company_config.legal_company_name, 'Example GmbH')
+ self.assertEqual(company_config.website_url, 'https://example.org')
+
+ def test_import_dry_run_does_not_persist(self):
+ branding = get_portal_branding()
+ company_config = get_portal_company_config()
+ payload = {
+ 'format': 'portal_deployment_config',
+ 'version': 1,
+ 'branding': {
+ 'name': branding.name,
+ 'portal_title': 'Dry Run Branding',
+ 'company_name': branding.company_name,
+ 'company_domain': branding.company_domain,
+ 'support_email': branding.support_email,
+ 'sender_display_name': branding.sender_display_name,
+ 'login_subtitle': branding.login_subtitle,
+ 'footer_text': branding.footer_text,
+ 'footer_text_en': branding.footer_text_en,
+ 'legal_notice': branding.legal_notice,
+ 'legal_notice_en': branding.legal_notice_en,
+ 'default_language': branding.default_language,
+ 'primary_color': branding.primary_color,
+ 'secondary_color': branding.secondary_color,
+ },
+ 'company_config': {
+ 'name': company_config.name,
+ 'legal_company_name': 'Dry Run Company',
+ 'street_address': company_config.street_address,
+ 'postal_code': company_config.postal_code,
+ 'city': company_config.city,
+ 'country': company_config.country,
+ 'website_url': company_config.website_url,
+ 'imprint_url': company_config.imprint_url,
+ 'privacy_url': company_config.privacy_url,
+ 'hr_contact_email': company_config.hr_contact_email,
+ 'it_contact_email': company_config.it_contact_email,
+ 'operations_contact_email': company_config.operations_contact_email,
+ 'phone_number': company_config.phone_number,
+ 'vat_id': company_config.vat_id,
+ 'registration_number': company_config.registration_number,
+ },
+ }
+ with TemporaryDirectory() as tmpdir:
+ input_path = Path(tmpdir) / 'portal-deployment-config.json'
+ input_path.write_text(json.dumps(payload), encoding='utf-8')
+ call_command('import_portal_deployment_config', str(input_path), '--dry-run')
+
+ branding.refresh_from_db()
+ company_config.refresh_from_db()
+ self.assertNotEqual(branding.portal_title, 'Dry Run Branding')
+ self.assertNotEqual(company_config.legal_company_name, 'Dry Run Company')
+
+ def test_import_rejects_unexpected_fields(self):
+ branding = get_portal_branding()
+ company_config = get_portal_company_config()
+ payload = {
+ 'format': 'portal_deployment_config',
+ 'version': 1,
+ 'branding': {
+ 'name': branding.name,
+ 'portal_title': branding.portal_title,
+ 'company_name': branding.company_name,
+ 'company_domain': branding.company_domain,
+ 'support_email': branding.support_email,
+ 'sender_display_name': branding.sender_display_name,
+ 'login_subtitle': branding.login_subtitle,
+ 'footer_text': branding.footer_text,
+ 'footer_text_en': branding.footer_text_en,
+ 'legal_notice': branding.legal_notice,
+ 'legal_notice_en': branding.legal_notice_en,
+ 'default_language': branding.default_language,
+ 'primary_color': branding.primary_color,
+ 'secondary_color': branding.secondary_color,
+ 'logo_image': 'should-not-be-here',
+ },
+ 'company_config': {
+ 'name': company_config.name,
+ 'legal_company_name': company_config.legal_company_name,
+ 'street_address': company_config.street_address,
+ 'postal_code': company_config.postal_code,
+ 'city': company_config.city,
+ 'country': company_config.country,
+ 'website_url': company_config.website_url,
+ 'imprint_url': company_config.imprint_url,
+ 'privacy_url': company_config.privacy_url,
+ 'hr_contact_email': company_config.hr_contact_email,
+ 'it_contact_email': company_config.it_contact_email,
+ 'operations_contact_email': company_config.operations_contact_email,
+ 'phone_number': company_config.phone_number,
+ 'vat_id': company_config.vat_id,
+ 'registration_number': company_config.registration_number,
+ },
+ }
+ with TemporaryDirectory() as tmpdir:
+ input_path = Path(tmpdir) / 'portal-deployment-config.json'
+ input_path.write_text(json.dumps(payload), encoding='utf-8')
+ with self.assertRaises(CommandError):
+ call_command('import_portal_deployment_config', str(input_path))