diff --git a/documents/admin.py b/documents/admin.py index 44948cb5..aba81856 100644 --- a/documents/admin.py +++ b/documents/admin.py @@ -1,8 +1,9 @@ from django.conf import settings from django.contrib import admin +from django.db.models import Count from django.db.models.query import QuerySet -from .models import BulkDocuments, Document, DocumentError, Vote +from .models import BulkDocuments, Document, DocumentError, DocumentReport, Vote @admin.action(description="Reprocess selected documents") @@ -73,6 +74,7 @@ class DocumentAdmin(admin.ModelAdmin): "pages", "views", "downloads", + "report_count", "hidden", "state", "created", @@ -124,12 +126,37 @@ class DocumentAdmin(admin.ModelAdmin): ), ) + def get_queryset(self, request): + queryset = super().get_queryset(request) + queryset = queryset.annotate(report_count=Count("reports")) + return queryset + + @admin.display(ordering="report_count", description="Reports") + def report_count(self, obj: Document) -> int: + return obj.report_count # type: ignore[attr-defined] + @admin.register(DocumentError) class DocumentErrorAdmin(admin.ModelAdmin): list_display = ("exception", "document", "task_id") +@admin.register(DocumentReport) +class DocumentReportAdmin(admin.ModelAdmin): + list_display = ( + "id", + "document", + "problem_type", + "user", + "created", + ) + list_filter = ("problem_type", "created") + search_fields = ("document__name", "user__netid", "user__email") + raw_id_fields = ("user", "document") + date_hierarchy = "created" + readonly_fields = ("created",) + + @admin.register(BulkDocuments) class BulkDocumentsAdmin(admin.ModelAdmin): list_display = ("url", "processed", "course", "user", "created") diff --git a/documents/forms.py b/documents/forms.py index 1c900121..370f21c8 100644 --- a/documents/forms.py +++ b/documents/forms.py @@ -2,7 +2,7 @@ from django.conf import settings from django.core.exceptions import ValidationError -from documents.models import Document +from documents.models import Document, DocumentReport def validate_uploaded_file(file): @@ -61,3 +61,22 @@ class ReUploadForm(forms.Form): class MultipleUploadFileForm(UploadFileForm): pass + + +class DocumentReportForm(forms.ModelForm): + class Meta: + model = DocumentReport + fields = ("problem_type", "description") + widgets = { + "problem_type": forms.Select( + attrs={ + "class": "form-select", + } + ), + "description": forms.Textarea( + attrs={ + "class": "form-control", + "rows": 2, + } + ), + } diff --git a/documents/migrations/0006_documentreport.py b/documents/migrations/0006_documentreport.py new file mode 100644 index 00000000..0ce24b87 --- /dev/null +++ b/documents/migrations/0006_documentreport.py @@ -0,0 +1,72 @@ +# Generated by Django 6.0 on 2025-12-25 19:59 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "documents", + "0005_alter_vote_unique_together_alter_bulkdocuments_id_and_more", + ), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="DocumentReport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "problem_type", + models.CharField( + choices=[ + ("wrong_module", "Ce document est dans le mauvais cours"), + ("wrong_title", "Le titre ou la description est erroné"), + ("low_quality", "Contenu de mauvaise qualité ou inutile"), + ("readability", "Problème de lisibilité"), + ("outdated", "Document obsolète"), + ("other", "Autre raison"), + ], + max_length=20, + verbose_name="Type de problème", + ), + ), + ( + "description", + models.TextField( + blank=True, default="", verbose_name="Description" + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "document", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reports", + to="documents.document", + verbose_name="Document", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="Utilisateur", + ), + ), + ], + ), + ] diff --git a/documents/models.py b/documents/models.py index e0d26968..721a5678 100644 --- a/documents/models.py +++ b/documents/models.py @@ -186,6 +186,55 @@ class Meta: ] +class DocumentReport(models.Model): + class ProblemType(models.TextChoices): + WRONG_MODULE = "wrong_module", "Ce document est dans le mauvais cours" + WRONG_TITLE = "wrong_title", "Le titre ou la description est erroné" + LOW_QUALITY = "low_quality", "Contenu de mauvaise qualité ou inutile" + READABILITY = "readability", "Problème de lisibilité" + OUTDATED = "outdated", "Document obsolète" + OTHER = "other", "Autre raison" + + @classmethod + def get_description(cls, value: str) -> str: + """Get the detailed description for a problem type.""" + descriptions = { + cls.WRONG_MODULE.value: "Ce document appartient à un autre cours", + cls.WRONG_TITLE.value: "Le contenu du document n'est pas correctement décrit", + cls.LOW_QUALITY.value: "Le contenu peut être non pertinent, contenir uniquement le plan du cours, avoir de nombreuses fautes ou être (presque) vide", + cls.READABILITY.value: "Le document est difficile à lire en raison d'une mauvaise écriture ou d'une photo de mauvaise qualité", + cls.OUTDATED.value: "Le document est dépassé ou ne correspond plus au contenu actuel du cours", + cls.OTHER.value: "Une autre raison non listée ci-dessus", + } + return descriptions.get(value, "") + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + verbose_name="Utilisateur", + ) + document = models.ForeignKey( + Document, + on_delete=models.CASCADE, + related_name="reports", + verbose_name="Document", + ) + problem_type = models.CharField( + max_length=20, + choices=ProblemType.choices, + verbose_name="Type de problème", + ) + description = models.TextField( + blank=True, + default="", + verbose_name="Description", + ) + created = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"Report on {self.document.name} by {self.user}" + + class DocumentError(models.Model): document = models.ForeignKey(Document, on_delete=models.CASCADE) task_id = models.CharField(max_length=255) diff --git a/documents/templates/documents/document_report.html b/documents/templates/documents/document_report.html new file mode 100644 index 00000000..b69e5354 --- /dev/null +++ b/documents/templates/documents/document_report.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}Signaler {{ document.name }}{% endblock %} + +{% block content %} +
+
+
+ {% include "documents/document_report.html#report_card" %} +
+
+
+{% endblock content %} + +{% partialdef report_modal %} + + {% include "documents/document_report.html#report_card" %} + +{% endpartialdef %} + +{% partialdef report_card %} +
+
+
Signaler un problème
+
+ +
+ {% csrf_token %} + + {{ form.non_field_errors }} +
+
+ {% load documents_tags %} + {% for choice_value, choice_label in form.fields.problem_type.choices %} + {% if choice_value %} +
+ + +
+ {% endif %} + {% endfor %} +
+ +
+ + + {{ form.description }} + +
+
+ + +
+
+{% endpartialdef %} diff --git a/documents/templates/documents/viewer.html b/documents/templates/documents/viewer.html index d64319a2..8c9b2f3e 100644 --- a/documents/templates/documents/viewer.html +++ b/documents/templates/documents/viewer.html @@ -74,6 +74,7 @@

