snapshot: preserve trial lifecycle and product-grade expiry UX
This commit is contained in:
@@ -44,6 +44,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
'workflows.middleware.TrialModeMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'config.urls'
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ from django.conf import settings
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .emailing import send_system_email
|
from .emailing import send_system_email
|
||||||
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EmployeeProfile)
|
@admin.register(EmployeeProfile)
|
||||||
@@ -30,6 +30,11 @@ class PortalCompanyConfigAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('name', 'legal_company_name', 'website_url', 'hr_contact_email', 'it_contact_email', 'updated_at')
|
list_display = ('name', 'legal_company_name', 'website_url', 'hr_contact_email', 'it_contact_email', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PortalTrialConfig)
|
||||||
|
class PortalTrialConfigAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'is_trial_mode', 'trial_expires_at', 'restrict_production_integrations', 'auto_cleanup_enabled', 'updated_at')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PortalAppConfig)
|
@admin.register(PortalAppConfig)
|
||||||
class PortalAppConfigAdmin(admin.ModelAdmin):
|
class PortalAppConfigAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
|
|||||||
action_label=_('Öffnen'),
|
action_label=_('Öffnen'),
|
||||||
capability='manage_company_config',
|
capability='manage_company_config',
|
||||||
),
|
),
|
||||||
|
AppDefinition(
|
||||||
|
key='trial_management',
|
||||||
|
section=PortalAppConfig.SECTION_PLATFORM,
|
||||||
|
route_name='portal_trial_config_page',
|
||||||
|
title=_('Trial Management'),
|
||||||
|
description=_('Testlaufzeit, Banner und sichere Einschränkungen für Demo-Umgebungen steuern.'),
|
||||||
|
action_label=_('Öffnen'),
|
||||||
|
capability='manage_trial_lifecycle',
|
||||||
|
),
|
||||||
AppDefinition(
|
AppDefinition(
|
||||||
key='branding',
|
key='branding',
|
||||||
section=PortalAppConfig.SECTION_PLATFORM,
|
section=PortalAppConfig.SECTION_PLATFORM,
|
||||||
@@ -200,6 +209,12 @@ DEFAULT_ROLE_VISIBILITY = {
|
|||||||
ROLE_IT_STAFF: False,
|
ROLE_IT_STAFF: False,
|
||||||
ROLE_STAFF: False,
|
ROLE_STAFF: False,
|
||||||
},
|
},
|
||||||
|
'trial_management': {
|
||||||
|
ROLE_SUPER_ADMIN: False,
|
||||||
|
ROLE_ADMIN: False,
|
||||||
|
ROLE_IT_STAFF: False,
|
||||||
|
ROLE_STAFF: False,
|
||||||
|
},
|
||||||
'app_registry': {
|
'app_registry': {
|
||||||
ROLE_SUPER_ADMIN: False,
|
ROLE_SUPER_ADMIN: False,
|
||||||
ROLE_ADMIN: False,
|
ROLE_ADMIN: False,
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from email.utils import formataddr
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
from .models import PortalBranding, PortalCompanyConfig
|
from .models import PortalBranding, PortalCompanyConfig, PortalTrialConfig
|
||||||
|
|
||||||
|
|
||||||
def get_portal_branding() -> PortalBranding:
|
def get_portal_branding() -> PortalBranding:
|
||||||
@@ -52,6 +53,58 @@ def get_portal_company_config() -> PortalCompanyConfig:
|
|||||||
return company_config
|
return company_config
|
||||||
|
|
||||||
|
|
||||||
|
def get_portal_trial_config() -> PortalTrialConfig:
|
||||||
|
trial_config, _ = PortalTrialConfig.objects.get_or_create(
|
||||||
|
name='Default',
|
||||||
|
defaults={
|
||||||
|
'is_trial_mode': False,
|
||||||
|
'restrict_production_integrations': True,
|
||||||
|
'auto_cleanup_enabled': True,
|
||||||
|
'trial_banner_text': '',
|
||||||
|
'trial_banner_text_en': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return trial_config
|
||||||
|
|
||||||
|
|
||||||
|
def is_trial_mode_enabled() -> bool:
|
||||||
|
return bool(get_portal_trial_config().is_trial_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def is_trial_expired() -> bool:
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
if not trial_config.is_trial_mode or not trial_config.trial_expires_at:
|
||||||
|
return False
|
||||||
|
return timezone.now() >= trial_config.trial_expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def should_restrict_trial_integrations() -> bool:
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
return bool(trial_config.is_trial_mode and trial_config.restrict_production_integrations)
|
||||||
|
|
||||||
|
|
||||||
|
def get_trial_context() -> dict[str, object]:
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
lang = (get_language() or 'de').split('-')[0]
|
||||||
|
banner_text = ((trial_config.trial_banner_text_en or '').strip() if lang == 'en' else '') or (trial_config.trial_banner_text or '').strip()
|
||||||
|
expired = is_trial_expired()
|
||||||
|
days_remaining = None
|
||||||
|
if trial_config.is_trial_mode and trial_config.trial_expires_at:
|
||||||
|
delta = timezone.localtime(trial_config.trial_expires_at) - timezone.localtime(timezone.now())
|
||||||
|
days_remaining = max(0, delta.days + (1 if delta.seconds > 0 else 0))
|
||||||
|
return {
|
||||||
|
'portal_trial_config': trial_config,
|
||||||
|
'portal_trial_enabled': bool(trial_config.is_trial_mode),
|
||||||
|
'portal_trial_expired': expired,
|
||||||
|
'portal_trial_started_at': trial_config.trial_started_at,
|
||||||
|
'portal_trial_expires_at': trial_config.trial_expires_at,
|
||||||
|
'portal_trial_days_remaining': days_remaining,
|
||||||
|
'portal_trial_banner_text': banner_text,
|
||||||
|
'portal_trial_restrict_integrations': bool(trial_config.is_trial_mode and trial_config.restrict_production_integrations),
|
||||||
|
'portal_trial_cleanup_enabled': bool(trial_config.auto_cleanup_enabled),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_company_email_domain() -> str:
|
def get_company_email_domain() -> str:
|
||||||
branding = get_portal_branding()
|
branding = get_portal_branding()
|
||||||
domain = (branding.company_domain or '').strip().lower().lstrip('@')
|
domain = (branding.company_domain or '').strip().lower().lstrip('@')
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from .branding import get_branding_context
|
from .branding import get_branding_context, get_trial_context
|
||||||
from .roles import template_role_context
|
from .roles import template_role_context
|
||||||
|
|
||||||
|
|
||||||
def role_context(request):
|
def role_context(request):
|
||||||
context = template_role_context(getattr(request, 'user', None))
|
context = template_role_context(getattr(request, 'user', None))
|
||||||
context.update(get_branding_context())
|
context.update(get_branding_context())
|
||||||
|
context.update(get_trial_context())
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.utils.translation import get_language, gettext as _, gettext_lazy
|
|||||||
|
|
||||||
from .branding import get_company_email_domain
|
from .branding import get_company_email_domain
|
||||||
from .form_builder import apply_form_field_config
|
from .form_builder import apply_form_field_config
|
||||||
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, WorkflowConfig
|
from .models import EmployeeProfile, FormOption, OffboardingRequest, OnboardingRequest, PortalBranding, PortalCompanyConfig, PortalTrialConfig, 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
|
||||||
|
|
||||||
|
|
||||||
@@ -281,6 +281,51 @@ class PortalCompanyConfigForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PortalTrialConfigForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = PortalTrialConfig
|
||||||
|
fields = [
|
||||||
|
'is_trial_mode',
|
||||||
|
'trial_started_at',
|
||||||
|
'trial_expires_at',
|
||||||
|
'restrict_production_integrations',
|
||||||
|
'auto_cleanup_enabled',
|
||||||
|
'trial_banner_text',
|
||||||
|
'trial_banner_text_en',
|
||||||
|
]
|
||||||
|
labels = {
|
||||||
|
'is_trial_mode': gettext_lazy('Trial-Modus aktiv'),
|
||||||
|
'trial_started_at': gettext_lazy('Trial-Beginn'),
|
||||||
|
'trial_expires_at': gettext_lazy('Trial-Ende'),
|
||||||
|
'restrict_production_integrations': gettext_lazy('Produktive Integrationen begrenzen'),
|
||||||
|
'auto_cleanup_enabled': gettext_lazy('Cleanup nach Ablauf zulassen'),
|
||||||
|
'trial_banner_text': gettext_lazy('Banner-Text DE'),
|
||||||
|
'trial_banner_text_en': gettext_lazy('Banner-Text EN'),
|
||||||
|
}
|
||||||
|
widgets = {
|
||||||
|
'trial_started_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
|
||||||
|
'trial_expires_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
for field_name in ('trial_started_at', 'trial_expires_at'):
|
||||||
|
field = self.fields[field_name]
|
||||||
|
if self.instance and getattr(self.instance, field_name):
|
||||||
|
field.initial = timezone.localtime(getattr(self.instance, field_name)).strftime('%Y-%m-%dT%H:%M')
|
||||||
|
field.input_formats = ['%Y-%m-%dT%H:%M']
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned = super().clean()
|
||||||
|
started = cleaned.get('trial_started_at')
|
||||||
|
expires = cleaned.get('trial_expires_at')
|
||||||
|
if cleaned.get('is_trial_mode') and not expires:
|
||||||
|
self.add_error('trial_expires_at', _('Bitte ein Trial-Ende festlegen.'))
|
||||||
|
if started and expires and expires <= started:
|
||||||
|
self.add_error('trial_expires_at', _('Das Trial-Ende muss nach dem Trial-Beginn liegen.'))
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
class OnboardingRequestForm(forms.ModelForm):
|
class OnboardingRequestForm(forms.ModelForm):
|
||||||
first_name = forms.CharField(label='Vorname', required=False)
|
first_name = forms.CharField(label='Vorname', required=False)
|
||||||
last_name = forms.CharField(label='Nachname', required=False)
|
last_name = forms.CharField(label='Nachname', required=False)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from workflows.trial import cleanup_trial_workspace_data, trial_cleanup_is_due
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Deletes operational trial data after trial expiry while keeping platform configuration.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--force', action='store_true', help='Run cleanup even if the trial is not due yet.')
|
||||||
|
parser.add_argument('--yes-delete', action='store_true', help='Confirm destructive cleanup.')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not options['yes_delete']:
|
||||||
|
raise CommandError(_('Bitte mit --yes-delete bestätigen.'))
|
||||||
|
if not options['force'] and not trial_cleanup_is_due():
|
||||||
|
raise CommandError(_('Kein abgelaufener Trial mit aktiviertem Cleanup gefunden.'))
|
||||||
|
|
||||||
|
result = cleanup_trial_workspace_data()
|
||||||
|
self.stdout.write(self.style.SUCCESS(_('Trial-Workspace bereinigt.')))
|
||||||
|
for key, value in result.items():
|
||||||
|
self.stdout.write(f'- {key}: {value}')
|
||||||
34
backend/workflows/middleware.py
Normal file
34
backend/workflows/middleware.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from .branding import is_trial_expired, is_trial_mode_enabled
|
||||||
|
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
||||||
|
|
||||||
|
|
||||||
|
class TrialModeMiddleware:
|
||||||
|
EXEMPT_PREFIXES = (
|
||||||
|
'/healthz/',
|
||||||
|
'/i18n/',
|
||||||
|
'/accounts/login/',
|
||||||
|
'/accounts/logout/',
|
||||||
|
'/accounts/password_reset/',
|
||||||
|
'/accounts/reset/',
|
||||||
|
'/static/',
|
||||||
|
'/media/',
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
if not is_trial_mode_enabled() or not is_trial_expired():
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
path = request.path or '/'
|
||||||
|
if any(path.startswith(prefix) for prefix in self.EXEMPT_PREFIXES):
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
if getattr(user, 'is_authenticated', False) and get_user_role_key(user) == ROLE_PLATFORM_OWNER:
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
return render(request, 'workflows/trial_expired.html', status=403)
|
||||||
33
backend/workflows/migrations/0043_portaltrialconfig.py
Normal file
33
backend/workflows/migrations/0043_portaltrialconfig.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.5 on 2026-03-26 13:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('workflows', '0042_portalcompanyconfig'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PortalTrialConfig',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='Default', max_length=80, unique=True)),
|
||||||
|
('is_trial_mode', models.BooleanField(default=False)),
|
||||||
|
('trial_started_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('trial_expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('restrict_production_integrations', models.BooleanField(default=True)),
|
||||||
|
('auto_cleanup_enabled', models.BooleanField(default=True)),
|
||||||
|
('trial_banner_text', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('trial_banner_text_en', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('last_cleanup_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Portal Trial Config',
|
||||||
|
'verbose_name_plural': 'Portal Trial Config',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -98,6 +98,26 @@ class PortalCompanyConfig(models.Model):
|
|||||||
return self.legal_company_name or self.name
|
return self.legal_company_name or self.name
|
||||||
|
|
||||||
|
|
||||||
|
class PortalTrialConfig(models.Model):
|
||||||
|
name = models.CharField(max_length=80, default='Default', unique=True)
|
||||||
|
is_trial_mode = models.BooleanField(default=False)
|
||||||
|
trial_started_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
trial_expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
restrict_production_integrations = models.BooleanField(default=True)
|
||||||
|
auto_cleanup_enabled = models.BooleanField(default=True)
|
||||||
|
trial_banner_text = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
trial_banner_text_en = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
last_cleanup_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Portal Trial Config'
|
||||||
|
verbose_name_plural = 'Portal Trial Config'
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class PortalAppConfig(models.Model):
|
class PortalAppConfig(models.Model):
|
||||||
SECTION_APP = 'app'
|
SECTION_APP = 'app'
|
||||||
SECTION_PLATFORM = 'platform'
|
SECTION_PLATFORM = 'platform'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ CAPABILITIES = {
|
|||||||
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
|
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
|
||||||
'manage_product_branding': {ROLE_PLATFORM_OWNER},
|
'manage_product_branding': {ROLE_PLATFORM_OWNER},
|
||||||
'manage_company_config': {ROLE_PLATFORM_OWNER},
|
'manage_company_config': {ROLE_PLATFORM_OWNER},
|
||||||
|
'manage_trial_lifecycle': {ROLE_PLATFORM_OWNER},
|
||||||
'manage_app_registry': {ROLE_PLATFORM_OWNER},
|
'manage_app_registry': {ROLE_PLATFORM_OWNER},
|
||||||
'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF},
|
'access_requests_dashboard': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF, ROLE_STAFF},
|
||||||
'view_request_timeline': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
|
'view_request_timeline': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN, ROLE_ADMIN, ROLE_IT_STAFF},
|
||||||
@@ -127,6 +128,7 @@ def template_role_context(user) -> dict[str, object]:
|
|||||||
'role_label': str(ROLE_LABELS[role_key]),
|
'role_label': str(ROLE_LABELS[role_key]),
|
||||||
'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'),
|
'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'),
|
||||||
'can_manage_company_config': user_has_capability(user, 'manage_company_config'),
|
'can_manage_company_config': user_has_capability(user, 'manage_company_config'),
|
||||||
|
'can_manage_trial_lifecycle': user_has_capability(user, 'manage_trial_lifecycle'),
|
||||||
'can_manage_app_registry': user_has_capability(user, 'manage_app_registry'),
|
'can_manage_app_registry': user_has_capability(user, 'manage_app_registry'),
|
||||||
'can_manage_users': user_has_capability(user, 'manage_users'),
|
'can_manage_users': user_has_capability(user, 'manage_users'),
|
||||||
'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'),
|
'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .branding import should_restrict_trial_integrations
|
||||||
from .models import WorkflowConfig
|
from .models import WorkflowConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -15,6 +16,8 @@ def _active_workflow_config() -> WorkflowConfig | None:
|
|||||||
|
|
||||||
|
|
||||||
def is_nextcloud_enabled() -> bool:
|
def is_nextcloud_enabled() -> bool:
|
||||||
|
if should_restrict_trial_integrations():
|
||||||
|
return False
|
||||||
config = _active_workflow_config()
|
config = _active_workflow_config()
|
||||||
if config and config.nextcloud_enabled_override is not None:
|
if config and config.nextcloud_enabled_override is not None:
|
||||||
return bool(config.nextcloud_enabled_override)
|
return bool(config.nextcloud_enabled_override)
|
||||||
@@ -22,6 +25,8 @@ def is_nextcloud_enabled() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def is_email_test_mode() -> bool:
|
def is_email_test_mode() -> bool:
|
||||||
|
if should_restrict_trial_integrations():
|
||||||
|
return True
|
||||||
config = _active_workflow_config()
|
config = _active_workflow_config()
|
||||||
if config and config.email_test_mode_override is not None:
|
if config and config.email_test_mode_override is not None:
|
||||||
return bool(config.email_test_mode_override)
|
return bool(config.email_test_mode_override)
|
||||||
|
|||||||
@@ -32,6 +32,82 @@ h1 { margin: 12px 0 6px; color: #000078; }
|
|||||||
.branding-preview-footer-main { color: #20385f; font-size: 11px; font-weight: 700; line-height: 1.35; }
|
.branding-preview-footer-main { color: #20385f; font-size: 11px; font-weight: 700; line-height: 1.35; }
|
||||||
.branding-preview-footer-legal { margin-top: 4px; color: #6c7f99; font-size: 10px; line-height: 1.4; }
|
.branding-preview-footer-legal { margin-top: 4px; color: #6c7f99; font-size: 10px; line-height: 1.4; }
|
||||||
.backup-grid { grid-template-columns: minmax(280px, 720px); }
|
.backup-grid { grid-template-columns: minmax(280px, 720px); }
|
||||||
|
.trial-overview { padding-bottom: 12px; }
|
||||||
|
.trial-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; }
|
||||||
|
.trial-summary-card { border: 1px solid #d9e4f1; border-radius: 14px; background: rgba(255,255,255,0.86); padding: 12px; display: grid; gap: 6px; }
|
||||||
|
.trial-summary-label { color: #60738d; font-size: 12px; font-weight: 700; }
|
||||||
|
.trial-summary-value { color: #17345e; font-size: 16px; line-height: 1.2; }
|
||||||
|
.trial-summary-value.is-active { color: #166534; }
|
||||||
|
.trial-summary-value.is-warn { color: #8a5a00; }
|
||||||
|
.trial-summary-value.is-inactive { color: #7a1f1f; }
|
||||||
|
.trial-summary-value.is-expired { color: #9f1d1d; }
|
||||||
|
.trial-expired-shell { padding: 28px 24px 36px; }
|
||||||
|
.trial-expired-card {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid #e7d1d1;
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(201, 68, 68, 0.12), transparent 24%),
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.99), rgba(255,247,247,0.96));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.94),
|
||||||
|
0 18px 36px rgba(16, 32, 57, 0.08);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.trial-expired-card h1 { margin: 10px 0 8px; color: #7f1d1d; }
|
||||||
|
.trial-expired-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(159, 29, 29, 0.14);
|
||||||
|
background: rgba(255,255,255,0.75);
|
||||||
|
color: #9f1d1d;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.trial-expired-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.trial-expired-panel {
|
||||||
|
border: 1px solid #ead9d9;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255,255,255,0.82);
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.trial-expired-label {
|
||||||
|
color: #8e5a5a;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.trial-expired-panel strong {
|
||||||
|
color: #6f1d1d;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.trial-expired-panel p {
|
||||||
|
margin: 0;
|
||||||
|
color: #805c5c;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.trial-expired-contact {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #7a5252;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; }
|
label { display: block; margin-bottom: 4px; font-size: 12px; color: #334155; font-weight: 700; }
|
||||||
input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); }
|
input, select, textarea { width: 100%; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 9px; background: #fff; transition: border-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 180ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 180ms cubic-bezier(0.2, 0.8, 0.2, 1); }
|
||||||
textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
textarea { min-height: 120px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||||
@@ -111,6 +187,10 @@ th { background: #f6f9ff; color: #334155; }
|
|||||||
.actions { white-space: nowrap; }
|
.actions { white-space: nowrap; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.trial-summary-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.trial-expired-shell { padding: 20px 16px 28px; }
|
||||||
|
.trial-expired-card { padding: 18px; }
|
||||||
|
.trial-expired-grid { grid-template-columns: 1fr; }
|
||||||
.branding-preview-header { flex-direction: column; align-items: flex-start; }
|
.branding-preview-header { flex-direction: column; align-items: flex-start; }
|
||||||
.branding-preview-band { flex-wrap: wrap; }
|
.branding-preview-band { flex-wrap: wrap; }
|
||||||
.app-registry-filters { grid-template-columns: 1fr; }
|
.app-registry-filters { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -29,6 +29,110 @@
|
|||||||
background-color var(--motion-base) var(--motion-ease);
|
background-color var(--motion-base) var(--motion-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-trial-banner {
|
||||||
|
width: min(var(--app-shell-width), 100%);
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-inner {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e9d4a2;
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(255, 206, 112, 0.22), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(255,251,243,0.98), rgba(255,244,222,0.94));
|
||||||
|
color: #875400;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255,255,255,0.88),
|
||||||
|
0 10px 20px rgba(16, 32, 57, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner.is-expired .app-trial-banner-inner {
|
||||||
|
border-color: #efc2c2;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(222, 92, 92, 0.16), transparent 28%),
|
||||||
|
linear-gradient(180deg, rgba(255,248,248,0.98), rgba(255,238,238,0.94));
|
||||||
|
color: #9f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(135, 84, 0, 0.16);
|
||||||
|
background: rgba(255,255,255,0.72);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner.is-expired .app-trial-banner-chip {
|
||||||
|
border-color: rgba(159, 29, 29, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1 1 440px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-title {
|
||||||
|
color: #4b3710;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner.is-expired .app-trial-banner-title {
|
||||||
|
color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-text {
|
||||||
|
color: #7f6540;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner.is-expired .app-trial-banner-text {
|
||||||
|
color: #8f3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-left: 1px solid rgba(135, 84, 0, 0.14);
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner.is-expired .app-trial-banner-meta {
|
||||||
|
border-left-color: rgba(159, 29, 29, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-meta-label {
|
||||||
|
color: #8a6c42;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-trial-banner-meta strong {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header-in-shell {
|
.app-header-in-shell {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -14,6 +14,43 @@
|
|||||||
</head>
|
</head>
|
||||||
<body{% block body_attrs %}{% endblock %}>
|
<body{% block body_attrs %}{% endblock %}>
|
||||||
{% block pre_shell %}{% endblock %}
|
{% block pre_shell %}{% endblock %}
|
||||||
|
{% if portal_trial_enabled %}
|
||||||
|
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
|
||||||
|
<div class="app-trial-banner-inner">
|
||||||
|
<div class="app-trial-banner-chip">
|
||||||
|
{% if portal_trial_expired %}{% trans "Trial abgelaufen" %}{% else %}{% trans "Trial-Modus" %}{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="app-trial-banner-copy">
|
||||||
|
<strong class="app-trial-banner-title">
|
||||||
|
{% if portal_trial_expired %}
|
||||||
|
{% trans "Zugriff für Testnutzer gesperrt" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Kontrollierte Testumgebung aktiv" %}
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
<span class="app-trial-banner-text">
|
||||||
|
{% if portal_trial_banner_text %}
|
||||||
|
{{ portal_trial_banner_text }}
|
||||||
|
{% elif portal_trial_expires_at %}
|
||||||
|
{% if portal_trial_expired %}
|
||||||
|
{% blocktrans with expires=portal_trial_expires_at|date:"d.m.Y H:i" %}Diese Testumgebung ist seit {{ expires }} abgelaufen.{% endblocktrans %}
|
||||||
|
{% else %}
|
||||||
|
{% blocktrans with expires=portal_trial_expires_at|date:"d.m.Y H:i" %}Diese Testumgebung ist bis {{ expires }} aktiv.{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Diese Umgebung läuft im Trial-Modus." %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if portal_trial_expires_at %}
|
||||||
|
<div class="app-trial-banner-meta">
|
||||||
|
<span class="app-trial-banner-meta-label">{% trans "Ende" %}</span>
|
||||||
|
<strong>{{ portal_trial_expires_at|date:"d.m.Y H:i" }}</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="{% block shell_class %}shell{% endblock %}">
|
<div class="{% block shell_class %}shell{% endblock %}">
|
||||||
{% block shell_header %}{% endblock %}
|
{% block shell_header %}{% endblock %}
|
||||||
{% block shell_body %}{% endblock %}
|
{% block shell_body %}{% endblock %}
|
||||||
|
|||||||
@@ -192,6 +192,18 @@ docker compose exec -T web django-admin compilemessages</code></pre>
|
|||||||
<li>Management UI: <code>/admin-tools/apps/</code> for <code>Platform Owner</code>.</li>
|
<li>Management UI: <code>/admin-tools/apps/</code> for <code>Platform Owner</code>.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="trial">10c) Trial Lifecycle</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Deployment-level trial settings are stored in the singleton model <code>PortalTrialConfig</code>.</li>
|
||||||
|
<li>Management UI: <code>/admin-tools/trial/</code> for <code>Platform Owner</code>.</li>
|
||||||
|
<li>Current scope: trial enable/disable, start/end timestamps, bilingual shell banner text, production-integration restriction, and cleanup readiness.</li>
|
||||||
|
<li>Enforcement lives in <code>workflows.middleware.TrialModeMiddleware</code>, so expiry is handled centrally instead of per-view.</li>
|
||||||
|
<li>While trial restriction is active, service-level integration checks force Nextcloud off and E-Mail into test mode.</li>
|
||||||
|
<li>Expired-trial cleanup is intentionally CLI-only:
|
||||||
|
<pre><code>docker compose exec -T web python manage.py cleanup_expired_trial_workspace --yes-delete</code></pre>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<h2 id="builders">11) Builder Architecture</h2>
|
<h2 id="builders">11) Builder Architecture</h2>
|
||||||
<h3>Form Builder</h3>
|
<h3>Form Builder</h3>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -179,7 +179,8 @@
|
|||||||
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
|
<li><strong>Einweisungs-Builder:</strong> manage custom checklist items for the intro PDF and live introduction checklist, including section, visibility, and conditional display logic.</li>
|
||||||
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li>
|
<li><strong>Integrations:</strong> Nextcloud, SMTP, default routing addresses, notification rules, workflow rules, and remote backup target settings.</li>
|
||||||
<li><strong>Branding:</strong> portal title, company name, company domain, support email, sender display name, logo, favicon, default language, PDF letterhead, footer/legal text, and basic brand colors.</li>
|
<li><strong>Branding:</strong> portal title, company name, company domain, support email, sender display name, logo, favicon, default language, PDF letterhead, footer/legal text, and basic brand colors.</li>
|
||||||
<li><strong>App Registry:</strong> platform-level registry for enabling, ordering, and relabeling landing-page apps without editing the home template.</li>
|
<li><strong>App Registry:</strong> platform-level registry for enabling, ordering, relabeling, and role-targeting landing-page apps without editing the home template.</li>
|
||||||
|
<li><strong>Trial Management:</strong> platform-only control surface for trial runtime, expiry, shell banner, and safe demo restrictions.</li>
|
||||||
<li><strong>Benutzer & Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, and deleting accounts when appropriate.</li>
|
<li><strong>Benutzer & Rollen:</strong> super-admin-only page for creating users, assigning roles, activating/deactivating access, sending access or password-reset links by email, and deleting accounts when appropriate.</li>
|
||||||
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
|
<li><strong>Welcome Emails:</strong> scheduled jobs, pause/resume/cancel/trigger now.</li>
|
||||||
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
|
<li><strong>Audit Log:</strong> staff-only trace of important admin changes such as builder edits, settings updates, PDF generation, welcome-email operations, and request deletions. Supports filtering by action, user, and date range.</li>
|
||||||
@@ -210,6 +211,10 @@
|
|||||||
<li>Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.</li>
|
<li>Nextcloud remote backups must use a separate backup directory, not the normal onboarding/offboarding document directory.</li>
|
||||||
<li>Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.</li>
|
<li>Longer-running admin actions such as backup create/verify and integration tests use the same shared progress overlay after confirmation.</li>
|
||||||
<li>Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → <code>Branding</code>.</li>
|
<li>Brand assets such as logo and PDF letterhead are managed separately under Admin Apps → <code>Branding</code>.</li>
|
||||||
|
<li>Trial deployments can force safe integration behavior: Nextcloud is treated as disabled and email remains in test mode while the trial restriction is active.</li>
|
||||||
|
<li>Expired trials should be cleaned with the dedicated command, not from the browser:
|
||||||
|
<pre><code>docker compose exec -T web python manage.py cleanup_expired_trial_workspace --yes-delete</code></pre>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h3>Deployment Notes</h3>
|
<h3>Deployment Notes</h3>
|
||||||
|
|||||||
46
backend/workflows/templates/workflows/trial_expired.html
Normal file
46
backend/workflows/templates/workflows/trial_expired.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends 'workflows/base_shell.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Trial abgelaufen" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_body %}
|
||||||
|
{% include 'workflows/includes/app_header.html' with header_show_home=0 header_show_lang=1 header_inside_shell=1 %}
|
||||||
|
<section class="trial-expired-shell">
|
||||||
|
<div class="trial-expired-card">
|
||||||
|
<div class="trial-expired-badge">{% trans "Trial expired" %}</div>
|
||||||
|
<h1>{% trans "Trial abgelaufen" %}</h1>
|
||||||
|
<p class="sub">{% trans "Diese Testumgebung ist nicht mehr aktiv. Bitte wenden Sie sich für eine Verlängerung oder ein Produktiv-Setup an den Plattformbetreiber." %}</p>
|
||||||
|
|
||||||
|
<div class="trial-expired-grid">
|
||||||
|
<div class="trial-expired-panel">
|
||||||
|
<span class="trial-expired-label">{% trans "Status" %}</span>
|
||||||
|
<strong>{% trans "Zugriff gesperrt" %}</strong>
|
||||||
|
<p>{% trans "Nicht-Platform-Nutzer können diese Umgebung nach Ablauf nicht mehr verwenden." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="trial-expired-panel">
|
||||||
|
<span class="trial-expired-label">{% trans "Nächster Schritt" %}</span>
|
||||||
|
<strong>{% trans "Verlängern oder Produktiv-Setup" %}</strong>
|
||||||
|
<p>{% trans "Ein Platform Owner kann den Trial verlängern oder das Setup in einen regulären Betrieb überführen." %}</p>
|
||||||
|
</div>
|
||||||
|
{% if portal_trial_expires_at %}
|
||||||
|
<div class="trial-expired-panel">
|
||||||
|
<span class="trial-expired-label">{% trans "Ablaufzeit" %}</span>
|
||||||
|
<strong>{{ portal_trial_expires_at|date:"d.m.Y H:i" }}</strong>
|
||||||
|
<p>{% trans "Das ist der im System hinterlegte Endzeitpunkt der Testumgebung." %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-secondary" href="{% url 'login' %}">{% trans "Zur Anmeldung" %}</a>
|
||||||
|
</div>
|
||||||
|
{% if portal_support_email %}
|
||||||
|
<div class="trial-expired-contact">{% blocktrans with email=portal_support_email %}Kontakt: {{ email }}{% endblocktrans %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
127
backend/workflows/templates/workflows/trial_management.html
Normal file
127
backend/workflows/templates/workflows/trial_management.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends 'workflows/base_shell.html' %}
|
||||||
|
{% load static i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans "Trial Management" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{% static 'workflows/css/admin_tools.css' %}" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block shell_body %}
|
||||||
|
{% include 'workflows/includes/app_header.html' with header_show_home=1 header_show_lang=1 header_inside_shell=1 %}
|
||||||
|
<h1>{% trans "Trial Management" %}</h1>
|
||||||
|
<p class="sub">{% trans "Testlaufzeit, Banner und sichere Einschränkungen für Demo- und Pilotumgebungen steuern." %}</p>
|
||||||
|
|
||||||
|
{% include 'workflows/includes/messages.html' %}
|
||||||
|
|
||||||
|
<section class="branding-sections">
|
||||||
|
<section class="branding-block trial-overview">
|
||||||
|
<div class="branding-block-head">
|
||||||
|
<h2>{% trans "Übersicht" %}</h2>
|
||||||
|
<p>{% trans "Aktueller Trial-Status und die daraus resultierende Systemwirkung." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="trial-summary-grid">
|
||||||
|
<div class="trial-summary-card">
|
||||||
|
<span class="trial-summary-label">{% trans "Status" %}</span>
|
||||||
|
<strong class="trial-summary-value {% if portal_trial_enabled %}{% if portal_trial_expired %}is-expired{% else %}is-active{% endif %}{% else %}is-inactive{% endif %}">
|
||||||
|
{% if portal_trial_enabled %}
|
||||||
|
{% if portal_trial_expired %}{% trans "Abgelaufen" %}{% else %}{% trans "Aktiv" %}{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Deaktiviert" %}
|
||||||
|
{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="trial-summary-card">
|
||||||
|
<span class="trial-summary-label">{% trans "Ende" %}</span>
|
||||||
|
<strong class="trial-summary-value">
|
||||||
|
{% if portal_trial_expires_at %}{{ portal_trial_expires_at|date:"d.m.Y H:i" }}{% else %}{% trans "Nicht gesetzt" %}{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="trial-summary-card">
|
||||||
|
<span class="trial-summary-label">{% trans "Nextcloud effektiv" %}</span>
|
||||||
|
<strong class="trial-summary-value {% if portal_trial_restrict_integrations and portal_trial_enabled %}is-inactive{% else %}is-active{% endif %}">
|
||||||
|
{% if portal_trial_restrict_integrations and portal_trial_enabled %}{% trans "Deaktiviert" %}{% else %}{% trans "Unverändert" %}{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="trial-summary-card">
|
||||||
|
<span class="trial-summary-label">{% trans "E-Mail effektiv" %}</span>
|
||||||
|
<strong class="trial-summary-value {% if portal_trial_restrict_integrations and portal_trial_enabled %}is-warn{% else %}is-active{% endif %}">
|
||||||
|
{% if portal_trial_restrict_integrations and portal_trial_enabled %}{% trans "Testmodus" %}{% else %}{% trans "Unverändert" %}{% endif %}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
{% trans "Zum Deaktivieren des Trial-Modus entfernen Sie den Haken bei „Trial-Modus aktiv“ und speichern Sie die Seite." %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'save_portal_trial_config' %}" class="stack-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<section class="branding-block">
|
||||||
|
<div class="branding-block-head">
|
||||||
|
<h2>{% trans "Trial-Status" %}</h2>
|
||||||
|
<p>{% trans "Aktivieren Sie den Trial-Modus und definieren Sie die gültige Laufzeit." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field field-full">
|
||||||
|
<label for="{{ form.is_trial_mode.id_for_label }}">{{ form.is_trial_mode.label }}</label>
|
||||||
|
<div class="check-row">
|
||||||
|
<label>{{ form.is_trial_mode }} {% trans "Diese Deployment-Umgebung als Trial führen" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">{% trans "Sobald dieser Schalter deaktiviert ist, verschwindet das Trial-Banner und die normalen Integrationsregeln greifen wieder." %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="{{ form.trial_started_at.id_for_label }}">{{ form.trial_started_at.label }}</label>
|
||||||
|
{{ form.trial_started_at }}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="{{ form.trial_expires_at.id_for_label }}">{{ form.trial_expires_at.label }}</label>
|
||||||
|
{{ form.trial_expires_at }}
|
||||||
|
{% if trial_is_expired %}<div class="hint">{% trans "Der konfigurierte Trial ist derzeit abgelaufen." %}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="branding-block">
|
||||||
|
<div class="branding-block-head">
|
||||||
|
<h2>{% trans "Sicherheitsregeln" %}</h2>
|
||||||
|
<p>{% trans "Testumgebungen sollen keine produktiven Integrationen verwenden." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="check-row">
|
||||||
|
<label>{{ form.restrict_production_integrations }} {% trans "Nextcloud produktiv deaktivieren und E-Mail-Testmodus erzwingen" %}</label>
|
||||||
|
<label>{{ form.auto_cleanup_enabled }} {% trans "Cleanup nach Ablauf vorbereiten" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="hint">{% trans "Wenn diese Regel aktiv ist, bleiben produktive Integrationen technisch gesperrt, auch wenn lokale Overrides anders gesetzt sind." %}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="branding-block">
|
||||||
|
<div class="branding-block-head">
|
||||||
|
<h2>{% trans "Banner" %}</h2>
|
||||||
|
<p>{% trans "Optionaler Hinweistext für die Shell. Ohne Text wird ein Standardhinweis mit Enddatum verwendet." %}</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid lang-pairs">
|
||||||
|
<div class="lang-block">
|
||||||
|
<h3>{% trans "Deutsch" %}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="{{ form.trial_banner_text.id_for_label }}">{{ form.trial_banner_text.label }}</label>
|
||||||
|
{{ form.trial_banner_text }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lang-block">
|
||||||
|
<h3>{% trans "English" %}</h3>
|
||||||
|
<div class="field">
|
||||||
|
<label for="{{ form.trial_banner_text_en.id_for_label }}">{{ form.trial_banner_text_en.label }}</label>
|
||||||
|
{{ form.trial_banner_text_en }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="toolbar" style="margin-top:1rem;">
|
||||||
|
<div class="hint">{% trans "Die eigentliche Datenbereinigung läuft bewusst nicht über die Web-UI. Nutzen Sie dafür den Cleanup-Command im Betrieb." %}</div>
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Trial-Konfiguration speichern" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
59
backend/workflows/trial.py
Normal file
59
backend/workflows/trial.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.sessions.models import Session
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .branding import get_portal_trial_config, is_trial_expired
|
||||||
|
from .models import AdminAuditLog, EmployeeProfile, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, ScheduledWelcomeEmail
|
||||||
|
from .roles import ROLE_PLATFORM_OWNER, get_user_role_key
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_trial_workspace_data() -> dict[str, int]:
|
||||||
|
user_model = get_user_model()
|
||||||
|
deleted_counts = {
|
||||||
|
'onboarding_requests': OnboardingRequest.objects.count(),
|
||||||
|
'offboarding_requests': OffboardingRequest.objects.count(),
|
||||||
|
'intro_sessions': OnboardingIntroductionSession.objects.count(),
|
||||||
|
'scheduled_welcome_emails': ScheduledWelcomeEmail.objects.count(),
|
||||||
|
'employee_profiles': EmployeeProfile.objects.count(),
|
||||||
|
'audit_logs': AdminAuditLog.objects.count(),
|
||||||
|
'sessions': Session.objects.count(),
|
||||||
|
'users_removed': 0,
|
||||||
|
'media_paths_removed': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
OnboardingIntroductionSession.objects.all().delete()
|
||||||
|
ScheduledWelcomeEmail.objects.all().delete()
|
||||||
|
OnboardingRequest.objects.all().delete()
|
||||||
|
OffboardingRequest.objects.all().delete()
|
||||||
|
EmployeeProfile.objects.all().delete()
|
||||||
|
AdminAuditLog.objects.all().delete()
|
||||||
|
Session.objects.all().delete()
|
||||||
|
|
||||||
|
for user in user_model.objects.all():
|
||||||
|
if get_user_role_key(user) == ROLE_PLATFORM_OWNER:
|
||||||
|
continue
|
||||||
|
user.delete()
|
||||||
|
deleted_counts['users_removed'] += 1
|
||||||
|
|
||||||
|
for path in (settings.MEDIA_ROOT / 'pdfs', settings.MEDIA_ROOT / 'signatures'):
|
||||||
|
candidate = Path(path)
|
||||||
|
if candidate.exists():
|
||||||
|
shutil.rmtree(candidate, ignore_errors=True)
|
||||||
|
candidate.mkdir(parents=True, exist_ok=True)
|
||||||
|
deleted_counts['media_paths_removed'] += 1
|
||||||
|
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
trial_config.last_cleanup_at = timezone.now()
|
||||||
|
trial_config.save(update_fields=['last_cleanup_at', 'updated_at'])
|
||||||
|
return deleted_counts
|
||||||
|
|
||||||
|
|
||||||
|
def trial_cleanup_is_due() -> bool:
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
return bool(trial_config.is_trial_mode and trial_config.auto_cleanup_enabled and is_trial_expired())
|
||||||
@@ -34,6 +34,8 @@ urlpatterns = [
|
|||||||
path('admin-tools/branding/save/', views.save_portal_branding, name='save_portal_branding'),
|
path('admin-tools/branding/save/', views.save_portal_branding, name='save_portal_branding'),
|
||||||
path('admin-tools/company/', views.portal_company_config_page, name='portal_company_config_page'),
|
path('admin-tools/company/', views.portal_company_config_page, name='portal_company_config_page'),
|
||||||
path('admin-tools/company/save/', views.save_portal_company_config, name='save_portal_company_config'),
|
path('admin-tools/company/save/', views.save_portal_company_config, name='save_portal_company_config'),
|
||||||
|
path('admin-tools/trial/', views.portal_trial_config_page, name='portal_trial_config_page'),
|
||||||
|
path('admin-tools/trial/save/', views.save_portal_trial_config, name='save_portal_trial_config'),
|
||||||
path('admin-tools/apps/', views.portal_app_registry_page, name='portal_app_registry_page'),
|
path('admin-tools/apps/', views.portal_app_registry_page, name='portal_app_registry_page'),
|
||||||
path('admin-tools/apps/save/', views.save_portal_app_registry, name='save_portal_app_registry'),
|
path('admin-tools/apps/save/', views.save_portal_app_registry, name='save_portal_app_registry'),
|
||||||
path('admin-tools/users/', views.user_management_page, name='user_management_page'),
|
path('admin-tools/users/', views.user_management_page, name='user_management_page'),
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows
|
from .app_registry import build_portal_app_sections, get_portal_app_registry_rows
|
||||||
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
|
from .backup_ops import create_backup_bundle, delete_backup_bundle, list_backup_bundles, verify_backup_bundle
|
||||||
from .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates
|
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 OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, UserManagementCreateForm
|
from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, PortalTrialConfigForm, UserManagementCreateForm
|
||||||
from .form_builder import (
|
from .form_builder import (
|
||||||
DEFAULT_FIELD_ORDER,
|
DEFAULT_FIELD_ORDER,
|
||||||
LOCKED_FIELD_RULES,
|
LOCKED_FIELD_RULES,
|
||||||
@@ -36,7 +36,7 @@ from .form_builder import (
|
|||||||
ONBOARDING_PAGE_ORDER,
|
ONBOARDING_PAGE_ORDER,
|
||||||
ensure_form_field_configs,
|
ensure_form_field_configs,
|
||||||
)
|
)
|
||||||
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
from .models import AdminAuditLog, EmployeeProfile, FormFieldConfig, FormOption, IntroChecklistItem, NotificationRule, NotificationTemplate, OffboardingRequest, OnboardingIntroductionSession, OnboardingRequest, PortalAppConfig, PortalBranding, PortalCompanyConfig, PortalTrialConfig, ScheduledWelcomeEmail, SystemEmailConfig, WorkflowConfig
|
||||||
from .emailing import send_system_email
|
from .emailing import send_system_email
|
||||||
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 .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 .services import get_email_test_redirect, is_email_test_mode, is_nextcloud_enabled, upload_to_nextcloud
|
||||||
@@ -645,6 +645,66 @@ def save_portal_company_config(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_require_capability('manage_trial_lifecycle')
|
||||||
|
def portal_trial_config_page(request):
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
form = PortalTrialConfigForm(instance=trial_config)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'workflows/trial_management.html',
|
||||||
|
{
|
||||||
|
'form': form,
|
||||||
|
'trial_config': trial_config,
|
||||||
|
'trial_is_expired': is_trial_expired(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_require_capability('manage_trial_lifecycle')
|
||||||
|
@require_POST
|
||||||
|
def save_portal_trial_config(request):
|
||||||
|
trial_config = get_portal_trial_config()
|
||||||
|
form = PortalTrialConfigForm(request.POST, instance=trial_config)
|
||||||
|
if not form.is_valid():
|
||||||
|
messages.error(request, _('Trial-Konfiguration konnte nicht gespeichert werden. Bitte prüfen Sie die Eingaben.'))
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'workflows/trial_management.html',
|
||||||
|
{
|
||||||
|
'form': form,
|
||||||
|
'trial_config': trial_config,
|
||||||
|
'trial_is_expired': is_trial_expired(),
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
trial_config = form.save()
|
||||||
|
_audit(
|
||||||
|
request,
|
||||||
|
'portal_trial_config_saved',
|
||||||
|
target_type='portal_trial_config',
|
||||||
|
target_id=trial_config.id,
|
||||||
|
target_label='Default',
|
||||||
|
details={
|
||||||
|
'is_trial_mode': trial_config.is_trial_mode,
|
||||||
|
'trial_started_at': trial_config.trial_started_at.isoformat() if trial_config.trial_started_at else '',
|
||||||
|
'trial_expires_at': trial_config.trial_expires_at.isoformat() if trial_config.trial_expires_at else '',
|
||||||
|
'restrict_production_integrations': trial_config.restrict_production_integrations,
|
||||||
|
'auto_cleanup_enabled': trial_config.auto_cleanup_enabled,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
messages.success(request, _('Trial-Konfiguration wurde gespeichert.'))
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'workflows/trial_management.html',
|
||||||
|
{
|
||||||
|
'form': PortalTrialConfigForm(instance=trial_config),
|
||||||
|
'trial_config': trial_config,
|
||||||
|
'trial_is_expired': is_trial_expired(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@_require_capability('manage_users')
|
@_require_capability('manage_users')
|
||||||
@require_POST
|
@require_POST
|
||||||
def create_user_from_admin(request):
|
def create_user_from_admin(request):
|
||||||
|
|||||||
Reference in New Issue
Block a user