Merge pull request #980 from ae-utbm/ban-groups

Ban groups
This commit is contained in:
thomas girod 2025-01-05 15:54:19 +01:00 committed by GitHub
commit 7f4cc5fb0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 638 additions and 92 deletions

View File

@ -17,7 +17,7 @@ from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import Permission
from core.models import Group, OperationLog, Page, SithFile, User
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
admin.site.unregister(AuthGroup)
@ -30,6 +30,19 @@ class GroupAdmin(admin.ModelAdmin):
autocomplete_fields = ("permissions",)
@admin.register(BanGroup)
class BanGroupAdmin(admin.ModelAdmin):
list_display = ("name", "description")
search_fields = ("name",)
autocomplete_fields = ("permissions",)
class UserBanInline(admin.TabularInline):
model = UserBan
extra = 0
autocomplete_fields = ("ban_group",)
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
list_display = ("first_name", "last_name", "username", "email", "nick_name")
@ -42,9 +55,16 @@ class UserAdmin(admin.ModelAdmin):
"user_permissions",
"groups",
)
inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"]
@admin.register(UserBan)
class UserBanAdmin(admin.ModelAdmin):
list_display = ("user", "ban_group", "created_at", "expires_at")
autocomplete_fields = ("user", "ban_group")
@admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)

View File

@ -48,7 +48,7 @@ from accounting.models import (
from club.models import Club, Membership
from com.calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import Group, Page, PageRev, SithFile, User
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import Counter, Product, ProductType, StudentCard
from election.models import Candidature, Election, ElectionList, Role
@ -94,6 +94,7 @@ class Command(BaseCommand):
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
groups = self._create_groups()
self._create_ban_groups()
root = User.objects.create_superuser(
id=0,
@ -951,11 +952,6 @@ Welcome to the wiki page!
)
)
)
Group.objects.create(
name="Banned from buying alcohol", is_manually_manageable=True
)
Group.objects.create(name="Banned from counters", is_manually_manageable=True)
Group.objects.create(name="Banned to subscribe", is_manually_manageable=True)
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
sas_admin.permissions.add(
*list(
@ -995,3 +991,8 @@ Welcome to the wiki page!
sas_admin=sas_admin,
pedagogy_admin=pedagogy_admin,
)
def _create_ban_groups(self):
BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", description="")
BanGroup.objects.create(name="Banned to subscribe", description="")

View File

@ -0,0 +1,164 @@
# Generated by Django 4.2.17 on 2024-12-31 13:30
import django.contrib.auth.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
def migrate_ban_groups(apps: StateApps, schema_editor):
Group = apps.get_model("core", "Group")
BanGroup = apps.get_model("core", "BanGroup")
ban_group_ids = [
settings.SITH_GROUP_BANNED_ALCOHOL_ID,
settings.SITH_GROUP_BANNED_COUNTER_ID,
settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
]
# this is a N+1 Queries, but the prod database has a grand total of 3 ban groups
for group in Group.objects.filter(id__in=ban_group_ids):
# auth_group, which both Group and BanGroup inherit,
# is unique by name.
# If we tried give the exact same name to the migrated BanGroup
# before deleting the corresponding Group,
# we would have an IntegrityError.
# So we append a space to the name, in order to create a name
# that will look the same, but that isn't really the same.
ban_group = BanGroup.objects.create(
name=f"{group.name} ",
description=group.description,
)
perms = list(group.permissions.values_list("id", flat=True))
if perms:
ban_group.permissions.add(*perms)
ban_group.users.add(
*group.users.values_list("id", flat=True), through_defaults={"reason": ""}
)
group.delete()
# now that the original group is no longer there,
# we can remove the appended space
ban_group.name = ban_group.name.strip()
ban_group.save()
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("core", "0042_invert_is_manually_manageable_20250104_1742"),
]
operations = [
migrations.CreateModel(
name="BanGroup",
fields=[
(
"group_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="auth.group",
),
),
("description", models.TextField(verbose_name="description")),
],
bases=("auth.group",),
managers=[
("objects", django.contrib.auth.models.GroupManager()),
],
options={
"verbose_name": "ban group",
"verbose_name_plural": "ban groups",
},
),
migrations.AlterField(
model_name="group",
name="description",
field=models.TextField(verbose_name="description"),
),
migrations.AlterField(
model_name="user",
name="groups",
field=models.ManyToManyField(
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="users",
to="core.group",
verbose_name="groups",
),
),
migrations.CreateModel(
name="UserBan",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"expires_at",
models.DateTimeField(
blank=True,
help_text="When the ban should be removed. Currently, there is no automatic removal, so this is purely indicative. Automatic ban removal may be implemented later on.",
null=True,
verbose_name="expires at",
),
),
("reason", models.TextField(verbose_name="reason")),
(
"ban_group",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_bans",
to="core.bangroup",
verbose_name="ban type",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bans",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
),
migrations.AddField(
model_name="user",
name="ban_groups",
field=models.ManyToManyField(
help_text="The bans this user has received.",
related_name="users",
through="core.UserBan",
to="core.bangroup",
verbose_name="ban groups",
),
),
migrations.AddConstraint(
model_name="userban",
constraint=models.UniqueConstraint(
fields=("ban_group", "user"), name="unique_ban_type_per_user"
),
),
migrations.AddConstraint(
model_name="userban",
constraint=models.CheckConstraint(
check=models.Q(("expires_at__gte", models.F("created_at"))),
name="user_ban_end_after_start",
),
),
migrations.RunPython(
migrate_ban_groups, reverse_code=migrations.RunPython.noop, elidable=True
),
]

