Skip to content
29 changes: 28 additions & 1 deletion documents/admin.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -73,6 +74,7 @@ class DocumentAdmin(admin.ModelAdmin):
"pages",
"views",
"downloads",
"report_count",
"hidden",
"state",
"created",
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 20 additions & 1 deletion documents/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
}
),
}
72 changes: 72 additions & 0 deletions documents/migrations/0006_documentreport.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
],
),
]
49 changes: 49 additions & 0 deletions documents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Comment thread
C4ptainCrunch marked this conversation as resolved.
@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)
Expand Down
74 changes: 74 additions & 0 deletions documents/templates/documents/document_report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
{% extends "base.html" %}

{% block title %}Signaler {{ document.name }}{% endblock %}

{% block content %}
<div class="container-xl py-4">
<div class="row justify-content-center">
<div class="col-lg-8">
{% include "documents/document_report.html#report_card" %}
</div>
</div>
</div>
{% endblock content %}

{% partialdef report_modal %}
<dialog id="reportDialog"
data-controller="modal"
data-action="click->modal#clickOutside"
class="rounded shadow-lg border-0 p-0">
{% include "documents/document_report.html#report_card" %}
</dialog>
{% endpartialdef %}

{% partialdef report_card %}
<div class="card">
<div class="card-header">
<h5 class="mb-0">Signaler un problème</h5>
</div>

<form action="{% url 'document_report' document.id %}" method="post">
{% csrf_token %}

{{ form.non_field_errors }}
<div class="card-body">
<div class="mb-3">
{% load documents_tags %}
{% for choice_value, choice_label in form.fields.problem_type.choices %}
{% if choice_value %}
<div class="form-check mb-3">
<input class="form-check-input" type="radio" name="problem_type"
id="problem_{{ choice_value }}" value="{{ choice_value }}"
{% if form.problem_type.value == choice_value %}checked{% endif %}>
<label class="form-check-label" for="problem_{{ choice_value }}">
<div class="fw-semibold">{{ choice_label }}</div>
<div class="text-muted small">
{{ choice_value|problem_type_description }}
</div>
</label>
</div>
{% endif %}
{% endfor %}
</div>

<div class="mb-3">
<label for="id_description" class="form-label">Commentaire (optionnel)
<div class="form-text small">
Tu peux ajouter des détails supplémentaires pour nous aider à comprendre le problème.
</div>
</label>

{{ form.description }}

</div>
</div>

<div class="card-footer d-flex gap-2 justify-content-end">
<button type="button" data-action="click->modal#close" class="btn btn-outline-secondary">Annuler</button>
<button type="submit" class="btn btn-primary">
Envoyer
</button>
</div>
</form>
</div>
{% endpartialdef %}
14 changes: 14 additions & 0 deletions documents/templates/documents/viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
Modifier
</a>
{% endif %}

<button data-controller="share" data-share-url="{{ document.get_absolute_url }}"
data-action="click->share#share"
class="d-none btn btn-outline-secondary btn-sm d-inline-flex align-items-center gap-1"
Expand Down Expand Up @@ -134,6 +135,17 @@ <h1 class="fs-4 mb-0 d-flex align-items-center gap-1">
</button>
</form>
</turbo-frame>
<a href="{% url 'document_report' document.pk %}" title="Signaler" data-controller="modal-trigger"
data-modal-trigger-target-value="reportDialog"
data-action="click->modal-trigger#open"
class="mt-2 btn btn-outline-warning btn-sm d-inline-flex align-items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-flag"
viewBox="0 0 16 16">
<path
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"/>
</svg>

</a>
</div>

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

{% endif %}

{% include "documents/document_report.html#report_modal" %}
{% endblock content %}
11 changes: 11 additions & 0 deletions documents/templatetags/documents_tags.py
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions documents/tests/report_test.py
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions documents/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
),
path("<int:pk>", documents.views.document_show, name="document_show"),
path("<int:pk>/vote", documents.views.document_vote, name="document_vote"),
path("<int:pk>/report", documents.views.document_report, name="document_report"),
path(
"<int:pk>/original",
documents.views.document_original_file,
Expand Down
Loading