Skip to content

Commit 07ee27e

Browse files
authored
Add a moderation interface to add and remove moderators and see a transparency log of their actions
1 parent d2adfa6 commit 07ee27e

12 files changed

Lines changed: 731 additions & 36 deletions

moderation/admin.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,36 @@
55

66
@admin.register(RepresentativeRequest)
77
class RepresentativeRequestAdmin(admin.ModelAdmin):
8-
list_display = ("user", "faculty", "processed", "created")
9-
list_filter = ("processed",)
8+
list_display = ("user", "faculty", "role", "processed", "created")
9+
list_filter = ("processed", "faculty", "role")
10+
search_fields = ("user__netid", "user__email")
1011

1112

1213
@admin.register(ModerationLog)
1314
class ModerationLogAdmin(admin.ModelAdmin):
1415
list_display = (
1516
"user",
16-
"content_type",
17-
"object_id",
18-
"target_field",
19-
"old_value",
20-
"new_value",
2117
"timestamp",
18+
"display_action",
19+
"display_target",
20+
"display_details",
2221
)
2322
list_filter = ("target_field", "timestamp", "content_type")
24-
search_fields = ("user__netid", "object_id", "old_value", "new_value")
23+
search_fields = ("user__netid", "object_id")
24+
25+
def get_queryset(self, request):
26+
"""Hide old technical logs to keep the admin clean"""
27+
qs = super().get_queryset(request)
28+
return qs.exclude(target_field__in=["processed", "rejection_reason", "statut"])
29+
30+
@admin.display(description="Action")
31+
def display_action(self, obj):
32+
return obj.action_text
33+
34+
@admin.display(description="Cible")
35+
def display_target(self, obj):
36+
return obj.target_text
37+
38+
@admin.display(description="Détails")
39+
def display_details(self, obj):
40+
return obj.details_text

moderation/forms.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,40 @@ class Meta:
1717
widgets = {
1818
"comment": Textarea(attrs={"rows": 3, "placeholder": "Optionnel"}),
1919
}
20+
21+
22+
class ProcessRepresentativeRequestForm(forms.Form):
23+
ACTION_CHOICES = [
24+
("accept", "Accepter"),
25+
("reject", "Refuser"),
26+
]
27+
action = forms.ChoiceField(choices=ACTION_CHOICES)
28+
rejection_reason = forms.CharField(required=False)
29+
30+
def clean(self):
31+
cleaned_data = super().clean()
32+
action = cleaned_data.get("action")
33+
reason = cleaned_data.get("rejection_reason", "").strip()
34+
35+
# If the action is 'reject', we require a reason of at least 10 characters
36+
if action == "reject" and len(reason) < 10:
37+
self.add_error(
38+
"rejection_reason",
39+
"Veuillez fournir une raison d'au moins 10 caractères pour justifier le refus.",
40+
)
41+
42+
return cleaned_data
43+
44+
45+
class AddModeratorForm(forms.Form):
46+
netid = forms.CharField(
47+
max_length=50,
48+
required=True,
49+
widget=forms.TextInput(
50+
attrs={
51+
"class": "form-control",
52+
"placeholder": "Ex: blabevue",
53+
"id": "netid",
54+
}
55+
),
56+
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 6.0 on 2026-04-02 09:52
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("moderation", "0003_moderationlog_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="representativerequest",
15+
name="rejection_reason",
16+
field=models.TextField(blank=True, verbose_name="Raison du refus"),
17+
),
18+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 6.0 on 2026-04-21 19:45
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("moderation", "0004_representativerequest_rejection_reason"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="representativerequest",
15+
name="rejection_reason",
16+
field=models.TextField(
17+
blank=True, default="", verbose_name="Raison du refus"
18+
),
19+
),
20+
]

moderation/models.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ class Role(models.TextChoices):
3838

3939
created = models.DateTimeField(auto_now_add=True)
4040
processed = models.BooleanField(default=False)
41+
rejection_reason = models.TextField(
42+
verbose_name="Raison du refus", blank=True, default=""
43+
)
44+
45+
def __str__(self):
46+
return f"Demande d'accès de {self.user.netid}"
4147

