Skip to content

Commit 8c3c1c4

Browse files
Feature: report a document (#356)
Based on and closes #279 --- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 14cc2f5 commit 8c3c1c4

12 files changed

Lines changed: 399 additions & 2 deletions

File tree

documents/admin.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django.conf import settings
22
from django.contrib import admin
3+
from django.db.models import Count
34
from django.db.models.query import QuerySet
45

5-
from .models import BulkDocuments, Document, DocumentError, Vote
6+
from .models import BulkDocuments, Document, DocumentError, DocumentReport, Vote
67

78

89
@admin.action(description="Reprocess selected documents")
@@ -73,6 +74,7 @@ class DocumentAdmin(admin.ModelAdmin):
7374
"pages",
7475
"views",
7576
"downloads",
77+
"report_count",
7678
"hidden",
7779
"state",
7880
"created",
@@ -124,12 +126,37 @@ class DocumentAdmin(admin.ModelAdmin):
124126
),
125127
)
126128

129+
def get_queryset(self, request):
130+
queryset = super().get_queryset(request)
131+
queryset = queryset.annotate(report_count=Count("reports"))
132+
return queryset
133+
134+
@admin.display(ordering="report_count", description="Reports")
135+
def report_count(self, obj: Document) -> int:
136+
return obj.report_count # type: ignore[attr-defined]
137+
127138

128139
@admin.register(DocumentError)
129140
class DocumentErrorAdmin(admin.ModelAdmin):
130141
list_display = ("exception", "document", "task_id")
131142

132143

144+
@admin.register(DocumentReport)
145+
class DocumentReportAdmin(admin.ModelAdmin):
146+
list_display = (
147+
"id",
148+
"document",
149+
"problem_type",
150+
"user",
151+
"created",
152+
)
153+
list_filter = ("problem_type", "created")
154+
search_fields = ("document__name", "user__netid", "user__email")
155+
raw_id_fields = ("user", "document")
156+
date_hierarchy = "created"
157+
readonly_fields = ("created",)
158+
159+
133160
@admin.register(BulkDocuments)
134161
class BulkDocumentsAdmin(admin.ModelAdmin):
135162
list_display = ("url", "processed", "course", "user", "created")

documents/forms.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from django.conf import settings
33
from django.core.exceptions import ValidationError
44

5-
from documents.models import Document
5+
from documents.models import Document, DocumentReport
66

77

88
def validate_uploaded_file(file):
@@ -61,3 +61,22 @@ class ReUploadForm(forms.Form):
6161

6262
class MultipleUploadFileForm(UploadFileForm):
6363
pass
64+
65+
66+
class DocumentReportForm(forms.ModelForm):
67+
class Meta:
68+
model = DocumentReport
69+
fields = ("problem_type", "description")
70+
widgets = {
71+
"problem_type": forms.Select(
72+
attrs={
73+
"class": "form-select",
74+
}
75+
),
76+
"description": forms.Textarea(
77+
attrs={
78+
"class": "form-control",
79+
"rows": 2,
80+
}
81+
),
82+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Generated by Django 6.0 on 2025-12-25 19:59
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
(
12+
"documents",
13+
"0005_alter_vote_unique_together_alter_bulkdocuments_id_and_more",
14+
),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.CreateModel(
20+
name="DocumentReport",
21+
fields=[
22+
(
23+
"id",
24+
models.BigAutoField(
25+
auto_created=True,
26+
primary_key=True,
27+
serialize=False,
28+
verbose_name="ID",
29+
),
30+
),
31+
(
32+
"problem_type",
33+
models.CharField(
34+
choices=[
35+
("wrong_module", "Ce document est dans le mauvais cours"),
36+
("wrong_title", "Le titre ou la description est erroné"),
37+
("low_quality", "Contenu de mauvaise qualité ou inutile"),
38+
("readability", "Problème de lisibilité"),
39+
("outdated", "Document obsolète"),
40+
("other", "Autre raison"),
41+
],
42+
max_length=20,
43+
verbose_name="Type de problème",
44+
),
45+
),
46+
(
47+
"description",
48+
models.TextField(
49+
blank=True, default="", verbose_name="Description"
50+
),
51+
),
52+
("created", models.DateTimeField(auto_now_add=True)),
53+
(
54+
"document",
55+
models.ForeignKey(
56+
on_delete=django.db.models.deletion.CASCADE,
57+
related_name="reports",
58+
to="documents.document",
59+
verbose_name="Document",
60+
),
61+
),
62+
(
63+
"user",
64+
models.ForeignKey(
65+
on_delete=django.db.models.deletion.CASCADE,
66+
to=settings.AUTH_USER_MODEL,
67+
verbose_name="Utilisateur",
68+
),
69+
),
70+
],
71+
),
72+
]

documents/models.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,55 @@ class Meta:
186186
]
187187

188188

