from __future__ import annotations from dataclasses import dataclass from django.urls import reverse from django.utils.translation import gettext_lazy as _ from .models import PortalAppConfig from .roles import ROLE_ADMIN, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, get_user_role_key, user_has_capability # The registry controls discoverability and packaging posture for apps. # Actual authorization still comes from role capabilities in roles.py. @dataclass(frozen=True) class AppDefinition: key: str section: str route_name: str title: object description: object action_label: object capability: str | None = None accent: str = '' accent_label: str = 'APP' style_variant: str = '' tags: tuple[object, ...] = () APP_DEFINITIONS: tuple[AppDefinition, ...] = ( AppDefinition( key='onboarding', section=PortalAppConfig.SECTION_APP, route_name='onboarding_create', title=_('Onboarding'), description=_('Neue Mitarbeitende erfassen, PDF mit Briefkopf erstellen, Benachrichtigungen senden und in Nextcloud ablegen.'), action_label=_('Onboarding starten'), accent='ON', tags=(_('Mehrschritt-Formular'), 'PDF', _('E-Mail Routing')), style_variant='primary', ), AppDefinition( key='offboarding', section=PortalAppConfig.SECTION_APP, route_name='offboarding_create', title=_('Offboarding'), description=_('Mitarbeitende suchen, Daten vorbefüllen, Offboarding-Dokumente erzeugen und Rückgabe-Prozess starten.'), action_label=_('Offboarding starten'), accent='OFF', tags=(_('Profile-Suche'), _('Hardware-Liste'), _('IT-Rückgabe')), style_variant='red', ), AppDefinition( key='requests_dashboard', section=PortalAppConfig.SECTION_APP, route_name='requests_dashboard', title=_('Anfragen Dashboard'), description=_('Status, Suchfunktion, PDF-Links und Verlauf aller Onboarding-/Offboarding-Anfragen.'), action_label=_('Dashboard öffnen'), capability='access_requests_dashboard', accent='APP', tags=(_('Suche'), _('Status'), _('PDF Zugriff')), ), AppDefinition( key='company_config', section=PortalAppConfig.SECTION_PLATFORM, route_name='portal_company_config_page', title=_('Company Config'), description=_('Rechtliche Firmendaten, Kontaktpunkte und öffentliche Unternehmenslinks pflegen.'), action_label=_('Öffnen'), capability='manage_company_config', ), AppDefinition( key='trial_management', section=PortalAppConfig.SECTION_PLATFORM, route_name='portal_trial_config_page', title=_('Trial Management'), description=_('Testlaufzeit, Banner und sichere Einschränkungen für Demo-Umgebungen steuern.'), action_label=_('Öffnen'), capability='manage_trial_lifecycle', ), AppDefinition( key='branding', section=PortalAppConfig.SECTION_PLATFORM, route_name='portal_branding_page', title=_('Branding'), description=_('Logo, Portalname, Farben und PDF-Briefkopf verwalten.'), action_label=_('Öffnen'), capability='manage_product_branding', ), AppDefinition( key='app_registry', section=PortalAppConfig.SECTION_PLATFORM, route_name='portal_app_registry_page', title=_('App Registry'), description=_('Apps zentral aktivieren, sortieren und für Kundenauftritte vorbereiten.'), action_label=_('Öffnen'), capability='manage_app_registry', ), AppDefinition( key='integrations', section=PortalAppConfig.SECTION_ADMIN, route_name='integrations_setup_page', title=_('Integrationen'), description=_('Nextcloud- und E-Mail-Setup.'), action_label=_('Öffnen'), capability='manage_integrations', ), AppDefinition( key='job_monitor', section=PortalAppConfig.SECTION_ADMIN, route_name='job_monitor_page', title=_('Job Monitor'), description=_('Asynchrone Aufgaben, Fehler und letzte Worker-Läufe prüfen.'), action_label=_('Öffnen'), capability='view_job_monitor', ), AppDefinition( key='users', section=PortalAppConfig.SECTION_ADMIN, route_name='user_management_page', title=_('Benutzer & Rollen'), description=_('Benutzer anlegen, Rollen zuweisen und Zugriffe steuern.'), action_label=_('Öffnen'), capability='manage_users', ), AppDefinition( key='audit_log', section=PortalAppConfig.SECTION_ADMIN, route_name='audit_log_page', title=_('Audit Log'), description=_('Wichtige Admin-Aktionen nachvollziehen und prüfen.'), action_label=_('Öffnen'), capability='view_audit_log', ), AppDefinition( key='backups', section=PortalAppConfig.SECTION_ADMIN, route_name='backup_recovery_page', title=_('Backup & Recovery'), description=_('Backups erstellen und sicher verifizieren.'), action_label=_('Öffnen'), capability='manage_backups', ), AppDefinition( key='welcome_emails', section=PortalAppConfig.SECTION_ADMIN, route_name='welcome_emails_page', title=_('Welcome E-Mails'), description=_('Geplante Welcome Mails verwalten.'), action_label=_('Öffnen'), capability='manage_welcome_emails', ), AppDefinition( key='form_builder', section=PortalAppConfig.SECTION_ADMIN, route_name='form_builder_page', title=_('Form Builder'), description=_('Felder, Schritte und Optionen verwalten.'), action_label=_('Öffnen'), capability='manage_builders', ), AppDefinition( key='intro_builder', section=PortalAppConfig.SECTION_ADMIN, route_name='intro_builder_page', title=_('Einweisungs-Builder'), description=_('Checklistenpunkte für das Einweisungsprotokoll konfigurieren.'), action_label=_('Öffnen'), capability='manage_builders', ), AppDefinition( key='handbook', section=PortalAppConfig.SECTION_ADMIN, route_name='handbook_page', title=_('Handbook'), description=_('Project wiki and developer documentation in one place.'), action_label=_('Öffnen'), capability='view_docs', ), AppDefinition( key='django_admin', section=PortalAppConfig.SECTION_ADMIN, route_name='admin:index', title=_('Django Admin'), description=_('Vollständige Datenverwaltung.'), action_label=_('Öffnen'), capability='access_django_admin_link', ), ) DEFAULT_ROLE_VISIBILITY = { # These defaults are product recommendations for fresh deployments. # Saved PortalAppConfig rows can override them per customer installation. 'onboarding': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: True, ROLE_STAFF: True, }, 'offboarding': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: True, ROLE_STAFF: True, }, 'requests_dashboard': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: True, ROLE_STAFF: False, }, 'branding': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: False, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'company_config': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: False, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'trial_management': { ROLE_SUPER_ADMIN: False, ROLE_ADMIN: False, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'app_registry': { ROLE_SUPER_ADMIN: False, ROLE_ADMIN: False, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'integrations': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'job_monitor': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'users': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: False, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'audit_log': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'backups': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'welcome_emails': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'form_builder': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'intro_builder': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'handbook': { ROLE_SUPER_ADMIN: True, ROLE_ADMIN: True, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, 'django_admin': { ROLE_SUPER_ADMIN: False, ROLE_ADMIN: False, ROLE_IT_STAFF: False, ROLE_STAFF: False, }, } def _default_visibility_summary(definition_key: str) -> str: visibility = DEFAULT_ROLE_VISIBILITY.get(definition_key, {}) enabled_roles = [ role for role in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF) if visibility.get(role) ] if not enabled_roles: return str(_('Nur Platform')) if enabled_roles == [ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF]: return str(_('Alle Firmenrollen')) return ' + '.join(str(ROLE_LABELS[role]) for role in enabled_roles if role in ROLE_LABELS) SECTION_META = { PortalAppConfig.SECTION_APP: { 'title': _('Apps'), 'subtitle': _('Wählen Sie den gewünschten Prozess.'), 'css_class': 'section-head-primary', 'grid_class': 'apps-grid', }, PortalAppConfig.SECTION_PLATFORM: { 'title': _('Platform Apps'), 'subtitle': _('Produktweite Konfiguration und Produktsteuerung.'), 'css_class': 'section-head-platform', 'grid_class': 'admin-grid', }, PortalAppConfig.SECTION_ADMIN: { 'title': _('Admin Apps'), 'subtitle': _('Konfiguration, Tests und Steuerung.'), 'css_class': 'section-head-admin', 'grid_class': 'admin-grid', }, } def ensure_portal_app_configs() -> None: for index, definition in enumerate(APP_DEFINITIONS): visibility = DEFAULT_ROLE_VISIBILITY.get(definition.key, {}) PortalAppConfig.objects.get_or_create( key=definition.key, defaults={ 'section': definition.section, 'sort_order': index, 'is_enabled': True, 'visible_to_super_admin': visibility.get(ROLE_SUPER_ADMIN, False), 'visible_to_admin': visibility.get(ROLE_ADMIN, False), 'visible_to_it_staff': visibility.get(ROLE_IT_STAFF, False), 'visible_to_staff': visibility.get(ROLE_STAFF, False), }, ) normalize_portal_app_sort_orders() def normalize_portal_app_sort_orders() -> None: for section_key, _label in PortalAppConfig.SECTION_CHOICES: configs = list(PortalAppConfig.objects.filter(section=section_key).order_by('sort_order', 'key')) for position, config in enumerate(configs): if config.sort_order != position: config.sort_order = position config.save(update_fields=['sort_order']) def get_portal_app_registry_rows() -> list[dict[str, object]]: ensure_portal_app_configs() config_map = {config.key: config for config in PortalAppConfig.objects.all()} rows: list[dict[str, object]] = [] for index, definition in enumerate(APP_DEFINITIONS): config = config_map[definition.key] rows.append( { 'definition': definition, 'config': config, 'default_section': definition.section, 'default_sort_order': index, 'default_visibility_summary': _default_visibility_summary(definition.key), } ) return rows def build_portal_app_sections(user) -> list[dict[str, object]]: ensure_portal_app_configs() config_map = {config.key: config for config in PortalAppConfig.objects.all()} grouped: dict[str, list[dict[str, object]]] = {key: [] for key in SECTION_META} role_key = get_user_role_key(user) for definition in APP_DEFINITIONS: config = config_map.get(definition.key) if not config or not config.is_enabled: continue if definition.capability and not user_has_capability(user, definition.capability): continue if role_key != ROLE_PLATFORM_OWNER: if role_key == ROLE_SUPER_ADMIN and not config.visible_to_super_admin: continue if role_key == ROLE_ADMIN and not config.visible_to_admin: continue if role_key == ROLE_IT_STAFF and not config.visible_to_it_staff: continue if role_key == ROLE_STAFF and not config.visible_to_staff: continue grouped[config.section].append( { 'key': definition.key, 'href': reverse(definition.route_name), 'title': config.translated_title_override() or str(definition.title), 'description': config.translated_description_override() or str(definition.description), 'action_label': config.translated_action_label_override() or str(definition.action_label), 'accent': definition.accent, 'accent_label': definition.accent_label, 'style_variant': definition.style_variant, 'tags': [str(tag) for tag in definition.tags], 'sort_order': config.sort_order, } ) sections: list[dict[str, object]] = [] for section_key, meta in SECTION_META.items(): apps = sorted(grouped.get(section_key, []), key=lambda item: (item['sort_order'], item['title'])) if not apps: continue sections.append( { 'key': section_key, 'title': str(meta['title']), 'subtitle': str(meta['subtitle']), 'css_class': meta['css_class'], 'grid_class': meta['grid_class'], 'apps': apps, } ) return sections