From a45c605b1ede945b1afb7ae45db4ed81ebb50864 Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Sun, 29 Mar 2026 01:35:40 +0100 Subject: [PATCH] feat: add deployment config sync commands --- .../export_portal_deployment_config.py | 22 +++ .../import_portal_deployment_config.py | 23 +++ backend/workflows/portal_config_sync.py | 139 ++++++++++++++ .../workflows/developer_handbook.html | 6 + .../test_portal_deployment_config_sync.py | 172 ++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 backend/workflows/management/commands/export_portal_deployment_config.py create mode 100644 backend/workflows/management/commands/import_portal_deployment_config.py create mode 100644 backend/workflows/portal_config_sync.py create mode 100644 backend/workflows/tests/test_portal_deployment_config_sync.py 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))