snapshot: preserve role management and user lifecycle controls

This commit is contained in:
Md Bayazid Bostame
2026-03-26 10:07:49 +01:00
parent 438334bd92
commit b585287004
17 changed files with 1137 additions and 273 deletions

View File

@@ -4,3 +4,6 @@ from django.apps import AppConfig
class WorkflowsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'workflows'
def ready(self):
from . import signals # noqa: F401

View File

@@ -0,0 +1,5 @@
from .roles import template_role_context
def role_context(request):
return template_role_context(getattr(request, 'user', None))

View File

@@ -1,11 +1,13 @@
from django import forms
from pathlib import Path
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.translation import get_language, gettext as _
from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
YES_NO_CHOICES = [('', '--'), ('ja', 'Ja'), ('nein', 'Nein')]
@@ -96,6 +98,60 @@ HARDWARE_EXTRA_CHOICES = [('Smartphone', 'Smartphone'), ('Anderes', 'Anderes')]
SOFTWARE_EXTRA_CHOICES = [('Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)', 'Adobe Acrobat Pro (Abonnement: Zusätzliche Kosten)'), ('Anderes', 'Anderes')]
class UserManagementCreateForm(forms.Form):
first_name = forms.CharField(label=_('Vorname'), max_length=150, required=False)
last_name = forms.CharField(label=_('Nachname'), max_length=150, required=False)
username = forms.CharField(label=_('Benutzername'), max_length=150)
email = forms.EmailField(label=_('E-Mail-Adresse'))
role_key = forms.ChoiceField(label=_('Rolle'))
password1 = forms.CharField(label=_('Passwort'), widget=forms.PasswordInput())
password2 = forms.CharField(label=_('Passwort bestätigen'), widget=forms.PasswordInput())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['role_key'].choices = [
(role_key, str(ROLE_LABELS[role_key]))
for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF)
]
def clean_username(self):
username = (self.cleaned_data.get('username') or '').strip()
user_model = get_user_model()
if user_model.objects.filter(username=username).exists():
raise forms.ValidationError(_('Dieser Benutzername ist bereits vergeben.'))
return username
def clean_email(self):
return (self.cleaned_data.get('email') or '').strip().lower()
def clean_role_key(self):
role_key = (self.cleaned_data.get('role_key') or '').strip()
if role_key not in ROLE_GROUP_NAMES:
raise forms.ValidationError(_('Ungültige Rolle.'))
return role_key
def clean(self):
cleaned = super().clean()
password1 = cleaned.get('password1')
password2 = cleaned.get('password2')
if password1 and password2 and password1 != password2:
self.add_error('password2', _('Die Passwörter stimmen nicht überein.'))
return cleaned
def save(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username=self.cleaned_data['username'],
email=self.cleaned_data['email'],
password=self.cleaned_data['password1'],
first_name=self.cleaned_data.get('first_name', ''),
last_name=self.cleaned_data.get('last_name', ''),
is_active=True,
)
assign_user_role(user, self.cleaned_data['role_key'])
return user
class OnboardingRequestForm(forms.ModelForm):
first_name = forms.CharField(label='Vorname', required=False)
last_name = forms.CharField(label='Nachname', required=False)

View File

@@ -1,6 +1,7 @@
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from workflows.roles import ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, ensure_role_groups
DEFAULT_USERS = [
{
@@ -43,6 +44,8 @@ class Command(BaseCommand):
is_staff=item['is_staff'],
is_superuser=item['is_superuser'],
)
ensure_role_groups()
assign_user_role(user, ROLE_SUPER_ADMIN if item['username'] == 'admin_test' else ROLE_STAFF)
self.stdout.write(f'created {user.username}')
self.stdout.write(self.style.SUCCESS('initial users created'))

127
backend/workflows/roles.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.utils.translation import gettext_lazy as _
ROLE_SUPER_ADMIN = 'super_admin'
ROLE_ADMIN = 'admin'
ROLE_IT_STAFF = 'it_staff'
ROLE_STAFF = 'staff'
ROLE_GROUP_NAMES = {
ROLE_SUPER_ADMIN: 'Super Admin',
ROLE_ADMIN: 'Admin',
ROLE_IT_STAFF: 'IT Staff',
ROLE_STAFF: 'Staff',
}
ROLE_LABELS = {
ROLE_SUPER_ADMIN: _('Super Admin'),
ROLE_ADMIN: _('Admin'),
ROLE_IT_STAFF: _('IT Staff'),
ROLE_STAFF: _('Mitarbeiter'),
}
CAPABILITIES = {
'manage_users': {ROLE_SUPER_ADMIN},
'access_requests_dashboard': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'run_intro_session': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'generate_intro_pdfs': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'retry_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
'delete_requests': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_integrations': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_welcome_emails': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_builders': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_audit_log': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'manage_backups': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'view_docs': {ROLE_SUPER_ADMIN, ROLE_ADMIN},
'access_django_admin_link': {ROLE_SUPER_ADMIN},
}
def ensure_role_groups() -> None:
for name in ROLE_GROUP_NAMES.values():
Group.objects.get_or_create(name=name)
def assign_user_role(user, role_key: str) -> None:
ensure_role_groups()
if role_key not in ROLE_GROUP_NAMES:
raise ValueError(f'Unknown role: {role_key}')
role_groups = Group.objects.filter(name__in=ROLE_GROUP_NAMES.values())
user.groups.remove(*role_groups)
user.groups.add(Group.objects.get(name=ROLE_GROUP_NAMES[role_key]))
is_super_admin = role_key == ROLE_SUPER_ADMIN
user.is_staff = is_super_admin
user.is_superuser = is_super_admin
user.save(update_fields=['is_staff', 'is_superuser'])
def ensure_bootstrap_role_assignments() -> None:
user_model = get_user_model()
bootstrap_roles = {
'admin_test': ROLE_SUPER_ADMIN,
'user_test': ROLE_STAFF,
}
role_group_names = set(ROLE_GROUP_NAMES.values())
for username, role_key in bootstrap_roles.items():
try:
user = user_model.objects.get(username=username)
except user_model.DoesNotExist:
continue
if user.groups.filter(name__in=role_group_names).exists():
continue
assign_user_role(user, role_key)
def get_user_role_key(user) -> str:
if not getattr(user, 'is_authenticated', False):
return ROLE_STAFF
if getattr(user, 'is_superuser', False):
return ROLE_SUPER_ADMIN
group_names = set(user.groups.values_list('name', flat=True))
for role_key in (ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF):
if ROLE_GROUP_NAMES[role_key] in group_names:
return role_key
if getattr(user, 'is_staff', False):
return ROLE_ADMIN
return ROLE_STAFF
def get_user_role_label(user) -> str:
return str(ROLE_LABELS[get_user_role_key(user)])
def user_has_capability(user, capability: str) -> bool:
if not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'is_superuser', False):
return True
allowed_roles = CAPABILITIES.get(capability, set())
return get_user_role_key(user) in allowed_roles
def template_role_context(user) -> dict[str, object]:
role_key = get_user_role_key(user)
return {
'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]),
'can_manage_users': user_has_capability(user, 'manage_users'),
'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'),
'can_run_intro_session': user_has_capability(user, 'run_intro_session'),
'can_generate_intro_pdfs': user_has_capability(user, 'generate_intro_pdfs'),
'can_retry_requests': user_has_capability(user, 'retry_requests'),
'can_delete_requests': user_has_capability(user, 'delete_requests'),
'can_manage_integrations': user_has_capability(user, 'manage_integrations'),
'can_manage_welcome_emails': user_has_capability(user, 'manage_welcome_emails'),
'can_manage_builders': user_has_capability(user, 'manage_builders'),
'can_view_audit_log': user_has_capability(user, 'view_audit_log'),
'can_manage_backups': user_has_capability(user, 'manage_backups'),
'can_view_docs': user_has_capability(user, 'view_docs'),
'can_access_django_admin_link': user_has_capability(user, 'access_django_admin_link'),
}