189+
class DocumentReport(models.Model):
190+
class ProblemType(models.TextChoices):
191+
WRONG_MODULE = "wrong_module", "Ce document est dans le mauvais cours"
192+
WRONG_TITLE = "wrong_title", "Le titre ou la description est erroné"
193+
LOW_QUALITY = "low_quality", "Contenu de mauvaise qualité ou inutile"
194+
READABILITY = "readability", "Problème de lisibilité"
195+
OUTDATED = "outdated", "Document obsolète"
196+
OTHER = "other", "Autre raison"
197+
198+
@classmethod
199+
def get_description(cls, value: str) -> str:
200+
"""Get the detailed description for a problem type."""
201+
descriptions = {
202+
cls.WRONG_MODULE.value: "Ce document appartient à un autre cours",
203+
cls.WRONG_TITLE.value: "Le contenu du document n'est pas correctement décrit",
204+
cls.LOW_QUALITY.value: "Le contenu peut être non pertinent, contenir uniquement le plan du cours, avoir de nombreuses fautes ou être (presque) vide",
205+
cls.READABILITY.value: "Le document est difficile à lire en raison d'une mauvaise écriture ou d'une photo de mauvaise qualité",
206+
cls.OUTDATED.value: "Le document est dépassé ou ne correspond plus au contenu actuel du cours",
207+
cls.OTHER.value: "Une autre raison non listée ci-dessus",
208+
}
209+
return descriptions.get(value, "")
210+
211+
user = models.ForeignKey(
212+
settings.AUTH_USER_MODEL,
213+
on_delete=models.CASCADE,
214+
verbose_name="Utilisateur",
215+
)
216+
document = models.ForeignKey(
217+
Document,
218+
on_delete=models.CASCADE,
219+
related_name="reports",
220+
verbose_name="Document",
221+
)
222+
problem_type = models.CharField(
223+
max_length=20,
224+
choices=ProblemType.choices,
225+
verbose_name="Type de problème",
226+
)
227+
description = models.TextField(
228+
blank=True,
229+
default="",
230+
verbose_name="Description",
231+
)
232+
created = models.DateTimeField(auto_now_add=True)
233+
234+
def __str__(self) -> str:
235+
return f"Report on {self.document.name} by {self.user}"
236+
237+
189238
class DocumentError(models.Model):
190239
document = models.ForeignKey(Document, on_delete=models.CASCADE)
191240
task_id = models.CharField(max_length=255)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
{% extends "base.html" %}
2+
3+
{% block title %}Signaler {{ document.name }}{% endblock %}
4+
5+
{% block content %}
6+
<div class="container-xl py-4">
7+
<div class="row justify-content-center">
8+
<div class="col-lg-8">
9+
{% include "documents/document_report.html#report_card" %}
10+
</div>
11+
</div>
12+
</div>
13+
{% endblock content %}
14+
15+
{% partialdef report_modal %}
16+
<dialog id="reportDialog"
17+
data-controller="modal"
18+
data-action="click->modal#clickOutside"
19+
class="rounded shadow-lg border-0 p-0">
20+
{% include "documents/document_report.html#report_card" %}
21+
</dialog>
22+
{% endpartialdef %}
23+
24+
{% partialdef report_card %}
25+
<div class="card">
26+
<div class="card-header">
27+
<h5 class="mb-0">Signaler un problème</h5>
28+
</div>
29+
30+
<form action="{% url 'document_report' document.id %}" method="post">
31+
{% csrf_token %}
32+
33+
{{ form.non_field_errors }}
34+
<div class="card-body">
35+
<div class="mb-3">
36+
{% load documents_tags %}
37+
{% for choice_value, choice_label in form.fields.problem_type.choices %}
38+
{% if choice_value %}
39+
<div class="form-check mb-3">
40+
<input class="form-check-input" type="radio" name="problem_type"
41+
id="problem_{{ choice_value }}" value="{{ choice_value }}"
42+
{% if form.problem_type.value == choice_value %}checked{% endif %}>
43+
<label class="form-check-label" for="problem_{{ choice_value }}">
44+
<div class="fw-semibold">{{ choice_label }}</div>
45+
<div class="text-muted small">
46+
{{ choice_value|problem_type_description }}
47+
</div>
48+
</label>
49+
</div>
50+
{% endif %}
51+
{% endfor %}
52+
</div>
53+
54+
<div class="mb-3">
55+
<label for="id_description" class="form-label">Commentaire (optionnel)
56+
<div class="form-text small">
57+
Tu peux ajouter des détails supplémentaires pour nous aider à comprendre le problème.
58+
</div>
59+
</label>
60+
61+
{{ form.description }}
62+
63+
</div>
64+
</div>
65+
66+
<div class="card-footer d-flex gap-2 justify-content-end">
67+
<button type="button" data-action="click->modal#close" class="btn btn-outline-secondary">Annuler</button>
68+
<button type="submit" class="btn btn-primary">
69+
Envoyer
70+
</button>
71+
</div>
72+
</form>
73+
</div>
74+
{% endpartialdef %}

