diff --git a/.gitignore b/.gitignore
index 1d16fc08..3c62ebc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,4 @@ docker/.*
.env
programs.json
courses.json
+.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a023308..3d210555 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@ This page tries to contain all use facing changes made on DocHub.
# Unreleased
+ * Add document moderation history view showing all actions taken on a document
* Fix login errors when ULB changes your email or netid
* Log CAS authentication failures in the admin for easier debugging
diff --git a/documents/templates/documents/document_edit.html b/documents/templates/documents/document_edit.html
index 7873ab57..74031977 100644
--- a/documents/templates/documents/document_edit.html
+++ b/documents/templates/documents/document_edit.html
@@ -39,10 +39,10 @@
Staff pick
- En tant que modérateur, tu peux marquer ce document comme un "staff pick".
+ En tant que modérateur, tu peux marquer ce document comme staff pick.
Cela signifie que l'équipe de modération de DocHub considère ce document comme
étant d'une grande qualité et méritant d'être mis en avant.
- Il apparaitra dans la liste des documents avec cette icône :
+ Il apparaîtra dans la liste des documents avec cette icône :
diff --git a/documents/templates/documents/viewer.html b/documents/templates/documents/viewer.html
index 8c9b2f3e..124c5ab2 100644
--- a/documents/templates/documents/viewer.html
+++ b/documents/templates/documents/viewer.html
@@ -74,6 +74,18 @@
Modifier
{% endif %}
+ {% if has_moderation_history %}
+
+
+
+
+
+
+ Historique
+
+ {% endif %}
Que peuvent faire les modérateur·trices ?
Modifier le titre, la description et les tags de n'importe quel document
Cacher ou rendre visible un document (par exemple si le contenu n'est pas approprié)
- Re-uploader une nouvelle version d'un document
- Marquer un document comme "Staff pick"
+ Remplacer le fichier d'un document
+ Marquer un document de qualité comme staff pick
Transparence
diff --git a/moderation/templates/moderation/document_history.html b/moderation/templates/moderation/document_history.html
new file mode 100644
index 00000000..bdf60a94
--- /dev/null
+++ b/moderation/templates/moderation/document_history.html
@@ -0,0 +1,72 @@
+{% extends "base.html" %}
+
+{% block title %}Historique — {{ document.name }}{% endblock %}
+
+{% block content %}
+
+
+
+ Modération
+ Journal
+ {{ document.name }}
+
+
+
+
+
Historique de modération
+
+ {{ document.name }}
+ — {{ document.course.slug|upper }} {{ document.course.name }}
+
+
+ Déposé par {{ document.user.netid }} · {{ document.date|date:"d/m/Y" }}
+
+
+
+
+
+
+
+
+ Date
+ Modérateur
+ Action
+ Détails
+
+
+
+ {% for log in logs %}
+
+
+ {{ log.timestamp|date:"d/m/Y à H:i" }}
+
+
+ {{ log.user.netid }}
+
+
+
+ {{ log.document_action_text }}
+
+
+
+ {% if log.target_field == "reupload" %}
+ {{ log.new_value }}
+ {% elif log.target_field != "hidden" and log.target_field != "staff_pick" and log.old_value and log.new_value %}
+ {{ log.old_value|truncatewords:15 }}
+ → {{ log.new_value|truncatewords:15 }}
+ {% endif %}
+
+
+ {% empty %}
+
+
+ Aucune action de modération enregistrée sur ce document.
+
+
+ {% endfor %}
+
+
+
+
+
+{% endblock content %}
diff --git a/moderation/tests/test_moderation.py b/moderation/tests/test_moderation.py
index 4b915707..ea7110cc 100644
--- a/moderation/tests/test_moderation.py
+++ b/moderation/tests/test_moderation.py
@@ -191,6 +191,77 @@ def test_log_on_document_edit_by_moderator(client, moderator, document):
assert log.user == moderator
+def test_document_owner_cannot_forge_staff_pick(client, regular_user, course):
+ """Prevent owners from setting the moderator-only staff_pick field by forging POST data."""
+ document = Document.objects.create(
+ user=regular_user,
+ course=course,
+ name="Student Doc",
+ staff_pick=False,
+ )
+
+ login(client, regular_user)
+ resp = client.post(
+ reverse("document_edit", args=[document.id]),
+ {"name": "Student Doc", "description": "", "staff_pick": "on"},
+ )
+
+ assert resp.status_code == 302
+ document.refresh_from_db()
+ assert document.staff_pick is False
+ assert not ModerationLog.objects.filter(target_field="staff_pick").exists()
+
+
+def test_document_owner_edit_preserves_existing_staff_pick(
+ client, regular_user, course
+):
+ """Prevent owner edits from clearing staff_pick because the hidden checkbox is absent from the form."""
+ document = Document.objects.create(
+ user=regular_user,
+ course=course,
+ name="Student Doc",
+ staff_pick=True,
+ )
+
+ login(client, regular_user)
+ resp = client.post(
+ reverse("document_edit", args=[document.id]),
+ {"name": "Updated Student Doc", "description": ""},
+ )
+
+ assert resp.status_code == 302
+ document.refresh_from_db()
+ assert document.name == "Updated Student Doc"
+ assert document.staff_pick is True
+ assert not ModerationLog.objects.filter(target_field="staff_pick").exists()
+
+
+def test_moderator_staff_pick_change_is_logged_on_own_document(
+ client, moderator, course
+):
+ """Ensure allowed staff_pick changes remain auditable even on a moderator's own document."""
+ document = Document.objects.create(
+ user=moderator,
+ course=course,
+ name="Moderator Doc",
+ staff_pick=False,
+ )
+
+ login(client, moderator)
+ resp = client.post(
+ reverse("document_edit", args=[document.id]),
+ {"name": "Moderator Doc", "description": "", "staff_pick": "on"},
+ )
+
+ assert resp.status_code == 302
+ document.refresh_from_db()
+ assert document.staff_pick is True
+ log = ModerationLog.objects.get(target_field="staff_pick")
+ assert log.user == moderator
+ assert log.old_value == "False"
+ assert log.new_value == "True"
+
+
def test_public_logs_view(client, moderator, regular_user):
ModerationLog.objects.create(
user=moderator,
diff --git a/moderation/urls.py b/moderation/urls.py
index afd4e64e..e7a14094 100644
--- a/moderation/urls.py
+++ b/moderation/urls.py
@@ -29,4 +29,10 @@
# --- Public Pages ---
path("tree/", views.moderation_tree, name="moderation_tree"),
path("profile//", views.moderation_profile, name="moderation_profile"),
+ # --- Document History ---
+ path(
+ "document//",
+ views.document_history,
+ name="moderation_document_history",
+ ),
]
diff --git a/moderation/views.py b/moderation/views.py
index 163781a6..268b69e9 100644
--- a/moderation/views.py
+++ b/moderation/views.py
@@ -4,6 +4,7 @@
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
+from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.db.models import Count, Q
@@ -11,6 +12,7 @@
from django.urls import reverse
from django.views.decorators.http import require_POST
+from documents.models import Document
from moderation.forms import (
AddModeratorForm,
ProcessRepresentativeRequestForm,
@@ -331,3 +333,20 @@ def moderation_profile(request, netid):
def moderation_about(request):
"""Public page explaining the moderation system."""
return render(request, "moderation/about.html")
+
+
+@login_required
+def document_history(request, pk):
+ """Moderation history for a single document — all actions by all moderators."""
+ document = get_object_or_404(Document, pk=pk)
+ content_type = ContentType.objects.get_for_model(Document)
+ logs = (
+ ModerationLog.objects.filter(content_type=content_type, object_id=pk)
+ .select_related("user")
+ .order_by("-timestamp")
+ )
+ return render(
+ request,
+ "moderation/document_history.html",
+ {"document": document, "logs": logs},
+ )