From 4160599f995fa6b255d3d1800a3d1e71dbd11bb4 Mon Sep 17 00:00:00 2001 From: mnietona Date: Thu, 2 Apr 2026 10:41:29 +0200 Subject: [PATCH 01/14] feat(moderation): implement moderator management interface with logging --- moderation/templates/moderation/home.html | 24 ++--- .../moderation/moderators_management.html | 92 ++++++++++++++++ moderation/urls.py | 4 + moderation/views.py | 101 +++++++++++++++++- 4 files changed, 199 insertions(+), 22 deletions(-) create mode 100644 moderation/templates/moderation/moderators_management.html diff --git a/moderation/templates/moderation/home.html b/moderation/templates/moderation/home.html index d0742784..1fab737e 100644 --- a/moderation/templates/moderation/home.html +++ b/moderation/templates/moderation/home.html @@ -7,28 +7,18 @@

Modération

- - - - - - Ajouter un modérateur - - - + href="{% url 'moderators_management' %}"> + + - Liste des modérateurs + Gérer les modérateurs {% endblock header %} {% block content %} +
+

Le dashboard sera complété bientôt !

+
{% endblock content %} diff --git a/moderation/templates/moderation/moderators_management.html b/moderation/templates/moderation/moderators_management.html new file mode 100644 index 00000000..b2280bf6 --- /dev/null +++ b/moderation/templates/moderation/moderators_management.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}Gestion des Modérateurs{% endblock %} + +{% block content %} +
+
+

Gestion des modérateurs

+ Retour au Dashboard +
+ +
+
+
+
+
Ajouter un modérateur
+
+
+
+ {% csrf_token %} + + + + +
+
+
+
+ +
+
+
+ + + + + + + + + + + + {% for mod in moderators %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
NetIDUtilisateurRôleDernière connexionActions
{{ mod.netid }} +
+
+ {{ mod.fullname|default:"-" }} +
{{ mod.email }} +
+
+
+ {% if mod.is_staff %} + Admin + {% else %} + Modérateur + {% endif %} + + {{ mod.last_login|date:"d/m/Y à H:i"|default:"Jamais" }} + + {% if mod == request.user %} + Vous + {% elif mod.is_staff %} + Intouchable + {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %} +
Aucun modérateur trouvé.
+
+
+
+
+
+{% endblock content %} diff --git a/moderation/urls.py b/moderation/urls.py index 36d5dfc6..139f4b9b 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -9,4 +9,8 @@ views.representative_request, name="representative_request", ), + # Nouvelle URL pour la gestion des modérateurs + path( + "manage-moderators/", views.moderators_management, name="moderators_management" + ), ] diff --git a/moderation/views.py b/moderation/views.py index 52b6853b..038b634a 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -1,15 +1,106 @@ -from django.contrib.auth.decorators import login_required -from django.shortcuts import redirect, render +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required, user_passes_test +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect, render from moderation.forms import RepresentativeRequestForm -from moderation.models import RepresentativeRequest +from moderation.models import ModerationLog, RepresentativeRequest + +User = get_user_model() + + +def is_moderator(user): + # On donne l'accès aux Admins (is_staff) ET aux Modérateurs (is_moderator) + return user.is_staff or user.is_moderator @login_required +@user_passes_test(is_moderator) def moderation_home(request): + return render(request, "moderation/home.html") + + +@login_required +@user_passes_test(is_moderator) +def moderators_management(request): + if request.method == "POST": + action = request.POST.get("action") + + # ACTION : AJOUTER UN MODÉRATEUR + if action == "add": + netid_to_add = request.POST.get("netid", "").strip() + if netid_to_add: + try: + target_user = User.objects.get(netid=netid_to_add) + + # Vérifier qu'il n'est ni Admin ni déjà Modérateur + if not target_user.is_staff and not target_user.is_moderator: + target_user.is_moderator = True + target_user.save() + + # ON CRÉE LE LOG ICI + ModerationLog.track( + user=request.user, + content_object=target_user, + values={"is_moderator": (False, True)}, + ) + + messages.success( + request, + f"{target_user.netid} est maintenant modérateur !", + ) + else: + messages.info( + request, + "Cet utilisateur a déjà des droits (Modérateur ou Admin).", + ) + except User.DoesNotExist: + messages.warning( + request, + f"❌ L'étudiant avec le netid '{netid_to_add}' n'a pas été trouvé.", + ) + + # ACTION : RETIRER UN MODÉRATEUR + elif action == "remove": + user_id = request.POST.get("user_id") + target_user = get_object_or_404(User, id=user_id) + + # Sécurité : impossible de toucher à un Admin ou de se retirer soi-même + # Gerer dans le Html en desactivant les boutons, mais on double la sécurité ici + if target_user.is_staff: + messages.warning( + request, + "Impossible de retirer les droits d'un Administrateur Système ici.", + ) + elif target_user == request.user: + messages.warning( + request, "Vous ne pouvez pas retirer vos propres droits ici." + ) + elif target_user.is_moderator: + target_user.is_moderator = False + target_user.save() + + # ON CRÉE LE LOG ICI + ModerationLog.track( + user=request.user, + content_object=target_user, + values={"is_moderator": (True, False)}, + ) + + messages.success( + request, f"🗑️ Les droits de {target_user.netid} ont été retirés." + ) + + return redirect("moderators_management") + + # Affichage de la page (GET) - On liste les Admins et les Modérateurs + moderators = User.objects.filter(Q(is_staff=True) | Q(is_moderator=True)).order_by( + "-is_staff", "first_name" + ) + return render( - request, - "moderation/home.html", + request, "moderation/moderators_management.html", {"moderators": moderators} ) From 4d6ffb904e33409a2b180536b00296acc7fda44c Mon Sep 17 00:00:00 2001 From: mnietona Date: Thu, 2 Apr 2026 10:42:02 +0200 Subject: [PATCH 02/14] chore(admin): display readable target object in ModerationLog admin --- moderation/admin.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/moderation/admin.py b/moderation/admin.py index 727d9473..ffbffb02 100644 --- a/moderation/admin.py +++ b/moderation/admin.py @@ -14,7 +14,7 @@ class ModerationLogAdmin(admin.ModelAdmin): list_display = ( "user", "content_type", - "object_id", + "target_item", "target_field", "old_value", "new_value", @@ -22,3 +22,10 @@ class ModerationLogAdmin(admin.ModelAdmin): ) list_filter = ("target_field", "timestamp", "content_type") search_fields = ("user__netid", "object_id", "old_value", "new_value") + + @admin.display(description="Objet ciblé") + def target_item(self, obj): + """Retourne le nom lisible de l'objet ciblé au lieu de son simple numéro d'ID""" + if obj.content_object: + return str(obj.content_object) + return f"ID {obj.object_id} (Supprimé)" From 7e3738ff325bf4678fbede2d43033d59e4c2d18c Mon Sep 17 00:00:00 2001 From: mnietona Date: Thu, 2 Apr 2026 11:49:40 +0200 Subject: [PATCH 03/14] feat(moderation): integrate requests processing on the dashboard --- moderation/templates/moderation/home.html | 65 ++++++++++++++++++++++- moderation/urls.py | 6 ++- moderation/views.py | 65 +++++++++++++++++++++-- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/moderation/templates/moderation/home.html b/moderation/templates/moderation/home.html index 1fab737e..a533399e 100644 --- a/moderation/templates/moderation/home.html +++ b/moderation/templates/moderation/home.html @@ -19,6 +19,69 @@