documents/templates/documents/viewer.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
7474
Modifier
7575
</a>
7676
{% endif %}
77+
7778
<button data-controller="share" data-share-url="{{ document.get_absolute_url }}"
7879
data-action="click->share#share"
7980
class="d-none btn btn-outline-secondary btn-sm d-inline-flex align-items-center gap-1"
@@ -134,6 +135,17 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
134135
</button>
135136
</form>
136137
</turbo-frame>
138+
<a href="{% url 'document_report' document.pk %}" title="Signaler" data-controller="modal-trigger"
139+
data-modal-trigger-target-value="reportDialog"
140+
data-action="click->modal-trigger#open"
141+
class="mt-2 btn btn-outline-warning btn-sm d-inline-flex align-items-center gap-1">
142+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-flag"
143+
viewBox="0 0 16 16">
144+
<path
145+
d="M14.778.085A.5.5 0 0 1 15 .5V8a.5.5 0 0 1-.314.464L14.5 8l.186.464-.003.001-.006.003-.023.009a12 12 0 0 1-.397.15c-.264.095-.631.223-1.047.35-.816.252-1.879.523-2.71.523-.847 0-1.548-.28-2.158-.525l-.028-.01C7.68 8.71 7.14 8.5 6.5 8.5c-.7 0-1.638.23-2.437.477A20 20 0 0 0 3 9.342V15.5a.5.5 0 0 1-1 0V.5a.5.5 0 0 1 1 0v.282c.226-.079.496-.17.79-.26C4.606.272 5.67 0 6.5 0c.84 0 1.524.277 2.121.519l.043.018C9.286.788 9.828 1 10.5 1c.7 0 1.638-.23 2.437-.477a20 20 0 0 0 1.349-.476l.019-.007.004-.002h.001M14 1.221c-.22.078-.48.167-.766.255-.81.252-1.872.523-2.734.523-.886 0-1.592-.286-2.203-.534l-.008-.003C7.662 1.21 7.139 1 6.5 1c-.669 0-1.606.229-2.415.478A21 21 0 0 0 3 1.845v6.433c.22-.078.48-.167.766-.255C4.576 7.77 5.638 7.5 6.5 7.5c.847 0 1.548.28 2.158.525l.028.01C9.32 8.29 9.86 8.5 10.5 8.5c.668 0 1.606-.229 2.415-.478A21 21 0 0 0 14 7.655V1.222z"/>
146+
</svg>
147+
148+
</a>
137149
</div>
138150

139151
</div>
@@ -188,4 +200,6 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
188200
</div>
189201

190202
{% endif %}
203+
204+
{% include "documents/document_report.html#report_modal" %}
191205
{% endblock content %}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django import template
2+
3+
from documents.models import DocumentReport
4+
5+
register = template.Library()
6+
7+
8+
@register.filter
9+
def problem_type_description(value: str) -> str:
10+
"""Get the detailed description for a problem type."""
11+
return DocumentReport.ProblemType.get_description(value)

documents/tests/report_test.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from django.urls import reverse
2+
3+
import pytest
4+
5+
from catalog.models import Course
6+
from documents.models import Document, DocumentReport
7+
from users.models import User
8+
9+
pytestmark = pytest.mark.django_db
10+
11+
12+
@pytest.fixture
13+
def user():
14+
return User.objects.create_user(
15+
netid="test_user", first_name="Test", last_name="User"
16+
)
17+
18+
19+
@pytest.fixture
20+
def course():
21+
return Course.objects.create(name="Test Course", slug="test-course")
22+
23+
24+
@pytest.fixture
25+
def document(user, course):
26+
return Document.objects.create(
27+
name="Test Document",
28+
user=user,
29+
course=course,
30+
state=Document.DocumentState.DONE,
31+
)
32+
33+
34+
def test_document_report_view_post(client, user, document):
35+
"""Test POST request to document_report view"""
36+
client.force_login(user)
37+
url = reverse("document_report", args=[document.pk])
38+
39+
response = client.post(
40+
url,
41+
{
42+
"problem_type": DocumentReport.ProblemType.WRONG_MODULE,
43+
"description": "Test description",
44+
},
45+
)
46+
47+
assert response.status_code == 302 # Redirect after successful submission
48+
assert DocumentReport.objects.filter(document=document, user=user).exists()
49+
50+
report = DocumentReport.objects.get(document=document, user=user)
51+
assert report.problem_type == DocumentReport.ProblemType.WRONG_MODULE
52+
assert report.description == "Test description"

documents/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
),
1616
path("<int:pk>", documents.views.document_show, name="document_show"),
1717
path("<int:pk>/vote", documents.views.document_vote, name="document_vote"),
18+
path("<int:pk>/report", documents.views.document_report, name="document_report"),
1819
path(
1920
"<int:pk>/original",
2021
documents.views.document_original_file,

0 commit comments

Comments
 (0)