Skip to content

Commit

Permalink
Merge pull request #980 from ae-utbm/ban-groups
Browse files Browse the repository at this point in the history
Ban groups
  • Loading branch information
imperosol authored Jan 5, 2025
2 parents 3c4daea + e7215be commit 7f4cc5f
Show file tree
Hide file tree
Showing 22 changed files with 638 additions and 92 deletions.
22 changes: 21 additions & 1 deletion core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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)

Expand All @@ -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")
Expand All @@ -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",)
Expand Down
13 changes: 7 additions & 6 deletions core/management/commands/populate.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
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
Expand Down Expand Up @@ -94,6 +94,7 @@ def handle(self, *args, **options):
Sith.objects.create(weekmail_destinations="[email protected] [email protected]")
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,
Expand Down Expand Up @@ -951,11 +952,6 @@ def _create_groups(self) -> PopulatedGroups:
)
)
)
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(
Expand Down Expand Up @@ -995,3 +991,8 @@ def _create_groups(self) -> PopulatedGroups:
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="")
Original file line number Diff line number Diff line change
@@ -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
),
]
89 changes: 81 additions & 8 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
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
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -424,12 +451,12 @@ def is_launderette_manager(self):
)

@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:
Expand Down Expand Up @@ -731,6 +758,52 @@ def get_display_name(self):
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
Expand Down
2 changes: 1 addition & 1 deletion core/static/core/components/card.scss
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
align-items: center;
gap: 20px;

&:hover {
&.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
}

Expand Down
Loading

0 comments on commit 7f4cc5f

Please sign in to comment.