{% block content %}
-

Le dashboard sera complété bientôt !

+
+

Demandes en attente ({{ pending_requests.count }})

+
+ +
+ {% for req in pending_requests %} +
+
+
+ +
+
+ {{ req.user.initials|upper }} +
+
+
{{ req.user.fullname }}
+ {{ req.user.netid }} +
+
+ +
+ {{ req.get_faculty_display }} + {{ req.get_role_display }} +
+ + {% if req.comment %} +
+

+ "{{ req.comment }}" +

+
+ {% else %} +
{% endif %} + +

+ 📅 Reçue le {{ req.created|date:"d/m/Y à H:i" }} +

+ +
+
+ {% csrf_token %} + + +
+ +
+ {% csrf_token %} + + +
+
+ +
+
+
+ {% empty %} +
+
+
Tout est calme ! ☕
+

Il n'y a aucune demande d'accès modérateur en attente pour le moment.

+
+
+ {% endfor %} +
{% endblock content %} diff --git a/moderation/urls.py b/moderation/urls.py index 139f4b9b..d1c38f11 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -9,8 +9,12 @@ views.representative_request, name="representative_request", ), - # Nouvelle URL pour la gestion des modérateurs path( "manage-moderators/", views.moderators_management, name="moderators_management" ), + path( + "process-request//", + views.process_request, + name="process_request", + ), ] diff --git a/moderation/views.py b/moderation/views.py index 038b634a..dcc37b7d 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -3,6 +3,7 @@ from django.contrib.auth.decorators import login_required, user_passes_test from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.http import require_POST from moderation.forms import RepresentativeRequestForm from moderation.models import ModerationLog, RepresentativeRequest @@ -16,13 +17,71 @@ def is_moderator(user): @login_required -@user_passes_test(is_moderator) +@user_passes_test(is_moderator, login_url="/") def moderation_home(request): - return render(request, "moderation/home.html") + # On récupère toutes les demandes non traitées, de la plus récente à la plus ancienne + pending_requests = ( + RepresentativeRequest.objects.filter(processed=False) + .select_related("user") + .order_by("-created") + ) + + return render( + request, + "moderation/home.html", + {"pending_requests": pending_requests}, + ) + + +@login_required +@user_passes_test(is_moderator, login_url="/") +@require_POST +def process_request(request, request_id): + """Traite une demande de rôle (Accepter ou Refuser)""" + rep_request = get_object_or_404(RepresentativeRequest, id=request_id) + action = request.POST.get("action") + target_user = rep_request.user + + if action == "accept": + # Vérifie qu'il n'a pas déjà les droits (au cas où un autre modo l'a déjà fait) + # Si les 2 modos font la meme chose en meme temps + if not target_user.is_staff and not target_user.is_moderator: + target_user.is_moderator = True + target_user.save() + + # Création du log de modération + ModerationLog.track( + user=request.user, + content_object=target_user, + values={"is_moderator": (False, True)}, + ) + messages.success( + request, + f"La demande a été acceptée. {target_user.netid} est maintenant modérateur !", + ) + else: + messages.info( + request, + f"{target_user.netid} avait déjà des droits. Demande archivée.", + ) + + rep_request.processed = True + rep_request.save() + + elif action == "reject": + # On marque simplement la demande comme traitée pour la faire disparaître + rep_request.processed = True + rep_request.save() + messages.warning( + request, + f"La demande de {target_user.netid} a été refusée.", + ) + + return redirect("moderation_home") @login_required -@user_passes_test(is_moderator) +@user_passes_test(is_moderator, login_url="/") def moderators_management(request): if request.method == "POST": action = request.POST.get("action") From a8edb29ab7e9a7955d31ae8e81392ab7a8648a31 Mon Sep 17 00:00:00 2001 From: mnietona Date: Thu, 2 Apr 2026 12:34:03 +0200 Subject: [PATCH 04/14] feat(moderation): add rejection reason to requests and improve logging --- ..._representativerequest_rejection_reason.py | 18 ++++++++ moderation/models.py | 1 + moderation/templates/moderation/home.html | 19 ++++---- .../moderation/representative_request.html | 32 +++++++++++++ moderation/views.py | 45 +++++++++++++++---- 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 moderation/migrations/0004_representativerequest_rejection_reason.py diff --git a/moderation/migrations/0004_representativerequest_rejection_reason.py b/moderation/migrations/0004_representativerequest_rejection_reason.py new file mode 100644 index 00000000..c8abae69 --- /dev/null +++ b/moderation/migrations/0004_representativerequest_rejection_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-04-02 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("moderation", "0003_moderationlog_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="representativerequest", + name="rejection_reason", + field=models.TextField(blank=True, verbose_name="Raison du refus"), + ), + ] diff --git a/moderation/models.py b/moderation/models.py index 43b01e90..13187d46 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -38,6 +38,7 @@ class Role(models.TextChoices): created = models.DateTimeField(auto_now_add=True) processed = models.BooleanField(default=False) + rejection_reason = models.TextField(verbose_name="Raison du refus", blank=True) class ModerationLog(models.Model): diff --git a/moderation/templates/moderation/home.html b/moderation/templates/moderation/home.html index a533399e..0fd3ed2d 100644 --- a/moderation/templates/moderation/home.html +++ b/moderation/templates/moderation/home.html @@ -51,23 +51,22 @@
{{ req.user.fullname }}

{% else %} -
{% endif %} +
+ {% endif %}

📅 Reçue le {{ req.created|date:"d/m/Y à H:i" }}

-
-
+
+ {% csrf_token %} - - - + -
- {% csrf_token %} - - +
+ + +
diff --git a/moderation/templates/moderation/representative_request.html b/moderation/templates/moderation/representative_request.html index 566d8e16..499f15ff 100644 --- a/moderation/templates/moderation/representative_request.html +++ b/moderation/templates/moderation/representative_request.html @@ -4,6 +4,38 @@ {% block content %}

Demande de droits de modération

+ + {% if rejection_reason %} +
+ +
+ + + +

Ta précédente demande a été refusée

+
+ +

+ Les administrateurs ont laissé ce message pour toi : +

+ +
+

+ « {{ rejection_reason }} » +

+
+ +

+ + + + + Tu peux corriger tes informations et soumettre une nouvelle demande ci-dessous. +

+ +
+ {% endif %} +

