Merge pull request #987 from ae-utbm/taiste

Better group management, unified calendar and fixes
This commit is contained in:
thomas girod 2025-01-05 17:52:36 +01:00 committed by GitHub
commit 16de128fdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 4556 additions and 3233 deletions

View File

@ -24,7 +24,7 @@ runs:
- name: Install Poetry - name: Install Poetry
if: steps.cached-poetry.outputs.cache-hit != 'true' if: steps.cached-poetry.outputs.cache-hit != 'true'
shell: bash shell: bash
run: curl -sSL https://install.python-poetry.org | python3 - run: curl -sSL https://install.python-poetry.org | python3 - --version 1.8.5
- name: Check pyproject.toml syntax - name: Check pyproject.toml syntax
shell: bash shell: bash

View File

@ -20,6 +20,14 @@ from club.models import Club, Membership
@admin.register(Club) @admin.register(Club)
class ClubAdmin(admin.ModelAdmin): class ClubAdmin(admin.ModelAdmin):
list_display = ("name", "unix_name", "parent", "is_active") list_display = ("name", "unix_name", "parent", "is_active")
search_fields = ("name", "unix_name")
autocomplete_fields = (
"parent",
"board_group",
"members_group",
"home",
"page",
)
@admin.register(Membership) @admin.register(Membership)

View File

@ -0,0 +1,106 @@
# Generated by Django 4.2.16 on 2024-11-20 17:08
import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs.
Until now, the meta groups were not attached to the clubs,
nor to the users.
This creates actual foreign relationships between the clubs
and theirs groups and the users and theirs groups.
Warnings:
When the meta groups associated with the clubs aren't found,
they are created.
Thus the migration shouldn't fail, and all the clubs will
have their groups.
However, there will probably be some groups that have
not been found but exist nonetheless,
so there will be duplicates and dangling groups.
There must be a manual cleanup after this migration.
"""
Group = apps.get_model("core", "Group")
Club = apps.get_model("club", "Club")
meta_groups = Group.objects.filter(is_meta=True)
clubs = list(Club.objects.all())
for club in clubs:
club.board_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_BOARD_SUFFIX,
defaults={"is_meta": True},
)[0]
club.members_group = meta_groups.get_or_create(
name=club.unix_name + settings.SITH_MEMBER_SUFFIX,
defaults={"is_meta": True},
)[0]
club.save()
club.refresh_from_db()
memberships = club.members.filter(
Q(end_date=None) | Q(end_date__gt=localdate())
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
)
# steps of the migration :
# - Create a nullable field for the board group and the member group
# - Edit those new fields to make them point to currently existing meta groups
# - When this data migration is done, make the fields non-nullable
class Migration(migrations.Migration):
dependencies = [
("core", "0040_alter_user_options_user_user_permissions_and_more"),
("club", "0011_auto_20180426_2013"),
]
operations = [
migrations.RemoveField(
model_name="club",
name="edit_groups",
),
migrations.RemoveField(
model_name="club",
name="owner_group",
),
migrations.RemoveField(
model_name="club",
name="view_groups",
),
migrations.AddField(
model_name="club",
name="board_group",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AddField(
model_name="club",
name="members_group",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.RunPython(
migrate_meta_groups, reverse_code=migrations.RunPython.noop, elidable=True
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.17 on 2025-01-04 16:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0012_club_board_group_club_members_group")]
operations = [
migrations.AlterField(
model_name="club",
name="board_group",
field=models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AlterField(
model_name="club",
name="members_group",
field=models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.AddConstraint(
model_name="membership",
constraint=models.CheckConstraint(
check=models.Q(("end_date__gte", models.F("start_date"))),
name="end_after_start",
),
),
]

View File

@ -23,7 +23,7 @@
# #
from __future__ import annotations from __future__ import annotations
from typing import Self from typing import Iterable, Self
from django.conf import settings from django.conf import settings
from django.core import validators from django.core import validators
@ -31,14 +31,14 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import Group, MetaGroup, Notification, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
# Create your models here. # Create your models here.
@ -79,19 +79,6 @@ class Club(models.Model):
_("short description"), max_length=1000, default="", blank=True, null=True _("short description"), max_length=1000, default="", blank=True, null=True
) )
address = models.CharField(_("address"), max_length=254) address = models.CharField(_("address"), max_length=254)
owner_group = models.ForeignKey(
Group,
related_name="owned_club",
default=get_default_owner_group,
on_delete=models.CASCADE,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_club", blank=True
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_club", blank=True
)
home = models.OneToOneField( home = models.OneToOneField(
SithFile, SithFile,
related_name="home_of_club", related_name="home_of_club",
@ -103,6 +90,12 @@ class Club(models.Model):
page = models.OneToOneField( page = models.OneToOneField(
Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE Page, related_name="club", blank=True, null=True, on_delete=models.CASCADE
) )
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
)
board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT
)
class Meta: class Meta:
ordering = ["name", "unix_name"] ordering = ["name", "unix_name"]
@ -112,23 +105,27 @@ class Club(models.Model):
@transaction.atomic() @transaction.atomic()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
old = Club.objects.filter(id=self.id).first() creation = self._state.adding
creation = old is None if not creation:
if not creation and old.unix_name != self.unix_name: db_club = Club.objects.get(id=self.id)
self._change_unixname(self.unix_name) if self.unix_name != db_club.unix_name:
self.home.name = self.unix_name
self.home.save()
if self.name != db_club.name:
self.board_group.name = f"{self.name} - Bureau"
self.board_group.save()
self.members_group.name = f"{self.name} - Membres"
self.members_group.save()
if creation:
self.board_group = Group.objects.create(
name=f"{self.name} - Bureau", is_manually_manageable=False
)
self.members_group = Group.objects.create(
name=f"{self.name} - Membres", is_manually_manageable=False
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
if creation: if creation:
board = MetaGroup(name=self.unix_name + settings.SITH_BOARD_SUFFIX)
board.save()
member = MetaGroup(name=self.unix_name + settings.SITH_MEMBER_SUFFIX)
member.save()
subscribers = Group.objects.filter(
name=settings.SITH_MAIN_MEMBERS_GROUP
).first()
self.make_home() self.make_home()
self.home.edit_groups.set([board])
self.home.view_groups.set([member, subscribers])
self.home.save()
self.make_page() self.make_page()
cache.set(f"sith_club_{self.unix_name}", self) cache.set(f"sith_club_{self.unix_name}", self)
@ -136,7 +133,8 @@ class Club(models.Model):
return reverse("club:club_view", kwargs={"club_id": self.id}) return reverse("club:club_view", kwargs={"club_id": self.id})
@cached_property @cached_property
def president(self): def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
return self.members.filter( return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first() ).first()
@ -154,27 +152,9 @@ class Club(models.Model):
def clean(self): def clean(self):
self.check_loop() self.check_loop()
def _change_unixname(self, old_name, new_name): def make_home(self) -> None:
c = Club.objects.filter(unix_name=new_name).first()
if c is None:
# Update all the groups names
Group.objects.filter(name=old_name).update(name=new_name)
Group.objects.filter(name=old_name + settings.SITH_BOARD_SUFFIX).update(
name=new_name + settings.SITH_BOARD_SUFFIX
)
Group.objects.filter(name=old_name + settings.SITH_MEMBER_SUFFIX).update(
name=new_name + settings.SITH_MEMBER_SUFFIX
)
if self.home: if self.home:
self.home.name = new_name return
self.home.save()
else:
raise ValidationError(_("A club with that unix_name already exists"))
def make_home(self):
if not self.home:
home_root = SithFile.objects.filter(parent=None, name="clubs").first() home_root = SithFile.objects.filter(parent=None, name="clubs").first()
root = User.objects.filter(username="root").first() root = User.objects.filter(username="root").first()
if home_root and root: if home_root and root:
@ -183,7 +163,7 @@ class Club(models.Model):
self.home = home self.home = home
self.save() self.save()
def make_page(self): def make_page(self) -> None:
root = User.objects.filter(username="root").first() root = User.objects.filter(username="root").first()
if not self.page: if not self.page:
club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first() club_root = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
@ -213,35 +193,34 @@ class Club(models.Model):
self.page.parent = self.parent.page self.page.parent = self.parent.page
self.page.save(force_lock=True) self.page.save(force_lock=True)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships # Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"): for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}") cache.delete(f"membership_{self.id}_{membership.user.id}")
cache.delete(f"sith_club_{self.unix_name}") cache.delete(f"sith_club_{self.unix_name}")
super().delete(*args, **kwargs) self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
def get_display_name(self): def get_display_name(self) -> str:
return self.name return self.name
def is_owned_by(self, user): def is_owned_by(self, user: User) -> bool:
"""Method to see if that object can be super edited by the given user.""" """Method to see if that object can be super edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
return False return False
return user.is_board_member return user.is_root or user.is_board_member
def get_full_logo_url(self): def get_full_logo_url(self) -> str:
return "https://%s%s" % (settings.SITH_URL, self.logo.url) return f"https://{settings.SITH_URL}{self.logo.url}"
def can_be_edited_by(self, user): def can_be_edited_by(self, user: User) -> bool:
"""Method to see if that object can be edited by the given user.""" """Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user) return self.has_rights_in_club(user)
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User) -> bool:
"""Method to see if that object can be seen by the given user.""" """Method to see if that object can be seen by the given user."""
sub = User.objects.filter(pk=user.pk).first() return user.was_subscribed
if sub is None:
return False
return sub.was_subscribed
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user. """Return the current membership the given user.
@ -262,9 +241,8 @@ class Club(models.Model):
cache.set(f"membership_{self.id}_{user.id}", membership) cache.set(f"membership_{self.id}_{user.id}", membership)
return membership return membership
def has_rights_in_club(self, user): def has_rights_in_club(self, user: User) -> bool:
m = self.get_membership_for(user) return user.is_in_group(pk=self.board_group_id)
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
class MembershipQuerySet(models.QuerySet): class MembershipQuerySet(models.QuerySet):
@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
""" """
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def update(self, **kwargs): def update(self, **kwargs) -> int:
"""Refresh the cache for the elements of the queryset. """Refresh the cache and edit group ownership.
Besides that, does the same job as a regular update method. Update the cache, when necessary, remove
users from club groups they are no more in
and add them in the club groups they should be in.
Be aware that this adds a db query to retrieve the updated objects Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
""" """
nb_rows = super().update(**kwargs) nb_rows = super().update(**kwargs)
if nb_rows > 0: if nb_rows == 0:
# if at least a row was affected, refresh the cache # if no row was affected, no need to refresh the cache
for membership in self.all(): return 0
if membership.end_date is not None:
cache.set(
f"membership_{membership.club_id}_{membership.user_id}",
"not_member",
)
else:
cache.set(
f"membership_{membership.club_id}_{membership.user_id}",
membership,
)
def delete(self): cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
"""Work just like the default Django's delete() method, """Work just like the default Django's delete() method,
but add a cache invalidation for the elements of the queryset but add a cache invalidation for the elements of the queryset
before the deletion. before the deletion,
and a removal of the user from the club groups.
Be aware that this adds a db query to retrieve the deleted element. Be aware that this adds some db queries :
As this first query take place before the deletion operation,
it will be performed even if the deletion fails. - 1 to retrieve the deleted elements in order to perform
post-delete operations.
As we can't know if a delete will affect rows or not,
this query will always happen
- 1 query to remove the users from the club groups.
If the delete operation affected no row,
this query won't happen.
""" """
ids = list(self.values_list("club_id", "user_id")) memberships = set(self.all())
nb_rows, _ = super().delete() nb_rows, rows_counts = super().delete()
if nb_rows > 0: if nb_rows > 0:
for club_id, user_id in ids: Membership._remove_club_groups(memberships)
cache.set(f"membership_{club_id}_{user_id}", "not_member") cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
class Membership(models.Model): class Membership(models.Model):
@ -361,6 +362,13 @@ class Membership(models.Model):
objects = MembershipQuerySet.as_manager() objects = MembershipQuerySet.as_manager()
class Meta:
constraints = [
models.CheckConstraint(
check=Q(end_date__gte=F("start_date")), name="end_after_start"
),
]
def __str__(self): def __str__(self):
return ( return (
f"{self.club.name} - {self.user.username} " f"{self.club.name} - {self.user.username} "
@ -370,7 +378,14 @@ class Membership(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
# a save may either be an update or a creation
# and may result in either an ongoing or an ended membership.
# It could also be a retrogradation from the board to being a simple member.
# To avoid problems, the user is removed from the club groups beforehand ;
# he will be added back if necessary
self._remove_club_groups([self])
if self.end_date is None: if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self) cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else: else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member") cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
@ -378,11 +393,11 @@ class Membership(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id}) return reverse("club:club_members", kwargs={"club_id": self.club_id})
def is_owned_by(self, user): def is_owned_by(self, user: User) -> bool:
"""Method to see if that object can be super edited by the given user.""" """Method to see if that object can be super edited by the given user."""
if user.is_anonymous: if user.is_anonymous:
return False return False
return user.is_board_member return user.is_root or user.is_board_member
def can_be_edited_by(self, user: User) -> bool: def can_be_edited_by(self, user: User) -> bool:
"""Check if that object can be edited by the given user.""" """Check if that object can be edited by the given user."""
@ -392,9 +407,91 @@ class Membership(models.Model):
return membership is not None and membership.role >= self.role return membership is not None and membership.role >= self.role
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}") cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(
memberships: Iterable[Membership],
) -> tuple[int, dict[str, int]]:
"""Remove users of those memberships from the club groups.
For example, if a user is in the Troll club board,
he is in the board group and the members group of the Troll.
After calling this function, he will be in neither.
Returns:
The result of the deletion queryset.
Warnings:
If this function isn't used in combination
with an actual deletion of the memberships,
it will result in an inconsistent state,
where users will be in the clubs, without
having the associated rights.
"""
clubs = {m.club_id for m in memberships}
users = {m.user_id for m in memberships}
groups = Group.objects.filter(Q(club__in=clubs) | Q(club_board__in=clubs))
return User.groups.through.objects.filter(
Q(group__in=groups) & Q(user__in=users)
).delete()
@staticmethod
def _add_club_groups(
memberships: Iterable[Membership],
) -> list[User.groups.through]:
"""Add users of those memberships to the club groups.
For example, if a user just joined the Troll club board,
he will be added in both the members group and the board group
of the club.
Returns:
The created User-Group relations.
Warnings:
If this function isn't used in combination
with an actual update/creation of the memberships,
it will result in an inconsistent state,
where users will have the rights associated to the
club, without actually being part of it.
"""
# only active membership (i.e. `end_date=None`)
# grant the attribution of club groups.
memberships = [m for m in memberships if m.end_date is None]
if not memberships:
return []
if sum(1 for m in memberships if not hasattr(m, "club")) > 1:
# if more than one membership hasn't its `club` attribute set
# it's less expensive to reload the whole query with
# a select_related than perform a distinct query
# to fetch each club.
ids = {m.id for m in memberships}
memberships = list(
Membership.objects.filter(id__in=ids).select_related("club")
)
club_groups = []
for membership in memberships:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
group_id=membership.club.board_group_id,
)
)
return User.groups.through.objects.bulk_create(
club_groups, ignore_conflicts=True
)
class Mailing(models.Model): class Mailing(models.Model):
"""A Mailing list for a club. """A Mailing list for a club.

