From af47587116606b30ff407d57ae2ebdc4a417259b Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 31 Dec 2024 16:09:08 +0100 Subject: [PATCH 1/3] Split groups and ban groups --- core/admin.py | 22 ++- core/management/commands/populate.py | 13 +- ..._description_alter_user_groups_and_more.py | 164 ++++++++++++++++++ core/models.py | 89 +++++++++- core/tests/test_core.py | 4 - counter/tests/test_counter.py | 10 +- sith/settings.py | 13 +- 7 files changed, 285 insertions(+), 30 deletions(-) create mode 100644 core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py diff --git a/core/admin.py b/core/admin.py index 601ba636..5de89ada 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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",) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 3ed1025d..e3d6d8e4 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -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="") diff --git a/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py b/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py new file mode 100644 index 00000000..daba4097 --- /dev/null +++ b/core/migrations/0043_bangroup_alter_group_description_alter_user_groups_and_more.py @@ -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 + ), + ] diff --git a/core/models.py b/core/models.py index 945fd7d0..278182ac 100644 --- a/core/models.py +++ b/core/models.py @@ -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 diff --git a/core/tests/test_core.py b/core/tests/test_core.py index a152b579..878db4e4 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -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, diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index 1b378d5b..3fdc7099 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -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( diff --git a/sith/settings.py b/sith/settings.py index a88734d3..42e46603 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -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 From 4f35cc00bccc8f6cd808d086d6e720db91a64a48 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 3 Jan 2025 01:13:43 +0100 Subject: [PATCH 2/3] Add UserBan management views --- core/static/core/components/card.scss | 2 +- core/static/core/forms.scss | 8 -- core/templates/core/user_tools.jinja | 3 + core/views/forms.py | 27 ++++++- docs/reference/rootplace/forms.md | 7 ++ docs/reference/rootplace/views.md | 13 +++- mkdocs.yml | 1 + rootplace/forms.py | 49 ++++++++++++ rootplace/templates/rootplace/userban.jinja | 62 +++++++++++++++ rootplace/tests/__init__.py | 0 rootplace/tests/test_ban.py | 57 ++++++++++++++ .../{tests.py => tests/test_merge_users.py} | 0 rootplace/urls.py | 6 ++ rootplace/views.py | 76 ++++++++++--------- 14 files changed, 266 insertions(+), 45 deletions(-) create mode 100644 docs/reference/rootplace/forms.md create mode 100644 rootplace/forms.py create mode 100644 rootplace/templates/rootplace/userban.jinja create mode 100644 rootplace/tests/__init__.py create mode 100644 rootplace/tests/test_ban.py rename rootplace/{tests.py => tests/test_merge_users.py} (100%) diff --git a/core/static/core/components/card.scss b/core/static/core/components/card.scss index 1cbb2601..c8e59098 100644 --- a/core/static/core/components/card.scss +++ b/core/static/core/components/card.scss @@ -29,7 +29,7 @@ align-items: center; gap: 20px; - &:hover { + &.clickable:hover { background-color: darken($primary-neutral-light-color, 5%); } diff --git a/core/static/core/forms.scss b/core/static/core/forms.scss index dd44aa8a..1d0fa1bc 100644 --- a/core/static/core/forms.scss +++ b/core/static/core/forms.scss @@ -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 { diff --git a/core/templates/core/user_tools.jinja b/core/templates/core/user_tools.jinja index d9a6c0c7..10f3fe9d 100644 --- a/core/templates/core/user_tools.jinja +++ b/core/templates/core/user_tools.jinja @@ -23,6 +23,9 @@
  • {% trans %}Operation logs{% endtrans %}
  • {% trans %}Delete user's forum messages{% endtrans %}
  • {% endif %} + {% if user.has_perm("core:view_userban") %} +
  • {% trans %}Bans{% endtrans %}
  • + {% endif %} {% if user.can_create_subscription or user.is_root %}
  • {% trans %}Subscriptions{% endtrans %}
  • {% endif %} diff --git a/core/views/forms.py b/core/views/forms.py index 5dbf8f3e..a0cdfb6b 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -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 diff --git a/docs/reference/rootplace/forms.md b/docs/reference/rootplace/forms.md new file mode 100644 index 00000000..ca4fa328 --- /dev/null +++ b/docs/reference/rootplace/forms.md @@ -0,0 +1,7 @@ +::: rootplace.forms + handler: python + options: + members: + - MergeForm + - SelectUserForm + - BanForm \ No newline at end of file diff --git a/docs/reference/rootplace/views.md b/docs/reference/rootplace/views.md index 88a1b31b..87a26f6b 100644 --- a/docs/reference/rootplace/views.md +++ b/docs/reference/rootplace/views.md @@ -1 +1,12 @@ -::: rootplace.views \ No newline at end of file +::: rootplace.views + handler: python + options: + members: + - merge_users + - delete_all_forum_user_messages + - MergeUsersView + - DeleteAllForumUserMessagesView + - OperationLogListView + - BanView + - BanCreateView + - BanDeleteView \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 3777e2e4..70075794 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/rootplace/forms.py b/rootplace/forms.py new file mode 100644 index 00000000..5e7f8e94 --- /dev/null +++ b/rootplace/forms.py @@ -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, + } diff --git a/rootplace/templates/rootplace/userban.jinja b/rootplace/templates/rootplace/userban.jinja new file mode 100644 index 00000000..4510abf2 --- /dev/null +++ b/rootplace/templates/rootplace/userban.jinja @@ -0,0 +1,62 @@ +{% extends "core/base.jinja" %} + + +{% block additional_css %} + +{% endblock %} + + +{% block content %} + {% if user.has_perm("core:add_userban") %} + + + {% trans %}Ban a user{% endtrans %} + + {% endif %} + {% for user_ban in user_bans %} +
    + profil de {{ user_ban.user.get_short_name() }} +
    + + + {{ user_ban.user.get_full_name() }} + + + {{ user_ban.ban_group.name }} +

    {% trans %}Since{% endtrans %} : {{ user_ban.created_at|date }}

    +

    + {% trans %}Until{% endtrans %} : + {% if user_ban.expires_at %} + {{ user_ban.expires_at|date }} {{ user_ban.expires_at|time }} + {% else %} + {% trans %}not specified{% endtrans %} + {% endif %} +

    +
    + {% trans %}Reason{% endtrans %} +

    {{ user_ban.reason }}

    +
    + {% if user.has_perm("core:delete_userban") %} + + + {% trans %}Remove ban{% endtrans %} + + + {% endif %} +
    +
    + {% else %} +

    {% trans %}No active ban.{% endtrans %}

    + {% endfor %} +{% endblock %} \ No newline at end of file diff --git a/rootplace/tests/__init__.py b/rootplace/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rootplace/tests/test_ban.py b/rootplace/tests/test_ban.py new file mode 100644 index 00000000..4616630e --- /dev/null +++ b/rootplace/tests/test_ban.py @@ -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() diff --git a/rootplace/tests.py b/rootplace/tests/test_merge_users.py similarity index 100% rename from rootplace/tests.py rename to rootplace/tests/test_merge_users.py diff --git a/rootplace/urls.py b/rootplace/urls.py index 81568558..6ba94c28 100644 --- a/rootplace/urls.py +++ b/rootplace/urls.py @@ -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//remove/", BanDeleteView.as_view(), name="ban_remove"), ] diff --git a/rootplace/views.py b/rootplace/views.py index 818f0aa1..930f3b45 100644 --- a/rootplace/views.py +++ b/rootplace/views.py @@ -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") From e7215be00e7515fa9b09b47d053e323845a004f1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 5 Jan 2025 14:19:03 +0100 Subject: [PATCH 3/3] translations --- locale/fr/LC_MESSAGES/django.po | 104 ++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 8540b562..f83f033e 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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"