View File

@ -42,7 +42,7 @@ from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, OuterRef, Q
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@ -65,8 +65,7 @@ class Group(AuthGroup):
default=False,
help_text=_("If False, this shouldn't be shown on group management pages"),
)
#: Description of the group
description = models.CharField(_("description"), max_length=60)
description = models.TextField(_("description"))
def get_absolute_url(self) -> str:
return reverse("core:group_list")
@ -134,6 +133,28 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
return group
class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them.
Users are linked to BanGroups through UserBan objects.
Example:
```python
user = User.objects.get(username="...")
ban_group = BanGroup.objects.first()
UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
assert user.ban_groups.contains(ban_group)
```
"""
description = models.TextField(_("description"))
class Meta:
verbose_name = _("ban group")
verbose_name_plural = _("ban groups")
class UserQuerySet(models.QuerySet):
def filter_inactive(self) -> Self:
from counter.models import Refilling, Selling
@ -184,7 +205,13 @@ class User(AbstractUser):
"granted to each of their groups."
),
related_name="users",
blank=True,
)
ban_groups = models.ManyToManyField(
BanGroup,
verbose_name=_("ban groups"),
through="UserBan",
help_text=_("The bans this user has received."),
related_name="users",
)
home = models.OneToOneField(
"SithFile",
@ -424,12 +451,12 @@ class User(AbstractUser):
)
@cached_property
def is_banned_alcohol(self):
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
def is_banned_alcohol(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
@cached_property
def is_banned_counter(self):
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
def is_banned_counter(self) -> bool:
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
@cached_property
def age(self) -> int:
@ -731,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
return _("Visitor")
class UserBan(models.Model):
"""A ban of a user.
A user can be banned for a specific reason, for a specific duration.
The expiration date is indicative, and the ban should be removed manually.
"""
ban_group = models.ForeignKey(
BanGroup,
verbose_name=_("ban type"),
related_name="user_bans",
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
expires_at = models.DateTimeField(
_("expires at"),
null=True,
blank=True,
help_text=_(
"When the ban should be removed. "
"Currently, there is no automatic removal, so this is purely indicative. "
"Automatic ban removal may be implemented later on."
),
)
reason = models.TextField(_("reason"))
class Meta:
verbose_name = _("user ban")
verbose_name_plural = _("user bans")
constraints = [
models.UniqueConstraint(
fields=["ban_group", "user"], name="unique_ban_type_per_user"
),
models.CheckConstraint(
check=Q(expires_at__gte=F("created_at")),
name="user_ban_end_after_start",
),
]
def __str__(self):
return f"Ban of user {self.user.id}"
class Preferences(models.Model):
user = models.OneToOneField(
User, related_name="_preferences", on_delete=models.CASCADE

View File

@ -29,7 +29,7 @@
align-items: center;
gap: 20px;
&:hover {
&.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
}

View File

@ -199,14 +199,6 @@ form {
}
}
// ------------- LEGEND
legend {
font-weight: var(--nf-label-font-weight);
display: block;
margin-bottom: calc(var(--nf-input-size) / 5);
}
.form-group,
> p,
> div {

View File

@ -23,6 +23,9 @@
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
{% endif %}
{% if user.has_perm("core:view_userban") %}
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
{% endif %}
{% if user.can_create_subscription or user.is_root %}
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
{% endif %}

View File

@ -358,9 +358,6 @@ class TestUserIsInGroup(TestCase):
cls.accounting_admin = Group.objects.get(name="Accounting admin")
cls.com_admin = Group.objects.get(name="Communication admin")
cls.counter_admin = Group.objects.get(name="Counter admin")
cls.banned_alcohol = Group.objects.get(name="Banned from buying alcohol")
cls.banned_counters = Group.objects.get(name="Banned from counters")
cls.banned_subscription = Group.objects.get(name="Banned to subscribe")
cls.sas_admin = Group.objects.get(name="SAS admin")
cls.club = baker.make(Club)
cls.main_club = Club.objects.get(id=1)
@ -373,7 +370,6 @@ class TestUserIsInGroup(TestCase):
self.assert_in_public_group(user)
for group in (
self.root_group,
self.banned_counters,
self.accounting_admin,
self.sas_admin,
self.subscribers,

View File

@ -21,6 +21,7 @@
#
#
import re
from datetime import date, datetime
from io import BytesIO
from captcha.fields import CaptchaField
@ -32,7 +33,14 @@ from django.contrib.staticfiles.management.commands.collectstatic import (
)
from django.core.exceptions import ValidationError
from django.db import transaction
from django.forms import CheckboxSelectMultiple, DateInput, DateTimeInput, TextInput
from django.forms import (
CheckboxSelectMultiple,
DateInput,
DateTimeInput,
TextInput,
Widget,
)
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@ -125,6 +133,23 @@ class SelectUser(TextInput):
return output
# Fields
def validate_future_timestamp(value: date | datetime):
if value <= now():
raise ValueError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField):
"""A datetime field that accepts only future timestamps."""
default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(now())}
# Forms

View File

@ -31,7 +31,7 @@ from model_bakery import baker
from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import Group, User
from core.models import BanGroup, User
from counter.baker_recipes import product_recipe
from counter.models import (
Counter,
@ -229,11 +229,11 @@ class TestCounterClick(TestFullClickBase):
cls.set_age(cls.banned_alcohol_customer, 20)
cls.set_age(cls.underage_customer, 17)
cls.banned_alcohol_customer.groups.add(
Group.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
cls.banned_alcohol_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
)
cls.banned_counter_customer.groups.add(
Group.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
cls.banned_counter_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
)
cls.beer = product_recipe.make(

View File

@ -0,0 +1,7 @@
::: rootplace.forms
handler: python
options:
members:
- MergeForm
- SelectUserForm
- BanForm

View File

@ -1 +1,12 @@
::: rootplace.views
handler: python
options:
members:
- merge_users
- delete_all_forum_user_messages
- MergeUsersView
- DeleteAllForumUserMessagesView
- OperationLogListView
- BanView
- BanCreateView
- BanDeleteView

View File

@ -921,8 +921,8 @@ msgstr "home"
msgid "You can not make loops in clubs"
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
#: club/models.py counter/models.py eboutic/models.py election/models.py
#: launderette/models.py sas/models.py trombi/models.py
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py launderette/models.py sas/models.py trombi/models.py
msgid "user"
msgstr "utilisateur"
@ -1009,6 +1009,7 @@ msgstr "Description"
#: club/templates/club/club_members.jinja core/templates/core/user_clubs.jinja
#: launderette/templates/launderette/launderette_admin.jinja
#: rootplace/templates/rootplace/userban.jinja
msgid "Since"
msgstr "Depuis"
@ -1787,7 +1788,7 @@ msgstr "Message d'alerte"
msgid "Screens list"
msgstr "Liste d'écrans"
#: com/views.py
#: com/views.py rootplace/templates/rootplace/userban.jinja
msgid "Until"
msgstr "Jusqu'à"
@ -1832,6 +1833,14 @@ msgstr ""
msgid "%(value)s is not a valid promo (between 0 and %(end)s)"
msgstr "%(value)s n'est pas une promo valide (doit être entre 0 et %(end)s)"
#: core/models.py
msgid "ban group"
msgstr "groupe de ban"
#: core/models.py
msgid "ban groups"
msgstr "groupes de ban"
#: core/models.py
msgid "first name"
msgstr "Prénom"
@ -1868,6 +1877,10 @@ msgstr ""
"Les groupes auxquels cet utilisateur appartient. Un utilisateur aura toutes "
"les permissions de chacun de ses groupes."
#: core/models.py
msgid "The bans this user has received."
msgstr "Les bans que cet utilisateur a reçus."
#: core/models.py
msgid "profile"
msgstr "profil"
@ -2019,6 +2032,42 @@ msgstr "Profil"
msgid "Visitor"
msgstr "Visiteur"
#: core/models.py
msgid "ban type"
msgstr "type de ban"
#: core/models.py
msgid "created at"
msgstr "créé le"
#: core/models.py
msgid "expires at"
msgstr "expire le"
#: core/models.py
msgid ""
"When the ban should be removed. Currently, there is no automatic removal, so "
"this is purely indicative. Automatic ban removal may be implemented later on."
msgstr ""
"Quand le ban devrait être retiré. Actuellement, il n'y a pas de retrait automatique, "
"donc ceci est purement indicatif. Le retrait automatique pourra être implémenté plus tard."
#: core/models.py pedagogy/models.py
msgid "reason"
msgstr "raison"
#: core/models.py
#, fuzzy
#| msgid "user"
msgid "user ban"
msgstr "utilisateur"
#: core/models.py
#, fuzzy
#| msgid "user"
msgid "user bans"
msgstr "utilisateur"
#: core/models.py
msgid "receive the Weekmail"
msgstr "recevoir le Weekmail"
@ -3117,6 +3166,10 @@ msgstr "Journal d'opérations"
msgid "Delete user's forum messages"
msgstr "Supprimer les messages forum d'un utilisateur"
#: core/templates/core/user_tools.jinja
msgid "Bans"
msgstr "Bans"
#: core/templates/core/user_tools.jinja
msgid "Subscriptions"
msgstr "Cotisations"
@ -3260,6 +3313,10 @@ msgstr "Choisir un fichier"
msgid "Choose user"
msgstr "Choisir un utilisateur"
#: core/views/forms.py
msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur"
#: core/views/forms.py
msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
@ -4882,10 +4939,6 @@ msgstr "signaler"
msgid "reporter"
msgstr "signalant"
#: pedagogy/models.py
msgid "reason"
msgstr "raison"
#: pedagogy/templates/pedagogy/guide.jinja
#, python-format
msgid "%(display_name)s"
@ -4917,7 +4970,8 @@ msgstr "non noté"
msgid "UV comment moderation"
msgstr "Modération des commentaires d'UV"
#: pedagogy/templates/pedagogy/moderation.jinja sas/models.py
#: pedagogy/templates/pedagogy/moderation.jinja
#: rootplace/templates/rootplace/userban.jinja sas/models.py
msgid "Reason"
msgstr "Raison"
@ -5038,6 +5092,18 @@ msgstr "Autocomplétion réussite"
msgid "An error occurred: "
msgstr "Une erreur est survenue : "
#: rootplace/forms.py
msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé"
#: rootplace/forms.py
msgid "User that will be deleted"
msgstr "Utilisateur qui sera supprimé"
#: rootplace/forms.py
msgid "User to be selected"
msgstr "Utilisateur à sélectionner"
#: rootplace/templates/rootplace/delete_user_messages.jinja
msgid "Delete all forum messages from an user"
msgstr "Supprimer tous les messages forum d'un utilisateur"
@ -5067,17 +5133,21 @@ msgstr "Fusionner deux utilisateurs"
msgid "Merge"
msgstr "Fusion"
#: rootplace/views.py
msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé"
#: rootplace/templates/rootplace/userban.jinja
msgid "Ban a user"
msgstr "Bannir un utilisateur"
#: rootplace/views.py
msgid "User that will be deleted"
msgstr "Utilisateur qui sera supprimé"
#: rootplace/templates/rootplace/userban.jinja
msgid "not specified"
msgstr "non spécifié"
#: rootplace/views.py
msgid "User to be selected"
msgstr "Utilisateur à sélectionner"
#: rootplace/templates/rootplace/userban.jinja
msgid "Remove ban"
msgstr "Retirer le ban"
#: rootplace/templates/rootplace/userban.jinja
msgid "No active ban."
msgstr "Pas de ban actif"
#: sas/forms.py
msgid "Add a new album"

View File

@ -127,6 +127,7 @@ nav:
- reference/pedagogy/schemas.md
- rootplace:
- reference/rootplace/models.md
- reference/rootplace/forms.md
- reference/rootplace/views.md
- sas:
- reference/sas/models.md

49
rootplace/forms.py Normal file
View File

@ -0,0 +1,49 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User, UserBan
from core.views.forms import FutureDateTimeField, SelectDateTime
from core.views.widgets.select import AutoCompleteSelectUser
class MergeForm(forms.Form):
user1 = forms.ModelChoiceField(
label=_("User that will be kept"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
user2 = forms.ModelChoiceField(
label=_("User that will be deleted"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class SelectUserForm(forms.Form):
user = forms.ModelChoiceField(
label=_("User to be selected"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class BanForm(forms.ModelForm):
"""Form to ban a user."""
required_css_class = "required"
class Meta:
model = UserBan
fields = ["user", "ban_group", "reason", "expires_at"]
field_classes = {"expires_at": FutureDateTimeField}
widgets = {
"user": AutoCompleteSelectUser,
"ban_group": forms.RadioSelect,
"expires_at": SelectDateTime,
}

View File

@ -0,0 +1,62 @@
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
{% block content %}
{% if user.has_perm("core:add_userban") %}
<a href="{{ url("rootplace:ban_create") }}" class="btn btn-red margin-bottom">
<i class="fa fa-person-circle-xmark"></i>
{% trans %}Ban a user{% endtrans %}
</a>
{% endif %}
{% for user_ban in user_bans %}
<div class="card card-row margin-bottom">
<img
class="card-image"
alt="profil de {{ user_ban.user.get_short_name() }}"
{%- if user_ban.user.profile_pict -%}
src="{{ user_ban.user.profile_pict.get_download_url() }}"
{%- else -%}
src="{{ static("core/img/unknown.jpg") }}"
{%- endif -%}
/>
<div class="card-content">
<strong>
<a href="{{ user_ban.user.get_absolute_url() }}">
{{ user_ban.user.get_full_name() }}
</a>
</strong>
<em>{{ user_ban.ban_group.name }}</em>
<p>{% trans %}Since{% endtrans %} : {{ user_ban.created_at|date }}</p>
<p>
{% trans %}Until{% endtrans %} :
{% if user_ban.expires_at %}
{{ user_ban.expires_at|date }} {{ user_ban.expires_at|time }}
{% else %}
{% trans %}not specified{% endtrans %}
{% endif %}
</p>
<details>
<summary class="clickable">{% trans %}Reason{% endtrans %}</summary>
<p>{{ user_ban.reason }}</p>
</details>
{% if user.has_perm("core:delete_userban") %}
<span>
<a
href="{{ url("rootplace:ban_remove", ban_id=user_ban.id) }}"
class="btn btn-blue"
>
{% trans %}Remove ban{% endtrans %}
</a>
</span>
{% endif %}
</div>
</div>
{% else %}
<p>{% trans %}No active ban.{% endtrans %}</p>
{% endfor %}
{% endblock %}

View File

View File

@ -0,0 +1,57 @@
from datetime import datetime, timedelta
import pytest
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localtime
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from core.models import BanGroup, User, UserBan
@pytest.fixture
def operator(db) -> User:
return baker.make(
User,
user_permissions=Permission.objects.filter(
codename__in=["view_userban", "add_userban", "delete_userban"]
),
)
@pytest.mark.django_db
@pytest.mark.parametrize(
"expires_at",
[None, localtime().replace(second=0, microsecond=0) + timedelta(days=7)],
)
def test_ban_user(client: Client, operator: User, expires_at: datetime):
client.force_login(operator)
user = baker.make(User)
ban_group = BanGroup.objects.first()
data = {
"user": user.id,
"ban_group": ban_group.id,
"reason": "Being naughty",
}
if expires_at is not None:
data["expires_at"] = expires_at.strftime("%Y-%m-%d %H:%M")
response = client.post(reverse("rootplace:ban_create"), data)
assertRedirects(response, expected_url=reverse("rootplace:ban_list"))
bans = list(user.bans.all())
assert len(bans) == 1
assert bans[0].expires_at == expires_at
assert bans[0].reason == "Being naughty"
assert bans[0].ban_group == ban_group
@pytest.mark.django_db
def test_remove_ban(client: Client, operator: User):
client.force_login(operator)
user = baker.make(User)
ban = baker.make(UserBan, user=user)
assert user.bans.exists()
response = client.post(reverse("rootplace:ban_remove", kwargs={"ban_id": ban.id}))
assertRedirects(response, expected_url=reverse("rootplace:ban_list"))
assert not user.bans.exists()

View File

@ -25,6 +25,9 @@
from django.urls import path
from rootplace.views import (
BanCreateView,
BanDeleteView,
BanView,
DeleteAllForumUserMessagesView,
MergeUsersView,
OperationLogListView,
@ -38,4 +41,7 @@ urlpatterns = [
name="delete_forum_messages",
),
path("logs/", OperationLogListView.as_view(), name="operation_logs"),
path("ban/", BanView.as_view(), name="ban_list"),
path("ban/new", BanCreateView.as_view(), name="ban_create"),
path("ban/<int:ban_id>/remove/", BanDeleteView.as_view(), name="ban_remove"),
]

View File

@ -23,20 +23,19 @@
#
import logging
from django import forms
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.timezone import localdate
from django.utils.translation import gettext as _
from django.views.generic import ListView
from django.views.generic.edit import FormView
from django.views.generic import DeleteView, ListView
from django.views.generic.edit import CreateView, FormView
from core.models import OperationLog, SithFile, User
from core.models import OperationLog, SithFile, User, UserBan
from core.views import CanEditPropMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Customer
from forum.models import ForumMessageMeta
from rootplace.forms import BanForm, MergeForm, SelectUserForm
def __merge_subscriptions(u1: User, u2: User):
@ -155,33 +154,6 @@ def delete_all_forum_user_messages(
ForumMessageMeta(message=message, user=moderator, action="DELETE").save()
class MergeForm(forms.Form):
user1 = forms.ModelChoiceField(
label=_("User that will be kept"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
user2 = forms.ModelChoiceField(
label=_("User that will be deleted"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class SelectUserForm(forms.Form):
user = forms.ModelChoiceField(
label=_("User to be selected"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
)
class MergeUsersView(FormView):
template_name = "rootplace/merge.jinja"
form_class = MergeForm
@ -233,3 +205,39 @@ class OperationLogListView(ListView, CanEditPropMixin):
template_name = "rootplace/logs.jinja"
ordering = ["-date"]
paginate_by = 100
class BanView(PermissionRequiredMixin, ListView):
"""[UserBan][core.models.UserBan] management view.
Displays :
- the list of active bans with their main information,
with a link to [BanDeleteView][rootplace.views.BanDeleteView] for each one
- a link which redirects to [BanCreateView][rootplace.views.BanCreateView]
"""
permission_required = "core.view_userban"
template_name = "rootplace/userban.jinja"
queryset = UserBan.objects.select_related("user", "user__profile_pict", "ban_group")
ordering = "created_at"
context_object_name = "user_bans"
class BanCreateView(PermissionRequiredMixin, CreateView):
"""[UserBan][core.models.UserBan] creation view."""
permission_required = "core.add_userban"
form_class = BanForm
template_name = "core/create.jinja"
success_url = reverse_lazy("rootplace:ban_list")
class BanDeleteView(PermissionRequiredMixin, DeleteView):
"""[UserBan][core.models.UserBan] deletion view."""
permission_required = "core.delete_userban"
pk_url_kwarg = "ban_id"
model = UserBan
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("rootplace:ban_list")

View File

@ -363,12 +363,13 @@ SITH_GROUP_OLD_SUBSCRIBERS_ID = 4
SITH_GROUP_ACCOUNTING_ADMIN_ID = 5
SITH_GROUP_COM_ADMIN_ID = 6
SITH_GROUP_COUNTER_ADMIN_ID = 7
SITH_GROUP_BANNED_ALCOHOL_ID = 8
SITH_GROUP_BANNED_COUNTER_ID = 9
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 10
SITH_GROUP_SAS_ADMIN_ID = 11
SITH_GROUP_FORUM_ADMIN_ID = 12
SITH_GROUP_PEDAGOGY_ADMIN_ID = 13
SITH_GROUP_SAS_ADMIN_ID = 8
SITH_GROUP_FORUM_ADMIN_ID = 9
SITH_GROUP_PEDAGOGY_ADMIN_ID = 10
SITH_GROUP_BANNED_ALCOHOL_ID = 11
SITH_GROUP_BANNED_COUNTER_ID = 12
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 13
SITH_CLUB_REFOUND_ID = 89
SITH_COUNTER_REFOUND_ID = 38