View File

@ -21,6 +21,7 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm from club.forms import MailingForm
from club.models import Club, Mailing, Membership from club.models import Club, Mailing, Membership
@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
assert new_mem != "not_member" assert new_mem != "not_member"
assert new_mem.role == 5 assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(end_date=localdate()) # end the membership
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self): def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache.""" """Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.skia.memberships.get(club=self.club) mem_skia = self.skia.memberships.get(club=self.club)
@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
) )
assert cached_mem == "not_member" assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
memberships[1].club.board_group,
}
assert set(user.groups.all()) == club_groups
user.memberships.all().delete()
assert user.groups.all().count() == 0
class TestClubModel(TestClub): class TestClubModel(TestClub):
def assert_membership_started_today(self, user: User, role: int): def assert_membership_started_today(self, user: User, role: int):
@ -192,10 +227,8 @@ class TestClubModel(TestClub):
assert membership.end_date is None assert membership.end_date is None
assert membership.role == role assert membership.role == role
assert membership.club.get_membership_for(user) == membership assert membership.club.get_membership_for(user) == membership
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX assert user.is_in_group(pk=self.club.members_group_id)
board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX assert user.is_in_group(pk=self.club.board_group_id)
assert user.is_in_group(name=member_group)
assert user.is_in_group(name=board_group)
def assert_membership_ended_today(self, user: User): def assert_membership_ended_today(self, user: User):
"""Assert that the given user have a membership which ended today.""" """Assert that the given user have a membership which ended today."""
@ -474,37 +507,35 @@ class TestClubModel(TestClub):
assert self.club.members.count() == nb_memberships assert self.club.members.count() == nb_memberships
assert membership == new_mem assert membership == new_mem
def test_delete_remove_from_meta_group(self): def test_remove_from_club_group(self):
"""Test that when a club is deleted, all its members are removed from the """Test that when a membership ends, the user is removed from club groups."""
associated metagroup. user = baker.make(User)
""" baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
memberships = self.club.members.select_related("user") assert user.groups.contains(self.club.members_group)
users = [membership.user for membership in memberships] assert user.groups.contains(self.club.board_group)
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX user.memberships.update(end_date=localdate())
assert not user.groups.contains(self.club.members_group)
assert not user.groups.contains(self.club.board_group)
self.club.delete() def test_add_to_club_group(self):
for user in users: """Test that when a membership begins, the user is added to the club group."""
assert not user.is_in_group(name=meta_group) assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_add_to_meta_group(self): def test_change_position_in_club(self):
"""Test that when a membership begins, the user is added to the meta group.""" """Test that when moving from board to members, club group change"""
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX membership = baker.make(
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX Membership, club=self.club, user=self.subscriber, role=3
assert not self.subscriber.is_in_group(name=group_members) )
assert not self.subscriber.is_in_group(name=board_members) assert self.subscriber.groups.contains(self.club.members_group)
Membership.objects.create(club=self.club, user=self.subscriber, role=3) assert self.subscriber.groups.contains(self.club.board_group)
assert self.subscriber.is_in_group(name=group_members) membership.role = 1
assert self.subscriber.is_in_group(name=board_members) membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
def test_remove_from_meta_group(self): assert not self.subscriber.groups.contains(self.club.board_group)
"""Test that when a membership ends, the user is removed from meta group."""
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
assert self.comptable.is_in_group(name=group_members)
assert self.comptable.is_in_group(name=board_members)
self.comptable.memberships.update(end_date=localtime(now()))
assert not self.comptable.is_in_group(name=group_members)
assert not self.comptable.is_in_group(name=board_members)
def test_club_owner(self): def test_club_owner(self):
"""Test that a club is owned only by board members of the main club.""" """Test that a club is owned only by board members of the main club."""
@ -517,6 +548,26 @@ class TestClubModel(TestClub):
Membership(club=self.ae, user=self.sli, role=3).save() Membership(club=self.ae, user=self.sli, role=3).save()
assert self.club.is_owned_by(self.sli) assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
"""Test that changing the club name doesn't break things."""
members_group = self.club.members_group
board_group = self.club.board_group
initial_members = set(members_group.users.values_list("id", flat=True))
initial_board = set(board_group.users.values_list("id", flat=True))
self.club.name = "something else"
self.club.save()
self.club.refresh_from_db()
# The names should have changed, but not the ids nor the group members
assert self.club.members_group.name == "something else - Membres"
assert self.club.board_group.name == "something else - Bureau"
assert self.club.members_group.id == members_group.id
assert self.club.board_group.id == board_group.id
new_members = set(self.club.members_group.users.values_list("id", flat=True))
new_board = set(self.club.board_group.users.values_list("id", flat=True))
assert new_members == initial_members
assert new_board == initial_board
class TestMailingForm(TestCase): class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm.""" """Perform validation tests for MailingForm."""

View File

@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin):
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
tab_list = [] tab_list = [
tab_list.append(
{ {
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}), "url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
"slug": "infos", "slug": "infos",
"name": _("Infos"), "name": _("Infos"),
} }
) ]
if self.request.user.can_view(self.object): if self.request.user.can_view(self.object):
tab_list.append( tab_list.append(
{ {

32
com/api.py Normal file
View File

@ -0,0 +1,32 @@
from pathlib import Path
from django.conf import settings
from django.http import Http404
from ninja_extra import ControllerBase, api_controller, route
from com.calendar import IcsCalendar
from core.views.files import send_raw_file
@api_controller("/calendar")
class CalendarController(ControllerBase):
CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
@route.get("/external.ics", url_name="calendar_external")
def calendar_external(self):
"""Return the ICS file of the AE Google Calendar
Because of Google's cors rules, we can't just do a request to google ics
from the frontend. Google is blocking CORS request in it's responses headers.
The only way to do it from the frontend is to use Google Calendar API with an API key
This is not especially desirable as your API key is going to be provided to the frontend.
This is why we have this backend based solution.
"""
if (calendar := IcsCalendar.get_external()) is not None:
return send_raw_file(calendar)
raise Http404
@route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self):
return send_raw_file(IcsCalendar.get_internal())

9
com/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class ComConfig(AppConfig):
name = "com"
verbose_name = "News and communication"
def ready(self):
import com.signals # noqa F401

76
com/calendar.py Normal file
View File

@ -0,0 +1,76 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import final
import urllib3
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from com.models import NewsDate
@final
class IcsCalendar:
_CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars"
_EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics"
_INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics"
@classmethod
def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None:
if (
cls._EXTERNAL_CALENDAR.exists()
and timezone.make_aware(
datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime)
)
+ expiration
> timezone.now()
):
return cls._EXTERNAL_CALENDAR
return cls.make_external()
@classmethod
def make_external(cls) -> Path | None:
calendar = urllib3.request(
"GET",
"https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics",
)
if calendar.status != 200:
return None
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._EXTERNAL_CALENDAR, "wb") as f:
_ = f.write(calendar.data)
return cls._EXTERNAL_CALENDAR
@classmethod
def get_internal(cls) -> Path:
if not cls._INTERNAL_CALENDAR.exists():
return cls.make_internal()
return cls._INTERNAL_CALENDAR
@classmethod
def make_internal(cls) -> Path:
# Updated through a post_save signal on News in com.signals
calendar = Calendar()
for news_date in NewsDate.objects.filter(
news__is_moderated=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
).prefetch_related("news"):
event = Event(
summary=news_date.news.title,
start=news_date.start_date,
end=news_date.end_date,
url=reverse("com:news_detail", kwargs={"news_id": news_date.news.id}),
)
calendar.events.append(event)
# Create a file so we can offload the download to the reverse proxy if available
cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True)
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8"))
return cls._INTERNAL_CALENDAR

View File

@ -17,11 +17,12 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple # this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives

10
com/signals.py Normal file
View File

@ -0,0 +1,10 @@
from django.db.models.base import post_save
from django.dispatch import receiver
from com.calendar import IcsCalendar
from com.models import News
@receiver(post_save, sender=News, dispatch_uid="update_internal_ics")
def update_internal_ics(*args, **kwargs):
_ = IcsCalendar.make_internal()

View File

@ -0,0 +1,194 @@
import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { calendarCalendarExternal, calendarCalendarInternal } from "#openapi";
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale"];
private calendar: Calendar;
private locale = "en";
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name !== "locale") {
return;
}
this.locale = newValue;
}
isMobile() {
return window.innerWidth < 765;
}
currentView() {
// Get view type based on viewport
return this.isMobile() ? "listMonth" : "dayGridMonth";
}
currentToolbar() {
if (this.isMobile()) {
return {
left: "prev,next",
center: "title",
right: "",
};
}
return {
left: "prev,next today",
center: "title",
right: "dayGridMonth,dayGridWeek,dayGridDay",
};
}
formatDate(date: Date) {
return new Intl.DateTimeFormat(this.locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
createEventDetailPopup(event: EventClickArg) {
// Delete previous popup
const oldPopup = document.getElementById("event-details");
if (oldPopup !== null) {
oldPopup.remove();
}
const makePopupInfo = (info: HTMLElement, iconClass: string) => {
const row = document.createElement("div");
const icon = document.createElement("i");
row.setAttribute("class", "event-details-row");
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
row.appendChild(icon);
row.appendChild(info);
return row;
};
const makePopupTitle = (event: EventImpl) => {
const row = document.createElement("div");
row.innerHTML = `
<h4 class="event-details-row-content">
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span>
`;
return makePopupInfo(
row,
"fa-solid fa-calendar-days fa-xl event-detail-row-icon",
);
};
const makePopupLocation = (event: EventImpl) => {
if (event.extendedProps.location === null) {
return null;
}
const info = document.createElement("div");
info.innerText = event.extendedProps.location;
return makePopupInfo(info, "fa-solid fa-location-dot");
};
const makePopupUrl = (event: EventImpl) => {
if (event.url === "") {
return null;
}
const url = document.createElement("a");
url.href = event.url;
url.textContent = gettext("More info");
return makePopupInfo(url, "fa-solid fa-link");
};
// Create new popup
const popup = document.createElement("div");
const popupContainer = document.createElement("div");
popup.setAttribute("id", "event-details");
popupContainer.setAttribute("class", "event-details-container");
popupContainer.appendChild(makePopupTitle(event.event));
const location = makePopupLocation(event.event);
if (location !== null) {
popupContainer.appendChild(location);
}
const url = makePopupUrl(event.event);
if (url !== null) {
popupContainer.appendChild(url);
}
popup.appendChild(popupContainer);
// We can't just add the element relative to the one we want to appear under
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells
// Here, we create a popup outside the calendar that follows the clicked element
this.node.appendChild(popup);
const follow = (node: HTMLElement) => {
const rect = node.getBoundingClientRect();
popup.setAttribute(
"style",
`top: calc(${rect.top + window.scrollY}px + ${rect.height}px); left: ${rect.left + window.scrollX}px;`,
);
};
follow(event.el);
window.addEventListener("resize", () => {
follow(event.el);
});
}
async connectedCallback() {
super.connectedCallback();
this.calendar = new Calendar(this.node, {
plugins: [dayGridPlugin, iCalendarPlugin, listPlugin],
locales: [frLocale, enLocale],
height: "auto",
locale: this.locale,
initialView: this.currentView(),
headerToolbar: this.currentToolbar(),
eventSources: [
{
url: await makeUrl(calendarCalendarInternal),
format: "ics",
},
{
url: await makeUrl(calendarCalendarExternal),
format: "ics",
},
],
windowResize: () => {
this.calendar.changeView(this.currentView());
this.calendar.setOption("headerToolbar", this.currentToolbar());
},
eventClick: (event) => {
// Avoid our popup to be deleted because we clicked outside of it
event.jsEvent.stopPropagation();
// Don't auto-follow events URLs
event.jsEvent.preventDefault();
this.createEventDetailPopup(event);
},
});
this.calendar.render();
window.addEventListener("click", (event: MouseEvent) => {
// Auto close popups when clicking outside of it
const popup = document.getElementById("event-details");
if (popup !== null && !popup.contains(event.target as Node)) {
popup.remove();
}
});
}
}

View File

@ -0,0 +1,101 @@
@import "core/static/core/colors";
:root {
--fc-button-border-color: #fff;
--fc-button-hover-border-color: #fff;
--fc-button-active-border-color: #fff;
--fc-button-text-color: #fff;
--fc-button-bg-color: #1a78b3;
--fc-button-active-bg-color: #15608F;
--fc-button-hover-bg-color: #15608F;
--fc-today-bg-color: rgba(26, 120, 179, 0.1);
--fc-border-color: #DDDDDD;
--event-details-background-color: white;
--event-details-padding: 20px;
--event-details-border: 1px solid #EEEEEE;
--event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
}
ics-calendar {
border: none;
box-shadow: none;
#event-details {
z-index: 10;
max-width: 1151px;
position: absolute;
.event-details-container {
display: flex;
color: black;
flex-direction: column;
min-width: 200px;
max-width: var(--event-details-max-width);
padding: var(--event-details-padding);
border: var(--event-details-border);
border-radius: var(--event-details-border-radius);
background-color: var(--event-details-background-color);
box-shadow: var(--event-details-box-shadow);
gap: 20px;
}
.event-detail-row-icon {
margin-left: 10px;
margin-right: 20px;
align-content: center;
align-self: center;
}
.event-details-row {
display: flex;
align-items: start;
}
.event-details-row-content {
display: flex;
align-items: start;
flex-direction: row;
background-color: var(--event-details-background-color);
margin-top: 0px;
margin-bottom: 4px;
}
}
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
td {
overflow-x: visible; // Show events on multiple days
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0px;
-moz-border-radius: 0px;
margin: 0px;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody>tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
}

View File

@ -0,0 +1,61 @@
@import "core/static/core/colors";
#news_details {
display: inline-block;
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
display: inline-block;
text-align: center;
width: 19%;
float: left;
min-width: 15em;
margin: 0;
img {
max-height: 15em;
max-width: 12em;
display: block;
margin: 0 auto;
margin-bottom: 10px;
}
}
.share_button {
border: none;
color: white;
padding: 0.5em 1em;
text-align: center;
text-decoration: none;
font-size: 1.2em;
border-radius: 2px;
float: right;
display: block;
margin-left: 0.3em;
&:hover {
color: lightgrey;
}
}
.facebook {
background: $faceblue;
}
.twitter {
background: $twitblue;
}
.news_meta {
margin-top: 10em;
font-size: small;
}
}

View File

@ -0,0 +1,297 @@
@import "core/static/core/colors";
@import "core/static/core/devices";
#news {
display: flex;
@media (max-width: 800px) {
flex-direction: column;
}
#news_admin {
margin-bottom: 1em;
}
#right_column {
flex: 20%;
margin: 3.2px;
display: inline-block;
vertical-align: top;
}
#left_column {
flex: 79%;
margin: 0.2em;
}
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
}
}
/* LINKS/BIRTHDAYS */
#links,
#birthdays {
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
h3 {
margin-bottom: 0;
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
height: 20em;
h4 {
margin-left: 5px;
}
ul {
list-style: none;
margin-left: 0;
li {
margin: 10px;
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
i {
width: 25px;
text-align: center;
}
}
}
}
#birthdays_content {
ul.birthdays_year {
margin: 0;
list-style-type: none;
font-weight: bold;
>li {
padding: 0.5em;
&:nth-child(even) {
background: $secondary-neutral-light-color;
}
}
ul {
margin: 0;
margin-left: 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
}
}
}
}
/* END AGENDA/BIRTHDAYS */
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-bottom: 0.5em;
.news_events_group_date {
display: table-cell;
padding: 0.6em;
vertical-align: middle;
background: $primary-neutral-dark-color;
color: $white-color;
text-transform: uppercase;
text-align: center;
font-weight: bold;
font-family: monospace;
font-size: 1.4em;
border-radius: 7px 0 0 7px;
div {
margin: 0 auto;
.day {
font-size: 1.5em;
}
}
}
.news_events_group_items {
display: table-cell;
width: 100%;
.news_event:nth-of-type(odd) {
background: white;
}
.news_event:nth-of-type(even) {
background: $primary-neutral-light-color;
}
.news_event {
display: block;
padding: 0.4em;
&:not(:last-child) {
border-bottom: 1px solid grey;
}
div {
margin: 0.2em;
}
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
float: left;
min-width: 7em;
max-width: 9em;
margin: 0;
margin-right: 1em;
margin-top: 0.8em;
img {
max-height: 6em;
max-width: 8em;
display: block;
margin: 0 auto;
}
}
.news_date {
font-size: 100%;
}
.news_content {
clear: left;
.button_bar {
text-align: right;
.fb {
color: $faceblue;
}
.twitter {
color: $twitblue;
}
}
}
}
}
}
/* END EVENTS TODAY AND NEXT FEW DAYS */
/* COMING SOON */
.news_coming_soon {
display: list-item;
list-style-type: square;
list-style-position: inside;
margin-left: 1em;
padding-left: 0;
a {
font-weight: bold;
text-transform: uppercase;
}
.news_date {
font-size: 0.9em;
}
}
/* END COMING SOON */
/* NOTICES */
.news_notice {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px;
h4 {
margin: 0;
}
.news_content {
margin-left: 1em;
}
}
/* END NOTICES */
/* CALLS */
.news_call {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
border: 1px solid grey;
box-shadow: $shadow-color 1px 1px 1px;
h4 {
margin: 0;
}
.news_date {
font-size: 0.9em;
}
.news_content {
margin-left: 1em;
}
}
/* END CALLS */
.news_empty {
margin-left: 1em;
}
.news_date {
color: grey;
}
}

View File

@ -0,0 +1,230 @@
#poster_list,
#screen_list,
#poster_edit,
#screen_edit {
position: relative;
#title {
position: relative;
padding: 10px;
margin: 10px;
border-bottom: 2px solid black;
h3 {
display: flex;
justify-content: center;
align-items: center;
}
#links {
position: absolute;
display: flex;
bottom: 5px;
&.left {
left: 0;
}
&.right {
right: 0;
}
.link {
padding: 5px;
padding-left: 20px;
padding-right: 20px;
margin-left: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&.delete {
background-color: hsl(0, 100%, 40%);
}
}
}
}
#posters,
#screens {
position: relative;
display: flex;
flex-wrap: wrap;
#no-posters,
#no-screens {
display: flex;
justify-content: center;
align-items: center;
}
.poster,
.screen {
min-width: 10%;
max-width: 20%;
display: flex;
flex-direction: column;
margin: 10px;
border: 2px solid darkgrey;
border-radius: 4px;
padding: 10px;
background-color: lightgrey;
* {
display: flex;
justify-content: center;
align-items: center;
}
.name {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
}
.image {
flex-grow: 1;
position: relative;
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
img {
max-height: 20vw;
max-width: 100%;
}
&:hover {
&::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
top: 0;
left: 0;
z-index: 10;
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
}
}
}
.dates {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
* {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-left: 5px;
margin-right: 5px;
}
.begin,
.end {
width: 48%;
}
.begin {
border-right: 1px solid whitesmoke;
padding-right: 2%;
}
}
.edit,
.moderate,
.slideshow {
padding: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&:nth-child(2n) {
margin-top: 5px;
margin-bottom: 5px;
}
}
.tooltip {
visibility: hidden;
width: 120px;
background-color: hsl(210, 20%, 98%);
color: hsl(0, 0%, 0%);
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 10;
ul {
margin-left: 0;
display: inline-block;
li {
display: list-item;
list-style-type: none;
}
}
}
&.not_moderated {
border: 1px solid red;
}
&:hover .tooltip {
visibility: visible;
}
}
}
#view {
position: fixed;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
z-index: 10;
visibility: hidden;
background-color: rgba(10, 10, 10, 0.9);
overflow: hidden;
&.active {
visibility: visible;
}
#placeholder {
width: 80vw;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
img {
max-width: 100%;
max-height: 100%;
}
}
}
}

View File

@ -11,6 +11,11 @@
{{ gen_news_metatags(news) }} {{ gen_news_metatags(news) }}
{% endblock %} {% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
{% endblock %}
{% block content %} {% block content %}
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p> <p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
<section id="news_details"> <section id="news_details">

View File

@ -5,6 +5,15 @@
{% trans %}News{% endtrans %} {% trans %}News{% endtrans %}
{% endblock %} {% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
{% endblock %}
{% block additional_js %}
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
{% endblock %}
{% block content %} {% block content %}
{% if user.is_com_admin %} {% if user.is_com_admin %}
<div id="news_admin"> <div id="news_admin">
@ -83,60 +92,58 @@
</div> </div>
{% endif %} {% endif %}
{% set coming_soon = object_list.filter(dates__start_date__gte=timezone.now()+timedelta(days=5),
type="EVENT").order_by('dates__start_date') %}
{% if coming_soon %}
<h3>{% trans %}Coming soon... don't miss!{% endtrans %}</h3>
{% for news in coming_soon %}
<section class="news_coming_soon">
<a href="{{ url('com:news_detail', news_id=news.id) }}">{{ news.title }}</a>
<span class="news_date">{{ news.dates.first().start_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().start_date|localtime|time(DATETIME_FORMAT) }} -
{{ news.dates.first().end_date|localtime|date(DATETIME_FORMAT) }}
{{ news.dates.first().end_date|localtime|time(DATETIME_FORMAT) }}</span>
</section>
{% endfor %}
{% endif %}
<h3>{% trans %}All coming events{% endtrans %}</h3> <h3>{% trans %}All coming events{% endtrans %}</h3>
<iframe <ics-calendar locale="{{ get_language() }}"></ics-calendar>
src="https://embed.styledcalendar.com/#2mF2is8CEXhr4ADcX6qN"
title="Styled Calendar"
class="styled-calendar-container"
style="width: 100%; border: none; height: 1060px"
data-cy="calendar-embed-iframe">
</iframe>
</div> </div>
<div id="right_column" class="news_column"> <div id="right_column">
<div id="agenda"> <div id="links">
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div> <h3>{% trans %}Links{% endtrans %}</h3>
<div id="agenda_content"> <div id="links_content">
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(), <h4>{% trans %}Our services{% endtrans %}</h4>
news__is_moderated=True, news__type__in=["WEEKLY", <ul>
"EVENT"]).order_by('start_date', 'end_date') %} <li>
<div class="agenda_item"> <i class="fa-solid fa-graduation-cap fa-xl"></i>
<div class="agenda_date"> <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong> </li>
</div> <li>
<div class="agenda_time"> <i class="fa-solid fa-magnifying-glass fa-xl"></i>
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> - <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span> </li>
</div> <li>
<div> <i class="fa-solid fa-check-to-slot fa-xl"></i>
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong> <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a> </li>
</div> </ul>
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div> <br>
</div> <h4>{% trans %}Social media{% endtrans %}</h4>
{% endfor %} <ul>
<li>
<i class="fa-brands fa-discord fa-xl"></i>
<a rel="nofollow" target="#" href="https://discord.gg/QvTm3XJrHR">{% trans %}Discord AE{% endtrans %}</a>
{% if user.was_subscribed %}
- <a rel="nofollow" target="#" href="https://discord.gg/XK9WfPsUFm">{% trans %}Dev Team{% endtrans %}</a>
{% endif %}
</li>
<li>
<i class="fa-brands fa-facebook fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.facebook.com/@AEUTBM/">{% trans %}Facebook{% endtrans %}</a>
</li>
<li>
<i class="fa-brands fa-square-instagram fa-xl"></i>
<a rel="nofollow" target="#" href="https://www.instagram.com/ae_utbm">{% trans %}Instagram{% endtrans %}</a>
</li>
</ul>
</div> </div>
</div> </div>
<div id="birthdays"> <div id="birthdays">
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div> <h3>{% trans %}Birthdays{% endtrans %}</h3>
<div id="birthdays_content"> <div id="birthdays_content">
{%- if user.is_subscribed -%} {%- if user.was_subscribed -%}
<ul class="birthdays_year"> <ul class="birthdays_year">
{%- for year, users in birthdays -%} {%- for year, users in birthdays -%}
<li> <li>
@ -150,14 +157,13 @@ type="EVENT").order_by('dates__start_date') %}
{%- endfor -%} {%- endfor -%}
</ul> </ul>
{%- else -%} {%- else -%}
<p>{% trans %}You need an up to date subscription to access this content{% endtrans %}</p> <p>{% trans %}You need to subscribe to access this content{% endtrans %}</p>
{%- endif -%} {%- endif -%}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -10,6 +10,10 @@
{% trans %}Poster{% endtrans %} {% trans %}Poster{% endtrans %}
{% endblock %} {% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %} {% block content %}
<div id="poster_list"> <div id="poster_list">

View File

@ -5,6 +5,10 @@
<script src="{{ static('com/js/poster_list.js') }}"></script> <script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %} {% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %} {% block content %}
<div id="poster_list"> <div id="poster_list">

View File

@ -3,7 +3,7 @@
<head> <head>
<title>{% trans %}Slideshow{% endtrans %}</title> <title>{% trans %}Slideshow{% endtrans %}</title>
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" /> <link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/jquery-index.js') }}"></script> <script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
<script src="{{ static('com/js/slideshow.js') }}"></script> <script src="{{ static('com/js/slideshow.js') }}"></script>
</head> </head>
<body> <body>

122
com/tests/test_api.py Normal file
View File

@ -0,0 +1,122 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Callable
from unittest.mock import MagicMock, patch
import pytest
from django.conf import settings
from django.http import HttpResponse
from django.test.client import Client
from django.urls import reverse
from django.utils import timezone
from com.calendar import IcsCalendar
@dataclass
class MockResponse:
status: int
value: str
@property
def data(self):
return self.value.encode("utf8")
def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):
return None
return settings.MEDIA_ROOT / redirect.relative_to(
Path("/") / settings.MEDIA_ROOT.stem
)
@pytest.mark.django_db
class TestExternalCalendar:
@pytest.fixture
def mock_request(self):
mock = MagicMock()
with patch("urllib3.request", mock):
yield mock
@pytest.fixture
def mock_current_time(self):
mock = MagicMock()
original = timezone.now
with patch("django.utils.timezone.now", mock):
yield mock, original
@pytest.fixture(autouse=True)
def clear_cache(self):
IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True)
@pytest.mark.parametrize("error_code", [403, 404, 500])
def test_fetch_error(
self, client: Client, mock_request: MagicMock, error_code: int
):
mock_request.return_value = MockResponse(error_code, "not allowed")
assert client.get(reverse("api:calendar_external")).status_code == 404
def test_fetch_success(self, client: Client, mock_request: MagicMock):
external_response = MockResponse(200, "Definitely an ICS")
mock_request.return_value = external_response
response = client.get(reverse("api:calendar_external"))
assert response.status_code == 200
out_file = accel_redirect_to_file(response)
assert out_file is not None
assert out_file.exists()
with open(out_file, "r") as f:
assert f.read() == external_response.value
def test_fetch_caching(
self,
client: Client,
mock_request: MagicMock,
mock_current_time: tuple[MagicMock, Callable[[], datetime]],
):
fake_current_time, original_timezone = mock_current_time
start_time = original_timezone()
fake_current_time.return_value = start_time
external_response = MockResponse(200, "Definitely an ICS")
mock_request.return_value = external_response
with open(
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
) as f:
assert f.read() == external_response.value
mock_request.return_value = MockResponse(200, "This should be ignored")
with open(
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
) as f:
assert f.read() == external_response.value
mock_request.assert_called_once()
fake_current_time.return_value = start_time + timedelta(hours=1, seconds=1)
external_response = MockResponse(200, "This won't be ignored")
mock_request.return_value = external_response
with open(
accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r"
) as f:
assert f.read() == external_response.value
assert mock_request.call_count == 2
@pytest.mark.django_db
class TestInternalCalendar:
@pytest.fixture(autouse=True)
def clear_cache(self):
IcsCalendar._INTERNAL_CALENDAR.unlink(missing_ok=True)
def test_fetch_success(self, client: Client):
response = client.get(reverse("api:calendar_internal"))
assert response.status_code == 200
out_file = accel_redirect_to_file(response)
assert out_file is not None
assert out_file.exists()

View File

@ -97,9 +97,7 @@ class TestCom(TestCase):
response = self.client.get(reverse("core:index")) response = self.client.get(reverse("core:index"))
self.assertContains( self.assertContains(
response, response,
text=html.escape( text=html.escape(_("You need to subscribe to access this content")),
_("You need an up to date subscription to access this content")
),
) )
def test_birthday_subscibed_user(self): def test_birthday_subscibed_user(self):
@ -107,9 +105,16 @@ class TestCom(TestCase):
self.assertNotContains( self.assertNotContains(
response, response,
text=html.escape( text=html.escape(_("You need to subscribe to access this content")),
_("You need an up to date subscription to access this content") )
),
def test_birthday_old_subscibed_user(self):
self.client.force_login(User.objects.get(username="old_subscriber"))
response = self.client.get(reverse("core:index"))
self.assertNotContains(
response,
text=html.escape(_("You need to subscribe to access this content")),
) )

View File

@ -685,8 +685,12 @@ class PosterEditBaseView(UpdateView):
def get_initial(self): def get_initial(self):
return { return {
"date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S"), "date_begin": self.object.date_begin.strftime("%Y-%m-%d %H:%M:%S")
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S"), if self.object.date_begin
else None,
"date_end": self.object.date_end.strftime("%Y-%m-%d %H:%M:%S")
if self.object.date_end
else None,
} }
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):

View File

@ -15,17 +15,32 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import Group as AuthGroup 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) admin.site.unregister(AuthGroup)
@admin.register(Group) @admin.register(Group)
class GroupAdmin(admin.ModelAdmin): class GroupAdmin(admin.ModelAdmin):
list_display = ("name", "description", "is_meta") list_display = ("name", "description", "is_manually_manageable")
list_filter = ("is_meta",) list_filter = ("is_manually_manageable",)
search_fields = ("name",) search_fields = ("name",)
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) @admin.register(User)
@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
"profile_pict", "profile_pict",
"avatar_pict", "avatar_pict",
"scrub_pict", "scrub_pict",
"user_permissions",
"groups",
) )
inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"] 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",)
@admin.register(Page) @admin.register(Page)
class PageAdmin(admin.ModelAdmin): class PageAdmin(admin.ModelAdmin):
list_display = ("name", "_full_name", "owner_group") list_display = ("name", "_full_name", "owner_group")

View File

@ -7,7 +7,7 @@ from model_bakery import seq
from model_bakery.recipe import Recipe, related from model_bakery.recipe import Recipe, related
from club.models import Membership from club.models import Membership
from core.models import User from core.models import Group, User
from subscription.models import Subscription from subscription.models import Subscription
active_subscription = Recipe( active_subscription = Recipe(
@ -60,5 +60,6 @@ board_user = Recipe(
first_name="AE", first_name="AE",
last_name=seq("member "), last_name=seq("member "),
memberships=related(ae_board_membership), memberships=related(ae_board_membership),
groups=lambda: [Group.objects.get(club_board=settings.SITH_MAIN_CLUB_ID)],
) )
"""A user which is in the board of the AE.""" """A user which is in the board of the AE."""

View File

@ -46,8 +46,9 @@ from accounting.models import (
SimplifiedAccountingType, SimplifiedAccountingType,
) )
from club.models import Club, Membership from club.models import Club, Membership
from com.calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail from com.models import News, NewsDate, Sith, Weekmail
from core.models import Group, Page, PageRev, RealGroup, SithFile, User from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from counter.models import Counter, Product, ProductType, StudentCard from counter.models import Counter, Product, ProductType, StudentCard
from election.models import Candidature, Election, ElectionList, Role from election.models import Candidature, Election, ElectionList, Role
@ -93,6 +94,7 @@ class Command(BaseCommand):
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an") Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME) Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
groups = self._create_groups() groups = self._create_groups()
self._create_ban_groups()
root = User.objects.create_superuser( root = User.objects.create_superuser(
id=0, id=0,
@ -143,7 +145,9 @@ class Command(BaseCommand):
Counter.objects.bulk_create(counters) Counter.objects.bulk_create(counters)
bar_groups = [] bar_groups = []
for bar_id, bar_name in settings.SITH_COUNTER_BARS: for bar_id, bar_name in settings.SITH_COUNTER_BARS:
group = RealGroup.objects.create(name=f"{bar_name} admin") group = Group.objects.create(
name=f"{bar_name} admin", is_manually_manageable=True
)
bar_groups.append( bar_groups.append(
Counter.edit_groups.through(counter_id=bar_id, group=group) Counter.edit_groups.through(counter_id=bar_id, group=group)
) )
@ -366,46 +370,42 @@ Welcome to the wiki page!
parent=main_club, parent=main_club,
) )
Membership.objects.bulk_create( Membership.objects.create(user=skia, club=main_club, role=3)
[ Membership.objects.create(
Membership(user=skia, club=main_club, role=3),
Membership(
user=comunity, user=comunity,
club=bar_club, club=bar_club,
start_date=localdate(), start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"], role=settings.SITH_CLUB_ROLES_ID["Board member"],
), )
Membership( Membership.objects.create(
user=sli, user=sli,
club=troll, club=troll,
role=9, role=9,
description="Padawan Troll", description="Padawan Troll",
start_date=localdate() - timedelta(days=17), start_date=localdate() - timedelta(days=17),
), )
Membership( Membership.objects.create(
user=krophil, user=krophil,
club=troll, club=troll,
role=10, role=10,
description="Maitre Troll", description="Maitre Troll",
start_date=localdate() - timedelta(days=200), start_date=localdate() - timedelta(days=200),
), )
Membership( Membership.objects.create(
user=skia, user=skia,
club=troll, club=troll,
role=2, role=2,
description="Grand Ancien Troll", description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400), start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86), end_date=localdate() - timedelta(days=86),
), )
Membership( Membership.objects.create(
user=richard, user=richard,
club=troll, club=troll,
role=2, role=2,
description="", description="",
start_date=localdate() - timedelta(days=200), start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100), end_date=localdate() - timedelta(days=100),
),
]
) )
p = ProductType.objects.create(name="Bières bouteilles") p = ProductType.objects.create(name="Bières bouteilles")
@ -594,7 +594,6 @@ Welcome to the wiki page!
) )
# Create an election # Create an election
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
el = Election.objects.create( el = Election.objects.create(
title="Élection 2017", title="Élection 2017",
description="La roue tourne", description="La roue tourne",
@ -604,7 +603,7 @@ Welcome to the wiki page!
end_date="7942-06-12 10:28:45+01", end_date="7942-06-12 10:28:45+01",
) )
el.view_groups.add(groups.public) el.view_groups.add(groups.public)
el.edit_groups.add(ae_board_group) el.edit_groups.add(main_club.board_group)
el.candidature_groups.add(groups.subscribers) el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers) el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el) liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@ -741,7 +740,7 @@ Welcome to the wiki page!
NewsDate( NewsDate(
news=n, news=n,
start_date=friday + timedelta(hours=24 * 7 + 1), start_date=friday + timedelta(hours=24 * 7 + 1),
end_date=self.now + timedelta(hours=24 * 7 + 9), end_date=friday + timedelta(hours=24 * 7 + 9),
) )
) )
# Weekly # Weekly
@ -767,8 +766,9 @@ Welcome to the wiki page!
] ]
) )
NewsDate.objects.bulk_create(news_dates) NewsDate.objects.bulk_create(news_dates)
IcsCalendar.make_internal() # Force refresh of the calendar after a bulk_create
# Create som data for pedagogy # Create some data for pedagogy
UV( UV(
code="PA00", code="PA00",
@ -889,7 +889,7 @@ Welcome to the wiki page!
def _create_groups(self) -> PopulatedGroups: def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all() perms = Permission.objects.all()
root_group = Group.objects.create(name="Root") root_group = Group.objects.create(name="Root", is_manually_manageable=True)
root_group.permissions.add(*list(perms.values_list("pk", flat=True))) root_group.permissions.add(*list(perms.values_list("pk", flat=True)))
# public has no permission. # public has no permission.
# Its purpose is not to link users to permissions, # Its purpose is not to link users to permissions,
@ -911,7 +911,9 @@ Welcome to the wiki page!
) )
) )
) )
accounting_admin = Group.objects.create(name="Accounting admin") accounting_admin = Group.objects.create(
name="Accounting admin", is_manually_manageable=True
)
accounting_admin.permissions.add( accounting_admin.permissions.add(
*list( *list(
perms.filter( perms.filter(
@ -931,13 +933,17 @@ Welcome to the wiki page!
).values_list("pk", flat=True) ).values_list("pk", flat=True)
) )
) )
com_admin = Group.objects.create(name="Communication admin") com_admin = Group.objects.create(
name="Communication admin", is_manually_manageable=True
)
com_admin.permissions.add( com_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="com").values_list("pk", flat=True) perms.filter(content_type__app_label="com").values_list("pk", flat=True)
) )
) )
counter_admin = Group.objects.create(name="Counter admin") counter_admin = Group.objects.create(
name="Counter admin", is_manually_manageable=True
)
counter_admin.permissions.add( counter_admin.permissions.add(
*list( *list(
perms.filter( perms.filter(
@ -946,16 +952,15 @@ Welcome to the wiki page!
) )
) )
) )
Group.objects.create(name="Banned from buying alcohol") sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
Group.objects.create(name="Banned from counters")
Group.objects.create(name="Banned to subscribe")
sas_admin = Group.objects.create(name="SAS admin")
sas_admin.permissions.add( sas_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="sas").values_list("pk", flat=True) perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
) )
) )
forum_admin = Group.objects.create(name="Forum admin") forum_admin = Group.objects.create(
name="Forum admin", is_manually_manageable=True
)
forum_admin.permissions.add( forum_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="forum").values_list( perms.filter(content_type__app_label="forum").values_list(
@ -963,7 +968,9 @@ Welcome to the wiki page!
) )
) )
) )
pedagogy_admin = Group.objects.create(name="Pedagogy admin") pedagogy_admin = Group.objects.create(
name="Pedagogy admin", is_manually_manageable=True
)
pedagogy_admin.permissions.add( pedagogy_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="pedagogy").values_list( perms.filter(content_type__app_label="pedagogy").values_list(
@ -984,3 +991,8 @@ Welcome to the wiki page!
sas_admin=sas_admin, sas_admin=sas_admin,
pedagogy_admin=pedagogy_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

@ -173,7 +173,8 @@ class Command(BaseCommand):
club=club, club=club,
) )
) )
Membership.objects.bulk_create(memberships) memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_uvs(self): def create_uvs(self):
root = User.objects.get(username="root") root = User.objects.get(username="root")

View File

@ -563,14 +563,21 @@ class Migration(migrations.Migration):
fields=[], fields=[],
options={"proxy": True}, options={"proxy": True},
bases=("core.group",), bases=("core.group",),
managers=[("objects", core.models.MetaGroupManager())], managers=[("objects", django.contrib.auth.models.GroupManager())],
), ),
# at first, there existed a RealGroupManager and a RealGroupManager,
# which have been since been removed.
# However, this removal broke the migrations because it caused an ImportError.
# Thus, the managers MetaGroupManager (above) and RealGroupManager (below)
# have been replaced by the base django GroupManager to fix the import.
# As those managers aren't actually used in migrations,
# this replacement doesn't break anything.
migrations.CreateModel( migrations.CreateModel(
name="RealGroup", name="RealGroup",
fields=[], fields=[],
options={"proxy": True}, options={"proxy": True},
bases=("core.group",), bases=("core.group",),
managers=[("objects", core.models.RealGroupManager())], managers=[("objects", django.contrib.auth.models.GroupManager())],
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name="page", unique_together={("name", "parent")} name="page", unique_together={("name", "parent")}

View File

@ -0,0 +1,37 @@
# Generated by Django 4.2.16 on 2024-11-30 13:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0040_alter_user_options_user_user_permissions_and_more"),
("club", "0013_alter_club_board_group_alter_club_members_group_and_more"),
]
operations = [
migrations.DeleteModel(
name="MetaGroup",
),
migrations.DeleteModel(
name="RealGroup",
),
migrations.AlterModelOptions(
name="group",
options={},
),
migrations.RenameField(
model_name="group",
old_name="is_meta",
new_name="is_manually_manageable",
),
migrations.AlterField(
model_name="group",
name="is_manually_manageable",
field=models.BooleanField(
default=False,
help_text="If False, this shouldn't be shown on group management pages",
verbose_name="Is manually manageable",
),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-04 16:42
from django.db import migrations
from django.db.migrations.state import StateApps
from django.db.models import F
def invert_is_manually_manageable(apps: StateApps, schema_editor):
"""Invert `is_manually_manageable`.
This field is a renaming of `is_meta`.
However, the meaning has been inverted : the groups
which were meta are not manually manageable and vice versa.
Thus, the value must be inverted.
"""
Group = apps.get_model("core", "Group")
Group.objects.all().update(is_manually_manageable=~F("is_manually_manageable"))
class Migration(migrations.Migration):
dependencies = [("core", "0041_delete_metagroup_alter_group_options_and_more")]
operations = [
migrations.RunPython(
invert_is_manually_manageable, reverse_code=invert_is_manually_manageable
),
]

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

@ -36,14 +36,13 @@ from django.conf import settings
from django.contrib.auth.models import AbstractUser, UserManager from django.contrib.auth.models import AbstractUser, UserManager
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import Group as AuthGroup
from django.contrib.auth.models import GroupManager as AuthGroupManager
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators from django.core import validators
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.mail import send_mail from django.core.mail import send_mail
from django.db import models, transaction 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.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
@ -58,33 +57,15 @@ if TYPE_CHECKING:
from club.models import Club from club.models import Club
class RealGroupManager(AuthGroupManager):
def get_queryset(self):
return super().get_queryset().filter(is_meta=False)
class MetaGroupManager(AuthGroupManager):
def get_queryset(self):
return super().get_queryset().filter(is_meta=True)
class Group(AuthGroup): class Group(AuthGroup):
"""Implement both RealGroups and Meta groups. """Wrapper around django.auth.Group"""
Groups are sorted by their is_meta property is_manually_manageable = models.BooleanField(
""" _("Is manually manageable"),
#: If False, this is a RealGroup
is_meta = models.BooleanField(
_("meta group status"),
default=False, default=False,
help_text=_("Whether a group is a meta group or not"), help_text=_("If False, this shouldn't be shown on group management pages"),
) )
#: Description of the group description = models.TextField(_("description"))
description = models.CharField(_("description"), max_length=60)
class Meta:
ordering = ["name"]
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
return reverse("core:group_list") return reverse("core:group_list")
@ -100,65 +81,6 @@ class Group(AuthGroup):
cache.delete(f"sith_group_{self.name.replace(' ', '_')}") cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
class MetaGroup(Group):
"""MetaGroups are dynamically created groups.
Generally used with clubs where creating a club creates two groups:
* club-SITH_BOARD_SUFFIX
* club-SITH_MEMBER_SUFFIX
"""
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=False
objects = MetaGroupManager()
class Meta:
proxy = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.is_meta = True
@cached_property
def associated_club(self) -> Club | None:
"""Return the group associated with this meta group.
The result of this function is cached
Returns:
The associated club if it exists, else None
"""
from club.models import Club
if self.name.endswith(settings.SITH_BOARD_SUFFIX):
# replace this with str.removesuffix as soon as Python
# is upgraded to 3.10
club_name = self.name[: -len(settings.SITH_BOARD_SUFFIX)]
elif self.name.endswith(settings.SITH_MEMBER_SUFFIX):
club_name = self.name[: -len(settings.SITH_MEMBER_SUFFIX)]
else:
return None
club = cache.get(f"sith_club_{club_name}")
if club is None:
club = Club.objects.filter(unix_name=club_name).first()
cache.set(f"sith_club_{club_name}", club)
return club
class RealGroup(Group):
"""RealGroups are created by the developer.
Most of the time they match a number in settings to be easily used for permissions.
"""
#: Assign a manager in a way that MetaGroup.objects only return groups with is_meta=True
objects = RealGroupManager()
class Meta:
proxy = True
def validate_promo(value: int) -> None: def validate_promo(value: int) -> None:
start_year = settings.SITH_SCHOOL_START_YEAR start_year = settings.SITH_SCHOOL_START_YEAR
delta = (localdate() + timedelta(days=180)).year - start_year delta = (localdate() + timedelta(days=180)).year - start_year
@ -204,13 +126,35 @@ def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None
else: else:
group = Group.objects.filter(name=name).first() group = Group.objects.filter(name=name).first()
if group is not None: if group is not None:
cache.set(f"sith_group_{group.id}", group) name = group.name.replace(" ", "_")
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group) cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else: else:
cache.set(f"sith_group_{pk_or_name}", "not_found") cache.set(f"sith_group_{pk_or_name}", "not_found")
return group 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): class UserQuerySet(models.QuerySet):
def filter_inactive(self) -> Self: def filter_inactive(self) -> Self:
from counter.models import Refilling, Selling from counter.models import Refilling, Selling
@ -261,7 +205,13 @@ class User(AbstractUser):
"granted to each of their groups." "granted to each of their groups."
), ),
related_name="users", 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( home = models.OneToOneField(
"SithFile", "SithFile",
@ -438,18 +388,6 @@ class User(AbstractUser):
return self.was_subscribed return self.was_subscribed
if group.id == settings.SITH_GROUP_ROOT_ID: if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root return self.is_root
if group.is_meta:
# check if this group is associated with a club
group.__class__ = MetaGroup
club = group.associated_club
if club is None:
return False
membership = club.get_membership_for(self)
if membership is None:
return False
if group.name.endswith(settings.SITH_MEMBER_SUFFIX):
return True
return membership.role > settings.SITH_MAXIMUM_FREE_ROLE
return group in self.cached_groups return group in self.cached_groups
@property @property
@ -474,12 +412,11 @@ class User(AbstractUser):
return any(g.id == root_id for g in self.cached_groups) return any(g.id == root_id for g in self.cached_groups)
@cached_property @cached_property
def is_board_member(self): def is_board_member(self) -> bool:
main_club = settings.SITH_MAIN_CLUB["unix_name"] return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
@cached_property @cached_property
def can_read_subscription_history(self): def can_read_subscription_history(self) -> bool:
if self.is_root or self.is_board_member: if self.is_root or self.is_board_member:
return True return True
@ -514,12 +451,12 @@ class User(AbstractUser):
) )
@cached_property @cached_property
def is_banned_alcohol(self): def is_banned_alcohol(self) -> bool:
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID) return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
@cached_property @cached_property
def is_banned_counter(self): def is_banned_counter(self) -> bool:
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID) return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
@cached_property @cached_property
def age(self) -> int: def age(self) -> int:
@ -821,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
return _("Visitor") 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): class Preferences(models.Model):
user = models.OneToOneField( user = models.OneToOneField(
User, related_name="_preferences", on_delete=models.CASCADE User, related_name="_preferences", on_delete=models.CASCADE
@ -943,7 +926,7 @@ class SithFile(models.Model):
param="1", param="1",
).save() ).save()
def is_owned_by(self, user): def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
return False return False
if user.is_root: if user.is_root:
@ -958,7 +941,7 @@ class SithFile(models.Model):
return True return True
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User) -> bool:
if hasattr(self, "profile_of"): if hasattr(self, "profile_of"):
return user.can_view(self.profile_of) return user.can_view(self.profile_of)
if hasattr(self, "avatar_of"): if hasattr(self, "avatar_of"):

View File

@ -24,6 +24,9 @@ $black-color: hsl(0, 0%, 17%);
$faceblue: hsl(221, 44%, 41%); $faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%); $twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223); $shadow-color: rgb(223, 223, 223);

View File

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

View File

@ -0,0 +1,5 @@
/*--------------------------MEDIA QUERY HELPERS------------------------*/
$small-devices: 576px;
$medium-devices: 768px;
$large-devices: 992px;

View File

@ -145,6 +145,7 @@ form {
margin-top: .25rem; margin-top: .25rem;
margin-bottom: .25rem; margin-bottom: .25rem;
font-size: 80%; font-size: 80%;
display: block;
} }
fieldset { fieldset {
@ -198,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, .form-group,
> p, > p,
> div { > div {

View File

@ -1,10 +1,6 @@
@import "colors"; @import "colors";
@import "forms"; @import "forms";
@import "devices";
/*--------------------------MEDIA QUERY HELPERS------------------------*/
$small-devices: 576px;
$medium-devices: 768px;
$large-devices: 992px;
/*--------------------------------GENERAL------------------------------*/ /*--------------------------------GENERAL------------------------------*/
@ -453,302 +449,6 @@ body {
} }
} }
/*---------------------------------NEWS--------------------------------*/
#news {
display: flex;
@media (max-width: 800px) {
flex-direction: column;
}
.news_column {
display: inline-block;
margin: 0;
vertical-align: top;
}
#news_admin {
margin-bottom: 1em;
}
#right_column {
flex: 20%;
float: right;
margin: 0.2em;
}
#left_column {
flex: 79%;
margin: 0.2em;
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 1.1em;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
}
}
}
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
}
}
/* AGENDA/BIRTHDAYS */
#agenda,
#birthdays {
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
#agenda_title,
#birthdays_title {
margin: 0;
border-radius: 5px 5px 0 0;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.5em;
font-weight: bold;
font-size: 150%;
text-align: center;
text-transform: uppercase;
background: $second-color;
}
#agenda_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
height: 20em;
}
#agenda_content,
#birthdays_content {
.agenda_item {
padding: 0.5em;
margin-bottom: 0.5em;
&:nth-of-type(even) {
background: $secondary-neutral-light-color;
}
.agenda_time {
font-size: 90%;
color: grey;
}
.agenda_item_content {
p {
margin-top: 0.2em;
}
}
}
ul.birthdays_year {
margin: 0;
list-style-type: none;
font-weight: bold;
>li {
padding: 0.5em;
&:nth-child(even) {
background: $secondary-neutral-light-color;
}
}
ul {
margin: 0;
margin-left: 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
}
}
}
}
/* END AGENDA/BIRTHDAYS */
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-bottom: 0.5em;
.news_events_group_date {
display: table-cell;
padding: 0.6em;
vertical-align: middle;
background: $primary-neutral-dark-color;
color: $white-color;
text-transform: uppercase;
text-align: center;
font-weight: bold;
font-family: monospace;
font-size: 1.4em;
border-radius: 7px 0 0 7px;
div {
margin: 0 auto;
.day {
font-size: 1.5em;
}
}
}
.news_events_group_items {
display: table-cell;
width: 100%;
.news_event:nth-of-type(odd) {
background: white;
}
.news_event:nth-of-type(even) {
background: $primary-neutral-light-color;
}
.news_event {
display: block;
padding: 0.4em;
&:not(:last-child) {
border-bottom: 1px solid grey;
}
div {
margin: 0.2em;
}
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
float: left;
min-width: 7em;
max-width: 9em;
margin: 0;
margin-right: 1em;
margin-top: 0.8em;
img {
max-height: 6em;
max-width: 8em;
display: block;
margin: 0 auto;
}
}
.news_date {
font-size: 100%;
}
.news_content {
clear: left;
.button_bar {
text-align: right;
.fb {
color: $faceblue;
}
.twitter {
color: $twitblue;
}
}
}
}
}
}
/* END EVENTS TODAY AND NEXT FEW DAYS */
/* COMING SOON */
.news_coming_soon {
display: list-item;
list-style-type: square;
list-style-position: inside;
margin-left: 1em;
padding-left: 0;
a {
font-weight: bold;
text-transform: uppercase;
}
.news_date {
font-size: 0.9em;
}
}
/* END COMING SOON */
/* NOTICES */
.news_notice {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
box-shadow: $shadow-color 0 0 2px;
border-radius: 18px 5px 18px 5px;
h4 {
margin: 0;
}
.news_content {
margin-left: 1em;
}
}
/* END NOTICES */
/* CALLS */
.news_call {
margin: 0 0 1em 1em;
padding: 0.4em;
padding-left: 1em;
background: $secondary-neutral-light-color;
border: 1px solid grey;
box-shadow: $shadow-color 1px 1px 1px;
h4 {
margin: 0;
}
.news_date {
font-size: 0.9em;
}
.news_content {
margin-left: 1em;
}
}
/* END CALLS */
.news_empty {
margin-left: 1em;
}
.news_date {
color: grey;
}
}
} }
@media screen and (max-width: $small-devices) { @media screen and (max-width: $small-devices) {
@ -757,304 +457,6 @@ body {
} }
} }
#news_details {
display: inline-block;
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
text-transform: uppercase;
}
.club_logo {
display: inline-block;
text-align: center;
width: 19%;
float: left;
min-width: 15em;
margin: 0;
img {
max-height: 15em;
max-width: 12em;
display: block;
margin: 0 auto;
margin-bottom: 10px;
}
}
.share_button {
border: none;
color: white;
padding: 0.5em 1em;
text-align: center;
text-decoration: none;
font-size: 1.2em;
border-radius: 2px;
float: right;
display: block;
margin-left: 0.3em;
&:hover {
color: lightgrey;
}
}
.facebook {
background: $faceblue;
}
.twitter {
background: $twitblue;
}
.news_meta {
margin-top: 10em;
font-size: small;
}
}
.helptext {
margin-top: 10px;
display: block;
}
/*---------------------------POSTERS----------------------------*/
#poster_list,
#screen_list,
#poster_edit,
#screen_edit {
position: relative;
#title {
position: relative;
padding: 10px;
margin: 10px;
border-bottom: 2px solid black;
h3 {
display: flex;
justify-content: center;
align-items: center;
}
#links {
position: absolute;
display: flex;
bottom: 5px;
&.left {
left: 0;
}
&.right {
right: 0;
}
.link {
padding: 5px;
padding-left: 20px;
padding-right: 20px;
margin-left: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&.delete {
background-color: hsl(0, 100%, 40%);
}
}
}
}
#posters,
#screens {
position: relative;
display: flex;
flex-wrap: wrap;
#no-posters,
#no-screens {
display: flex;
justify-content: center;
align-items: center;
}
.poster,
.screen {
min-width: 10%;
max-width: 20%;
display: flex;
flex-direction: column;
margin: 10px;
border: 2px solid darkgrey;
border-radius: 4px;
padding: 10px;
background-color: lightgrey;
* {
display: flex;
justify-content: center;
align-items: center;
}
.name {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
}
.image {
flex-grow: 1;
position: relative;
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
img {
max-height: 20vw;
max-width: 100%;
}
&:hover {
&::before {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
top: 0;
left: 0;
z-index: 10;
content: "Click to expand";
color: white;
background-color: rgba(black, 0.5);
}
}
}
.dates {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid whitesmoke;
* {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-left: 5px;
margin-right: 5px;
}
.begin,
.end {
width: 48%;
}
.begin {
border-right: 1px solid whitesmoke;
padding-right: 2%;
}
}
.edit,
.moderate,
.slideshow {
padding: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&:nth-child(2n) {
margin-top: 5px;
margin-bottom: 5px;
}
}
.tooltip {
visibility: hidden;
width: 120px;
background-color: hsl(210, 20%, 98%);
color: hsl(0, 0%, 0%);
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 10;
ul {
margin-left: 0;
display: inline-block;
li {
display: list-item;
list-style-type: none;
}
}
}
&.not_moderated {
border: 1px solid red;
}
&:hover .tooltip {
visibility: visible;
}
}
}
#view {
position: fixed;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
z-index: 10;
visibility: hidden;
background-color: rgba(10, 10, 10, 0.9);
overflow: hidden;
&.active {
visibility: visible;
}
#placeholder {
width: 80vw;
height: 80vh;
display: flex;
justify-content: center;
align-items: center;
top: 0;
left: 0;
img {
max-width: 100%;
max-height: 100%;
}
}
}
}
/*---------------------------ACCOUNTING----------------------------*/ /*---------------------------ACCOUNTING----------------------------*/
#accounting { #accounting {
.journal-table { .journal-table {
@ -1420,6 +822,10 @@ footer {
margin-top: 3px; margin-top: 3px;
color: rgba(0, 0, 0, 0.3); color: rgba(0, 0, 0, 0.3);
} }
.fa-github {
color: $githubblack;
}
} }

View File

@ -108,7 +108,8 @@
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a> <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a> <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</div> </div>
<a href="https://discord.gg/XK9WfPsUFm" target="_link"> <a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %} {% trans %}Site created by the IT Department of the AE{% endtrans %}
</a> </a>
{% endblock %} {% endblock %}

View File

@ -1,54 +0,0 @@
{% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block title %}
{% trans %}Poster{% endtrans %}
{% endblock %}
{% block content %}
<div id="poster_list">
<div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3>
<div id="links" class="right">
<a id="create" class="link" href="{{ url(app + ":poster_list") }}">{% trans %}Create{% endtrans %}</a>
{% if app == "com" %}
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
{% endif %}
</div>
</div>
<div id="posters">
{% if poster_list.count() == 0 %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
{% else %}
{% for poster in poster_list %}
<div class="poster">
<div class="name">{{ poster.name }}</div>
<div class="image"><img src="{{ poster.file.url }}"></img></div>
<div class="dates">
<div class="begin">{{ poster.date_begin | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | date("d/M/Y H:m") }}</div>
</div>
<a class="edit" href="{{ url(poster_edit_url_name, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
</div>
{% endfor %}
{% endif %}
</div>
<div id="view"><div id="placeholder"></div></div>
</div>
{% endblock %}

View File

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

View File

@ -18,6 +18,7 @@ from smtplib import SMTPException
import freezegun import freezegun
import pytest import pytest
from django.contrib.auth.hashers import make_password
from django.core import mail from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
@ -30,7 +31,7 @@ from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Membership from club.models import Club, Membership
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
@ -145,7 +146,7 @@ class TestUserRegistration:
class TestUserLogin: class TestUserLogin:
@pytest.fixture() @pytest.fixture()
def user(self) -> User: def user(self) -> User:
return User.objects.first() return baker.make(User, password=make_password("plop"))
def test_login_fail(self, client, user): def test_login_fail(self, client, user):
"""Should not login a user correctly.""" """Should not login a user correctly."""
@ -349,56 +350,35 @@ class TestUserIsInGroup(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
from club.models import Club
cls.root_group = Group.objects.get(name="Root") cls.root_group = Group.objects.get(name="Root")
cls.public = Group.objects.get(name="Public") cls.public_group = Group.objects.get(name="Public")
cls.skia = User.objects.get(username="skia") cls.public_user = baker.make(User)
cls.toto = User.objects.create(
username="toto", first_name="a", last_name="b", email="a.b@toto.fr"
)
cls.subscribers = Group.objects.get(name="Subscribers") cls.subscribers = Group.objects.get(name="Subscribers")
cls.old_subscribers = Group.objects.get(name="Old subscribers") cls.old_subscribers = Group.objects.get(name="Old subscribers")
cls.accounting_admin = Group.objects.get(name="Accounting admin") cls.accounting_admin = Group.objects.get(name="Accounting admin")
cls.com_admin = Group.objects.get(name="Communication admin") cls.com_admin = Group.objects.get(name="Communication admin")
cls.counter_admin = Group.objects.get(name="Counter 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.sas_admin = Group.objects.get(name="SAS admin")
cls.club = Club.objects.create( cls.club = baker.make(Club)
name="Fake Club",
unix_name="fake-club",
address="Fake address",
)
cls.main_club = Club.objects.get(id=1) cls.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user): def assert_in_public_group(self, user):
assert user.is_in_group(pk=self.public.id) assert user.is_in_group(pk=self.public_group.id)
assert user.is_in_group(name=self.public.name) assert user.is_in_group(name=self.public_group.name)
def assert_in_club_metagroups(self, user, club):
meta_groups_board = club.unix_name + settings.SITH_BOARD_SUFFIX
meta_groups_members = club.unix_name + settings.SITH_MEMBER_SUFFIX
assert user.is_in_group(name=meta_groups_board) is False
assert user.is_in_group(name=meta_groups_members) is False
def assert_only_in_public_group(self, user): def assert_only_in_public_group(self, user):
self.assert_in_public_group(user) self.assert_in_public_group(user)
for group in ( for group in (
self.root_group, self.root_group,
self.banned_counters,
self.accounting_admin, self.accounting_admin,
self.sas_admin, self.sas_admin,
self.subscribers, self.subscribers,
self.old_subscribers, self.old_subscribers,
self.club.members_group,
self.club.board_group,
): ):
assert not user.is_in_group(pk=group.pk) assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name) assert not user.is_in_group(name=group.name)
meta_groups_board = self.club.unix_name + settings.SITH_BOARD_SUFFIX
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
assert user.is_in_group(name=meta_groups_board) is False
assert user.is_in_group(name=meta_groups_members) is False
def test_anonymous_user(self): def test_anonymous_user(self):
"""Test that anonymous users are only in the public group.""" """Test that anonymous users are only in the public group."""
@ -407,80 +387,80 @@ class TestUserIsInGroup(TestCase):
def test_not_subscribed_user(self): def test_not_subscribed_user(self):
"""Test that users who never subscribed are only in the public group.""" """Test that users who never subscribed are only in the public group."""
self.assert_only_in_public_group(self.toto) self.assert_only_in_public_group(self.public_user)
def test_wrong_parameter_fail(self): def test_wrong_parameter_fail(self):
"""Test that when neither the pk nor the name argument is given, """Test that when neither the pk nor the name argument is given,
the function raises a ValueError. the function raises a ValueError.
""" """
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.toto.is_in_group() self.public_user.is_in_group()
def test_number_queries(self): def test_number_queries(self):
"""Test that the number of db queries is stable """Test that the number of db queries is stable
and that less queries are made when making a new call. and that less queries are made when making a new call.
""" """
# make sure Skia is in at least one group # make sure Skia is in at least one group
self.skia.groups.add(Group.objects.first().pk) group_in = baker.make(Group)
skia_groups = self.skia.groups.all() self.public_user.groups.add(group_in)
group_in = skia_groups.first()
cache.clear() cache.clear()
# Test when the user is in the group # Test when the user is in the group
with self.assertNumQueries(2): with self.assertNumQueries(2):
self.skia.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.skia.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
ids = skia_groups.values_list("pk", flat=True) group_not_in = baker.make(Group)
group_not_in = Group.objects.exclude(pk__in=ids).first()
cache.clear() cache.clear()
# Test when the user is not in the group # Test when the user is not in the group
with self.assertNumQueries(2): with self.assertNumQueries(2):
self.skia.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.skia.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)
def test_cache_properly_cleared_membership(self): def test_cache_properly_cleared_membership(self):
"""Test that when the membership of a user end, """Test that when the membership of a user end,
the cache is properly invalidated. the cache is properly invalidated.
""" """
membership = Membership.objects.create( membership = baker.make(Membership, club=self.club, user=self.public_user)
club=self.club, user=self.toto, end_date=None
)
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
cache.clear() cache.clear()
assert self.toto.is_in_group(name=meta_groups_members) is True self.club.get_membership_for(self.public_user) # this should populate the cache
assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}") assert membership == cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
membership.end_date = now() - timedelta(minutes=5) membership.end_date = now() - timedelta(minutes=5)
membership.save() membership.save()
cached_membership = cache.get(f"membership_{self.club.id}_{self.toto.id}") cached_membership = cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
assert cached_membership == "not_member" assert cached_membership == "not_member"
assert self.toto.is_in_group(name=meta_groups_members) is False
def test_cache_properly_cleared_group(self): def test_cache_properly_cleared_group(self):
"""Test that when a user is removed from a group, """Test that when a user is removed from a group,
the is_in_group_method return False when calling it again. the is_in_group_method return False when calling it again.
""" """
# testing with pk # testing with pk
self.toto.groups.add(self.com_admin.pk) self.public_user.groups.add(self.com_admin.pk)
assert self.toto.is_in_group(pk=self.com_admin.pk) is True assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
self.toto.groups.remove(self.com_admin.pk) self.public_user.groups.remove(self.com_admin.pk)
assert self.toto.is_in_group(pk=self.com_admin.pk) is False assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
# testing with name # testing with name
self.toto.groups.add(self.sas_admin.pk) self.public_user.groups.add(self.sas_admin.pk)
assert self.toto.is_in_group(name="SAS admin") is True assert self.public_user.is_in_group(name="SAS admin") is True
self.toto.groups.remove(self.sas_admin.pk) self.public_user.groups.remove(self.sas_admin.pk)
assert self.toto.is_in_group(name="SAS admin") is False assert self.public_user.is_in_group(name="SAS admin") is False
def test_not_existing_group(self): def test_not_existing_group(self):
"""Test that searching for a not existing group """Test that searching for a not existing group
returns False. returns False.
""" """
assert self.skia.is_in_group(name="This doesn't exist") is False user = baker.make(User)
user.groups.set(list(Group.objects.all()))
assert not user.is_in_group(name="This doesn't exist")
class TestDateUtils(TestCase): class TestDateUtils(TestCase):

View File

@ -13,6 +13,7 @@
# #
# #
import mimetypes import mimetypes
from pathlib import Path
from urllib.parse import quote, urljoin from urllib.parse import quote, urljoin
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
@ -48,6 +49,41 @@ from core.views.widgets.select import (
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
def send_raw_file(path: Path) -> HttpResponse:
"""Send a file located in the MEDIA_ROOT
This handles all the logic of using production reverse proxy or debug server.
THIS DOESN'T CHECK ANY PERMISSIONS !
"""
if not path.is_relative_to(settings.MEDIA_ROOT):
raise Http404
if not path.is_file() or not path.exists():
raise Http404
response = HttpResponse(
headers={"Content-Disposition": f'inline; filename="{quote(path.name)}"'}
)
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response["Content-Type"] = "" # automatically set by nginx
response["X-Accel-Redirect"] = quote(
urljoin(settings.MEDIA_URL, str(path.relative_to(settings.MEDIA_ROOT)))
)
return response
with open(path, "rb") as filename:
response.content = FileWrapper(filename)
response["Content-Type"] = mimetypes.guess_type(path)[0]
response["Last-Modified"] = http_date(path.stat().st_mtime)
response["Content-Length"] = path.stat().st_size
return response
def send_file( def send_file(
request: HttpRequest, request: HttpRequest,
file_id: int, file_id: int,
@ -66,28 +102,7 @@ def send_file(
raise PermissionDenied raise PermissionDenied
name = getattr(f, file_attr).name name = getattr(f, file_attr).name
response = HttpResponse( return send_raw_file(settings.MEDIA_ROOT / name)
headers={"Content-Disposition": f'inline; filename="{quote(name)}"'}
)
if not settings.DEBUG:
# When receiving a response with the Accel-Redirect header,
# the reverse proxy will automatically handle the file sending.
# This is really hard to test (thus isn't tested)
# so please do not mess with this.
response["Content-Type"] = "" # automatically set by nginx
response["X-Accel-Redirect"] = quote(urljoin(settings.MEDIA_URL, name))
return response
filepath = settings.MEDIA_ROOT / name
# check if file exists on disk
if not filepath.exists():
raise Http404
with open(filepath, "rb") as filename:
response.content = FileWrapper(filename)
response["Content-Type"] = mimetypes.guess_type(filepath)[0]
response["Last-Modified"] = http_date(f.date.timestamp())
response["Content-Length"] = filepath.stat().st_size
return response
class MultipleFileInput(forms.ClearableFileInput): class MultipleFileInput(forms.ClearableFileInput):

View File

@ -21,6 +21,7 @@
# #
# #
import re import re
from datetime import date, datetime
from io import BytesIO from io import BytesIO
from captcha.fields import CaptchaField from captcha.fields import CaptchaField
@ -37,14 +38,16 @@ from django.forms import (
DateInput, DateInput,
DateTimeInput, DateTimeInput,
TextInput, TextInput,
Widget,
) )
from django.utils.timezone import now
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Page, RealGroup, SithFile, User from core.models import Gift, Group, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.select import ( from core.views.widgets.select import (
AutoCompleteSelect, AutoCompleteSelect,
@ -130,6 +133,23 @@ class SelectUser(TextInput):
return output 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 # Forms
@ -293,7 +313,7 @@ class UserGroupsForm(forms.ModelForm):
required_css_class = "required" required_css_class = "required"
groups = forms.ModelMultipleChoiceField( groups = forms.ModelMultipleChoiceField(
queryset=RealGroup.objects.all(), queryset=Group.objects.filter(is_manually_manageable=True),
widget=CheckboxSelectMultiple, widget=CheckboxSelectMultiple,
label=_("Groups"), label=_("Groups"),
required=False, required=False,

View File

@ -21,7 +21,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.models import RealGroup, User from core.models import Group, User
from core.views import CanCreateMixin, CanEditMixin, DetailFormView from core.views import CanCreateMixin, CanEditMixin, DetailFormView
from core.views.widgets.select import AutoCompleteSelectMultipleUser from core.views.widgets.select import AutoCompleteSelectMultipleUser
@ -57,7 +57,8 @@ class EditMembersForm(forms.Form):
class GroupListView(CanEditMixin, ListView): class GroupListView(CanEditMixin, ListView):
"""Displays the Group list.""" """Displays the Group list."""
model = RealGroup model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
ordering = ["name"] ordering = ["name"]
template_name = "core/group_list.jinja" template_name = "core/group_list.jinja"
@ -65,7 +66,8 @@ class GroupListView(CanEditMixin, ListView):
class GroupEditView(CanEditMixin, UpdateView): class GroupEditView(CanEditMixin, UpdateView):
"""Edit infos of a Group.""" """Edit infos of a Group."""
model = RealGroup model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
pk_url_kwarg = "group_id" pk_url_kwarg = "group_id"
template_name = "core/group_edit.jinja" template_name = "core/group_edit.jinja"
fields = ["name", "description"] fields = ["name", "description"]
@ -74,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView):
class GroupCreateView(CanCreateMixin, CreateView): class GroupCreateView(CanCreateMixin, CreateView):
"""Add a new Group.""" """Add a new Group."""
model = RealGroup model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
template_name = "core/create.jinja" template_name = "core/create.jinja"
fields = ["name", "description"] fields = ["name", "description"]
@ -84,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
Allow adding and removing users from it. Allow adding and removing users from it.
""" """
model = RealGroup model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
form_class = EditMembersForm form_class = EditMembersForm
pk_url_kwarg = "group_id" pk_url_kwarg = "group_id"
template_name = "core/group_detail.jinja" template_name = "core/group_detail.jinja"
@ -118,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
class GroupDeleteView(CanEditMixin, DeleteView): class GroupDeleteView(CanEditMixin, DeleteView):
"""Delete a Group.""" """Delete a Group."""
model = RealGroup model = Group
queryset = Group.objects.filter(is_manually_manageable=True)
pk_url_kwarg = "group_id" pk_url_kwarg = "group_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("core:group_list") success_url = reverse_lazy("core:group_list")

View File

@ -64,16 +64,20 @@ class PageView(CanViewMixin, DetailView):
class PageHistView(CanViewMixin, DetailView): class PageHistView(CanViewMixin, DetailView):
model = Page model = Page
template_name = "core/page_hist.jinja" template_name = "core/page_hist.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name"
_cached_object: Page | None = None
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs) page = self.get_object()
if self.object.need_club_redirection: if page.need_club_redirection:
return redirect("club:club_hist", club_id=self.object.club.id) return redirect("club:club_hist", club_id=page.club.id)
return res return super().dispatch(request, *args, **kwargs)
def get_object(self): def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"]) if not self._cached_object:
return self.page self._cached_object = super().get_object()
return self._cached_object
class PageRevView(CanViewMixin, DetailView): class PageRevView(CanViewMixin, DetailView):

View File

@ -154,6 +154,9 @@ class CounterEditForm(forms.ModelForm):
class ProductEditForm(forms.ModelForm): class ProductEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta: class Meta:
model = Product model = Product
fields = [ fields = [
@ -171,6 +174,12 @@ class ProductEditForm(forms.ModelForm):
"tray", "tray",
"archived", "archived",
] ]
help_texts = {
"description": _(
"Describe the product. If it's an event's click, "
"give some insights about it, like the date (including the year)."
)
}
widgets = { widgets = {
"product_type": AutoCompleteSelect, "product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup, "buying_groups": AutoCompleteSelectMultipleGroup,

View File

@ -532,9 +532,12 @@ class Counter(models.Model):
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
if self.type == "BAR": return (
return True self.type == "BAR"
return user.is_board_member or user in self.sellers.all() or user.is_root
or user.is_in_group(pk=self.club.board_group_id)
or user in self.sellers.all()
)
def gen_token(self) -> None: def gen_token(self) -> None:
"""Generate a new token for this counter.""" """Generate a new token for this counter."""

View File

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

View File

@ -38,10 +38,20 @@ l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serve
```bash ```bash
# Pour le backend # Pour le backend
./manage.py makemessages --locale=fr -e py,jinja --ignore=node_modules ./manage.py makemessages \
--locale=fr \
-e py,jinja \
--ignore=node_modules \
--add-location=file
# Pour le frontend # Pour le frontend
./manage.py makemessages --locale=fr -d djangojs -e js,ts --ignore=node_modules --ignore=staticfiles/generated ./manage.py makemessages \
--locale=fr \
-d djangojs \
-e js,ts \
--ignore=node_modules \
--ignore=staticfiles/generated \
--add-location=file
``` ```
## Éditer le fichier django.po ## Éditer le fichier django.po

View File

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

View File

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

View File

@ -1,54 +1,165 @@
Il existe deux types de groupes sur le site AE : ## Un peu d'histoire
Par défaut, Django met à disposition un modèle `Group`,
lié par clef étrangère au modèle `User`.
Pour créer un système de gestion des groupes qui semblait plus
approprié aux développeurs initiaux, un nouveau
modèle [core.models.Group][]
a été crée, et la relation de clef étrangère a été modifiée
pour lier [core.models.User][] à ce dernier.
L'ancien modèle `Group` était implicitement
divisé en deux catégories :
- les *méta-groupes* : groupes liés aux clubs et créés à la volée.
Ces groupes n'étaient liés par clef étrangère à aucun utilisateur.
Ils étaient récupérés à partir de leur nom uniquement
et étaient plus une indirection pour désigner l'appartenance à un club
que des groupes à proprement parler.
- les *groupes réels* : groupes créés à la main
et souvent hardcodés dans la configuration.
Cependant, ce nouveau système s'éloignait trop du cadre de Django
et a fini par devenir une gêne.
La vérification des droits lors des opérations est devenue
une opération complexe et coûteuse en temps.
La gestion des groupes a donc été modifiée pour recoller un
peu plus au cadre de Django.
Toutefois, il n'a pas été tenté de revenir à 100%
sur l'architecture prônée par Django.
D'une part, cela représentait un risque pour le succès de l'application
de la migration sur la base de données de production.
D'autre part, si une autre architecture a été tentée au début,
ce n'était pas sans raison :
ce que nous voulons modéliser sur le site AE n'est pas
complètement modélisable avec ce qu'offre Django.
Il faut donc bien garder une surcouche au-dessus de l'authentification
de Django.
Tout le défi est de réussir à maintenir cette surcouche aussi fine
que possible sans limiter ce que nous voulons faire.
## Représentation en base de données
Le modèle [core.models.Group][] a donc été légèrement remanié
et la distinction entre groupes méta et groupes réels a été plus ou moins
supprimée.
La liaison de clef étrangère se fait toujours entre [core.models.User][]
et [core.models.Group][].
Cependant, il y a une subtilité.
Depuis le début, le modèle `Group` de django n'a jamais disparu.
En effet, lorsqu'un modèle hérite d'un modèle qui n'est pas
abstrait, Django garde les deux tables et les lie
par une clef étrangère unique de clef primaire à clef primaire
(pour plus de détail, lire
[la doc de django sur l'héritage de modèle](https://docs.djangoproject.com/fr/stable/topics/db/models/#model-inheritance))
L'organisation réelle de notre système de groupes
est donc la suivante :
<!-- J'ai utilisé un diagramme entité-relation
au lieu d'un diagramme de db, parce que Mermaid n'a que
le diagramme entité-relation. -->
```mermaid
---
title: Représentation des groupes
---
erDiagram
core_user }o..o{ core_group: core_user_groups
auth_group }o..o{ auth_permission: auth_group_permissions
core_group ||--|| auth_group: ""
core_user }o..o{ auth_permission :"core_user_user_permissions"
core_user {
int id PK
string username
string email
string first_name
etc etc
}
core_group {
int group_ptr_id PK,FK
string description
bool is_manually_manageable
}
auth_group {
int id PK
name string
}
auth_permission {
int id PK
string name
}
```
Cette organisation, rajoute une certaine complexité,
mais celle-ci est presque entièrement gérée par django,
ce qui fait que la gestion n'est pas tellement plus compliquée
du point de vue du développeur.
Chaque fois qu'un queryset implique notre `Group`
ou le `Group` de django, l'autre modèle est automatiquement
ajouté à la requête par jointure.
De cette façon, on peut manipuler l'un ou l'autre,
sans même se rendre que les tables sont dans des tables séparées.
Par exemple :
=== "python"
```python
from core.models import Group
Group.objects.all()
```
=== "SQL généré"
```sql
SELECT "auth_group"."id",
"auth_group"."name",
"core_group"."group_ptr_id",
"core_group"."is_manually_manageable",
"core_group"."description"
FROM "core_group"
INNER JOIN "auth_group" ON ("core_group"."group_ptr_id" = "auth_group"."id")
```
!!!warning
Django réussit à abstraire assez bien la logique relationnelle.
Cependant, gardez bien en mémoire que ce n'est pas quelque chose
de magique et que cette manière de faire a des limitations.
Par exemple, il devient impossible de `bulk_create`
des groupes.
- l'un se base sur des groupes enregistrés en base de données pendant le développement,
c'est le système de groupes réels.
- L'autre est plus dynamique et comprend tous les groupes générés
pendant l'exécution et l'utilisation du programme.
Cela correspond généralement aux groupes liés aux clubs.
Ce sont les méta-groupes.
## La définition d'un groupe ## La définition d'un groupe
Les deux types de groupes sont stockés dans la même table Un groupe est constitué des informations suivantes :
en base de données, et ne sont différenciés que par un attribut `is_meta`.
### Les groupes réels - son nom : `name`
- sa description : `description` (optionnelle)
- si on autorise sa gestion par les utilisateurs du site : `is_manually_manageable`
Pour plus différencier l'utilisation de ces deux types de groupe, Si un groupe est gérable manuellement, alors les administrateurs du site
il a été créé une classe proxy auront le droit d'assigner des utilisateurs à ce groupe depuis l'interface dédiée.
(c'est-à-dire qu'elle ne correspond pas à une vraie table en base de donnée)
qui encapsule leur utilisation.
`RealGroup` peut être utilisé pour créer des groupes réels dans le code
et pour faire une recherche sur ceux-ci
(dans le cadre d'une vérification de permissions par exemple).
### Les méta-groupes S'il n'est pas gérable manuellement, on cache aux utilisateurs du site
la gestion des membres de ce groupe.
La gestion se fait alors uniquement "sous le capot",
de manière automatique lors de certains évènements.
Par exemple, lorsqu'un utilisateur rejoint un club,
il est automatiquement ajouté au groupe des membres
du club.
Lorsqu'il quitte le club, il est retiré du groupe.
Les méta-groupes, comme expliqué précédemment, ## Les principaux groupes utilisés
sont utilisés dans les contextes où il est nécessaire de créer un groupe dynamiquement.
Les objets `MetaGroup`, bien que dynamiques, doivent tout de même s'enregistrer
en base de données comme des vrais groupes afin de pouvoir être affectés
dans les permissions d'autres objets, comme un forum ou une page de wiki par exemple.
C'est principalement utilisé au travers des clubs,
qui génèrent automatiquement deux groupes à leur création :
- club-bureau : contient tous les membres d'un club **au dessus** Les groupes les plus notables gérables par les administrateurs du site sont :
du grade défini dans `settings.SITH_MAXIMUM_FREE_ROLE`.
- club-membres : contient tous les membres d'un club
**en dessous** du grade défini dans `settings.SITH_MAXIMUM_FREE_ROLE`.
## Les groupes réels utilisés
Les groupes réels que l'on utilise dans le site sont les suivants :
Groupes gérés automatiquement par le site :
- `Public` : tous les utilisateurs du site
- `Subscribers` : tous les cotisants du site
- `Old subscribers` : tous les anciens cotisants
Groupes gérés par les administrateurs (à appliquer à la main sur un utilisateur) :
- `Root` : administrateur global du site - `Root` : administrateur global du site
- `Accounting admin` : les administrateurs de la comptabilité - `Accounting admin` : les administrateurs de la comptabilité
@ -62,3 +173,10 @@ Groupes gérés par les administrateurs (à appliquer à la main sur un utilisat
- `Banned to subscribe` : les utilisateurs interdits de cotisation - `Banned to subscribe` : les utilisateurs interdits de cotisation
En plus de ces groupes, on peut noter :
- `Public` : tous les utilisateurs du site
- `Subscribers` : tous les cotisants du site
- `Old subscribers` : tous les anciens cotisants

View File

@ -81,7 +81,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
libffi-dev python-dev-is-python3 pkg-config \ libffi-dev python-dev-is-python3 pkg-config \
gettext git pipx gettext git pipx
pipx install poetry pipx install poetry==1.8.5
``` ```
=== "Arch Linux" === "Arch Linux"
@ -101,7 +101,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```bash ```bash
brew install git python pipx npm brew install git python pipx npm
pipx install poetry pipx install poetry==1.8.5
# Pour bien configurer gettext # Pour bien configurer gettext
brew link gettext # (suivez bien les instructions supplémentaires affichées) brew link gettext # (suivez bien les instructions supplémentaires affichées)

View File

@ -4,7 +4,7 @@
Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions Le fonctionnement de l'AE ne permet pas d'utiliser le système de permissions
intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le intégré à Django tel quel. Lors de la conception du Sith, ce qui paraissait le
plus simple à l'époque était de concevoir un système maison afin de se calquer plus simple à l'époque était de concevoir un système maison afin de se calquer
sur ce que faisais l'ancien site. sur ce que faisait l'ancien site.
### Protéger un modèle ### Protéger un modèle

View File

@ -11,8 +11,6 @@ class TestElection(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
cls.election = Election.objects.first() cls.election = Election.objects.first()
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.subscriber_group = Group.objects.get(name=settings.SITH_MAIN_MEMBERS_GROUP)
cls.ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.subscriber = User.objects.get(username="subscriber") cls.subscriber = User.objects.get(username="subscriber")
cls.public = User.objects.get(username="public") cls.public = User.objects.get(username="public")

View File

@ -118,18 +118,26 @@ class Command(BaseCommand):
self.make_important_citizen(u) self.make_important_citizen(u)
def make_clubs(self): def make_clubs(self):
"""Create all the clubs (:class:`club.models.Club`). """Create all the clubs [club.models.Club][].
After creation, the clubs are stored in `self.clubs` for fast access later. After creation, the clubs are stored in `self.clubs` for fast access later.
Don't create the meta groups (:class:`core.models.MetaGroup`) Don't create the pages of the clubs ([core.models.Page][]).
nor the pages of the clubs (:class:`core.models.Page`).
""" """
self.clubs = [] # dummy groups.
for i in range(self.NB_CLUBS): # the galaxy doesn't care about the club groups,
self.clubs.append(Club(unix_name=f"galaxy-club-{i}", name=f"club-{i}")) # but it's necessary to add them nonetheless in order
# We don't need to create corresponding groups here, as the Galaxy doesn't care about them # not to break the integrity constraints
Club.objects.bulk_create(self.clubs) self.clubs = Club.objects.bulk_create(
self.clubs = list(Club.objects.filter(unix_name__startswith="galaxy-").all()) [
Club(
unix_name=f"galaxy-club-{i}",
name=f"club-{i}",
board_group=Group.objects.create(name=f"board {i}"),
members_group=Group.objects.create(name=f"members {i}"),
)
for i in range(self.NB_CLUBS)
]
)
def make_users(self): def make_users(self):
"""Create all the users and store them in `self.users` for fast access later. """Create all the users and store them in `self.users` for fast access later.

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-23 02:38+0100\n" "POT-Creation-Date: 2025-01-04 23:07+0100\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -17,168 +17,172 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: core/static/bundled/core/components/ajax-select-base.ts:68 #: com/static/bundled/com/components/ics-calendar-index.ts
msgid "More info"
msgstr "Plus d'informations"
#: core/static/bundled/core/components/ajax-select-base.ts
msgid "Remove" msgid "Remove"
msgstr "Retirer" msgstr "Retirer"
#: core/static/bundled/core/components/ajax-select-base.ts:90 #: core/static/bundled/core/components/ajax-select-base.ts
msgid "You need to type %(number)s more characters" msgid "You need to type %(number)s more characters"
msgstr "Vous devez taper %(number)s caractères de plus" msgstr "Vous devez taper %(number)s caractères de plus"
#: core/static/bundled/core/components/ajax-select-base.ts:94 #: core/static/bundled/core/components/ajax-select-base.ts
msgid "No results found" msgid "No results found"
msgstr "Aucun résultat trouvé" msgstr "Aucun résultat trouvé"
#: core/static/bundled/core/components/easymde-index.ts:38 #: core/static/bundled/core/components/easymde-index.ts
msgid "Heading" msgid "Heading"
msgstr "Titre" msgstr "Titre"
#: core/static/bundled/core/components/easymde-index.ts:44 #: core/static/bundled/core/components/easymde-index.ts
msgid "Italic" msgid "Italic"
msgstr "Italique" msgstr "Italique"
#: core/static/bundled/core/components/easymde-index.ts:50 #: core/static/bundled/core/components/easymde-index.ts
msgid "Bold" msgid "Bold"
msgstr "Gras" msgstr "Gras"
#: core/static/bundled/core/components/easymde-index.ts:56 #: core/static/bundled/core/components/easymde-index.ts
msgid "Strikethrough" msgid "Strikethrough"
msgstr "Barré" msgstr "Barré"
#: core/static/bundled/core/components/easymde-index.ts:65 #: core/static/bundled/core/components/easymde-index.ts
msgid "Underline" msgid "Underline"
msgstr "Souligné" msgstr "Souligné"
#: core/static/bundled/core/components/easymde-index.ts:74 #: core/static/bundled/core/components/easymde-index.ts
msgid "Superscript" msgid "Superscript"
msgstr "Exposant" msgstr "Exposant"
#: core/static/bundled/core/components/easymde-index.ts:83 #: core/static/bundled/core/components/easymde-index.ts
msgid "Subscript" msgid "Subscript"
msgstr "Indice" msgstr "Indice"
#: core/static/bundled/core/components/easymde-index.ts:89 #: core/static/bundled/core/components/easymde-index.ts
msgid "Code" msgid "Code"
msgstr "Code" msgstr "Code"
#: core/static/bundled/core/components/easymde-index.ts:96 #: core/static/bundled/core/components/easymde-index.ts
msgid "Quote" msgid "Quote"
msgstr "Citation" msgstr "Citation"
#: core/static/bundled/core/components/easymde-index.ts:102 #: core/static/bundled/core/components/easymde-index.ts
msgid "Unordered list" msgid "Unordered list"
msgstr "Liste non ordonnée" msgstr "Liste non ordonnée"
#: core/static/bundled/core/components/easymde-index.ts:108 #: core/static/bundled/core/components/easymde-index.ts
msgid "Ordered list" msgid "Ordered list"
msgstr "Liste ordonnée" msgstr "Liste ordonnée"
#: core/static/bundled/core/components/easymde-index.ts:115 #: core/static/bundled/core/components/easymde-index.ts
msgid "Insert link" msgid "Insert link"
msgstr "Insérer lien" msgstr "Insérer lien"
#: core/static/bundled/core/components/easymde-index.ts:121 #: core/static/bundled/core/components/easymde-index.ts
msgid "Insert image" msgid "Insert image"
msgstr "Insérer image" msgstr "Insérer image"
#: core/static/bundled/core/components/easymde-index.ts:127 #: core/static/bundled/core/components/easymde-index.ts
msgid "Insert table" msgid "Insert table"
msgstr "Insérer tableau" msgstr "Insérer tableau"
#: core/static/bundled/core/components/easymde-index.ts:134 #: core/static/bundled/core/components/easymde-index.ts
msgid "Clean block" msgid "Clean block"
msgstr "Nettoyer bloc" msgstr "Nettoyer bloc"
#: core/static/bundled/core/components/easymde-index.ts:141 #: core/static/bundled/core/components/easymde-index.ts
msgid "Toggle preview" msgid "Toggle preview"
msgstr "Activer la prévisualisation" msgstr "Activer la prévisualisation"
#: core/static/bundled/core/components/easymde-index.ts:147 #: core/static/bundled/core/components/easymde-index.ts
msgid "Toggle side by side" msgid "Toggle side by side"
msgstr "Activer la vue côte à côte" msgstr "Activer la vue côte à côte"
#: core/static/bundled/core/components/easymde-index.ts:153 #: core/static/bundled/core/components/easymde-index.ts
msgid "Toggle fullscreen" msgid "Toggle fullscreen"
msgstr "Activer le plein écran" msgstr "Activer le plein écran"
#: core/static/bundled/core/components/easymde-index.ts:160 #: core/static/bundled/core/components/easymde-index.ts
msgid "Markdown guide" msgid "Markdown guide"
msgstr "Guide markdown" msgstr "Guide markdown"
#: core/static/bundled/core/components/nfc-input-index.ts:26 #: core/static/bundled/core/components/nfc-input-index.ts
msgid "Unsupported NFC card" msgid "Unsupported NFC card"
msgstr "Carte NFC non supportée" msgstr "Carte NFC non supportée"
#: core/static/bundled/user/family-graph-index.js:233 #: core/static/bundled/user/family-graph-index.js
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"
#: core/static/bundled/user/pictures-index.js:76 #: core/static/bundled/user/pictures-index.js
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"
#: core/static/user/js/user_edit.js:91 #: core/static/user/js/user_edit.js
#, javascript-format #, javascript-format
msgid "captured.%s" msgid "captured.%s"
msgstr "capture.%s" msgstr "capture.%s"
#: counter/static/bundled/counter/counter-click-index.ts:60 #: counter/static/bundled/counter/counter-click-index.ts
msgid "Not enough money" msgid "Not enough money"
msgstr "Pas assez d'argent" msgstr "Pas assez d'argent"
#: counter/static/bundled/counter/counter-click-index.ts:113 #: counter/static/bundled/counter/counter-click-index.ts
msgid "You can't send an empty basket." msgid "You can't send an empty basket."
msgstr "Vous ne pouvez pas envoyer un panier vide." msgstr "Vous ne pouvez pas envoyer un panier vide."
#: counter/static/bundled/counter/product-list-index.ts:40 #: counter/static/bundled/counter/product-list-index.ts
msgid "name" msgid "name"
msgstr "nom" msgstr "nom"
#: counter/static/bundled/counter/product-list-index.ts:43 #: counter/static/bundled/counter/product-list-index.ts
msgid "product type" msgid "product type"
msgstr "type de produit" msgstr "type de produit"
#: counter/static/bundled/counter/product-list-index.ts:45 #: counter/static/bundled/counter/product-list-index.ts
msgid "limit age" msgid "limit age"
msgstr "limite d'âge" msgstr "limite d'âge"
#: counter/static/bundled/counter/product-list-index.ts:46 #: counter/static/bundled/counter/product-list-index.ts
msgid "purchase price" msgid "purchase price"
msgstr "prix d'achat" msgstr "prix d'achat"
#: counter/static/bundled/counter/product-list-index.ts:47 #: counter/static/bundled/counter/product-list-index.ts
msgid "selling price" msgid "selling price"
msgstr "prix de vente" msgstr "prix de vente"
#: counter/static/bundled/counter/product-list-index.ts:48 #: counter/static/bundled/counter/product-list-index.ts
msgid "archived" msgid "archived"
msgstr "archivé" msgstr "archivé"
#: counter/static/bundled/counter/product-list-index.ts:125 #: counter/static/bundled/counter/product-list-index.ts
msgid "Uncategorized" msgid "Uncategorized"
msgstr "Sans catégorie" msgstr "Sans catégorie"
#: counter/static/bundled/counter/product-list-index.ts:143 #: counter/static/bundled/counter/product-list-index.ts
msgid "products.csv" msgid "products.csv"
msgstr "produits.csv" msgstr "produits.csv"
#: counter/static/bundled/counter/product-type-index.ts:46 #: counter/static/bundled/counter/product-type-index.ts
msgid "Products types reordered!" msgid "Products types reordered!"
msgstr "Types de produits réordonnés !" msgstr "Types de produits réordonnés !"
#: counter/static/bundled/counter/product-type-index.ts:50 #: counter/static/bundled/counter/product-type-index.ts
#, javascript-format #, javascript-format
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/eboutic/js/makecommand.js:56 #: eboutic/static/eboutic/js/makecommand.js
msgid "Incorrect value" msgid "Incorrect value"
msgstr "Valeur incorrecte" msgstr "Valeur incorrecte"
#: sas/static/bundled/sas/viewer-index.ts:271 #: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't moderate picture" msgid "Couldn't moderate picture"
msgstr "Il n'a pas été possible de modérer l'image" msgstr "Il n'a pas été possible de modérer l'image"
#: sas/static/bundled/sas/viewer-index.ts:284 #: sas/static/bundled/sas/viewer-index.ts
msgid "Couldn't delete picture" msgid "Couldn't delete picture"
msgstr "Il n'a pas été possible de supprimer l'image" msgstr "Il n'a pas été possible de supprimer l'image"

View File

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

52
package-lock.json generated
View File

@ -11,6 +11,10 @@
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
@ -2384,6 +2388,39 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@fullcalendar/core": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz",
"integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz",
"integrity": "sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.15"
}
},
"node_modules/@fullcalendar/icalendar": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/icalendar/-/icalendar-6.1.15.tgz",
"integrity": "sha512-iroDc02fjxWCEYE9Lg8x+4HCJTrt04ZgDddwm0LLaWUbtx24rEcnzJP34NUx0KOTLsBjel6U/33lXvU9qDCrhg==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.15",
"ical.js": "^1.4.0"
}
},
"node_modules/@fullcalendar/list": {
"version": "6.1.15",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.15.tgz",
"integrity": "sha512-U1bce04tYDwkFnuVImJSy2XalYIIQr6YusOWRPM/5ivHcJh67Gm8CIMSWpi3KdRSNKFkqBxLPkfZGBMaOcJYug==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.15"
}
},
"node_modules/@hey-api/client-fetch": { "node_modules/@hey-api/client-fetch": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.4.0.tgz", "resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.4.0.tgz",
@ -4162,6 +4199,12 @@
"node": ">=16.17.0" "node": ">=16.17.0"
} }
}, },
"node_modules/ical.js": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/ical.js/-/ical.js-1.5.0.tgz",
"integrity": "sha512-7ZxMkogUkkaCx810yp0ZGKvq1ZpRgJeornPttpoxe6nYZ3NLesZe1wWMXDdwTkj/b5NtXT+Y16Aakph/ao98ZQ==",
"peer": true
},
"node_modules/import-from-esm": { "node_modules/import-from-esm": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz", "resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz",
@ -4924,6 +4967,15 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@ -19,7 +19,8 @@
"#openapi": "./staticfiles/generated/openapi/index.ts", "#openapi": "./staticfiles/generated/openapi/index.ts",
"#core:*": "./core/static/bundled/*", "#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*", "#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*" "#counter:*": "./counter/static/bundled/*",
"#com:*": "./com/static/bundled/*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
@ -36,6 +37,10 @@
"dependencies": { "dependencies": {
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@hey-api/client-fetch": "^0.4.0", "@hey-api/client-fetch": "^0.4.0",
"@sentry/browser": "^8.34.0", "@sentry/browser": "^8.34.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",

33
poetry.lock generated
View File

@ -931,6 +931,23 @@ files = [
{file = "hiredis-3.1.0.tar.gz", hash = "sha256:51d40ac3611091020d7dea6b05ed62cb152bff595fa4f931e7b6479d777acf7c"}, {file = "hiredis-3.1.0.tar.gz", hash = "sha256:51d40ac3611091020d7dea6b05ed62cb152bff595fa4f931e7b6479d777acf7c"},
] ]
[[package]]
name = "ical"
version = "8.3.0"
description = "Python iCalendar implementation (rfc 2445)"
optional = false
python-versions = ">=3.10"
files = [
{file = "ical-8.3.0-py3-none-any.whl", hash = "sha256:606f2f561bd8b75cb726710dddbb20f3f84dfa1d6323550947dba97359423850"},
{file = "ical-8.3.0.tar.gz", hash = "sha256:e277cc518cbb0132e6827c318c8ec3b379b125ebf0a2a44337f08795d5530937"},
]
[package.dependencies]
pydantic = ">=1.9.1"
pyparsing = ">=3.0.9"
python-dateutil = ">=2.8.2"
tzdata = ">=2023.3"
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.6.3" version = "2.6.3"
@ -1883,6 +1900,20 @@ pyyaml = "*"
[package.extras] [package.extras]
extra = ["pygments (>=2.12)"] extra = ["pygments (>=2.12)"]
[[package]]
name = "pyparsing"
version = "3.2.1"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
optional = false
python-versions = ">=3.9"
files = [
{file = "pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1"},
{file = "pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.3.4" version = "8.3.4"
@ -2724,4 +2755,4 @@ filelock = ">=3.4"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "5836c1a8ad42645d7d045194c8c371754b19957ebdcd2aaa902a2fb3dc97cc53" content-hash = "7f348f74a05c27e29aaaf25a5584bba9b416f42c3f370db234dd69e5e10dc8df"

View File

@ -45,6 +45,7 @@ Sphinx = "^5" # Needed for building xapian
tomli = "^2.2.1" tomli = "^2.2.1"
django-honeypot = "^1.2.1" django-honeypot = "^1.2.1"
pydantic-extra-types = "^2.10.1" pydantic-extra-types = "^2.10.1"
ical = "^8.3.0"
[tool.poetry.group.prod.dependencies] [tool.poetry.group.prod.dependencies]
# deps used in prod, but unnecessary for development # deps used in prod, but unnecessary for development

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 django.urls import path
from rootplace.views import ( from rootplace.views import (
BanCreateView,
BanDeleteView,
BanView,
DeleteAllForumUserMessagesView, DeleteAllForumUserMessagesView,
MergeUsersView, MergeUsersView,
OperationLogListView, OperationLogListView,
@ -38,4 +41,7 @@ urlpatterns = [
name="delete_forum_messages", name="delete_forum_messages",
), ),
path("logs/", OperationLogListView.as_view(), name="operation_logs"), 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 import logging
from django import forms from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied 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 import timezone
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext as _ from django.views.generic import DeleteView, ListView
from django.views.generic import ListView from django.views.generic.edit import CreateView, FormView
from django.views.generic.edit import FormView
from core.models import OperationLog, SithFile, User from core.models import OperationLog, SithFile, User, UserBan
from core.views import CanEditPropMixin from core.views import CanEditPropMixin
from core.views.widgets.select import AutoCompleteSelectUser
from counter.models import Customer from counter.models import Customer
from forum.models import ForumMessageMeta from forum.models import ForumMessageMeta
from rootplace.forms import BanForm, MergeForm, SelectUserForm
def __merge_subscriptions(u1: User, u2: User): 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() 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): class MergeUsersView(FormView):
template_name = "rootplace/merge.jinja" template_name = "rootplace/merge.jinja"
form_class = MergeForm form_class = MergeForm
@ -233,3 +205,39 @@ class OperationLogListView(ListView, CanEditPropMixin):
template_name = "rootplace/logs.jinja" template_name = "rootplace/logs.jinja"
ordering = ["-date"] ordering = ["-date"]
paginate_by = 100 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

@ -163,6 +163,7 @@ TEMPLATES = [
"ProductType": "counter.models.ProductType", "ProductType": "counter.models.ProductType",
"timezone": "django.utils.timezone", "timezone": "django.utils.timezone",
"get_sith": "com.views.sith", "get_sith": "com.views.sith",
"get_language": "django.utils.translation.get_language",
}, },
"bytecode_cache": { "bytecode_cache": {
"name": "default", "name": "default",
@ -362,12 +363,13 @@ SITH_GROUP_OLD_SUBSCRIBERS_ID = 4
SITH_GROUP_ACCOUNTING_ADMIN_ID = 5 SITH_GROUP_ACCOUNTING_ADMIN_ID = 5
SITH_GROUP_COM_ADMIN_ID = 6 SITH_GROUP_COM_ADMIN_ID = 6
SITH_GROUP_COUNTER_ADMIN_ID = 7 SITH_GROUP_COUNTER_ADMIN_ID = 7
SITH_GROUP_BANNED_ALCOHOL_ID = 8 SITH_GROUP_SAS_ADMIN_ID = 8
SITH_GROUP_BANNED_COUNTER_ID = 9 SITH_GROUP_FORUM_ADMIN_ID = 9
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 10 SITH_GROUP_PEDAGOGY_ADMIN_ID = 10
SITH_GROUP_SAS_ADMIN_ID = 11
SITH_GROUP_FORUM_ADMIN_ID = 12 SITH_GROUP_BANNED_ALCOHOL_ID = 11
SITH_GROUP_PEDAGOGY_ADMIN_ID = 13 SITH_GROUP_BANNED_COUNTER_ID = 12
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 13
SITH_CLUB_REFOUND_ID = 89 SITH_CLUB_REFOUND_ID = 89
SITH_COUNTER_REFOUND_ID = 38 SITH_COUNTER_REFOUND_ID = 38

View File

@ -16,7 +16,8 @@
"#openapi": ["./staticfiles/generated/openapi/index.ts"], "#openapi": ["./staticfiles/generated/openapi/index.ts"],
"#core:*": ["./core/static/bundled/*"], "#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"], "#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"] "#counter:*": ["./counter/static/bundled/*"],
"#com:*": ["./com/static/bundled/*"]
} }
} }
} }