4248

4349
class ModerationLog(models.Model):
@@ -58,14 +64,59 @@ class Meta:
5864
]
5965

6066
def __str__(self):
61-
return f"{self.user.get_short_name()} modified {self.content_type.name}#{self.object_id} {self.target_field}: '{self.old_value}' -> '{self.new_value}'"
67+
return f"{self.user.get_short_name()} a fait une action le {self.timestamp.strftime('%d/%m/%Y')}"
68+
69+
### Action translation logic ###
70+
71+
@property
72+
def action_text(self):
73+
"""Translates the semantic action into a readable French sentence."""
74+
if self.target_field == "is_moderator":
75+
return (
76+
"a promu modérateur"
77+
if str(self.new_value) == "True"
78+
else "a retiré les droits de"
79+
)
80+
elif self.target_field == "action_accepter":
81+
return "a accepté la demande de"
82+
elif self.target_field == "action_rejeter":
83+
return "a refusé la demande de"
84+
return f"a modifié '{self.target_field}' sur"
85+
86+
@property
87+
def action_color(self):
88+
"""Assigns a Bootstrap color based on the action type."""
89+
if self.target_field == "is_moderator":
90+
return "success" if str(self.new_value) == "True" else "danger"
91+
elif self.target_field == "action_accepter":
92+
return "success"
93+
elif self.target_field == "action_rejeter":
94+
return "warning"
95+
return "secondary"
96+
97+
@property
98+
def target_text(self):
99+
"""Smartly retrieves the target NetID or object name."""
100+
if not self.content_object:
101+
return "Objet supprimé"
102+
if self.content_type.model == "representativerequest":
103+
return self.content_object.user.netid
104+
if self.content_type.model == "user":
105+
return self.content_object.netid
106+
return str(self.content_object)
107+
108+
@property
109+
def details_text(self):
110+
"""Displays additional details (like the rejection reason)"""
111+
if self.target_field == "action_rejeter" and self.new_value != "Sans motif":
112+
return f'Motif : "{self.new_value}"'
113+
return ""
62114

