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 @@
Modération
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 %}

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 %} +
+ + +
+

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" }} +

+
+ +
+
+ + + + + + + + + + + {% for log in logs %} + + + + + + + {% empty %} + + + + {% endfor %} + +
DateModérateurActionDétails
+ {{ 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 %} +
+ Aucune action de modération enregistrée sur ce document. +
+
+
+
+{% 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}, + )