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

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 

17 

18try: 

19 from rangefilter.filters import DateRangeFilter 

20 

21 HAS_RANGE_FILTER = True 

22except ImportError: 

23 HAS_RANGE_FILTER = False 

24 

25 

26# Base admin classes 

27class ReadOnlyAdmin(admin.ModelAdmin): 

28 """Base admin class for read-only models.""" 

29 

30 def has_add_permission(self, request): 

31 return False 

32 

33 def has_change_permission(self, request, obj=None): 

34 return False 

35 

36 def has_delete_permission(self, request, obj=None): 

37 return False 

38 

39 

40class BrowserTypeFilter(SimpleListFilter): 

41 """Filter logs by browser type.""" 

42 

43 title = "Browser" 

44 parameter_name = "browser_type" 

45 

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 ) 

58 

59 def queryset(self, request, queryset): 

60 if not self.value(): 

61 return queryset 

62 

63 value = self.value() 

64 

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) 

95 

96 

97class DeviceTypeFilter(SimpleListFilter): 

98 """Filter logs by device type.""" 

99 

100 title = "Device Type" 

101 parameter_name = "device_type" 

102 

103 def lookups(self, request, model_admin): 

104 return ( 

105 ("desktop", "Desktop"), 

106 ("mobile", "Mobile"), 

107 ("tablet", "Tablet"), 

108 ("bot", "Bot/Crawler"), 

109 ) 

110 

111 def queryset(self, request, queryset): 

112 if not self.value(): 

113 return queryset 

114 

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 ) 

127 

128 

129class AccessLogAdmin(ReadOnlyAdmin): 

130 """Admin class for AccessLog model.""" 

131 

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 ) 

166 

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" 

172 

173 browser_type.short_description = "Browser" 

174 

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" 

179 

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 } 

189 

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 """ 

210 

211 return mark_safe(html) 

212 

213 normalized_user_agent.short_description = "Normalized User Agent" 

214 

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) 

222 

223 def get_user_agent_summary(self): 

224 """Generate a summary of user agent statistics.""" 

225 from django.db.models import Count 

226 

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 

233 

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 ) 

244 

245 if not normalized_user_agents and not legacy_user_agents: 

246 return "No user agent data available" 

247 

248 # Initialize categories 

249 categories = { 

250 "browsers": {}, 

251 "operating_systems": {}, 

252 "device_types": {}, 

253 "bots": 0, 

254 "total": 0, 

255 } 

256 

257 # Process normalized user agents (more efficient) 

258 for agent in normalized_user_agents: 

259 count = agent["count"] 

260 categories["total"] += count 

261 

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 

267 

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 

273 

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 

279 

280 # Count bots 

281 if agent["is_bot"]: 

282 categories["bots"] += count 

283 

284 # Process legacy user agents 

285 if legacy_user_agents: 

286 legacy_categories = UserAgentUtil.categorize_user_agents(legacy_user_agents) 

287 

288 # Merge legacy data 

289 categories["total"] += legacy_categories["total"] 

290 categories["bots"] += legacy_categories["bots"] 

291 

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 

296 

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 

301 

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 

306 

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 """ 

323 

324 html = [ 

325 style, 

326 '<div class="ua-summary"><h2>User Agent Statistics Summary</h2><div class="ua-chart">', 

327 ] 

328 

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

350 

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

372 

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

394 

395 html.append("</div>") # Close chart 

396 

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 

409 

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 ) 

420 

421 html.append("</div>") # Close summary 

422 

423 return mark_safe("".join(html)) 

424 

425 

426class LogPathAdmin(ReadOnlyAdmin): 

427 """Admin class for LogPath model.""" 

428 

429 list_display = ("path",) 

430 search_fields = ("path",) 

431 readonly_fields = ("path",) 

432 

433 

434class LogSessionKeyAdmin(ReadOnlyAdmin): 

435 """Admin class for LogSessionKey model.""" 

436 

437 list_display = ("key",) 

438 search_fields = ("key",) 

439 readonly_fields = ("key",) 

440 

441 

442class ActivityLevelFilter(SimpleListFilter): 

443 """Filter users by their activity level in a time period.""" 

444 

445 title = "Activity Level" 

446 parameter_name = "activity" 

447 

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 ) 

456 

457 def queryset(self, request, queryset): 

458 if not self.value(): 

459 return queryset 

460 

461 if self.value() == "high": 

462 return queryset.annotate(count=models.Count("accesslog")).filter( 

463 count__gte=10 

464 ) 

465 

466 if self.value() == "medium": 