Tu es délégué d'année, délégué cours de ton cercle ou dans un bureau étudiant ? Tu peux avoir des droits de modération sur DocHub. diff --git a/moderation/views.py b/moderation/views.py index dcc37b7d..153e21b1 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -37,11 +37,14 @@ def moderation_home(request): @user_passes_test(is_moderator, login_url="/") @require_POST def process_request(request, request_id): - """Traite une demande de rôle (Accepter ou Refuser)""" + """Traite une demande de rôle (Accepter ou Refuser) et log l'action""" rep_request = get_object_or_404(RepresentativeRequest, id=request_id) action = request.POST.get("action") target_user = rep_request.user + # Dictionnaire des modifications pour le système de logs + log_values = {"processed": (False, True)} + if action == "accept": # Vérifie qu'il n'a pas déjà les droits (au cas où un autre modo l'a déjà fait) # Si les 2 modos font la meme chose en meme temps @@ -65,18 +68,26 @@ def process_request(request, request_id): f"{target_user.netid} avait déjà des droits. Demande archivée.", ) - rep_request.processed = True - rep_request.save() - elif action == "reject": - # On marque simplement la demande comme traitée pour la faire disparaître - rep_request.processed = True - rep_request.save() + # On récupère la raison du refus + reason = request.POST.get("rejection_reason", "").strip() + rep_request.rejection_reason = reason + if reason: + log_values["rejection_reason"] = ("", reason) + messages.warning( request, f"La demande de {target_user.netid} a été refusée.", ) + # On logue que la demande a été traitée (et potentiellement la raison) + ModerationLog.track( + user=request.user, content_object=rep_request, values=log_values + ) + + rep_request.processed = True + rep_request.save() + return redirect("moderation_home") @@ -165,6 +176,7 @@ def moderators_management(request): @login_required def representative_request(request): + # check si demande de refus if RepresentativeRequest.objects.filter( user=request.user, processed=False ).exists(): @@ -173,6 +185,18 @@ def representative_request(request): "moderation/representative_request_received.html", ) + # On récupère la dernière raison de refus si l'étudiant n'est ni Admin ni Modo + rejection_msg = None + if not request.user.is_moderator and not request.user.is_staff: + last_req = ( + RepresentativeRequest.objects.filter(user=request.user, processed=True) + .order_by("-created") + .first() + ) + if last_req and last_req.rejection_reason: + rejection_msg = last_req.rejection_reason + + # Traitement classique if request.method == "POST": form = RepresentativeRequestForm(request.POST) if form.is_valid(): @@ -182,4 +206,9 @@ def representative_request(request): return redirect("representative_request") else: form = RepresentativeRequestForm() - return render(request, "moderation/representative_request.html", {"form": form}) + + return render( + request, + "moderation/representative_request.html", + {"form": form, "rejection_reason": rejection_msg}, + ) From d24e065da20c1635819577abc90341e5b5f789dd Mon Sep 17 00:00:00 2001 From: mnietona Date: Mon, 13 Apr 2026 20:05:34 +0200 Subject: [PATCH 05/14] refactor(moderation): use form for request processing, rename view, and enforce rejection reason --- moderation/forms.py | 23 +++++++++ moderation/templates/moderation/home.html | 14 +++++- moderation/urls.py | 4 +- moderation/views.py | 60 +++++++++++++---------- 4 files changed, 71 insertions(+), 30 deletions(-) diff --git a/moderation/forms.py b/moderation/forms.py index 78eaf2e9..961d5986 100644 --- a/moderation/forms.py +++ b/moderation/forms.py @@ -17,3 +17,26 @@ class Meta: widgets = { "comment": Textarea(attrs={"rows": 3, "placeholder": "Optionnel"}), } + + +class ProcessRepresentativeRequestForm(forms.Form): + ACTION_CHOICES = [ + ("accept", "Accepter"), + ("reject", "Refuser"), + ] + action = forms.ChoiceField(choices=ACTION_CHOICES) + rejection_reason = forms.CharField(required=False) + + def clean(self): + cleaned_data = super().clean() + action = cleaned_data.get("action") + reason = cleaned_data.get("rejection_reason", "").strip() + + # If the action is 'reject', we require a reason of at least 10 characters + if action == "reject" and len(reason) < 10: + self.add_error( + "rejection_reason", + "Veuillez fournir une raison d'au moins 10 caractères pour justifier le refus.", + ) + + return cleaned_data diff --git a/moderation/templates/moderation/home.html b/moderation/templates/moderation/home.html index 0fd3ed2d..214e93ab 100644 --- a/moderation/templates/moderation/home.html +++ b/moderation/templates/moderation/home.html @@ -19,6 +19,16 @@

{% block content %}
+ + {% if request.GET.error == 'reason' %} + + {% endif %}

Demandes en attente ({{ pending_requests.count }})

@@ -59,9 +69,9 @@
{{ req.user.fullname }}

-
+ {% csrf_token %} - +
diff --git a/moderation/urls.py b/moderation/urls.py index d1c38f11..70b4a847 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -14,7 +14,7 @@ ), path( "process-request//", - views.process_request, - name="process_request", + views.process_representative_request, + name="process_representative_request", ), ] diff --git a/moderation/views.py b/moderation/views.py index 153e21b1..4efded04 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -3,23 +3,24 @@ from django.contrib.auth.decorators import login_required, user_passes_test from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.views.decorators.http import require_POST -from moderation.forms import RepresentativeRequestForm +from moderation.forms import ProcessRepresentativeRequestForm, RepresentativeRequestForm from moderation.models import ModerationLog, RepresentativeRequest User = get_user_model() def is_moderator(user): - # On donne l'accès aux Admins (is_staff) ET aux Modérateurs (is_moderator) + # Grant access to Admins (is_staff) AND Moderators (is_moderator) return user.is_staff or user.is_moderator @login_required @user_passes_test(is_moderator, login_url="/") def moderation_home(request): - # On récupère toutes les demandes non traitées, de la plus récente à la plus ancienne + # Fetch all pending requests, from newest to oldest pending_requests = ( RepresentativeRequest.objects.filter(processed=False) .select_related("user") @@ -36,23 +37,31 @@ def moderation_home(request): @login_required @user_passes_test(is_moderator, login_url="/") @require_POST -def process_request(request, request_id): - """Traite une demande de rôle (Accepter ou Refuser) et log l'action""" +def process_representative_request(request, request_id): + """Processes a role request (Accept or Reject) and logs the action""" rep_request = get_object_or_404(RepresentativeRequest, id=request_id) - action = request.POST.get("action") target_user = rep_request.user - # Dictionnaire des modifications pour le système de logs + # Pass POST data to the Django form + form = ProcessRepresentativeRequestForm(request.POST) + + if not form.is_valid(): + # If the form is invalid (e.g., rejection reason is too short) + url = reverse("moderation_home") + "?error=reason" + return redirect(url) + + action = form.cleaned_data["action"] + + # Dictionary of changes for the logging system log_values = {"processed": (False, True)} if action == "accept": - # Vérifie qu'il n'a pas déjà les droits (au cas où un autre modo l'a déjà fait) - # Si les 2 modos font la meme chose en meme temps + # Check if the user already has rights (in case another mod already processed it) if not target_user.is_staff and not target_user.is_moderator: target_user.is_moderator = True target_user.save() - # Création du log de modération + # Create the moderation log ModerationLog.track( user=request.user, content_object=target_user, @@ -69,18 +78,17 @@ def process_request(request, request_id): ) elif action == "reject": - # On récupère la raison du refus - reason = request.POST.get("rejection_reason", "").strip() + # Get the rejection reason from the validated form + reason = form.cleaned_data["rejection_reason"] rep_request.rejection_reason = reason - if reason: - log_values["rejection_reason"] = ("", reason) + log_values["rejection_reason"] = ("", reason) messages.warning( request, f"La demande de {target_user.netid} a été refusée.", ) - # On logue que la demande a été traitée (et potentiellement la raison) + # Log that the request was processed (and potentially the reason) ModerationLog.track( user=request.user, content_object=rep_request, values=log_values ) @@ -97,19 +105,19 @@ def moderators_management(request): if request.method == "POST": action = request.POST.get("action") - # ACTION : AJOUTER UN MODÉRATEUR + # ACTION: ADD A MODERATOR if action == "add": netid_to_add = request.POST.get("netid", "").strip() if netid_to_add: try: target_user = User.objects.get(netid=netid_to_add) - # Vérifier qu'il n'est ni Admin ni déjà Modérateur + # Check that the user is not an Admin or already a Moderator if not target_user.is_staff and not target_user.is_moderator: target_user.is_moderator = True target_user.save() - # ON CRÉE LE LOG ICI + # CREATE THE LOG HERE ModerationLog.track( user=request.user, content_object=target_user, @@ -131,13 +139,13 @@ def moderators_management(request): f"❌ L'étudiant avec le netid '{netid_to_add}' n'a pas été trouvé.", ) - # ACTION : RETIRER UN MODÉRATEUR + # ACTION: REMOVE A MODERATOR elif action == "remove": user_id = request.POST.get("user_id") target_user = get_object_or_404(User, id=user_id) - # Sécurité : impossible de toucher à un Admin ou de se retirer soi-même - # Gerer dans le Html en desactivant les boutons, mais on double la sécurité ici + # Security: cannot modify an Admin or remove yourself + # Handled in HTML by disabling buttons, but we double the security here if target_user.is_staff: messages.warning( request, @@ -151,7 +159,7 @@ def moderators_management(request): target_user.is_moderator = False target_user.save() - # ON CRÉE LE LOG ICI + # CREATE THE LOG HERE ModerationLog.track( user=request.user, content_object=target_user, @@ -164,7 +172,7 @@ def moderators_management(request): return redirect("moderators_management") - # Affichage de la page (GET) - On liste les Admins et les Modérateurs + # Display the page (GET) - List Admins and Moderators moderators = User.objects.filter(Q(is_staff=True) | Q(is_moderator=True)).order_by( "-is_staff", "first_name" ) @@ -176,7 +184,7 @@ def moderators_management(request): @login_required def representative_request(request): - # check si demande de refus + # Check if there is a pending request if RepresentativeRequest.objects.filter( user=request.user, processed=False ).exists(): @@ -185,7 +193,7 @@ def representative_request(request): "moderation/representative_request_received.html", ) - # On récupère la dernière raison de refus si l'étudiant n'est ni Admin ni Modo + # Get the last rejection reason if the user is not Admin or Mod rejection_msg = None if not request.user.is_moderator and not request.user.is_staff: last_req = ( @@ -196,7 +204,7 @@ def representative_request(request): if last_req and last_req.rejection_reason: rejection_msg = last_req.rejection_reason - # Traitement classique + # Standard processing if request.method == "POST": form = RepresentativeRequestForm(request.POST) if form.is_valid(): From fcaf91ab4a2acf3e52bafd7a17611f6f5b4e923e Mon Sep 17 00:00:00 2001 From: mnietona Date: Mon, 13 Apr 2026 20:17:14 +0200 Subject: [PATCH 06/14] refactor(moderation): split moderator management into 3 distinct views and restrict removal to admins --- moderation/templates/moderation/home.html | 2 +- .../moderation/moderators_management.html | 19 +-- moderation/urls.py | 7 +- moderation/views.py | 129 +++++++++--------- 4 files changed, 79 insertions(+), 78 deletions(-) diff --git a/moderation/templates/moderation/home.html b/moderation/templates/moderation/home.html index 214e93ab..f02919af 100644 --- a/moderation/templates/moderation/home.html +++ b/moderation/templates/moderation/home.html @@ -8,7 +8,7 @@

