Skip to content

Commit 5134a4b

Browse files
authored
Merge pull request #21 from DonAsako/feat/analytics
Feat/analytics
2 parents 57c6a3f + 9dfd0eb commit 5134a4b

11 files changed

Lines changed: 581 additions & 237 deletions

File tree

apps/analytics/metrics.py

Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
"""Aggregations feeding the admin dashboard analytics panels.
1+
"""Aggregations feeding the dedicated Analytics admin page.
22
3-
Exposes ``add_dashboard_metrics(context)``, called by the core dashboard
4-
callback. Kept here so all page-view logic lives in the analytics app.
3+
Exposes ``full_metrics(context)``, called by the analytics view. Kept here so
4+
all page-view logic lives in the analytics app.
55
"""
66

77
from datetime import timedelta
@@ -16,6 +16,13 @@
1616
from apps.analytics.models import PageView
1717

1818

19+
def _pct_change(current, previous):
20+
"""Signed % change vs the previous equal-length window."""
21+
if not previous:
22+
return None
23+
return round((current - previous) / previous * 100)
24+
25+
1926
def _daily_series(views, days=30):
2027
"""Zero-filled daily counts (oldest -> newest) with bar heights as a
2128
percentage of the busiest day, for the sparkline / bar chart."""
@@ -37,6 +44,24 @@ def _daily_series(views, days=30):
3744
return series, peak
3845

3946

47+
def _realtime_series(views, minutes=60, bucket=5):
48+
"""Counts grouped into bucket -minute slots over the last minutes."""
49+
now = timezone.now()
50+
cur = now.replace(second=0, microsecond=0)
51+
start = cur - timedelta(minutes=minutes - 1)
52+
nslots = -(-minutes // bucket) # ceil
53+
slots = [0] * nslots
54+
for ts in views.filter(created_at__gte=start).values_list("created_at", flat=True):
55+
mi = int((ts - start).total_seconds() // 60)
56+
if 0 <= mi < minutes:
57+
slots[mi // bucket] += 1
58+
series = [{"min": start + timedelta(minutes=i * bucket), "span": bucket, "count": c} for i, c in enumerate(slots)]
59+
peak = max(slots, default=0) or 1
60+
for s in series:
61+
s["pct"] = max(round(s["count"] / peak * 100), 3) if s["count"] else 0
62+
return series, peak
63+
64+
4065
def _top_content(views, since, limit=10):
4166
"""Most-viewed content objects (resolved to title + admin/site links)."""
4267
rows = (
@@ -60,47 +85,50 @@ def _top_content(views, since, limit=10):
6085
return items
6186

6287

63-
def add_dashboard_metrics(context):
64-
"""Compact analytics for the admin dashboard landing page."""
65-
now = timezone.now()
66-
d7 = now - timedelta(days=7)
67-
d30 = now - timedelta(days=30)
68-
views = PageView.objects.all()
69-
70-
context["metrics"] = {
71-
"views_7d": views.filter(created_at__gte=d7).count(),
72-
"views_30d": views.filter(created_at__gte=d30).count(),
73-
"uniques_7d": views.filter(created_at__gte=d7).values("visitor_hash").distinct().count(),
74-
}
75-
context["top_pages"] = list(
76-
views.filter(created_at__gte=d30).values("path").annotate(n=Count("id")).order_by("-n")[:8]
77-
)
78-
context["views_series"], context["views_peak"] = _daily_series(views, days=30)
79-
80-
8188
def full_metrics(context):
8289
"""Richer analytics for the dedicated Analytics admin page."""
8390
now = timezone.now()
8491
d7 = now - timedelta(days=7)
92+
d14 = now - timedelta(days=14)
8593
d30 = now - timedelta(days=30)
94+
d60 = now - timedelta(days=60)
8695
views = PageView.objects.all()
8796

97+
v7 = views.filter(created_at__gte=d7).count()
98+
v7_prev = views.filter(created_at__gte=d14, created_at__lt=d7).count()
99+
v30 = views.filter(created_at__gte=d30).count()
100+
v30_prev = views.filter(created_at__gte=d60, created_at__lt=d30).count()
101+
u7 = views.filter(created_at__gte=d7).values("visitor_hash").distinct().count()
102+
u7_prev = views.filter(created_at__gte=d14, created_at__lt=d7).values("visitor_hash").distinct().count()
103+
u30 = views.filter(created_at__gte=d30).values("visitor_hash").distinct().count()
104+
u30_prev = views.filter(created_at__gte=d60, created_at__lt=d30).values("visitor_hash").distinct().count()
105+
88106
metrics = {
89-
"views_7d": views.filter(created_at__gte=d7).count(),
90-
"views_30d": views.filter(created_at__gte=d30).count(),
107+
"views_7d": v7,
108+
"views_30d": v30,
91109
"views_all": views.count(),
92-
"uniques_7d": views.filter(created_at__gte=d7).values("visitor_hash").distinct().count(),
93-
"uniques_30d": views.filter(created_at__gte=d30).values("visitor_hash").distinct().count(),
110+
"uniques_7d": u7,
111+
"uniques_30d": u30,
94112
}
95113
context["metrics"] = metrics
114+
# (label, value, delta%) — delta is None for the all-time card (no baseline).
96115
context["cards"] = [
97-
(_("Views (7 days)"), metrics["views_7d"]),
98-
(_("Views (30 days)"), metrics["views_30d"]),
99-
(_("Views (all time)"), metrics["views_all"]),
100-
(_("Unique visitors (7 days)"), metrics["uniques_7d"]),
101-
(_("Unique visitors (30 days)"), metrics["uniques_30d"]),
116+
(_("Views (7 days)"), v7, _pct_change(v7, v7_prev)),
117+
(_("Views (30 days)"), v30, _pct_change(v30, v30_prev)),
118+
(_("Views (all time)"), metrics["views_all"], None),
119+
(_("Unique visitors (7 days)"), u7, _pct_change(u7, u7_prev)),
120+
(_("Unique visitors (30 days)"), u30, _pct_change(u30, u30_prev)),
102121
]
103122
context["views_series"], context["views_peak"] = _daily_series(views, days=30)
123+
124+
rt = views.filter(created_at__gte=now - timedelta(hours=1))
125+
context["realtime"] = {
126+
"views_1h": rt.count(),
127+
"uniques_1h": rt.values("visitor_hash").distinct().count(),
128+
}
129+
context["realtime_series"], context["realtime_peak"] = _realtime_series(views, minutes=60, bucket=5)
130+
context["realtime_pages"] = list(rt.values("path").annotate(n=Count("id")).order_by("-n")[:5])
131+
104132
context["top_content"] = _top_content(views, d30, limit=10)
105133
context["top_pages"] = list(
106134
views.filter(created_at__gte=d30).values("path").annotate(n=Count("id")).order_by("-n")[:15]
@@ -112,3 +140,10 @@ def full_metrics(context):
112140
.annotate(n=Count("id"))
113141
.order_by("-n")[:10]
114142
)
143+
context["top_campaigns"] = list(
144+
views.filter(created_at__gte=d30)
145+
.exclude(utm_source="")
146+
.values("utm_source", "utm_medium", "utm_campaign")
147+
.annotate(n=Count("id"))
148+
.order_by("-n")[:10]
149+
)

apps/analytics/middleware.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def _record(self, request, response):
3737
if any(path.startswith(p) for p in self._excluded):
3838
return
3939

40-
from apps.analytics.tracking import is_bot, visitor_hash
40+
from apps.analytics.tracking import external_referrer, is_bot, visitor_hash
4141

4242
if is_bot(request.META.get("HTTP_USER_AGENT", "")):
4343
return
@@ -61,5 +61,8 @@ def _record(self, request, response):
6161
object_id=getattr(obj, "pk", None),
6262
path=path[:512],
6363
visitor_hash=vhash,
64-
referrer=(request.META.get("HTTP_REFERER", "") or "")[:512],
64+
referrer=external_referrer(request)[:512],
65+
utm_source=request.GET.get("utm_source", "")[:128],
66+
utm_medium=request.GET.get("utm_medium", "")[:128],
67+
utm_campaign=request.GET.get("utm_campaign", "")[:128],
6568
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Generated by Django 6.0.4 on 2026-06-08 15:36
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("analytics", "0001_initial"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="pageview",
15+
name="utm_campaign",
16+
field=models.CharField(blank=True, max_length=128),
17+
),
18+
migrations.AddField(
19+
model_name="pageview",
20+
name="utm_medium",
21+
field=models.CharField(blank=True, max_length=128),
22+
),
23+
migrations.AddField(
24+
model_name="pageview",
25+
name="utm_source",
26+
field=models.CharField(blank=True, max_length=128),
27+
),
28+
]

apps/analytics/models.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
class PageView(models.Model):
88
"""A single (de-duplicated) page visit.
99
10-
Recorded server-side by ``PageViewMiddleware`` so it is immune to ad
11-
blockers. No personal data is stored: ``visitor_hash`` is a non-reversible,
10+
Recorded server-side by `PageViewMiddleware` so it is immune to ad
11+
blockers. No personal data is stored: `visitor_hash` is a non-reversible,
1212
daily-rotating hash of IP + user-agent used only to estimate unique
1313
visitors. When the visited page is a content object (article, writeup…),
1414
the generic FK links the view to it for per-object counts.
@@ -21,6 +21,9 @@ class PageView(models.Model):
2121
path = models.CharField(max_length=512, db_index=True)
2222
visitor_hash = models.CharField(max_length=64, db_index=True)
2323
referrer = models.CharField(max_length=512, blank=True)
24+
utm_source = models.CharField(max_length=128, blank=True)
25+
utm_medium = models.CharField(max_length=128, blank=True)
26+
utm_campaign = models.CharField(max_length=128, blank=True)
2427
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
2528