Modifier {% endif %} + + + + + + + @@ -188,4 +200,6 @@

{% endif %} + + {% include "documents/document_report.html#report_modal" %} {% endblock content %} diff --git a/documents/templatetags/documents_tags.py b/documents/templatetags/documents_tags.py new file mode 100644 index 00000000..b211836e --- /dev/null +++ b/documents/templatetags/documents_tags.py @@ -0,0 +1,11 @@ +from django import template + +from documents.models import DocumentReport + +register = template.Library() + + +@register.filter +def problem_type_description(value: str) -> str: + """Get the detailed description for a problem type.""" + return DocumentReport.ProblemType.get_description(value) diff --git a/documents/tests/report_test.py b/documents/tests/report_test.py new file mode 100644 index 00000000..582e7880 --- /dev/null +++ b/documents/tests/report_test.py @@ -0,0 +1,52 @@ +from django.urls import reverse + +import pytest + +from catalog.models import Course +from documents.models import Document, DocumentReport +from users.models import User + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def user(): + return User.objects.create_user( + netid="test_user", first_name="Test", last_name="User" + ) + + +@pytest.fixture +def course(): + return Course.objects.create(name="Test Course", slug="test-course") + + +@pytest.fixture +def document(user, course): + return Document.objects.create( + name="Test Document", + user=user, + course=course, + state=Document.DocumentState.DONE, + ) + + +def test_document_report_view_post(client, user, document): + """Test POST request to document_report view""" + client.force_login(user) + url = reverse("document_report", args=[document.pk]) + + response = client.post( + url, + { + "problem_type": DocumentReport.ProblemType.WRONG_MODULE, + "description": "Test description", + }, + ) + + assert response.status_code == 302 # Redirect after successful submission + assert DocumentReport.objects.filter(document=document, user=user).exists() + + report = DocumentReport.objects.get(document=document, user=user) + assert report.problem_type == DocumentReport.ProblemType.WRONG_MODULE + assert report.description == "Test description" diff --git a/documents/urls.py b/documents/urls.py index b439f1b2..65f6ee0f 100644 --- a/documents/urls.py +++ b/documents/urls.py @@ -15,6 +15,7 @@ ), path("", documents.views.document_show, name="document_show"), path("/vote", documents.views.document_vote, name="document_vote"), + path("/report", documents.views.document_report, name="document_report"), path( "/original", documents.views.document_original_file, diff --git a/documents/views.py b/documents/views.py index b1854fef..89eb4ce6 100644 --- a/documents/views.py +++ b/documents/views.py @@ -17,6 +17,7 @@ from documents.forms import ( BulkFilesForm, DocumentForm, + DocumentReportForm, MultipleUploadFileForm, ReUploadForm, UploadFileForm, @@ -228,6 +229,7 @@ def document_show(request, pk): context = { "document": document, "user_vote": document.vote_set.filter(user=request.user).first(), + "form": DocumentReportForm(), } return render(request, "documents/viewer.html", context) @@ -307,3 +309,35 @@ def submit_bulk(request, slug): ) return HttpResponseRedirect(reverse("document_put", args=[course.slug])) + + +@login_required +def document_report(request, pk): + document = get_object_or_404(Document, pk=pk) + + if request.method == "POST": + form = DocumentReportForm(request.POST) + + if form.is_valid(): + report = form.save(commit=False) + report.user = request.user + report.document = document + report.save() + + messages.success( + request, + "Ton signalement a bien été enregistré. Merci de ta contribution !", + ) + return redirect(document.get_absolute_url()) + + else: + form = DocumentReportForm() + + return render( + request, + "documents/document_report.html", + { + "form": form, + "document": document, + }, + ) diff --git a/static/main.js b/static/main.js index bc4d9b55..1d45257e 100644 --- a/static/main.js +++ b/static/main.js @@ -294,6 +294,32 @@ class Share extends Controller { } +class Modal extends Controller { + close() { + this.element.close(); + } +} + +class ModalTrigger extends Controller { + static values = { + target: String + } + + open(event) { + // Allow browser default behavior when modifier keys are pressed + // (Ctrl+click, Cmd+click, Shift+click, or middle-click) + if (event.ctrlKey || event.metaKey || event.shiftKey || event.button === 1) { + return; + } + + event.preventDefault(); + const dialog = document.getElementById(this.targetValue); + if (dialog) { + dialog.showModal(); + } + } +} + const application = Application.start() application.register("course-filter", CourseFilter); @@ -303,5 +329,7 @@ application.register("upload", Upload); application.register('autocomplete', Autocomplete); application.register('tom-select', TomSelect); application.register('share', Share); +application.register('modal', Modal); +application.register('modal-trigger', ModalTrigger); application.debug = true; diff --git a/www/templates/base.html b/www/templates/base.html index c43ff24a..c84a774b 100644 --- a/www/templates/base.html +++ b/www/templates/base.html @@ -194,6 +194,14 @@ margin-bottom: 1em; } + #reportDialog { + max-width: 800px; + width: 90vw; + } + + #reportDialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); + } {% block head %}{% endblock %} @@ -208,6 +216,14 @@ {% block header %} {% endblock header %}
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} {% block content %} {% endblock content %}