Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
4 changes: 4 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down
108 changes: 76 additions & 32 deletions drf_api_logger/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<slug:chart_name>/',
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce model permissions on chart data

When a logged-in staff user lacks the drf_api_logger.view_apilogsmodel permission, the normal changelist is blocked by Django's model-admin permission checks, but this custom endpoint is only wrapped in admin_site.admin_view and immediately builds the queryset. That lets any staff user fetch aggregate API-log volume/status/SQL data via /chart-data/.../ despite not being allowed to view API logs, so this view should check has_view_or_change_permission(request) before returning data.

Useful? React with 👍 / 👎.

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.
Expand Down
Loading
Loading