Self-hostable website analytics on Django: a straightforward collector API, dashboards, a world map, and PDF reports.
analyticsdjangodockerhandcodedpythonself-hostedsqliteviteweb-analytics
1import json
2import uuid
3
4from django.conf import settings
5from django.contrib import messages
6from django.core.cache import cache
7from django.core.files.storage import default_storage
8from django.http import HttpResponse, JsonResponse
9from django.shortcuts import redirect, render
10from django.template.loader import render_to_string
11from django.utils import timezone
12
13from analytics.chromium import generate_pdf_from_html
14
15from . import queries as q
16from .forms import PropertyForm
17from .models import Property
18
19
20DASHBOARD_CACHE_TTL = 300 # seconds
21
22
23def _chart_polyline(points, width=600, height=100, padding=4):
24 """SVG polyline points string for the events-over-time line chart on the
25 printed report. Toner-friendly: thin black stroke, no fill, no gradient."""
26 if not points:
27 return ""
28 counts = [p["count"] for p in points]
29 max_c = max(counts) or 1
30 n = len(counts)
31 usable_h = height - 2 * padding
32 if n == 1:
33 x = width / 2
34 y = height - padding - (counts[0] / max_c) * usable_h
35 return f"{x:.1f},{y:.1f}"
36 return " ".join(
37 f"{(i / (n - 1)) * width:.1f},{height - padding - (c / max_c) * usable_h:.1f}"
38 for i, c in enumerate(counts)
39 )
40
41
42def _breakdown_total(rows):
43 return sum(r["count"] for r in rows) or 1
44
45
46def properties(request):
47 if not request.user.is_authenticated:
48 return redirect("/")
49
50 if request.method == "POST":
51 form = PropertyForm(request.POST)
52 if form.is_valid():
53 new_property = form.save(commit=False)
54 new_property.user = request.user
55 new_property.save()
56 messages.success(request, "Property added successfully.")
57 return redirect("properties")
58 else:
59 form = PropertyForm()
60
61 properties = request.user.properties.all()
62 search = request.GET.get("q", None)
63 if search:
64 properties = properties.filter(name__icontains=search)
65
66 return render(
67 request,
68 "properties/properties.html",
69 {
70 "form": form,
71 "title": "Properties",
72 "description": "Manage your properties.",
73 "properties": properties,
74 "q": search,
75 },
76 )
77
78
79def property_delete(request, property_id):
80 if not request.user.is_authenticated:
81 return redirect("/")
82
83 try:
84 property_obj = request.user.properties.get(pk=property_id)
85 except Property.DoesNotExist:
86 return redirect("properties")
87
88 property_obj.delete()
89 messages.success(request, "Property deleted successfully.")
90 return redirect("properties")
91
92
93def adjust_custom_event_cards(request, property_id):
94 if not request.user.is_authenticated:
95 return redirect("/")
96
97 try:
98 property_obj = request.user.properties.get(pk=property_id)
99 except Property.DoesNotExist:
100 return redirect("properties")
101
102 if request.method == "POST":
103 custom_cards = json.loads(request.body.decode("utf-8"))
104 property_obj.custom_cards = custom_cards
105 property_obj.save()
106 return JsonResponse({"success": True})
107
108 return JsonResponse({"success": False})
109
110
111def adjust_is_public_property(request, property_id):
112 if not request.user.is_authenticated:
113 return redirect("/")
114
115 try:
116 property_obj = request.user.properties.get(pk=property_id)
117 except Property.DoesNotExist:
118 return redirect("properties")
119
120 if request.method == "POST":
121 property_obj.is_public = property_obj.is_public is False
122 property_obj.save()
123 return JsonResponse({"success": True})
124
125 return JsonResponse({"success": False})
126
127
128def _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, filter_url):
129 """
130 Heavy context built from DB queries. Cacheable — no request-specific state.
131 """
132 events_all = property_obj.events.filter(
133 created_at__gte=date_start_obj, created_at__lte=date_end_obj
134 )
135 if filter_url:
136 events_all = events_all.filter(data__url=filter_url)
137
138 events_filtered = q.human_events(events_all)
139
140 date_start_obj_prev = date_start_obj - timezone.timedelta(days=date_range)
141 date_end_obj_prev = date_end_obj - timezone.timedelta(days=date_range)
142 events_filtered_prev = q.human_events(
143 property_obj.events.filter(
144 created_at__gte=date_start_obj_prev, created_at__lte=date_end_obj_prev
145 )
146 )
147 if filter_url:
148 events_filtered_prev = events_filtered_prev.filter(data__url=filter_url)
149
150 event_cards = list(q.standard_event_cards(events_filtered, events_filtered_prev))
151 custom_cards, custom_events = q.custom_event_cards(
152 property_obj, events_filtered, events_filtered_prev
153 )
154 event_cards.extend(custom_cards)
155
156 return {
157 "event_cards": event_cards,
158 "custom_events": custom_events,
159 "total_events_graph": q.events_graph(events_filtered, date_end_obj, date_range),
160 "total_events_by_screen_size": q.events_by_screen_size(events_filtered),
161 "total_events_by_device": q.events_by_device(events_filtered),
162 "total_events_by_browser": q.events_by_browser(events_filtered),
163 "total_events_by_platform": q.events_by_platform(events_filtered),
164 "total_events_by_page_url": q.events_by_page_url(events_filtered),
165 "total_page_views_by_page_url": q.page_views_by_page_url(events_filtered),
166 "total_events_by_custom_event": q.events_by_custom_event(events_filtered),
167 "total_session_starts_by_referrer": q.session_starts_by_referrer(events_filtered),
168 "total_page_views_by_utm_medium": q.page_views_by_utm(events_filtered, "medium"),
169 "total_page_views_by_utm_source": q.page_views_by_utm(events_filtered, "source"),
170 "total_page_views_by_utm_campaign": q.page_views_by_utm(events_filtered, "campaign"),
171 "session_starts_by_country": q.session_starts_by_country(events_filtered),
172 "session_starts_by_country_region": q.session_starts_by_country_region(events_filtered),
173 "bot_traffic": q.bot_traffic(events_all),
174 }
175
176
177def property(request, property_id):
178 try:
179 property_obj = Property.objects.get(pk=property_id)
180 except Property.DoesNotExist:
181 return redirect("properties")
182
183 if not property_obj.is_public and property_obj.user != request.user:
184 return redirect("properties")
185
186 date_start = request.GET.get(
187 "date_start",
188 (timezone.now() - timezone.timedelta(days=28)).strftime("%Y-%m-%d"),
189 )
190 date_end = request.GET.get("date_end", timezone.now().strftime("%Y-%m-%d"))
191 date_range = request.GET.get("date_range", 28)
192
193 date_start_obj = timezone.make_aware(
194 timezone.datetime.strptime(date_start, "%Y-%m-%d"),
195 timezone.get_current_timezone(),
196 )
197 date_end_obj = timezone.make_aware(
198 timezone.datetime.strptime(date_end, "%Y-%m-%d")
199 + timezone.timedelta(hours=23, minutes=59, seconds=59),
200 timezone.get_current_timezone(),
201 )
202
203 if date_range == "custom":
204 date_range = (date_end_obj - date_start_obj).days
205 else:
206 date_range = int(date_range)
207
208 filter_url = request.GET.get("filter_url", None)
209
210 # Heavy DB work goes through a 5-min cache. Live users stays uncached so
211 # "live" actually means live. ?report bypasses the cache so exports match
212 # whatever the user sees in the dashboard right now.
213 # Include updated_at so custom-cards/visibility changes bust the cache.
214 ver = int(property_obj.updated_at.timestamp())
215 cache_key = (
216 f"dash:{property_obj.id}:{ver}:{date_start}:{date_end}:{date_range}:{filter_url or ''}"
217 )
218 if "report" in request.GET:
219 dashboard = _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, filter_url)
220 else:
221 dashboard = cache.get(cache_key)
222 if dashboard is None:
223 dashboard = _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, filter_url)
224 cache.set(cache_key, dashboard, DASHBOARD_CACHE_TTL)
225
226 context = {
227 "property": property_obj,
228 "title": property_obj.name,
229 "description": "Analytics for " + property_obj.name,
230 "BASE_URL": settings.BASE_URL,
231 "date_start": date_start,
232 "date_end": date_end,
233 "date_range": date_range,
234 "filter_url": filter_url,
235 "total_live_users": q.total_live_users(property_obj),
236 **dashboard,
237 }
238
239 if "report" in request.GET:
240 fmt = request.GET.get("report") or "pdf"
241 if fmt == "md":
242 md = render_to_string("properties/property_report.md", context)
243 response = HttpResponse(md, content_type="text/markdown; charset=utf-8")
244 response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"'
245 return response
246 if fmt == "pdf":
247 graph = context.get("total_events_graph") or []
248 peak = max(graph, key=lambda p: p["count"]) if graph else None
249 country_counts = context.get("session_starts_by_country") or {}
250 top_countries = sorted(
251 ({"label": code, "count": count} for code, count in country_counts.items()),
252 key=lambda r: r["count"],
253 reverse=True,
254 )[:10]
255 print_context = {
256 **context,
257 "chart_polyline": _chart_polyline(graph),
258 "chart_label_start": graph[0]["label"] if graph else "",
259 "chart_label_end": graph[-1]["label"] if graph else "",
260 "chart_peak_count": peak["count"] if peak else 0,
261 "chart_peak_label": peak["label"] if peak else "",
262 "breakdown_totals": {
263 "device": _breakdown_total(context.get("total_events_by_device") or []),
264 "browser": _breakdown_total(context.get("total_events_by_browser") or []),
265 "platform": _breakdown_total(context.get("total_events_by_platform") or []),
266 "screen_size": _breakdown_total(context.get("total_events_by_screen_size") or []),
267 },
268 "top_countries": top_countries,
269 }
270 html = render_to_string("properties/property_print.html", print_context)
271 filename = f"reports/{uuid.uuid4()}.pdf"
272 generate_pdf_from_html(html, filename)
273 with open(default_storage.path(filename), "rb") as pdf:
274 response = HttpResponse(pdf.read(), content_type="application/pdf")
275 response["Content-Disposition"] = "inline; filename=report.pdf"
276 return response
277
278 return render(request, "properties/property.html", context)