2629
class Meta:

apps/analytics/tracking.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import hashlib
1010
import re
11+
from urllib.parse import urlsplit
1112

1213
from django.conf import settings
1314
from django.utils import timezone
@@ -24,6 +25,14 @@ def is_bot(user_agent):
2425
return not user_agent or bool(_BOT_RE.search(user_agent))
2526

2627

28+
def external_referrer(request):
29+
"""HTTP referer, blanked when it points back at our own host"""
30+
ref = request.META.get("HTTP_REFERER", "") or ""
31+
if ref and urlsplit(ref).netloc == request.get_host():
32+
return ""
33+
return ref
34+
35+
2736
def client_ip(request):
2837
xff = request.META.get("HTTP_X_FORWARDED_FOR")
2938
if xff:

apps/core/dashboard.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from django.urls import NoReverseMatch, reverse
1010
from django.utils.translation import gettext_lazy as _
1111

12-
from apps.analytics.metrics import add_dashboard_metrics
1312
from apps.core.models import SiteSettings
1413
from apps.core.updates import get_update_info
1514

@@ -33,8 +32,6 @@ def _change_url(entry):
3332

3433

3534
def dashboard_callback(request, context):
36-
add_dashboard_metrics(context)
37-
3835
site_settings = SiteSettings.objects.first()
3936
if site_settings and site_settings.check_for_updates:
4037
context["update_info"] = get_update_info()