Modération

+ href="{% url 'moderators_list' %}"> diff --git a/moderation/templates/moderation/moderators_management.html b/moderation/templates/moderation/moderators_management.html index b2280bf6..4cd324b5 100644 --- a/moderation/templates/moderation/moderators_management.html +++ b/moderation/templates/moderation/moderators_management.html @@ -16,9 +16,8 @@

Gestion des modérateurs

Ajouter un modérateur
- + {% csrf_token %} - @@ -68,12 +67,16 @@
Ajouter un modérateur
{% elif mod.is_staff %} Intouchable {% else %} - - {% csrf_token %} - - - - + + {% if user.is_staff %} +
+ {% csrf_token %} + +
+ {% else %} + Admin seulement + {% endif %} + {% endif %} diff --git a/moderation/urls.py b/moderation/urls.py index 70b4a847..5804ca92 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -9,8 +9,13 @@ views.representative_request, name="representative_request", ), + # New architecture for moderator management + path("moderators/", views.moderators_list, name="moderators_list"), + path("moderators/add/", views.moderator_add, name="moderator_add"), path( - "manage-moderators/", views.moderators_management, name="moderators_management" + "moderators/remove//", + views.moderator_remove, + name="moderator_remove", ), path( "process-request//", diff --git a/moderation/views.py b/moderation/views.py index 4efded04..f91a484c 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -101,85 +101,78 @@ def process_representative_request(request, request_id): @login_required @user_passes_test(is_moderator, login_url="/") -def moderators_management(request): - if request.method == "POST": - action = request.POST.get("action") - - # ACTION: ADD A MODERATOR - if action == "add": - netid_to_add = request.POST.get("netid", "").strip() - if netid_to_add: - try: - target_user = User.objects.get(netid=netid_to_add) - - # Check that the user is not an Admin or already a Moderator - if not target_user.is_staff and not target_user.is_moderator: - target_user.is_moderator = True - target_user.save() - - # CREATE THE LOG HERE - ModerationLog.track( - user=request.user, - content_object=target_user, - values={"is_moderator": (False, True)}, - ) - - messages.success( - request, - f"{target_user.netid} est maintenant modérateur !", - ) - else: - messages.info( - request, - "Cet utilisateur a déjà des droits (Modérateur ou Admin).", - ) - except User.DoesNotExist: - messages.warning( - request, - f"❌ L'étudiant avec le netid '{netid_to_add}' n'a pas été trouvé.", - ) - - # ACTION: REMOVE A MODERATOR - elif action == "remove": - user_id = request.POST.get("user_id") - target_user = get_object_or_404(User, id=user_id) - - # Security: cannot modify an Admin or remove yourself - # Handled in HTML by disabling buttons, but we double the security here - if target_user.is_staff: - messages.warning( - request, - "Impossible de retirer les droits d'un Administrateur Système ici.", - ) - elif target_user == request.user: - messages.warning( - request, "Vous ne pouvez pas retirer vos propres droits ici." - ) - elif target_user.is_moderator: - target_user.is_moderator = False +def moderators_list(request): + """Display the list of all Admins and Moderators""" + moderators = User.objects.filter(Q(is_staff=True) | Q(is_moderator=True)).order_by( + "-is_staff", "first_name" + ) + return render( + request, "moderation/moderators_management.html", {"moderators": moderators} + ) + + +@login_required +@user_passes_test(is_moderator, login_url="/") +@require_POST +def moderator_add(request): + """Add a new moderator using their NetID""" + netid_to_add = request.POST.get("netid", "").strip() + if netid_to_add: + try: + target_user = User.objects.get(netid=netid_to_add) + + if not target_user.is_staff and not target_user.is_moderator: + target_user.is_moderator = True target_user.save() - # CREATE THE LOG HERE ModerationLog.track( user=request.user, content_object=target_user, - values={"is_moderator": (True, False)}, + values={"is_moderator": (False, True)}, ) - messages.success( - request, f"🗑️ Les droits de {target_user.netid} ont été retirés." + request, f"{target_user.netid} est maintenant modérateur !" + ) + else: + messages.info( + request, "Cet utilisateur a déjà des droits (Modérateur ou Admin)." ) + except User.DoesNotExist: + messages.warning( + request, + f"❌ L'étudiant avec le netid '{netid_to_add}' n'a pas été trouvé.", + ) - return redirect("moderators_management") + return redirect("moderators_list") - # Display the page (GET) - List Admins and Moderators - moderators = User.objects.filter(Q(is_staff=True) | Q(is_moderator=True)).order_by( - "-is_staff", "first_name" - ) - return render( - request, "moderation/moderators_management.html", {"moderators": moderators} - ) +@login_required +@user_passes_test(lambda u: u.is_staff, login_url="/") # 🔒 Only Admins can remove +@require_POST +def moderator_remove(request, user_id): + """Remove moderator rights from a user (Admin only)""" + target_user = get_object_or_404(User, id=user_id) + + if target_user.is_staff: + messages.warning( + request, "Impossible de retirer les droits d'un Administrateur Système ici." + ) + elif target_user == request.user: + messages.warning(request, "Vous ne pouvez pas retirer vos propres droits ici.") + elif target_user.is_moderator: + target_user.is_moderator = False + target_user.save() + + ModerationLog.track( + user=request.user, + content_object=target_user, + values={"is_moderator": (True, False)}, + ) + messages.success( + request, f"🗑️ Les droits de {target_user.netid} ont été retirés." + ) + + return redirect("moderators_list") @login_required From 7c06edf4ec5b7333457e196a1916a23eec818ddb Mon Sep 17 00:00:00 2001 From: mnietona Date: Mon, 13 Apr 2026 21:03:12 +0200 Subject: [PATCH 07/14] feat(moderation): add paginated public transparency logs --- .../templates/moderation/public_logs.html | 90 +++++++++++++++++++ moderation/urls.py | 5 ++ moderation/views.py | 26 ++++++ www/templates/base.html | 12 ++- 4 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 moderation/templates/moderation/public_logs.html diff --git a/moderation/templates/moderation/public_logs.html b/moderation/templates/moderation/public_logs.html new file mode 100644 index 00000000..d7bafd31 --- /dev/null +++ b/moderation/templates/moderation/public_logs.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block title %}Transparence Modération{% endblock %} + +{% block header %} +
+