467 return queryset.annotate(count=models.Count("accesslog")).filter( 

468 count__gte=3, count__lte=9 

469 ) 

470 

471 if self.value() == "low": 

472 return queryset.annotate(count=models.Count("accesslog")).filter( 

473 count__gte=1, count__lte=2 

474 ) 

475 

476 if self.value() == "inactive": 

477 return queryset.annotate(count=models.Count("accesslog")).filter(count=0) 

478 

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

482 

483 

484class MultipleIPFilter(SimpleListFilter): 

485 """Filter users who have used multiple IP addresses.""" 

486 

487 title = "IP Usage" 

488 parameter_name = "ip_usage" 

489 

490 def lookups(self, request, model_admin): 

491 return ( 

492 ("multiple", "Multiple IPs"), 

493 ("single", "Single IP"), 

494 ) 

495 

496 def queryset(self, request, queryset): 

497 if not self.value(): 

498 return queryset 

499 

500 if self.value() == "multiple": 

501 return queryset.annotate( 

502 ip_count=models.Count("accesslog__ip", distinct=True) 

503 ).filter(ip_count__gt=1) 

504 

505 if self.value() == "single": 

506 return queryset.annotate( 

507 ip_count=models.Count("accesslog__ip", distinct=True) 

508 ).filter(ip_count=1) 

509 

510 

511class UserAgentUtil: 

512 """Utility class for parsing and normalizing user agents.""" 

513 

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 } 

524 

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 ] 

546 

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 ] 

564 

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 ] 

575 

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 ] 

583 

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})") 

588 

589 @classmethod 

590 def normalize_user_agent(cls, user_agent): 

591 """ 

592 Normalize a user agent string to categorize browsers, OS, and device types. 

593 

594 Args: 

595 user_agent: The raw user agent string 

596 

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 } 

610 

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 } 

620 

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 

633 

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 

641 

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 

651 

652 # Detect OS 

653 for pattern, os in cls.OS_PATTERNS: 

654 if re.search(pattern, user_agent): 

655 result["os"] = os 

656 break 

657 

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 

664 

665 return result 

666 

667 @classmethod 

668 def categorize_user_agents(cls, user_agents): 

669 """ 

670 Group a list of user agent strings into categories. 

671 

672 Args: 

673 user_agents: List of (user_agent, count) tuples 

674 

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 } 

685 

686 for agent, count in user_agents: 

687 categories["total"] += count 

688 info = cls.normalize_user_agent(agent) 

689 

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 

695 

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 

701 

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 

707 

708 # Count bots 

709 if info["is_bot"]: 

710 categories["bots"] += count 

711 

712 return categories 

713 

714 

715class LogUserAdmin(ReadOnlyAdmin): 

716 """Admin class for LogUser model.""" 

717 

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 ) 

735 

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) 

745 

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 

754 

755 def access_count(self, obj): 

756 return obj.access_count 

757 

758 access_count.admin_order_field = "access_count" 

759 access_count.short_description = "Total Accesses" 

760 

761 def ip_addresses_count(self, obj): 

762 return obj.ip_count 

763 

764 ip_addresses_count.admin_order_field = "ip_count" 

765 ip_addresses_count.short_description = "Unique IPs" 

766 

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" 

772 

773 last_active.admin_order_field = "last_activity" 

774 last_active.short_description = "Last Active" 

775 

776 def user_agent_stats(self, obj): 

777 """Show user agent statistics for this user with charts.""" 

778 from django.db.models import Count 

779 

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 ) 

793 

794 if not user_agents: 

795 return "No user agent data available" 

796 

797 # Initialize categories 

798 categories = { 

799 "browsers": {}, 

800 "operating_systems": {}, 

801 "device_types": {}, 

802 "bots": 0, 

803 "total": 0, 

804 } 

805 

806 # Process normalized user agents 

807 for agent in user_agents: 

808 count = agent["count"] 

809 categories["total"] += count 

810 

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 

816 

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 

822 

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 

828 

829 # Count bots 

830 if agent["user_agent_normalized__is_bot"]: 

831 categories["bots"] += count 

832 

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 """ 

847 

848 html = [style, '<div class="ua-stats">'] 

849 

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

867 

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

885 

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

903 

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 ) 

910 

911 html.append("</div>") 

912 

913 return mark_safe("".join(html)) 

914 

915 user_agent_stats.short_description = "User Agent Statistics" 

916 

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] 

920 

921 if not recent_logs: 

922 return "No recent activity" 

923 

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 """ 

939 

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" 

951 

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

967 

