|
8 | 8 | from django.urls import reverse |
9 | 9 | from django.utils import timezone |
10 | 10 |
|
11 | | -from trends.models import TopicCentroidSnapshot |
| 11 | +from trends.models import SourceDiversitySnapshot, TopicCentroidSnapshot |
12 | 12 |
|
13 | 13 |
|
14 | 14 | def _project_pk(snapshot: TopicCentroidSnapshot) -> int: |
@@ -44,6 +44,32 @@ def _drift_card_color(value) -> str: |
44 | 44 | return "danger" |
45 | 45 |
|
46 | 46 |
|
| 47 | +def _diversity_card_color(value) -> str: |
| 48 | + """Return an admin card severity for normalized diversity scores.""" |
| 49 | + |
| 50 | + if value is None: |
| 51 | + return "info" |
| 52 | + numeric_value = float(value) |
| 53 | + if numeric_value >= 0.75: |
| 54 | + return "success" |
| 55 | + if numeric_value >= 0.4: |
| 56 | + return "warning" |
| 57 | + return "danger" |
| 58 | + |
| 59 | + |
| 60 | +def _share_card_color(value) -> str: |
| 61 | + """Return an admin card severity for concentration share metrics.""" |
| 62 | + |
| 63 | + if value is None: |
| 64 | + return "info" |
| 65 | + numeric_value = float(value) |
| 66 | + if numeric_value <= 0.4: |
| 67 | + return "success" |
| 68 | + if numeric_value <= 0.7: |
| 69 | + return "warning" |
| 70 | + return "danger" |
| 71 | + |
| 72 | + |
47 | 73 | def _format_snapshot_freshness(computed_at) -> str: |
48 | 74 | """Return a compact human-readable age for the latest snapshot.""" |
49 | 75 |
|
@@ -115,6 +141,46 @@ def _build_topic_centroid_project_drilldowns(queryset, changelist_url: str): |
115 | 141 | return project_drilldowns |
116 | 142 |
|
117 | 143 |
|
| 144 | +def _build_source_diversity_project_drilldowns(queryset, changelist_url: str): |
| 145 | + """Build one filtered-history drilldown row per project.""" |
| 146 | + |
| 147 | + latest_by_project: dict[int, SourceDiversitySnapshot] = {} |
| 148 | + snapshot_counts: dict[int, int] = {} |
| 149 | + ordered_snapshots = queryset.select_related("project").order_by( |
| 150 | + "project_id", "-computed_at" |
| 151 | + ) |
| 152 | + |
| 153 | + for snapshot in ordered_snapshots: |
| 154 | + project_id = int(snapshot.project_id) |
| 155 | + snapshot_counts[project_id] = snapshot_counts.get(project_id, 0) + 1 |
| 156 | + latest_by_project.setdefault(project_id, snapshot) |
| 157 | + |
| 158 | + project_drilldowns = [] |
| 159 | + for snapshot in sorted( |
| 160 | + latest_by_project.values(), |
| 161 | + key=lambda value: value.project.name.lower(), |
| 162 | + ): |
| 163 | + project_id = int(snapshot.project_id) |
| 164 | + alerts = cast( |
| 165 | + list[dict[str, Any]], (snapshot.breakdown or {}).get("alerts", []) |
| 166 | + ) |
| 167 | + project_drilldowns.append( |
| 168 | + { |
| 169 | + "project_id": project_id, |
| 170 | + "project_name": snapshot.project.name, |
| 171 | + "snapshot_count": snapshot_counts[project_id], |
| 172 | + "latest_snapshot": _format_snapshot_freshness(snapshot.computed_at), |
| 173 | + "plugin_entropy": f"{_score_to_percent(snapshot.plugin_entropy):.1f}%", |
| 174 | + "source_entropy": f"{_score_to_percent(snapshot.source_entropy):.1f}%", |
| 175 | + "top_plugin_share": f"{_score_to_percent(snapshot.top_plugin_share):.1f}%", |
| 176 | + "alert_count": len(alerts), |
| 177 | + "href": f"{changelist_url}?{urlencode({'project__id__exact': project_id})}", |
| 178 | + } |
| 179 | + ) |
| 180 | + |
| 181 | + return project_drilldowns |
| 182 | + |
| 183 | + |
118 | 184 | @admin.register(TopicCentroidSnapshot) |
119 | 185 | class TopicCentroidSnapshotAdmin(admin.ModelAdmin): |
120 | 186 | """Admin view for persisted topic-centroid history and drift.""" |
@@ -219,3 +285,120 @@ def changelist_view(self, request, extra_context=None): |
219 | 285 | _build_topic_centroid_project_drilldowns(queryset, changelist_url) |
220 | 286 | ) |
221 | 287 | return super().changelist_view(request, extra_context=extra_context) |
| 288 | + |
| 289 | + |
| 290 | +@admin.register(SourceDiversitySnapshot) |
| 291 | +class SourceDiversitySnapshotAdmin(admin.ModelAdmin): |
| 292 | + """Admin view for persisted source-diversity history and concentration alerts.""" |
| 293 | + |
| 294 | + list_before_template = "admin/source_diversity_snapshot_changelist_widget.html" |
| 295 | + list_display = ( |
| 296 | + "project", |
| 297 | + "display_plugin_entropy", |
| 298 | + "display_source_entropy", |
| 299 | + "display_author_entropy", |
| 300 | + "display_top_plugin_share", |
| 301 | + "computed_at", |
| 302 | + ) |
| 303 | + list_filter = ( |
| 304 | + "window_days", |
| 305 | + ("project", admin.RelatedOnlyFieldListFilter), |
| 306 | + "computed_at", |
| 307 | + ) |
| 308 | + search_fields = ("project__name",) |
| 309 | + autocomplete_fields = ("project",) |
| 310 | + |
| 311 | + @admin.display(description="Plugin Diversity", ordering="plugin_entropy") |
| 312 | + def display_plugin_entropy(self, obj): |
| 313 | + """Render normalized plugin diversity as a percentage.""" |
| 314 | + |
| 315 | + return f"{_score_to_percent(obj.plugin_entropy):.1f}%" |
| 316 | + |
| 317 | + @admin.display(description="Source Diversity", ordering="source_entropy") |
| 318 | + def display_source_entropy(self, obj): |
| 319 | + """Render normalized source diversity as a percentage.""" |
| 320 | + |
| 321 | + return f"{_score_to_percent(obj.source_entropy):.1f}%" |
| 322 | + |
| 323 | + @admin.display(description="Author Diversity", ordering="author_entropy") |
| 324 | + def display_author_entropy(self, obj): |
| 325 | + """Render normalized author diversity as a percentage.""" |
| 326 | + |
| 327 | + return f"{_score_to_percent(obj.author_entropy):.1f}%" |
| 328 | + |
| 329 | + @admin.display(description="Top Plugin Share", ordering="top_plugin_share") |
| 330 | + def display_top_plugin_share(self, obj): |
| 331 | + """Render the largest plugin share as a percentage.""" |
| 332 | + |
| 333 | + return f"{_score_to_percent(obj.top_plugin_share):.1f}%" |
| 334 | + |
| 335 | + def changelist_view(self, request, extra_context=None): |
| 336 | + """Augment the changelist with diversity summaries and alert callouts.""" |
| 337 | + |
| 338 | + queryset = self.get_queryset(request) |
| 339 | + changelist_url = reverse( |
| 340 | + f"{self.admin_site.name}:{self.model._meta.app_label}_{self.model._meta.model_name}_changelist" |
| 341 | + ) |
| 342 | + metrics = queryset.aggregate( |
| 343 | + avg_plugin_entropy=Avg("plugin_entropy"), |
| 344 | + avg_source_entropy=Avg("source_entropy"), |
| 345 | + avg_author_entropy=Avg("author_entropy"), |
| 346 | + avg_top_plugin_share=Avg("top_plugin_share"), |
| 347 | + latest_snapshot_at=Max("computed_at"), |
| 348 | + ) |
| 349 | + alerts = [ |
| 350 | + alert |
| 351 | + for snapshot in queryset.order_by("project_id", "-computed_at") |
| 352 | + for alert in cast( |
| 353 | + list[dict[str, Any]], (snapshot.breakdown or {}).get("alerts", []) |
| 354 | + ) |
| 355 | + ] |
| 356 | + |
| 357 | + extra_context = cast(dict[str, Any], extra_context or {}) |
| 358 | + extra_context["dashboard_stats"] = [ |
| 359 | + { |
| 360 | + "title": "Plugin Diversity", |
| 361 | + "value": ( |
| 362 | + f"{_score_to_percent(metrics['avg_plugin_entropy']):.1f}%" |
| 363 | + if metrics["avg_plugin_entropy"] is not None |
| 364 | + else "-" |
| 365 | + ), |
| 366 | + "icon": "hub", |
| 367 | + "color": _diversity_card_color(metrics["avg_plugin_entropy"]), |
| 368 | + }, |
| 369 | + { |
| 370 | + "title": "Source Diversity", |
| 371 | + "value": ( |
| 372 | + f"{_score_to_percent(metrics['avg_source_entropy']):.1f}%" |
| 373 | + if metrics["avg_source_entropy"] is not None |
| 374 | + else "-" |
| 375 | + ), |
| 376 | + "icon": "lan", |
| 377 | + "color": _diversity_card_color(metrics["avg_source_entropy"]), |
| 378 | + }, |
| 379 | + { |
| 380 | + "title": "Author Diversity", |
| 381 | + "value": ( |
| 382 | + f"{_score_to_percent(metrics['avg_author_entropy']):.1f}%" |
| 383 | + if metrics["avg_author_entropy"] is not None |
| 384 | + else "-" |
| 385 | + ), |
| 386 | + "icon": "group", |
| 387 | + "color": _diversity_card_color(metrics["avg_author_entropy"]), |
| 388 | + }, |
| 389 | + { |
| 390 | + "title": "Top Plugin Share", |
| 391 | + "value": ( |
| 392 | + f"{_score_to_percent(metrics['avg_top_plugin_share']):.1f}%" |
| 393 | + if metrics["avg_top_plugin_share"] is not None |
| 394 | + else "-" |
| 395 | + ), |
| 396 | + "icon": "pie_chart", |
| 397 | + "color": _share_card_color(metrics["avg_top_plugin_share"]), |
| 398 | + }, |
| 399 | + ] |
| 400 | + extra_context["source_diversity_alerts"] = alerts |
| 401 | + extra_context["source_diversity_project_drilldowns"] = ( |
| 402 | + _build_source_diversity_project_drilldowns(queryset, changelist_url) |
| 403 | + ) |
| 404 | + return super().changelist_view(request, extra_context=extra_context) |
0 commit comments