63115
@classmethod
64116
def track(cls, user, content_object: models.Model, values: dict[str, tuple]):
65117
"""
66118
Saves a new ModerationLog for each field that has changed
67119
`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].
68-
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.
69120
"""
70121
for field, (old, new) in values.items():
71122
if isinstance(old, (collections.abc.Iterable, QuerySet)) and not isinstance( # type: ignore

moderation/templates/moderation/home.html

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,90 @@
77
<h1 class="d-flex align-items-center gap-2">
88
Modération
99
</h1>
10-
<a class="btn btn-outline-success btn-sm d-inline-flex align-items-center gap-1"
11-
href="">
12-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
13-
class="bi bi-person-fill-add" viewBox="0 0 16 16">
14-
<path
15-
d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm.5-5v1h1a.5.5 0 0 1 0 1h-1v1a.5.5 0 0 1-1 0v-1h-1a.5.5 0 0 1 0-1h1v-1a.5.5 0 0 1 1 0Zm-2-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
16-
<path
17-
d="M2 13c0 1 1 1 1 1h5.256A4.493 4.493 0 0 1 8 12.5a4.49 4.49 0 0 1 1.544-3.393C9.077 9.038 8.564 9 8 9c-5 0-6 3-6 4Z"/>
18-
</svg>
19-
<span>Ajouter un modérateur</span>
20-
</a>
2110
<a class="btn btn-outline-primary btn-sm d-inline-flex align-items-center gap-1"
22-
href="">
23-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill"
24-
viewBox="0 0 16 16">
25-
<path
26-
d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1H7Zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.784 6A2.238 2.238 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.325 6.325 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1h4.216ZM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/>
11+
href="{% url 'moderators_list' %}">
12+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
13+
<path d="M7 14s-1 0-1-1 1-4 5-4 5 3 5 4-1 1-1 1H7Zm4-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-5.784 6A2.238 2.238 0 0 1 5 13c0-1.355.68-2.75 1.936-3.72A6.325 6.325 0 0 0 5 9c-4 0-5 3-5 4s1 1 1 1h4.216ZM4.5 8a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z"/>
2714
</svg>
28-
<span>Liste des modérateurs</span>
15+
<span>Gérer les modérateurs</span>
2916
</a>
3017
</header>
3118
{% endblock header %}
3219

3320
{% block content %}
21+
<div class="container-xl mt-4">
22+
23+
{% if request.GET.error == 'reason' %}
24+
<div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
25+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill flex-shrink-0 me-2" viewBox="0 0 16 16">
26+
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
27+
</svg>
28+
Veuillez fournir une raison d'au moins 10 caractères pour justifier le refus.
29+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
30+
</div>
31+
{% endif %}
32+
<div class="d-flex align-items-center justify-content-between mb-4">
33+
<h2 class="h4 mb-0">Demandes en attente ({{ pending_requests.count }})</h2>
34+
</div>
35+
36+
<div class="row">
37+
{% for req in pending_requests %}
38+
<div class="col-md-6 col-lg-4 mb-4">
39+
<div class="card shadow-sm border-0 h-100">
40+
<div class="card-body d-flex flex-column">
41+
42+
<div class="d-flex align-items-center gap-3 mb-3">
43+
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center shadow-sm" style="width: 45px; height: 45px; font-size: 1.1rem; font-weight: bold;">
44+
{{ req.user.initials|upper }}
45+
</div>
46+
<div>
47+
<h5 class="card-title h6 mb-0">{{ req.user.fullname }}</h5>
48+
<small class="text-muted">{{ req.user.netid }}</small>
49+
</div>
50+
</div>
51+
52+
<div class="mb-3">
53+
<span class="badge bg-light text-dark border border-secondary">{{ req.get_faculty_display }}</span>
54+
<span class="badge bg-light text-dark border border-secondary">{{ req.get_role_display }}</span>
55+
</div>
56+
57+
{% if req.comment %}
58+
<div class="bg-light p-3 rounded mb-3 flex-grow-1">
59+
<p class="card-text small fst-italic mb-0 text-muted">
60+
"{{ req.comment }}"
61+
</p>
62+
</div>
63+
{% else %}
64+
<div class="flex-grow-1"></div>
65+
{% endif %}
66+
67+
<p class="text-muted small mb-3">
68+
📅 Reçue le {{ req.created|date:"d/m/Y à H:i" }}
69+
</p>
70+
71+
<div class="pt-3 border-top mt-auto">
72+
<form method="post" action="{% url 'process_representative_request' req.id %}" class="w-100">
73+
{% csrf_token %}
74+
<input type="text" name="rejection_reason" class="form-control form-control-sm mb-2" placeholder="Message de refus (10 caractères min.)" minlength="10">
75+
76+
<div class="d-flex gap-2">
77+
<button type="submit" name="action" value="accept" class="btn btn-success btn-sm flex-grow-1 fw-bold">Accepter</button>
78+
<button type="submit" name="action" value="reject" class="btn btn-outline-danger btn-sm flex-grow-1" data-turbo-confirm="Es-tu sûr de vouloir refuser cette demande ?">Refuser</button>
79+
</div>
80+
</form>
81+
</div>
82+
83+
</div>
84+
</div>
85+
</div>
86+
{% empty %}
87+
<div class="col-12">
88+
<div class="alert alert-light border border-secondary text-center py-5 text-muted shadow-sm">
89+
<h5 class="mb-2">Tout est calme ! ☕</h5>
90+
<p class="mb-0">Il n'y a aucune demande d'accès modérateur en attente pour le moment.</p>
91+
</div>
92+
</div>
93+
{% endfor %}
94+
</div>
95+
</div>
3496
{% endblock content %}

0 commit comments

Comments
 (0)