snapshot: preserve role-aware notification preferences and operational alerts

This commit is contained in:
Md Bayazid Bostame
2026-03-27 11:26:57 +01:00
parent fe3a8933fd
commit aa54f41731
25 changed files with 2958 additions and 633 deletions

View File

@@ -79,7 +79,7 @@ Snapshot commit:
### Phase 1. Product Core Standardization
Status: next
Status: completed
Purpose:
@@ -117,8 +117,18 @@ Deliverables:
- branding flow
- PDF/letterhead override behavior
Delivered:
- generic branding model and management UI
- shared branding context across shell/auth/pages
- configurable favicon, logo, sender display, footer/legal text, and PDF letterhead
- company-domain-driven email defaults and validation
- platform vs company admin separation for product-level controls
### Phase 2. App Registry and Navigation
Status: completed
Purpose:
- stop hardcoding app cards and app visibility in the homepage template
@@ -129,9 +139,13 @@ Deliverables:
- title / subtitle / icon / route / required capability / enabled flag
- homepage and navigation driven by registry data
- ability to enable/disable apps per deployment
- role-based app visibility and section grouping
- drag-and-drop ordering with filter-safe behavior
### Phase 3. Trial Mode Lifecycle
Status: completed
Purpose:
- allow limited-time test environments for demos and sales
@@ -145,9 +159,18 @@ Deliverables:
- cleanup command / scheduled deletion
- DB/media cleanup policy
Delivered:
- platform-only trial management UI
- shared trial banner and expiry enforcement
- integration restriction during trial mode
- cleanup/verification management commands
### Phase 4. New Business Apps
Only start after phases 1-3 are stable.
Status: next
Only start after phases 1-3 are stable and the workflow regression suite is green.
Candidate apps:
@@ -191,22 +214,21 @@ These should move into configuration progressively, not all at once in one risky
## Immediate Next Slice
Implement first:
Implement next:
1. `PortalBranding` model
2. branding management page
3. shared branding context processor
4. replace header/logo/title references on:
- home
- shared header
- login/auth pages
5. make PDF letterhead configurable
1. restore and keep green the onboarding/offboarding regression suite
2. extend dynamic onboarding configuration:
- field visibility
- section visibility
- guarded required/optional controls
3. remove remaining hardcoded customer/product leakage from docs, fixtures, and fallback assets
4. continue security and observability hardening before the next business app
This is the first productization slice because it gives:
This is the next productization slice because it gives:
- generic portal identity
- customer-specific configurability
- a cleaner base for every future app
- reliable core workflow behavior
- safer deployment-neutral product defaults
- a configurable onboarding experience for future customers
## Guardrails

View File

@@ -4,16 +4,18 @@ from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from workflows.forms import AppAuthenticationForm, AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm
from workflows import views as workflow_views
urlpatterns = [
path('admin/', admin.site.urls),
path('i18n/', include('django.conf.urls.i18n')),
path(
'accounts/login/',
auth_views.LoginView.as_view(template_name='workflows/auth/login.html', authentication_form=AppAuthenticationForm),
workflow_views.login_page,
name='login',
),
path('accounts/login/totp/', workflow_views.login_totp_page, name='login_totp'),
path(
'accounts/logout/',
auth_views.LogoutView.as_view(),

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
from .branding import get_branding_context, get_trial_context
from .models import UserNotification
from .roles import template_role_context
@@ -6,4 +7,15 @@ def role_context(request):
context = template_role_context(getattr(request, 'user', None))
context.update(get_branding_context())
context.update(get_trial_context())
user = getattr(request, 'user', None)
if getattr(user, 'is_authenticated', False):
notifications = list(UserNotification.objects.filter(user=user).order_by('-created_at')[:8])
context.update(
{
'header_notifications': notifications,
'header_unread_notification_count': UserNotification.objects.filter(user=user, read_at__isnull=True).count(),
}
)
else:
context.update({'header_notifications': [], 'header_unread_notification_count': 0})
return context

View File

@@ -1,7 +1,7 @@
from django import forms
from pathlib import Path
from datetime import timedelta
from django.contrib.auth import get_user_model, password_validation
from django.contrib.auth import authenticate, get_user_model, password_validation
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, PasswordResetForm, SetPasswordForm
from django.core.exceptions import ValidationError
from django.utils import timezone
@@ -10,7 +10,7 @@ from django.utils.translation import get_language, gettext as _, gettext_lazy
from .branding import get_company_email_domain
from .form_builder import apply_form_field_config
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, UserProfile, WorkflowConfig
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role
from .roles import ROLE_ADMIN, ROLE_GROUP_NAMES, ROLE_IT_STAFF, ROLE_LABELS, ROLE_PLATFORM_OWNER, ROLE_STAFF, ROLE_SUPER_ADMIN, assign_user_role, user_has_capability
from .totp import normalize_recovery_code, normalize_totp_token, verify_totp_token
@@ -102,9 +102,41 @@ 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 AppAuthenticationForm(AuthenticationForm):
class AppLoginForm(forms.Form):
username = forms.CharField(label=gettext_lazy('Benutzername'))
password = forms.CharField(label=gettext_lazy('Passwort'), strip=False, widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}))
password = forms.CharField(
label=gettext_lazy('Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
error_messages = {
'invalid_login': gettext_lazy('Benutzername oder Passwort sind nicht korrekt.'),
'inactive': gettext_lazy('Dieses Konto ist deaktiviert.'),
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
username = cleaned_data.get('username')
password = cleaned_data.get('password')
if username and password:
self.user_cache = authenticate(self.request, username=username, password=password)
if self.user_cache is None:
raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
if not self.user_cache.is_active:
raise ValidationError(self.error_messages['inactive'], code='inactive')
return cleaned_data
def get_user(self):
return self.user_cache
class AppTOTPChallengeForm(forms.Form):
otp_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
required=False,
@@ -119,37 +151,30 @@ class AppAuthenticationForm(AuthenticationForm):
)
error_messages = {
**AuthenticationForm.error_messages,
'invalid_otp': gettext_lazy('Der TOTP-Code ist ungültig.'),
'missing_otp': gettext_lazy('Bitte geben Sie Ihren TOTP-Code ein.'),
}
def __init__(self, *args, profile=None, **kwargs):
self.profile = profile
super().__init__(*args, **kwargs)
def clean(self):
cleaned_data = super().clean()
user = self.get_user()
if not user:
profile = self.profile
if not profile or not profile.totp_enabled:
return cleaned_data
profile, _ = UserProfile.objects.get_or_create(user=user)
if profile.totp_enabled:
otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if recovery_code:
if not profile.consume_recovery_code(recovery_code):
raise ValidationError(
self.error_messages['invalid_otp'],
code='invalid_otp',
)
return cleaned_data
if not otp_code:
raise ValidationError(
self.error_messages['missing_otp'],
code='missing_otp',
)
if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())):
raise ValidationError(
self.error_messages['invalid_otp'],
code='invalid_otp',
)
otp_code = normalize_totp_token(cleaned_data.get('otp_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if recovery_code:
if not profile.consume_recovery_code(recovery_code):
raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp')
return cleaned_data
if not otp_code:
raise ValidationError(self.error_messages['missing_otp'], code='missing_otp')
if not profile.totp_secret or not verify_totp_token(profile.totp_secret, otp_code, for_time=int(timezone.now().timestamp())):
raise ValidationError(self.error_messages['invalid_otp'], code='invalid_otp')
return cleaned_data
@@ -307,18 +332,6 @@ class AccountTOTPDisableForm(forms.Form):
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code', 'inputmode': 'numeric'}),
)
recovery_code = forms.CharField(
label=gettext_lazy('Recovery-Code'),
max_length=32,
required=False,
widget=forms.TextInput(attrs={'autocomplete': 'one-time-code'}),
)
def __init__(self, *args, user=None, profile=None, **kwargs):
super().__init__(*args, **kwargs)
@@ -333,26 +346,12 @@ class AccountTOTPDisableForm(forms.Form):
def clean(self):
cleaned_data = super().clean()
code = normalize_totp_token(cleaned_data.get('verification_code'))
recovery_code = normalize_recovery_code(cleaned_data.get('recovery_code'))
if not code and not recovery_code:
raise ValidationError(_('Bitte geben Sie einen gültigen TOTP-Code oder Recovery-Code ein.'))
secret = getattr(self.profile, 'totp_secret', '') or ''
if code:
if not secret or not verify_totp_token(secret, code, for_time=int(timezone.now().timestamp())):
raise ValidationError(_('Der TOTP-Code ist ungültig.'))
return cleaned_data
if not self.profile.consume_recovery_code(recovery_code):
raise ValidationError(_('Der Recovery-Code ist ungültig.'))
if not self.profile or not self.profile.totp_enabled:
raise ValidationError(_('TOTP ist für dieses Konto nicht aktiv.'))
return cleaned_data
class AccountTOTPRegenerateRecoveryCodesForm(forms.Form):
current_password = forms.CharField(
label=gettext_lazy('Aktuelles Passwort'),
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'current-password'}),
)
verification_code = forms.CharField(
label=gettext_lazy('TOTP-Code'),
max_length=12,
@@ -371,12 +370,6 @@ class AccountTOTPRegenerateRecoveryCodesForm(forms.Form):
self.user = user
self.profile = profile
def clean_current_password(self):
password = self.cleaned_data.get('current_password') or ''
if not self.user or not self.user.check_password(password):
raise ValidationError(_('Das aktuelle Passwort ist nicht korrekt.'))
return password
def clean(self):
cleaned_data = super().clean()
code = normalize_totp_token(cleaned_data.get('verification_code'))
@@ -393,6 +386,87 @@ class AccountTOTPRegenerateRecoveryCodesForm(forms.Form):
return cleaned_data
class AccountNotificationPreferencesForm(forms.Form):
onboarding_success = forms.BooleanField(label=gettext_lazy('Onboarding erfolgreich'), required=False)
onboarding_failure = forms.BooleanField(label=gettext_lazy('Onboarding fehlgeschlagen'), required=False)
offboarding_success = forms.BooleanField(label=gettext_lazy('Offboarding erfolgreich'), required=False)
offboarding_failure = forms.BooleanField(label=gettext_lazy('Offboarding fehlgeschlagen'), required=False)
backup_success = forms.BooleanField(label=gettext_lazy('Backup erfolgreich'), required=False)
backup_failure = forms.BooleanField(label=gettext_lazy('Backup fehlgeschlagen'), required=False)
welcome_email_success = forms.BooleanField(label=gettext_lazy('Welcome E-Mail erfolgreich'), required=False)
welcome_email_failure = forms.BooleanField(label=gettext_lazy('Welcome E-Mail fehlgeschlagen'), required=False)
trial_alerts = forms.BooleanField(label=gettext_lazy('Trial-Hinweise'), required=False)
system_alerts = forms.BooleanField(label=gettext_lazy('System-Hinweise'), required=False)
FIELD_TO_EVENT = {
'onboarding_success': UserProfile.NOTIFICATION_ONBOARDING_SUCCESS,
'onboarding_failure': UserProfile.NOTIFICATION_ONBOARDING_FAILURE,
'offboarding_success': UserProfile.NOTIFICATION_OFFBOARDING_SUCCESS,
'offboarding_failure': UserProfile.NOTIFICATION_OFFBOARDING_FAILURE,
'backup_success': UserProfile.NOTIFICATION_BACKUP_SUCCESS,
'backup_failure': UserProfile.NOTIFICATION_BACKUP_FAILURE,
'welcome_email_success': UserProfile.NOTIFICATION_WELCOME_EMAIL_SUCCESS,
'welcome_email_failure': UserProfile.NOTIFICATION_WELCOME_EMAIL_FAILURE,
'trial_alerts': UserProfile.NOTIFICATION_TRIAL_ALERTS,
'system_alerts': UserProfile.NOTIFICATION_SYSTEM_ALERTS,
}
GROUPS = [
('workflow', gettext_lazy('Workflow'), ['onboarding_success', 'onboarding_failure', 'offboarding_success', 'offboarding_failure']),
('welcome', gettext_lazy('Welcome E-Mail'), ['welcome_email_success', 'welcome_email_failure']),
('operations', gettext_lazy('Operations'), ['backup_success', 'backup_failure', 'system_alerts']),
('platform', gettext_lazy('Platform'), ['trial_alerts']),
]
def __init__(self, *args, profile=None, user=None, **kwargs):
self.profile = profile
self.user = user
initial = kwargs.setdefault('initial', {})
if profile is not None and not args:
prefs = profile.get_notification_preferences()
for field_name, event_key in self.FIELD_TO_EVENT.items():
initial.setdefault(field_name, prefs.get(event_key, True))
super().__init__(*args, **kwargs)
self.visible_field_names = self._compute_visible_field_names()
for field_name in list(self.fields.keys()):
if field_name not in self.visible_field_names:
self.fields.pop(field_name)
def _compute_visible_field_names(self) -> list[str]:
visible = [
'onboarding_success',
'onboarding_failure',
'offboarding_success',
'offboarding_failure',
'welcome_email_success',
'welcome_email_failure',
]
if user_has_capability(self.user, 'manage_backups'):
visible.extend(['backup_success', 'backup_failure'])
if user_has_capability(self.user, 'manage_integrations'):
visible.append('system_alerts')
if user_has_capability(self.user, 'manage_trial_lifecycle'):
visible.append('trial_alerts')
return visible
def grouped_fields(self):
groups = []
for key, label, field_names in self.GROUPS:
rows = [self[name] for name in field_names if name in self.fields]
if rows:
groups.append({'key': key, 'label': label, 'fields': rows})
return groups
def save(self):
prefs = self.profile.get_notification_preferences()
for field_name in self.visible_field_names:
event_key = self.FIELD_TO_EVENT[field_name]
prefs[event_key] = bool(self.cleaned_data.get(field_name))
self.profile.notification_preferences = prefs
self.profile.save(update_fields=['notification_preferences', 'updated_at'])
return self.profile
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)
@@ -604,7 +678,7 @@ class OnboardingRequestForm(forms.ModelForm):
department = forms.ChoiceField(label='Abteilung', choices=DEPARTMENT_CHOICES, required=True)
work_email = forms.EmailField(
label='Gewünschte dienstliche E-Mail-Adresse',
help_text='Bitte nutzen Sie das Format name@tub.co.',
help_text='',
)
contract_start = forms.DateField(label='Vertragsbeginn', widget=forms.DateInput(attrs={'type': 'date'}))
employment_type = forms.ChoiceField(label='Beschäftigungsverhältnis', choices=EMPLOYMENT_CHOICES, required=True)

