Merge branch 'develop'
This commit is contained in:
162
backend/workflows/app_registry_sync.py
Normal file
162
backend/workflows/app_registry_sync.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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']}"
|
||||
)
|
||||
)
|
||||
@@ -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']}")
|
||||
)
|
||||
139
backend/workflows/portal_config_sync.py
Normal file
139
backend/workflows/portal_config_sync.py
Normal file
@@ -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,
|
||||
}
|
||||
@@ -235,6 +235,12 @@ docker compose exec -T web django-admin compilemessages</code></pre>
|
||||
<li>The company domain now drives onboarding/offboarding email autofill and domain validation, so new customer deployments no longer require <code>@tub.co</code> code changes.</li>
|
||||
<li>Outgoing system mail sender names are now branded through the same layer.</li>
|
||||
<li>User invitation emails and welcome-template fallbacks also use the configured branding defaults.</li>
|
||||
<li>Environment sync for text/color/company metadata is explicit:
|
||||
<pre><code>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</code></pre>
|
||||
</li>
|
||||
<li>Uploaded assets such as logo, favicon, and PDF letterhead are intentionally not included in this sync payload.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="app-registry">12b) App Registry</h2>
|
||||
@@ -244,6 +250,12 @@ docker compose exec -T web django-admin compilemessages</code></pre>
|
||||
<li>The landing page now renders from registry data instead of hardcoded cards.</li>
|
||||
<li>Security remains code-based: app visibility/order is configurable, but access still depends on role capabilities in <code>roles.py</code>.</li>
|
||||
<li>Management UI: <code>/admin-tools/apps/</code> for <code>Platform Owner</code>.</li>
|
||||
<li>Environment sync is explicit, not automatic. Use:
|
||||
<pre><code>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</code></pre>
|
||||
</li>
|
||||
<li>This is the correct path when local and server app ordering differ.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="trial">12c) Trial Lifecycle</h2>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<a class="btn btn-secondary" href="/admin-tools/developer-handbook/">{% trans "Open Developer Handbook" %}</a>
|
||||
<a class="btn btn-secondary" href="/admin-tools/developer-handbook/#workflow">{% trans "Open Contributor Guide" %}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
125
backend/workflows/tests/test_portal_app_config_sync.py
Normal file
125
backend/workflows/tests/test_portal_app_config_sync.py
Normal file
@@ -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))
|
||||
172
backend/workflows/tests/test_portal_deployment_config_sync.py
Normal file
172
backend/workflows/tests/test_portal_deployment_config_sync.py
Normal file
@@ -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))
|
||||
Reference in New Issue
Block a user