Skip to content

Commit e3eb610

Browse files
committed
feat(admin): Build real-time user session and status dashboard
1 parent 3939730 commit e3eb610

File tree

7 files changed

+172
-13
lines changed

7 files changed

+172
-13
lines changed

inventory/middleware.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# sherlock-python/inventory/middleware.py
2+
3+
from django.utils import timezone
4+
from .models import UserProfile
5+
6+
class UpdateLastSeenMiddleware:
7+
"""
8+
Custom middleware to update the 'last_seen' timestamp for an
9+
authenticated user with every request they make.
10+
"""
11+
def __init__(self, get_response):
12+
self.get_response = get_response
13+
14+
def __call__(self, request):
15+
response = self.get_response(request)
16+
17+
if request.user.is_authenticated:
18+
UserProfile.objects.filter(user=request.user).update(last_seen=timezone.now())
19+
20+
return response
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.7 on 2025-10-26 09:47
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('inventory', '0014_checkinlog_condition'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='userprofile',
15+
name='last_seen',
16+
field=models.DateTimeField(blank=True, help_text='The last time the user made a request.', null=True),
17+
),
18+
]

inventory/models.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,28 @@ def save(self, *args, **kwargs):
218218
super().save(*args, **kwargs)
219219

220220
class UserProfile(models.Model):
221-
"""Extends the default Django User model to include a role."""
221+
"""Extends the default Django User model to include a role and activity tracking."""
222222
class Role(models.TextChoices):
223223
ADMIN = 'ADMIN', 'Admin'
224224
MEMBER = 'MEMBER', 'Member'
225225

226226
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile')
227227
role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER)
228+
last_seen = models.DateTimeField(null=True, blank=True, help_text="The last time the user made a request.")
228229

229230
def __str__(self):
230231
return f"{self.user.username} - {self.get_role_display()}"
231232

233+
@property
234+
def is_online(self):
235+
"""
236+
Determines if the user is 'online' based on their last seen time.
237+
A user is considered online if they were active in the last 5 minutes.
238+
"""
239+
if not self.last_seen:
240+
return False
241+
return timezone.now() < self.last_seen + timedelta(minutes=5)
242+
232243
def create_user_profile(sender, instance, created, **kwargs):
233244
if created:
234245
UserProfile.objects.create(user=instance)

inventory/templates/inventory/team_management.html

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,95 @@
11
{% extends "inventory/base.html" %}
2+
23
{% block content %}
3-
<div style="display: flex; justify-content: space-between; align-items: center;">
4+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
45
<h1>Team Management</h1>
5-
<a href="{% url 'inventory:create_user' %}" class="link-button">Add New User</a>
6+
<a href="{% url 'inventory:create_user' %}" class="btn btn-primary">
7+
<i class="fas fa-user-plus"></i> Add New User
8+
</a>
69
</div>
7-
<p>As an Admin, you can manage the roles of other users in the system.</p>
10+
<p>As an Admin, you can manage user roles, view their activity status, and control their access to the system.</p>
811

912
<table class="open-table">
1013
<thead>
11-
<tr><th>Username</th><th>Email</th><th>Current Role</th><th>Change Role</th></tr>
14+
<tr>
15+
<th>Username</th>
16+
<th>Email</th>
17+
<th>Role</th>
18+
<th>Account Status</th>
19+
<th>Activity Status</th>
20+
<th style="text-align: center;">Actions</th>
21+
</tr>
1222
</thead>
1323
<tbody>
1424
{% for profile in users %}
1525
<tr>
26+
<!-- Username -->
1627
<td>{{ profile.user.username }}</td>
17-
<td>{{ profile.user.email }}</td>
18-
<td>{{ profile.get_role_display }}</td>
28+
29+
<!-- Email -->
30+
<td>{{ profile.user.email|default:"Not set" }}</td>
31+
32+
<!-- Role -->
1933
<td>
20-
<form action="{% url 'inventory:update_user_role' profile.id %}" method="post">
34+
<form action="{% url 'inventory:update_user_role' profile.id %}" method="post" style="margin:0;">
2135
{% csrf_token %}
22-
<select name="role" onchange="this.form.submit()">
23-
{% for value, name in form.fields.role.choices %}
36+
<select name="role" onchange="this.form.submit()" class="form-control form-control-sm">
37+
{% for value, name in profile.Role.choices %}
2438
<option value="{{ value }}" {% if profile.role == value %}selected{% endif %}>{{ name }}</option>
2539
{% endfor %}
2640
</select>
2741
</form>
2842
</td>
43+
44+
<!-- Account Status (Active/Suspended) -->
45+
<td>
46+
{% if profile.user.is_active %}
47+
<span class="badge bg-success">Active</span>
48+
{% else %}
49+
<span class="badge bg-secondary">Suspended</span>
50+
{% endif %}
51+
</td>
52+
53+
<!-- Activity Status (Online/Offline) -->
54+
<td>
55+
{% if profile.is_online %}
56+
<span class="badge bg-primary">Online</span>
57+
{% else %}
58+
<span class="badge bg-light text-dark">Offline</span>
59+
{% endif %}
60+
</td>
61+
62+
<!-- Actions -->
63+
<td style="text-align: center; white-space: nowrap;">
64+
<!-- Suspend/Reactivate Button -->
65+
<form action="{% url 'inventory:toggle_user_active' profile.user.id %}" method="post" style="display: inline-block; margin: 0;">
66+
{% csrf_token %}
67+
{% if profile.user.is_active %}
68+
<button type="submit" class="btn btn-warning btn-sm" title="Suspend this user's account">
69+
<i class="fas fa-user-slash"></i> Suspend
70+
</button>
71+
{% else %}
72+
<button type="submit" class="btn btn-success btn-sm" title="Reactivate this user's account">
73+
<i class="fas fa-user-check"></i> Reactivate
74+
</button>
75+
{% endif %}
76+
</form>
77+
78+
<!-- Force Logout Button -->
79+
{% if profile.is_online and profile.user.is_active %}
80+
<form action="{% url 'inventory:force_logout_user' profile.user.id %}" method="post" style="display: inline-block; margin: 0 0 0 5px;">
81+
{% csrf_token %}
82+
<button type="submit" class="btn btn-danger btn-sm" title="Forcibly end all active sessions for this user"
83+
onclick="return confirm('Are you sure you want to force logout {{ profile.user.username }}? They will be immediately signed out of all devices.');">
84+
<i class="fas fa-sign-out-alt"></i> Force Logout
85+
</button>
86+
</form>
87+
{% endif %}
88+
</td>
89+
</tr>
90+
{% empty %}
91+
<tr>
92+
<td colspan="6" style="text-align: center;">There are no other users in the system.</td>
2993
</tr>
3094
{% endfor %}
3195
</tbody>

