repos
/ analytics-django master

analytics-django

mirror archived upstream

Self-hostable website analytics on Django: a straightforward collector API, dashboards, a world map, and PDF reports.

analyticsdjangodockerhandcodedpythonself-hostedsqliteviteweb-analytics

10.5 KB · 279 lines · Python Raw History
  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)