snapshot: preserve scalable app registry and landing visibility rules

This commit is contained in:
Md Bayazid Bostame
2026-03-26 12:59:45 +01:00
parent 007d4e329a
commit 9437aaa29a
9 changed files with 762 additions and 242 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -27,11 +27,21 @@ class PortalBrandingAdmin(admin.ModelAdmin):
@admin.register(PortalAppConfig) @admin.register(PortalAppConfig)
class PortalAppConfigAdmin(admin.ModelAdmin): class PortalAppConfigAdmin(admin.ModelAdmin):
list_display = ('key', 'section', 'sort_order', 'is_enabled', 'updated_at') list_display = (
list_filter = ('section', 'is_enabled') 'key',
'section',
'sort_order',
'is_enabled',
'visible_to_super_admin',
'visible_to_admin',
'visible_to_it_staff',
'visible_to_staff',
'updated_at',
)
list_filter = ('section', 'is_enabled', 'visible_to_super_admin', 'visible_to_admin', 'visible_to_it_staff', 'visible_to_staff')
search_fields = ('key', 'title_override', 'title_override_en') search_fields = ('key', 'title_override', 'title_override_en')
ordering = ('section', 'sort_order', 'key') ordering = ('section', 'sort_order', 'key')
list_editable = ('section', 'sort_order', 'is_enabled') list_editable = ('section', 'sort_order', 'is_enabled', 'visible_to_super_admin', 'visible_to_admin', 'visible_to_it_staff', 'visible_to_staff')
@admin.register(OnboardingRequest) @admin.register(OnboardingRequest)

View File

