Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ docker/.*
.env
programs.json
courses.json
.playwright-mcp/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions documents/templates/documents/document_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ <h5 class="card-header">Modération</h5>
Staff pick
</label>
<div class="form-text">
En tant que modérateur, tu peux marquer ce document comme un "staff pick".<br/>
En tant que modérateur, tu peux marquer ce document comme staff pick.<br/>
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.<br/>
Il apparaitra dans la liste des documents avec cette icône :
Il apparaîtra dans la liste des documents avec cette icône :
<svg title="Staff pick" xmlns="http://www.w3.org/2000/svg"
width="16" height="16" fill="currentColor"
class="bi bi-patch-check-fill" viewBox="0 0 16 16">
Expand Down
12 changes: 12 additions & 0 deletions documents/templates/documents/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
Modifier
</a>
{% endif %}
{% if has_moderation_history %}
<a class="btn btn-outline-secondary btn-sm d-inline-flex align-items-center gap-1"
href="{% url 'moderation_document_history' document.pk %}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-clock-history" viewBox="0 0 16 16">
<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"/>
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0z"/>
<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"/>
</svg>
Historique
</a>
{% endif %}

<button data-controller="share" data-share-url="{{ document.get_absolute_url }}"
data-action="click->share#share"
Expand Down
43 changes: 36 additions & 7 deletions documents/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.db.models import F
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
Expand All @@ -26,6 +27,13 @@
from moderation.models import ModerationLog


def _document_form_for_user(user, document, *args, **kwargs):
form = DocumentForm(*args, instance=document, **kwargs)
if not user.moderation_perm(document):
form.fields.pop("staff_pick", None)
return form


@login_required
@slug_redirect
def upload_file(request, slug):
Expand Down Expand Up @@ -128,21 +136,38 @@ def document_edit(request, pk):
old_name = doc.name
old_description = doc.description
old_tags = list(doc.tags.all())
old_staff_pick = doc.staff_pick

form = DocumentForm(request.POST, instance=doc)
can_staff_pick = request.user.moderation_perm(doc)
form = _document_form_for_user(request.user, doc, request.POST)

