snapshot: preserve trial lifecycle and product-grade expiry UX

This commit is contained in:
Md Bayazid Bostame
2026-03-26 14:43:10 +01:00
parent 8821a7943b
commit 811bcd8745
24 changed files with 1196 additions and 148 deletions

View File

@@ -3,7 +3,7 @@ from django.conf import settings
from django import forms
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)
@@ -30,6 +30,11 @@ class PortalCompanyConfigAdmin(admin.ModelAdmin):
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)
class PortalAppConfigAdmin(admin.ModelAdmin):
list_display = (

View File

@@ -67,6 +67,15 @@ APP_DEFINITIONS: tuple[AppDefinition, ...] = (
action_label=_('Öffnen'),
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(
key='branding',
section=PortalAppConfig.SECTION_PLATFORM,
@@ -200,6 +209,12 @@ DEFAULT_ROLE_VISIBILITY = {
ROLE_IT_STAFF: False,
ROLE_STAFF: False,
},
'trial_management': {
ROLE_SUPER_ADMIN: False,
ROLE_ADMIN: False,
ROLE_IT_STAFF: False,
ROLE_STAFF: False,
},
'app_registry': {
ROLE_SUPER_ADMIN: False,
ROLE_ADMIN: False,

View File

@@ -5,9 +5,10 @@ from email.utils import formataddr
from django.conf import settings
from django.templatetags.static import static
from django.utils import timezone
from django.utils.translation import get_language
from .models import PortalBranding, PortalCompanyConfig
from .models import PortalBranding, PortalCompanyConfig, PortalTrialConfig
def get_portal_branding() -> PortalBranding:
@@ -52,6 +53,58 @@ def get_portal_company_config() -> PortalCompanyConfig:
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:
branding = get_portal_branding()
domain = (branding.company_domain or '').strip().lower().lstrip('@')

View File

@@ -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
def role_context(request):
context = template_role_context(getattr(request, 'user', None))
context.update(get_branding_context())
context.update(get_trial_context())
return context

View File

@@ -8,7 +8,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, 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
@@ -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):
first_name = forms.CharField(label='Vorname', required=False)
last_name = forms.CharField(label='Nachname', required=False)

View File

@@ -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}')

View 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)

View 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',
},
),
]

View File

@@ -98,6 +98,26 @@ class PortalCompanyConfig(models.Model):
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):
SECTION_APP = 'app'
SECTION_PLATFORM = 'platform'

View File

@@ -30,6 +30,7 @@ CAPABILITIES = {
'manage_users': {ROLE_PLATFORM_OWNER, ROLE_SUPER_ADMIN},
'manage_product_branding': {ROLE_PLATFORM_OWNER},
'manage_company_config': {ROLE_PLATFORM_OWNER},
'manage_trial_lifecycle': {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},
'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]),
'can_manage_product_branding': user_has_capability(user, 'manage_product_branding'),
'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_users': user_has_capability(user, 'manage_users'),
'can_access_requests_dashboard': user_has_capability(user, 'access_requests_dashboard'),

View File

@@ -5,6 +5,7 @@ import time
import requests
from django.conf import settings
from .branding import should_restrict_trial_integrations
from .models import WorkflowConfig
logger = logging.getLogger(__name__)
@@ -15,6 +16,8 @@ def _active_workflow_config() -> WorkflowConfig | None:
def is_nextcloud_enabled() -> bool:
if should_restrict_trial_integrations():
return False
config = _active_workflow_config()
if config and config.nextcloud_enabled_override is not None:
return bool(config.nextcloud_enabled_override)
@@ -22,6 +25,8 @@ def is_nextcloud_enabled() -> bool:
def is_email_test_mode() -> bool:
if should_restrict_trial_integrations():
return True
config = _active_workflow_config()
if config and config.email_test_mode_override is not None:
return bool(config.email_test_mode_override)

View File

@@ -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-legal { margin-top: 4px; color: #6c7f99; font-size: 10px; line-height: 1.4; }
.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; }
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; }
@@ -111,6 +187,10 @@ th { background: #f6f9ff; color: #334155; }
.actions { white-space: nowrap; }
@media (max-width: 760px) {
.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-band { flex-wrap: wrap; }
.app-registry-filters { grid-template-columns: 1fr; }

View File

@@ -29,6 +29,110 @@
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 {
box-sizing: border-box;
width: 100%;

View File

@@ -14,6 +14,43 @@
</head>
<body{% block body_attrs %}{% 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 %}">
{% block shell_header %}{% endblock %}
{% block shell_body %}{% endblock %}

View File

@@ -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>
</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>
<h3>Form Builder</h3>
<ul>

View File

@@ -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>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>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 &amp; 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>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>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>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>
<h3>Deployment Notes</h3>

View 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 %}

View 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 %}

View 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())

View File

@@ -34,6 +34,8 @@ urlpatterns = [
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/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/save/', views.save_portal_app_registry, name='save_portal_app_registry'),
path('admin-tools/users/', views.user_management_page, name='user_management_page'),

View File

@@ -26,8 +26,8 @@ from django.urls import reverse
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 .branding import get_branding_email_copy, get_company_email_domain, get_default_notification_templates
from .forms import OffboardingRequestForm, OnboardingRequestForm, PortalBrandingForm, PortalCompanyConfigForm, UserManagementCreateForm
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, PortalTrialConfigForm, UserManagementCreateForm
from .form_builder import (
DEFAULT_FIELD_ORDER,
LOCKED_FIELD_RULES,
@@ -36,7 +36,7 @@ from .form_builder import (
ONBOARDING_PAGE_ORDER,
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 .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
@@ -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_POST
def create_user_from_admin(request):