diff --git a/README.md b/README.md index 1af1cb6..00b222f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,11 @@ DRF_API_LOGGER_DATABASE = True - 🎛️ **Smart Filtering**: Filter by date, status code, HTTP method, and performance - 📈 **Visual Analytics**: Built-in performance charts and statistics +Admin graphs are collapsed by default and loaded on demand when opened, keeping +the log list fast and focused during routine investigation. Each graph has its +own control and fetches its backend data only when opened; graph data requests +time out after 30 seconds. + ### Admin Dashboard Screenshots **Admin Home** diff --git a/docs/index.rst b/docs/index.rst index 37df218..15ee14d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -110,6 +110,10 @@ Enable database storage for API logs: DRF_API_LOGGER_DATABASE = True # Default: False Logs will be available in the Django Admin Panel with search, filtering, and analytics charts. +Admin graphs are collapsed by default and loaded on demand when opened, keeping +the log list fast and focused during routine investigation. Each graph has its +own control and fetches its backend data only when opened; graph data requests +time out after 30 seconds. .. note:: diff --git a/drf_api_logger/admin.py b/drf_api_logger/admin.py index fef9968..3398b11 100644 --- a/drf_api_logger/admin.py +++ b/drf_api_logger/admin.py @@ -3,7 +3,8 @@ from django.conf import settings from django.contrib import admin from django.db.models import Count, Avg -from django.http import HttpResponse +from django.http import Http404, HttpResponse, JsonResponse +from django.urls import path from django.utils.html import escape from django.utils.safestring import mark_safe from drf_api_logger.utils import database_log_enabled @@ -390,47 +391,90 @@ def response_prettified(self, obj): change_form_template = 'change_form.html' date_hierarchy = 'added_on' + def get_urls(self): + urls = super().get_urls() + opts = self.model._meta + custom_urls = [ + path( + 'chart-data//', + self.admin_site.admin_view(self.chart_data_view), + name='{}_{}_chart_data'.format(opts.app_label, opts.model_name), + ), + ] + return custom_urls + urls + def changelist_view(self, request, extra_context=None): """ - Override to inject custom chart data for status codes and analytics into the context. + Add lightweight chart configuration without precomputing chart data. """ response = super(APILogsAdmin, self).changelist_view(request, extra_context) try: - filtered_query_set = response.context_data["cl"].queryset + response.context_data except Exception: return response - # Aggregate logs by date - analytics_model = filtered_query_set.values('added_on__date').annotate( - total=Count('id') - ).order_by('total') - - # Count each unique status code - status_code_count_mode = filtered_query_set.values('id').values('status_code').annotate( - total=Count('id')).order_by('status_code') - - status_code_count_keys = [item.get('status_code') for item in status_code_count_mode] - status_code_count_values = [item.get('total') for item in status_code_count_mode] - - # Add chart data to context - extra_context = dict( - analytics=analytics_model, - status_code_count_keys=status_code_count_keys, - status_code_count_values=status_code_count_values - ) - - # Add profiling chart data when profiling is enabled - if self._DRF_API_LOGGER_ENABLE_PROFILING: - profiled_qs = filtered_query_set.filter(sql_query_count__isnull=False) - sql_distribution = profiled_qs.values('added_on__date').annotate( - avg_queries=Avg('sql_query_count') - ).order_by('added_on__date') - extra_context['sql_distribution'] = list(sql_distribution) - extra_context['profiling_enabled'] = True - - response.context_data.update(extra_context) + response.context_data.update({ + 'profiling_enabled': bool( + getattr(settings, 'DRF_API_LOGGER_ENABLE_PROFILING', False) + ), + }) return response + def chart_data_view(self, request, chart_name): + queryset = self.get_changelist_instance(request).queryset + if chart_name == 'api-calls-by-day': + return JsonResponse(self._api_calls_by_day_chart_data(queryset)) + if chart_name == 'api-calls-by-status-code': + return JsonResponse(self._api_calls_by_status_code_chart_data(queryset)) + if chart_name == 'sql-queries-by-day': + if not getattr(settings, 'DRF_API_LOGGER_ENABLE_PROFILING', False): + raise Http404('Chart is not available when profiling is disabled.') + return JsonResponse(self._sql_queries_by_day_chart_data(queryset)) + raise Http404('Unknown chart.') + + def _api_calls_by_day_chart_data(self, queryset): + rows = queryset.values('added_on__date').annotate( + total=Count('id') + ).order_by('added_on__date') + return { + 'chart': 'api-calls-by-day', + 'label': 'Number of API calls', + 'data': [ + { + 'x': item['added_on__date'].isoformat(), + 'y': item['total'], + } + for item in rows + ], + } + + def _api_calls_by_status_code_chart_data(self, queryset): + rows = queryset.values('status_code').annotate( + total=Count('id') + ).order_by('status_code') + return { + 'chart': 'api-calls-by-status-code', + 'label': 'Number of API calls by status code', + 'labels': [item['status_code'] for item in rows], + 'values': [item['total'] for item in rows], + } + + def _sql_queries_by_day_chart_data(self, queryset): + rows = queryset.filter(sql_query_count__isnull=False).values( + 'added_on__date' + ).annotate(avg_queries=Avg('sql_query_count')).order_by('added_on__date') + return { + 'chart': 'sql-queries-by-day', + 'label': 'Avg SQL queries per request', + 'data': [ + { + 'x': item['added_on__date'].isoformat(), + 'y': round(float(item['avg_queries']), 1), + } + for item in rows + ], + } + def get_queryset(self, request): """ Ensure the queryset uses the correct database as configured in settings. diff --git a/drf_api_logger/templates/charts_change_list.html b/drf_api_logger/templates/charts_change_list.html index 1514942..9302f16 100644 --- a/drf_api_logger/templates/charts_change_list.html +++ b/drf_api_logger/templates/charts_change_list.html @@ -1,162 +1,326 @@ {% extends "admin/change_list.html" %} {% load static %} - + {% block extrahead %} {{ block.super }} - - {% endblock %} {% block content %} - -
- +
+