if form.is_valid():
if request.user != doc.user:
values = {
"name": (old_name, form.cleaned_data["name"]),
"description": (
old_description,
form.cleaned_data["description"],
),
"tags": (old_tags, form.cleaned_data["tags"]),
}
if can_staff_pick:
values["staff_pick"] = (
old_staff_pick,
form.cleaned_data["staff_pick"],
)
ModerationLog.track(
user=request.user, content_object=doc, values=values
)
elif can_staff_pick:
ModerationLog.track(
user=request.user,
content_object=doc,
values={
"name": (old_name, form.cleaned_data["name"]),
"description": (
old_description,
form.cleaned_data["description"],
"staff_pick": (
old_staff_pick,
form.cleaned_data["staff_pick"],
),
"tags": (old_tags, form.cleaned_data["tags"]),
},
)

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

else:
form = DocumentForm(instance=doc)
form = _document_form_for_user(request.user, doc)

return render(
request,
Expand Down Expand Up @@ -234,6 +259,10 @@ def document_show(request, pk):
"document": document,
"user_vote": document.vote_set.filter(user=request.user).first(),
"form": DocumentReportForm(),
"has_moderation_history": ModerationLog.objects.filter(
content_type=ContentType.objects.get_for_model(Document),
object_id=document.pk,
).exists(),
}

return render(request, "documents/viewer.html", context)
Expand Down
47 changes: 42 additions & 5 deletions moderation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ class Meta:
def __str__(self):
return f"{self.user.get_short_name()} a fait une action le {self.timestamp.strftime('%d/%m/%Y')}"

### Action translation logic ###
FIELD_LABELS = {
"name": "titre",
"description": "description",
"tags": "tags",
"hidden": "visibilité",
"staff_pick": "staff pick",
}

@property
def action_text(self):
Expand All @@ -82,8 +88,39 @@ def action_text(self):
elif self.target_field == "action_rejeter":
return "a refusé la demande de"
elif self.target_field == "reupload":
return "a re-uploadé"
return f"a modifié '{self.target_field}' sur"
return "a remplacé le fichier de"
elif self.target_field == "staff_pick":
return (
"a ajouté un staff pick sur"
if str(self.new_value) == "True"
else "a retiré le staff pick de"
)
label = self.FIELD_LABELS.get(self.target_field, self.target_field)
return f"a modifié '{label}' sur"

@property
def document_action_text(self):
"""Action text for document history context (no trailing 'sur')."""
if self.target_field == "reupload":
return "fichier remplacé"
if self.target_field == "hidden":
return (
"document caché"
if str(self.new_value) == "True"
else "document rendu visible"
)
if self.target_field == "staff_pick":
return (
"staff pick ajouté"
if str(self.new_value) == "True"
else "staff pick retiré"
)
field_actions = {
"name": "titre modifié",
"description": "description modifiée",
"tags": "tags modifiés",
}
return field_actions.get(self.target_field, f"{self.target_field} modifié")

@property
def action_color(self):
Expand Down Expand Up @@ -126,11 +163,11 @@ def track(cls, user, content_object: models.Model, values: dict[str, tuple]):
if isinstance(old, (collections.abc.Iterable, QuerySet)) and not isinstance( # type: ignore
old, str
):
old = ",".join([str(x) for x in old]) # noqa: PLW2901
old = ", ".join([str(x) for x in old]) # noqa: PLW2901
if isinstance(new, (collections.abc.Iterable, QuerySet)) and not isinstance( # type: ignore
new, str
):
new = ",".join([str(x) for x in new]) # noqa: PLW2901
new = ", ".join([str(x) for x in new]) # noqa: PLW2901
if old != new:
cls.objects.create(
user=user,
Expand Down
4 changes: 2 additions & 2 deletions moderation/templates/moderation/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ <h2 id="role">Que peuvent faire les modérateur·trices ?</h2>
<ul class="mb-4">
<li>Modifier le titre, la description et les tags de n'importe quel document</li>
<li>Cacher ou rendre visible un document (par exemple si le contenu n'est pas approprié)</li>
<li>Re-uploader une nouvelle version d'un document</li>
<li>Marquer un document comme "Staff pick"</li>
<li>Remplacer le fichier d'un document</li>
<li>Marquer un document de qualité comme staff pick</li>
</ul>

<h2 id="transparence">Transparence</h2>
Expand Down
72 changes: 72 additions & 0 deletions moderation/templates/moderation/document_history.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{% extends "base.html" %}

{% block title %}Historique — {{ document.name }}{% endblock %}

{% block content %}
<div class="container-xl mt-4">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'moderation_about' %}">Modération</a></li>
<li class="breadcrumb-item"><a href="{% url 'public_logs' %}">Journal</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ document.name }}</li>
</ol>
</nav>

<div class="mb-4">
<h1 class="mb-0">Historique de modération</h1>
<p class="text-muted mb-1">
<a href="{% url 'document_show' document.pk %}">{{ document.name }}</a>
— {{ document.course.slug|upper }} {{ document.course.name }}
</p>
<p class="text-muted small mb-0">
Déposé par {{ document.user.netid }} · {{ document.date|date:"d/m/Y" }}
</p>
</div>

<div class="card shadow-sm border-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-muted small text-uppercase">
<tr>
<th class="ps-4">Date</th>
<th>Modérateur</th>
<th>Action</th>
<th>Détails</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td class="ps-4 text-muted small" style="white-space: nowrap;">
{{ log.timestamp|date:"d/m/Y à H:i" }}
</td>
<td>
<a href="{% url 'moderation_profile' log.user.netid %}">{{ log.user.netid }}</a>
</td>
<td>
<span class="badge bg-{{ log.action_color }} bg-opacity-10 text-{{ log.action_color }} border border-{{ log.action_color }}">
{{ log.document_action_text }}
</span>
</td>
<td class="small text-muted">
{% 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 %}
<span class="text-decoration-line-through">{{ log.old_value|truncatewords:15 }}</span>
&rarr; {{ log.new_value|truncatewords:15 }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted py-5">
Aucune action de modération enregistrée sur ce document.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock content %}
71 changes: 71 additions & 0 deletions moderation/tests/test_moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions moderation/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@
# --- Public Pages ---
path("tree/", views.moderation_tree, name="moderation_tree"),
path("profile/<str:netid>/", views.moderation_profile, name="moderation_profile"),
# --- Document History ---
path(
"document/<int:pk>/",
views.document_history,
name="moderation_document_history",
),
]
Loading
Loading