View File

@@ -39,7 +39,7 @@ class RequestIDMiddleware:
class RateLimitMiddleware:
LOGIN_PATHS = ('/accounts/login/',)
LOGIN_PATHS = ('/accounts/login/', '/accounts/login/totp/')
PASSWORD_RESET_PATHS = ('/accounts/password_reset/',)
# Keep this list path-prefix based so new platform actions get protected
# without having to wire every single view into a second permission layer.
@@ -157,7 +157,13 @@ class AuthSessionHardeningMiddleware:
login_url = reverse('login')
return redirect(f'{login_url}?next={request.get_full_path()}')
if request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES):
is_sensitive_post = request.method == 'POST' and any(path.startswith(prefix) for prefix in self.SENSITIVE_POST_PREFIXES)
if request.method == 'POST' and path == '/account/':
account_form = (request.POST.get('account_form') or '').strip()
if account_form in {'totp_disable', 'totp_regenerate_codes'}:
is_sensitive_post = True
if is_sensitive_post:
fresh_window = max(60, settings.SENSITIVE_ACTION_REAUTH_SECONDS)
auth_fresh_ts = int(request.session.get('auth_fresh_ts') or last_activity_ts)
if now_ts - auth_fresh_ts > fresh_window:

View File

@@ -0,0 +1,31 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('workflows', '0050_userprofile_totp_recovery_codes'),
]
operations = [
migrations.CreateModel(
name='UserNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('body', models.TextField(blank=True, default='')),
('level', models.CharField(choices=[('info', 'Info'), ('success', 'Erfolg'), ('warning', 'Warnung'), ('error', 'Fehler')], default='info', max_length=20)),
('link_url', models.CharField(blank=True, default='', max_length=500)),
('created_at', models.DateTimeField(auto_now_add=True)),
('read_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Notification',
'verbose_name_plural': 'User Notifications',
'ordering': ['-created_at', '-id'],
},
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('workflows', '0051_usernotification'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='notification_preferences',
field=models.JSONField(blank=True, default=dict),
),
]

View File

@@ -28,6 +28,29 @@ class EmployeeProfile(models.Model):
class UserProfile(models.Model):
NOTIFICATION_ONBOARDING_SUCCESS = 'onboarding_success'
NOTIFICATION_ONBOARDING_FAILURE = 'onboarding_failure'
NOTIFICATION_OFFBOARDING_SUCCESS = 'offboarding_success'
NOTIFICATION_OFFBOARDING_FAILURE = 'offboarding_failure'
NOTIFICATION_BACKUP_SUCCESS = 'backup_success'
NOTIFICATION_BACKUP_FAILURE = 'backup_failure'
NOTIFICATION_WELCOME_EMAIL_SUCCESS = 'welcome_email_success'
NOTIFICATION_WELCOME_EMAIL_FAILURE = 'welcome_email_failure'
NOTIFICATION_TRIAL_ALERTS = 'trial_alerts'
NOTIFICATION_SYSTEM_ALERTS = 'system_alerts'
NOTIFICATION_PREFERENCE_DEFAULTS = {
NOTIFICATION_ONBOARDING_SUCCESS: True,
NOTIFICATION_ONBOARDING_FAILURE: True,
NOTIFICATION_OFFBOARDING_SUCCESS: True,
NOTIFICATION_OFFBOARDING_FAILURE: True,
NOTIFICATION_BACKUP_SUCCESS: True,
NOTIFICATION_BACKUP_FAILURE: True,
NOTIFICATION_WELCOME_EMAIL_SUCCESS: True,
NOTIFICATION_WELCOME_EMAIL_FAILURE: True,
NOTIFICATION_TRIAL_ALERTS: True,
NOTIFICATION_SYSTEM_ALERTS: True,
}
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile')
avatar_image = models.FileField(
upload_to='profiles/',
@@ -45,6 +68,7 @@ class UserProfile(models.Model):
totp_enabled = models.BooleanField(default=False)
totp_confirmed_at = models.DateTimeField(null=True, blank=True)
totp_recovery_codes = models.JSONField(default=list, blank=True)
notification_preferences = models.JSONField(default=dict, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
@@ -84,6 +108,55 @@ class UserProfile(models.Model):
self.save(update_fields=['totp_recovery_codes', 'updated_at'])
return matched
def get_notification_preferences(self) -> dict[str, bool]:
current = self.notification_preferences or {}
prefs = dict(self.NOTIFICATION_PREFERENCE_DEFAULTS)
for key in prefs:
if key in current:
prefs[key] = bool(current[key])
return prefs
def notification_enabled(self, event_key: str) -> bool:
return bool(self.get_notification_preferences().get(event_key, True))
class UserNotification(models.Model):
LEVEL_INFO = 'info'
LEVEL_SUCCESS = 'success'
LEVEL_WARNING = 'warning'
LEVEL_ERROR = 'error'
LEVEL_CHOICES = [
(LEVEL_INFO, _('Info')),
(LEVEL_SUCCESS, _('Erfolg')),
(LEVEL_WARNING, _('Warnung')),
(LEVEL_ERROR, _('Fehler')),
]
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications')
title = models.CharField(max_length=255)
body = models.TextField(blank=True, default='')
level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default=LEVEL_INFO)
link_url = models.CharField(max_length=500, blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
read_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at', '-id']
verbose_name = 'User Notification'
verbose_name_plural = 'User Notifications'
def __str__(self) -> str:
return f'{self.user_id} | {self.level} | {self.title}'
@property
def is_unread(self) -> bool:
return self.read_at is None
def mark_read(self) -> None:
if self.read_at is None:
self.read_at = timezone.now()
self.save(update_fields=['read_at'])
class PortalBranding(models.Model):
name = models.CharField(max_length=80, default='Default', unique=True)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from .models import UserNotification, UserProfile
def create_user_notification(*, user, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '') -> UserNotification:
return UserNotification.objects.create(
user=user,
title=(title or '').strip(),
body=(body or '').strip(),
level=level,
link_url=(link_url or '').strip(),
)
def notify_user(*, user, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '', event_key: str = '') -> bool:
if not user or not getattr(user, 'is_authenticated', False):
return False
profile, _ = UserProfile.objects.get_or_create(user=user)
if event_key and not profile.notification_enabled(event_key):
return False
create_user_notification(user=user, title=title, body=body, level=level, link_url=link_url)
return True
def notify_user_by_email(*, email: str, title: str, body: str = '', level: str = UserNotification.LEVEL_INFO, link_url: str = '', event_key: str = '') -> bool:
normalized_email = (email or '').strip().lower()
if not normalized_email:
return False
user = get_user_model().objects.filter(email__iexact=normalized_email).first()
if not user:
return False
return notify_user(user=user, title=title, body=body, level=level, link_url=link_url, event_key=event_key)

View File

@@ -19,7 +19,7 @@ body {
}
.account-shell-body {
padding: 28px;
padding: 24px;
background:
radial-gradient(90% 120% at 10% 0%, rgba(31, 79, 214, 0.06), rgba(31, 79, 214, 0)),
linear-gradient(180deg, rgba(255,255,255,0.72), rgba(248,251,255,0.48));
@@ -29,7 +29,7 @@ body {
width: min(1120px, 100%);
margin: 0 auto;
display: grid;
gap: 22px;
gap: 18px;
}
.account-hero {
@@ -37,13 +37,14 @@ body {
justify-content: space-between;
gap: 18px;
align-items: flex-start;
padding: 26px 28px;
padding: 22px 24px;
border: 1px solid #d9e3f0;
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(30, 64, 175, 0.1), transparent 24%),
linear-gradient(135deg, rgba(255,255,255,0.96), rgba(244,248,255,0.9));
box-shadow: 0 14px 34px rgba(28, 45, 79, 0.08);
animation: accountFadeUp 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-kicker {
@@ -72,6 +73,16 @@ body {
line-height: 1.55;
}
.account-hero-submeta {
margin-top: 14px !important;
color: #6a7a8f;
font-size: 13px;
}
.account-hero-submeta strong {
color: #132238;
}
.account-hero-badges {
display: flex;
gap: 10px;
@@ -111,12 +122,20 @@ body {
border-radius: 22px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 14px 32px rgba(28, 45, 79, 0.09);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-profile-card {
padding: 24px;
padding: 20px;
position: sticky;
top: 24px;
animation: accountFadeUp 360ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-profile-card:hover,
.account-panel:hover {
transform: translateY(-1px);
box-shadow: 0 18px 34px rgba(28, 45, 79, 0.10);
}
.account-avatar-form {
@@ -205,30 +224,36 @@ body {
word-break: break-word;
}
.account-profile-meta {
.account-nav {
display: grid;
gap: 12px;
gap: 10px;
margin-top: 22px;
}
.account-profile-meta div {
padding: 12px 14px;
border-radius: 14px;
background: #f7faff;
.account-nav-item {
width: 100%;
padding: 14px 16px;
text-align: left;
border-radius: 16px;
border: 1px solid #dce6f2;
background: #f7faff;
color: #17345e;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-profile-meta span {
display: block;
margin-bottom: 4px;
color: #6b7a90;
font-size: 12px;
.account-nav-item:hover {
border-color: #c5d5ea;
background: #f3f8ff;
}
.account-profile-meta strong {
color: #132238;
font-size: 14px;
line-height: 1.4;
.account-nav-item.is-active {
border-color: rgba(0, 0, 120, 0.18);
background: linear-gradient(180deg, rgba(238,243,255,0.95), rgba(231,239,255,0.92));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
transform: translateX(3px);
}
.account-main {
@@ -236,8 +261,129 @@ body {
gap: 22px;
}
.account-notification-pref-grid {
display: grid;
gap: 12px;
}
.account-notification-group + .account-notification-group {
margin-top: 18px;
}
.account-notification-group h3 {
margin: 0 0 10px;
color: #17345e;
font-size: 13px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.account-notification-pref-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid #dce6f2;
border-radius: 16px;
background: #f7faff;
}
.account-notification-pref-item span {
color: #17345e;
font-weight: 700;
}
.account-notification-pref-item strong {
color: #617389;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.account-notification-pref-grid-edit {
gap: 14px;
}
.account-notification-pref-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 16px;
border: 1px solid #dce6f2;
border-radius: 16px;
background: #f7faff;
}
.account-notification-pref-copy {
display: grid;
gap: 4px;
}
.account-notification-pref-copy strong {
color: #17345e;
font-size: 14px;
}
.account-notification-pref-copy small {
color: #617389;
font-size: 12px;
line-height: 1.45;
}
.account-toggle-control {
position: relative;
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.account-toggle-control input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.account-toggle-slider {
position: relative;
width: 50px;
height: 30px;
border-radius: 999px;
background: #d7e1ee;
transition: background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-toggle-slider::after {
content: "";
position: absolute;
top: 4px;
left: 4px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
box-shadow: 0 4px 10px rgba(18, 34, 56, 0.12);
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-toggle-control input:checked + .account-toggle-slider {
background: #0f8f57;
}
.account-toggle-control input:checked + .account-toggle-slider::after {
transform: translateX(20px);
}
.account-panel {
padding: 24px;
animation: accountFadeUp 380ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-panel.is-entering {
animation: accountPanelSwap 260ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.account-panel-head {
@@ -299,7 +445,7 @@ body {
}
.account-security-item {
padding: 16px 18px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid #dbe5f2;
background:
@@ -307,6 +453,13 @@ body {
linear-gradient(180deg, rgba(255,255,255,0.96), rgba(246,250,255,0.9));
}
.account-security-item-active {
border-color: rgba(34, 139, 86, 0.26);
background:
radial-gradient(circle at top right, rgba(34, 139, 86, 0.10), transparent 26%),
linear-gradient(180deg, rgba(242,255,247,0.96), rgba(236,251,242,0.92));
}
.account-security-item span {
display: block;
margin-bottom: 6px;
@@ -333,7 +486,7 @@ body {
.account-totp-card {
margin-bottom: 18px;
padding: 18px;
padding: 16px;
border-radius: 18px;
border: 1px solid #dbe5f2;
background:
@@ -356,7 +509,55 @@ body {
}
.account-totp-form {
margin-top: 14px;
margin-top: 12px;
}
.account-totp-action-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.account-totp-action-copy strong {
display: block;
color: #132238;
font-size: 15px;
}
.account-totp-action-copy p {
margin: 6px 0 0;
color: #617389;
font-size: 13px;
line-height: 1.45;
}
.account-totp-toggle-form {
margin-top: 12px;
}
.account-totp-status-row {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: center;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid #dbe5f2;
background: rgba(255,255,255,0.88);
}
.account-totp-status-copy strong {
display: block;
color: #132238;
font-size: 16px;
}
.account-totp-status-copy p {
margin: 6px 0 0;
color: #55718f;
font-size: 13px;
line-height: 1.45;
}
.account-qr-card,
@@ -456,6 +657,19 @@ body {
grid-column: 1 / -1;
}
.account-form-field.is-hidden {
display: none;
}
.account-totp-form.is-hidden {
display: none;
}
.account-form-grid.is-hidden,
.account-inline-actions.is-hidden {
display: none;
}
.account-form-field label {
color: #132238;
font-size: 13px;
@@ -506,6 +720,32 @@ body {
margin-top: 16px;
}
.account-recovery-toggle {
width: auto;
}
@keyframes accountFadeUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes accountPanelSwap {
from {
opacity: 0;
transform: translateY(10px) scale(0.995);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 980px) {
.account-layout {
grid-template-columns: 1fr;
@@ -540,10 +780,6 @@ body {
font-size: 26px;
}
.account-hero-badges {
justify-content: flex-start;
}
.account-detail-grid,
.account-security-overview,
.account-form-grid,
@@ -558,4 +794,39 @@ body {
.account-panel-head {
flex-direction: column;
}
.account-totp-status-card {
flex-direction: column;
}
.account-totp-status-row {
flex-direction: column;
align-items: stretch;
}
.account-totp-action-row {
flex-direction: column;
align-items: stretch;
}
}
@media (prefers-reduced-motion: reduce) {
.account-hero,
.account-profile-card,
.account-panel,
.account-panel.is-entering {
animation: none;
}
.account-profile-card,
.account-panel,
.account-nav-item {
transition: none;
}
.account-profile-card:hover,
.account-panel:hover,
.account-nav-item.is-active {
transform: none;
}
}

View File

@@ -180,10 +180,227 @@
align-items: center;
}
.app-notification-menu,
.app-user-menu {
position: relative;
}
.app-notification-trigger {
list-style: none;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--app-line);
border-radius: 999px;
background: rgba(248, 251, 255, 0.92);
color: #1f3a5f;
cursor: pointer;
transition:
border-color var(--motion-fast) var(--motion-ease),
background-color var(--motion-fast) var(--motion-ease),
transform var(--motion-fast) var(--motion-ease),
box-shadow var(--motion-fast) var(--motion-ease);
}
.app-notification-trigger::-webkit-details-marker {
display: none;
}
.app-notification-trigger:hover {
transform: translateY(-1px);
}
.app-notification-menu[open] .app-notification-trigger {
border-color: rgba(0, 0, 120, 0.22);
box-shadow: 0 0 0 4px rgba(0, 0, 120, 0.08);
}
.app-notification-bell {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: #28446e;
}
.app-notification-bell svg {
width: 18px;
height: 18px;
display: block;
}
.app-notification-count {
position: absolute;
top: -2px;
right: -2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #c0002b;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.98);
}
.app-notification-panel {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: min(380px, calc(100vw - 32px));
max-height: min(70vh, 520px);
padding: 10px;
border: 1px solid rgba(217, 227, 238, 0.96);
border-radius: 18px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 44px rgba(18, 34, 56, 0.16);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 45;
overflow: hidden;
}
.app-notification-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 4px 6px 8px;
border-bottom: 1px solid rgba(217, 227, 238, 0.85);
}
.app-notification-panel-head strong {
color: #132238;
font-size: 13px;
line-height: 1.2;
}
.app-notification-panel-head form {
margin: 0;
}
.app-notification-panel-head button {
border: 0;
background: transparent;
color: var(--app-brand-blue);
font-size: 12px;
font-weight: 700;
cursor: pointer;
padding: 0;
}
.app-notification-list {
display: grid;
gap: 8px;
overflow: auto;
padding-right: 2px;
}
.app-notification-item {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid rgba(217, 227, 238, 0.92);
border-radius: 14px;
background: linear-gradient(180deg, rgba(249, 252, 255, 0.96), rgba(243, 248, 255, 0.92));
}
.app-notification-item.is-unread {
border-color: rgba(0, 0, 120, 0.22);
box-shadow: inset 3px 0 0 rgba(0, 0, 120, 0.9);
}
.app-notification-success {
background: linear-gradient(180deg, #f4fcf7, #edf9f1);
border-color: #cce9d5;
}
.app-notification-error {
background: linear-gradient(180deg, #fff7f7, #fff1f1);
border-color: #f0c8c8;
}
.app-notification-warning {
background: linear-gradient(180deg, #fffaf0, #fff4dd);
border-color: #f3d9a7;
}
.app-notification-copy {
display: grid;
gap: 4px;
}
.app-notification-copy strong {
color: #132238;
font-size: 13px;
line-height: 1.35;
}
.app-notification-copy p {
margin: 0;
color: #51657f;
font-size: 12px;
line-height: 1.45;
}
.app-notification-copy span {
color: #7a8ca3;
font-size: 11px;
line-height: 1.3;
}
.app-notification-actions {
display: flex;
align-items: center;
gap: 8px;
}
.app-notification-actions a,
.app-notification-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid rgba(217, 227, 238, 0.92);
background: rgba(255, 255, 255, 0.82);
color: #1f3a5f;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--motion-fast) var(--motion-ease),
border-color var(--motion-fast) var(--motion-ease),
color var(--motion-fast) var(--motion-ease);
}
.app-notification-actions a:hover,
.app-notification-actions button:hover,
.app-notification-panel-head button:hover {
color: var(--app-brand-blue);
}
.app-notification-empty {
padding: 14px 8px 8px;
color: #64748b;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.app-user-trigger {
list-style: none;
display: inline-flex;
@@ -330,6 +547,10 @@
.app-user-panel a:focus-visible,
.app-user-panel button:focus-visible,
.app-notification-trigger:focus-visible,
.app-notification-actions a:focus-visible,
.app-notification-actions button:focus-visible,
.app-notification-panel-head button:focus-visible,
.app-user-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 120, 0.10);

View File

@@ -47,6 +47,26 @@ body {
line-height: 1.45;
}
.login-step-caption {
display: grid;
gap: 2px;
margin: 0 0 14px;
padding: 12px 14px;
border: 1px solid #d9e3f0;
border-radius: 14px;
background: #f7faff;
}
.login-step-caption strong {
color: #132238;
font-size: 14px;
}
.login-step-caption span {
color: #607086;
font-size: 13px;
}
.account-card {
width: min(560px, 100%);
}
@@ -138,6 +158,34 @@ body {
width: 100%;
}
.btn-inline-toggle {
width: auto;
min-width: 0;
}
.login-recovery-toggle-row {
display: flex;
justify-content: flex-start;
margin: -2px 0 12px;
}
.login-recovery-box.is-hidden {
display: none;
}
.login-back-link {
display: inline-flex;
align-items: center;
margin-top: 14px;
color: #36506e;
font-size: 14px;
text-decoration: none;
}
.login-back-link:hover {
text-decoration: underline;
}
.login-card .app-alert {
margin: 0 0 12px;
}

View File

@@ -28,6 +28,7 @@ from .forms import (
SOFTWARE_EXTRA_CHOICES,
WORKSPACE_GROUP_CHOICES,
)
from .notifications import notify_user_by_email
# These templates are the product-level defaults for fresh deployments.
# Runtime branding and company config can override the company-facing identity
@@ -251,6 +252,32 @@ DEFAULT_NOTIFICATION_TEMPLATES = {
}
def _notify_request_result(*, recipient_email: str, title: str, body: str, level: str, event_key: str) -> None:
notify_user_by_email(
email=recipient_email,
title=title,
body=body,
level=level,
link_url='/requests/',
event_key=event_key,
)
def _notify_welcome_email_result(*, recipient_email: str, full_name: str, body: str, level: str, event_key: str) -> None:
notify_user_by_email(
email=recipient_email,
title=(
_('Welcome E-Mail gesendet: %(name)s') % {'name': full_name}
if event_key == 'welcome_email_success'
else _('Welcome E-Mail fehlgeschlagen: %(name)s') % {'name': full_name}
),
body=body,
level=level,
link_url='/admin-tools/welcome-emails/',
event_key=event_key,
)
def _start_task_log(task_name: str, *, target_type: str = '', target_id: int | None = None, target_label: str = '') -> AsyncTaskLog:
task_request = getattr(current_task, 'request', None)
return AsyncTaskLog.objects.create(
@@ -1331,11 +1358,25 @@ def process_onboarding_request(onboarding_request_id: int) -> None:
request_obj.processing_status = 'completed'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.onboarded_by_email,
title=_('Onboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name},
body=_('Die Onboarding-Anfrage wurde erfolgreich verarbeitet.'),
level='success',
event_key='onboarding_success',
)
_finish_task_log(task_log, status='succeeded')
except Exception as exc:
request_obj.processing_status = 'failed'
request_obj.last_error = str(exc)
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.onboarded_by_email,
title=_('Onboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name},
body=str(exc),
level='error',
event_key='onboarding_failure',
)
_finish_task_log(task_log, status='failed', error_message=str(exc))
raise
@@ -1355,7 +1396,7 @@ def process_offboarding_request(offboarding_request_id: int) -> None:
try:
branding_copy = get_branding_email_copy()
company_contact = get_company_contact_copy()
it_email, general_info_email, _, hr_works_email, _ = _resolve_workflow_emails()
it_email, general_info_email, business_card_email_unused, hr_works_email, key_email_unused = _resolve_workflow_emails()
pdf_path = _generate_offboarding_pdf(request_obj)
request_obj.generated_pdf_path = str(pdf_path)
@@ -1418,11 +1459,25 @@ def process_offboarding_request(offboarding_request_id: int) -> None:
request_obj.processing_status = 'completed'
request_obj.last_error = ''
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.requested_by_email,
title=_('Offboarding abgeschlossen: %(name)s') % {'name': request_obj.full_name},
body=_('Die Offboarding-Anfrage wurde erfolgreich verarbeitet.'),
level='success',
event_key='offboarding_success',
)
_finish_task_log(task_log, status='succeeded')
except Exception as exc:
request_obj.processing_status = 'failed'
request_obj.last_error = str(exc)
request_obj.save(update_fields=['processing_status', 'last_error'])
_notify_request_result(
recipient_email=request_obj.requested_by_email,
title=_('Offboarding fehlgeschlagen: %(name)s') % {'name': request_obj.full_name},
body=str(exc),
level='error',
event_key='offboarding_failure',
)
_finish_task_log(task_log, status='failed', error_message=str(exc))
raise
@@ -1490,10 +1545,24 @@ def send_scheduled_welcome_email(scheduled_email_id: int, force_now: bool = Fals
scheduled.status = 'sent'
scheduled.sent_at = timezone.now()
scheduled.last_error = ''
_notify_welcome_email_result(
recipient_email=request_obj.onboarded_by_email,
full_name=request_obj.full_name,
body=_('Die geplante Welcome E-Mail wurde erfolgreich versendet.'),
level='success',
event_key='welcome_email_success',
)
_finish_task_log(task_log, status='succeeded')
except Exception as exc:
scheduled.status = 'failed'
scheduled.last_error = str(exc)
_notify_welcome_email_result(
recipient_email=request_obj.onboarded_by_email,
full_name=request_obj.full_name,
body=str(exc),
level='error',
event_key='welcome_email_failure',
)
_finish_task_log(task_log, status='failed', error_message=str(exc))
raise
finally:

View File

@@ -18,7 +18,11 @@
<div class="account-hero-copy">
<span class="account-kicker">{% trans "Konto" %}</span>
<h1>{% trans "Profil" %}</h1>
<p>{% trans "Ihre aktuelle Workdock-Kontoübersicht und wichtige Sicherheitsaktionen." %}</p>
<p>{% trans "Ihre aktuelle Kontoübersicht und wichtige Sicherheitsaktionen." %}</p>
<p class="account-hero-submeta">
{% trans "Letzte Anmeldung" %}:
<strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
</p>
</div>
</section>
@@ -53,24 +57,36 @@
<h2>{{ account_user.get_full_name|default:account_user.username }}</h2>
<p>{{ account_user.email|default:account_user.username }}</p>
</div>
<div class="account-profile-meta">
<div>
<span>{% trans "Position" %}</span>
<strong>{{ account_user_profile.job_title|default:"-" }}</strong>
</div>
<div>
<span>{% trans "Abteilung" %}</span>
<strong>{{ account_user_profile.department|default:"-" }}</strong>
</div>
<div>
<span>{% trans "Letzte Anmeldung" %}</span>
<strong>{% if account_user.last_login %}{{ account_user.last_login|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
</div>
<div class="account-nav">
<button
class="account-nav-item is-active"
type="button"
data-account-tab="details"
aria-expanded="true"
>
{% trans "Kontodaten" %}
</button>
<button
class="account-nav-item"
type="button"
data-account-tab="security"
aria-expanded="false"
>
{% trans "Sicherheit & Aktionen" %}
</button>
<button
class="account-nav-item"
type="button"
data-account-tab="notifications"
aria-expanded="false"
>
{% trans "Benachrichtigungen" %}
</button>
</div>
</aside>
<div class="account-main">
<section class="account-panel">
<section class="account-panel" data-account-panel="details">
<div class="account-panel-head">
<div>
<h2>{% trans "Kontodaten" %}</h2>
@@ -156,13 +172,92 @@
</form>
</section>
<section class="account-panel">
<section class="account-panel" data-account-panel="notifications" hidden>
<div class="account-panel-head">
<div>
<h2>{% trans "Benachrichtigungen" %}</h2>
<p>{% trans "Legen Sie fest, welche Workflow-Ereignisse im Header als Benachrichtigung erscheinen sollen." %}</p>
</div>
<button
class="btn btn-secondary account-inline-edit-trigger"
type="button"
data-account-edit-toggle="notifications"
aria-expanded="{% if notifications_edit_open %}true{% else %}false{% endif %}"
>
{% trans "Bearbeiten" %}
</button>
</div>
<div class="account-inline-view{% if notifications_edit_open %} is-hidden{% endif %}" data-account-edit-view="notifications">
{% for group in notification_preference_groups %}
<section class="account-notification-group">
<h3>{{ group.label }}</h3>
<div class="account-notification-pref-grid">
{% for field in group.fields %}
<div class="account-notification-pref-item">
<span>{{ field.label }}</span>
<strong>{% if field.value %}{% trans "Aktiv" %}{% else %}{% trans "Aus" %}{% endif %}</strong>
</div>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<form
class="account-inline-form{% if not notifications_edit_open %} is-hidden{% endif %}"
method="post"
data-account-edit-form="notifications"
>
{% csrf_token %}
<input type="hidden" name="account_form" value="notification_preferences" />
{% for group in notification_preference_groups %}
<section class="account-notification-group">
<h3>{{ group.label }}</h3>
<div class="account-notification-pref-grid account-notification-pref-grid-edit">
{% for field in group.fields %}
<label class="account-notification-pref-toggle{% if field.errors %} has-error{% endif %}" for="{{ field.id_for_label }}">
<span class="account-notification-pref-copy">
<strong>{{ field.label }}</strong>
<small>
{% if field.name == 'onboarding_success' %}{% trans "Benachrichtigung nach erfolgreich abgeschlossenem Onboarding." %}
{% elif field.name == 'onboarding_failure' %}{% trans "Benachrichtigung wenn ein Onboarding fehlschlägt." %}
{% elif field.name == 'offboarding_success' %}{% trans "Benachrichtigung nach erfolgreich abgeschlossenem Offboarding." %}
{% elif field.name == 'offboarding_failure' %}{% trans "Benachrichtigung wenn ein Offboarding fehlschlägt." %}
{% elif field.name == 'backup_success' %}{% trans "Benachrichtigung bei erfolgreicher Backup-Erstellung oder Verifikation." %}
{% elif field.name == 'backup_failure' %}{% trans "Benachrichtigung wenn Backup-Aktionen fehlschlagen." %}
{% elif field.name == 'welcome_email_success' %}{% trans "Benachrichtigung wenn eine geplante Welcome E-Mail erfolgreich gesendet wurde." %}
{% elif field.name == 'welcome_email_failure' %}{% trans "Benachrichtigung wenn eine geplante Welcome E-Mail fehlschlägt." %}
{% elif field.name == 'trial_alerts' %}{% trans "Hinweise zu Trial-Ablauf, Ablaufdatum oder Deaktivierung." %}
{% else %}{% trans "Hinweise aus Systemtests wie SMTP oder Nextcloud." %}{% endif %}
</small>
</span>
<span class="account-toggle-control">
{{ field }}
<span class="account-toggle-slider" aria-hidden="true"></span>
</span>
</label>
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
<div class="account-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "Speichern" %}</button>
<button class="btn btn-secondary" type="button" data-account-edit-cancel="notifications">{% trans "Abbrechen" %}</button>
</div>
</form>
</section>
<section class="account-panel" data-account-panel="security" hidden>
<div class="account-panel-head">
<h2>{% trans "Sicherheit & Aktionen" %}</h2>
</div>
<div class="account-security-overview">
<div class="account-security-item">
<div class="account-security-item{% if account_user_profile.totp_enabled %} account-security-item-active{% endif %}">
<span>{% trans "TOTP" %}</span>
<strong>{% if account_user_profile.totp_enabled %}{% trans "Aktiv" %}{% else %}{% trans "Aus" %}{% endif %}</strong>
<p>
@@ -198,60 +293,97 @@
<h3>{% trans "Zwei-Faktor-Authentifizierung" %}</h3>
<p>{% trans "Aktivieren Sie TOTP mit einer Authenticator-App. Standardmäßig bleibt es ausgeschaltet." %}</p>
</div>
<span class="account-chip{% if not account_user_profile.totp_enabled %} account-chip-muted{% endif %}">
{% if account_user_profile.totp_enabled %}
{% trans "Aktiv" %}
{% else %}
{% trans "Aus" %}
{% endif %}
</span>
</div>
{% if account_user_profile.totp_enabled %}
<div class="account-detail-grid">
<div class="account-detail">
<span>{% trans "Status" %}</span>
<div class="account-totp-status-row">
<div class="account-totp-status-copy">
<strong>{% trans "TOTP ist aktiviert." %}</strong>
<p>{% trans "Bestätigt am" %}: {% if account_user_profile.totp_confirmed_at %}{{ account_user_profile.totp_confirmed_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}</p>
</div>
<div class="account-detail">
<span>{% trans "Bestätigt am" %}</span>
<strong>{% if account_user_profile.totp_confirmed_at %}{{ account_user_profile.totp_confirmed_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}</strong>
</div>
<button
class="btn btn-secondary"
type="button"
data-disable-toggle
aria-expanded="{% if totp_disable_form.errors %}true{% else %}false{% endif %}"
>
{% trans "TOTP deaktivieren" %}
</button>
</div>
<form class="account-totp-form" method="post">
<form class="account-totp-form{% if not totp_disable_form.errors %} is-hidden{% endif %}" method="post" data-disable-form>
{% csrf_token %}
<input type="hidden" name="account_form" value="totp_disable" />
<div class="account-form-grid">
{% for field in totp_disable_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
<div class="account-form-field">
<label for="{{ totp_disable_form.current_password.id_for_label }}">{{ totp_disable_form.current_password.label }}</label>
{{ totp_disable_form.current_password }}
{% if totp_disable_form.current_password.errors %}
<div class="account-form-error">{{ totp_disable_form.current_password.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
{% if totp_disable_form.non_field_errors %}
<div class="account-form-field account-form-field-wide">
<div class="account-form-error">{{ totp_disable_form.non_field_errors|join:", " }}</div>
</div>
{% endif %}
</div>
<div class="account-inline-actions">
<button class="btn btn-secondary" type="submit">{% trans "TOTP deaktivieren" %}</button>
<button class="btn btn-secondary" type="submit">{% trans "Deaktivierung bestätigen" %}</button>
<button class="btn btn-secondary" type="button" data-disable-cancel>{% trans "Abbrechen" %}</button>
</div>
</form>
<form class="account-totp-form" method="post">
{% csrf_token %}
<input type="hidden" name="account_form" value="totp_regenerate_codes" />
<div class="account-form-grid">
{% for field in totp_regenerate_form %}
<div class="account-form-field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.errors %}
<div class="account-form-error">{{ field.errors|join:", " }}</div>
<div class="account-totp-action-row">
<div class="account-totp-action-copy">
<strong>{% trans "Recovery-Codes neu erzeugen" %}</strong>
<p>{% trans "Neue Recovery-Codes sollten nur erzeugt werden, wenn die bisherigen Codes nicht mehr sicher sind." %}</p>
</div>
<button
class="btn btn-primary"
type="button"
data-regenerate-toggle
aria-expanded="{% if totp_regenerate_form.errors %}true{% else %}false{% endif %}"
>
{% trans "Recovery-Codes neu erzeugen" %}
</button>
</div>
<div class="account-form-grid{% if not totp_regenerate_form.errors %} is-hidden{% endif %}" data-regenerate-form>
<div class="account-form-field{% if totp_regenerate_form.verification_code.errors %} has-error{% endif %}">
<label for="{{ totp_regenerate_form.verification_code.id_for_label }}">{{ totp_regenerate_form.verification_code.label }}</label>
{{ totp_regenerate_form.verification_code }}
{% if totp_regenerate_form.verification_code.errors %}
<div class="account-form-error">{{ totp_regenerate_form.verification_code.errors|join:", " }}</div>
{% endif %}
</div>
{% endfor %}
<div class="account-form-field account-form-field-wide">
<button
class="btn btn-secondary account-recovery-toggle"
type="button"
data-recovery-toggle
aria-expanded="{% if totp_regenerate_form.recovery_code.value %}true{% else %}false{% endif %}"
aria-controls="account-regenerate-recovery-code"
>
{% trans "Stattdessen Recovery-Code verwenden" %}
</button>
</div>
<div class="account-form-field account-form-field-wide{% if not totp_regenerate_form.recovery_code.value %} is-hidden{% endif %}" id="account-regenerate-recovery-code" data-recovery-field>
<label for="{{ totp_regenerate_form.recovery_code.id_for_label }}">{{ totp_regenerate_form.recovery_code.label }}</label>
{{ totp_regenerate_form.recovery_code }}
{% if totp_regenerate_form.recovery_code.errors %}
<div class="account-form-error">{{ totp_regenerate_form.recovery_code.errors|join:", " }}</div>
{% endif %}
</div>
{% if totp_regenerate_form.non_field_errors %}
<div class="account-form-field account-form-field-wide">
<div class="account-form-error">{{ totp_regenerate_form.non_field_errors|join:", " }}</div>
</div>
{% endif %}
</div>
<div class="account-inline-actions">
<button class="btn btn-primary" type="submit">{% trans "Recovery-Codes neu erzeugen" %}</button>
<div class="account-inline-actions{% if not totp_regenerate_form.errors %} is-hidden{% endif %}" data-regenerate-actions>
<button class="btn btn-primary" type="submit">{% trans "Erzeugung bestätigen" %}</button>
<button class="btn btn-secondary" type="button" data-regenerate-cancel>{% trans "Abbrechen" %}</button>
</div>
</form>
{% else %}
@@ -309,8 +441,11 @@
<h3>{% trans "Recovery-Codes" %}</h3>
<p>{% trans "Diese Codes werden nur jetzt im Klartext angezeigt. Jeden Code können Sie genau einmal verwenden." %}</p>
</div>
<button class="btn btn-secondary" type="button" data-recovery-download>
{% trans "Herunterladen" %}
</button>
</div>
<div class="account-recovery-grid">
<div class="account-recovery-grid" data-recovery-codes>
{% for code in totp_recovery_codes %}
<div class="account-recovery-code">{{ code }}</div>
{% endfor %}
@@ -328,27 +463,68 @@
{% block extra_scripts %}
<script>
(function () {
var toggle = document.querySelector('[data-account-edit-toggle="details"]');
var cancel = document.querySelector('[data-account-edit-cancel="details"]');
var view = document.querySelector('[data-account-edit-view="details"]');
var form = document.querySelector('[data-account-edit-form="details"]');
var editConfigs = ['details', 'notifications'].map(function (key) {
return {
key: key,
toggle: document.querySelector('[data-account-edit-toggle="' + key + '"]'),
cancel: document.querySelector('[data-account-edit-cancel="' + key + '"]'),
view: document.querySelector('[data-account-edit-view="' + key + '"]'),
form: document.querySelector('[data-account-edit-form="' + key + '"]')
};
});
var secretToggle = document.querySelector('[data-secret-toggle]');
var secretBody = document.querySelector('[data-secret-body]');
var secretIcon = document.querySelector('[data-secret-toggle-icon]');
if (!toggle || !cancel || !view || !form) return;
function setMode(editing) {
view.classList.toggle('is-hidden', editing);
form.classList.toggle('is-hidden', !editing);
toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
toggle.addEventListener('click', function () {
setMode(true);
var recoveryToggle = document.querySelector('[data-recovery-toggle]');
var recoveryField = document.querySelector('[data-recovery-field]');
var disableToggle = document.querySelector('[data-disable-toggle]');
var disableForm = document.querySelector('[data-disable-form]');
var disableCancel = document.querySelector('[data-disable-cancel]');
var regenerateToggle = document.querySelector('[data-regenerate-toggle]');
var regenerateForm = document.querySelector('[data-regenerate-form]');
var regenerateActions = document.querySelector('[data-regenerate-actions]');
var regenerateCancel = document.querySelector('[data-regenerate-cancel]');
var recoveryDownload = document.querySelector('[data-recovery-download]');
var recoveryCodes = document.querySelector('[data-recovery-codes]');
var tabButtons = document.querySelectorAll('[data-account-tab]');
var panels = document.querySelectorAll('[data-account-panel]');
editConfigs.forEach(function (config) {
if (!config.toggle || !config.cancel || !config.view || !config.form) return;
function setMode(editing) {
config.view.classList.toggle('is-hidden', editing);
config.form.classList.toggle('is-hidden', !editing);
config.toggle.setAttribute('aria-expanded', editing ? 'true' : 'false');
}
config.toggle.addEventListener('click', function () {
setMode(true);
});
config.cancel.addEventListener('click', function () {
setMode(false);
});
});
cancel.addEventListener('click', function () {
setMode(false);
function setTab(tabKey) {
tabButtons.forEach(function (button) {
var isActive = button.getAttribute('data-account-tab') === tabKey;
button.classList.toggle('is-active', isActive);
button.setAttribute('aria-expanded', isActive ? 'true' : 'false');
});
panels.forEach(function (panel) {
var shouldShow = panel.getAttribute('data-account-panel') === tabKey;
panel.hidden = !shouldShow;
if (shouldShow) {
panel.classList.remove('is-entering');
window.requestAnimationFrame(function () {
panel.classList.add('is-entering');
});
}
});
}
tabButtons.forEach(function (button) {
button.addEventListener('click', function () {
setTab(button.getAttribute('data-account-tab'));
});
});
if (secretToggle && secretBody) {
@@ -361,6 +537,74 @@
}
});
}
if (recoveryToggle && recoveryField) {
recoveryToggle.addEventListener('click', function () {
var isOpen = recoveryToggle.getAttribute('aria-expanded') === 'true';
recoveryToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
recoveryField.classList.toggle('is-hidden', isOpen);
});
}
if (disableToggle && disableForm) {
disableToggle.addEventListener('click', function () {
var isOpen = disableToggle.getAttribute('aria-expanded') === 'true';
disableToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
disableForm.classList.toggle('is-hidden', isOpen);
});
}
if (disableCancel && disableToggle && disableForm) {
disableCancel.addEventListener('click', function () {
disableToggle.setAttribute('aria-expanded', 'false');
disableForm.classList.add('is-hidden');
});
}
if (regenerateToggle && regenerateForm && regenerateActions) {
regenerateToggle.addEventListener('click', function () {
var isOpen = regenerateToggle.getAttribute('aria-expanded') === 'true';
regenerateToggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
regenerateForm.classList.toggle('is-hidden', isOpen);
regenerateActions.classList.toggle('is-hidden', isOpen);
});
}
if (regenerateCancel && regenerateToggle && regenerateForm && regenerateActions) {
regenerateCancel.addEventListener('click', function () {
regenerateToggle.setAttribute('aria-expanded', 'false');
regenerateForm.classList.add('is-hidden');
regenerateActions.classList.add('is-hidden');
});
}
if (recoveryDownload && recoveryCodes) {
recoveryDownload.addEventListener('click', function () {
var codes = Array.from(recoveryCodes.querySelectorAll('.account-recovery-code')).map(function (node) {
return node.textContent.trim();
}).filter(Boolean);
if (!codes.length) return;
var blob = new Blob([codes.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'workdock-recovery-codes.txt';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
});
}
if ({{ account_edit_open|yesno:"true,false" }}) {
setTab('details');
} else if ({{ notifications_edit_open|yesno:"true,false" }}) {
setTab('notifications');
} else if ({{ totp_edit_open|yesno:"true,false" }} || {{ totp_disable_form.errors|yesno:"true,false" }} || {{ totp_regenerate_form.errors|yesno:"true,false" }}) {
setTab('security');
} else {
setTab('details');
}
}());
</script>
{% endblock %}

View File

@@ -14,10 +14,21 @@
{% block shell_body %}
<section class="login-shell-body">
<div class="login-card">
{% if login_step == 'totp' %}
<h1>{% trans "Zwei-Faktor-Prüfung" %}</h1>
<p>{% trans "Geben Sie Ihren TOTP-Code ein, um die Anmeldung abzuschließen." %}</p>
{% if login_totp_user %}
<div class="login-step-caption">
<strong>{{ login_totp_user.get_full_name|default:login_totp_user.username }}</strong>
<span>{{ login_totp_user.email|default:login_totp_user.username }}</span>
</div>
{% endif %}
{% else %}
<h1>{% trans "Anmeldung" %}</h1>
<p>{{ portal_login_subtitle }}</p>
{% endif %}
<form method="post" action="/accounts/login/">
<form method="post" action="{% if login_step == 'totp' %}/accounts/login/totp/{% else %}/accounts/login/{% endif %}">
{% csrf_token %}
{% if next %}
<input type="hidden" name="next" value="{{ next }}" />
@@ -25,23 +36,54 @@
{% if form.errors %}
<div class="app-alert app-alert-error" role="alert" aria-live="assertive">
<div class="app-alert-body">
{% if login_step == 'totp' %}
<strong>{% trans "Code ungültig" %}</strong><br />
<span>{% trans "Der eingegebene TOTP- oder Recovery-Code ist nicht korrekt. Bitte versuchen Sie es erneut." %}</span>
{% else %}
<strong>{% trans "Anmeldung fehlgeschlagen" %}</strong><br />
<span>{% trans "Anmeldedaten oder TOTP-Code sind nicht korrekt. Bitte versuchen Sie es erneut." %}</span>
<span>{% trans "Benutzername oder Passwort sind nicht korrekt. Bitte versuchen Sie es erneut." %}</span>
{% endif %}
</div>
</div>
{% endif %}
{% if login_step == 'totp' %}
<div class="field{% if form.otp_code.errors or form.errors %} has-error{% endif %}">
{{ form.otp_code.label_tag }}{{ form.otp_code }}
</div>
<div class="login-recovery-toggle-row">
<button
class="btn btn-secondary btn-inline-toggle"
type="button"
data-toggle-target="login-recovery-box"
aria-expanded="{% if show_recovery_code %}true{% else %}false{% endif %}"
>
{% trans "Recovery-Code verwenden" %}
</button>
</div>
<div class="login-recovery-box{% if not show_recovery_code %} is-hidden{% endif %}" id="login-recovery-box">
<div class="field{% if form.recovery_code.errors %} has-error{% endif %}">
{{ form.recovery_code.label_tag }}{{ form.recovery_code }}
<div class="mini">{% trans "Nutzen Sie stattdessen einen einmaligen Recovery-Code." %}</div>
</div>
</div>
<button class="btn btn-primary" type="submit">{% trans "Code prüfen" %}</button>
<a class="login-back-link" href="/accounts/login/">{% trans "Zurück zur Anmeldung" %}</a>
{% else %}
<div class="field{% if form.username.errors or form.errors %} has-error{% endif %}">{{ form.username.label_tag }}{{ form.username }}</div>
<div class="field{% if form.password.errors or form.errors %} has-error{% endif %}">{{ form.password.label_tag }}{{ form.password }}</div>
<div class="field{% if form.otp_code.errors %} has-error{% endif %}">
{{ form.otp_code.label_tag }}{{ form.otp_code }}
<div class="mini">{% trans "Nur erforderlich, wenn TOTP für Ihr Konto aktiviert ist." %}</div>
</div>
<div class="field{% if form.recovery_code.errors %} has-error{% endif %}">
{{ form.recovery_code.label_tag }}{{ form.recovery_code }}
<div class="mini">{% trans "Alternativ können Sie einen einmaligen Recovery-Code verwenden." %}</div>
</div>
<button class="btn btn-primary" type="submit">{% trans "Anmelden" %}</button>
{% endif %}
</form>
</div>
</section>
<script>
document.querySelectorAll('[data-toggle-target]').forEach((trigger) => {
trigger.addEventListener('click', () => {
const target = document.getElementById(trigger.getAttribute('data-toggle-target'));
if (!target) return;
const hidden = target.classList.toggle('is-hidden');
trigger.setAttribute('aria-expanded', hidden ? 'false' : 'true');
});
});
</script>
{% endblock %}

View File

@@ -20,6 +20,58 @@
<a class="btn btn-secondary" href="/">{% trans "Zur Startseite" %}</a>
{% endif %}
{% if request.user.is_authenticated %}
<details class="app-notification-menu">
<summary class="app-notification-trigger" aria-label="{% trans 'Benachrichtigungen' %}">
<span class="app-notification-bell" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" focusable="false" aria-hidden="true">
<path d="M12 4.25a4.25 4.25 0 0 0-4.25 4.25v2.04c0 .83-.24 1.65-.69 2.35l-1.2 1.88a1.5 1.5 0 0 0 1.27 2.31h9.74a1.5 1.5 0 0 0 1.27-2.31l-1.2-1.88a4.34 4.34 0 0 1-.69-2.35V8.5A4.25 4.25 0 0 0 12 4.25Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.75 18.25a2.25 2.25 0 0 0 4.5 0" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
{% if header_unread_notification_count %}
<span class="app-notification-count">{{ header_unread_notification_count }}</span>
{% endif %}
</summary>
<div class="app-notification-panel">
<div class="app-notification-panel-head">
<strong>{% trans "Benachrichtigungen" %}</strong>
{% if header_unread_notification_count %}
<form method="post" action="{% url 'mark_all_notifications_read' %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
<button type="submit">{% trans "Alle als gelesen" %}</button>
</form>
{% endif %}
</div>
{% if header_notifications %}
<div class="app-notification-list">
{% for notification in header_notifications %}
<article class="app-notification-item app-notification-{{ notification.level }}{% if notification.is_unread %} is-unread{% endif %}">
<div class="app-notification-copy">
<strong>{{ notification.title }}</strong>
{% if notification.body %}<p>{{ notification.body|truncatechars:140 }}</p>{% endif %}
<span>{{ notification.created_at|date:"d.m.Y H:i" }}</span>
</div>
<div class="app-notification-actions">
{% if notification.link_url %}
<a href="{{ notification.link_url }}">{% trans "Öffnen" %}</a>
{% endif %}
{% if notification.is_unread %}
<form method="post" action="{% url 'mark_notification_read' notification.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}" />
<button type="submit">{% trans "Gelesen" %}</button>
</form>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="app-notification-empty">{% trans "Keine Benachrichtigungen vorhanden." %}</div>
{% endif %}
</div>
</details>
<details class="app-user-menu">
<summary class="app-user-trigger">
<span class="app-user-avatar" aria-hidden="true">

View File

@@ -3,6 +3,7 @@ from django.test import Client, TestCase
from django.utils import timezone
from workflows.models import UserProfile
from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role
from workflows.totp import generate_totp_token
@@ -32,6 +33,55 @@ class AccountUISmokeTests(TestCase):
def test_user_profile_is_created_automatically(self):
self.assertTrue(UserProfile.objects.filter(user=self.user).exists())
def test_notification_preferences_can_be_updated(self):
response = self.client.post(
'/account/',
{
'account_form': 'notification_preferences',
'onboarding_success': 'on',
'onboarding_failure': '',
'offboarding_success': '',
'offboarding_failure': 'on',
},
HTTP_HOST='localhost',
follow=True,
)
self.assertEqual(response.status_code, 200)
profile = UserProfile.objects.get(user=self.user)
self.assertEqual(
profile.notification_preferences,
{
'onboarding_success': True,
'onboarding_failure': False,
'offboarding_success': False,
'offboarding_failure': True,
'backup_success': True,
'backup_failure': True,
'welcome_email_success': False,
'welcome_email_failure': False,
'trial_alerts': True,
'system_alerts': True,
},
)
def test_staff_account_notifications_hide_admin_only_categories(self):
response = self.client.get('/account/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'Backup erfolgreich')
self.assertNotContains(response, 'Trial-Hinweise')
self.assertNotContains(response, 'System-Hinweise')
self.assertContains(response, 'Welcome E-Mail erfolgreich')
def test_platform_owner_sees_all_notification_categories(self):
assign_user_role(self.user, ROLE_PLATFORM_OWNER)
response = self.client.get('/account/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Backup erfolgreich')
self.assertContains(response, 'Trial-Hinweise')
self.assertContains(response, 'System-Hinweise')
def test_account_profile_details_can_be_updated(self):
response = self.client.post(
'/account/',
@@ -106,23 +156,26 @@ class AccountUISmokeTests(TestCase):
{'username': 'profile-user', 'password': 'secret-12345'},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/accounts/login/totp/')
response = client.get('/accounts/login/totp/', HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'TOTP-Code')
self.assertContains(response, 'Recovery-Code verwenden')
token = generate_totp_token(profile.totp_secret, int(timezone.now().timestamp()))
response = client.post(
'/accounts/login/',
{'username': 'profile-user', 'password': 'secret-12345', 'otp_code': token},
'/accounts/login/totp/',
{'otp_code': token},
HTTP_HOST='localhost',
)
self.assertEqual(response.status_code, 302)
client = Client()
response = client.post(
'/accounts/login/',
{'username': 'profile-user', 'password': 'secret-12345', 'recovery_code': 'ABCDE-12345'},
HTTP_HOST='localhost',
)
first_step = client.post('/accounts/login/', {'username': 'profile-user', 'password': 'secret-12345'}, HTTP_HOST='localhost')
self.assertEqual(first_step.status_code, 302)
response = client.post('/accounts/login/totp/', {'recovery_code': 'ABCDE-12345'}, HTTP_HOST='localhost')
self.assertEqual(response.status_code, 302)
profile.refresh_from_db()
self.assertEqual(profile.totp_recovery_codes, [])

View File

@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.utils import timezone
from workflows.branding import get_company_email_domain
from workflows.models import EmployeeProfile, NotificationTemplate, OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail
from workflows.tasks import process_onboarding_request, send_scheduled_welcome_email
@@ -13,11 +14,12 @@ from workflows.tasks import process_onboarding_request, send_scheduled_welcome_e
@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs'))
class BilingualSmokeTests(TestCase):
def setUp(self):
self.company_domain = get_company_email_domain()
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='bilingual_user',
password='secret123',
email='requester@tub.co',
email=f'requester@{self.company_domain}',
first_name='Mia',
last_name='Beispiel',
)
@@ -28,7 +30,7 @@ class BilingualSmokeTests(TestCase):
last_name='Beispiel',
department='IT-Service',
job_title='Engineer',
work_email='lara.beispiel@tub.co',
work_email=f'lara.beispiel@{self.company_domain}',
)
@patch('workflows.views.process_onboarding_request.delay')
@@ -39,7 +41,7 @@ class BilingualSmokeTests(TestCase):
'gender': 'herr',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': 'max.mustermann@tub.co',
'work_email': f'max.mustermann@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
@@ -54,7 +56,7 @@ class BilingualSmokeTests(TestCase):
response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost', HTTP_ACCEPT_LANGUAGE='en')
self.assertEqual(response.status_code, 302)
obj = OnboardingRequest.objects.get(work_email='max.mustermann@tub.co')
obj = OnboardingRequest.objects.get(work_email=f'max.mustermann@{self.company_domain}')
self.assertEqual(obj.preferred_language, 'en')
mock_delay.assert_called_once_with(obj.id)
@@ -66,7 +68,7 @@ class BilingualSmokeTests(TestCase):
'gender': 'frau',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': 'erika.muster@tub.co',
'work_email': f'erika.muster@{self.company_domain}',
'contract_start': '2026-11-02',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
@@ -81,7 +83,7 @@ class BilingualSmokeTests(TestCase):
response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost', HTTP_ACCEPT_LANGUAGE='de')
self.assertEqual(response.status_code, 302)
obj = OnboardingRequest.objects.get(work_email='erika.muster@tub.co')
obj = OnboardingRequest.objects.get(work_email=f'erika.muster@{self.company_domain}')
self.assertEqual(obj.preferred_language, 'de')
mock_delay.assert_called_once_with(obj.id)
@@ -140,10 +142,10 @@ class BilingualSmokeTests(TestCase):
gender='herr',
job_title='Engineer',
department='IT-Service',
work_email='english.person@tub.co',
work_email=f'english.person@{self.company_domain}',
contract_start=date(2026, 11, 1),
employment_type='unbefristet',
onboarded_by_email='requester@tub.co',
onboarded_by_email=f'requester@{self.company_domain}',
onboarded_by_name='Mia Beispiel',
agreement='accepted',
preferred_language='en',
@@ -172,16 +174,16 @@ class BilingualSmokeTests(TestCase):
gender='frau',
job_title='Manager',
department='IT-Service',
work_email='welcome.person@tub.co',
work_email=f'welcome.person@{self.company_domain}',
contract_start=date(2026, 11, 1),
employment_type='unbefristet',
onboarded_by_email='requester@tub.co',
onboarded_by_email=f'requester@{self.company_domain}',
agreement='accepted',
preferred_language='en',
)
scheduled = ScheduledWelcomeEmail.objects.create(
onboarding_request=onboarding,
recipient_email='welcome.person@tub.co',
recipient_email=f'welcome.person@{self.company_domain}',
send_at=timezone.now(),
status='scheduled',
)

View File

@@ -0,0 +1,352 @@
from pathlib import Path
from datetime import date
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from workflows.models import OffboardingRequest, OnboardingRequest, ScheduledWelcomeEmail, UserNotification, UserProfile
from workflows.roles import ROLE_PLATFORM_OWNER, assign_user_role
from workflows.tasks import process_offboarding_request, process_onboarding_request, send_scheduled_welcome_email
@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs'))
class NotificationFlowTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.requester = user_model.objects.create_user(
username='notify_user',
password='secret123',
email='requester@workdock.de',
first_name='Nina',
last_name='Requester',
)
@patch('workflows.tasks._apply_notification_rules')
@patch('workflows.tasks._schedule_welcome_email')
@patch('workflows.tasks.upload_to_nextcloud')
@patch('workflows.tasks._send_templated_email')
@patch('workflows.tasks._generate_onboarding_pdf')
def test_onboarding_success_creates_success_notification(
self,
mock_generate_pdf,
mock_send_templated_email,
mock_upload,
mock_schedule,
mock_rules,
):
pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Nina_Notify.pdf')
pdf_path.parent.mkdir(parents=True, exist_ok=True)
pdf_path.write_bytes(b'%PDF-1.4\n%test\n')
mock_generate_pdf.return_value = pdf_path
request_obj = OnboardingRequest.objects.create(
full_name='Nina Notify',
gender='frau',
job_title='Engineer',
department='IT',
work_email='nina.notify@workdock.de',
contract_start=date(2026, 11, 1),
employment_type='unbefristet',
onboarded_by_email=self.requester.email,
onboarded_by_name='Nina Requester',
agreement='accepted',
)
process_onboarding_request(request_obj.id)
notification = UserNotification.objects.get(user=self.requester)
self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS)
self.assertIn('Onboarding abgeschlossen', notification.title)
self.assertEqual(notification.link_url, '/requests/')
mock_upload.assert_called_once_with(pdf_path, pdf_path.name)
@patch('workflows.tasks._generate_onboarding_pdf', side_effect=RuntimeError('PDF kaputt'))
def test_onboarding_failure_creates_error_notification(self, mock_generate_pdf):
request_obj = OnboardingRequest.objects.create(
full_name='Lara Broken',
gender='frau',
job_title='Engineer',
department='IT',
work_email='lara.broken@workdock.de',
contract_start=date(2026, 11, 1),
employment_type='unbefristet',
onboarded_by_email=self.requester.email,
onboarded_by_name='Nina Requester',
agreement='accepted',
)
with self.assertRaises(RuntimeError):
process_onboarding_request(request_obj.id)
notification = UserNotification.objects.get(user=self.requester)
self.assertEqual(notification.level, UserNotification.LEVEL_ERROR)
self.assertIn('Onboarding fehlgeschlagen', notification.title)
self.assertIn('PDF kaputt', notification.body)
@patch('workflows.tasks._apply_notification_rules')
@patch('workflows.tasks.upload_to_nextcloud')
@patch('workflows.tasks._send_templated_email')
@patch('workflows.tasks._generate_offboarding_pdf')
def test_offboarding_success_creates_success_notification(
self,
mock_generate_pdf,
mock_send_templated_email,
mock_upload,
mock_rules,
):
pdf_path = Path('/tmp/onoff_test_pdfs/offboarding_letter_Nina_Notify.pdf')
pdf_path.parent.mkdir(parents=True, exist_ok=True)
pdf_path.write_bytes(b'%PDF-1.4\n%test\n')
mock_generate_pdf.return_value = pdf_path
request_obj = OffboardingRequest.objects.create(
full_name='Nina Notify',
work_email='nina.notify@workdock.de',
department='IT',
job_title='Engineer',
last_working_day=date(2026, 12, 31),
requested_by_email=self.requester.email,
requested_by_name='Nina Requester',
)
process_offboarding_request(request_obj.id)
notification = UserNotification.objects.get(user=self.requester)
self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS)
self.assertIn('Offboarding abgeschlossen', notification.title)
self.assertEqual(notification.link_url, '/requests/')
mock_upload.assert_called_once_with(pdf_path, pdf_path.name)
@patch('workflows.tasks._apply_notification_rules')
@patch('workflows.tasks._schedule_welcome_email')
@patch('workflows.tasks.upload_to_nextcloud')
@patch('workflows.tasks._send_templated_email')
@patch('workflows.tasks._generate_onboarding_pdf')
def test_onboarding_success_notification_respects_user_preferences(
self,
mock_generate_pdf,
mock_send_templated_email,
mock_upload,
mock_schedule,
mock_rules,
):
profile, _ = UserProfile.objects.get_or_create(user=self.requester)
profile.notification_preferences = {
'onboarding_success': False,
'onboarding_failure': True,
'offboarding_success': True,
'offboarding_failure': True,
}
profile.save(update_fields=['notification_preferences', 'updated_at'])
pdf_path = Path('/tmp/onoff_test_pdfs/onboarding_letter_Pref_Off.pdf')
pdf_path.parent.mkdir(parents=True, exist_ok=True)
pdf_path.write_bytes(b'%PDF-1.4\n%test\n')
mock_generate_pdf.return_value = pdf_path
request_obj = OnboardingRequest.objects.create(
full_name='Pref Off',
gender='frau',
job_title='Engineer',
department='IT',
work_email='pref.off@workdock.de',
contract_start=date(2026, 11, 1),
employment_type='unbefristet',
onboarded_by_email=self.requester.email,
onboarded_by_name='Nina Requester',
agreement='accepted',
)
process_onboarding_request(request_obj.id)
self.assertFalse(UserNotification.objects.filter(user=self.requester).exists())
class NotificationHeaderTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='notify_header',
password='secret123',
email='notify.header@workdock.de',
)
self.client.force_login(self.user)
def test_mark_notification_read_marks_single_entry(self):
notification = UserNotification.objects.create(
user=self.user,
title='Backup fehlgeschlagen',
body='Bitte prüfen.',
level=UserNotification.LEVEL_ERROR,
link_url='/requests/',
)
response = self.client.post(
reverse('mark_notification_read', args=[notification.id]),
{'next': '/'},
HTTP_HOST='localhost',
)
notification.refresh_from_db()
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/')
self.assertIsNotNone(notification.read_at)
def test_mark_all_notifications_read_marks_unread_items(self):
first = UserNotification.objects.create(user=self.user, title='Erfolg', level=UserNotification.LEVEL_SUCCESS)
second = UserNotification.objects.create(user=self.user, title='Fehler', level=UserNotification.LEVEL_ERROR)
response = self.client.post(
reverse('mark_all_notifications_read'),
{'next': '/requests/'},
HTTP_HOST='localhost',
)
first.refresh_from_db()
second.refresh_from_db()
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], '/requests/')
self.assertIsNotNone(first.read_at)
self.assertIsNotNone(second.read_at)
class OperationalNotificationTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='ops_notify',
password='secret123',
email='ops.notify@workdock.de',
)
assign_user_role(self.user, ROLE_PLATFORM_OWNER)
self.client = Client(HTTP_HOST='localhost')
self.client.force_login(self.user)
@patch('workflows.views.create_backup_bundle')
def test_backup_success_creates_notification(self, mock_create_backup_bundle):
mock_create_backup_bundle.return_value = {'name': 'backup_20260327_101010', 'path': '/tmp/backup'}
response = self.client.post(reverse('create_backup_from_admin'))
self.assertEqual(response.status_code, 302)
notification = UserNotification.objects.get(user=self.user)
self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS)
self.assertIn('Backup erstellt', notification.title)
def test_backup_success_respects_preferences(self):
profile = UserProfile.objects.get(user=self.user)
profile.notification_preferences = {
'onboarding_success': True,
'onboarding_failure': True,
'offboarding_success': True,
'offboarding_failure': True,
'backup_success': False,
'backup_failure': True,
'trial_alerts': True,
'system_alerts': True,
}
profile.save(update_fields=['notification_preferences', 'updated_at'])
with patch('workflows.views.create_backup_bundle', return_value={'name': 'backup_20260327_111111', 'path': '/tmp/backup'}):
response = self.client.post(reverse('create_backup_from_admin'))
self.assertEqual(response.status_code, 302)
self.assertFalse(UserNotification.objects.filter(user=self.user).exists())
def test_trial_warning_creates_notification(self):
response = self.client.post(
reverse('save_portal_trial_config'),
{
'is_trial_mode': 'on',
'trial_started_at': '2026-03-27T10:00',
'trial_expires_at': '2026-03-30T10:00',
'restrict_production_integrations': 'on',
'auto_cleanup_enabled': 'on',
'trial_banner_text': 'Trial läuft',
'trial_banner_text_en': 'Trial running',
},
)
self.assertEqual(response.status_code, 200)
notification = UserNotification.objects.get(user=self.user)
self.assertEqual(notification.level, UserNotification.LEVEL_WARNING)
self.assertIn('Trial läuft bald ab', notification.title)
@override_settings(PDF_OUTPUT_DIR=Path('/tmp/onoff_test_pdfs'))
class WelcomeEmailNotificationTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.requester = user_model.objects.create_user(
username='welcome_notify_user',
password='secret123',
email='welcome.requester@workdock.de',
)
self.onboarding = OnboardingRequest.objects.create(
full_name='Welcome Notify',
gender='frau',
job_title='Engineer',
department='IT',
work_email='welcome.notify@workdock.de',
contract_start=date(2026, 11, 1),
employment_type='unbefristet',
onboarded_by_email=self.requester.email,
onboarded_by_name='Welcome Requester',
agreement='accepted',
)
@patch('workflows.tasks._send_templated_email')
def test_welcome_email_success_creates_notification(self, mock_send_templated_email):
scheduled = ScheduledWelcomeEmail.objects.create(
onboarding_request=self.onboarding,
recipient_email='welcome.notify@workdock.de',
send_at=timezone.now() - timezone.timedelta(minutes=1),
status='scheduled',
)
send_scheduled_welcome_email(scheduled.id, True)
notification = UserNotification.objects.get(user=self.requester)
self.assertEqual(notification.level, UserNotification.LEVEL_SUCCESS)
self.assertIn('Welcome E-Mail gesendet', notification.title)
@patch('workflows.tasks._send_templated_email', side_effect=RuntimeError('SMTP broken'))
def test_welcome_email_failure_creates_notification(self, mock_send_templated_email):
scheduled = ScheduledWelcomeEmail.objects.create(
onboarding_request=self.onboarding,
recipient_email='welcome.notify@workdock.de',
send_at=timezone.now() - timezone.timedelta(minutes=1),
status='scheduled',
)
with self.assertRaises(RuntimeError):
send_scheduled_welcome_email(scheduled.id, True)
notification = UserNotification.objects.get(user=self.requester)
self.assertEqual(notification.level, UserNotification.LEVEL_ERROR)
self.assertIn('Welcome E-Mail fehlgeschlagen', notification.title)
@patch('workflows.tasks._send_templated_email')
def test_welcome_email_success_respects_preferences(self, mock_send_templated_email):
profile, _ = UserProfile.objects.get_or_create(user=self.requester)
profile.notification_preferences = {
'onboarding_success': True,
'onboarding_failure': True,
'offboarding_success': True,
'offboarding_failure': True,
'backup_success': True,
'backup_failure': True,
'welcome_email_success': False,
'welcome_email_failure': True,
'trial_alerts': True,
'system_alerts': True,
}
profile.save(update_fields=['notification_preferences', 'updated_at'])
scheduled = ScheduledWelcomeEmail.objects.create(
onboarding_request=self.onboarding,
recipient_email='welcome.notify@workdock.de',
send_at=timezone.now() - timezone.timedelta(minutes=1),
status='scheduled',
)
send_scheduled_welcome_email(scheduled.id, True)
self.assertFalse(UserNotification.objects.filter(user=self.requester).exists())

View File

@@ -3,16 +3,18 @@ from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from workflows.branding import get_company_email_domain
from workflows.models import EmployeeProfile, OffboardingRequest
class OffboardingFlowTests(TestCase):
def setUp(self):
self.company_domain = get_company_email_domain()
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='offboard_user',
password='secret123',
email='operator@tub.co',
email=f'operator@{self.company_domain}',
first_name='Nina',
last_name='Admin',
)
@@ -23,7 +25,7 @@ class OffboardingFlowTests(TestCase):
last_name='Beispiel',
department='IT-Service',
job_title='Engineer',
work_email='lara.beispiel@tub.co',
work_email=f'lara.beispiel@{self.company_domain}',
)
def test_offboarding_prefill_from_profile(self):
@@ -32,7 +34,7 @@ class OffboardingFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertIn('value="Lara Beispiel"', html)
self.assertIn('value="lara.beispiel@tub.co"', html)
self.assertIn(f'value="lara.beispiel@{self.company_domain}"', html)
self.assertIn('value="Engineer"', html)
@patch('workflows.views.process_offboarding_request.delay')
@@ -54,6 +56,6 @@ class OffboardingFlowTests(TestCase):
self.assertEqual(response.status_code, 302)
obj = OffboardingRequest.objects.get(work_email=self.profile.work_email)
self.assertEqual(obj.requested_by_email, 'operator@tub.co')
self.assertEqual(obj.requested_by_email, f'operator@{self.company_domain}')
self.assertEqual(obj.requested_by_name, 'Nina Admin')
mock_delay.assert_called_once_with(obj.id)

View File

@@ -3,16 +3,18 @@ from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from workflows.models import OnboardingRequest
from workflows.branding import get_company_email_domain
from workflows.models import FormFieldConfig, OnboardingRequest
class OnboardingFlowTests(TestCase):
def setUp(self):
self.company_domain = get_company_email_domain()
user_model = get_user_model()
self.user = user_model.objects.create_user(
username='onboard_user',
password='secret123',
email='requester@workdock.de',
email=f'requester@{self.company_domain}',
first_name='Mia',
last_name='Beispiel',
)
@@ -26,7 +28,7 @@ class OnboardingFlowTests(TestCase):
'gender': 'herr',
'job_title': 'Consultant',
'department': 'IT-Service',
'work_email': 'max.mustermann@workdock.de',
'work_email': f'max.mustermann@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
@@ -43,8 +45,70 @@ class OnboardingFlowTests(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn('/onboarding/new/?saved=1&id=', response['Location'])
obj = OnboardingRequest.objects.get(work_email='max.mustermann@workdock.de')
obj = OnboardingRequest.objects.get(work_email=f'max.mustermann@{self.company_domain}')
self.assertEqual(obj.full_name, 'Max Mustermann')
self.assertEqual(obj.onboarded_by_email, 'requester@workdock.de')
self.assertEqual(obj.onboarded_by_email, f'requester@{self.company_domain}')
self.assertEqual(obj.onboarded_by_name, 'Mia Beispiel')
mock_delay.assert_called_once_with(obj.id)
@patch('workflows.views.process_onboarding_request.delay')
def test_hidden_non_locked_field_does_not_block_submission(self, mock_delay):
FormFieldConfig.objects.update_or_create(
form_type='onboarding',
field_name='department',
defaults={'is_visible': False},
)
payload = {
'first_name': 'Nora',
'last_name': 'Neutral',
'gender': 'frau',
'job_title': 'Consultant',
'work_email': f'nora.neutral@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
'additional_hardware_needed_choice': 'nein',
'additional_software_needed_choice': 'nein',
'additional_access_needed_choice': 'nein',
'successor_required_choice': 'nein',
'inherit_phone_number_choice': 'nein',
'agreement_confirm': 'on',
}
response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost')
self.assertEqual(response.status_code, 302)
obj = OnboardingRequest.objects.get(work_email=f'nora.neutral@{self.company_domain}')
self.assertEqual(obj.department, '')
mock_delay.assert_called_once_with(obj.id)
@patch('workflows.views.process_onboarding_request.delay')
def test_required_override_blocks_submission_when_field_is_missing(self, mock_delay):
FormFieldConfig.objects.update_or_create(
form_type='onboarding',
field_name='job_title',
defaults={'is_required': True},
)
payload = {
'first_name': 'Lina',
'last_name': 'Leer',
'gender': 'frau',
'department': 'IT-Service',
'work_email': f'lina.leer@{self.company_domain}',
'contract_start': '2026-11-01',
'employment_type': 'unbefristet',
'group_mailboxes_required_choice': 'nein',
'additional_hardware_needed_choice': 'nein',
'additional_software_needed_choice': 'nein',
'additional_access_needed_choice': 'nein',
'successor_required_choice': 'nein',
'inherit_phone_number_choice': 'nein',
'agreement_confirm': 'on',
}
response = self.client.post('/onboarding/new/', payload, HTTP_HOST='localhost')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Dieses Feld ist zwingend erforderlich.')
self.assertFalse(OnboardingRequest.objects.filter(work_email=f'lina.leer@{self.company_domain}').exists())
mock_delay.assert_not_called()

View File

@@ -6,6 +6,8 @@ urlpatterns = [
path('healthz/', views.healthz, name='healthz'),
path('', views.home, name='home'),
path('account/', views.account_profile_page, name='account_profile_page'),
path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),
path('notifications/read-all/', views.mark_all_notifications_read, name='mark_all_notifications_read'),
path('requests/', views.requests_dashboard, name='requests_dashboard'),
path('onboarding/new/', views.onboarding_create, name='onboarding_create'),
path('onboarding/success/<int:request_id>/', views.onboarding_success, name='onboarding_success'),

View File

@@ -12,7 +12,7 @@ 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 import get_user_model
from django.contrib.auth import get_user_model, login as auth_login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.tokens import default_token_generator
from django.http import JsonResponse
@@ -34,7 +34,7 @@ from .backup_ops import (
verify_backup_bundle,
)
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates, get_portal_trial_config, is_trial_expired
from .forms import AccountAvatarForm, AccountDetailsForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .forms import AccountAvatarForm, AccountDetailsForm, AccountNotificationPreferencesForm, AccountTOTPDisableForm, AccountTOTPEnableForm, AccountTOTPRegenerateRecoveryCodesForm, AppLoginForm, AppTOTPChallengeForm, OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES,
@@ -43,8 +43,9 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER,
ensure_form_field_configs,
)
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserProfile, WorkflowConfig
from .models import AdminAuditLog, AsyncTaskLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, UserNotification, UserProfile, WorkflowConfig
from .emailing import send_system_email
from .notifications import notify_user
from .roles import ROLE_GROUP_NAMES, ROLE_LABELS, ROLE_PLATFORM_OWNER, 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 .totp import build_otpauth_uri, generate_recovery_codes, generate_totp_secret
@@ -67,6 +68,21 @@ def _redirect_back(request, fallback: str):
return redirect(referer)
return redirect(fallback)
@login_required
@require_POST
def mark_notification_read(request, notification_id: int):
notification = get_object_or_404(UserNotification, id=notification_id, user=request.user)
notification.mark_read()
return _redirect_back(request, 'home')
@login_required
@require_POST
def mark_all_notifications_read(request):
UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now())
return _redirect_back(request, 'home')
ONBOARDING_GROUPS = {
'business-card-box': ['business_card_name', 'business_card_title', 'business_card_email', 'business_card_phone'],
'employment-end-box': ['employment_end_date'],
@@ -128,6 +144,85 @@ def healthz(request):
)
def login_page(request):
if getattr(request.user, 'is_authenticated', False):
return redirect('home')
next_target = (request.POST.get('next') or request.GET.get('next') or '').strip()
form = AppLoginForm(request=request, data=request.POST or None)
if request.method == 'POST' and form.is_valid():
user = form.get_user()
profile, _ = UserProfile.objects.get_or_create(user=user)
safe_next = next_target if next_target.startswith('/') else ''
if profile.totp_enabled:
request.session['login_pending_user_id'] = user.pk
request.session['login_pending_backend'] = getattr(user, 'backend', '')
request.session['login_pending_next'] = safe_next
return redirect('login_totp')
auth_login(request, user, backend=getattr(user, 'backend', None))
now_ts = int(timezone.now().timestamp())
request.session['auth_fresh_ts'] = now_ts
request.session['last_activity_ts'] = now_ts
return redirect(safe_next or reverse('home'))
request.session.pop('login_pending_user_id', None)
request.session.pop('login_pending_backend', None)
request.session.pop('login_pending_next', None)
return render(
request,
'workflows/auth/login.html',
{
'form': form,
'next': next_target,
'login_step': 'password',
},
)
def login_totp_page(request):
if getattr(request.user, 'is_authenticated', False):
return redirect('home')
pending_user_id = request.session.get('login_pending_user_id')
backend_path = (request.session.get('login_pending_backend') or '').strip()
next_target = (request.session.get('login_pending_next') or '').strip()
if not pending_user_id:
return redirect('login')
user = get_object_or_404(get_user_model(), pk=pending_user_id)
profile, _ = UserProfile.objects.get_or_create(user=user)
if not profile.totp_enabled:
request.session.pop('login_pending_user_id', None)
request.session.pop('login_pending_backend', None)
request.session.pop('login_pending_next', None)
return redirect('login')
show_recovery = request.method == 'POST' and bool((request.POST.get('recovery_code') or '').strip())
form = AppTOTPChallengeForm(data=request.POST or None, profile=profile)
if request.method == 'POST' and form.is_valid():
auth_login(request, user, backend=backend_path or 'django.contrib.auth.backends.ModelBackend')
request.session.pop('login_pending_user_id', None)
request.session.pop('login_pending_backend', None)
request.session.pop('login_pending_next', None)
now_ts = int(timezone.now().timestamp())
request.session['auth_fresh_ts'] = now_ts
request.session['last_activity_ts'] = now_ts
return redirect(next_target if next_target.startswith('/') else reverse('home'))
return render(
request,
'workflows/auth/login.html',
{
'form': form,
'next': next_target,
'login_step': 'totp',
'login_totp_user': user,
'show_recovery_code': show_recovery,
},
)
@login_required
def account_profile_page(request):
session_secret_key = 'account_totp_pending_secret'
@@ -144,10 +239,12 @@ def account_profile_page(request):
avatar_form = AccountAvatarForm(instance=profile)
details_form = AccountDetailsForm(user=request.user, profile=profile)
notification_preferences_form = AccountNotificationPreferencesForm(profile=profile, user=request.user)
totp_enable_form = AccountTOTPEnableForm(user=request.user, secret=pending_totp_secret)
totp_disable_form = AccountTOTPDisableForm(user=request.user, profile=profile)
totp_regenerate_form = AccountTOTPRegenerateRecoveryCodesForm(user=request.user, profile=profile)
account_edit_open = False
notifications_edit_open = False
totp_edit_open = False
if request.method == 'POST':
form_kind = (request.POST.get('account_form') or '').strip()
@@ -166,6 +263,14 @@ def account_profile_page(request):
messages.success(request, _('Profildaten gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Profildaten konnten nicht gespeichert werden.'))
elif form_kind == 'notification_preferences':
notifications_edit_open = True
notification_preferences_form = AccountNotificationPreferencesForm(request.POST, profile=profile, user=request.user)
if notification_preferences_form.is_valid():
notification_preferences_form.save()
messages.success(request, _('Benachrichtigungseinstellungen gespeichert.'))
return redirect('account_profile_page')
messages.error(request, _('Benachrichtigungseinstellungen konnten nicht gespeichert werden.'))
elif form_kind == 'totp_enable':
totp_edit_open = True
totp_enable_form = AccountTOTPEnableForm(request.POST, user=request.user, secret=pending_totp_secret)
@@ -222,10 +327,13 @@ def account_profile_page(request):
'account_user_profile': profile,
'avatar_form': avatar_form,
'details_form': details_form,
'notification_preferences_form': notification_preferences_form,
'notification_preference_groups': notification_preferences_form.grouped_fields(),
'totp_enable_form': totp_enable_form,
'totp_disable_form': totp_disable_form,
'totp_regenerate_form': totp_regenerate_form,
'account_edit_open': account_edit_open,
'notifications_edit_open': notifications_edit_open,
'totp_edit_open': totp_edit_open,
'role_label': get_user_role_label(request.user),
'totp_pending_secret': pending_totp_secret,
@@ -757,7 +865,7 @@ def _build_branding_sections(form, branding):
'fields': ['portal_title', 'company_name', 'company_domain', 'default_language', 'login_subtitle'],
'field_full': {'login_subtitle'},
'hint_map': {
'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. tub.co.'),
'company_domain': _('Wird für E-Mail-Vorschläge und Domain-bezogene Standardtexte verwendet, z. B. workdock.de.'),
},
},
{
@@ -973,6 +1081,35 @@ def save_portal_trial_config(request):
'auto_cleanup_enabled': trial_config.auto_cleanup_enabled,
},
)
if trial_config.is_trial_mode and trial_config.trial_expires_at:
remaining = trial_config.trial_expires_at - timezone.now()
if remaining.total_seconds() <= 0:
notify_user(
user=request.user,
title=_('Trial ist abgelaufen'),
body=_('Der Trial-Zeitraum ist überschritten. Nicht-Platform-Owner werden jetzt blockiert.'),
level=UserNotification.LEVEL_WARNING,
link_url='/admin-tools/trial/',
event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS,
)
elif remaining <= timedelta(days=7):
notify_user(
user=request.user,
title=_('Trial läuft bald ab'),
body=_('Der Trial endet am %(date)s.') % {'date': timezone.localtime(trial_config.trial_expires_at).strftime('%d.%m.%Y %H:%M')},
level=UserNotification.LEVEL_WARNING,
link_url='/admin-tools/trial/',
event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS,
)
elif not trial_config.is_trial_mode:
notify_user(
user=request.user,
title=_('Trial-Modus deaktiviert'),
body=_('Der Trial-Modus wurde ausgeschaltet.'),
level=UserNotification.LEVEL_INFO,
link_url='/admin-tools/trial/',
event_key=UserProfile.NOTIFICATION_TRIAL_ALERTS,
)
messages.success(request, _('Trial-Konfiguration wurde gespeichert.'))
return render(
request,
@@ -1196,8 +1333,24 @@ def create_backup_from_admin(request):
target_label=result['name'],
details={'path': result['path']},
)
notify_user(
user=request.user,
title=_('Backup erstellt: %(name)s') % {'name': result['name']},
body=_('Das Backup-Bundle wurde erfolgreich erstellt.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_SUCCESS,
)
messages.success(request, _('Backup wurde erstellt: %(name)s') % {'name': result['name']})
except Exception as exc:
notify_user(
user=request.user,
title=_('Backup fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_FAILURE,
)
messages.error(request, _('Backup konnte nicht erstellt werden: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')
@@ -1214,8 +1367,24 @@ def verify_backup_from_admin(request, backup_name: str):
target_label=backup_name,
details={'summary': result['summary']},
)
notify_user(
user=request.user,
title=_('Backup verifiziert: %(name)s') % {'name': result['name']},
body=result.get('summary') or _('Das Backup wurde erfolgreich verifiziert.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_SUCCESS,
)
messages.success(request, _('Backup wurde verifiziert: %(name)s') % {'name': result['name']})
except Exception as exc:
notify_user(
user=request.user,
title=_('Backup-Verifikation fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/backups/',
event_key=UserProfile.NOTIFICATION_BACKUP_FAILURE,
)
messages.error(request, _('Backup-Verifikation fehlgeschlagen: %(error)s') % {'error': exc})
return redirect('backup_recovery_page')
@@ -2492,17 +2661,36 @@ def form_builder_save_order(request):
def send_test_email(request):
mode = 'TEST_MODE_ON' if is_email_test_mode() else 'TEST_MODE_OFF'
redirect_email = get_email_test_redirect()
send_system_email(
subject=f'SMTP test from onboarding/offboarding v2 ({mode})',
body=(
'This is a test email. If you see this, SMTP is configured correctly.\n'
f'EMAIL_TEST_MODE={is_email_test_mode()}\n'
f'EMAIL_TEST_REDIRECT={redirect_email}\n'
),
to=[settings.TEST_NOTIFICATION_EMAIL],
)
_audit(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()})
messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).')
try:
send_system_email(
subject=f'SMTP test from onboarding/offboarding v2 ({mode})',
body=(
'This is a test email. If you see this, SMTP is configured correctly.\n'
f'EMAIL_TEST_MODE={is_email_test_mode()}\n'
f'EMAIL_TEST_REDIRECT={redirect_email}\n'
),
to=[settings.TEST_NOTIFICATION_EMAIL],
)
_audit(request, 'smtp_test_sent', target_type='system_email', target_label=settings.TEST_NOTIFICATION_EMAIL, details={'email_test_mode': is_email_test_mode()})
notify_user(
user=request.user,
title=_('SMTP-Test erfolgreich'),
body=_('Die SMTP-Testmail wurde erfolgreich gesendet.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.success(request, f'SMTP-Testmail wurde gesendet ({mode}).')
except Exception as exc:
notify_user(
user=request.user,
title=_('SMTP-Test fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.error(request, _('SMTP-Testmail konnte nicht gesendet werden: %(error)s') % {'error': exc})
return _redirect_back(request, 'home')
@@ -2525,11 +2713,35 @@ def nextcloud_test_upload(request):
ok = upload_to_nextcloud(temp_path, filename)
if ok:
_audit(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'success'})
notify_user(
user=request.user,
title=_('Nextcloud-Test erfolgreich'),
body=_('Der Testupload nach Nextcloud war erfolgreich.'),
level=UserNotification.LEVEL_SUCCESS,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.success(request, f'Nextcloud-Testupload erfolgreich: {filename}')
else:
_audit(request, 'nextcloud_test_upload', target_type='nextcloud', target_label=filename, details={'result': 'error'})
notify_user(
user=request.user,
title=_('Nextcloud-Test fehlgeschlagen'),
body=_('Der Testupload nach Nextcloud ist fehlgeschlagen.'),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.error(request, 'Nextcloud-Testupload fehlgeschlagen. Bitte Konfiguration prüfen.')
except Exception as exc:
notify_user(
user=request.user,
title=_('Nextcloud-Test fehlgeschlagen'),
body=str(exc),
level=UserNotification.LEVEL_ERROR,
link_url='/admin-tools/integrations/',
event_key=UserProfile.NOTIFICATION_SYSTEM_ALERTS,
)
messages.error(request, f'Nextcloud-Testupload fehlgeschlagen: {exc}')
finally:
if temp_path and temp_path.exists():