Coverage for src/django_audit_log/admin.py: 72%
512 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-02 11:48 +0700
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-02 11:48 +0700
1from django.contrib import admin
2from .models import (
3 AccessLog,
4 LogIpAddress,
5 LogPath,
6 LogSessionKey,
7 LogUser,
8 LogUserAgent,
9)
10from django.db import models
11from django.db.models.functions import Cast
12from django.utils.safestring import mark_safe
13from django.contrib.admin import SimpleListFilter
14from django.utils import timezone
15from datetime import timedelta
16import re
18try:
19 from rangefilter.filters import DateRangeFilter
21 HAS_RANGE_FILTER = True
22except ImportError:
23 HAS_RANGE_FILTER = False
26# Base admin classes
27class ReadOnlyAdmin(admin.ModelAdmin):
28 """Base admin class for read-only models."""
30 def has_add_permission(self, request):
31 return False
33 def has_change_permission(self, request, obj=None):
34 return False
36 def has_delete_permission(self, request, obj=None):
37 return False
40class BrowserTypeFilter(SimpleListFilter):
41 """Filter logs by browser type."""
43 title = "Browser"
44 parameter_name = "browser_type"
46 def lookups(self, request, model_admin):
47 return (
48 ("chrome", "Chrome"),
49 ("firefox", "Firefox"),
50 ("safari", "Safari"),
51 ("edge", "Edge"),
52 ("ie", "Internet Explorer"),
53 ("opera", "Opera"),
54 ("mobile", "Mobile Browsers"),
55 ("bots", "Bots/Crawlers"),
56 ("other", "Other Browsers"),
57 )
59 def queryset(self, request, queryset):
60 if not self.value():
61 return queryset
63 value = self.value()
65 if value == "chrome":
66 return queryset.filter(
67 user_agent_normalized__browser="Chrome"
68 ).exclude(user_agent_normalized__browser="Chromium")
69 elif value == "firefox":
70 return queryset.filter(user_agent_normalized__browser="Firefox")
71 elif value == "safari":
72 return queryset.filter(
73 user_agent_normalized__browser="Safari"
74 ).exclude(user_agent_normalized__browser="Chrome")
75 elif value == "edge":
76 return queryset.filter(user_agent_normalized__browser="Edge")
77 elif value == "ie":
78 return queryset.filter(user_agent_normalized__browser="Internet Explorer")
79 elif value == "opera":
80 return queryset.filter(user_agent_normalized__browser="Opera")
81 elif value == "mobile":
82 return queryset.filter(user_agent_normalized__device_type="Mobile")
83 elif value == "bots":
84 return queryset.filter(user_agent_normalized__is_bot=True)
85 elif value == "other":
86 major_browsers = [
87 "Chrome",
88 "Firefox",
89 "Safari",
90 "Edge",
91 "Internet Explorer",
92 "Opera",
93 ]
94 return queryset.exclude(user_agent_normalized__browser__in=major_browsers)
97class DeviceTypeFilter(SimpleListFilter):
98 """Filter logs by device type."""
100 title = "Device Type"
101 parameter_name = "device_type"
103 def lookups(self, request, model_admin):
104 return (
105 ("desktop", "Desktop"),
106 ("mobile", "Mobile"),
107 ("tablet", "Tablet"),
108 ("bot", "Bot/Crawler"),
109 )
111 def queryset(self, request, queryset):
112 if not self.value():
113 return queryset
115 value = self.value()
116 if value == "mobile":
117 return queryset.filter(user_agent_normalized__device_type="Mobile")
118 elif value == "tablet":
119 return queryset.filter(user_agent_normalized__device_type="Tablet")
120 elif value == "bot":
121 return queryset.filter(user_agent_normalized__is_bot=True)
122 elif value == "desktop":
123 return queryset.filter(
124 user_agent_normalized__device_type="Desktop",
125 user_agent_normalized__is_bot=False,
126 )
129class AccessLogAdmin(ReadOnlyAdmin):
130 """Admin class for AccessLog model."""
132 list_display = (
133 "method",
134 "path",
135 "status_code",
136 "user",
137 "ip",
138 "browser_type",
139 "timestamp",
140 )
141 list_filter = (
142 "method",
143 "status_code",
144 "user",
145 BrowserTypeFilter,
146 DeviceTypeFilter,
147 "timestamp",
148 )
149 search_fields = ("path__path", "user__user_name")
150 date_hierarchy = "timestamp"
151 readonly_fields = (
152 "path",
153 "referrer",
154 "response_url",
155 "method",
156 "data",
157 "status_code",
158 "user_agent",
159 "user_agent_normalized",
160 "normalized_user_agent",
161 "user",
162 "session_key",
163 "ip",
164 "timestamp",
165 )
167 def browser_type(self, obj):
168 """Return a simplified browser type."""
169 if obj.user_agent_normalized:
170 return obj.user_agent_normalized.browser
171 return "Unknown"
173 browser_type.short_description = "Browser"
175 def normalized_user_agent(self, obj):
176 """Show the normalized user agent info."""
177 if not obj.user_agent_normalized:
178 return "No user agent data"
180 ua_info = {
181 "browser": obj.user_agent_normalized.browser,
182 "browser_version": obj.user_agent_normalized.browser_version,
183 "os": obj.user_agent_normalized.operating_system,
184 "os_version": obj.user_agent_normalized.operating_system_version,
185 "device_type": obj.user_agent_normalized.device_type,
186 "is_bot": obj.user_agent_normalized.is_bot,
187 "raw": obj.user_agent or obj.user_agent_normalized.user_agent,
188 }
190 html = f"""
191 <style>
192 .ua-info {{ margin: 10px 0; }}
193 .ua-key {{ font-weight: bold; width: 120px; display: inline-block; }}
194 .ua-browser {{ color: #0066cc; }}
195 .ua-os {{ color: #28a745; }}
196 .ua-device {{ color: #fd7e14; }}
197 .ua-raw {{ margin-top: 15px; font-family: monospace; font-size: 12px;
198 padding: 10px; background-color: #f8f9fa; border-radius: 4px; word-break: break-all; }}
199 </style>
200 <div class="ua-info">
201 <div><span class="ua-key">Browser:</span> <span class="ua-browser">{ua_info['browser']}</span></div>
202 <div><span class="ua-key">Version:</span> {ua_info['browser_version'] or 'Unknown'}</div>
203 <div><span class="ua-key">OS:</span> <span class="ua-os">{ua_info['os']}</span></div>
204 <div><span class="ua-key">OS Version:</span> {ua_info['os_version'] or 'Unknown'}</div>
205 <div><span class="ua-key">Device Type:</span> <span class="ua-device">{ua_info['device_type']}</span></div>
206 <div><span class="ua-key">Is Bot/Crawler:</span> {ua_info['is_bot']}</div>
207 <div class="ua-raw">{ua_info['raw']}</div>
208 </div>
209 """
211 return mark_safe(html)
213 normalized_user_agent.short_description = "Normalized User Agent"
215 def changelist_view(self, request, extra_context=None):
216 """Override to add user agent statistics to the changelist view."""
217 # Only add stats if we're not filtering
218 if len(request.GET) <= 1: # Just the page number or nothing
219 extra_context = extra_context or {}
220 extra_context["user_agent_summary"] = self.get_user_agent_summary()
221 return super().changelist_view(request, extra_context=extra_context)
223 def get_user_agent_summary(self):
224 """Generate a summary of user agent statistics."""
225 from django.db.models import Count
227 # Get normalized user agent data with counts
228 normalized_user_agents = (
229 LogUserAgent.objects.annotate(count=Count("access_logs"))
230 .values("browser", "operating_system", "device_type", "is_bot", "count")
231 .order_by("-count")[:1000]
232 ) # Limit to 1000 most common for performance
234 # Get legacy user agent data (for records with user_agent_normalized=NULL)
235 legacy_user_agents = (
236 AccessLog.objects.filter(
237 user_agent_normalized__isnull=True, user_agent__isnull=False
238 )
239 .exclude(user_agent="")
240 .values_list("user_agent")
241 .annotate(count=Count("user_agent"))
242 .order_by("-count")[:1000]
243 )
245 if not normalized_user_agents and not legacy_user_agents:
246 return "No user agent data available"
248 # Initialize categories
249 categories = {
250 "browsers": {},
251 "operating_systems": {},
252 "device_types": {},
253 "bots": 0,
254 "total": 0,
255 }
257 # Process normalized user agents (more efficient)
258 for agent in normalized_user_agents:
259 count = agent["count"]
260 categories["total"] += count
262 # Add to browser counts
263 browser = agent["browser"] or "Unknown"
264 if browser not in categories["browsers"]:
265 categories["browsers"][browser] = 0
266 categories["browsers"][browser] += count
268 # Add to OS counts
269 os = agent["operating_system"] or "Unknown"
270 if os not in categories["operating_systems"]:
271 categories["operating_systems"][os] = 0
272 categories["operating_systems"][os] += count
274 # Add to device type counts
275 device = agent["device_type"] or "Unknown"
276 if device not in categories["device_types"]:
277 categories["device_types"][device] = 0
278 categories["device_types"][device] += count
280 # Count bots
281 if agent["is_bot"]:
282 categories["bots"] += count
284 # Process legacy user agents
285 if legacy_user_agents:
286 legacy_categories = UserAgentUtil.categorize_user_agents(legacy_user_agents)
288 # Merge legacy data
289 categories["total"] += legacy_categories["total"]
290 categories["bots"] += legacy_categories["bots"]
292 for browser, count in legacy_categories["browsers"].items():
293 if browser not in categories["browsers"]:
294 categories["browsers"][browser] = 0
295 categories["browsers"][browser] += count
297 for os, count in legacy_categories["operating_systems"].items():
298 if os not in categories["operating_systems"]:
299 categories["operating_systems"][os] = 0
300 categories["operating_systems"][os] += count
302 for device, count in legacy_categories["device_types"].items():
303 if device not in categories["device_types"]:
304 categories["device_types"][device] = 0
305 categories["device_types"][device] += count
307 # Create HTML for the statistics
308 style = """
309 <style>
310 .ua-summary { margin: 20px 0; padding: 15px; background-color: #f8f9fa; border-radius: 4px; }
311 .ua-summary h2 { margin-top: 0; color: #333; }
312 .ua-chart { display: flex; flex-wrap: wrap; }
313 .ua-column { flex: 1; min-width: 300px; margin-right: 20px; }
314 .ua-bar { height: 20px; background-color: #4a6785; margin-bottom: 1px; }
315 .ua-bar-container { margin-bottom: 5px; }
316 .ua-bar-label { display: flex; justify-content: space-between; font-size: 12px; }
317 .ua-bar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
318 .ua-bar-value { text-align: right; font-weight: bold; }
319 .ua-category { margin-bottom: 20px; }
320 .ua-category h3 { margin-top: 10px; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 5px; }
321 </style>
322 """
324 html = [
325 style,
326 '<div class="ua-summary"><h2>User Agent Statistics Summary</h2><div class="ua-chart">',
327 ]
329 # Browser statistics column
330 html.append(
331 '<div class="ua-column"><div class="ua-category"><h3>Top Browsers</h3>'
332 )
333 sorted_browsers = sorted(
334 categories["browsers"].items(), key=lambda x: x[1], reverse=True
335 )[:10]
336 for browser, count in sorted_browsers:
337 percentage = (count / categories["total"]) * 100
338 html.append(
339 f"""
340 <div class="ua-bar-container">
341 <div class="ua-bar" style="width: {percentage}%;"></div>
342 <div class="ua-bar-label">
343 <div class="ua-bar-name">{browser}</div>
344 <div class="ua-bar-value">{percentage:.1f}%</div>
345 </div>
346 </div>
347 """
348 )
349 html.append("</div></div>")
351 # OS statistics column
352 html.append(
353 '<div class="ua-column"><div class="ua-category"><h3>Top Operating Systems</h3>'
354 )
355 sorted_os = sorted(
356 categories["operating_systems"].items(), key=lambda x: x[1], reverse=True
357 )[:10]
358 for os, count in sorted_os:
359 percentage = (count / categories["total"]) * 100
360 html.append(
361 f"""
362 <div class="ua-bar-container">
363 <div class="ua-bar" style="width: {percentage}%;"></div>
364 <div class="ua-bar-label">
365 <div class="ua-bar-name">{os}</div>
366 <div class="ua-bar-value">{percentage:.1f}%</div>
367 </div>
368 </div>
369 """
370 )
371 html.append("</div></div>")
373 # Device type statistics column
374 html.append(
375 '<div class="ua-column"><div class="ua-category"><h3>Device Types</h3>'
376 )
377 sorted_devices = sorted(
378 categories["device_types"].items(), key=lambda x: x[1], reverse=True
379 )
380 for device, count in sorted_devices:
381 percentage = (count / categories["total"]) * 100
382 html.append(
383 f"""
384 <div class="ua-bar-container">
385 <div class="ua-bar" style="width: {percentage}%;"></div>
386 <div class="ua-bar-label">
387 <div class="ua-bar-name">{device}</div>
388 <div class="ua-bar-value">{percentage:.1f}%</div>
389 </div>
390 </div>
391 """
392 )
393 html.append("</div></div>")
395 html.append("</div>") # Close chart
397 # Summary stats
398 bot_percentage = (
399 (categories["bots"] / categories["total"]) * 100
400 if categories["bots"] > 0
401 else 0
402 )
403 top_browser = sorted_browsers[0][0] if sorted_browsers else "Unknown"
404 top_browser_pct = (
405 sorted_browsers[0][1] / categories["total"] * 100 if sorted_browsers else 0
406 )
407 top_os = sorted_os[0][0] if sorted_os else "Unknown"
408 top_os_pct = sorted_os[0][1] / categories["total"] * 100 if sorted_os else 0
410 html.append(
411 f"""
412 <div style="margin-top: 15px; font-size: 13px;">
413 <p>Based on {categories['total']} requests •
414 Bot/Crawler traffic: {bot_percentage:.1f}% •
415 Top browser: {top_browser} ({top_browser_pct:.1f}%) •
416 Top OS: {top_os} ({top_os_pct:.1f}%)</p>
417 </div>
418 """
419 )
421 html.append("</div>") # Close summary
423 return mark_safe("".join(html))
426class LogPathAdmin(ReadOnlyAdmin):
427 """Admin class for LogPath model."""
429 list_display = ("path",)
430 search_fields = ("path",)
431 readonly_fields = ("path",)
434class LogSessionKeyAdmin(ReadOnlyAdmin):
435 """Admin class for LogSessionKey model."""
437 list_display = ("key",)
438 search_fields = ("key",)
439 readonly_fields = ("key",)
442class ActivityLevelFilter(SimpleListFilter):
443 """Filter users by their activity level in a time period."""
445 title = "Activity Level"
446 parameter_name = "activity"
448 def lookups(self, request, model_admin):
449 return (
450 ("high", "High (10+ requests)"),
451 ("medium", "Medium (3-9 requests)"),
452 ("low", "Low (1-2 requests)"),
453 ("inactive", "Inactive (no requests)"),
454 ("recent", "Active in last 7 days"),
455 )
457 def queryset(self, request, queryset):
458 if not self.value():
459 return queryset
461 if self.value() == "high":
462 return queryset.annotate(count=models.Count("accesslog")).filter(
463 count__gte=10
464 )
466 if self.value() == "medium":
467 return queryset.annotate(count=models.Count("accesslog")).filter(
468 count__gte=3, count__lte=9
469 )
471 if self.value() == "low":
472 return queryset.annotate(count=models.Count("accesslog")).filter(
473 count__gte=1, count__lte=2
474 )
476 if self.value() == "inactive":
477 return queryset.annotate(count=models.Count("accesslog")).filter(count=0)
479 if self.value() == "recent":
480 seven_days_ago = timezone.now() - timedelta(days=7)
481 return queryset.filter(accesslog__timestamp__gte=seven_days_ago).distinct()
484class MultipleIPFilter(SimpleListFilter):
485 """Filter users who have used multiple IP addresses."""
487 title = "IP Usage"
488 parameter_name = "ip_usage"
490 def lookups(self, request, model_admin):
491 return (
492 ("multiple", "Multiple IPs"),
493 ("single", "Single IP"),
494 )
496 def queryset(self, request, queryset):
497 if not self.value():
498 return queryset
500 if self.value() == "multiple":
501 return queryset.annotate(
502 ip_count=models.Count("accesslog__ip", distinct=True)
503 ).filter(ip_count__gt=1)
505 if self.value() == "single":
506 return queryset.annotate(
507 ip_count=models.Count("accesslog__ip", distinct=True)
508 ).filter(ip_count=1)
511class UserAgentUtil:
512 """Utility class for parsing and normalizing user agents."""
514 # Samsung device model mapping
515 SAMSUNG_DEVICE_MODELS = {
516 'gta4ljt': 'Galaxy Tab A4 Lite',
517 'gta8xx': 'Galaxy Tab A8',
518 'gta9pxxx': 'Galaxy Tab A9+',
519 'gtanotexlltedx': 'Galaxy Note 10.1',
520 'gtaxlltexx': 'Galaxy Tab A 10.1',
521 'gts210ltedx': 'Galaxy Tab S2 9.7 LTE',
522 'gts210ltexx': 'Galaxy Tab S2 9.7 LTE',
523 }
525 # Browser pattern regex
526 BROWSER_PATTERNS = [
527 (r"tl\.eskola\.eskola_app-(\d+\.\d+\.\d+)-release(?:/(\w+))?", "Eskola APK"), # Non-playstore format
528 (r"tl\.eskola\.eskola_app\.playstore-(\d+\.\d+\.\d+)-release(?:/(\w+))?", "Eskola APK"), # Playstore format
529 (r"Chrome/(\d+)", "Chrome"),
530 (r"Firefox/(\d+)", "Firefox"),
531 (r"Safari/(\d+)", "Safari"),
532 (r"Edge/(\d+)", "Edge"),
533 (r"Edg/(\d+)", "Edge"), # New Edge based on Chromium
534 (r"MSIE\s(\d+)", "Internet Explorer"),
535 (r"Trident/.*rv:(\d+)", "Internet Explorer"),
536 (r"OPR/(\d+)", "Opera"),
537 (r"Opera/(\d+)", "Opera"),
538 (r"UCBrowser/(\d+)", "UC Browser"),
539 (r"SamsungBrowser/(\d+)", "Samsung Browser"),
540 (r"YaBrowser/(\d+)", "Yandex Browser"),
541 (r"HeadlessChrome", "Headless Chrome"),
542 (r"Googlebot", "Googlebot"),
543 (r"bingbot", "Bingbot"),
544 (r"DuckDuckBot", "DuckDuckBot"),
545 ]
547 # OS pattern regex
548 OS_PATTERNS = [
549 (r"Windows NT 10\.0", "Windows 10"),
550 (r"Windows NT 6\.3", "Windows 8.1"),
551 (r"Windows NT 6\.2", "Windows 8"),
552 (r"Windows NT 6\.1", "Windows 7"),
553 (r"Windows NT 6\.0", "Windows Vista"),
554 (r"Windows NT 5\.1", "Windows XP"),
555 (r"Windows NT 5\.0", "Windows 2000"),
556 (r"Macintosh.*Mac OS X", "macOS"),
557 (r"Android\s+(\d+)", "Android"),
558 (r"Linux", "Linux"),
559 (r"iPhone.*OS\s+(\d+)", "iOS"),
560 (r"iPad.*OS\s+(\d+)", "iOS"),
561 (r"iPod.*OS\s+(\d+)", "iOS"),
562 (r"CrOS", "Chrome OS"),
563 ]
565 # Device type patterns
566 DEVICE_PATTERNS = [
567 (r"iPhone", "Mobile"),
568 (r"iPod", "Mobile"),
569 (r"iPad", "Tablet"),
570 (r"Android.*Mobile", "Mobile"),
571 (r"Android(?!.*Mobile)", "Tablet"),
572 (r"Mobile", "Mobile"),
573 (r"Tablet", "Tablet"),
574 ]
576 # Bot/crawler patterns
577 BOT_PATTERNS = [
578 (
579 r"bot|crawler|spider|crawl|Googlebot|bingbot|yahoo|slurp|ahref|semrush|baidu|bitdiscovery-suggestions",
580 "Bot/Crawler",
581 ),
582 ]
584 @classmethod
585 def get_device_model_name(cls, device_code):
586 """Get the human-readable device name from a Samsung device code."""
587 return cls.SAMSUNG_DEVICE_MODELS.get(device_code, f"Unknown Samsung Device ({device_code})")
589 @classmethod
590 def normalize_user_agent(cls, user_agent):
591 """
592 Normalize a user agent string to categorize browsers, OS, and device types.
594 Args:
595 user_agent: The raw user agent string
597 Returns:
598 dict: Containing browser, browser_version, os, device_type, is_bot
599 """
600 if not user_agent:
601 return {
602 "browser": "Unknown",
603 "browser_version": None,
604 "os": "Unknown",
605 "os_version": None,
606 "device_type": "Unknown",
607 "is_bot": False,
608 "raw": user_agent,
609 }
611 result = {
612 "browser": "Unknown",
613 "browser_version": None,
614 "os": "Unknown",
615 "os_version": None,
616 "device_type": "Mobile", # Default to Mobile for Eskola APK
617 "is_bot": False,
618 "raw": user_agent,
619 }
621 # Special case for Eskola APK (both formats)
622 eskola_match = re.search(r"tl\.eskola\.eskola_app(?:\.playstore)?-(\d+\.\d+\.\d+)-release(?:/(\w+))?", user_agent)
623 if eskola_match:
624 result["browser"] = "Eskola APK"
625 result["browser_version"] = eskola_match.group(1)
626 result["os"] = "Android"
627 # Try to extract device model if present
628 if eskola_match.group(2):
629 device_code = eskola_match.group(2)
630 device_name = cls.get_device_model_name(device_code)
631 result["os_version"] = f"Device: {device_code} ({device_name})"
632 return result
634 # Check if it's a bot
635 for pattern, _ in cls.BOT_PATTERNS:
636 if re.search(pattern, user_agent, re.IGNORECASE):
637 result["is_bot"] = True
638 result["browser"] = "Bot/Crawler"
639 result["device_type"] = "Bot"
640 break
642 # Detect browser and version
643 for pattern, browser in cls.BROWSER_PATTERNS:
644 match = re.search(pattern, user_agent)
645 if match:
646 result["browser"] = browser
647 # Get version if available
648 if len(match.groups()) > 0 and match.group(1).isdigit():
649 result["browser_version"] = match.group(1)
650 break
652 # Detect OS
653 for pattern, os in cls.OS_PATTERNS:
654 if re.search(pattern, user_agent):
655 result["os"] = os
656 break
658 # Detect device type (only if not already a bot)
659 if not result["is_bot"]:
660 for pattern, device in cls.DEVICE_PATTERNS:
661 if re.search(pattern, user_agent, re.IGNORECASE):
662 result["device_type"] = device
663 break
665 return result
667 @classmethod
668 def categorize_user_agents(cls, user_agents):
669 """
670 Group a list of user agent strings into categories.
672 Args:
673 user_agents: List of (user_agent, count) tuples
675 Returns:
676 dict: Categorized counts by browser, os, and device type
677 """
678 categories = {
679 "browsers": {},
680 "operating_systems": {},
681 "device_types": {},
682 "bots": 0,
683 "total": 0,
684 }
686 for agent, count in user_agents:
687 categories["total"] += count
688 info = cls.normalize_user_agent(agent)
690 # Add to browser counts
691 browser = info["browser"]
692 if browser not in categories["browsers"]:
693 categories["browsers"][browser] = 0
694 categories["browsers"][browser] += count
696 # Add to OS counts
697 os = info["os"]
698 if os not in categories["operating_systems"]:
699 categories["operating_systems"][os] = 0
700 categories["operating_systems"][os] += count
702 # Add to device type counts
703 device = info["device_type"]
704 if device not in categories["device_types"]:
705 categories["device_types"][device] = 0
706 categories["device_types"][device] += count
708 # Count bots
709 if info["is_bot"]:
710 categories["bots"] += count
712 return categories
715class LogUserAdmin(ReadOnlyAdmin):
716 """Admin class for LogUser model."""
718 list_display = (
719 "id",
720 "user_name",
721 "ip_addresses_count",
722 "access_count",
723 "last_active",
724 )
725 search_fields = ("user_name",)
726 readonly_fields = (
727 "id",
728 "user_name",
729 "ip_addresses_used",
730 "url_access_stats",
731 "recent_activity",
732 "user_agent_stats",
733 "distinct_user_agents"
734 )
736 # Set up the list_filter with conditional DateRangeFilter if available
737 if HAS_RANGE_FILTER:
738 list_filter = (
739 ActivityLevelFilter,
740 MultipleIPFilter,
741 ("accesslog__timestamp", DateRangeFilter),
742 )
743 else:
744 list_filter = (ActivityLevelFilter, MultipleIPFilter)
746 def get_queryset(self, request):
747 qs = super().get_queryset(request)
748 qs = qs.annotate(
749 access_count=models.Count("accesslog"),
750 ip_count=models.Count("accesslog__ip", distinct=True),
751 last_activity=models.Max("accesslog__timestamp"),
752 )
753 return qs
755 def access_count(self, obj):
756 return obj.access_count
758 access_count.admin_order_field = "access_count"
759 access_count.short_description = "Total Accesses"
761 def ip_addresses_count(self, obj):
762 return obj.ip_count
764 ip_addresses_count.admin_order_field = "ip_count"
765 ip_addresses_count.short_description = "Unique IPs"
767 def last_active(self, obj):
768 """Return the last activity time for this user."""
769 if hasattr(obj, "last_activity") and obj.last_activity:
770 return obj.last_activity
771 return "Never"
773 last_active.admin_order_field = "last_activity"
774 last_active.short_description = "Last Active"
776 def user_agent_stats(self, obj):
777 """Show user agent statistics for this user with charts."""
778 from django.db.models import Count
780 # Get user agent data with counts using the normalized model
781 user_agents = (
782 AccessLog.objects.filter(user=obj)
783 .exclude(user_agent_normalized__isnull=True)
784 .values(
785 "user_agent_normalized__browser",
786 "user_agent_normalized__operating_system",
787 "user_agent_normalized__device_type",
788 "user_agent_normalized__is_bot",
789 )
790 .annotate(count=Count("id"))
791 .order_by("-count")
792 )
794 if not user_agents:
795 return "No user agent data available"
797 # Initialize categories
798 categories = {
799 "browsers": {},
800 "operating_systems": {},
801 "device_types": {},
802 "bots": 0,
803 "total": 0,
804 }
806 # Process normalized user agents
807 for agent in user_agents:
808 count = agent["count"]
809 categories["total"] += count
811 # Add to browser counts
812 browser = agent["user_agent_normalized__browser"] or "Unknown"
813 if browser not in categories["browsers"]:
814 categories["browsers"][browser] = 0
815 categories["browsers"][browser] += count
817 # Add to OS counts
818 os = agent["user_agent_normalized__operating_system"] or "Unknown"
819 if os not in categories["operating_systems"]:
820 categories["operating_systems"][os] = 0
821 categories["operating_systems"][os] += count
823 # Add to device type counts
824 device = agent["user_agent_normalized__device_type"] or "Unknown"
825 if device not in categories["device_types"]:
826 categories["device_types"][device] = 0
827 categories["device_types"][device] += count
829 # Count bots
830 if agent["user_agent_normalized__is_bot"]:
831 categories["bots"] += count
833 # Create HTML for the statistics
834 style = """
835 <style>
836 .ua-stats { width: 100%; margin-top: 20px; }
837 .ua-stats h3 { margin-top: 20px; color: #333; }
838 .ua-chart { display: flex; margin: 15px 0; }
839 .ua-bar { height: 30px; min-width: 2px; background-color: #4a6785; margin-right: 1px; }
840 .ua-bar-container { display: flex; align-items: center; margin-bottom: 8px; }
841 .ua-bar-label { width: 120px; text-align: right; padding-right: 10px; }
842 .ua-bar-value { margin-left: 10px; font-weight: bold; }
843 .ua-category { margin-bottom: 30px; }
844 .ua-bot-note { margin-top: 15px; font-style: italic; color: #666; }
845 </style>
846 """
848 html = [style, '<div class="ua-stats">']
850 # Browser statistics
851 html.append('<div class="ua-category"><h3>Browsers</h3>')
852 sorted_browsers = sorted(
853 categories["browsers"].items(), key=lambda x: x[1], reverse=True
854 )
855 for browser, count in sorted_browsers:
856 percentage = (count / categories["total"]) * 100
857 html.append(
858 f"""
859 <div class="ua-bar-container">
860 <div class="ua-bar-label">{browser}</div>
861 <div class="ua-bar" style="width: {max(percentage, 2)}%;"></div>
862 <div class="ua-bar-value">{count} ({percentage:.1f}%)</div>
863 </div>
864 """
865 )
866 html.append("</div>")
868 # OS statistics
869 html.append('<div class="ua-category"><h3>Operating Systems</h3>')
870 sorted_os = sorted(
871 categories["operating_systems"].items(), key=lambda x: x[1], reverse=True
872 )
873 for os, count in sorted_os:
874 percentage = (count / categories["total"]) * 100
875 html.append(
876 f"""
877 <div class="ua-bar-container">
878 <div class="ua-bar-label">{os}</div>
879 <div class="ua-bar" style="width: {max(percentage, 2)}%;"></div>
880 <div class="ua-bar-value">{count} ({percentage:.1f}%)</div>
881 </div>
882 """
883 )
884 html.append("</div>")
886 # Device type statistics
887 html.append('<div class="ua-category"><h3>Device Types</h3>')
888 sorted_devices = sorted(
889 categories["device_types"].items(), key=lambda x: x[1], reverse=True
890 )
891 for device, count in sorted_devices:
892 percentage = (count / categories["total"]) * 100
893 html.append(
894 f"""
895 <div class="ua-bar-container">
896 <div class="ua-bar-label">{device}</div>
897 <div class="ua-bar" style="width: {max(percentage, 2)}%;"></div>
898 <div class="ua-bar-value">{count} ({percentage:.1f}%)</div>
899 </div>
900 """
901 )
902 html.append("</div>")
904 # Bot percentage
905 if categories["bots"] > 0:
906 bot_percentage = (categories["bots"] / categories["total"]) * 100
907 html.append(
908 f'<div class="ua-bot-note">Bot/Crawler traffic: {categories["bots"]} requests ({bot_percentage:.1f}%)</div>'
909 )
911 html.append("</div>")
913 return mark_safe("".join(html))
915 user_agent_stats.short_description = "User Agent Statistics"
917 def recent_activity(self, obj):
918 """Show the most recent activity for this user."""
919 recent_logs = AccessLog.objects.filter(user=obj).order_by("-timestamp")[:10]
921 if not recent_logs:
922 return "No recent activity"
924 style = """
925 <style>
926 .activity-list { margin: 10px 0; }
927 .activity-list .timestamp { color: #666; font-size: 0.9em; }
928 .activity-list .method { font-weight: bold; display: inline-block; width: 50px; }
929 .activity-list .method-GET { color: #28a745; }
930 .activity-list .method-POST { color: #007bff; }
931 .activity-list .method-PUT { color: #fd7e14; }
932 .activity-list .method-DELETE { color: #dc3545; }
933 .activity-list .status { font-weight: bold; }
934 .activity-list .status-success { color: #28a745; }
935 .activity-list .status-redirect { color: #fd7e14; }
936 .activity-list .status-error { color: #dc3545; }
937 </style>
938 """
940 html = [style, '<div class="activity-list">']
941 for log in recent_logs:
942 # Determine status class
943 status_class = ""
944 if log.status_code:
945 if 200 <= log.status_code < 300:
946 status_class = "status-success"
947 elif 300 <= log.status_code < 400:
948 status_class = "status-redirect"
949 elif log.status_code >= 400:
950 status_class = "status-error"
952 # Format the log entry
953 status_html = (
954 f'<span class="status {status_class}">[{log.status_code}]</span>'
955 if log.status_code
956 else ""
957 )
958 html.append(
959 f"<div>"
960 f'<span class="timestamp">{log.timestamp.strftime("%Y-%m-%d %H:%M:%S")}</span> '
961 f'<span class="method method-{log.method}">{log.method}</span> '
962 f'<span class="path">{log.path}</span> '
963 f"{status_html}"
964 f"</div>"
965 )
966 html.append("</div>")
968 return mark_safe("".join(html))
970 recent_activity.short_description = "Recent Activity (Last 10 Actions)"
972 def ip_addresses_used(self, obj):
973 """Return HTML list of IP addresses used by this user with request counts."""
974 from django.db.models import Count
976 # More efficient query with annotation
977 ip_stats = (
978 AccessLog.objects.filter(user=obj)
979 .values("ip__address")
980 .annotate(count=Count("ip"))
981 .order_by("-count")
982 )
984 if not ip_stats:
985 return "No IP addresses recorded"
987 style = """
988 <style>
989 .ip-list { margin: 10px 0; padding: 0; list-style-type: none; }
990 .ip-list li { padding: 5px 10px; margin-bottom: 5px; background-color: #f8f9fa; border-radius: 4px; }
991 .ip-count { font-weight: bold; color: #0066cc; }
992 </style>
993 """
995 html = [style, '<ul class="ip-list">']
996 for item in ip_stats:
997 html.append(
998 f'<li>{item["ip__address"]} - <span class="ip-count">{item["count"]} requests</span></li>'
999 )
1000 html.append("</ul>")
1002 return mark_safe("".join(html))
1004 ip_addresses_used.short_description = "IP Addresses Used"
1006 def url_access_stats(self, obj):
1007 """Return HTML table of URLs accessed by this user with counts."""
1008 from django.db.models import Count
1010 # More efficient query with annotation
1011 url_stats = (
1012 AccessLog.objects.filter(user=obj)
1013 .values("path__path")
1014 .annotate(count=Count("path"))
1015 .order_by("-count")[:50]
1016 ) # Limit to top 50 to avoid performance issues
1018 if not url_stats:
1019 return "No URLs recorded"
1021 style = """
1022 <style>
1023 .url-table { border-collapse: collapse; width: 100%; margin-top: 10px; }
1024 .url-table th { background-color: #4a6785; color: white; text-align: left; padding: 8px; }
1025 .url-table td { border: 1px solid #ddd; padding: 8px; }
1026 .url-table tr:nth-child(even) { background-color: #f2f2f2; }
1027 .url-table tr:hover { background-color: #ddd; }
1028 .url-count { text-align: center; font-weight: bold; }
1029 </style>
1030 """
1032 html = [style, '<table class="url-table"><tr><th>URL</th><th>Count</th></tr>']
1033 for item in url_stats:
1034 html.append(
1035 f'<tr><td>{item["path__path"]}</td><td class="url-count">{item["count"]}</td></tr>'
1036 )
1038 if len(url_stats) == 50:
1039 html.append(
1040 '<tr><td colspan="2" style="text-align:center; font-style:italic;">Showing top 50 results</td></tr>'
1041 )
1043 html.append("</table>")
1045 return mark_safe("".join(html))
1047 url_access_stats.short_description = "URL Access Statistics"
1049 def distinct_user_agents(self, obj):
1050 """Display a list of all distinct user agents used by this user."""
1051 from django.db.models import Count
1053 # Get all distinct user agents for this user
1054 user_agents = (
1055 AccessLog.objects.filter(user=obj)
1056 .exclude(user_agent_normalized__isnull=True)
1057 .values(
1058 'user_agent_normalized__user_agent',
1059 'user_agent_normalized__browser',
1060 'user_agent_normalized__browser_version',
1061 'user_agent_normalized__operating_system',
1062 'user_agent_normalized__operating_system_version',
1063 'user_agent_normalized__device_type',
1064 'user_agent_normalized__is_bot'
1065 )
1066 .annotate(count=Count('user_agent_normalized'))
1067 .order_by('-count')
1068 )
1070 if not user_agents:
1071 return "No user agent data available"
1073 style = """
1074 <style>
1075 .ua-list {
1076 width: 100%;
1077 border-collapse: collapse;
1078 margin-top: 10px;
1079 }
1080 .ua-list th {
1081 background-color: #4a6785;
1082 color: white;
1083 text-align: left;
1084 padding: 8px;
1085 }
1086 .ua-list td {
1087 border: 1px solid #ddd;
1088 padding: 8px;
1089 vertical-align: top;
1090 }
1091 .ua-list tr:nth-child(even) {
1092 background-color: #f2f2f2;
1093 }
1094 .ua-list tr:hover {
1095 background-color: #ddd;
1096 }
1097 .ua-count {
1098 font-weight: bold;
1099 color: #0066cc;
1100 }
1101 .ua-raw {
1102 font-family: monospace;
1103 font-size: 0.9em;
1104 color: #666;
1105 margin-top: 4px;
1106 }
1107 .ua-bot {
1108 color: #dc3545;
1109 font-weight: bold;
1110 }
1111 </style>
1112 """
1114 html = [
1115 style,
1116 '''<table class="ua-list">
1117 <tr>
1118 <th>Browser</th>
1119 <th>Operating System</th>
1120 <th>Device Type</th>
1121 <th>Usage Count</th>
1122 <th>Raw User Agent</th>
1123 </tr>'''
1124 ]
1126 for agent in user_agents:
1127 browser = f"{agent['user_agent_normalized__browser']} {agent['user_agent_normalized__browser_version'] or ''}"
1128 os_version = f" {agent['user_agent_normalized__operating_system_version']}" if agent['user_agent_normalized__operating_system_version'] else ""
1129 os = f"{agent['user_agent_normalized__operating_system']}{os_version}"
1131 bot_class = ' class="ua-bot"' if agent['user_agent_normalized__is_bot'] else ''
1133 html.append(f'''
1134 <tr{bot_class}>
1135 <td>{browser}</td>
1136 <td>{os}</td>
1137 <td>{agent['user_agent_normalized__device_type']}</td>
1138 <td class="ua-count">{agent['count']}</td>
1139 <td>
1140 <div class="ua-raw">{agent['user_agent_normalized__user_agent']}</div>
1141 </td>
1142 </tr>
1143 ''')
1145 html.append('</table>')
1147 return mark_safe(''.join(html))
1149 distinct_user_agents.short_description = "Distinct User Agents"
1152class LogIpAddressAdmin(ReadOnlyAdmin):
1153 """Admin class for LogIpAddress model."""
1155 list_display = ("address", "user_count", "request_count")
1156 search_fields = ("address",)
1157 readonly_fields = ("address", "user_agent_stats")
1159 def get_queryset(self, request):
1160 qs = super().get_queryset(request)
1161 qs = qs.annotate(
1162 request_count=models.Count("accesslog"),
1163 user_count=models.Count("accesslog__user", distinct=True),
1164 )
1165 return qs
1167 def user_count(self, obj):
1168 return obj.user_count
1170 user_count.admin_order_field = "user_count"
1171 user_count.short_description = "Unique Users"
1173 def request_count(self, obj):
1174 return obj.request_count
1176 request_count.admin_order_field = "request_count"
1177 request_count.short_description = "Total Requests"
1179 def user_agent_stats(self, obj):
1180 """Show user agent statistics for this IP address."""
1181 from django.db.models import Count
1183 # Get user agent data with counts
1184 user_agents = (
1185 AccessLog.objects.filter(ip=obj, user_agent__isnull=False)
1186 .exclude(user_agent="")
1187 .values_list("user_agent")
1188 .annotate(count=Count("user_agent"))
1189 .order_by("-count")
1190 )
1192 if not user_agents:
1193 return "No user agent data available"
1195 # Get categorized data
1196 categories = UserAgentUtil.categorize_user_agents(user_agents)
1198 # Create HTML for the statistics
1199 style = """
1200 <style>
1201 .ua-stats { width: 100%; margin-top: 20px; }
1202 .ua-stats h3 { margin-top: 20px; color: #333; }
1203 .ua-chart { display: flex; margin: 15px 0; }
1204 .ua-bar { height: 30px; min-width: 2px; background-color: #4a6785; margin-right: 1px; }
1205 .ua-bar-container { display: flex; align-items: center; margin-bottom: 8px; }
1206 .ua-bar-label { width: 120px; text-align: right; padding-right: 10px; }
1207 .ua-bar-value { margin-left: 10px; font-weight: bold; }
1208 .ua-category { margin-bottom: 30px; }
1209 .ua-bot-note { margin-top: 15px; font-style: italic; color: #666; }
1210 </style>
1211 """
1213 html = [style, '<div class="ua-stats">']
1215 # Browser statistics
1216 html.append('<div class="ua-category"><h3>Browsers</h3>')
1217 sorted_browsers = sorted(
1218 categories["browsers"].items(), key=lambda x: x[1], reverse=True
1219 )
1220 for browser, count in sorted_browsers:
1221 percentage = (count / categories["total"]) * 100
1222 html.append(
1223 f"""
1224 <div class="ua-bar-container">
1225 <div class="ua-bar-label">{browser}</div>
1226 <div class="ua-bar" style="width: {max(percentage, 2)}%;"></div>
1227 <div class="ua-bar-value">{count} ({percentage:.1f}%)</div>
1228 </div>
1229 """
1230 )
1231 html.append("</div>")
1233 # OS statistics
1234 html.append('<div class="ua-category"><h3>Operating Systems</h3>')
1235 sorted_os = sorted(
1236 categories["operating_systems"].items(), key=lambda x: x[1], reverse=True
1237 )
1238 for os, count in sorted_os:
1239 percentage = (count / categories["total"]) * 100
1240 html.append(
1241 f"""
1242 <div class="ua-bar-container">
1243 <div class="ua-bar-label">{os}</div>
1244 <div class="ua-bar" style="width: {max(percentage, 2)}%;"></div>
1245 <div class="ua-bar-value">{count} ({percentage:.1f}%)</div>
1246 </div>
1247 """
1248 )
1249 html.append("</div>")
1251 # Device type statistics
1252 html.append('<div class="ua-category"><h3>Device Types</h3>')
1253 sorted_devices = sorted(
1254 categories["device_types"].items(), key=lambda x: x[1], reverse=True
1255 )
1256 for device, count in sorted_devices:
1257 percentage = (count / categories["total"]) * 100
1258 html.append(
1259 f"""
1260 <div class="ua-bar-container">
1261 <div class="ua-bar-label">{device}</div>
1262 <div class="ua-bar" style="width: {max(percentage, 2)}%;"></div>
1263 <div class="ua-bar-value">{count} ({percentage:.1f}%)</div>
1264 </div>
1265 """
1266 )
1267 html.append("</div>")
1269 # Bot percentage
1270 if categories["bots"] > 0:
1271 bot_percentage = (categories["bots"] / categories["total"]) * 100
1272 html.append(
1273 f'<div class="ua-bot-note">Bot/Crawler traffic: {categories["bots"]} requests ({bot_percentage:.1f}%)</div>'
1274 )
1276 html.append("</div>")
1278 return mark_safe("".join(html))
1280 user_agent_stats.short_description = "User Agent Statistics"
1283class LogUserAgentAdmin(ReadOnlyAdmin):
1284 """Admin class for LogUserAgent model."""
1286 list_display = (
1287 "browser",
1288 "browser_version",
1289 "operating_system",
1290 "operating_system_version",
1291 "device_type",
1292 "is_bot",
1293 "usage_count",
1294 "unique_users_count",
1295 )
1296 list_filter = ("browser", "operating_system", "device_type", "is_bot", "operating_system_version",)
1297 search_fields = ("user_agent", "browser", "operating_system")
1298 readonly_fields = (
1299 "user_agent",
1300 "browser",
1301 "browser_version",
1302 "operating_system",
1303 "operating_system_version",
1304 "device_type",
1305 "is_bot",
1306 "usage_details",
1307 "related_users"
1308 )
1310 def get_queryset(self, request):
1311 qs = super().get_queryset(request)
1312 qs = qs.annotate(
1313 usage_count=models.Count("access_logs"),
1314 unique_users=models.Count("access_logs__user", distinct=True),
1315 # Add semantic version ordering
1316 version_as_int=models.Case(
1317 models.When(
1318 operating_system_version__regex=r'^\d+$',
1319 then=Cast('operating_system_version', models.IntegerField())
1320 ),
1321 default=0,
1322 output_field=models.IntegerField(),
1323 )
1324 ).order_by('operating_system', '-version_as_int', 'operating_system_version')
1325 return qs
1327 def operating_system_version(self, obj):
1328 """Display the operating system version with semantic ordering."""
1329 return obj.operating_system_version
1330 operating_system_version.admin_order_field = 'version_as_int'
1331 operating_system_version.short_description = "OS Version"
1333 def usage_count(self, obj):
1334 """Return number of times this user agent appears in logs."""
1335 return obj.usage_count
1337 usage_count.admin_order_field = "usage_count"
1338 usage_count.short_description = "Usage Count"
1340 def unique_users_count(self, obj):
1341 """Return number of unique users that have used this user agent."""
1342 return obj.unique_users
1344 unique_users_count.admin_order_field = "unique_users"
1345 unique_users_count.short_description = "Unique Users"
1347 def usage_details(self, obj):
1348 """Show details of how this user agent is used."""
1349 from django.db.models import Count
1351 # Get user count and IP count
1352 user_count = (
1353 AccessLog.objects.filter(user_agent_normalized=obj)
1354 .values("user")
1355 .distinct()
1356 .count()
1357 )
1359 ip_count = (
1360 AccessLog.objects.filter(user_agent_normalized=obj)
1361 .values("ip")
1362 .distinct()
1363 .count()
1364 )
1366 # Get top 10 paths accessed with this user agent
1367 top_paths = (
1368 AccessLog.objects.filter(user_agent_normalized=obj)
1369 .values("path__path")
1370 .annotate(count=Count("path"))
1371 .order_by("-count")[:10]
1372 )
1374 # Create HTML for the statistics
1375 style = """
1376 <style>
1377 .ua-usage { margin: 20px 0; }
1378 .ua-usage h3 { margin-top: 20px; color: #333; }
1379 .ua-usage-stat { margin-bottom: 10px; }
1380 .ua-stat-label { font-weight: bold; color: #555; }
1381 .ua-path-list { margin-top: 10px; }
1382 .ua-path-item { padding: 5px 0; border-bottom: 1px solid #eee; }
1383 .ua-path-count { font-weight: bold; color: #0066cc; margin-right: 10px; }
1384 </style>
1385 """
1387 html = [style, '<div class="ua-usage">']
1389 # Usage statistics
1390 html.append('<div class="ua-usage-stat">')
1391 html.append(
1392 f'<span class="ua-stat-label">Total requests:</span> {obj.usage_count}</div>'
1393 )
1394 html.append(
1395 f'<div class="ua-usage-stat"><span class="ua-stat-label">Unique users:</span> {user_count}</div>'
1396 )
1397 html.append(
1398 f'<div class="ua-usage-stat"><span class="ua-stat-label">Unique IP addresses:</span> {ip_count}</div>'
1399 )
1401 # Path statistics
1402 if top_paths:
1403 html.append("<h3>Top Accessed Paths</h3>")
1404 html.append('<div class="ua-path-list">')
1405 for item in top_paths:
1406 html.append(
1407 f"""
1408 <div class="ua-path-item">
1409 <span class="ua-path-count">{item["count"]}</span>
1410 <span class="ua-path-url">{item["path__path"]}</span>
1411 </div>
1412 """
1413 )
1414 html.append("</div>")
1416 html.append("</div>")
1418 return mark_safe("".join(html))
1420 usage_details.short_description = "Usage Details"
1422 def related_users(self, obj):
1423 """Display a list of users who have used this user agent."""
1424 from django.db.models import Count
1425 from django.urls import reverse
1426 from django.utils.html import format_html
1428 # Get users who have used this user agent with their usage counts
1429 users = (
1430 AccessLog.objects.filter(user_agent_normalized=obj)
1431 .values('user__id', 'user__user_name')
1432 .annotate(
1433 usage_count=Count('id'),
1434 last_used=models.Max('timestamp')
1435 )
1436 .order_by('-usage_count')
1437 )
1439 if not users:
1440 return "No users have used this user agent"
1442 style = """
1443 <style>
1444 .user-list {
1445 width: 100%;
1446 border-collapse: collapse;
1447 margin-top: 10px;
1448 }
1449 .user-list th {
1450 background-color: #4a6785;
1451 color: white;
1452 text-align: left;
1453 padding: 8px;
1454 }
1455 .user-list td {
1456 border: 1px solid #ddd;
1457 padding: 8px;
1458 }
1459 .user-list tr:nth-child(even) {
1460 background-color: #f2f2f2;
1461 }
1462 .user-list tr:hover {
1463 background-color: #ddd;
1464 }
1465 .user-count {
1466 text-align: center;
1467 font-weight: bold;
1468 color: #0066cc;
1469 }
1470 .user-link {
1471 color: #0066cc;
1472 text-decoration: none;
1473 }
1474 .user-link:hover {
1475 text-decoration: underline;
1476 }
1477 .last-used {
1478 color: #666;
1479 font-size: 0.9em;
1480 }
1481 </style>
1482 """
1484 html = [
1485 style,
1486 '''<table class="user-list">
1487 <tr>
1488 <th>User</th>
1489 <th>Usage Count</th>
1490 <th>Last Used</th>
1491 </tr>'''
1492 ]
1494 for user in users:
1495 # Create a link to the user's admin page
1496 user_url = reverse('admin:django_audit_log_loguser_change', args=[user['user__id']])
1497 user_link = format_html(
1498 '<a class="user-link" href="{}">{}</a>',
1499 user_url,
1500 user['user__user_name']
1501 )
1503 html.append(f'''
1504 <tr>
1505 <td>{user_link}</td>
1506 <td class="user-count">{user['usage_count']}</td>
1507 <td class="last-used">{user['last_used'].strftime('%Y-%m-%d %H:%M:%S')}</td>
1508 </tr>
1509 ''')
1511 html.append('</table>')
1513 return mark_safe(''.join(html))
1515 related_users.short_description = "Users of this User Agent"
1518# Register models with their admin classes
1519admin.site.register(AccessLog, AccessLogAdmin)
1520admin.site.register(LogIpAddress, LogIpAddressAdmin)
1521admin.site.register(LogPath, LogPathAdmin)
1522admin.site.register(LogSessionKey, LogSessionKeyAdmin)
1523admin.site.register(LogUser, LogUserAdmin)
1524admin.site.register(LogUserAgent, LogUserAgentAdmin)