968 return mark_safe("".join(html)) 

969 

970 recent_activity.short_description = "Recent Activity (Last 10 Actions)" 

971 

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 

975 

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 ) 

983 

984 if not ip_stats: 

985 return "No IP addresses recorded" 

986 

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 """ 

994 

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

1001 

1002 return mark_safe("".join(html)) 

1003 

1004 ip_addresses_used.short_description = "IP Addresses Used" 

1005 

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 

1009 

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 

1017 

1018 if not url_stats: 

1019 return "No URLs recorded" 

1020 

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 """ 

1031 

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 ) 

1037 

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 ) 

1042 

1043 html.append("</table>") 

1044 

1045 return mark_safe("".join(html)) 

1046 

1047 url_access_stats.short_description = "URL Access Statistics" 

1048 

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 

1052 

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 ) 

1069 

1070 if not user_agents: 

1071 return "No user agent data available" 

1072 

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 """ 

1113 

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 ] 

1125 

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}" 

1130 

1131 bot_class = ' class="ua-bot"' if agent['user_agent_normalized__is_bot'] else '' 

1132 

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

1144 

1145 html.append('</table>') 

1146 

1147 return mark_safe(''.join(html)) 

1148 

1149 distinct_user_agents.short_description = "Distinct User Agents" 

1150 

1151 

1152class LogIpAddressAdmin(ReadOnlyAdmin): 

1153 """Admin class for LogIpAddress model.""" 

1154 

1155 list_display = ("address", "user_count", "request_count") 

1156 search_fields = ("address",) 

1157 readonly_fields = ("address", "user_agent_stats") 

1158 

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 

1166 

1167 def user_count(self, obj): 

1168 return obj.user_count 

1169 

1170 user_count.admin_order_field = "user_count" 

1171 user_count.short_description = "Unique Users" 

1172 

1173 def request_count(self, obj): 

1174 return obj.request_count 

1175 

1176 request_count.admin_order_field = "request_count" 

1177 request_count.short_description = "Total Requests" 

1178 

1179 def user_agent_stats(self, obj): 

1180 """Show user agent statistics for this IP address.""" 

1181 from django.db.models import Count 

1182 

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 ) 

1191 

1192 if not user_agents: 

1193 return "No user agent data available" 

1194 

1195 # Get categorized data 

1196 categories = UserAgentUtil.categorize_user_agents(user_agents) 

1197 

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 """ 

1212 

1213 html = [style, '<div class="ua-stats">'] 

1214 

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

1232 

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

1250 

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

1268 

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 ) 

1275 

1276 html.append("</div>") 

1277 

1278 return mark_safe("".join(html)) 

1279 

1280 user_agent_stats.short_description = "User Agent Statistics" 

1281 

1282 

1283class LogUserAgentAdmin(ReadOnlyAdmin): 

1284 """Admin class for LogUserAgent model.""" 

1285 

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 ) 

1309 

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 

1326 

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" 

1332 

1333 def usage_count(self, obj): 

1334 """Return number of times this user agent appears in logs.""" 

1335 return obj.usage_count 

1336 

1337 usage_count.admin_order_field = "usage_count" 

1338 usage_count.short_description = "Usage Count" 

1339 

1340 def unique_users_count(self, obj): 

1341 """Return number of unique users that have used this user agent.""" 

1342 return obj.unique_users 

1343 

1344 unique_users_count.admin_order_field = "unique_users" 

1345 unique_users_count.short_description = "Unique Users" 

1346 

1347 def usage_details(self, obj): 

1348 """Show details of how this user agent is used.""" 

1349 from django.db.models import Count 

1350 

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 ) 

1358 

1359 ip_count = ( 

1360 AccessLog.objects.filter(user_agent_normalized=obj) 

1361 .values("ip") 

1362 .distinct() 

1363 .count() 

1364 ) 

1365 

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 ) 

1373 

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 """ 

1386 

1387 html = [style, '<div class="ua-usage">'] 

1388 

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 ) 

1400 

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

1415 

1416 html.append("</div>") 

1417 

1418 return mark_safe("".join(html)) 

1419 

1420 usage_details.short_description = "Usage Details" 

1421 

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 

1427 

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 ) 

1438 

1439 if not users: 

1440 return "No users have used this user agent" 

1441 

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 """ 

1483 

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 ] 

1493 

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 ) 

1502 

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

1510 

1511 html.append('</table>') 

1512 

1513 return mark_safe(''.join(html)) 

1514 

1515 related_users.short_description = "Users of this User Agent" 

1516 

1517 

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)