Skip to content

Commit 4d45112

Browse files
Add /stats/ page with activity charts over time + track user-action metrics (aggregate only, no per-user tracking.) (#391)
1 parent 03c3800 commit 4d45112

18 files changed

Lines changed: 562 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This page tries to contain all use facing changes made on DocHub.
44

55
# Unreleased
66

7+
* Add a /stats/ page with charts of DocHub activity over time
8+
* Track more activity on /stats/ (views, downloads, searches, follows, pages traffic, feature usage, ...)
79
* Add document moderation history view showing all actions taken on a document
810
* Fix login errors when ULB changes your email or netid
911
* Log CAS authentication failures in the admin for easier debugging

catalog/views.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from catalog.models import Category, Course, CourseUserView
1212
from catalog.slug import normalize_slug
1313
from documents.models import Vote
14+
from stats.models import DailyStat, Metric
1415

1516

1617
def slug_redirect(view):
@@ -60,6 +61,7 @@ def show_course(request, slug: str):
6061
if request.user.is_authenticated:
6162
template = "catalog/course.html"
6263
CourseUserView.visit(request.user, course)
64+
DailyStat.track(Metric.COURSE_PAGE_VIEW)
6365
else:
6466
template = "catalog/noauth/course.html"
6567

@@ -73,8 +75,10 @@ def set_follow_course(request, slug: str, action: str) -> HttpResponse:
7375
course = get_object_or_404(Course, slug=slug)
7476
if action == "follow":
7577
course.followed_by.add(request.user)
78+
DailyStat.track(Metric.COURSE_FOLLOW)
7679
else:
7780
course.followed_by.remove(request.user)
81+
DailyStat.track(Metric.COURSE_UNFOLLOW)
7882
course.save()
7983
nextpage = request.GET.get("next", reverse("catalog:course_show", args=[slug]))
8084
return HttpResponseRedirect(nextpage)
@@ -97,6 +101,7 @@ def leave_course(request: HttpRequest, slug: str):
97101
@login_required
98102
def my_courses(request):
99103
# "suggestions": suggest(request.user),
104+
DailyStat.track(Metric.MY_COURSES_VIEW)
100105
return render(
101106
request,
102107
"catalog/my_courses.html",
@@ -125,6 +130,11 @@ class Column:
125130

126131
def finder(request, slugs: str = ""):
127132
slug_list = slugs.split("/")
133+
134+
DailyStat.track(Metric.FINDER_VIEW)
135+
if len(slug_list) >= 3:
136+
DailyStat.track(Metric.FINDER_VIEW_DEEP)
137+
128138
categories = [get_object_or_404(Category, slug=x) for x in slug_list]
129139

130140
for i in range(len(categories) - 1, 0, -1):

documents/views.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
)
2626
from documents.models import BulkDocuments, Document, Vote
2727
from moderation.models import ModerationLog
28+
from stats.models import DailyStat, Metric
2829

2930

3031
def _document_form_for_user(user, document, *args, **kwargs):
@@ -43,6 +44,8 @@ def upload_file(request, slug):
4344
if settings.READ_ONLY:
4445
return HttpResponse("Upload is disabled for a few hours", status=401)
4546

47+
DailyStat.track(Metric.UPLOAD_SUBMIT)
48+
4649
form = UploadFileForm(request.POST, request.FILES)
4750

4851
if form.is_valid():
@@ -101,6 +104,8 @@ def document_edit(request, pk):
101104
if settings.READ_ONLY:
102105
return HttpResponse("Upload is disabled for a few hours", status=401)
103106

107+
DailyStat.track(Metric.DOCUMENT_EDIT)
108+
104109
if "hide" in request.POST:
105110
if request.user != doc.user:
106111
ModerationLog.track(
@@ -208,6 +213,8 @@ def document_reupload(request, pk):
208213
if settings.READ_ONLY:
209214
return HttpResponse("Upload is disabled for a few hours", status=401)
210215

216+
DailyStat.track(Metric.DOCUMENT_REUPLOAD)
217+
211218
form = ReUploadForm(request.POST, request.FILES)
212219

213220
if form.is_valid():
@@ -254,6 +261,7 @@ def document_show(request, pk):
254261
if document.state == Document.DocumentState.DONE:
255262
document.views = F("views") + 1
256263
document.save(update_fields=["views"])
264+
DailyStat.track(Metric.DOCUMENT_VIEW)
257265

258266
context = {
259267
"document": document,
@@ -304,6 +312,7 @@ def document_original_file(request, pk):
304312

305313
document.downloads = F("downloads") + 1
306314
document.save(update_fields=["downloads"])
315+
DailyStat.track(Metric.DOCUMENT_DOWNLOAD)
307316
return response
308317

309318

@@ -327,6 +336,7 @@ def document_pdf_file(request, pk):
327336

328337
document.downloads = F("views") + 1
329338
document.save(update_fields=["views"])
339+
DailyStat.track(Metric.DOCUMENT_DOWNLOAD)
330340
return response
331341

332342

moderation/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
RepresentativeRequestForm,
2020
)
2121
from moderation.models import ModerationLog, RepresentativeRequest
22+
from stats.models import DailyStat, Metric
2223

2324
User = get_user_model()
2425

@@ -127,6 +128,7 @@ def process_representative_request(request, request_id):
127128
@moderator_required
128129
def manage_moderators(request):
129130
"""Display moderator requests and direct promotion controls."""
131+
DailyStat.track(Metric.MODERATION_MANAGE_VIEW)
130132
pending_requests = (
131133
RepresentativeRequest.objects.filter(processed=False)
132134
.select_related("user")
@@ -256,6 +258,8 @@ def representative_request(request):
256258
def public_logs(request):
257259
"""Public ledger of moderation actions for accountability (accessible to all logged users)."""
258260

261+
DailyStat.track(Metric.MODERATION_LOG_VIEW)
262+
259263
# Fetch all relevant moderation logs
260264
log_list = (
261265
ModerationLog.objects.select_related("user", "content_type")
@@ -278,6 +282,7 @@ def public_logs(request):
278282
@login_required
279283
def moderation_tree(request):
280284
"""Public page showing who promoted whom as moderator."""
285+
DailyStat.track(Metric.MODERATION_TREE_VIEW)
281286
moderators = (
282287
User.objects.filter(Q(is_staff=True) | Q(is_moderator=True))
283288
.select_related("promoted_by")
@@ -316,6 +321,8 @@ def moderation_profile(request, netid):
316321
):
317322
raise PermissionDenied("Ce profil n'a pas d'activité de modération publique.")
318323

324+
DailyStat.track(Metric.MODERATION_PROFILE_VIEW)
325+
319326
logs = (
320327
ModerationLog.objects.filter(user=profile_user)
321328
.select_related("content_type")
@@ -332,12 +339,17 @@ def moderation_profile(request, netid):
332339
@login_required
333340
def moderation_about(request):
334341
"""Public page explaining the moderation system."""
342+
if is_moderator(request.user):
343+
DailyStat.track(Metric.MODERATION_ABOUT_VIEW_MOD)
344+
else:
345+
DailyStat.track(Metric.MODERATION_ABOUT_VIEW)
335346
return render(request, "moderation/about.html")
336347

337348

338349
@login_required
339350
def document_history(request, pk):
340351
"""Moderation history for a single document — all actions by all moderators."""
352+
DailyStat.track(Metric.DOCUMENT_HISTORY_VIEW)
341353
document = get_object_or_404(Document, pk=pk)
342354
content_type = ContentType.objects.get_for_model(Document)
343355
logs = (

search/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import search.logic
66
from catalog.models import Course
7+
from stats.models import DailyStat, Metric
78

89

910
class CourseSearchView(ListView):
@@ -13,6 +14,8 @@ class CourseSearchView(ListView):
1314

1415
def get_queryset(self):
1516
query = self.request.GET.get("q", "")
17+
if query:
18+
DailyStat.track(Metric.SEARCH_QUERY)
1619
return search.logic.search_course(query)
1720

1821
def get_context_data(self, **kwargs):

static/main.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,55 @@ class ModalTrigger extends Controller {
320320
}
321321
}
322322

323+
class Chart extends Controller {
324+
static values = {
325+
data: Array,
326+
label: String,
327+
labelsId: String,
328+
}
329+
330+
async connect() {
331+
const ChartJS = (await import('https://cdn.jsdelivr.net/npm/chart.js@4.4.4/auto/+esm')).default;
332+
333+
const labels = JSON.parse(document.getElementById(this.labelsIdValue).textContent);
334+
335+
this.chart = new ChartJS(this.element, {
336+
type: "line",
337+
data: {
338+
labels,
339+
datasets: [{
340+
label: this.labelValue,
341+
data: this.dataValue,
342+
borderColor: "#0d6efd",
343+
backgroundColor: "rgba(13, 110, 253, 0.1)",
344+
fill: true,
345+
tension: 0,
346+
pointRadius: 0,
347+
borderWidth: 1.5,
348+
}],
349+
},
350+
options: {
351+
responsive: true,
352+
maintainAspectRatio: true,
353+
aspectRatio: 2.5,
354+
animation: false,
355+
interaction: {mode: "index", intersect: false},
356+
plugins: {legend: {display: false}},
357+
scales: {
358+
y: {beginAtZero: true, ticks: {precision: 0}},
359+
x: {ticks: {maxTicksLimit: 8, maxRotation: 0}},
360+
},
361+
},
362+
});
363+
}
364+
365+
disconnect() {
366+
if (this.chart) {
367+
this.chart.destroy();
368+
}
369+
}
370+
}
371+
323372
const application = Application.start()
324373

325374
application.register("course-filter", CourseFilter);
@@ -331,5 +380,6 @@ application.register('tom-select', TomSelect);
331380
application.register('share', Share);
332381
application.register('modal', Modal);
333382
application.register('modal-trigger', ModalTrigger);
383+
application.register('chart', Chart);
334384

335385
application.debug = true;

stats/__init__.py

Whitespace-only changes.

stats/apps.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
3+
4+
class StatsConfig(AppConfig):
5+
name = "stats"
6+
7+
def ready(self):
8+
from stats import signals # noqa: F401, PLC0415

stats/migrations/0001_initial.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Generated by Django 6.0.5 on 2026-05-17 19:38
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
initial = True
9+
10+
dependencies = []
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name="DailyStat",
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+
("date", models.DateField()),
26+
(
27+
"name",
28+
models.CharField(
29+
choices=[
30+
("document_view", "Vues de documents"),
31+
("document_download", "Téléchargements"),
32+
("search_query", "Recherches"),
33+
("course_page_view", "Vues de cours"),
34+
("login_success", "Connexions"),
35+
("course_follow", "Cours suivis"),
36+
("course_unfollow", "Cours dé-suivis"),
37+
("document_edit", "Éditions de documents"),
38+
("stats_view", "Vues de la page stats"),
39+
("moderation_log_view", "Vues du log de modération"),
40+
("moderation_tree_view", "Vues de l'arbre de modération"),
41+
("upload_submit", "Soumissions d'upload"),
42+
("document_reupload", "Réuploads de documents"),
43+
("my_courses_view", 'Vues de "Mes cours"'),
44+
(
45+
"moderation_profile_view",
46+
"Vues de profils de modérateur·trices",
47+
),
48+
("document_history_view", "Vues d'historique de documents"),
49+
(
50+
"moderation_about_view",
51+
"Vues de la page modération (utilisateurs)",
52+
),
53+
(
54+
"moderation_about_view_mod",
55+
"Vues de la page modération (modérateur·trices)",
56+
),
57+
("finder_view", "Vues du finder"),
58+
("finder_view_deep", "Vues du finder (profondeur ≥ 3)"),
59+
(
60+
"moderation_manage_view",
61+
"Vues de la gestion des modérateur·trices",
62+
),
63+
],
64+
max_length=64,
65+
),
66+
),
67+
("value", models.PositiveIntegerField(default=0)),
68+
],
69+
options={
70+
"indexes": [
71+
models.Index(
72+
fields=["name", "date"], name="stats_daily_name_85da59_idx"
73+
)
74+
],
75+
"constraints": [
76+
models.UniqueConstraint(
77+
fields=("date", "name"), name="unique_dailystat"
78+
)
79+
],
80+
},
81+
),
82+
]

stats/migrations/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)