Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions moderation/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,36 @@

@admin.register(RepresentativeRequest)
class RepresentativeRequestAdmin(admin.ModelAdmin):
Comment thread
mnietona marked this conversation as resolved.
list_display = ("user", "faculty", "processed", "created")
list_filter = ("processed",)
list_display = ("user", "faculty", "role", "processed", "created")
list_filter = ("processed", "faculty", "role")
search_fields = ("user__netid", "user__email")


@admin.register(ModerationLog)
class ModerationLogAdmin(admin.ModelAdmin):
list_display = (
"user",
"content_type",
"object_id",
"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="Détails")
def display_details(self, obj):
return obj.details_text
37 changes: 37 additions & 0 deletions moderation/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,40 @@ 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
Comment thread
C4ptainCrunch marked this conversation as resolved.
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


class AddModeratorForm(forms.Form):
netid = forms.CharField(
max_length=50,
required=True,
widget=forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Ex: blabevue",
"id": "netid",
}
),
)
Original file line number Diff line number Diff line change
@@ -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"),
),
]
Original file line number Diff line number Diff line change
@@ -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"
),
),
]
55 changes: 53 additions & 2 deletions moderation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ 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, default=""
)

def __str__(self):
return f"Demande d'accès de {self.user.netid}"


class ModerationLog(models.Model):
Expand All @@ -58,14 +64,59 @@ 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 semantic action into a readable French sentence."""
if self.target_field == "is_moderator":
return (
"a promu modérateur"
if str(self.new_value) == "True"
Comment thread
C4ptainCrunch marked this conversation as resolved.
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"
return f"a modifié '{self.target_field}' sur"

@property
def action_color(self):
"""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"
return "secondary"

@property
def target_text(self):
"""Smartly retrieves the target NetID or object name."""
if not self.content_object:
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
return str(self.content_object)

@property
def details_text(self):
"""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}"'
return ""

@classmethod
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
Expand Down
96 changes: 79 additions & 17 deletions moderation/templates/moderation/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,90 @@
<h1 class="d-flex align-items-center gap-2">
Modération
</h1>
<a class="btn btn-outline-success btn-sm d-inline-flex align-items-center gap-1"
href="">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-person-fill-add" viewBox="0 0 16 16">
<path
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"/>
<path
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"/>
</svg>
<span>Ajouter un modérateur</span>
</a>
<a class="btn btn-outline-primary btn-sm d-inline-flex align-items-center gap-1"
href="">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill"
viewBox="0 0 16 16">
<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"/>
href="{% url 'moderators_list' %}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-people-fill" viewBox="0 0 16 16">
<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"/>
</svg>
<span>Liste des modérateurs</span>
<span>Gérer les modérateurs</span>
</a>
</header>
{% endblock header %}

{% block content %}
<div class="container-xl mt-4">

{% if request.GET.error == 'reason' %}
<div class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
<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">
<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"/>
</svg>
Veuillez fournir une raison d'au moins 10 caractères pour justifier le refus.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="h4 mb-0">Demandes en attente ({{ pending_requests.count }})</h2>
</div>

<div class="row">
{% for req in pending_requests %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-body d-flex flex-column">

<div class="d-flex align-items-center gap-3 mb-3">
<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;">
{{ req.user.initials|upper }}
</div>
<div>
<h5 class="card-title h6 mb-0">{{ req.user.fullname }}</h5>
<small class="text-muted">{{ req.user.netid }}</small>
</div>
</div>

<div class="mb-3">
<span class="badge bg-light text-dark border border-secondary">{{ req.get_faculty_display }}</span>
<span class="badge bg-light text-dark border border-secondary">{{ req.get_role_display }}</span>
</div>

{% if req.comment %}
<div class="bg-light p-3 rounded mb-3 flex-grow-1">
<p class="card-text small fst-italic mb-0 text-muted">
"{{ req.comment }}"
</p>
</div>
{% else %}
<div class="flex-grow-1"></div>
{% endif %}

<p class="text-muted small mb-3">
📅 Reçue le {{ req.created|date:"d/m/Y à H:i" }}
</p>

<div class="pt-3 border-top mt-auto">
<form method="post" action="{% url 'process_representative_request' req.id %}" class="w-100">
{% csrf_token %}
<input type="text" name="rejection_reason" class="form-control form-control-sm mb-2" placeholder="Message de refus (10 caractères min.)" minlength="10">

<div class="d-flex gap-2">
<button type="submit" name="action" value="accept" class="btn btn-success btn-sm flex-grow-1 fw-bold">Accepter</button>
<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>
</div>
</form>
</div>

</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="alert alert-light border border-secondary text-center py-5 text-muted shadow-sm">
<h5 class="mb-2">Tout est calme ! ☕</h5>
<p class="mb-0">Il n'y a aucune demande d'accès modérateur en attente pour le moment.</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock content %}
Loading
Loading