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")