+ + + + + Log de Transparence +

+
+{% endblock header %} + +{% block content %} +
+{% endblock content %} diff --git a/moderation/urls.py b/moderation/urls.py index 5804ca92..64eb4b65 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -22,4 +22,9 @@ views.process_representative_request, name="process_representative_request", ), + path( + "logs/", + views.public_logs, + name="public_logs", + ), ] diff --git a/moderation/views.py b/moderation/views.py index f91a484c..ad6e7ac1 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -1,6 +1,7 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required, user_passes_test +from django.core.paginator import Paginator from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -213,3 +214,28 @@ def representative_request(request): "moderation/representative_request.html", {"form": form, "rejection_reason": rejection_msg}, ) + + +@login_required +def public_logs(request): + """Public ledger of moderation actions for accountability (accessible to all logged users).""" + + # Fetch all relevant logs, excluding noisy backend fields + log_list = ( + ModerationLog.objects.exclude( + target_field__in=["processed", "rejection_reason", "statut"] + ) + .select_related("user", "content_type") + .order_by("-timestamp") + ) + + # Set up pagination (e.g., 50 logs per page) + paginator = Paginator(log_list, 50) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + return render( + request, + "moderation/public_logs.html", + {"logs": page_obj}, + ) diff --git a/www/templates/base.html b/www/templates/base.html index c84a774b..eeac34f5 100644 --- a/www/templates/base.html +++ b/www/templates/base.html @@ -236,13 +236,17 @@
- + UrLab - - Code open-source sur GitHub + + + + | + GitHub + + Transparence modération
{% endblock fullpage %} From 15322ded623e4f9d392ab9239f4e907323f6d83d Mon Sep 17 00:00:00 2001 From: mnietona Date: Mon, 13 Apr 2026 21:29:54 +0200 Subject: [PATCH 08/14] fix(moderation): implement robust OOP fallbacks for legacy logs and clean admin UI --- moderation/admin.py | 40 +++++++++++----------- moderation/models.py | 81 +++++++++++++++++++++++++++++++++++++++++++- moderation/views.py | 26 +++++++------- 3 files changed, 113 insertions(+), 34 deletions(-) diff --git a/moderation/admin.py b/moderation/admin.py index ffbffb02..3ea9b3bb 100644 --- a/moderation/admin.py +++ b/moderation/admin.py @@ -1,31 +1,33 @@ from django.contrib import admin -from moderation.models import ModerationLog, RepresentativeRequest - - -@admin.register(RepresentativeRequest) -class RepresentativeRequestAdmin(admin.ModelAdmin): - list_display = ("user", "faculty", "processed", "created") - list_filter = ("processed",) +from moderation.models import ModerationLog @admin.register(ModerationLog) class ModerationLogAdmin(admin.ModelAdmin): list_display = ( "user", - "content_type", - "target_item", - "target_field", - "old_value", - "new_value", "timestamp", + "display_action", + "display_target", + "display_details", ) list_filter = ("target_field", "timestamp", "content_type") - search_fields = ("user__netid", "object_id", "old_value", "new_value") + search_fields = ("user__netid", "object_id") + + def get_queryset(self, request): + """Hide old technical logs to keep the admin clean""" + qs = super().get_queryset(request) + return qs.exclude(target_field__in=["processed", "rejection_reason", "statut"]) + + @admin.display(description="Action") + def display_action(self, obj): + return obj.action_text + + @admin.display(description="Cible") + def display_target(self, obj): + return obj.target_text - @admin.display(description="Objet ciblé") - def target_item(self, obj): - """Retourne le nom lisible de l'objet ciblé au lieu de son simple numéro d'ID""" - if obj.content_object: - return str(obj.content_object) - return f"ID {obj.object_id} (Supprimé)" + @admin.display(description="Détails") + def display_details(self, obj): + return obj.details_text diff --git a/moderation/models.py b/moderation/models.py index 13187d46..a965e8d6 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -40,6 +40,9 @@ class Role(models.TextChoices): processed = models.BooleanField(default=False) rejection_reason = models.TextField(verbose_name="Raison du refus", blank=True) + def __str__(self): + return f"Demande d'accès de {self.user.netid}" + class ModerationLog(models.Model): user = models.ForeignKey("users.User", on_delete=models.CASCADE) @@ -59,7 +62,83 @@ class Meta: ] def __str__(self): - return f"{self.user.get_short_name()} modified {self.content_type.name}#{self.object_id} {self.target_field}: '{self.old_value}' -> '{self.new_value}'" + return f"{self.user.get_short_name()} a fait une action le {self.timestamp.strftime('%d/%m/%Y')}" + + ### Action translation logic ### + + @property + def action_text(self): + """Translates the technical action into a readable French sentence. Acts as a catch-all.""" + if self.target_field == "is_moderator": + return ( + "a promu modérateur" + if str(self.new_value) == "True" + else "a retiré les droits de" + ) + elif self.target_field == "action_accepter": + return "a accepté la demande de" + elif self.target_field == "action_rejeter": + return "a refusé la demande de" + + # Fallback for all other technical fields (e.g., 'visible', 'is_active', title changes) + return f"a modifié le champ '{self.target_field}' de" + + @property + def action_color(self): + """Assigns a Bootstrap color based on the action type. Acts as a catch-all.""" + if self.target_field == "is_moderator": + return "success" if str(self.new_value) == "True" else "danger" + elif self.target_field == "action_accepter": + return "success" + elif self.target_field == "action_rejeter": + return "warning" + + # Specific colors for known fallback fields + if self.target_field in ["visible", "is_active"]: + return "success" if str(self.new_value) == "True" else "danger" + + return "secondary" + + @property + def target_text(self): + """Smartly retrieves the target NetID or object name. Acts as a catch-all.""" + if not self.content_object: + return f"Objet supprimé (ID: {self.object_id})" + + if self.content_type.model == "representativerequest": + return self.content_object.user.netid + if self.content_type.model == "user": + return self.content_object.netid + + # Fallback for Documents, Courses, etc. + return str(self.content_object) + + @property + def details_text(self): + """Displays additional details (like the rejection reason or old values for legacy logs)""" + if self.target_field == "action_rejeter" and self.new_value != "Sans motif": + return f'Motif : "{self.new_value}"' + + # If it's a technical fallback action, we show what changed + if self.target_field not in [ + "is_moderator", + "action_accepter", + "action_rejeter", + ]: + # Truncate strings that are too long to avoid breaking the UI + old = ( + self.old_value[:40] + "..." + if len(self.old_value) > 40 + else self.old_value + ) + new = ( + self.new_value[:40] + "..." + if len(self.new_value) > 40 + else self.new_value + ) + return f"Ancienne valeur : {old} ➔ Nouvelle valeur : {new}" + + return "" @classmethod def track(cls, user, content_object: models.Model, values: dict[str, tuple]): diff --git a/moderation/views.py b/moderation/views.py index ad6e7ac1..0ab0fe7c 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -53,21 +53,18 @@ def process_representative_request(request, request_id): action = form.cleaned_data["action"] - # Dictionary of changes for the logging system - log_values = {"processed": (False, True)} - if action == "accept": - # Check if the user already has rights (in case another mod already processed it) + # Check if the user already has rights if not target_user.is_staff and not target_user.is_moderator: target_user.is_moderator = True target_user.save() - # Create the moderation log ModerationLog.track( user=request.user, - content_object=target_user, - values={"is_moderator": (False, True)}, + content_object=rep_request, + values={"action_accepter": ("", "Acceptée")}, ) + messages.success( request, f"La demande a été acceptée. {target_user.netid} est maintenant modérateur !", @@ -82,18 +79,19 @@ def process_representative_request(request, request_id): # Get the rejection reason from the validated form reason = form.cleaned_data["rejection_reason"] rep_request.rejection_reason = reason - log_values["rejection_reason"] = ("", reason) + + ModerationLog.track( + user=request.user, + content_object=rep_request, + values={"action_rejeter": ("", reason if reason else "Sans motif")}, + ) messages.warning( request, f"La demande de {target_user.netid} a été refusée.", ) - # Log that the request was processed (and potentially the reason) - ModerationLog.track( - user=request.user, content_object=rep_request, values=log_values - ) - + # Mark as processed without logging technical noise rep_request.processed = True rep_request.save() @@ -148,7 +146,7 @@ def moderator_add(request): @login_required -@user_passes_test(lambda u: u.is_staff, login_url="/") # 🔒 Only Admins can remove +@user_passes_test(lambda u: u.is_staff, login_url="/") # Only Admins can remove @require_POST def moderator_remove(request, user_id): """Remove moderator rights from a user (Admin only)""" From fbbcdec814159dce37836986a012beffe730b09d Mon Sep 17 00:00:00 2001 From: mnietona Date: Tue, 21 Apr 2026 21:50:22 +0200 Subject: [PATCH 09/14] refactor(moderation): clean up legacy log fallbacks and enforce default on rejection_reason --- moderation/admin.py | 9 +++- ..._representativerequest_rejection_reason.py | 20 ++++++++ moderation/models.py | 47 ++++--------------- 3 files changed, 37 insertions(+), 39 deletions(-) create mode 100644 moderation/migrations/0005_alter_representativerequest_rejection_reason.py diff --git a/moderation/admin.py b/moderation/admin.py index 3ea9b3bb..89928703 100644 --- a/moderation/admin.py +++ b/moderation/admin.py @@ -1,6 +1,13 @@ from django.contrib import admin -from moderation.models import ModerationLog +from moderation.models import ModerationLog, RepresentativeRequest + + +@admin.register(RepresentativeRequest) +class RepresentativeRequestAdmin(admin.ModelAdmin): + list_display = ("user", "faculty", "role", "processed", "created") + list_filter = ("processed", "faculty", "role") + search_fields = ("user__netid", "user__email") @admin.register(ModerationLog) diff --git a/moderation/migrations/0005_alter_representativerequest_rejection_reason.py b/moderation/migrations/0005_alter_representativerequest_rejection_reason.py new file mode 100644 index 00000000..16406748 --- /dev/null +++ b/moderation/migrations/0005_alter_representativerequest_rejection_reason.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0 on 2026-04-21 19:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("moderation", "0004_representativerequest_rejection_reason"), + ] + + operations = [ + migrations.AlterField( + model_name="representativerequest", + name="rejection_reason", + field=models.TextField( + blank=True, default="", verbose_name="Raison du refus" + ), + ), + ] diff --git a/moderation/models.py b/moderation/models.py index a965e8d6..88c3fc40 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -38,7 +38,9 @@ class Role(models.TextChoices): created = models.DateTimeField(auto_now_add=True) processed = models.BooleanField(default=False) - rejection_reason = models.TextField(verbose_name="Raison du refus", blank=True) + rejection_reason = models.TextField( + verbose_name="Raison du refus", blank=True, default="" + ) def __str__(self): return f"Demande d'accès de {self.user.netid}" @@ -68,7 +70,7 @@ def __str__(self): @property def action_text(self): - """Translates the technical action into a readable French sentence. Acts as a catch-all.""" + """Translates the semantic action into a readable French sentence.""" if self.target_field == "is_moderator": return ( "a promu modérateur" @@ -79,65 +81,35 @@ def action_text(self): return "a accepté la demande de" elif self.target_field == "action_rejeter": return "a refusé la demande de" - - # Fallback for all other technical fields (e.g., 'visible', 'is_active', title changes) - return f"a modifié le champ '{self.target_field}' de" + return f"a modifié '{self.target_field}' sur" @property def action_color(self): - """Assigns a Bootstrap color based on the action type. Acts as a catch-all.""" + """Assigns a Bootstrap color based on the action type.""" if self.target_field == "is_moderator": return "success" if str(self.new_value) == "True" else "danger" elif self.target_field == "action_accepter": return "success" elif self.target_field == "action_rejeter": return "warning" - - # Specific colors for known fallback fields - if self.target_field in ["visible", "is_active"]: - return "success" if str(self.new_value) == "True" else "danger" - return "secondary" @property def target_text(self): - """Smartly retrieves the target NetID or object name. Acts as a catch-all.""" + """Smartly retrieves the target NetID or object name.""" if not self.content_object: - return f"Objet supprimé (ID: {self.object_id})" - + return "Objet supprimé" if self.content_type.model == "representativerequest": return self.content_object.user.netid if self.content_type.model == "user": return self.content_object.netid - - # Fallback for Documents, Courses, etc. return str(self.content_object) @property def details_text(self): - """Displays additional details (like the rejection reason or old values for legacy logs)""" + """Displays additional details (like the rejection reason)""" if self.target_field == "action_rejeter" and self.new_value != "Sans motif": return f'Motif : "{self.new_value}"' - - # If it's a technical fallback action, we show what changed - if self.target_field not in [ - "is_moderator", - "action_accepter", - "action_rejeter", - ]: - # Truncate strings that are too long to avoid breaking the UI - old = ( - self.old_value[:40] + "..." - if len(self.old_value) > 40 - else self.old_value - ) - new = ( - self.new_value[:40] + "..." - if len(self.new_value) > 40 - else self.new_value - ) - return f"Ancienne valeur : {old} ➔ Nouvelle valeur : {new}" - return "" @classmethod @@ -145,7 +117,6 @@ def track(cls, user, content_object: models.Model, values: dict[str, tuple]): """ Saves a new ModerationLog for each field that has changed `values` should be dict where the key is the field name, and the value is a tuple of [old value of the field, new value of the field]. - The old and new values should be strings of lists (in that case, every item of the list is converted to a str and then joined by comas. """ for field, (old, new) in values.items(): if isinstance(old, (collections.abc.Iterable, QuerySet)) and not isinstance( # type: ignore From d29c05aa9c4bb5dfb8c1450fc22fe92785b02447 Mon Sep 17 00:00:00 2001 From: mnietona Date: Tue, 21 Apr 2026 22:33:35 +0200 Subject: [PATCH 10/14] refactor(moderation): implement strict 403 permissions, forms validation, and Turbo UI --- moderation/forms.py | 14 +++ moderation/templates/moderation/home.html | 2 +- moderation/urls.py | 23 ++-- moderation/views.py | 128 +++++++++++++++------- 4 files changed, 116 insertions(+), 51 deletions(-) diff --git a/moderation/forms.py b/moderation/forms.py index 961d5986..341a6635 100644 --- a/moderation/forms.py +++ b/moderation/forms.py @@ -40,3 +40,17 @@ def clean(self): ) return cleaned_data + + +class AddModeratorForm(forms.Form): + netid = forms.CharField( + max_length=50, + required=True, + widget=forms.TextInput( + attrs={ + "class": "form-control", + "placeholder": "Ex: blabevue", + "id": "netid", + } + ), + ) diff --git a/moderation/templates/moderation/home.html b/moderation/templates/moderation/home.html index f02919af..bca53564 100644 --- a/moderation/templates/moderation/home.html +++ b/moderation/templates/moderation/home.html @@ -75,7 +75,7 @@
{{ req.user.fullname }}
- +
diff --git a/moderation/urls.py b/moderation/urls.py index 64eb4b65..06d62459 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -3,13 +3,22 @@ from . import views urlpatterns = [ + # --- Main Dashboard --- path("", views.moderation_home, name="moderation_home"), + # --- Logs Publics --- + path("logs/", views.public_logs, name="public_logs"), + # --- Representative Request --- path( - "representative-request", + "representative-request/", views.representative_request, name="representative_request", ), - # New architecture for moderator management + path( + "representative-request//process/", + views.process_representative_request, + name="process_representative_request", + ), + # --- Moderators Management --- path("moderators/", views.moderators_list, name="moderators_list"), path("moderators/add/", views.moderator_add, name="moderator_add"), path( @@ -17,14 +26,4 @@ views.moderator_remove, name="moderator_remove", ), - path( - "process-request//", - views.process_representative_request, - name="process_representative_request", - ), - path( - "logs/", - views.public_logs, - name="public_logs", - ), ] diff --git a/moderation/views.py b/moderation/views.py index 0ab0fe7c..4dcce970 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -1,27 +1,67 @@ +from functools import wraps + from django.contrib import messages from django.contrib.auth import get_user_model -from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied from django.core.paginator import Paginator from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.http import require_POST -from moderation.forms import ProcessRepresentativeRequestForm, RepresentativeRequestForm +from moderation.forms import ( + AddModeratorForm, + ProcessRepresentativeRequestForm, + RepresentativeRequestForm, +) from moderation.models import ModerationLog, RepresentativeRequest User = get_user_model() +# --- Custom Decorators for 403 Permissions --- + + def is_moderator(user): - # Grant access to Admins (is_staff) AND Moderators (is_moderator) return user.is_staff or user.is_moderator +def moderator_required(view_func): + """Decorator for views that checks that the user is a moderator, raising a 403 if not.""" + + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + if is_moderator(request.user): + return view_func(request, *args, **kwargs) + raise PermissionDenied( + "Tu n'as pas les droits de modération pour accéder à cette page." + ) + + return _wrapped_view + + +def admin_required(view_func): + """Decorator for views that checks that the user is staff, raising a 403 if not.""" + + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + if request.user.is_staff: + return view_func(request, *args, **kwargs) + raise PermissionDenied( + "Seul un administrateur système peut effectuer cette action." + ) + + return _wrapped_view + + +# --------------------------------------------- + + @login_required -@user_passes_test(is_moderator, login_url="/") +@moderator_required def moderation_home(request): - # Fetch all pending requests, from newest to oldest + """Main dashboard for moderators.""" pending_requests = ( RepresentativeRequest.objects.filter(processed=False) .select_related("user") @@ -36,28 +76,27 @@ def moderation_home(request): @login_required -@user_passes_test(is_moderator, login_url="/") +@moderator_required @require_POST def process_representative_request(request, request_id): - """Processes a role request (Accept or Reject) and logs the action""" - rep_request = get_object_or_404(RepresentativeRequest, id=request_id) + """Processes a role request (Accept or Reject) and logs the action.""" + rep_request = get_object_or_404( + RepresentativeRequest, id=request_id, processed=False + ) target_user = rep_request.user - # Pass POST data to the Django form form = ProcessRepresentativeRequestForm(request.POST) if not form.is_valid(): - # If the form is invalid (e.g., rejection reason is too short) url = reverse("moderation_home") + "?error=reason" return redirect(url) action = form.cleaned_data["action"] if action == "accept": - # Check if the user already has rights if not target_user.is_staff and not target_user.is_moderator: target_user.is_moderator = True - target_user.save() + target_user.save(update_fields=["is_moderator"]) ModerationLog.track( user=request.user, @@ -76,7 +115,6 @@ def process_representative_request(request, request_id): ) elif action == "reject": - # Get the rejection reason from the validated form reason = form.cleaned_data["rejection_reason"] rep_request.rejection_reason = reason @@ -91,38 +129,48 @@ def process_representative_request(request, request_id): f"La demande de {target_user.netid} a été refusée.", ) - # Mark as processed without logging technical noise + # Mark as processed with update_fields for performance rep_request.processed = True - rep_request.save() + rep_request.save(update_fields=["processed", "rejection_reason"]) return redirect("moderation_home") @login_required -@user_passes_test(is_moderator, login_url="/") +@moderator_required def moderators_list(request): - """Display the list of all Admins and Moderators""" + """Display the list of all Admins and Moderators.""" moderators = User.objects.filter(Q(is_staff=True) | Q(is_moderator=True)).order_by( "-is_staff", "first_name" ) return render( - request, "moderation/moderators_management.html", {"moderators": moderators} + request, + "moderation/moderators_management.html", + {"moderators": moderators, "add_form": AddModeratorForm()}, ) @login_required -@user_passes_test(is_moderator, login_url="/") +@moderator_required @require_POST def moderator_add(request): - """Add a new moderator using their NetID""" - netid_to_add = request.POST.get("netid", "").strip() - if netid_to_add: + """Add a new moderator using a validated Form.""" + form = AddModeratorForm(request.POST) + + if form.is_valid(): + netid_to_add = form.cleaned_data["netid"] + try: target_user = User.objects.get(netid=netid_to_add) if not target_user.is_staff and not target_user.is_moderator: target_user.is_moderator = True - target_user.save() + target_user.save(update_fields=["is_moderator"]) + + # FIX : Close any pending request for this user automatically + RepresentativeRequest.objects.filter( + user=target_user, processed=False + ).update(processed=True, rejection_reason="") ModerationLog.track( user=request.user, @@ -146,10 +194,10 @@ def moderator_add(request): @login_required -@user_passes_test(lambda u: u.is_staff, login_url="/") # Only Admins can remove +@admin_required @require_POST def moderator_remove(request, user_id): - """Remove moderator rights from a user (Admin only)""" + """Remove moderator rights from a user (Admin only).""" target_user = get_object_or_404(User, id=user_id) if target_user.is_staff: @@ -157,10 +205,10 @@ def moderator_remove(request, user_id): request, "Impossible de retirer les droits d'un Administrateur Système ici." ) elif target_user == request.user: - messages.warning(request, "Vous ne pouvez pas retirer vos propres droits ici.") + messages.warning(request, "Tu ne peux pas retirer tes propres droits ici.") elif target_user.is_moderator: target_user.is_moderator = False - target_user.save() + target_user.save(update_fields=["is_moderator"]) ModerationLog.track( user=request.user, @@ -176,7 +224,14 @@ def moderator_remove(request, user_id): @login_required def representative_request(request): - # Check if there is a pending request + """Handle student requests to become a moderator.""" + if is_moderator(request.user): + messages.info( + request, + "Tu es déjà modérateur (ou admin), tu n'as pas besoin de faire de demande !", + ) + return redirect("/") + if RepresentativeRequest.objects.filter( user=request.user, processed=False ).exists(): @@ -185,18 +240,15 @@ def representative_request(request): "moderation/representative_request_received.html", ) - # Get the last rejection reason if the user is not Admin or Mod rejection_msg = None - if not request.user.is_moderator and not request.user.is_staff: - last_req = ( - RepresentativeRequest.objects.filter(user=request.user, processed=True) - .order_by("-created") - .first() - ) - if last_req and last_req.rejection_reason: - rejection_msg = last_req.rejection_reason + last_req = ( + RepresentativeRequest.objects.filter(user=request.user, processed=True) + .order_by("-created") + .first() + ) + if last_req and last_req.rejection_reason: + rejection_msg = last_req.rejection_reason - # Standard processing if request.method == "POST": form = RepresentativeRequestForm(request.POST) if form.is_valid(): From 54dab417b6b5c6b4c302707be22f5ec3d8bb6576 Mon Sep 17 00:00:00 2001 From: mnietona Date: Wed, 22 Apr 2026 18:53:34 +0200 Subject: [PATCH 11/14] style: fix broken SVG icon rendering as a checkbox --- moderation/templates/moderation/representative_request.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/moderation/templates/moderation/representative_request.html b/moderation/templates/moderation/representative_request.html index 499f15ff..873b77ed 100644 --- a/moderation/templates/moderation/representative_request.html +++ b/moderation/templates/moderation/representative_request.html @@ -27,8 +27,7 @@

Ta p

- - + Tu peux corriger tes informations et soumettre une nouvelle demande ci-dessous.

From ce55759a679be2bf3cce58acf915e228999b09d0 Mon Sep 17 00:00:00 2001 From: mnietona Date: Wed, 22 Apr 2026 19:22:00 +0200 Subject: [PATCH 12/14] refactor(moderation): simplify log fetching in public_logs by removing unnecessary exclusions --- moderation/views.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/moderation/views.py b/moderation/views.py index 4dcce970..1742d50c 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -271,12 +271,8 @@ def public_logs(request): """Public ledger of moderation actions for accountability (accessible to all logged users).""" # Fetch all relevant logs, excluding noisy backend fields - log_list = ( - ModerationLog.objects.exclude( - target_field__in=["processed", "rejection_reason", "statut"] - ) - .select_related("user", "content_type") - .order_by("-timestamp") + log_list = ModerationLog.objects.select_related("user", "content_type").order_by( + "-timestamp" ) # Set up pagination (e.g., 50 logs per page) From ae841b4d39df39f700eb05abaede6075101ff9e0 Mon Sep 17 00:00:00 2001 From: mnietona Date: Wed, 22 Apr 2026 19:36:13 +0200 Subject: [PATCH 13/14] perf: add prefetch_related for generic foreign key to fix N+1 --- moderation/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/moderation/views.py b/moderation/views.py index 1742d50c..8889b74d 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -270,9 +270,11 @@ def representative_request(request): def public_logs(request): """Public ledger of moderation actions for accountability (accessible to all logged users).""" - # Fetch all relevant logs, excluding noisy backend fields - log_list = ModerationLog.objects.select_related("user", "content_type").order_by( - "-timestamp" + # Fetch all relevant moderation logs + log_list = ( + ModerationLog.objects.select_related("user", "content_type") + .prefetch_related("content_object") + .order_by("-timestamp") ) # Set up pagination (e.g., 50 logs per page) From 01d198f3833e28995753e7a5c5c176831d22e990 Mon Sep 17 00:00:00 2001 From: mnietona Date: Mon, 27 Apr 2026 09:26:20 +0200 Subject: [PATCH 14/14] refactor(moderation): replace confirm dialog with Turbo confirmation for moderator removal --- moderation/templates/moderation/moderators_management.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/moderation/templates/moderation/moderators_management.html b/moderation/templates/moderation/moderators_management.html index 4cd324b5..f93ec6b5 100644 --- a/moderation/templates/moderation/moderators_management.html +++ b/moderation/templates/moderation/moderators_management.html @@ -69,7 +69,7 @@
Ajouter un modérateur
{% else %} {% if user.is_staff %} -
+ {% csrf_token %}