inventory/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
# ==========================================================================
1212
path('my-profile/', views.my_profile, name='my_profile'),
1313
path('team-management/', views.team_management, name='team_management'),
14-
path('team-management/update-role/<int:user_id>/', views.update_user_role, name='update_user_role'),
1514
path('team-management/create-user/', views.create_user, name='create_user'),
15+
path('team-management/update-role/<int:user_id>/', views.update_user_role, name='update_user_role'),
16+
path('team-management/toggle-active/<int:user_id>/', views.toggle_user_active_status, name='toggle_user_active'),
17+
path('team-management/force-logout/<int:user_id>/', views.force_logout_user, name='force_logout_user'),
1618

1719
# ==========================================================================
1820
# Main Navigation & Dashboards

inventory/views.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from django.db.models import Q, Sum, Max, F, Count
1414
from django.db.models.functions import TruncDay
1515
from django.contrib.auth.models import User
16-
16+
from django.contrib.sessions.models import Session
1717

1818
from .models import Section, Space, Item, PrintQueue, PrintQueueItem, SearchEntry, Student, CheckoutLog, CheckInLog, ItemLog, UserProfile
1919
from .forms import SectionForm, SpaceForm, ItemForm, StudentForm, StockAdjustmentForm, UserUpdateForm, UserRoleForm
@@ -1042,6 +1042,50 @@ def create_user(request):
10421042
context = {'form': form}
10431043
return render(request, 'inventory/create_user.html', context)
10441044

1045+
@login_required
1046+
@admin_required
1047+
def force_logout_user(request, user_id):
1048+
"""
1049+
Finds and deletes all active sessions for a given user,
1050+
effectively logging them out on their next request.
1051+
"""
1052+
if request.method == 'POST':
1053+
user_to_logout = get_object_or_404(User, id=user_id)
1054+
1055+
# Find all session objects associated with this user
1056+
for session in Session.objects.all():
1057+
if session.get_decoded().get('_auth_user_id') == str(user_to_logout.id):
1058+
session.delete()
1059+
1060+
messages.success(request, f"All active sessions for {user_to_logout.username} have been terminated.")
1061+
return redirect('inventory:team_management')
1062+
1063+
@login_required
1064+
@admin_required
1065+
def toggle_user_active_status(request, user_id):
1066+
"""
1067+
Toggles the is_active flag for a user, effectively suspending or
1068+
reactivating their account.
1069+
"""
1070+
if request.method == 'POST':
1071+
user_to_toggle = get_object_or_404(User, id=user_id)
1072+
1073+
# Toggle the is_active status
1074+
user_to_toggle.is_active = not user_to_toggle.is_active
1075+
user_to_toggle.save()
1076+
1077+
status = "reactivated" if user_to_toggle.is_active else "suspended"
1078+
messages.success(request, f"The account for {user_to_toggle.username} has been {status}.")
1079+
1080+
# If we suspend the user, also log them out for immediate effect
1081+
if not user_to_toggle.is_active:
1082+
for session in Session.objects.all():
1083+
if session.get_decoded().get('_auth_user_id') == str(user_to_toggle.id):
1084+
session.delete()
1085+
messages.info(request, f"Active sessions for {user_to_toggle.username} were also terminated.")
1086+
1087+
return redirect('inventory:team_management')
1088+
10451089
def custom_page_not_found_view(request, exception):
10461090
"""
10471091
Custom view to render the 404.html template.

sherlock/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@
8181
'django.middleware.security.SecurityMiddleware',
8282
'whitenoise.middleware.WhiteNoiseMiddleware',
8383
'django.contrib.sessions.middleware.SessionMiddleware',
84+
'inventory.middleware.UpdateLastSeenMiddleware',
8485
'django.middleware.common.CommonMiddleware',
8586
'django.middleware.csrf.CsrfViewMiddleware',
8687
'django.contrib.auth.middleware.AuthenticationMiddleware',
8788
'django.contrib.messages.middleware.MessageMiddleware',
8889
'django.middleware.clickjacking.XFrameOptionsMiddleware',
89-
9090
]
9191

9292
ROOT_URLCONF = 'sherlock.urls'

0 commit comments

Comments
 (0)