eskoz/settings/base.py

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,6 @@ def _unfold_account_links(request):
158158
"icon": "rocket_launch",
159159
"link": reverse_lazy("admin:blog_project_changelist"),
160160
},
161-
{
162-
"title": _("Categories"),
163-
"icon": "category",
164-
"link": reverse_lazy("admin:blog_category_changelist"),
165-
},
166-
{"title": _("Tags"), "icon": "label", "link": reverse_lazy("admin:blog_articletag_changelist")},
167161
],
168162
},
169163
{
@@ -172,24 +166,12 @@ def _unfold_account_links(request):
172166
"collapsible": True,
173167
"items": [
174168
{"title": _("Writeups"), "icon": "flag", "link": reverse_lazy("admin:infosec_writeup_changelist")},
175-
{"title": _("CTF"), "icon": "emoji_events", "link": reverse_lazy("admin:infosec_ctf_changelist")},
176169
{"title": _("CVE"), "icon": "bug_report", "link": reverse_lazy("admin:infosec_cve_changelist")},
177170
{
178171
"title": _("Certifications"),
179172
"icon": "workspace_premium",
180173
"link": reverse_lazy("admin:infosec_certification_changelist"),
181174
},
182-
{
183-
"title": _("Issuers"),
184-
"icon": "business",
185-
"link": reverse_lazy("admin:infosec_issuer_changelist"),
186-
},
187-
{
188-
"title": _("Categories"),
189-
"icon": "category",
190-
"link": reverse_lazy("admin:infosec_category_changelist"),
191-
},
192-
{"title": _("Tags"), "icon": "label", "link": reverse_lazy("admin:infosec_writeuptag_changelist")},
193175
],
194176
},
195177
{
@@ -246,18 +228,21 @@ def _unfold_account_links(request):
246228
"TABS": [
247229
# CTF-related content
248230
{
249-
"models": ["infosec.writeup", "infosec.ctf"],
231+
"models": ["infosec.writeup", "infosec.ctf", "infosec.category", "infosec.writeuptag"],
250232
"items": [
251233
{"title": _("Writeups"), "link": reverse_lazy("admin:infosec_writeup_changelist")},
252234
{"title": _("CTF"), "link": reverse_lazy("admin:infosec_ctf_changelist")},
235+
{"title": _("Categories"), "link": reverse_lazy("admin:infosec_category_changelist")},
236+
{"title": _("Tags"), "link": reverse_lazy("admin:infosec_writeuptag_changelist")},
253237
],
254238
},
255239
# Blog content
256240
{
257-
"models": ["blog.article", "blog.project"],
241+
"models": ["blog.article", "blog.category", "blog.articletag"],
258242
"items": [
259243
{"title": _("Articles"), "link": reverse_lazy("admin:blog_article_changelist")},
260-
{"title": _("Projects"), "link": reverse_lazy("admin:blog_project_changelist")},
244+
{"title": _("Categories"), "link": reverse_lazy("admin:blog_category_changelist")},
245+
{"title": _("Article Tags"), "link": reverse_lazy("admin:blog_articletag_changelist")},
261246
],
262247
},
263248
# Certifications & Issuers
@@ -270,21 +255,12 @@ def _unfold_account_links(request):
270255
},
271256
# Blog taxonomy
272257
{
273-
"models": ["blog.category", "blog.articletag", "blog.projecttag"],
258+
"models": ["blog.project", "blog.projecttag"],
274259
"items": [
275-
{"title": _("Categories"), "link": reverse_lazy("admin:blog_category_changelist")},
276-
{"title": _("Article Tags"), "link": reverse_lazy("admin:blog_articletag_changelist")},
260+
{"title": _("Projects"), "link": reverse_lazy("admin:blog_project_changelist")},
277261
{"title": _("Project Tags"), "link": reverse_lazy("admin:blog_projecttag_changelist")},
278262
],
279263
},
280-
# Infosec taxonomy
281-
{
282-
"models": ["infosec.category", "infosec.writeuptag"],
283-
"items": [
284-
{"title": _("Categories"), "link": reverse_lazy("admin:infosec_category_changelist")},
285-
{"title": _("Tags"), "link": reverse_lazy("admin:infosec_writeuptag_changelist")},
286-
],
287-
},
288264
],
289265
}
290266

0 commit comments

Comments
 (0)