API log graphs

+
+ +
+
+

API calls by day

+ +
+ + +
-
- + +
+
+

API calls by status code

+ +
+ + +
+ {% if profiling_enabled %} -
- +
+
+

Average SQL queries per request

+ +
+ + +
{% endif %} + {{ block.super }} {% endblock %} diff --git a/tests/test_admin_charts.py b/tests/test_admin_charts.py new file mode 100644 index 0000000..4603e18 --- /dev/null +++ b/tests/test_admin_charts.py @@ -0,0 +1,142 @@ +from datetime import timedelta + +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from drf_api_logger.utils import database_log_enabled + + +@override_settings(DRF_API_LOGGER_DATABASE=True) +class AdminChartDataEndpointTests(TestCase): + def setUp(self): + if not database_log_enabled(): + self.skipTest("Database logging is not enabled") + + from drf_api_logger.models import APILogsModel + + self.APILogsModel = APILogsModel + self.user = User.objects.create_superuser( + username="admin", + email="admin@test.com", + password="password", + ) + self.client.force_login(self.user) + now = timezone.now() + self.APILogsModel.objects.create( + api="/api/one/", + headers="{}", + body="", + method="GET", + client_ip_address="127.0.0.1", + response="{}", + status_code=200, + execution_time=0.1, + added_on=now - timedelta(days=1), + sql_query_count=3, + ) + self.APILogsModel.objects.create( + api="/api/two/", + headers="{}", + body="", + method="POST", + client_ip_address="127.0.0.1", + response="{}", + status_code=500, + execution_time=0.2, + added_on=now, + sql_query_count=11, + ) + self.APILogsModel.objects.create( + api="/api/three/", + headers="{}", + body="", + method="GET", + client_ip_address="127.0.0.1", + response="{}", + status_code=500, + execution_time=0.3, + added_on=now, + sql_query_count=None, + ) + + def test_changelist_does_not_precompute_chart_data(self): + response = self.client.get( + reverse("admin:drf_api_logger_apilogsmodel_changelist") + ) + + self.assertEqual(response.status_code, 200) + self.assertNotIn("analytics", response.context_data) + self.assertNotIn("status_code_count_keys", response.context_data) + self.assertNotIn("status_code_count_values", response.context_data) + self.assertNotIn("sql_distribution", response.context_data) + + def test_api_calls_by_day_chart_data_uses_current_admin_filters(self): + response = self.client.get( + reverse( + "admin:drf_api_logger_apilogsmodel_chart_data", + args=["api-calls-by-day"], + ), + {"method__exact": "GET"}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["chart"], "api-calls-by-day") + self.assertEqual(data["label"], "Number of API calls") + self.assertEqual(sum(item["y"] for item in data["data"]), 2) + + def test_api_calls_by_status_code_chart_data_uses_current_admin_filters(self): + response = self.client.get( + reverse( + "admin:drf_api_logger_apilogsmodel_chart_data", + args=["api-calls-by-status-code"], + ), + {"method__exact": "GET"}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["chart"], "api-calls-by-status-code") + self.assertEqual(data["labels"], [200, 500]) + self.assertEqual(data["values"], [1, 1]) + + @override_settings(DRF_API_LOGGER_ENABLE_PROFILING=True) + def test_sql_queries_by_day_chart_data_requires_profiling_and_filters(self): + response = self.client.get( + reverse( + "admin:drf_api_logger_apilogsmodel_chart_data", + args=["sql-queries-by-day"], + ), + {"method__exact": "POST"}, + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["chart"], "sql-queries-by-day") + self.assertEqual(data["label"], "Avg SQL queries per request") + self.assertEqual(len(data["data"]), 1) + self.assertEqual(data["data"][0]["y"], 11.0) + + def test_unknown_chart_data_returns_404(self): + response = self.client.get( + reverse( + "admin:drf_api_logger_apilogsmodel_chart_data", + args=["private-fields"], + ) + ) + + self.assertEqual(response.status_code, 404) + + def test_chart_data_requires_admin_authentication(self): + self.client.logout() + + response = self.client.get( + reverse( + "admin:drf_api_logger_apilogsmodel_chart_data", + args=["api-calls-by-day"], + ) + ) + + self.assertEqual(response.status_code, 302) diff --git a/tests/test_admin_templates.py b/tests/test_admin_templates.py new file mode 100644 index 0000000..4eb935c --- /dev/null +++ b/tests/test_admin_templates.py @@ -0,0 +1,119 @@ +from pathlib import Path + +from django.test import SimpleTestCase + + +ROOT = Path(__file__).resolve().parents[1] + + +class AdminChartTemplateTests(SimpleTestCase): + def test_each_changelist_chart_has_its_own_collapsible_loader(self): + template = ROOT.joinpath( + "drf_api_logger", "templates", "charts_change_list.html" + ).read_text(encoding="utf-8") + + for chart_name in ( + "api-calls-by-day", + "api-calls-by-status-code", + "sql-queries-by-day", + ): + with self.subTest(chart_name=chart_name): + self.assertIn(f'data-chart="{chart_name}"', template) + self.assertIn( + f'data-chart-url="chart-data/{chart_name}/"', template + ) + + self.assertIn('aria-expanded="false"', template) + self.assertIn('hidden', template) + self.assertIn("loadChartData(card)", template) + self.assertIn("fetch(chartUrl", template) + self.assertIn("card.dataset.loaded = 'true';", template) + + def test_changelist_charts_do_not_embed_backend_datasets(self): + template = ROOT.joinpath( + "drf_api_logger", "templates", "charts_change_list.html" + ).read_text(encoding="utf-8") + + self.assertNotIn("{% for item in analytics %}", template) + self.assertNotIn("{{ status_code_count_keys }}", template) + self.assertNotIn("{{ status_code_count_values }}", template) + self.assertNotIn("{% for item in sql_distribution %}", template) + + def test_changelist_chart_cards_use_full_available_width(self): + template = ROOT.joinpath( + "drf_api_logger", "templates", "charts_change_list.html" + ).read_text(encoding="utf-8") + + card_rule_start = template.find(".api-log-chart-card {") + card_rule_end = template.find("}", card_rule_start) + + self.assertNotEqual(-1, card_rule_start) + self.assertNotEqual(-1, card_rule_end) + + card_rule = template[card_rule_start:card_rule_end] + self.assertIn("width: 100%;", card_rule) + self.assertIn("box-sizing: border-box;", card_rule) + self.assertNotIn("max-width", card_rule) + + def test_changelist_chart_panels_are_capped_to_200px_height(self): + template = ROOT.joinpath( + "drf_api_logger", "templates", "charts_change_list.html" + ).read_text(encoding="utf-8") + + panel_rule_start = template.find(".api-log-chart-panel {") + panel_rule_end = template.find("}", panel_rule_start) + canvas_rule_start = template.find(".api-log-chart-panel canvas {") + canvas_rule_end = template.find("}", canvas_rule_start) + + self.assertNotEqual(-1, panel_rule_start) + self.assertNotEqual(-1, panel_rule_end) + self.assertNotEqual(-1, canvas_rule_start) + self.assertNotEqual(-1, canvas_rule_end) + + panel_rule = template[panel_rule_start:panel_rule_end] + canvas_rule = template[canvas_rule_start:canvas_rule_end] + + self.assertIn("height: 200px;", panel_rule) + self.assertIn("max-height: 200px;", panel_rule) + self.assertIn("overflow: hidden;", panel_rule) + self.assertIn("height: 200px !important;", canvas_rule) + self.assertIn("max-height: 200px;", canvas_rule) + self.assertNotIn("min-height", canvas_rule) + self.assertEqual(2, template.count("maintainAspectRatio: false")) + + def test_changelist_graph_api_fetch_has_30_second_timeout(self): + template = ROOT.joinpath( + "drf_api_logger", "templates", "charts_change_list.html" + ).read_text(encoding="utf-8") + + load_chart_data_start = template.find("function loadChartData(card)") + render_charts_start = template.find("function showMessage") + + self.assertNotEqual(-1, load_chart_data_start) + self.assertNotEqual(-1, render_charts_start) + + load_chart_data = template[load_chart_data_start:render_charts_start] + self.assertIn("const graphApiTimeoutMs = 30000;", template) + self.assertIn("const controller = new AbortController();", load_chart_data) + self.assertIn( + "setTimeout(() => controller.abort(), graphApiTimeoutMs)", + load_chart_data, + ) + self.assertIn("signal: controller.signal", load_chart_data) + self.assertIn("clearTimeout(timeoutId)", load_chart_data) + + def test_changelist_charts_render_only_after_toggle_click(self): + template = ROOT.joinpath( + "drf_api_logger", "templates", "charts_change_list.html" + ).read_text(encoding="utf-8") + + dom_ready_start = template.find("document.addEventListener('DOMContentLoaded'") + render_charts_start = template.find("function renderCharts") + + self.assertNotEqual(-1, dom_ready_start) + self.assertNotEqual(-1, render_charts_start) + + dom_ready_block = template[dom_ready_start:render_charts_start] + + self.assertIn("toggle.addEventListener('click'", dom_ready_block) + self.assertNotIn("new Chart(", dom_ready_block) diff --git a/tests/test_docs_content.py b/tests/test_docs_content.py index 726e20d..e32ad95 100644 --- a/tests/test_docs_content.py +++ b/tests/test_docs_content.py @@ -302,6 +302,17 @@ def test_asgi_native_logging_is_documented(self): for content in (readme, quickstart, operations, testing, developer_testing, developer_testing_md): self.assertIn("ASGI", content) + def test_admin_chart_lazy_loading_is_documented(self): + readme = read_text("README.md") + index = read_text("docs/index.rst") + + for content in (readme, index): + self.assertIn("collapsed by default", content) + self.assertIn("loaded on demand", content) + self.assertIn("Each graph", content) + self.assertIn("fetches its backend data only when opened", content) + self.assertIn("30 seconds", content) + if __name__ == "__main__": unittest.main()