feat: Implement group-restricted Live Sessions & IP Monitoring and 1-click Admin Email Confirmation
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,15 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||||
from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting
|
from django.contrib.auth.models import Group
|
||||||
|
from .models import User, RegistrationDomainRule, RegistrationRestrictionSetting, UserActivity
|
||||||
|
|
||||||
|
SESSION_MONITOR_GROUP = "Session Monitor"
|
||||||
|
|
||||||
|
# Ensure "Session Monitor" group exists in Django Admin
|
||||||
|
try:
|
||||||
|
Group.objects.get_or_create(name=SESSION_MONITOR_GROUP)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class UserAdmin(BaseUserAdmin):
|
class UserAdmin(BaseUserAdmin):
|
||||||
@@ -18,3 +27,9 @@ class RegistrationDomainRuleAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(RegistrationRestrictionSetting)
|
@admin.register(RegistrationRestrictionSetting)
|
||||||
class RegistrationRestrictionSettingAdmin(admin.ModelAdmin):
|
class RegistrationRestrictionSettingAdmin(admin.ModelAdmin):
|
||||||
list_display = ['is_restriction_enabled']
|
list_display = ['is_restriction_enabled']
|
||||||
|
|
||||||
|
@admin.register(UserActivity)
|
||||||
|
class UserActivityAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['user', 'guest_name', 'ip_address', 'last_path', 'last_seen']
|
||||||
|
list_filter = ['last_seen']
|
||||||
|
search_fields = ['ip_address', 'guest_name', 'user__username', 'user__email', 'user__display_name', 'last_path']
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from .models import UserActivity
|
||||||
|
|
||||||
|
class UserActivityMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
# Record activity for non-static requests
|
||||||
|
path = request.path
|
||||||
|
if not path.startswith('/static/') and not path.startswith('/media/'):
|
||||||
|
try:
|
||||||
|
# Ensure session exists
|
||||||
|
if not request.session.session_key:
|
||||||
|
request.session.create()
|
||||||
|
session_key = request.session.session_key
|
||||||
|
|
||||||
|
# Extract Client IP handling reverse proxies (Traefik / Nginx)
|
||||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
if x_forwarded_for:
|
||||||
|
ip = x_forwarded_for.split(',')[0].strip()
|
||||||
|
else:
|
||||||
|
ip = request.META.get('REMOTE_ADDR', '')
|
||||||
|
|
||||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '')[:245]
|
||||||
|
user = request.user if request.user.is_authenticated else None
|
||||||
|
guest_name = request.session.get('guest_name', '') or request.COOKIES.get('guest_display_name', '')
|
||||||
|
|
||||||
|
if session_key:
|
||||||
|
UserActivity.objects.update_or_create(
|
||||||
|
session_key=session_key,
|
||||||
|
defaults={
|
||||||
|
'user': user,
|
||||||
|
'guest_name': guest_name if not user else '',
|
||||||
|
'ip_address': ip,
|
||||||
|
'user_agent': user_agent,
|
||||||
|
'last_path': path[:245],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-08-01 10:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0006_registrationrestrictionsetting_require_email_verification'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserActivity',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('guest_name', models.CharField(blank=True, max_length=150, null=True)),
|
||||||
|
('session_key', models.CharField(db_index=True, max_length=255)),
|
||||||
|
('ip_address', models.CharField(blank=True, default='', max_length=50)),
|
||||||
|
('user_agent', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('last_path', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('last_seen', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Benutzer-Aktivität / Sitzung',
|
||||||
|
'verbose_name_plural': 'Benutzer-Aktivitäten / Sitzungen',
|
||||||
|
'ordering': ['-last_seen'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -86,3 +86,21 @@ class GuestClaimToken(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Claim Token for {self.guest_name} ({self.token})"
|
return f"Claim Token for {self.guest_name} ({self.token})"
|
||||||
|
|
||||||
|
class UserActivity(models.Model):
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name='activities')
|
||||||
|
guest_name = models.CharField(max_length=150, null=True, blank=True)
|
||||||
|
session_key = models.CharField(max_length=255, db_index=True)
|
||||||
|
ip_address = models.CharField(max_length=50, blank=True, default='')
|
||||||
|
user_agent = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
last_path = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
last_seen = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-last_seen']
|
||||||
|
verbose_name = 'Benutzer-Aktivität / Sitzung'
|
||||||
|
verbose_name_plural = 'Benutzer-Aktivitäten / Sitzungen'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
name = self.user.get_display_name() if self.user else (self.guest_name or "Gast")
|
||||||
|
return f"{name} ({self.ip_address}) - {self.last_seen}"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from .views import (
|
|||||||
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
|
RegistrationDomainRuleViewSet, RegistrationRestrictionSettingView,
|
||||||
PendingUsersView, ApproveUserView, AdminUserViewSet,
|
PendingUsersView, ApproveUserView, AdminUserViewSet,
|
||||||
GenerateClaimLinkView, ClaimInfoView,
|
GenerateClaimLinkView, ClaimInfoView,
|
||||||
SMTPSettingView, TestSMTPEmailView, UserListView
|
SMTPSettingView, TestSMTPEmailView, UserListView, ActiveSessionsView
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
path('login/', LoginView.as_view(), name='login'),
|
path('login/', LoginView.as_view(), name='login'),
|
||||||
path('me/', MeView.as_view(), name='me'),
|
path('me/', MeView.as_view(), name='me'),
|
||||||
path('all/', UserListView.as_view(), name='all-users'),
|
path('all/', UserListView.as_view(), name='all-users'),
|
||||||
|
path('active-sessions/', ActiveSessionsView.as_view(), name='active-sessions'),
|
||||||
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
|
path('restriction-setting/', RegistrationRestrictionSettingView.as_view(), name='restriction-setting'),
|
||||||
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
|
path('smtp-setting/', SMTPSettingView.as_view(), name='smtp-setting'),
|
||||||
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
|
path('smtp-setting/test/', TestSMTPEmailView.as_view(), name='smtp-setting-test'),
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
from rest_framework import views, viewsets, permissions, status
|
from rest_framework import views, viewsets, permissions, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
from django.contrib.auth import authenticate, get_user_model
|
from django.contrib.auth import authenticate, get_user_model
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.core.mail import get_connection, EmailMultiAlternatives
|
from django.core.mail import get_connection, EmailMultiAlternatives
|
||||||
from .models import RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken, EmailVerificationToken, SMTPSetting
|
from .models import (
|
||||||
|
RegistrationDomainRule, RegistrationRestrictionSetting, GuestClaimToken,
|
||||||
|
EmailVerificationToken, SMTPSetting, UserActivity
|
||||||
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
UserSerializer, AdminUserSerializer, RegisterSerializer,
|
UserSerializer, AdminUserSerializer, RegisterSerializer,
|
||||||
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer
|
RegistrationDomainRuleSerializer, RegistrationRestrictionSettingSerializer, SMTPSettingSerializer
|
||||||
@@ -80,6 +84,17 @@ class AdminUserViewSet(viewsets.ModelViewSet):
|
|||||||
user_to_delete.delete()
|
user_to_delete.delete()
|
||||||
return Response({'message': f'Benutzer "{name}" wurde erfolgreich gelöscht.'})
|
return Response({'message': f'Benutzer "{name}" wurde erfolgreich gelöscht.'})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], url_path='confirm-email')
|
||||||
|
def confirm_email(self, request, pk=None):
|
||||||
|
target_user = self.get_object()
|
||||||
|
target_user.is_email_verified = True
|
||||||
|
target_user.is_approved = True
|
||||||
|
target_user.save()
|
||||||
|
EmailVerificationToken.objects.filter(user=target_user).update(is_used=True)
|
||||||
|
return Response({
|
||||||
|
'message': f'E-Mail-Adresse von {target_user.get_display_name()} ({target_user.email}) wurde manuell von dir als bestätigt markiert.'
|
||||||
|
})
|
||||||
|
|
||||||
class RegisterView(views.APIView):
|
class RegisterView(views.APIView):
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
@@ -406,3 +421,54 @@ class UserListView(views.APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
users = User.objects.filter(is_active=True).order_by('username')
|
users = User.objects.filter(is_active=True).order_by('username')
|
||||||
return Response(UserSerializer(users, many=True).data)
|
return Response(UserSerializer(users, many=True).data)
|
||||||
|
|
||||||
|
SESSION_MONITOR_GROUP = "Session Monitor"
|
||||||
|
|
||||||
|
class IsSessionMonitorGroupOrSuperuser(permissions.BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if request.user.is_superuser:
|
||||||
|
return True
|
||||||
|
return request.user.groups.filter(name=SESSION_MONITOR_GROUP).exists()
|
||||||
|
|
||||||
|
class ActiveSessionsView(views.APIView):
|
||||||
|
permission_classes = [IsSessionMonitorGroupOrSuperuser]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
cutoff = timezone.now() - timedelta(hours=24)
|
||||||
|
|
||||||
|
activities = UserActivity.objects.filter(last_seen__gte=cutoff).select_related('user').order_by('-last_seen')
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for act in activities:
|
||||||
|
user_info = None
|
||||||
|
if act.user:
|
||||||
|
user_info = {
|
||||||
|
'id': act.user.id,
|
||||||
|
'username': act.user.username,
|
||||||
|
'email': act.user.email,
|
||||||
|
'display_name': act.user.get_display_name(),
|
||||||
|
'is_admin': act.user.is_admin_user or act.user.is_staff or act.user.is_superuser,
|
||||||
|
}
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'id': act.id,
|
||||||
|
'user': user_info,
|
||||||
|
'guest_name': act.guest_name if not act.user else None,
|
||||||
|
'display_name': act.user.get_display_name() if act.user else (act.guest_name or "Unangemeldeter Gast"),
|
||||||
|
'session_key': act.session_key[:12] + '...',
|
||||||
|
'ip_address': act.ip_address or '127.0.0.1',
|
||||||
|
'user_agent': act.user_agent,
|
||||||
|
'last_path': act.last_path,
|
||||||
|
'last_seen': act.last_seen.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'group_name': SESSION_MONITOR_GROUP,
|
||||||
|
'is_authorized': True,
|
||||||
|
'count': len(data),
|
||||||
|
'sessions': data
|
||||||
|
})
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -71,6 +71,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',
|
||||||
|
'apps.users.middleware.UserActivityMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'shiftplan_backend.urls'
|
ROOT_URLCONF = 'shiftplan_backend.urls'
|
||||||
|
|||||||
Vendored
+2
-2
@@ -11,8 +11,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<title>Schichtplaner — Veranstaltungsschichtpläne</title>
|
<title>Schichtplaner — Veranstaltungsschichtpläne</title>
|
||||||
<script type="module" crossorigin src="/assets/index-AKhcitQN.js"></script>
|
<script type="module" crossorigin src="/assets/index-RWm1c0SS.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DHeBvKbG.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BIEZThrv.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-paper text-main font-sans antialiased min-h-screen">
|
<body class="bg-paper text-main font-sans antialiased min-h-screen">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+478
-378
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user