snapshot: preserve role-aware notification preferences and operational alerts
This commit is contained in:
@@ -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(),
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
31
backend/workflows/migrations/0051_usernotification.py
Normal file
31
backend/workflows/migrations/0051_usernotification.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
35
backend/workflows/notifications.py
Normal file
35
backend/workflows/notifications.py
Normal 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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
352
backend/workflows/tests/test_notifications.py
Normal file
352
backend/workflows/tests/test_notifications.py
Normal 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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user