View File

@@ -0,0 +1,12 @@
from django.db.models.signals import post_migrate
from django.dispatch import receiver
from .roles import ensure_bootstrap_role_assignments, ensure_role_groups
@receiver(post_migrate)
def workflows_post_migrate(sender, **kwargs):
if getattr(sender, 'name', '') != 'workflows':
return
ensure_role_groups()
ensure_bootstrap_role_assignments()

View File

@@ -1,5 +1,6 @@
body { margin: 0; font-family: Arial, sans-serif; background: #f4f8ff; color: #0f172a; padding: 20px; }
[hidden] { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.shell { max-width: 1100px; margin: 0 auto; background: #fff; border: 1px solid #d8e3f0; border-radius: 14px; padding: 16px; }
h1 { margin: 12px 0 6px; color: #000078; }
.sub { margin: 0 0 12px; color: #54657c; }

View File

@@ -52,6 +52,7 @@
<li><code>/backend/config/</code>: Django settings, WSGI, URL config</li>
<li><code>/backend/workflows/</code>: application logic, views, models, tasks, templates, static assets</li>
<li><code>/backend/workflows/templates/workflows/base_shell.html</code>: standard page shell for new staff-facing pages</li>
<li><code>/backend/workflows/roles.py</code>: centralized role names, capability matrix, and template permission helpers</li>
<li>Rule: all interactive app pages should extend <code>base_shell.html</code>; do not rebuild topbar/frame logic in page-local templates.</li>
<li><code>/backend/media/templates/</code>: PDF HTML templates and letterhead source files</li>
<li><code>/backend/media/pdfs/</code>: generated PDF outputs on host volume</li>
@@ -99,6 +100,19 @@ docker compose exec -T web python manage.py check</code></pre>
<li>Fresh boot sequence runs migrations automatically in <code>entrypoint-web.sh</code>.</li>
</ul>
<h3>Role and Permission Model</h3>
<ul>
<li>Stable Django group names: <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, <code>Staff</code>.</li>
<li>Groups are created automatically through a <code>post_migrate</code> hook in <code>workflows.signals</code>.</li>
<li>Capability checks are centralized in <code>workflows.roles.CAPABILITIES</code>.</li>
<li>Use <code>_require_capability(...)</code> in views instead of flat <code>is_staff</code> checks.</li>
<li>Templates receive permission flags from <code>workflows.context_processors.role_context</code>.</li>
<li>Super-admin-only user management lives at <code>/admin-tools/users/</code> and is the preferred path for normal role assignment, account activation, password-reset mail dispatch, and controlled user deletion.</li>
<li>Backward-compatibility rule: authenticated legacy users with <code>is_staff=True</code> but no explicit role group currently fall back to the <code>Admin</code> capability set.</li>
<li><code>superuser</code> accounts resolve to <code>Super Admin</code>.</li>
<li>When adding a new operational page or action, define the capability in <code>roles.py</code>, gate the view, and hide the UI affordance when the capability is absent.</li>
</ul>
<h2 id="translations">6) Translation Workflow</h2>
<h3>Standard Django i18n path</h3>
<pre><code>make i18n-update-en

View File

@@ -35,7 +35,7 @@
<h1>{% trans "TUBCO Onboarding & Offboarding Portal" %}</h1>
<p>{% trans "Zentrale Arbeitsfläche für Anfragen, PDF-Generierung, E-Mail-Workflows und Ablage in Nextcloud." %}</p>
<div class="status-row">
<span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {% if request.user.is_staff %}{% trans "Admin" %}{% else %}{% trans "Mitarbeiter" %}{% endif %}</span>
<span class="status-pill status-pill-neutral">{% trans "Rolle:" %} {{ role_label }}</span>
<span class="status-pill {% if nextcloud_enabled %}ok{% else %}warn{% endif %}">
{% trans "Nextcloud:" %} {% if nextcloud_enabled %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}
</span>
@@ -88,6 +88,7 @@
</div>
</section>
{% if can_access_requests_dashboard %}
<section class="app-card">
<div>
<div class="top-line"><div class="accent">APP</div></div>
@@ -103,34 +104,51 @@
<a class="btn btn-secondary" href="/requests/">{% trans "Dashboard öffnen" %}</a>
</div>
</section>
{% endif %}
</div>
{% if request.user.is_staff %}
{% if can_manage_users or can_manage_integrations or can_view_audit_log or can_manage_backups or can_manage_welcome_emails or can_manage_builders or can_view_docs or can_access_django_admin_link %}
<div class="section-head">
<h2>{% trans "Admin Apps" %}</h2>
<p>{% trans "Konfiguration, Tests und Steuerung." %}</p>
</div>
<div class="admin-grid">
{% if can_manage_integrations %}
<section class="admin-card">
<h3>{% trans "Integrationen" %}</h3>
<p>{% trans "Nextcloud- und E-Mail-Setup." %}</p>
<a class="btn btn-secondary" href="/admin-tools/integrations/?kind=nextcloud">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_users %}
<section class="admin-card">
<h3>{% trans "Benutzer & Rollen" %}</h3>
<p>{% trans "Benutzer anlegen, Rollen zuweisen und Zugriffe steuern." %}</p>
<a class="btn btn-secondary" href="/admin-tools/users/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_view_audit_log %}
<section class="admin-card">
<h3>{% trans "Audit Log" %}</h3>
<p>{% trans "Wichtige Admin-Aktionen nachvollziehen und prüfen." %}</p>
<a class="btn btn-secondary" href="/admin-tools/audit-log/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_backups %}
<section class="admin-card">
<h3>{% trans "Backup & Recovery" %}</h3>
<p>{% trans "Backups erstellen und sicher verifizieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/backups/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_welcome_emails %}
<section class="admin-card">
<h3>{% trans "Welcome E-Mails" %}</h3>
<p>{% trans "Geplante Welcome Mails verwalten." %}</p>
<a class="btn btn-secondary" href="/admin-tools/welcome-emails/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_manage_builders %}
<section class="admin-card">
<h3>{% trans "Form Builder" %}</h3>
<p>{% trans "Felder, Schritte und Optionen verwalten." %}</p>
@@ -141,16 +159,21 @@
<p>{% trans "Checklistenpunkte für das Einweisungsprotokoll konfigurieren." %}</p>
<a class="btn btn-secondary" href="/admin-tools/intro-builder/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_view_docs %}
<section class="admin-card">
<h3>{% trans "Handbook" %}</h3>
<p>{% trans "Project wiki and developer documentation in one place." %}</p>
<a class="btn btn-secondary" href="/admin-tools/handbook/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
{% if can_access_django_admin_link %}
<section class="admin-card">
<h3>{% trans "Django Admin" %}</h3>
<p>{% trans "Vollständige Datenverwaltung." %}</p>
<a class="btn btn-secondary" href="/admin/">{% trans "Öffnen" %}</a>
</section>
{% endif %}
</div>
{% endif %}

View File

@@ -172,9 +172,13 @@
<h2 id="admin">9) Admin Apps (Home)</h2>
<ul>
<li><strong>Rollenmodell:</strong> the app now uses four named roles: <code>Super Admin</code>, <code>Admin</code>, <code>IT Staff</code>, and <code>Staff</code>.</li>
<li><strong>Zugriffslogik:</strong> page visibility and critical actions are controlled by capability checks, not only by <code>is_staff</code>.</li>
<li><strong>Fallback-Verhalten:</strong> legacy staff users without an explicit role group currently fall back to <code>Admin</code> access so existing operations do not break during rollout.</li>
<li><strong>Form Builder:</strong> manage field visibility/order/options.</li>
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li>
<li><strong>Benutzer &amp; Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending password-reset links, and deleting accounts when appropriate.</li>
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
<li><strong>Requests Dashboard:</strong> search records, open PDFs, delete records (single/bulk for staff).</li>
@@ -223,6 +227,7 @@
<h2 id="hardening">11) Security & Reliability Hardening (Current)</h2>
<ul>
<li><strong>Rollen &amp; Berechtigungen:</strong> operational areas such as Integrationen, Builders, Audit Log, Backup &amp; Recovery, request delete/retry, and intro-session actions are now protected by capability-based role checks.</li>
<li><strong>Cookie + header hardening:</strong> HTTPOnly cookies, SameSite cookies, <code>X-Content-Type-Options: nosniff</code>, stricter referrer policy, and frame protection.</li>
<li><strong>Optional secure-cookie mode:</strong> can be enabled via environment for HTTPS deployments.</li>
<li><strong>Upload guards:</strong> server-side upload size limits plus signature image magic-byte validation for PNG/JPEG.</li>

View File

@@ -167,7 +167,7 @@
</div>
</form>
</div>
{% if request.user.is_staff %}
{% if can_delete_requests %}
<div class="control-stack">
<form method="post" action="/requests/" id="bulk-delete-form" data-confirm="{% trans 'Ausgewählte Einträge wirklich löschen?' %}">
{% csrf_token %}
@@ -184,19 +184,19 @@
<table>
<thead>
<tr>
{% if request.user.is_staff %}<th class="select-col"><input type="checkbox" id="select-all" aria-label="Alle auswählen" /></th>{% endif %}
{% if can_delete_requests %}<th class="select-col"><input type="checkbox" id="select-all" aria-label="Alle auswählen" /></th>{% endif %}
<th>{% trans "Typ" %}</th>
<th>{% trans "Person" %}</th>
<th>{% trans "E-Mail" %}</th>
<th>{% trans "Dokument" %}</th>
{% if request.user.is_staff %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if request.user.is_staff %}<th>{% trans "Aktion" %}</th>{% endif %}
{% if can_run_intro_session or can_generate_intro_pdfs %}<th>{% trans "Einweisung" %}</th>{% endif %}
{% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}<th>{% trans "Aktion" %}</th>{% endif %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
{% if request.user.is_staff %}
{% if can_delete_requests %}
<td class="select-col">
<input type="checkbox" class="row-select" name="selected_requests" value="{{ row.kind_slug }}:{{ row.id }}" aria-label="{{ row.kind }} {{ row.name }}" form="bulk-delete-form" />
</td>
@@ -225,7 +225,7 @@
{% endif %}
{% endif %}
</td>
{% if request.user.is_staff %}
{% if can_run_intro_session or can_generate_intro_pdfs %}
<td class="actions-cell intro-panel">
{% if row.kind_slug == 'onboarding' %}
<details>
@@ -234,12 +234,15 @@
<div class="intro-group">
<div class="intro-group-title">{% trans "Live-Protokoll" %}</div>
<div class="intro-actions">
{% if can_run_intro_session %}
<a class="btn btn-secondary" href="/requests/onboarding/{{ row.id }}/intro-session/">{% trans "Einweisung öffnen" %}</a>
{% if row.intro_session and row.intro_session.exported_pdf_url %}
{% endif %}
{% if can_run_intro_session and row.intro_session and row.intro_session.exported_pdf_url %}
<a class="btn btn-secondary" href="{{ row.intro_session.exported_pdf_url }}" target="_blank" rel="noopener">{% trans "Live-Protokoll öffnen" %}</a>
{% endif %}
</div>
</div>
{% if can_generate_intro_pdfs %}
<div class="intro-group">
<div class="intro-group-title">{% trans "Standard-Einweisungs-PDF" %}</div>
<div class="intro-actions">
@@ -257,6 +260,7 @@
{% endif %}
</div>
</div>
{% endif %}
</div>
{% if row.intro_session %}
<div class="intro-meta">{% trans "Status:" %} {{ row.intro_session.get_status_display }}</div>
@@ -266,24 +270,28 @@
<span class="person-meta">{% trans "Nicht relevant" %}</span>
{% endif %}
</td>
{% endif %}
{% if can_retry_requests or can_delete_requests or can_access_requests_dashboard %}
<td class="actions-cell">
<a class="btn btn-secondary" href="/requests/timeline/{{ row.kind_slug }}/{{ row.id }}/">{% trans "Timeline" %}</a>
{% if row.status_key == 'failed' %}
{% if can_retry_requests and row.status_key == 'failed' %}
<form method="post" action="/requests/retry/{{ row.kind_slug }}/{{ row.id }}/" class="inline-delete" data-confirm="{% trans 'Eintrag erneut verarbeiten?' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Erneut versuchen" %}</button>
</form>
{% endif %}
{% if can_delete_requests %}
<form method="post" action="/requests/" class="inline-delete" data-confirm="{% trans 'Eintrag wirklich löschen?' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit" name="single_delete" value="{{ row.kind_slug }}:{{ row.id }}">{% trans "Löschen" %}</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% empty %}
<tr>
<td colspan="{% if request.user.is_staff %}8{% else %}5{% endif %}" class="empty-state">{% trans "Noch keine Vorgänge vorhanden." %}</td>
<td colspan="{{ column_count }}" class="empty-state">{% trans "Noch keine Vorgänge vorhanden." %}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -0,0 +1,151 @@
{% extends 'workflows/base_shell.html' %}
{% load static i18n %}
{% block title %}{% trans "Benutzer & Rollen" %}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
{% endblock %}
{% block shell_body %}
{% include 'workflows/includes/app_header.html' with header_show_lang=1 header_show_home=1 header_inside_shell=1 %}
<div class="toolbar">
<div>
<h1>{% trans "Benutzer & Rollen" %}</h1>
<p class="sub">{% trans "Super Admins verwalten Benutzerkonten, Rollen und den aktiven Zugriff." %}</p>
</div>
</div>
{% include 'workflows/includes/messages.html' %}
<section class="card">
<h2>{% trans "Benutzer anlegen" %}</h2>
<form method="post" action="{% url 'create_user_from_admin' %}">
{% csrf_token %}
<div class="grid">
<div>
<label for="{{ create_form.first_name.id_for_label }}">{{ create_form.first_name.label }}</label>
{{ create_form.first_name }}
{% for error in create_form.first_name.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.last_name.id_for_label }}">{{ create_form.last_name.label }}</label>
{{ create_form.last_name }}
{% for error in create_form.last_name.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.username.id_for_label }}">{{ create_form.username.label }}</label>
{{ create_form.username }}
{% for error in create_form.username.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.email.id_for_label }}">{{ create_form.email.label }}</label>
{{ create_form.email }}
{% for error in create_form.email.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.role_key.id_for_label }}">{{ create_form.role_key.label }}</label>
{{ create_form.role_key }}
{% for error in create_form.role_key.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.password1.id_for_label }}">{{ create_form.password1.label }}</label>
{{ create_form.password1 }}
{% for error in create_form.password1.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
<div>
<label for="{{ create_form.password2.id_for_label }}">{{ create_form.password2.label }}</label>
{{ create_form.password2 }}
{% for error in create_form.password2.errors %}<div class="hint">{{ error }}</div>{% endfor %}
</div>
</div>
{% for error in create_form.non_field_errors %}<div class="hint">{{ error }}</div>{% endfor %}
<div class="actions">
<button class="btn btn-primary" type="submit">{% trans "Benutzer erstellen" %}</button>
</div>
</form>
</section>
<section class="card">
<div class="toolbar">
<div>
<h2>{% trans "Benutzerübersicht" %}</h2>
<p class="sub">{% trans "Rollen ändern, Zugriffe sperren oder ein neues Passwort setzen." %}</p>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Benutzername" %}</th>
<th>{% trans "E-Mail" %}</th>
<th>{% trans "Rolle" %}</th>
<th>{% trans "Aktiv" %}</th>
<th>{% trans "Letzte Anmeldung" %}</th>
<th>{% trans "Neues Passwort" %}</th>
<th>{% trans "Aktionen" %}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
<strong>{{ row.display_name|default:row.user.username }}</strong>
{% if row.user == request.user %}
<div class="mini">{% trans "Sie selbst" %}</div>
{% endif %}
</td>
<td>{{ row.user.username }}</td>
<td>{{ row.user.email|default:"-" }}</td>
<td>
<label class="sr-only" for="role_key_{{ row.user.id }}">{% trans "Rolle" %}</label>
<select id="role_key_{{ row.user.id }}" name="role_key" form="user-form-{{ row.user.id }}">
{% for role_key, role_label in role_choices %}
<option value="{{ role_key }}"{% if role_key == row.role_key %} selected{% endif %}>{{ role_label }}</option>
{% endfor %}
</select>
</td>
<td>
<label class="check-row" for="is_active_{{ row.user.id }}">
<input id="is_active_{{ row.user.id }}" type="checkbox" name="is_active" form="user-form-{{ row.user.id }}"{% if row.user.is_active %} checked{% endif %} />
<span>{% if row.user.is_active %}{% trans "aktiv" %}{% else %}{% trans "inaktiv" %}{% endif %}</span>
</label>
</td>
<td>{{ row.user.last_login|date:"d.m.Y H:i"|default:"-" }}</td>
<td>
<label class="sr-only" for="new_password_{{ row.user.id }}">{% trans "Neues Passwort" %}</label>
<input id="new_password_{{ row.user.id }}" type="password" name="new_password" form="user-form-{{ row.user.id }}" placeholder="{% trans 'Optional' %}" />
</td>
<td class="actions">
<form id="user-form-{{ row.user.id }}" method="post" action="{% url 'update_user_from_admin' row.user.id %}">
{% csrf_token %}
</form>
<button class="btn btn-secondary" type="submit" form="user-form-{{ row.user.id }}">{% trans "Speichern" %}</button>
{% if row.user.email %}
<form method="post" action="{% url 'send_password_reset_from_admin' row.user.id %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Reset-Link senden" %}</button>
</form>
{% else %}
<span class="mini">{% trans "Keine E-Mail" %}</span>
{% endif %}
{% if row.user != request.user %}
<form method="post" action="{% url 'delete_user_from_admin' row.user.id %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit" data-confirm="1" data-confirm-message="{% trans 'Benutzer wirklich löschen?' %}">{% trans "Löschen" %}</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="8">{% trans "Es sind noch keine Benutzer vorhanden." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">{% trans "Hinweis: Der aktuell angemeldete Super Admin kann sich hier nicht selbst deaktivieren oder auf eine niedrigere Rolle setzen." %}</p>
</section>
{% endblock %}

View File

@@ -30,6 +30,11 @@ urlpatterns = [
path('admin-tools/welcome-emails/<int:schedule_id>/resume/', views.resume_welcome_email, name='resume_welcome_email'),
path('admin-tools/welcome-emails/<int:schedule_id>/cancel/', views.cancel_welcome_email, name='cancel_welcome_email'),
path('admin-tools/handbook/', views.handbook_page, name='handbook_page'),
path('admin-tools/users/', views.user_management_page, name='user_management_page'),
path('admin-tools/users/create/', views.create_user_from_admin, name='create_user_from_admin'),
path('admin-tools/users/<int:user_id>/update/', views.update_user_from_admin, name='update_user_from_admin'),
path('admin-tools/users/<int:user_id>/send-password-reset/', views.send_password_reset_from_admin, name='send_password_reset_from_admin'),
path('admin-tools/users/<int:user_id>/delete/', views.delete_user_from_admin, name='delete_user_from_admin'),
path('admin-tools/wiki/', views.project_wiki_page, name='project_wiki_page'),
path('admin-tools/developer-handbook/', views.developer_handbook_page, name='developer_handbook_page'),
path('admin-tools/release-checklist/', views.release_checklist_page, name='release_checklist_page'),

View File

@@ -2,6 +2,7 @@ from pathlib import Path
from datetime import timedelta
from tempfile import NamedTemporaryFile
import json
from functools import wraps
from celery import current_app
from django.conf import settings
@@ -10,16 +11,21 @@ from django.db import IntegrityError
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.contrib import messages
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
from django.contrib.auth.tokens import default_token_generator
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import ensure_csrf_cookie
from django.utils import timezone
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import get_language, override
from django.urls import reverse
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
from .forms import OffboardingRequestForm, OnboardingRequestForm
from .forms import OffboardingRequestForm, OnboardingRequestForm, UserManagementCreateForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES,
@@ -30,6 +36,7 @@ from .form_builder import (
)
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
from .emailing import send_system_email
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_SUPER_ADMIN, assign_user_role, get_user_role_key, get_user_role_label, user_has_capability
from .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
from .tasks import (
DEFAULT_NOTIFICATION_TEMPLATES,
@@ -112,8 +119,19 @@ def healthz(request):
)
def _is_staff(user) -> bool:
return user.is_authenticated and user.is_staff
def _require_capability(capability: str):
def decorator(view_func):
@wraps(view_func)
@login_required
def wrapped(request, *args, **kwargs):
if not user_has_capability(request.user, capability):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('home')
return view_func(request, *args, **kwargs)
return wrapped
return decorator
def _display_user_name(user) -> str:
@@ -218,6 +236,10 @@ def _audit_action_label(action: str) -> str:
'mail_settings_saved': _('Mail-Einstellungen gespeichert'),
'email_routing_saved': _('E-Mail-Routing gespeichert'),
'notification_rules_saved': _('Benachrichtigungsregeln gespeichert'),
'user_created': _('Benutzer erstellt'),
'user_updated': _('Benutzer aktualisiert'),
'user_password_reset_sent': _('Passwort-Reset-Link versendet'),
'user_deleted': _('Benutzer gelöscht'),
'backup_created': _('Backup erstellt'),
'backup_verified': _('Backup verifiziert'),
'backup_deleted': _('Backup gelöscht'),
@@ -311,36 +333,220 @@ def home(request):
'nextcloud_enabled': is_nextcloud_enabled(),
'email_test_mode': is_email_test_mode(),
'workflow_config': config,
'role_label': get_user_role_label(request.user),
},
)
@login_required
@user_passes_test(_is_staff)
def _user_management_rows():
user_model = get_user_model()
role_order = {
ROLE_SUPER_ADMIN: 0,
'admin': 1,
'it_staff': 2,
'staff': 3,
}
rows = []
for user in user_model.objects.all().order_by('-is_active', 'username'):
role_key = get_user_role_key(user)
rows.append(
{
'user': user,
'role_key': role_key,
'role_label': str(ROLE_LABELS[role_key]),
'role_sort': role_order.get(role_key, 99),
'display_name': _display_user_name(user),
}
)
rows.sort(key=lambda item: (not item['user'].is_active, item['role_sort'], item['user'].username.lower()))
return rows
def _render_user_management(request, create_form=None, status_code: int = 200):
return render(
request,
'workflows/user_management.html',
{
'create_form': create_form or UserManagementCreateForm(),
'rows': _user_management_rows(),
'role_choices': [(key, str(ROLE_LABELS[key])) for key in ROLE_GROUP_NAMES],
},
status=status_code,
)
def _super_admin_user_count() -> int:
user_model = get_user_model()
return sum(1 for user in user_model.objects.all() if get_user_role_key(user) == ROLE_SUPER_ADMIN and user.is_active)
def _would_remove_last_super_admin(user, new_role_key: str | None = None, new_is_active: bool | None = None, deleting: bool = False) -> bool:
if get_user_role_key(user) != ROLE_SUPER_ADMIN or not user.is_active:
return False
if _super_admin_user_count() > 1:
return False
if deleting:
return True
if new_role_key is not None and new_role_key != ROLE_SUPER_ADMIN:
return True
if new_is_active is not None and not new_is_active:
return True
return False
@_require_capability('manage_users')
def user_management_page(request):
return _render_user_management(request)
@_require_capability('manage_users')
@require_POST
def create_user_from_admin(request):
form = UserManagementCreateForm(request.POST)
if not form.is_valid():
messages.error(request, _('Benutzer konnte nicht erstellt werden. Bitte prüfen Sie die Eingaben.'))
return _render_user_management(request, create_form=form, status_code=400)
user = form.save()
_audit(
request,
'user_created',
target_type='user',
target_id=user.id,
target_label=_display_user_name(user),
details={'username': user.username, 'role': get_user_role_key(user)},
)
messages.success(request, _('Benutzer wurde erstellt: %(username)s') % {'username': user.username})
return redirect('user_management_page')
@_require_capability('manage_users')
@require_POST
def update_user_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
role_key = (request.POST.get('role_key') or '').strip()
is_active = request.POST.get('is_active') == 'on'
new_password = (request.POST.get('new_password') or '').strip()
if role_key not in ROLE_GROUP_NAMES:
messages.error(request, _('Ungültige Rolle.'))
return redirect('user_management_page')
if target_user == request.user and (role_key != ROLE_SUPER_ADMIN or not is_active):
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst sperren oder herabstufen.'))
return redirect('user_management_page')
if _would_remove_last_super_admin(target_user, new_role_key=role_key, new_is_active=is_active):
messages.error(request, _('Der letzte aktive Super Admin kann nicht deaktiviert oder herabgestuft werden.'))
return redirect('user_management_page')
assign_user_role(target_user, role_key)
target_user.is_active = is_active
if new_password:
target_user.set_password(new_password)
target_user.save()
_audit(
request,
'user_updated',
target_type='user',
target_id=target_user.id,
target_label=_display_user_name(target_user),
details={'username': target_user.username, 'role': role_key, 'is_active': is_active, 'password_changed': bool(new_password)},
)
messages.success(request, _('Benutzer wurde aktualisiert: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')
@_require_capability('manage_users')
@require_POST
def send_password_reset_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
email = (target_user.email or '').strip()
if not email:
messages.error(request, _('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.'))
return redirect('user_management_page')
uid = urlsafe_base64_encode(force_bytes(target_user.pk))
token = default_token_generator.make_token(target_user)
reset_path = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token})
reset_url = request.build_absolute_uri(reset_path)
send_system_email(
subject=_('Passwort zurücksetzen für %(username)s') % {'username': target_user.username},
body=_(
'Hallo %(name)s,\n\n'
'für Ihr Konto wurde ein Link zum Zurücksetzen des Passworts erstellt.\n'
'Bitte öffnen Sie den folgenden Link:\n'
'%(url)s\n\n'
'Wenn Sie diese Anfrage nicht erwartet haben, können Sie diese E-Mail ignorieren.'
) % {
'name': _display_user_name(target_user),
'url': reset_url,
},
to=[email],
)
_audit(
request,
'user_password_reset_sent',
target_type='user',
target_id=target_user.id,
target_label=_display_user_name(target_user),
details={'username': target_user.username, 'email': email},
)
messages.success(request, _('Passwort-Reset-Link wurde versendet: %(username)s') % {'username': target_user.username})
return redirect('user_management_page')
@_require_capability('manage_users')
@require_POST
def delete_user_from_admin(request, user_id: int):
user_model = get_user_model()
target_user = get_object_or_404(user_model, id=user_id)
if target_user == request.user:
messages.error(request, _('Der aktuell angemeldete Super Admin kann sich hier nicht selbst löschen.'))
return redirect('user_management_page')
if _would_remove_last_super_admin(target_user, deleting=True):
messages.error(request, _('Der letzte aktive Super Admin kann nicht gelöscht werden.'))
return redirect('user_management_page')
target_label = _display_user_name(target_user)
username = target_user.username
target_user.delete()
_audit(
request,
'user_deleted',
target_type='user',
target_label=target_label,
details={'username': username},
)
messages.success(request, _('Benutzer wurde gelöscht: %(username)s') % {'username': username})
return redirect('user_management_page')
@_require_capability('view_docs')
def handbook_page(request):
return render(request, 'workflows/handbook.html')
@login_required
@user_passes_test(_is_staff)
@_require_capability('view_docs')
def project_wiki_page(request):
return render(request, 'workflows/project_wiki.html')
@login_required
@user_passes_test(_is_staff)
@_require_capability('view_docs')
def developer_handbook_page(request):
return render(request, 'workflows/developer_handbook.html')
@login_required
@user_passes_test(_is_staff)
@_require_capability('view_docs')
def release_checklist_page(request):
return render(request, 'workflows/release_checklist.html')
@login_required
@user_passes_test(_is_staff)
@_require_capability('view_audit_log')
def audit_log_page(request):
action = (request.GET.get('action') or '').strip()
user_query = (request.GET.get('user') or '').strip()
@@ -380,8 +586,7 @@ def audit_log_page(request):
)
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_backups')
def backup_recovery_page(request):
return render(
request,
@@ -392,8 +597,7 @@ def backup_recovery_page(request):
)
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_backups')
@require_POST
def create_backup_from_admin(request):
try:
@@ -411,8 +615,7 @@ def create_backup_from_admin(request):
return redirect('backup_recovery_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_backups')
@require_POST
def verify_backup_from_admin(request, backup_name: str):
try:
@@ -430,8 +633,7 @@ def verify_backup_from_admin(request, backup_name: str):
return redirect('backup_recovery_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_backups')
@require_POST
def delete_backup_from_admin(request, backup_name: str):
try:
@@ -449,8 +651,7 @@ def delete_backup_from_admin(request, backup_name: str):
return redirect('backup_recovery_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('access_requests_dashboard')
def request_timeline_page(request, kind: str, request_id: int):
if kind == 'onboarding':
obj = get_object_or_404(OnboardingRequest, id=request_id)
@@ -569,8 +770,12 @@ def request_timeline_page(request, kind: str, request_id: int):
@login_required
def requests_dashboard(request):
if not user_has_capability(request.user, 'access_requests_dashboard'):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('home')
if request.method == 'POST':
if not request.user.is_staff:
if not user_has_capability(request.user, 'delete_requests'):
messages.error(request, _('Sie haben keine Berechtigung für diese Aktion.'))
return redirect('requests_dashboard')
@@ -765,6 +970,13 @@ def requests_dashboard(request):
{'value': 'failed', 'label': _request_status_label('failed', language_code)},
]
has_filters = any([search_query, type_filter, status_filter, department_filter, date_from, date_to])
column_count = 4
if user_has_capability(request.user, 'delete_requests'):
column_count += 1
if user_has_capability(request.user, 'run_intro_session') or user_has_capability(request.user, 'generate_intro_pdfs'):
column_count += 1
if user_has_capability(request.user, 'access_requests_dashboard'):
column_count += 1
return render(
request,
'workflows/requests_dashboard.html',
@@ -779,6 +991,7 @@ def requests_dashboard(request):
'departments': departments,
'status_choices': status_choices,
'has_filters': has_filters,
'column_count': column_count,
'onboarding_total': onboarding_total,
'offboarding_total': offboarding_total,
'combined_total': onboarding_total + offboarding_total,
@@ -838,8 +1051,7 @@ def onboarding_success(request, request_id: int):
return render(request, 'workflows/onboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
@login_required
@user_passes_test(_is_staff)
@_require_capability('generate_intro_pdfs')
@require_POST
def generate_onboarding_intro_pdf(request, request_id: int):
obj = get_object_or_404(OnboardingRequest, id=request_id)
@@ -851,8 +1063,7 @@ def generate_onboarding_intro_pdf(request, request_id: int):
return redirect('requests_dashboard')
@login_required
@user_passes_test(_is_staff)
@_require_capability('generate_intro_pdfs')
@require_POST
def generate_onboarding_intro_session_pdf(request, request_id: int):
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
@@ -869,8 +1080,7 @@ def generate_onboarding_intro_session_pdf(request, request_id: int):
return redirect('onboarding_intro_session_page', request_id=request_id)
@login_required
@user_passes_test(_is_staff)
@_require_capability('run_intro_session')
def onboarding_intro_session_page(request, request_id: int):
onboarding = get_object_or_404(OnboardingRequest, id=request_id)
session, _created = OnboardingIntroductionSession.objects.get_or_create(onboarding_request=onboarding)
@@ -1024,8 +1234,7 @@ def offboarding_success(request, request_id: int):
return render(request, 'workflows/offboarding_success.html', {'obj': obj, 'pdf_url': pdf_url})
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_builders')
def form_builder_page(request):
language_code = get_language()
form_type = request.GET.get('form_type', 'onboarding')
@@ -1200,8 +1409,7 @@ def form_builder_page(request):
)
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_builders')
def intro_builder_page(request):
if request.method == 'POST':
delete_id = (request.POST.get('delete_item_id') or '').strip()
@@ -1311,8 +1519,7 @@ def intro_builder_page(request):
)
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
def integrations_setup_page(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
kind = (request.GET.get('kind') or 'nextcloud').strip().lower()
@@ -1342,8 +1549,7 @@ def integrations_setup_page(request):
)
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
def welcome_emails_page(request):
rows = ScheduledWelcomeEmail.objects.select_related('onboarding_request').order_by('-send_at', '-id')[:200]
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1373,8 +1579,7 @@ def welcome_emails_page(request):
)
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
@require_POST
def trigger_welcome_email_now(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1395,8 +1600,7 @@ def trigger_welcome_email_now(request, schedule_id: int):
return redirect('welcome_emails_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
@require_POST
def save_welcome_email_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1498,8 +1702,7 @@ def _parse_selected_schedule_ids(raw: str) -> list[int]:
return parsed
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
@require_POST
def bulk_welcome_email_action(request):
action = (request.POST.get('bulk_action') or '').strip().lower()
@@ -1569,8 +1772,7 @@ def bulk_welcome_email_action(request):
return redirect('welcome_emails_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
@require_POST
def pause_welcome_email(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1589,8 +1791,7 @@ def pause_welcome_email(request, schedule_id: int):
return redirect('welcome_emails_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
@require_POST
def resume_welcome_email(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1612,8 +1813,7 @@ def resume_welcome_email(request, schedule_id: int):
return redirect('welcome_emails_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_welcome_emails')
@require_POST
def cancel_welcome_email(request, schedule_id: int):
scheduled = ScheduledWelcomeEmail.objects.filter(id=schedule_id).first()
@@ -1633,8 +1833,7 @@ def cancel_welcome_email(request, schedule_id: int):
return redirect('welcome_emails_page')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_builders')
@require_POST
def form_builder_save_order(request):
try:
@@ -1698,8 +1897,7 @@ def form_builder_save_order(request):
return JsonResponse({'ok': True, 'saved_count': len(configs)})
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def send_test_email(request):
mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF'
@@ -1718,8 +1916,7 @@ def send_test_email(request):
return _redirect_back(request, 'home')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def nextcloud_test_upload(request):
filename = f"nextcloud_test_{timezone.now().strftime('%Y%m%d_%H%M%S')}.txt"
@@ -1751,8 +1948,7 @@ def nextcloud_test_upload(request):
return _redirect_back(request, 'home')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def toggle_nextcloud_enabled(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1766,8 +1962,7 @@ def toggle_nextcloud_enabled(request):
return _redirect_back(request, 'home')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def toggle_email_mode(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1781,8 +1976,7 @@ def toggle_email_mode(request):
return _redirect_back(request, 'home')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_integrations_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1820,8 +2014,7 @@ def save_integrations_settings(request):
return redirect('home')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_nextcloud_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1846,8 +2039,7 @@ def save_nextcloud_settings(request):
return redirect('/admin-tools/integrations/?kind=nextcloud')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_workflow_rules(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1877,8 +2069,7 @@ def save_workflow_rules(request):
return redirect('/admin-tools/integrations/?kind=rules')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_backup_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1930,8 +2121,7 @@ def save_backup_settings(request):
return redirect('/admin-tools/integrations/?kind=backup')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_mail_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -1971,8 +2161,7 @@ def save_mail_settings(request):
return redirect('/admin-tools/integrations/?kind=mail')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_email_routing_settings(request):
config, _ = WorkflowConfig.objects.get_or_create(name='Default')
@@ -2039,8 +2228,7 @@ def save_email_routing_settings(request):
return redirect('/admin-tools/integrations/?kind=emails')
@login_required
@user_passes_test(_is_staff)
@_require_capability('manage_integrations')
@require_POST
def save_notification_rules(request):
rule_ids = request.POST.getlist('rule_ids')
@@ -2103,8 +2291,7 @@ def save_notification_rules(request):
return redirect('/admin-tools/integrations/?kind=emails')
@login_required
@user_passes_test(_is_staff)
@_require_capability('delete_requests')
@require_POST
def delete_request_from_dashboard(request, kind: str, request_id: int):
if kind == 'onboarding':
@@ -2122,8 +2309,7 @@ def delete_request_from_dashboard(request, kind: str, request_id: int):
return redirect('requests_dashboard')
@login_required
@user_passes_test(_is_staff)
@_require_capability('retry_requests')
@require_POST
def retry_request_from_dashboard(request, kind: str, request_id: int):
if kind == 'onboarding':