@@ -6,7 +6,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import PortalAppConfig from .models import PortalAppConfig
from .roles import user_has_capability 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
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -160,6 +160,108 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
) )
DEFAULT_ROLE_VISIBILITY = {
'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: 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,
},
'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 = { SECTION_META = {
PortalAppConfig.SECTION_APP: { PortalAppConfig.SECTION_APP: {
'title': _('Apps'), 'title': _('Apps'),
@@ -184,12 +286,17 @@ SECTION_META = {
def ensure_portal_app_configs() -> None: def ensure_portal_app_configs() -> None:
for index, definition in enumerate(APP_DEFINITIONS): for index, definition in enumerate(APP_DEFINITIONS):
visibility = DEFAULT_ROLE_VISIBILITY.get(definition.key, {})
PortalAppConfig.objects.get_or_create( PortalAppConfig.objects.get_or_create(
key=definition.key, key=definition.key,
defaults={ defaults={
'section': definition.section, 'section': definition.section,
'sort_order': index, 'sort_order': index,
'is_enabled': True, '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),
}, },
) )
@@ -206,6 +313,7 @@ def get_portal_app_registry_rows() -> list[dict[str, object]]:
'config': config, 'config': config,
'default_section': definition.section, 'default_section': definition.section,
'default_sort_order': index, 'default_sort_order': index,
'default_visibility_summary': _default_visibility_summary(definition.key),
} }
) )
return rows return rows
@@ -215,6 +323,7 @@ def build_portal_app_sections(user) -> list[dict[str, object]]:
ensure_portal_app_configs() ensure_portal_app_configs()
config_map = {config.key: config for config in PortalAppConfig.objects.all()} config_map = {config.key: config for config in PortalAppConfig.objects.all()}
grouped: dict[str, list[dict[str, object]]] = {key: [] for key in SECTION_META} 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: for definition in APP_DEFINITIONS:
config = config_map.get(definition.key) config = config_map.get(definition.key)
@@ -222,6 +331,15 @@ def build_portal_app_sections(user) -> list[dict[str, object]]:
continue continue
if definition.capability and not user_has_capability(user, definition.capability): if definition.capability and not user_has_capability(user, definition.capability):
continue 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( grouped[config.section].append(
{ {
'key': definition.key, 'key': definition.key,

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.1.5 on 2026-03-26 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0040_portalbranding_favicon_image_and_more'),
]
operations = [
migrations.AddField(
model_name='portalappconfig',
name='visible_to_admin',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='portalappconfig',
name='visible_to_it_staff',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='portalappconfig',
name='visible_to_staff',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='portalappconfig',
name='visible_to_super_admin',
field=models.BooleanField(default=True),
),
]

View File

@@ -86,6 +86,10 @@ class PortalAppConfig(models.Model):
section = models.CharField(max_length=20, choices=SECTION_CHOICES, default=SECTION_APP) section = models.CharField(max_length=20, choices=SECTION_CHOICES, default=SECTION_APP)
sort_order = models.PositiveIntegerField(default=0) sort_order = models.PositiveIntegerField(default=0)
is_enabled = models.BooleanField(default=True) is_enabled = models.BooleanField(default=True)
visible_to_super_admin = models.BooleanField(default=True)
visible_to_admin = models.BooleanField(default=True)
visible_to_it_staff = models.BooleanField(default=False)
visible_to_staff = models.BooleanField(default=False)
title_override = models.CharField(max_length=255, blank=True) title_override = models.CharField(max_length=255, blank=True)
title_override_en = models.CharField(max_length=255, blank=True) title_override_en = models.CharField(max_length=255, blank=True)
description_override = models.TextField(blank=True) description_override = models.TextField(blank=True)

View File

@@ -65,6 +65,30 @@ th { background: #f6f9ff; color: #334155; }
.app-registry-table select, .app-registry-table select,
.app-registry-table textarea { min-width: 160px; } .app-registry-table textarea { min-width: 160px; }
.app-registry-table td { background: rgba(255,255,255,0.9); } .app-registry-table td { background: rgba(255,255,255,0.9); }
.app-registry-cards { display: grid; gap: 14px; }
.app-registry-filters { display: grid; grid-template-columns: minmax(260px, 1.5fr) repeat(2, minmax(180px, 0.7fr)); gap: 12px; margin-bottom: 14px; }
.app-registry-card { border: 1px solid #d9e4f1; border-radius: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,250,255,0.95)); padding: 16px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.94); transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms cubic-bezier(0.2, 0.8, 0.2, 1); }
.app-registry-card:hover { transform: translateY(-1px); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.06); border-color: #c9d8eb; }
.app-registry-card.is-disabled { opacity: 0.84; }
.app-registry-card[hidden] { display: none !important; }
.app-registry-card-head { display: flex; justify-content: space-between; align-items: start; gap: 14px; margin-bottom: 14px; }
.app-registry-card-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.app-registry-card-title-row h2 { margin: 0; color: #17345e; font-size: 19px; }
.app-registry-card-copy { margin: 8px 0 0; color: #60738d; max-width: 760px; }
.app-registry-summary { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.9fr); gap: 16px; align-items: center; list-style: none; cursor: pointer; }
.app-registry-summary::-webkit-details-marker { display: none; }
.app-registry-summary::marker { display: none; }
.app-registry-summary-main { min-width: 0; }
.app-registry-summary-meta { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; align-items: center; }
.app-registry-card-grid { display: grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap: 12px; align-items: start; }
.app-registry-card .app-registry-card-grid { margin-top: 14px; padding-top: 14px; border-top: 1px solid #dce6f2; }
.app-registry-panel { border: 1px solid #dce6f2; border-radius: 14px; background: rgba(255,255,255,0.86); padding: 12px; }
.app-registry-panel h3 { margin: 0 0 10px; color: #213a61; font-size: 15px; }
.app-registry-panel h4 { margin: 0 0 10px; color: #223b63; font-size: 14px; }
.app-registry-checks { gap: 10px 14px; }
.app-registry-checks label { min-width: 130px; padding: 8px 10px; border: 1px solid #d7e2ef; border-radius: 12px; background: #f8fbff; }
.app-registry-copy-panel { grid-column: 1 / -1; }
.app-registry-savebar { position: sticky; bottom: 14px; z-index: 5; display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 16px; padding: 12px 14px; border: 1px solid #cad8ea; border-radius: 16px; background: rgba(255,255,255,0.95); box-shadow: 0 12px 24px rgba(16, 32, 57, 0.08); backdrop-filter: blur(8px); }
.template-block { border: 1px solid #d8e3f0; border-radius: 10px; background: #fff; padding: 10px; margin-top: 10px; } .template-block { border: 1px solid #d8e3f0; border-radius: 10px; background: #fff; padding: 10px; margin-top: 10px; }
.template-title, .rule-title { margin: 0 0 8px; color: #24344e; font-weight: 700; font-size: 14px; } .template-title, .rule-title { margin: 0 0 8px; color: #24344e; font-weight: 700; font-size: 14px; }
.rule-card { margin-top: 12px; border: 1px solid #d8e3f0; border-radius: 12px; padding: 10px; background: #fff; } .rule-card { margin-top: 12px; border: 1px solid #d8e3f0; border-radius: 12px; padding: 10px; background: #fff; }
@@ -89,4 +113,10 @@ th { background: #f6f9ff; color: #334155; }
.grid { grid-template-columns: 1fr; } .grid { grid-template-columns: 1fr; }
.branding-preview-header { flex-direction: column; align-items: flex-start; } .branding-preview-header { flex-direction: column; align-items: flex-start; }
.branding-preview-band { flex-wrap: wrap; } .branding-preview-band { flex-wrap: wrap; }
.app-registry-filters { grid-template-columns: 1fr; }
.app-registry-summary { grid-template-columns: 1fr; }
.app-registry-summary-meta { justify-content: flex-start; }
.app-registry-card-grid { grid-template-columns: 1fr; }
.app-registry-copy-panel { grid-column: auto; }
.app-registry-savebar { align-items: stretch; flex-direction: column; }
} }

View File

@@ -1,5 +1,6 @@
{% extends 'workflows/base_shell.html' %} {% extends 'workflows/base_shell.html' %}
{% load static i18n %} {% load static i18n %}
{% trans "Ungespeicherte Änderungen" as dirty_state_label %}
{% block title %}{% trans "App Registry" %}{% endblock %} {% block title %}{% trans "App Registry" %}{% endblock %}
@@ -21,63 +22,220 @@
</div> </div>
<form method="post" action="{% url 'save_portal_app_registry' %}" class="stack-form"> <form method="post" action="{% url 'save_portal_app_registry' %}" class="stack-form">
{% csrf_token %} {% csrf_token %}
<div class="table-wrap app-registry-wrap"> <section class="app-registry-filters">
<table class="app-registry-table"> <div class="field">
<thead> <label for="app-registry-search">{% trans "Suche" %}</label>
<tr> <input id="app-registry-search" type="text" placeholder="{% trans 'Nach App-Name oder Key filtern' %}" />
<th>{% trans "Key" %}</th> </div>
<th>{% trans "Aktiv" %}</th> <div class="field">
<th>{% trans "Bereich" %}</th> <label for="app-registry-state">{% trans "Status" %}</label>
<th>{% trans "Reihenfolge" %}</th> <select id="app-registry-state">
<th>{% trans "Titel DE" %}</th> <option value="all">{% trans "Alle" %}</option>
<th>{% trans "Titel EN" %}</th> <option value="enabled">{% trans "Aktiv" %}</option>
<th>{% trans "Aktion DE" %}</th> <option value="disabled">{% trans "Deaktiviert" %}</option>
<th>{% trans "Aktion EN" %}</th> <option value="platform_only">{% trans "Platform only" %}</option>
</tr> </select>
</thead> </div>
<tbody> <div class="field">
{% for row in rows %} <label for="app-registry-section">{% trans "Bereich" %}</label>
<tr> <select id="app-registry-section">
<td> <option value="all">{% trans "Alle" %}</option>
<div><strong>{{ row.config.key }}</strong></div> <option value="apps">{% trans "Apps" %}</option>
<div class="mini">{{ row.definition.title }}</div> <option value="platform_apps">{% trans "Platform Apps" %}</option>
</td> <option value="admin_apps">{% trans "Admin Apps" %}</option>
<td class="select-col"> </select>
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} /> </div>
</td> </section>
<td> <div class="app-registry-cards">
<select name="section__{{ row.config.key }}"> {% for row in rows %}
{% for value, label in section_choices %} <details class="app-registry-card{% if not row.config.is_enabled %} is-disabled{% endif %}" data-app-card
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option> data-key="{{ row.config.key|lower }}"
{% endfor %} data-title="{{ row.definition.title|lower }}"
</select> data-enabled="{% if row.config.is_enabled %}1{% else %}0{% endif %}"
</td> data-platform-only="{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}1{% else %}0{% endif %}"
<td> data-section="{{ row.config.section }}">
<input type="number" name="sort_order__{{ row.config.key }}" value="{{ row.config.sort_order }}" min="0" step="1" /> <summary class="app-registry-summary">
</td> <div class="app-registry-summary-main">
<td> <div class="app-registry-card-title-row">
<input type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" /> <h2>{{ row.definition.title }}</h2>
<textarea name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea> {% if row.config.is_enabled %}
</td> <span class="badge sent">{% trans "Aktiv" %}</span>
<td> {% else %}
<input type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" /> <span class="badge cancelled">{% trans "Deaktiviert" %}</span>
<textarea name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea> {% endif %}
</td> </div>
<td> <div class="mini">{{ row.config.key }}</div>
<input type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" /> <p class="app-registry-card-copy">{{ row.definition.description }}</p>
</td> <p class="mini">{% trans "Empfohlener Standardzugriff:" %} {{ row.default_visibility_summary }}</p>
<td> </div>
<input type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" /> <div class="app-registry-summary-meta">
</td> <span class="badge scheduled">
</tr> {% if row.config.section == 'platform_apps' %}
{% endfor %} {% trans "Platform Apps" %}
</tbody> {% elif row.config.section == 'admin_apps' %}
</table> {% trans "Admin Apps" %}
{% else %}
{% trans "Apps" %}
{% endif %}
</span>
<span class="badge">{% trans "Sortierung" %}: {{ row.config.sort_order }}</span>
{% if not row.config.visible_to_super_admin and not row.config.visible_to_admin and not row.config.visible_to_it_staff and not row.config.visible_to_staff %}
<span class="badge paused">{% trans "Platform only" %}</span>
{% elif row.config.visible_to_super_admin and row.config.visible_to_admin and row.config.visible_to_it_staff and row.config.visible_to_staff %}
<span class="badge sent">{% trans "Alle Firmenrollen" %}</span>
{% else %}
<span class="badge scheduled">
{% if row.config.visible_to_super_admin %}{% trans "Super Admin" %}{% endif %}
{% if row.config.visible_to_admin %}{% if row.config.visible_to_super_admin %} + {% endif %}{% trans "Admin" %}{% endif %}
{% if row.config.visible_to_it_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin %} + {% endif %}{% trans "IT Staff" %}{% endif %}
{% if row.config.visible_to_staff %}{% if row.config.visible_to_super_admin or row.config.visible_to_admin or row.config.visible_to_it_staff %} + {% endif %}{% trans "Staff" %}{% endif %}
</span>
{% endif %}
</div>
</summary>
<div class="app-registry-card-grid">
<section class="app-registry-panel">
<h3>{% trans "Verfügbarkeit" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="is_enabled__{{ row.config.key }}" {% if row.config.is_enabled %}checked{% endif %} />
<span>{% trans "App aktiviert" %}</span>
</label>
</div>
<p class="hint">{% trans "Deaktivierte Apps erscheinen nicht auf der Landing Page, selbst wenn Rollen sie sehen dürften." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Sichtbarkeit nach Rolle" %}</h3>
<div class="check-row app-registry-checks">
<label>
<input type="checkbox" name="visible_to_super_admin__{{ row.config.key }}" {% if row.config.visible_to_super_admin %}checked{% endif %} />
<span>{% trans "Super Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_admin__{{ row.config.key }}" {% if row.config.visible_to_admin %}checked{% endif %} />
<span>{% trans "Admin" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_it_staff__{{ row.config.key }}" {% if row.config.visible_to_it_staff %}checked{% endif %} />
<span>{% trans "IT Staff" %}</span>
</label>
<label>
<input type="checkbox" name="visible_to_staff__{{ row.config.key }}" {% if row.config.visible_to_staff %}checked{% endif %} />
<span>{% trans "Staff" %}</span>
</label>
</div>
<p class="hint">{% trans "Wenn keine Firmenrolle aktiv ist, bleibt die App nur für die Platform sichtbar." %}</p>
</section>
<section class="app-registry-panel">
<h3>{% trans "Platzierung" %}</h3>
<div class="grid">
<div class="field">
<label for="section__{{ row.config.key }}">{% trans "Bereich" %}</label>
<select id="section__{{ row.config.key }}" name="section__{{ row.config.key }}">
{% for value, label in section_choices %}
<option value="{{ value }}"{% if row.config.section == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="sort_order__{{ row.config.key }}">{% trans "Reihenfolge" %}</label>
<input id="sort_order__{{ row.config.key }}" type="number" name="sort_order__{{ row.config.key }}" value="{{ row.config.sort_order }}" min="0" step="1" />
</div>
</div>
</section>
<section class="app-registry-panel app-registry-copy-panel">
<h3>{% trans "Bezeichnungen & Texte" %}</h3>
<div class="grid lang-pairs">
<div class="lang-block">
<h4>{% trans "Deutsch" %}</h4>
<div class="field">
<label for="title_override__{{ row.config.key }}">{% trans "Titel" %}</label>
<input id="title_override__{{ row.config.key }}" type="text" name="title_override__{{ row.config.key }}" value="{{ row.config.title_override }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override__{{ row.config.key }}">{% trans "Beschreibung" %}</label>
<textarea id="description_override__{{ row.config.key }}" name="description_override__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override }}</textarea>
</div>
<div class="field">
<label for="action_label_override__{{ row.config.key }}">{% trans "Aktionslabel" %}</label>
<input id="action_label_override__{{ row.config.key }}" type="text" name="action_label_override__{{ row.config.key }}" value="{{ row.config.action_label_override }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
<div class="lang-block">
<h4>{% trans "English" %}</h4>
<div class="field">
<label for="title_override_en__{{ row.config.key }}">{% trans "Title" %}</label>
<input id="title_override_en__{{ row.config.key }}" type="text" name="title_override_en__{{ row.config.key }}" value="{{ row.config.title_override_en }}" placeholder="{{ row.definition.title }}" />
</div>
<div class="field">
<label for="description_override_en__{{ row.config.key }}">{% trans "Description" %}</label>
<textarea id="description_override_en__{{ row.config.key }}" name="description_override_en__{{ row.config.key }}" rows="3" placeholder="{{ row.definition.description }}">{{ row.config.description_override_en }}</textarea>
</div>
<div class="field">
<label for="action_label_override_en__{{ row.config.key }}">{% trans "Action label" %}</label>
<input id="action_label_override_en__{{ row.config.key }}" type="text" name="action_label_override_en__{{ row.config.key }}" value="{{ row.config.action_label_override_en }}" placeholder="{{ row.definition.action_label }}" />
</div>
</div>
</div>
</section>
</div>
</details>
{% endfor %}
</div> </div>
<div class="toolbar" style="margin-top:1rem;"> <div class="app-registry-savebar">
<div class="hint">{% trans "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter Apps oder Admin Apps einordnen." %}</div> <div>
<div class="hint">{% trans "Empfehlung: Produktweite Apps sparsam halten, kundenbezogene Prozesse unter Apps oder Admin Apps einordnen." %}</div>
<div class="mini" id="app-registry-dirty-state">{% trans "Keine ungespeicherten Änderungen" %}</div>
</div>
<button class="btn btn-primary" type="submit">{% trans "App Registry speichern" %}</button> <button class="btn btn-primary" type="submit">{% trans "App Registry speichern" %}</button>
</div> </div>
</form> </form>
</section> </section>
{% endblock %} {% endblock %}
{% block extra_js %}
<script>
(function () {
const searchInput = document.getElementById('app-registry-search');
const stateSelect = document.getElementById('app-registry-state');
const sectionSelect = document.getElementById('app-registry-section');
const cards = Array.from(document.querySelectorAll('[data-app-card]'));
const form = document.querySelector('form[action$="app-registry/save/"], form[action*="save_portal_app_registry"]') || document.querySelector('.stack-form');
const dirtyState = document.getElementById('app-registry-dirty-state');
function applyFilters() {
const query = (searchInput?.value || '').trim().toLowerCase();
const state = stateSelect?.value || 'all';
const section = sectionSelect?.value || 'all';
cards.forEach((card) => {
const matchesQuery = !query || card.dataset.key.includes(query) || card.dataset.title.includes(query);
const matchesState =
state === 'all' ||
(state === 'enabled' && card.dataset.enabled === '1') ||
(state === 'disabled' && card.dataset.enabled === '0') ||
(state === 'platform_only' && card.dataset.platformOnly === '1');
const matchesSection = section === 'all' || card.dataset.section === section;
card.hidden = !(matchesQuery && matchesState && matchesSection);
});
}
function markDirty() {
if (!dirtyState) return;
dirtyState.textContent = "{{ dirty_state_label|escapejs }}";
}
searchInput?.addEventListener('input', applyFilters);
stateSelect?.addEventListener('change', applyFilters);
sectionSelect?.addEventListener('change', applyFilters);
form?.addEventListener('input', markDirty);
form?.addEventListener('change', markDirty);
applyFilters();
})();
</script>
{% endblock %}

View File

@@ -34,6 +34,7 @@
<span class="eyebrow">{% trans "Operations Console" %}</span> <span class="eyebrow">{% trans "Operations Console" %}</span>
<h1>{{ portal_title }}</h1> <h1>{{ portal_title }}</h1>
<p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p> <p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p>
{% if can_manage_integrations %}
<div class="status-row"> <div class="status-row">
<span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {{ role_label }}</span> <span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {{ role_label }}</span>
<span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}"> <span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}">
@@ -44,6 +45,7 @@
</span> </span>
<span class="status-pill status-pill-neutral">{% trans "PDF + E-Mail Workflow bereit" %}</span> <span class="status-pill status-pill-neutral">{% trans "PDF + E-Mail Workflow bereit" %}</span>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -328,6 +328,7 @@ def _build_onboarding_sections(blocks: list[dict], field_pages: dict[str, str])
@login_required @login_required
def home(request): def home(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default') config, _ = WorkflowConfig.objects.get_or_create(name='Default')
role_key = get_user_role_key(request.user)
return render( return render(
request, request,
'workflows/home.html', 'workflows/home.html',
@@ -336,6 +337,7 @@ def home(request):
'email_test_mode': is_email_test_mode(), 'email_test_mode': is_email_test_mode(),
'workflow_config': config, 'workflow_config': config,
'role_label': get_user_role_label(request.user), 'role_label': get_user_role_label(request.user),
'role_key': role_key,
'portal_app_sections': build_portal_app_sections(request.user), 'portal_app_sections': build_portal_app_sections(request.user),
}, },
) )
@@ -364,6 +366,10 @@ def save_portal_app_registry(request):
if config.section not in dict(PortalAppConfig.SECTION_CHOICES): if config.section not in dict(PortalAppConfig.SECTION_CHOICES):
config.section = row['default_section'] config.section = row['default_section']
config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on' config.is_enabled = request.POST.get(f'is_enabled__{key}') == 'on'
config.visible_to_super_admin = request.POST.get(f'visible_to_super_admin__{key}') == 'on'
config.visible_to_admin = request.POST.get(f'visible_to_admin__{key}') == 'on'
config.visible_to_it_staff = request.POST.get(f'visible_to_it_staff__{key}') == 'on'
config.visible_to_staff = request.POST.get(f'visible_to_staff__{key}') == 'on'
try: try:
config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order']) config.sort_order = int((request.POST.get(f'sort_order__{key}') or '').strip() or row['default_sort_order'])
except ValueError: except ValueError: