Skip to content

Commit 228b72f

Browse files
Add document moderation history view (#385)
1 parent 09142a4 commit 228b72f

11 files changed

Lines changed: 264 additions & 16 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ docker/.*
2727
.env
2828
programs.json
2929
courses.json
30+
.playwright-mcp/

CHANGELOG.md

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

55
# Unreleased
66

7+
* Add document moderation history view showing all actions taken on a document
78
* Fix login errors when ULB changes your email or netid
89
* Log CAS authentication failures in the admin for easier debugging
910

documents/templates/documents/document_edit.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ <h5 class="card-header">Modération</h5>
3939
Staff pick
4040
</label>
4141
<div class="form-text">
42-
En tant que modérateur, tu peux marquer ce document comme un "staff pick".<br/>
42+
En tant que modérateur, tu peux marquer ce document comme staff pick.<br/>
4343
Cela signifie que l'équipe de modération de DocHub considère ce document comme
4444
étant d'une grande qualité et méritant d'être mis en avant.<br/>
45-
Il apparaitra dans la liste des documents avec cette icône :
45+
Il apparaîtra dans la liste des documents avec cette icône :
4646
<svg title="Staff pick" xmlns="http://www.w3.org/2000/svg"
4747
width="16" height="16" fill="currentColor"
4848
class="bi bi-patch-check-fill" viewBox="0 0 16 16">

documents/templates/documents/viewer.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
7474
Modifier
7575
</a>
7676
{% endif %}
77+
{% if has_moderation_history %}
78+
<a class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center gap-1"
79+
href="{% url 'moderation_document_history' document.pk %}">
80+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
81+
class="bi bi-clock-history" viewBox="0 0 16 16">
82+
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022zm2.004.45a7 7 0 0 0-.985-.299l.219-.976q.576.129 1.126.342zm1.37.71a7 7 0 0 0-.439-.27l.493-.87a8 8 0 0 1 .979.654l-.615.789a7 7 0 0 0-.418-.302zm1.834 1.79a7 7 0 0 0-.653-.796l.724-.69q.406.429.747.91zm.744 1.352a7 7 0 0 0-.214-.468l.893-.45a8 8 0 0 1 .45 1.088l-.95.313a7 7 0 0 0-.179-.483m.53 2.507a7 7 0 0 0-.1-1.025l.985-.17q.1.58.116 1.17zm-.131 1.538q.05-.254.081-.51l.993.123a8 8 0 0 1-.23 1.155l-.964-.267q.069-.247.12-.501m-.952 2.379q.276-.436.486-.908l.914.405q-.24.54-.555 1.038zm-.964 1.205q.183-.183.35-.378l.758.653a8 8 0 0 1-.401.432z"/>
83+
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0z"/>
84+
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5"/>
85+
</svg>
86+
Historique
87+
</a>
88+
{% endif %}
7789

7890
<button data-controller="share" data-share-url="{{ document.get_absolute_url }}"
7991
data-action="click->share#share"

documents/views.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.conf import settings
55
from django.contrib import messages
66
from django.contrib.auth.decorators import login_required
7+
from django.contrib.contenttypes.models import ContentType
78
from django.db.models import F
89
from django.http import Http404, HttpResponse, HttpResponseRedirect
910
from django.shortcuts import get_object_or_404, redirect, render
@@ -26,6 +27,13 @@
2627
from moderation.models import ModerationLog
2728

2829

30+
def _document_form_for_user(user, document, *args, **kwargs):
31+
form = DocumentForm(*args, instance=document, **kwargs)
32+
if not user.moderation_perm(document):
33+
form.fields.pop("staff_pick", None)
34+
return form
35+
36+
2937
@login_required
3038
@slug_redirect
3139
def upload_file(request, slug):
@@ -128,21 +136,38 @@ def document_edit(request, pk):
128136
old_name = doc.name
129137
old_description = doc.description
130138
old_tags = list(doc.tags.all())
139+
old_staff_pick = doc.staff_pick
131140

132-
form = DocumentForm(request.POST, instance=doc)
141+
can_staff_pick = request.user.moderation_perm(doc)
142+
form = _document_form_for_user(request.user, doc, request.POST)
133143

134144
if form.is_valid():
135145
if request.user != doc.user:
146+
values = {
147+
"name": (old_name, form.cleaned_data["name"]),
148+
"description": (
149+
old_description,
150+
form.cleaned_data["description"],
151+
),
152+
"tags": (old_tags, form.cleaned_data["tags"]),
153+
}
154+
if can_staff_pick:
155+
values["staff_pick"] = (
156+
old_staff_pick,
157+
form.cleaned_data["staff_pick"],
158+
)
159+
ModerationLog.track(
160+
user=request.user, content_object=doc, values=values
161+
)
162+
elif can_staff_pick:
136163
ModerationLog.track(
137164
user=request.user,
138165
content_object=doc,
139166
values={
140-
"name": (old_name, form.cleaned_data["name"]),
141-
"description": (
142-
old_description,
143-
form.cleaned_data["description"],
167+
"staff_pick": (
168+
old_staff_pick,
169+
form.cleaned_data["staff_pick"],
144170
),
145-
"tags": (old_tags, form.cleaned_data["tags"]),
146171
},
147172
)
148173

@@ -155,7 +180,7 @@ def document_edit(request, pk):
155180
return HttpResponseRedirect(reverse("document_show", args=[doc.id]))
156181

157182
else:
158-
form = DocumentForm(instance=doc)
183+
form = _document_form_for_user(request.user, doc)
159184

160185
return render(
161186
request,
@@ -234,6 +259,10 @@ def document_show(request, pk):
234259
"document": document,
235260
"user_vote": document.vote_set.filter(user=request.user).first(),
236261
"form": DocumentReportForm(),
262+
"has_moderation_history": ModerationLog.objects.filter(
263+
content_type=ContentType.objects.get_for_model(Document),
264+
object_id=document.pk,
265+
).exists(),
237266
}
238267

239268
return render(request, "documents/viewer.html", context)

moderation/models.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,13 @@ class Meta:
6666
def __str__(self):
6767
return f"{self.user.get_short_name()} a fait une action le {self.timestamp.strftime('%d/%m/%Y')}"
6868

69-
### Action translation logic ###
69+
FIELD_LABELS = {
70+
"name": "titre",
71+
"description": "description",
72+
"tags": "tags",
73+
"hidden": "visibilité",
74+
"staff_pick": "staff pick",
75+
}
7076

7177
@property
7278
def action_text(self):
@@ -82,8 +88,39 @@ def action_text(self):
8288
elif self.target_field == "action_rejeter":
8389
return "a refusé la demande de"
8490
elif self.target_field == "reupload":
85-
return "a re-uploadé"
86-
return f"a modifié '{self.target_field}' sur"
91+
return "a remplacé le fichier de"
92+
elif self.target_field == "staff_pick":
93+
return (
94+
"a ajouté un staff pick sur"
95+
if str(self.new_value) == "True"
96+
else "a retiré le staff pick de"
97+
)
98+
label = self.FIELD_LABELS.get(self.target_field, self.target_field)
99+
return f"a modifié '{label}' sur"
100+
101+
@property
102+
def document_action_text(self):
103+
"""Action text for document history context (no trailing 'sur')."""
104+
if self.target_field == "reupload":
105+
return "fichier remplacé"
106+
if self.target_field == "hidden":
107+
return (
108+
"document caché"
109+
if str(self.new_value) == "True"
110+
else "document rendu visible"
111+
)
112+
if self.target_field == "staff_pick":
113+
return (
114+
"staff pick ajouté"
115+
if str(self.new_value) == "True"
116+
else "staff pick retiré"
117+
)
118+
field_actions = {
119+
"name": "titre modifié",
120+
"description": "description modifiée",
121+
"tags": "tags modifiés",
122+
}
123+
return field_actions.get(self.target_field, f"{self.target_field} modifié")
87124

88125
@property
89126
def action_color(self):
@@ -126,11 +163,11 @@ def track(cls, user, content_object: models.Model, values: dict[str, tuple]):
126163
if isinstance(old, (collections.abc.Iterable, QuerySet)) and not isinstance( # type: ignore
127164
old, str
128165
):
129-
old = ",".join([str(x) for x in old]) # noqa: PLW2901
166+
old = ", ".join([str(x) for x in old]) # noqa: PLW2901
130167
if isinstance(new, (collections.abc.Iterable, QuerySet)) and not isinstance( # type: ignore
131168
new, str
132169
):
133-
new = ",".join([str(x) for x in new]) # noqa: PLW2901
170+
new = ", ".join([str(x) for x in new]) # noqa: PLW2901
134171
if old != new:
135172
cls.objects.create(
136173
user=user,

moderation/templates/moderation/about.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ <h2 id="role">Que peuvent faire les modérateur·trices ?</h2>
1414
<ul class="mb-4">
1515
<li>Modifier le titre, la description et les tags de n'importe quel document</li>
1616
<li>Cacher ou rendre visible un document (par exemple si le contenu n'est pas approprié)</li>
17-
<li>Re-uploader une nouvelle version d'un document</li>
18-
<li>Marquer un document comme "Staff pick"</li>
17+
<li>Remplacer le fichier d'un document</li>
18+
<li>Marquer un document de qualité comme staff pick</li>
1919
</ul>
2020

2121
<h2 id="transparence">Transparence</h2>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
{% extends "base.html" %}
2+
3+
{% block title %}Historique — {{ document.name }}{% endblock %}
4+
5+
{% block content %}
6+
<div class="container-xl mt-4">
7+
<nav aria-label="breadcrumb">
8+
<ol class="breadcrumb">
9+
<li class="breadcrumb-item"><a href="{% url 'moderation_about' %}">Modération</a></li>
10+
<li class="breadcrumb-item"><a href="{% url 'public_logs' %}">Journal</a></li>
11+
<li class="breadcrumb-item active" aria-current="page">{{ document.name }}</li>
12+
</ol>
13+
</nav>
14+
15+
<div class="mb-4">
16+
<h1 class="mb-0">Historique de modération</h1>
17+
<p class="text-muted mb-1">
18+
<a href="{% url 'document_show' document.pk %}">{{ document.name }}</a>
19+
— {{ document.course.slug|upper }} {{ document.course.name }}
20+
</p>
21+
<p class="text-muted small mb-0">
22+
Déposé par {{ document.user.netid }} · {{ document.date|date:"d/m/Y" }}
23+
</p>
24+
</div>
25+
26+
<div class="card shadow-sm border-0">
27+
<div class="table-responsive">
28+
<table class="table table-hover align-middle mb-0">
29+
<thead class="table-light text-muted small text-uppercase">
30+
<tr>
31+
<th class="ps-4">Date</th>
32+
<th>Modérateur</th>
33+
<th>Action</th>
34+
<th>Détails</th>
35+
</tr>
36+
</thead>
37+
<tbody>
38+
{% for log in logs %}
39+
<tr>
40+
<td class="ps-4 text-muted small" style="white-space: nowrap;">
41+
{{ log.timestamp|date:"d/m/Y à H:i" }}
42+
</td>
43+
<td>
44+
<a href="{% url 'moderation_profile' log.user.netid %}">{{ log.user.netid }}</a>
45+
</td>
46+
<td>
47+
<span class="badge bg-{{ log.action_color }} bg-opacity-10 text-{{ log.action_color }} border border-{{ log.action_color }}">
48+
{{ log.document_action_text }}
49+
</span>
50+
</td>
51+
<td class="small text-muted">
52+
{% if log.target_field == "reupload" %}
53+
{{ log.new_value }}
54+
{% elif log.target_field != "hidden" and log.target_field != "staff_pick" and log.old_value and log.new_value %}
55+
<span class="text-decoration-line-through">{{ log.old_value|truncatewords:15 }}</span>
56+
&rarr; {{ log.new_value|truncatewords:15 }}
57+
{% endif %}
58+
</td>
59+
</tr>
60+
{% empty %}
61+
<tr>
62+
<td colspan="4" class="text-center text-muted py-5">
63+
Aucune action de modération enregistrée sur ce document.
64+
</td>
65+
</tr>
66+
{% endfor %}
67+
</tbody>
68+
</table>
69+
</div>
70+
</div>
71+
</div>
72+
{% endblock content %}

moderation/tests/test_moderation.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,77 @@ def test_log_on_document_edit_by_moderator(client, moderator, document):
191191
assert log.user == moderator
192192

193193

194+
def test_document_owner_cannot_forge_staff_pick(client, regular_user, course):
195+
"""Prevent owners from setting the moderator-only staff_pick field by forging POST data."""
196+
document = Document.objects.create(
197+
user=regular_user,
198+
course=course,
199+
name="Student Doc",
200+
staff_pick=False,
201+
)
202+
203+
login(client, regular_user)
204+
resp = client.post(
205+
reverse("document_edit", args=[document.id]),
206+
{"name": "Student Doc", "description": "", "staff_pick": "on"},
207+
)
208+
209+
assert resp.status_code == 302
210+
document.refresh_from_db()
211+
assert document.staff_pick is False
212+
assert not ModerationLog.objects.filter(target_field="staff_pick").exists()
213+
214+
215+
def test_document_owner_edit_preserves_existing_staff_pick(
216+
client, regular_user, course
217+
):
218+
"""Prevent owner edits from clearing staff_pick because the hidden checkbox is absent from the form."""
219+
document = Document.objects.create(
220+
user=regular_user,
221+
course=course,
222+
name="Student Doc",
223+
staff_pick=True,
224+
)
225+
226+
login(client, regular_user)
227+
resp = client.post(
228+
reverse("document_edit", args=[document.id]),
229+
{"name": "Updated Student Doc", "description": ""},
230+
)
231+
232+
assert resp.status_code == 302
233+
document.refresh_from_db()
234+
assert document.name == "Updated Student Doc"
235+
assert document.staff_pick is True
236+
assert not ModerationLog.objects.filter(target_field="staff_pick").exists()
237+
238+
239+
def test_moderator_staff_pick_change_is_logged_on_own_document(
240+
client, moderator, course
241+
):
242+
"""Ensure allowed staff_pick changes remain auditable even on a moderator's own document."""
243+
document = Document.objects.create(
244+
user=moderator,
245+
course=course,
246+
name="Moderator Doc",
247+
staff_pick=False,
248+
)
249+
250+
login(client, moderator)
251+
resp = client.post(
252+
reverse("document_edit", args=[document.id]),
253+
{"name": "Moderator Doc", "description": "", "staff_pick": "on"},
254+
)
255+
256+
assert resp.status_code == 302
257+
document.refresh_from_db()
258+
assert document.staff_pick is True
259+
log = ModerationLog.objects.get(target_field="staff_pick")
260+
assert log.user == moderator
261+
assert log.old_value == "False"
262+
assert log.new_value == "True"
263+
264+
194265
def test_public_logs_view(client, moderator, regular_user):
195266
ModerationLog.objects.create(
196267
user=moderator,

moderation/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@
2929
# --- Public Pages ---
3030
path("tree/", views.moderation_tree, name="moderation_tree"),
3131
path("profile/<str:netid>/", views.moderation_profile, name="moderation_profile"),
32+
# --- Document History ---
33+
path(
34+
"document/<int:pk>/",
35+
views.document_history,
36+
name="moderation_document_history",
37+
),
3238
]

0 commit comments

Comments
 (0)