diff --git a/backend/workflows/app_registry_sync.py b/backend/workflows/app_registry_sync.py new file mode 100644 index 0000000..7cc3f28 --- /dev/null +++ b/backend/workflows/app_registry_sync.py @@ -0,0 +1,162 @@ +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 .app_registry import APP_DEFINITIONS, ensure_portal_app_configs, normalize_portal_app_sort_orders +from .models import PortalAppConfig + + +EXPORT_FIELDS = ( + 'key', + 'section', + 'sort_order', + 'is_enabled', + 'visible_to_super_admin', + 'visible_to_admin', + 'visible_to_it_staff', + 'visible_to_staff', + 'title_override', + 'title_override_en', + 'description_override', + 'description_override_en', + 'action_label_override', + 'action_label_override_en', +) + +FORMAT_VERSION = 1 + + +def _valid_keys() -> set[str]: + return {definition.key for definition in APP_DEFINITIONS} + + +def _valid_sections() -> set[str]: + return {key for key, _label in PortalAppConfig.SECTION_CHOICES} + + +def export_portal_app_config_payload() -> dict[str, object]: + ensure_portal_app_configs() + rows = list( + PortalAppConfig.objects.order_by('section', 'sort_order', 'key').values(*EXPORT_FIELDS) + ) + return { + 'format': 'portal_app_config', + 'version': FORMAT_VERSION, + 'generated_at': timezone.now(), + 'items': rows, + } + + +def write_portal_app_config_export(path: str | Path) -> Path: + payload = export_portal_app_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_app_config_payload(path: str | Path) -> dict[str, object]: + payload = json.loads(Path(path).read_text(encoding='utf-8')) + if payload.get('format') != 'portal_app_config': + raise ValueError('Unsupported payload format.') + if payload.get('version') != FORMAT_VERSION: + raise ValueError(f"Unsupported payload version: {payload.get('version')}") + items = payload.get('items') + if not isinstance(items, list): + raise ValueError('Payload items must be a list.') + return payload + + +def validate_portal_app_config_payload(payload: dict[str, object]) -> list[dict[str, object]]: + valid_keys = _valid_keys() + valid_sections = _valid_sections() + items = payload['items'] + seen_keys: set[str] = set() + normalized_items: list[dict[str, object]] = [] + + for item in items: + if not isinstance(item, dict): + raise ValueError('Each item must be an object.') + key = item.get('key') + if not isinstance(key, str) or not key.strip(): + raise ValueError('Each item must contain a non-empty key.') + if key not in valid_keys: + raise ValueError(f'Unknown app key: {key}') + if key in seen_keys: + raise ValueError(f'Duplicate app key: {key}') + seen_keys.add(key) + + section = item.get('section') + if section not in valid_sections: + raise ValueError(f'Invalid section for {key}: {section}') + + sort_order = item.get('sort_order') + if not isinstance(sort_order, int) or sort_order < 0: + raise ValueError(f'Invalid sort_order for {key}: {sort_order}') + + normalized: dict[str, object] = {'key': key, 'section': section, 'sort_order': sort_order} + for field in EXPORT_FIELDS: + if field in {'key', 'section', 'sort_order'}: + continue + value = item.get(field, False if field.startswith('visible_to_') or field == 'is_enabled' else '') + if field == 'is_enabled' or field.startswith('visible_to_'): + if not isinstance(value, bool): + raise ValueError(f'Invalid boolean for {key}: {field}') + else: + if value is None: + value = '' + if not isinstance(value, str): + raise ValueError(f'Invalid string for {key}: {field}') + normalized[field] = value + normalized_items.append(normalized) + + missing = valid_keys.difference(seen_keys) + if missing: + raise ValueError(f'Missing app keys: {", ".join(sorted(missing))}') + return normalized_items + + +@transaction.atomic +def import_portal_app_config_payload(payload: dict[str, object], dry_run: bool = False) -> dict[str, int]: + ensure_portal_app_configs() + items = validate_portal_app_config_payload(payload) + updated = 0 + created = 0 + + existing_map = {config.key: config for config in PortalAppConfig.objects.all()} + for item in items: + key = str(item['key']) + config = existing_map.get(key) + if config is None: + config = PortalAppConfig(key=key) + created += 1 + + changed = False + for field in EXPORT_FIELDS: + value = item[field] + if getattr(config, field) != value: + setattr(config, field, value) + changed = True + if config.pk is None: + changed = True + if changed: + updated += 1 + if not dry_run: + config.save() + + if not dry_run: + normalize_portal_app_sort_orders() + else: + transaction.set_rollback(True) + + return { + 'created': created, + 'updated': updated, + 'total': len(items), + 'dry_run': 1 if dry_run else 0, + } diff --git a/backend/workflows/management/commands/export_portal_app_config.py b/backend/workflows/management/commands/export_portal_app_config.py new file mode 100644 index 0000000..eb458d6 --- /dev/null +++ b/backend/workflows/management/commands/export_portal_app_config.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand + +from workflows.app_registry_sync import export_portal_app_config_payload, write_portal_app_config_export + + +class Command(BaseCommand): + help = 'Export PortalAppConfig 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_app_config_export(output) + self.stdout.write(self.style.SUCCESS(f'Exported PortalAppConfig to {path}')) + return + + payload = export_portal_app_config_payload() + import json + + self.stdout.write(json.dumps(payload, indent=2, default=str)) 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_app_config.py b/backend/workflows/management/commands/import_portal_app_config.py new file mode 100644 index 0000000..208f145 --- /dev/null +++ b/backend/workflows/management/commands/import_portal_app_config.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand, CommandError + +from workflows.app_registry_sync import import_portal_app_config_payload, load_portal_app_config_payload + + +class Command(BaseCommand): + help = 'Import PortalAppConfig JSON for environment sync.' + + def add_arguments(self, parser): + parser.add_argument('input', help='Path to a previously exported PortalAppConfig JSON file.') + parser.add_argument('--dry-run', action='store_true', help='Validate and report changes without saving.') + + def handle(self, *args, **options): + try: + payload = load_portal_app_config_payload(options['input']) + summary = import_portal_app_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. total={summary['total']} updated={summary['updated']} created={summary['created']}" + ) + ) 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 01b9dea..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

    @@ -244,6 +250,12 @@ docker compose exec -T web django-admin compilemessages
  • The landing page now renders from registry data instead of hardcoded cards.
  • Security remains code-based: app visibility/order is configurable, but access still depends on role capabilities in roles.py.
  • Management UI: /admin-tools/apps/ for Platform Owner.
  • +
  • Environment sync is explicit, not automatic. Use: +
    docker compose exec -T web python manage.py export_portal_app_config --output /tmp/portal-app-config.json
    +docker compose exec -T web python manage.py import_portal_app_config /tmp/portal-app-config.json --dry-run
    +docker compose exec -T web python manage.py import_portal_app_config /tmp/portal-app-config.json
    +
  • +
  • This is the correct path when local and server app ordering differ.
  • 12c) Trial Lifecycle

    diff --git a/backend/workflows/templates/workflows/handbook.html b/backend/workflows/templates/workflows/handbook.html index e1fa3ca..4e90260 100644 --- a/backend/workflows/templates/workflows/handbook.html +++ b/backend/workflows/templates/workflows/handbook.html @@ -46,6 +46,7 @@
    {% trans "Open Developer Handbook" %} + {% trans "Open Contributor Guide" %}
    diff --git a/backend/workflows/tests/test_portal_app_config_sync.py b/backend/workflows/tests/test_portal_app_config_sync.py new file mode 100644 index 0000000..129a050 --- /dev/null +++ b/backend/workflows/tests/test_portal_app_config_sync.py @@ -0,0 +1,125 @@ +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.app_registry import ensure_portal_app_configs +from workflows.models import PortalAppConfig + + +class PortalAppConfigSyncTests(TestCase): + def setUp(self): + ensure_portal_app_configs() + + def test_export_writes_expected_payload(self): + with TemporaryDirectory() as tmpdir: + output_path = Path(tmpdir) / 'portal-app-config.json' + call_command('export_portal_app_config', '--output', str(output_path)) + payload = json.loads(output_path.read_text(encoding='utf-8')) + + self.assertEqual(payload['format'], 'portal_app_config') + self.assertEqual(payload['version'], 1) + self.assertEqual(len(payload['items']), PortalAppConfig.objects.count()) + self.assertIn('generated_at', payload) + + def test_import_applies_runtime_ordering_changes(self): + payload = { + 'format': 'portal_app_config', + 'version': 1, + 'items': list(PortalAppConfig.objects.order_by('section', 'sort_order', 'key').values( + 'key', + 'section', + 'sort_order', + 'is_enabled', + 'visible_to_super_admin', + 'visible_to_admin', + 'visible_to_it_staff', + 'visible_to_staff', + 'title_override', + 'title_override_en', + 'description_override', + 'description_override_en', + 'action_label_override', + 'action_label_override_en', + )), + } + for item in payload['items']: + if item['key'] == 'branding': + item['sort_order'] = 0 + elif item['key'] == 'company_config': + item['sort_order'] = 1 + item['title_override'] = 'Company Setup' + + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / 'portal-app-config.json' + input_path.write_text(json.dumps(payload), encoding='utf-8') + call_command('import_portal_app_config', str(input_path)) + + branding = PortalAppConfig.objects.get(key='branding') + company_config = PortalAppConfig.objects.get(key='company_config') + self.assertEqual(branding.sort_order, 0) + self.assertEqual(company_config.title_override, 'Company Setup') + + def test_import_dry_run_does_not_persist(self): + payload = { + 'format': 'portal_app_config', + 'version': 1, + 'items': list(PortalAppConfig.objects.order_by('section', 'sort_order', 'key').values( + 'key', + 'section', + 'sort_order', + 'is_enabled', + 'visible_to_super_admin', + 'visible_to_admin', + 'visible_to_it_staff', + 'visible_to_staff', + 'title_override', + 'title_override_en', + 'description_override', + 'description_override_en', + 'action_label_override', + 'action_label_override_en', + )), + } + for item in payload['items']: + if item['key'] == 'branding': + item['title_override'] = 'Dry Run Only' + + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / 'portal-app-config.json' + input_path.write_text(json.dumps(payload), encoding='utf-8') + call_command('import_portal_app_config', str(input_path), '--dry-run') + + self.assertEqual(PortalAppConfig.objects.get(key='branding').title_override, '') + + def test_import_rejects_unknown_keys(self): + payload = { + 'format': 'portal_app_config', + 'version': 1, + 'items': [ + { + 'key': 'unknown_app', + 'section': 'platform', + 'sort_order': 0, + 'is_enabled': True, + 'visible_to_super_admin': True, + 'visible_to_admin': True, + 'visible_to_it_staff': False, + 'visible_to_staff': False, + 'title_override': '', + 'title_override_en': '', + 'description_override': '', + 'description_override_en': '', + 'action_label_override': '', + 'action_label_override_en': '', + } + ], + } + with TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / 'portal-app-config.json' + input_path.write_text(json.dumps(payload), encoding='utf-8') + with self.assertRaises(CommandError): + call_command('import_portal_app_config', str(input_path)) 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))