Skip to content

Commit 76cff18

Browse files
author
Cuong Nguyen
committed
support admin chart
1 parent 22b4104 commit 76cff18

File tree

8 files changed

+605
-1
lines changed

8 files changed

+605
-1
lines changed

admin_extended/admin/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .bookmark import BookmarkAdmin
2+
from .chart import TimeSeriesChartAdmin

admin_extended/admin.py renamed to admin_extended/admin/bookmark.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.utils.decorators import method_decorator
55
from django.views.decorators.csrf import csrf_exempt
66

7-
from . import models
7+
from .. import models
88

99

1010
@admin.register(models.Bookmark)

admin_extended/admin/chart.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
from collections import defaultdict
2+
from datetime import timedelta
3+
4+
from django import forms
5+
from django.contrib import admin
6+
from django.db.models.functions import TruncDay, TruncWeek, TruncMonth, TruncHour
7+
from django.http import JsonResponse
8+
from django.shortcuts import render
9+
from django.urls import path
10+
from django.utils import timezone
11+
from django.utils.html import format_html
12+
13+
from ..models import TimeSeriesChart
14+
15+
SCALE_MAPPING = {
16+
'HOUR': TruncHour,
17+
'DAY': TruncDay,
18+
'WEEK': TruncWeek,
19+
'MONTH': TruncMonth,
20+
}
21+
22+
23+
class ChartStandardForm(forms.Form):
24+
time_range = forms.CharField(widget=forms.Select(choices=TimeSeriesChart.TimeRange.choices), required=False)
25+
scale = forms.CharField(widget=forms.Select(choices=TimeSeriesChart.Scale.choices), required=False)
26+
27+
28+
class ChartFilterForm(forms.Form):
29+
time_range = forms.CharField(widget=forms.Select(choices=TimeSeriesChart.TimeRange.choices), required=False)
30+
scale = forms.CharField(widget=forms.Select(choices=TimeSeriesChart.Scale.choices), required=False)
31+
filters = forms.ChoiceField(required=False)
32+
33+
34+
@admin.register(TimeSeriesChart)
35+
class TimeSeriesChartAdmin(admin.ModelAdmin):
36+
list_display = ('display_chart_url', 'name', 'chart_type', 'app_label', 'model_name')
37+
list_display_links = ('name',)
38+
search_fields = ('name',)
39+
fieldsets = (
40+
(
41+
None,
42+
{
43+
'fields': (
44+
'name', 'description',
45+
('chart_type', 'stacked')
46+
)
47+
},
48+
),
49+
(
50+
'Target model',
51+
{
52+
'fields': (
53+
('app_label', 'model_name', 'time_field'),
54+
('aggregate', 'aggregate_field', 'aggregate_label'),
55+
('split_field', 'filter_field', 'filters')
56+
)
57+
}
58+
),
59+
(
60+
'Time options',
61+
{
62+
'fields': (
63+
'default_time_range',
64+
'default_scale',
65+
)
66+
}
67+
),
68+
)
69+
70+
def get_urls(self):
71+
urls = super().get_urls()
72+
my_urls = [
73+
path('<int:chart_id>/chart/', self.admin_site.admin_view(self.chart_view), name='admin_chart_chart'),
74+
path('<int:chart_id>/metrics/', self.admin_site.admin_view(self.metrics_api), name='admin_chart_metrics'),
75+
]
76+
return my_urls + urls
77+
78+
def metrics_api(self, request, chart_id):
79+
chart = TimeSeriesChart.objects.get(id=chart_id)
80+
data = {
81+
'time_range': request.GET.get('time_range', chart.default_time_range),
82+
'scale': request.GET.get('scale', chart.default_scale),
83+
'filters': request.GET.get('filters', None)
84+
}
85+
86+
if chart.filter_field:
87+
form = ChartFilterForm(data)
88+
StatsModel = chart.get_stats_model()
89+
choices = StatsModel.objects.values_list(chart.filter_field, flat=True).distinct()
90+
choices = [(x, x) for x in choices]
91+
form.fields['filters'].choices = choices
92+
else:
93+
form = ChartStandardForm(data)
94+
95+
if form.is_valid():
96+
time_range = int(form.cleaned_data.get('time_range'))
97+
scale = form.cleaned_data.get('scale')
98+
filter_value = form.cleaned_data.get('filters', None)
99+
else:
100+
raise Exception('filter is invalid')
101+
102+
if scale == TimeSeriesChart.Scale.HOUR:
103+
date_format = '%Y-%m-%d %H:%M'
104+
else:
105+
date_format = '%Y-%m-%d'
106+
107+
if time_range:
108+
start_time = timezone.now() - timedelta(days=time_range)
109+
else:
110+
start_time = None
111+
stats = chart.get_queryset(start_time, scale, filter_value)
112+
113+
labels = []
114+
115+
if not chart.split_field:
116+
data = []
117+
for item in stats:
118+
labels.append(item['time'].strftime(date_format))
119+
data.append(str(item['total'] if item['total'] is not None else 0))
120+
datasets = [
121+
{
122+
'label': chart.aggregate_label,
123+
'data': data
124+
}
125+
]
126+
else:
127+
labels = []
128+
last_labels = None
129+
data = defaultdict(dict)
130+
for item in stats:
131+
label = item['time'].strftime(date_format)
132+
if item['time'] != last_labels:
133+
labels.append(label)
134+
last_labels = item['time']
135+
data[item['split']][label] = item['total']
136+
137+
split_names = data.keys()
138+
datasets = defaultdict(list)
139+
140+
for label in labels:
141+
for name in split_names:
142+
datasets[name].append(data[name].get(label, 0))
143+
144+
datasets = [{'label': k, 'data': v} for k, v in datasets.items()]
145+
146+
return JsonResponse({
147+
'chart_type': chart.chart_type,
148+
'stacked': chart.stacked,
149+
'labels': labels,
150+
'datasets': datasets
151+
})
152+
153+
def chart_view(self, request, chart_id):
154+
context = {
155+
**admin.site.each_context(request),
156+
}
157+
158+
chart = TimeSeriesChart.objects.get(id=chart_id)
159+
160+
data = {
161+
'time_range': request.GET.get('time_range', chart.default_time_range),
162+
'scale': request.GET.get('scale', chart.default_scale),
163+
}
164+
165+
if chart.filter_field:
166+
form = ChartFilterForm(data)
167+
StatsModel = chart.get_stats_model()
168+
choices = StatsModel.objects.values_list(chart.filter_field, flat=True).distinct()
169+
choices = [(x, x) for x in choices]
170+
choices = [('', 'All')] + choices
171+
form.fields['filters'].choices = choices
172+
form.fields['filters'].label = chart.filter_field.title()
173+
else:
174+
form = ChartStandardForm(data)
175+
176+
context["chart"] = chart
177+
context["chart_title"] = chart.name
178+
context["form"] = form
179+
180+
return render(request, 'admin/chart.html', context)
181+
182+
@admin.display(description='View Chart')
183+
def display_chart_url(self, obj):
184+
return format_html('<a href="/admin/admin_extended/timeserieschart/{}/chart/">View Chart</a>', obj.id)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Generated by Django 5.1 on 2024-09-12 09:59
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("admin_extended", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="TimeSeriesChart",
15+
fields=[
16+
(
17+
"id",
18+
models.BigAutoField(
19+
auto_created=True,
20+
primary_key=True,
21+
serialize=False,
22+
verbose_name="ID",
23+
),
24+
),
25+
("name", models.CharField(max_length=255)),
26+
(
27+
"description",
28+
models.CharField(
29+
blank=True, default=None, max_length=1000, null=True
30+
),
31+
),
32+
(
33+
"chart_type",
34+
models.CharField(
35+
choices=[("BAR", "Bar"), ("LINE", "Line")],
36+
default="BAR",
37+
max_length=55,
38+
),
39+
),
40+
("stacked", models.BooleanField(default=False)),
41+
(
42+
"default_time_range",
43+
models.IntegerField(
44+
choices=[
45+
(7, "Last 7 days"),
46+
(30, "Last 30 days"),
47+
(365, "Last 1 year"),
48+
(0, "All time"),
49+
],
50+
default=30,
51+
),
52+
),
53+
(
54+
"default_scale",
55+
models.CharField(
56+
choices=[
57+
("HOUR", "Hour"),
58+
("DAY", "Day"),
59+
("WEEK", "Week"),
60+
("MONTH", "Month"),
61+
],
62+
default="DAY",
63+
max_length=45,
64+
),
65+
),
66+
("app_label", models.CharField(max_length=255)),
67+
("model_name", models.CharField(max_length=255)),
68+
("time_field", models.CharField(max_length=255)),
69+
(
70+
"aggregate",
71+
models.CharField(
72+
choices=[
73+
("COUNT", "COUNT"),
74+
("SUM", "SUM"),
75+
("AVG", "AVG"),
76+
("MIN", "MIN"),
77+
("MAX", "MAX"),
78+
],
79+
max_length=45,
80+
),
81+
),
82+
("aggregate_field", models.CharField(default="*", max_length=255)),
83+
("aggregate_label", models.CharField(max_length=255)),
84+
(
85+
"split_field",
86+
models.CharField(
87+
blank=True, default=None, max_length=255, null=True
88+
),
89+
),
90+
(
91+
"filter_field",
92+
models.CharField(
93+
blank=True, default=None, max_length=255, null=True
94+
),
95+
),
96+
(
97+
"filters",
98+
models.CharField(
99+
blank=True,
100+
default=None,
101+
help_text="Filters for query. Example value: status=1&cate=3",
102+
max_length=1000,
103+
null=True,
104+
),
105+
),
106+
],
107+
),
108+
]

admin_extended/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .bookmark import Bookmark
2+
from .chart import TimeSeriesChart
File renamed without changes.

0 commit comments

Comments
 (0)