mirror of
https://github.com/ae-utbm/sith.git
synced 2025-01-08 08:01:13 +00:00
Merge pull request #987 from ae-utbm/taiste
Better group management, unified calendar and fixes
This commit is contained in:
commit
16de128fdb
2
.github/actions/setup_project/action.yml
vendored
2
.github/actions/setup_project/action.yml
vendored
@ -24,7 +24,7 @@ runs:
|
||||
- name: Install Poetry
|
||||
if: steps.cached-poetry.outputs.cache-hit != 'true'
|
||||
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
|
||||
shell: bash
|
||||
|
@ -20,6 +20,14 @@ from club.models import Club, Membership
|
||||
@admin.register(Club)
|
||||
class ClubAdmin(admin.ModelAdmin):
|
||||
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)
|
||||
|
106
club/migrations/0012_club_board_group_club_members_group.py
Normal file
106
club/migrations/0012_club_board_group_club_members_group.py
Normal 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
|
||||
),
|
||||
]
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
305
club/models.py
305
club/models.py
@ -23,7 +23,7 @@
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Self
|
||||
from typing import Iterable, Self
|
||||
|
||||
from django.conf import settings
|
||||
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.validators import RegexValidator, validate_email
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import localdate
|
||||
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.
|
||||
|
||||
@ -79,19 +79,6 @@ class Club(models.Model):
|
||||
_("short description"), max_length=1000, default="", blank=True, null=True
|
||||
)
|
||||
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(
|
||||
SithFile,
|
||||
related_name="home_of_club",
|
||||
@ -103,6 +90,12 @@ class Club(models.Model):
|
||||
page = models.OneToOneField(
|
||||
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:
|
||||
ordering = ["name", "unix_name"]
|
||||
@ -112,23 +105,27 @@ class Club(models.Model):
|
||||
|
||||
@transaction.atomic()
|
||||
def save(self, *args, **kwargs):
|
||||
old = Club.objects.filter(id=self.id).first()
|
||||
creation = old is None
|
||||
if not creation and old.unix_name != self.unix_name:
|
||||
self._change_unixname(self.unix_name)
|
||||
creation = self._state.adding
|
||||
if not creation:
|
||||
db_club = Club.objects.get(id=self.id)
|
||||
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)
|
||||
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.home.edit_groups.set([board])
|
||||
self.home.view_groups.set([member, subscribers])
|
||||
self.home.save()
|
||||
self.make_page()
|
||||
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})
|
||||
|
||||
@cached_property
|
||||
def president(self):
|
||||
def president(self) -> Membership | None:
|
||||
"""Fetch the membership of the current president of this club."""
|
||||
return self.members.filter(
|
||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||
).first()
|
||||
@ -154,36 +152,18 @@ class Club(models.Model):
|
||||
def clean(self):
|
||||
self.check_loop()
|
||||
|
||||
def _change_unixname(self, old_name, new_name):
|
||||
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
|
||||
)
|
||||
def make_home(self) -> None:
|
||||
if self.home:
|
||||
return
|
||||
home_root = SithFile.objects.filter(parent=None, name="clubs").first()
|
||||
root = User.objects.filter(username="root").first()
|
||||
if home_root and root:
|
||||
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
||||
home.save()
|
||||
self.home = home
|
||||
self.save()
|
||||
|
||||
if self.home:
|
||||
self.home.name = new_name
|
||||
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()
|
||||
root = User.objects.filter(username="root").first()
|
||||
if home_root and root:
|
||||
home = SithFile(parent=home_root, name=self.unix_name, owner=root)
|
||||
home.save()
|
||||
self.home = home
|
||||
self.save()
|
||||
|
||||
def make_page(self):
|
||||
def make_page(self) -> None:
|
||||
root = User.objects.filter(username="root").first()
|
||||
if not self.page:
|
||||
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.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
|
||||
for membership in self.members.ongoing().select_related("user"):
|
||||
cache.delete(f"membership_{self.id}_{membership.user.id}")
|
||||
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
|
||||
|
||||
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."""
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
return user.is_board_member
|
||||
return user.is_root or user.is_board_member
|
||||
|
||||
def get_full_logo_url(self):
|
||||
return "https://%s%s" % (settings.SITH_URL, self.logo.url)
|
||||
def get_full_logo_url(self) -> str:
|
||||
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."""
|
||||
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."""
|
||||
sub = User.objects.filter(pk=user.pk).first()
|
||||
if sub is None:
|
||||
return False
|
||||
return sub.was_subscribed
|
||||
return user.was_subscribed
|
||||
|
||||
def get_membership_for(self, user: User) -> Membership | None:
|
||||
"""Return the current membership the given user.
|
||||
@ -262,9 +241,8 @@ class Club(models.Model):
|
||||
cache.set(f"membership_{self.id}_{user.id}", membership)
|
||||
return membership
|
||||
|
||||
def has_rights_in_club(self, user):
|
||||
m = self.get_membership_for(user)
|
||||
return m is not None and m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||
def has_rights_in_club(self, user: User) -> bool:
|
||||
return user.is_in_group(pk=self.board_group_id)
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
@ -283,42 +261,65 @@ class MembershipQuerySet(models.QuerySet):
|
||||
"""
|
||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
|
||||
def update(self, **kwargs):
|
||||
"""Refresh the cache for the elements of the queryset.
|
||||
def update(self, **kwargs) -> int:
|
||||
"""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)
|
||||
if nb_rows > 0:
|
||||
# if at least a row was affected, refresh the cache
|
||||
for membership in self.all():
|
||||
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,
|
||||
)
|
||||
if nb_rows == 0:
|
||||
# if no row was affected, no need to refresh the cache
|
||||
return 0
|
||||
|
||||
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,
|
||||
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.
|
||||
As this first query take place before the deletion operation,
|
||||
it will be performed even if the deletion fails.
|
||||
Be aware that this adds some db queries :
|
||||
|
||||
- 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"))
|
||||
nb_rows, _ = super().delete()
|
||||
memberships = set(self.all())
|
||||
nb_rows, rows_counts = super().delete()
|
||||
if nb_rows > 0:
|
||||
for club_id, user_id in ids:
|
||||
cache.set(f"membership_{club_id}_{user_id}", "not_member")
|
||||
Membership._remove_club_groups(memberships)
|
||||
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):
|
||||
@ -361,6 +362,13 @@ class Membership(models.Model):
|
||||
|
||||
objects = MembershipQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(end_date__gte=F("start_date")), name="end_after_start"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.club.name} - {self.user.username} "
|
||||
@ -370,7 +378,14 @@ class Membership(models.Model):
|
||||
|
||||
def save(self, *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:
|
||||
self._add_club_groups([self])
|
||||
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
|
||||
else:
|
||||
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):
|
||||
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."""
|
||||
if user.is_anonymous:
|
||||
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:
|
||||
"""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
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self._remove_club_groups([self])
|
||||
super().delete(*args, **kwargs)
|
||||
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):
|
||||
"""A Mailing list for a club.
|
||||
|
117
club/tests.py
117
club/tests.py
@ -21,6 +21,7 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localdate, localtime, now
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, Mailing, Membership
|
||||
@ -164,6 +165,27 @@ class TestMembershipQuerySet(TestClub):
|
||||
assert new_mem != "not_member"
|
||||
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):
|
||||
"""Test that the `delete` queryset properly invalidate cache."""
|
||||
mem_skia = self.skia.memberships.get(club=self.club)
|
||||
@ -182,6 +204,19 @@ class TestMembershipQuerySet(TestClub):
|
||||
)
|
||||
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):
|
||||
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.role == role
|
||||
assert membership.club.get_membership_for(user) == membership
|
||||
member_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
board_group = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
||||
assert user.is_in_group(name=member_group)
|
||||
assert user.is_in_group(name=board_group)
|
||||
assert user.is_in_group(pk=self.club.members_group_id)
|
||||
assert user.is_in_group(pk=self.club.board_group_id)
|
||||
|
||||
def assert_membership_ended_today(self, user: User):
|
||||
"""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 membership == new_mem
|
||||
|
||||
def test_delete_remove_from_meta_group(self):
|
||||
"""Test that when a club is deleted, all its members are removed from the
|
||||
associated metagroup.
|
||||
"""
|
||||
memberships = self.club.members.select_related("user")
|
||||
users = [membership.user for membership in memberships]
|
||||
meta_group = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
def test_remove_from_club_group(self):
|
||||
"""Test that when a membership ends, the user is removed from club groups."""
|
||||
user = baker.make(User)
|
||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||
assert user.groups.contains(self.club.members_group)
|
||||
assert user.groups.contains(self.club.board_group)
|
||||
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()
|
||||
for user in users:
|
||||
assert not user.is_in_group(name=meta_group)
|
||||
def test_add_to_club_group(self):
|
||||
"""Test that when a membership begins, the user is added to the club 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):
|
||||
"""Test that when a membership begins, the user is added to the meta group."""
|
||||
group_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
board_members = self.club.unix_name + settings.SITH_BOARD_SUFFIX
|
||||
assert not self.subscriber.is_in_group(name=group_members)
|
||||
assert not self.subscriber.is_in_group(name=board_members)
|
||||
Membership.objects.create(club=self.club, user=self.subscriber, role=3)
|
||||
assert self.subscriber.is_in_group(name=group_members)
|
||||
assert self.subscriber.is_in_group(name=board_members)
|
||||
|
||||
def test_remove_from_meta_group(self):
|
||||
"""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_change_position_in_club(self):
|
||||
"""Test that when moving from board to members, club group change"""
|
||||
membership = 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)
|
||||
membership.role = 1
|
||||
membership.save()
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_club_owner(self):
|
||||
"""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()
|
||||
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):
|
||||
"""Perform validation tests for MailingForm."""
|
||||
|
@ -71,14 +71,13 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
return self.object.get_display_name()
|
||||
|
||||
def get_list_of_tabs(self):
|
||||
tab_list = []
|
||||
tab_list.append(
|
||||
tab_list = [
|
||||
{
|
||||
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
|
||||
"slug": "infos",
|
||||
"name": _("Infos"),
|
||||
}
|
||||
)
|
||||
]
|
||||
if self.request.user.can_view(self.object):
|
||||
tab_list.append(
|
||||
{
|
||||
|
32
com/api.py
Normal file
32
com/api.py
Normal 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
9
com/apps.py
Normal 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
76
com/calendar.py
Normal 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
|
@ -17,11 +17,12 @@
|
||||
# details.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
|
10
com/signals.py
Normal file
10
com/signals.py
Normal 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()
|
194
com/static/bundled/com/components/ics-calendar-index.ts
Normal file
194
com/static/bundled/com/components/ics-calendar-index.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
101
com/static/com/components/ics-calendar.scss
Normal file
101
com/static/com/components/ics-calendar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
61
com/static/com/css/news-detail.scss
Normal file
61
com/static/com/css/news-detail.scss
Normal 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;
|
||||
}
|
||||
}
|
297
com/static/com/css/news-list.scss
Normal file
297
com/static/com/css/news-list.scss
Normal 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;
|
||||
}
|
||||
}
|
230
com/static/com/css/posters.scss
Normal file
230
com/static/com/css/posters.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -11,6 +11,11 @@
|
||||
{{ gen_news_metatags(news) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/news-detail.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p><a href="{{ url('com:news_list') }}">{% trans %}Back to news{% endtrans %}</a></p>
|
||||
<section id="news_details">
|
||||
|
@ -5,6 +5,15 @@
|
||||
{% trans %}News{% endtrans %}
|
||||
{% 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 %}
|
||||
{% if user.is_com_admin %}
|
||||
<div id="news_admin">
|
||||
@ -83,60 +92,58 @@
|
||||
</div>
|
||||
{% 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>
|
||||
<iframe
|
||||
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>
|
||||
<ics-calendar locale="{{ get_language() }}"></ics-calendar>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="right_column" class="news_column">
|
||||
<div id="agenda">
|
||||
<div id="agenda_title">{% trans %}Agenda{% endtrans %}</div>
|
||||
<div id="agenda_content">
|
||||
{% for d in NewsDate.objects.filter(end_date__gte=timezone.now(),
|
||||
news__is_moderated=True, news__type__in=["WEEKLY",
|
||||
"EVENT"]).order_by('start_date', 'end_date') %}
|
||||
<div class="agenda_item">
|
||||
<div class="agenda_date">
|
||||
<strong>{{ d.start_date|localtime|date('D d M Y') }}</strong>
|
||||
</div>
|
||||
<div class="agenda_time">
|
||||
<span>{{ d.start_date|localtime|time(DATETIME_FORMAT) }}</span> -
|
||||
<span>{{ d.end_date|localtime|time(DATETIME_FORMAT) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong><a href="{{ url('com:news_detail', news_id=d.news.id) }}">{{ d.news.title }}</a></strong>
|
||||
<a href="{{ d.news.club.get_absolute_url() }}">{{ d.news.club }}</a>
|
||||
</div>
|
||||
<div class="agenda_item_content">{{ d.news.summary|markdown }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div id="right_column">
|
||||
<div id="links">
|
||||
<h3>{% trans %}Links{% endtrans %}</h3>
|
||||
<div id="links_content">
|
||||
<h4>{% trans %}Our services{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li>
|
||||
<i class="fa-solid fa-graduation-cap fa-xl"></i>
|
||||
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
</li>
|
||||
<li>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<br>
|
||||
<h4>{% trans %}Social media{% endtrans %}</h4>
|
||||
<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 id="birthdays">
|
||||
<div id="birthdays_title">{% trans %}Birthdays{% endtrans %}</div>
|
||||
<h3>{% trans %}Birthdays{% endtrans %}</h3>
|
||||
<div id="birthdays_content">
|
||||
{%- if user.is_subscribed -%}
|
||||
{%- if user.was_subscribed -%}
|
||||
<ul class="birthdays_year">
|
||||
{%- for year, users in birthdays -%}
|
||||
<li>
|
||||
@ -150,14 +157,13 @@ type="EVENT").order_by('dates__start_date') %}
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{%- 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 -%}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
@ -10,6 +10,10 @@
|
||||
{% trans %}Poster{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="poster_list">
|
||||
|
||||
|
@ -5,6 +5,10 @@
|
||||
<script src="{{ static('com/js/poster_list.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="poster_list">
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>{% trans %}Slideshow{% endtrans %}</title>
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
122
com/tests/test_api.py
Normal file
122
com/tests/test_api.py
Normal 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()
|
@ -97,9 +97,7 @@ class TestCom(TestCase):
|
||||
response = self.client.get(reverse("core:index"))
|
||||
self.assertContains(
|
||||
response,
|
||||
text=html.escape(
|
||||
_("You need an up to date subscription to access this content")
|
||||
),
|
||||
text=html.escape(_("You need to subscribe to access this content")),
|
||||
)
|
||||
|
||||
def test_birthday_subscibed_user(self):
|
||||
@ -107,9 +105,16 @@ class TestCom(TestCase):
|
||||
|
||||
self.assertNotContains(
|
||||
response,
|
||||
text=html.escape(
|
||||
_("You need an up to date subscription to access this content")
|
||||
),
|
||||
text=html.escape(_("You need to subscribe 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")),
|
||||
)
|
||||
|
||||
|
@ -685,8 +685,12 @@ class PosterEditBaseView(UpdateView):
|
||||
|
||||
def get_initial(self):
|
||||
return {
|
||||
"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"),
|
||||
"date_begin": self.object.date_begin.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):
|
||||
|
@ -15,17 +15,32 @@
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.models import Group as AuthGroup
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
from core.models import Group, OperationLog, Page, SithFile, User
|
||||
from core.models import BanGroup, Group, OperationLog, Page, SithFile, User, UserBan
|
||||
|
||||
admin.site.unregister(AuthGroup)
|
||||
|
||||
|
||||
@admin.register(Group)
|
||||
class GroupAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "description", "is_meta")
|
||||
list_filter = ("is_meta",)
|
||||
list_display = ("name", "description", "is_manually_manageable")
|
||||
list_filter = ("is_manually_manageable",)
|
||||
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)
|
||||
@ -37,10 +52,24 @@ class UserAdmin(admin.ModelAdmin):
|
||||
"profile_pict",
|
||||
"avatar_pict",
|
||||
"scrub_pict",
|
||||
"user_permissions",
|
||||
"groups",
|
||||
)
|
||||
inlines = (UserBanInline,)
|
||||
search_fields = ["first_name", "last_name", "username"]
|
||||
|
||||
|
||||
@admin.register(UserBan)
|
||||
class UserBanAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "ban_group", "created_at", "expires_at")
|
||||
autocomplete_fields = ("user", "ban_group")
|
||||
|
||||
|
||||
@admin.register(Permission)
|
||||
class PermissionAdmin(admin.ModelAdmin):
|
||||
search_fields = ("codename",)
|
||||
|
||||
|
||||
@admin.register(Page)
|
||||
class PageAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "_full_name", "owner_group")
|
||||
|
@ -7,7 +7,7 @@ from model_bakery import seq
|
||||
from model_bakery.recipe import Recipe, related
|
||||
|
||||
from club.models import Membership
|
||||
from core.models import User
|
||||
from core.models import Group, User
|
||||
from subscription.models import Subscription
|
||||
|
||||
active_subscription = Recipe(
|
||||
@ -60,5 +60,6 @@ board_user = Recipe(
|
||||
first_name="AE",
|
||||
last_name=seq("member "),
|
||||
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."""
|
||||
|
@ -46,8 +46,9 @@ from accounting.models import (
|
||||
SimplifiedAccountingType,
|
||||
)
|
||||
from club.models import Club, Membership
|
||||
from com.calendar import IcsCalendar
|
||||
from com.models import News, NewsDate, Sith, Weekmail
|
||||
from core.models import Group, Page, PageRev, RealGroup, SithFile, User
|
||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from counter.models import Counter, Product, ProductType, StudentCard
|
||||
from election.models import Candidature, Election, ElectionList, Role
|
||||
@ -93,6 +94,7 @@ class Command(BaseCommand):
|
||||
Sith.objects.create(weekmail_destinations="etudiants@git.an personnel@git.an")
|
||||
Site.objects.create(domain=settings.SITH_URL, name=settings.SITH_NAME)
|
||||
groups = self._create_groups()
|
||||
self._create_ban_groups()
|
||||
|
||||
root = User.objects.create_superuser(
|
||||
id=0,
|
||||
@ -143,7 +145,9 @@ class Command(BaseCommand):
|
||||
Counter.objects.bulk_create(counters)
|
||||
bar_groups = []
|
||||
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(
|
||||
Counter.edit_groups.through(counter_id=bar_id, group=group)
|
||||
)
|
||||
@ -366,46 +370,42 @@ Welcome to the wiki page!
|
||||
parent=main_club,
|
||||
)
|
||||
|
||||
Membership.objects.bulk_create(
|
||||
[
|
||||
Membership(user=skia, club=main_club, role=3),
|
||||
Membership(
|
||||
user=comunity,
|
||||
club=bar_club,
|
||||
start_date=localdate(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
),
|
||||
Membership(
|
||||
user=sli,
|
||||
club=troll,
|
||||
role=9,
|
||||
description="Padawan Troll",
|
||||
start_date=localdate() - timedelta(days=17),
|
||||
),
|
||||
Membership(
|
||||
user=krophil,
|
||||
club=troll,
|
||||
role=10,
|
||||
description="Maitre Troll",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
),
|
||||
Membership(
|
||||
user=skia,
|
||||
club=troll,
|
||||
role=2,
|
||||
description="Grand Ancien Troll",
|
||||
start_date=localdate() - timedelta(days=400),
|
||||
end_date=localdate() - timedelta(days=86),
|
||||
),
|
||||
Membership(
|
||||
user=richard,
|
||||
club=troll,
|
||||
role=2,
|
||||
description="",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
end_date=localdate() - timedelta(days=100),
|
||||
),
|
||||
]
|
||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
||||
Membership.objects.create(
|
||||
user=comunity,
|
||||
club=bar_club,
|
||||
start_date=localdate(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=sli,
|
||||
club=troll,
|
||||
role=9,
|
||||
description="Padawan Troll",
|
||||
start_date=localdate() - timedelta(days=17),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=krophil,
|
||||
club=troll,
|
||||
role=10,
|
||||
description="Maitre Troll",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=skia,
|
||||
club=troll,
|
||||
role=2,
|
||||
description="Grand Ancien Troll",
|
||||
start_date=localdate() - timedelta(days=400),
|
||||
end_date=localdate() - timedelta(days=86),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=richard,
|
||||
club=troll,
|
||||
role=2,
|
||||
description="",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
end_date=localdate() - timedelta(days=100),
|
||||
)
|
||||
|
||||
p = ProductType.objects.create(name="Bières bouteilles")
|
||||
@ -594,7 +594,6 @@ Welcome to the wiki page!
|
||||
)
|
||||
|
||||
# Create an election
|
||||
ae_board_group = Group.objects.get(name=settings.SITH_MAIN_BOARD_GROUP)
|
||||
el = Election.objects.create(
|
||||
title="Élection 2017",
|
||||
description="La roue tourne",
|
||||
@ -604,7 +603,7 @@ Welcome to the wiki page!
|
||||
end_date="7942-06-12 10:28:45+01",
|
||||
)
|
||||
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.vote_groups.add(groups.subscribers)
|
||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||
@ -741,7 +740,7 @@ Welcome to the wiki page!
|
||||
NewsDate(
|
||||
news=n,
|
||||
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
|
||||
@ -767,8 +766,9 @@ Welcome to the wiki page!
|
||||
]
|
||||
)
|
||||
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(
|
||||
code="PA00",
|
||||
@ -889,7 +889,7 @@ Welcome to the wiki page!
|
||||
def _create_groups(self) -> PopulatedGroups:
|
||||
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)))
|
||||
# public has no permission.
|
||||
# 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(
|
||||
*list(
|
||||
perms.filter(
|
||||
@ -931,13 +933,17 @@ Welcome to the wiki page!
|
||||
).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(
|
||||
*list(
|
||||
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(
|
||||
*list(
|
||||
perms.filter(
|
||||
@ -946,16 +952,15 @@ Welcome to the wiki page!
|
||||
)
|
||||
)
|
||||
)
|
||||
Group.objects.create(name="Banned from buying alcohol")
|
||||
Group.objects.create(name="Banned from counters")
|
||||
Group.objects.create(name="Banned to subscribe")
|
||||
sas_admin = Group.objects.create(name="SAS admin")
|
||||
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
|
||||
sas_admin.permissions.add(
|
||||
*list(
|
||||
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(
|
||||
*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(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="pedagogy").values_list(
|
||||
@ -984,3 +991,8 @@ Welcome to the wiki page!
|
||||
sas_admin=sas_admin,
|
||||
pedagogy_admin=pedagogy_admin,
|
||||
)
|
||||
|
||||
def _create_ban_groups(self):
|
||||
BanGroup.objects.create(name="Banned from buying alcohol", description="")
|
||||
BanGroup.objects.create(name="Banned from counters", description="")
|
||||
BanGroup.objects.create(name="Banned to subscribe", description="")
|
||||
|
@ -173,7 +173,8 @@ class Command(BaseCommand):
|
||||
club=club,
|
||||
)
|
||||
)
|
||||
Membership.objects.bulk_create(memberships)
|
||||
memberships = Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
|
||||
def create_uvs(self):
|
||||
root = User.objects.get(username="root")
|
||||
|
@ -563,14 +563,21 @@ class Migration(migrations.Migration):
|
||||
fields=[],
|
||||
options={"proxy": True},
|
||||
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(
|
||||
name="RealGroup",
|
||||
fields=[],
|
||||
options={"proxy": True},
|
||||
bases=("core.group",),
|
||||
managers=[("objects", core.models.RealGroupManager())],
|
||||
managers=[("objects", django.contrib.auth.models.GroupManager())],
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="page", unique_together={("name", "parent")}
|
||||
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@ -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
|
||||
),
|
||||
]
|
@ -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
|
||||
),
|
||||
]
|
201
core/models.py
201
core/models.py
@ -36,14 +36,13 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser, UserManager
|
||||
from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
|
||||
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.core import validators
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
@ -58,33 +57,15 @@ if TYPE_CHECKING:
|
||||
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):
|
||||
"""Implement both RealGroups and Meta groups.
|
||||
"""Wrapper around django.auth.Group"""
|
||||
|
||||
Groups are sorted by their is_meta property
|
||||
"""
|
||||
|
||||
#: If False, this is a RealGroup
|
||||
is_meta = models.BooleanField(
|
||||
_("meta group status"),
|
||||
is_manually_manageable = models.BooleanField(
|
||||
_("Is manually manageable"),
|
||||
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.CharField(_("description"), max_length=60)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
description = models.TextField(_("description"))
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("core:group_list")
|
||||
@ -100,65 +81,6 @@ class Group(AuthGroup):
|
||||
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:
|
||||
start_year = settings.SITH_SCHOOL_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:
|
||||
group = Group.objects.filter(name=name).first()
|
||||
if group is not None:
|
||||
cache.set(f"sith_group_{group.id}", group)
|
||||
cache.set(f"sith_group_{group.name.replace(' ', '_')}", group)
|
||||
name = group.name.replace(" ", "_")
|
||||
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
|
||||
else:
|
||||
cache.set(f"sith_group_{pk_or_name}", "not_found")
|
||||
return group
|
||||
|
||||
|
||||
class BanGroup(AuthGroup):
|
||||
"""An anti-group, that removes permissions instead of giving them.
|
||||
|
||||
Users are linked to BanGroups through UserBan objects.
|
||||
|
||||
Example:
|
||||
```python
|
||||
user = User.objects.get(username="...")
|
||||
ban_group = BanGroup.objects.first()
|
||||
UserBan.objects.create(user=user, ban_group=ban_group, reason="...")
|
||||
|
||||
assert user.ban_groups.contains(ban_group)
|
||||
```
|
||||
"""
|
||||
|
||||
description = models.TextField(_("description"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("ban group")
|
||||
verbose_name_plural = _("ban groups")
|
||||
|
||||
|
||||
class UserQuerySet(models.QuerySet):
|
||||
def filter_inactive(self) -> Self:
|
||||
from counter.models import Refilling, Selling
|
||||
@ -261,7 +205,13 @@ class User(AbstractUser):
|
||||
"granted to each of their groups."
|
||||
),
|
||||
related_name="users",
|
||||
blank=True,
|
||||
)
|
||||
ban_groups = models.ManyToManyField(
|
||||
BanGroup,
|
||||
verbose_name=_("ban groups"),
|
||||
through="UserBan",
|
||||
help_text=_("The bans this user has received."),
|
||||
related_name="users",
|
||||
)
|
||||
home = models.OneToOneField(
|
||||
"SithFile",
|
||||
@ -438,18 +388,6 @@ class User(AbstractUser):
|
||||
return self.was_subscribed
|
||||
if group.id == settings.SITH_GROUP_ROOT_ID:
|
||||
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
|
||||
|
||||
@property
|
||||
@ -474,12 +412,11 @@ class User(AbstractUser):
|
||||
return any(g.id == root_id for g in self.cached_groups)
|
||||
|
||||
@cached_property
|
||||
def is_board_member(self):
|
||||
main_club = settings.SITH_MAIN_CLUB["unix_name"]
|
||||
return self.is_in_group(name=main_club + settings.SITH_BOARD_SUFFIX)
|
||||
def is_board_member(self) -> bool:
|
||||
return self.groups.filter(club_board=settings.SITH_MAIN_CLUB_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def can_read_subscription_history(self):
|
||||
def can_read_subscription_history(self) -> bool:
|
||||
if self.is_root or self.is_board_member:
|
||||
return True
|
||||
|
||||
@ -514,12 +451,12 @@ class User(AbstractUser):
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def is_banned_alcohol(self):
|
||||
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
def is_banned_alcohol(self) -> bool:
|
||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_ALCOHOL_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def is_banned_counter(self):
|
||||
return self.is_in_group(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||
def is_banned_counter(self) -> bool:
|
||||
return self.ban_groups.filter(id=settings.SITH_GROUP_BANNED_COUNTER_ID).exists()
|
||||
|
||||
@cached_property
|
||||
def age(self) -> int:
|
||||
@ -821,6 +758,52 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
return _("Visitor")
|
||||
|
||||
|
||||
class UserBan(models.Model):
|
||||
"""A ban of a user.
|
||||
|
||||
A user can be banned for a specific reason, for a specific duration.
|
||||
The expiration date is indicative, and the ban should be removed manually.
|
||||
"""
|
||||
|
||||
ban_group = models.ForeignKey(
|
||||
BanGroup,
|
||||
verbose_name=_("ban type"),
|
||||
related_name="user_bans",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
User, verbose_name=_("user"), related_name="bans", on_delete=models.CASCADE
|
||||
)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
expires_at = models.DateTimeField(
|
||||
_("expires at"),
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"When the ban should be removed. "
|
||||
"Currently, there is no automatic removal, so this is purely indicative. "
|
||||
"Automatic ban removal may be implemented later on."
|
||||
),
|
||||
)
|
||||
reason = models.TextField(_("reason"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("user ban")
|
||||
verbose_name_plural = _("user bans")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["ban_group", "user"], name="unique_ban_type_per_user"
|
||||
),
|
||||
models.CheckConstraint(
|
||||
check=Q(expires_at__gte=F("created_at")),
|
||||
name="user_ban_end_after_start",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Ban of user {self.user.id}"
|
||||
|
||||
|
||||
class Preferences(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User, related_name="_preferences", on_delete=models.CASCADE
|
||||
@ -943,7 +926,7 @@ class SithFile(models.Model):
|
||||
param="1",
|
||||
).save()
|
||||
|
||||
def is_owned_by(self, user):
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
if user.is_root:
|
||||
@ -958,7 +941,7 @@ class SithFile(models.Model):
|
||||
return True
|
||||
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"):
|
||||
return user.can_view(self.profile_of)
|
||||
if hasattr(self, "avatar_of"):
|
||||
|
@ -24,6 +24,9 @@ $black-color: hsl(0, 0%, 17%);
|
||||
|
||||
$faceblue: hsl(221, 44%, 41%);
|
||||
$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);
|
||||
|
||||
|
@ -29,7 +29,7 @@
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
&:hover {
|
||||
&.clickable:hover {
|
||||
background-color: darken($primary-neutral-light-color, 5%);
|
||||
}
|
||||
|
||||
|
5
core/static/core/devices.scss
Normal file
5
core/static/core/devices.scss
Normal file
@ -0,0 +1,5 @@
|
||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
||||
|
||||
$small-devices: 576px;
|
||||
$medium-devices: 768px;
|
||||
$large-devices: 992px;
|
@ -145,6 +145,7 @@ form {
|
||||
margin-top: .25rem;
|
||||
margin-bottom: .25rem;
|
||||
font-size: 80%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
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,
|
||||
> p,
|
||||
> div {
|
||||
|
@ -1,10 +1,6 @@
|
||||
@import "colors";
|
||||
@import "forms";
|
||||
|
||||
/*--------------------------MEDIA QUERY HELPERS------------------------*/
|
||||
$small-devices: 576px;
|
||||
$medium-devices: 768px;
|
||||
$large-devices: 992px;
|
||||
@import "devices";
|
||||
|
||||
/*--------------------------------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) {
|
||||
@ -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 {
|
||||
.journal-table {
|
||||
@ -1420,6 +822,10 @@ footer {
|
||||
margin-top: 3px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -108,7 +108,8 @@
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</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 %}
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
@ -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 %}
|
||||
|
||||
|
||||
|
@ -23,6 +23,9 @@
|
||||
<li><a href="{{ url('rootplace:operation_logs') }}">{% trans %}Operation logs{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('rootplace:delete_forum_messages') }}">{% trans %}Delete user's forum messages{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
{% if user.has_perm("core:view_userban") %}
|
||||
<li><a href="{{ url("rootplace:ban_list") }}">{% trans %}Bans{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
{% if user.can_create_subscription or user.is_root %}
|
||||
<li><a href="{{ url('subscription:subscription') }}">{% trans %}Subscriptions{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
|
@ -18,6 +18,7 @@ from smtplib import SMTPException
|
||||
|
||||
import freezegun
|
||||
import pytest
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.mail import EmailMessage
|
||||
@ -30,7 +31,7 @@ from model_bakery import baker
|
||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||
|
||||
from antispam.models import ToxicDomain
|
||||
from club.models import Membership
|
||||
from club.models import Club, Membership
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
@ -145,7 +146,7 @@ class TestUserRegistration:
|
||||
class TestUserLogin:
|
||||
@pytest.fixture()
|
||||
def user(self) -> User:
|
||||
return User.objects.first()
|
||||
return baker.make(User, password=make_password("plop"))
|
||||
|
||||
def test_login_fail(self, client, user):
|
||||
"""Should not login a user correctly."""
|
||||
@ -349,56 +350,35 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
from club.models import Club
|
||||
|
||||
cls.root_group = Group.objects.get(name="Root")
|
||||
cls.public = Group.objects.get(name="Public")
|
||||
cls.skia = User.objects.get(username="skia")
|
||||
cls.toto = User.objects.create(
|
||||
username="toto", first_name="a", last_name="b", email="a.b@toto.fr"
|
||||
)
|
||||
cls.public_group = Group.objects.get(name="Public")
|
||||
cls.public_user = baker.make(User)
|
||||
cls.subscribers = Group.objects.get(name="Subscribers")
|
||||
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
||||
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
||||
cls.com_admin = Group.objects.get(name="Communication admin")
|
||||
cls.counter_admin = Group.objects.get(name="Counter admin")
|
||||
cls.banned_alcohol = Group.objects.get(name="Banned from buying alcohol")
|
||||
cls.banned_counters = Group.objects.get(name="Banned from counters")
|
||||
cls.banned_subscription = Group.objects.get(name="Banned to subscribe")
|
||||
cls.sas_admin = Group.objects.get(name="SAS admin")
|
||||
cls.club = Club.objects.create(
|
||||
name="Fake Club",
|
||||
unix_name="fake-club",
|
||||
address="Fake address",
|
||||
)
|
||||
cls.club = baker.make(Club)
|
||||
cls.main_club = Club.objects.get(id=1)
|
||||
|
||||
def assert_in_public_group(self, user):
|
||||
assert user.is_in_group(pk=self.public.id)
|
||||
assert user.is_in_group(name=self.public.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
|
||||
assert user.is_in_group(pk=self.public_group.id)
|
||||
assert user.is_in_group(name=self.public_group.name)
|
||||
|
||||
def assert_only_in_public_group(self, user):
|
||||
self.assert_in_public_group(user)
|
||||
for group in (
|
||||
self.root_group,
|
||||
self.banned_counters,
|
||||
self.accounting_admin,
|
||||
self.sas_admin,
|
||||
self.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(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):
|
||||
"""Test that anonymous users are only in the public group."""
|
||||
@ -407,80 +387,80 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
def test_not_subscribed_user(self):
|
||||
"""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):
|
||||
"""Test that when neither the pk nor the name argument is given,
|
||||
the function raises a ValueError.
|
||||
"""
|
||||
with self.assertRaises(ValueError):
|
||||
self.toto.is_in_group()
|
||||
self.public_user.is_in_group()
|
||||
|
||||
def test_number_queries(self):
|
||||
"""Test that the number of db queries is stable
|
||||
and that less queries are made when making a new call.
|
||||
"""
|
||||
# make sure Skia is in at least one group
|
||||
self.skia.groups.add(Group.objects.first().pk)
|
||||
skia_groups = self.skia.groups.all()
|
||||
group_in = baker.make(Group)
|
||||
self.public_user.groups.add(group_in)
|
||||
|
||||
group_in = skia_groups.first()
|
||||
cache.clear()
|
||||
# Test when the user is in the group
|
||||
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):
|
||||
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 = Group.objects.exclude(pk__in=ids).first()
|
||||
group_not_in = baker.make(Group)
|
||||
cache.clear()
|
||||
# Test when the user is not in the group
|
||||
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):
|
||||
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):
|
||||
"""Test that when the membership of a user end,
|
||||
the cache is properly invalidated.
|
||||
"""
|
||||
membership = Membership.objects.create(
|
||||
club=self.club, user=self.toto, end_date=None
|
||||
)
|
||||
meta_groups_members = self.club.unix_name + settings.SITH_MEMBER_SUFFIX
|
||||
membership = baker.make(Membership, club=self.club, user=self.public_user)
|
||||
cache.clear()
|
||||
assert self.toto.is_in_group(name=meta_groups_members) is True
|
||||
assert membership == cache.get(f"membership_{self.club.id}_{self.toto.id}")
|
||||
self.club.get_membership_for(self.public_user) # this should populate the cache
|
||||
assert membership == cache.get(
|
||||
f"membership_{self.club.id}_{self.public_user.id}"
|
||||
)
|
||||
membership.end_date = now() - timedelta(minutes=5)
|
||||
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 self.toto.is_in_group(name=meta_groups_members) is False
|
||||
|
||||
def test_cache_properly_cleared_group(self):
|
||||
"""Test that when a user is removed from a group,
|
||||
the is_in_group_method return False when calling it again.
|
||||
"""
|
||||
# testing with pk
|
||||
self.toto.groups.add(self.com_admin.pk)
|
||||
assert self.toto.is_in_group(pk=self.com_admin.pk) is True
|
||||
self.public_user.groups.add(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is True
|
||||
|
||||
self.toto.groups.remove(self.com_admin.pk)
|
||||
assert self.toto.is_in_group(pk=self.com_admin.pk) is False
|
||||
self.public_user.groups.remove(self.com_admin.pk)
|
||||
assert self.public_user.is_in_group(pk=self.com_admin.pk) is False
|
||||
|
||||
# testing with name
|
||||
self.toto.groups.add(self.sas_admin.pk)
|
||||
assert self.toto.is_in_group(name="SAS admin") is True
|
||||
self.public_user.groups.add(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is True
|
||||
|
||||
self.toto.groups.remove(self.sas_admin.pk)
|
||||
assert self.toto.is_in_group(name="SAS admin") is False
|
||||
self.public_user.groups.remove(self.sas_admin.pk)
|
||||
assert self.public_user.is_in_group(name="SAS admin") is False
|
||||
|
||||
def test_not_existing_group(self):
|
||||
"""Test that searching for a not existing group
|
||||
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):
|
||||
|
@ -13,6 +13,7 @@
|
||||
#
|
||||
#
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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(
|
||||
request: HttpRequest,
|
||||
file_id: int,
|
||||
@ -66,28 +102,7 @@ def send_file(
|
||||
raise PermissionDenied
|
||||
name = getattr(f, file_attr).name
|
||||
|
||||
response = HttpResponse(
|
||||
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
|
||||
return send_raw_file(settings.MEDIA_ROOT / name)
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
|
@ -21,6 +21,7 @@
|
||||
#
|
||||
#
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from io import BytesIO
|
||||
|
||||
from captcha.fields import CaptchaField
|
||||
@ -37,14 +38,16 @@ from django.forms import (
|
||||
DateInput,
|
||||
DateTimeInput,
|
||||
TextInput,
|
||||
Widget,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from PIL import Image
|
||||
|
||||
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.views.widgets.select import (
|
||||
AutoCompleteSelect,
|
||||
@ -130,6 +133,23 @@ class SelectUser(TextInput):
|
||||
return output
|
||||
|
||||
|
||||
# Fields
|
||||
|
||||
|
||||
def validate_future_timestamp(value: date | datetime):
|
||||
if value <= now():
|
||||
raise ValueError(_("Ensure this timestamp is set in the future"))
|
||||
|
||||
|
||||
class FutureDateTimeField(forms.DateTimeField):
|
||||
"""A datetime field that accepts only future timestamps."""
|
||||
|
||||
default_validators = [validate_future_timestamp]
|
||||
|
||||
def widget_attrs(self, widget: Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(now())}
|
||||
|
||||
|
||||
# Forms
|
||||
|
||||
|
||||
@ -293,7 +313,7 @@ class UserGroupsForm(forms.ModelForm):
|
||||
required_css_class = "required"
|
||||
|
||||
groups = forms.ModelMultipleChoiceField(
|
||||
queryset=RealGroup.objects.all(),
|
||||
queryset=Group.objects.filter(is_manually_manageable=True),
|
||||
widget=CheckboxSelectMultiple,
|
||||
label=_("Groups"),
|
||||
required=False,
|
||||
|
@ -21,7 +21,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import ListView
|
||||
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.widgets.select import AutoCompleteSelectMultipleUser
|
||||
|
||||
@ -57,7 +57,8 @@ class EditMembersForm(forms.Form):
|
||||
class GroupListView(CanEditMixin, ListView):
|
||||
"""Displays the Group list."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
ordering = ["name"]
|
||||
template_name = "core/group_list.jinja"
|
||||
|
||||
@ -65,7 +66,8 @@ class GroupListView(CanEditMixin, ListView):
|
||||
class GroupEditView(CanEditMixin, UpdateView):
|
||||
"""Edit infos of a Group."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
pk_url_kwarg = "group_id"
|
||||
template_name = "core/group_edit.jinja"
|
||||
fields = ["name", "description"]
|
||||
@ -74,7 +76,8 @@ class GroupEditView(CanEditMixin, UpdateView):
|
||||
class GroupCreateView(CanCreateMixin, CreateView):
|
||||
"""Add a new Group."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
template_name = "core/create.jinja"
|
||||
fields = ["name", "description"]
|
||||
|
||||
@ -84,7 +87,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
||||
Allow adding and removing users from it.
|
||||
"""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
form_class = EditMembersForm
|
||||
pk_url_kwarg = "group_id"
|
||||
template_name = "core/group_detail.jinja"
|
||||
@ -118,7 +122,8 @@ class GroupTemplateView(CanEditMixin, DetailFormView):
|
||||
class GroupDeleteView(CanEditMixin, DeleteView):
|
||||
"""Delete a Group."""
|
||||
|
||||
model = RealGroup
|
||||
model = Group
|
||||
queryset = Group.objects.filter(is_manually_manageable=True)
|
||||
pk_url_kwarg = "group_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("core:group_list")
|
||||
|
@ -64,16 +64,20 @@ class PageView(CanViewMixin, DetailView):
|
||||
class PageHistView(CanViewMixin, DetailView):
|
||||
model = Page
|
||||
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):
|
||||
res = super().dispatch(request, *args, **kwargs)
|
||||
if self.object.need_club_redirection:
|
||||
return redirect("club:club_hist", club_id=self.object.club.id)
|
||||
return res
|
||||
page = self.get_object()
|
||||
if page.need_club_redirection:
|
||||
return redirect("club:club_hist", club_id=page.club.id)
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_object(self):
|
||||
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
|
||||
return self.page
|
||||
def get_object(self, *args, **kwargs):
|
||||
if not self._cached_object:
|
||||
self._cached_object = super().get_object()
|
||||
return self._cached_object
|
||||
|
||||
|
||||
class PageRevView(CanViewMixin, DetailView):
|
||||
|
@ -154,6 +154,9 @@ class CounterEditForm(forms.ModelForm):
|
||||
|
||||
|
||||
class ProductEditForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = [
|
||||
@ -171,6 +174,12 @@ class ProductEditForm(forms.ModelForm):
|
||||
"tray",
|
||||
"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 = {
|
||||
"product_type": AutoCompleteSelect,
|
||||
"buying_groups": AutoCompleteSelectMultipleGroup,
|
||||
|
@ -532,9 +532,12 @@ class Counter(models.Model):
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
def can_be_viewed_by(self, user: User) -> bool:
|
||||
if self.type == "BAR":
|
||||
return True
|
||||
return user.is_board_member or user in self.sellers.all()
|
||||
return (
|
||||
self.type == "BAR"
|
||||
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:
|
||||
"""Generate a new token for this counter."""
|
||||
|
@ -31,7 +31,7 @@ from model_bakery import baker
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||
from core.models import Group, User
|
||||
from core.models import BanGroup, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.models import (
|
||||
Counter,
|
||||
@ -229,11 +229,11 @@ class TestCounterClick(TestFullClickBase):
|
||||
cls.set_age(cls.banned_alcohol_customer, 20)
|
||||
cls.set_age(cls.underage_customer, 17)
|
||||
|
||||
cls.banned_alcohol_customer.groups.add(
|
||||
Group.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
cls.banned_alcohol_customer.ban_groups.add(
|
||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_ALCOHOL_ID)
|
||||
)
|
||||
cls.banned_counter_customer.groups.add(
|
||||
Group.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||
cls.banned_counter_customer.ban_groups.add(
|
||||
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
|
||||
)
|
||||
|
||||
cls.beer = product_recipe.make(
|
||||
|
@ -38,10 +38,20 @@ l'éditer et enfin le compiler au format binaire pour qu'il soit lu par le serve
|
||||
|
||||
```bash
|
||||
# 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
|
||||
./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
|
||||
|
7
docs/reference/rootplace/forms.md
Normal file
7
docs/reference/rootplace/forms.md
Normal file
@ -0,0 +1,7 @@
|
||||
::: rootplace.forms
|
||||
handler: python
|
||||
options:
|
||||
members:
|
||||
- MergeForm
|
||||
- SelectUserForm
|
||||
- BanForm
|
@ -1 +1,12 @@
|
||||
::: rootplace.views
|
||||
::: rootplace.views
|
||||
handler: python
|
||||
options:
|
||||
members:
|
||||
- merge_users
|
||||
- delete_all_forum_user_messages
|
||||
- MergeUsersView
|
||||
- DeleteAllForumUserMessagesView
|
||||
- OperationLogListView
|
||||
- BanView
|
||||
- BanCreateView
|
||||
- BanDeleteView
|
@ -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
|
||||
|
||||
Les deux types de groupes sont stockés dans la même table
|
||||
en base de données, et ne sont différenciés que par un attribut `is_meta`.
|
||||
Un groupe est constitué des informations suivantes :
|
||||
|
||||
### 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,
|
||||
il a été créé une classe proxy
|
||||
(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).
|
||||
Si un groupe est gérable manuellement, alors les administrateurs du site
|
||||
auront le droit d'assigner des utilisateurs à ce groupe depuis l'interface dédiée.
|
||||
|
||||
### 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,
|
||||
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 :
|
||||
## Les principaux groupes utilisés
|
||||
|
||||
- club-bureau : contient tous les membres d'un club **au dessus**
|
||||
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) :
|
||||
Les groupes les plus notables gérables par les administrateurs du site sont :
|
||||
|
||||
- `Root` : administrateur global du site
|
||||
- `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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
@ -81,7 +81,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
libffi-dev python-dev-is-python3 pkg-config \
|
||||
gettext git pipx
|
||||
|
||||
pipx install poetry
|
||||
pipx install poetry==1.8.5
|
||||
```
|
||||
|
||||
=== "Arch Linux"
|
||||
@ -101,7 +101,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
|
||||
```bash
|
||||
brew install git python pipx npm
|
||||
pipx install poetry
|
||||
pipx install poetry==1.8.5
|
||||
|
||||
# Pour bien configurer gettext
|
||||
brew link gettext # (suivez bien les instructions supplémentaires affichées)
|
||||
|
@ -4,7 +4,7 @@
|
||||
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
|
||||
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
|
||||
|
||||
|
@ -11,8 +11,6 @@ class TestElection(TestCase):
|
||||
def setUpTestData(cls):
|
||||
cls.election = Election.objects.first()
|
||||
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.subscriber = User.objects.get(username="subscriber")
|
||||
cls.public = User.objects.get(username="public")
|
||||
|
@ -118,18 +118,26 @@ class Command(BaseCommand):
|
||||
self.make_important_citizen(u)
|
||||
|
||||
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.
|
||||
Don't create the meta groups (:class:`core.models.MetaGroup`)
|
||||
nor the pages of the clubs (:class:`core.models.Page`).
|
||||
Don't create the pages of the clubs ([core.models.Page][]).
|
||||
"""
|
||||
self.clubs = []
|
||||
for i in range(self.NB_CLUBS):
|
||||
self.clubs.append(Club(unix_name=f"galaxy-club-{i}", name=f"club-{i}"))
|
||||
# We don't need to create corresponding groups here, as the Galaxy doesn't care about them
|
||||
Club.objects.bulk_create(self.clubs)
|
||||
self.clubs = list(Club.objects.filter(unix_name__startswith="galaxy-").all())
|
||||
# dummy groups.
|
||||
# the galaxy doesn't care about the club groups,
|
||||
# but it's necessary to add them nonetheless in order
|
||||
# not to break the integrity constraints
|
||||
self.clubs = Club.objects.bulk_create(
|
||||
[
|
||||
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):
|
||||
"""Create all the users and store them in `self.users` for fast access later.
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"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"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -17,168 +17,172 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\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"
|
||||
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"
|
||||
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"
|
||||
msgstr "Aucun résultat trouvé"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:38
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Heading"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:44
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Italic"
|
||||
msgstr "Italique"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:50
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Bold"
|
||||
msgstr "Gras"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:56
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Strikethrough"
|
||||
msgstr "Barré"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:65
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Underline"
|
||||
msgstr "Souligné"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:74
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Superscript"
|
||||
msgstr "Exposant"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:83
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Subscript"
|
||||
msgstr "Indice"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:89
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:96
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Quote"
|
||||
msgstr "Citation"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:102
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Unordered list"
|
||||
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"
|
||||
msgstr "Liste ordonnée"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:115
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Insert link"
|
||||
msgstr "Insérer lien"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:121
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Insert 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"
|
||||
msgstr "Insérer tableau"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:134
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Clean block"
|
||||
msgstr "Nettoyer bloc"
|
||||
|
||||
#: core/static/bundled/core/components/easymde-index.ts:141
|
||||
#: core/static/bundled/core/components/easymde-index.ts
|
||||
msgid "Toggle preview"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
|
||||
#: core/static/bundled/user/pictures-index.js:76
|
||||
#: core/static/bundled/user/pictures-index.js
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
|
||||
#: core/static/user/js/user_edit.js:91
|
||||
#: core/static/user/js/user_edit.js
|
||||
#, javascript-format
|
||||
msgid "captured.%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"
|
||||
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."
|
||||
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"
|
||||
msgstr "nom"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:43
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "product type"
|
||||
msgstr "type de produit"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:45
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "limit age"
|
||||
msgstr "limite d'âge"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:46
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "purchase price"
|
||||
msgstr "prix d'achat"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:47
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "selling price"
|
||||
msgstr "prix de vente"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:48
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "archived"
|
||||
msgstr "archivé"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:125
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "Uncategorized"
|
||||
msgstr "Sans catégorie"
|
||||
|
||||
#: counter/static/bundled/counter/product-list-index.ts:143
|
||||
#: counter/static/bundled/counter/product-list-index.ts
|
||||
msgid "products.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!"
|
||||
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
|
||||
msgid "Product type reorganisation failed with status 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"
|
||||
msgstr "Valeur incorrecte"
|
||||
|
||||
#: sas/static/bundled/sas/viewer-index.ts:271
|
||||
#: sas/static/bundled/sas/viewer-index.ts
|
||||
msgid "Couldn't moderate picture"
|
||||
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"
|
||||
msgstr "Il n'a pas été possible de supprimer l'image"
|
||||
|
@ -127,6 +127,7 @@ nav:
|
||||
- reference/pedagogy/schemas.md
|
||||
- rootplace:
|
||||
- reference/rootplace/models.md
|
||||
- reference/rootplace/forms.md
|
||||
- reference/rootplace/views.md
|
||||
- sas:
|
||||
- reference/sas/models.md
|
||||
|
52
package-lock.json
generated
52
package-lock.json
generated
@ -11,6 +11,10 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@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",
|
||||
"@sentry/browser": "^8.34.0",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
@ -2384,6 +2388,39 @@
|
||||
"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": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.4.0.tgz",
|
||||
@ -4162,6 +4199,12 @@
|
||||
"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": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/import-from-esm/-/import-from-esm-1.3.4.tgz",
|
||||
@ -4924,6 +4967,15 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
@ -19,7 +19,8 @@
|
||||
"#openapi": "./staticfiles/generated/openapi/index.ts",
|
||||
"#core:*": "./core/static/bundled/*",
|
||||
"#pedagogy:*": "./pedagogy/static/bundled/*",
|
||||
"#counter:*": "./counter/static/bundled/*"
|
||||
"#counter:*": "./counter/static/bundled/*",
|
||||
"#com:*": "./com/static/bundled/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@ -36,6 +37,10 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@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",
|
||||
"@sentry/browser": "^8.34.0",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
|
33
poetry.lock
generated
33
poetry.lock
generated
@ -931,6 +931,23 @@ files = [
|
||||
{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]]
|
||||
name = "identify"
|
||||
version = "2.6.3"
|
||||
@ -1883,6 +1900,20 @@ pyyaml = "*"
|
||||
[package.extras]
|
||||
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]]
|
||||
name = "pytest"
|
||||
version = "8.3.4"
|
||||
@ -2724,4 +2755,4 @@ filelock = ">=3.4"
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "5836c1a8ad42645d7d045194c8c371754b19957ebdcd2aaa902a2fb3dc97cc53"
|
||||
content-hash = "7f348f74a05c27e29aaaf25a5584bba9b416f42c3f370db234dd69e5e10dc8df"
|
||||
|
@ -45,6 +45,7 @@ Sphinx = "^5" # Needed for building xapian
|
||||
tomli = "^2.2.1"
|
||||
django-honeypot = "^1.2.1"
|
||||
pydantic-extra-types = "^2.10.1"
|
||||
ical = "^8.3.0"
|
||||
|
||||
[tool.poetry.group.prod.dependencies]
|
||||
# deps used in prod, but unnecessary for development
|
||||
|
49
rootplace/forms.py
Normal file
49
rootplace/forms.py
Normal 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,
|
||||
}
|
62
rootplace/templates/rootplace/userban.jinja
Normal file
62
rootplace/templates/rootplace/userban.jinja
Normal 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 %}
|
0
rootplace/tests/__init__.py
Normal file
0
rootplace/tests/__init__.py
Normal file
57
rootplace/tests/test_ban.py
Normal file
57
rootplace/tests/test_ban.py
Normal 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()
|
@ -25,6 +25,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from rootplace.views import (
|
||||
BanCreateView,
|
||||
BanDeleteView,
|
||||
BanView,
|
||||
DeleteAllForumUserMessagesView,
|
||||
MergeUsersView,
|
||||
OperationLogListView,
|
||||
@ -38,4 +41,7 @@ urlpatterns = [
|
||||
name="delete_forum_messages",
|
||||
),
|
||||
path("logs/", OperationLogListView.as_view(), name="operation_logs"),
|
||||
path("ban/", BanView.as_view(), name="ban_list"),
|
||||
path("ban/new", BanCreateView.as_view(), name="ban_create"),
|
||||
path("ban/<int:ban_id>/remove/", BanDeleteView.as_view(), name="ban_remove"),
|
||||
]
|
||||
|
@ -23,20 +23,19 @@
|
||||
#
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import ListView
|
||||
from django.views.generic.edit import FormView
|
||||
from django.views.generic import DeleteView, ListView
|
||||
from django.views.generic.edit import CreateView, FormView
|
||||
|
||||
from core.models import OperationLog, SithFile, User
|
||||
from core.models import OperationLog, SithFile, User, UserBan
|
||||
from core.views import CanEditPropMixin
|
||||
from core.views.widgets.select import AutoCompleteSelectUser
|
||||
from counter.models import Customer
|
||||
from forum.models import ForumMessageMeta
|
||||
from rootplace.forms import BanForm, MergeForm, SelectUserForm
|
||||
|
||||
|
||||
def __merge_subscriptions(u1: User, u2: User):
|
||||
@ -155,33 +154,6 @@ def delete_all_forum_user_messages(
|
||||
ForumMessageMeta(message=message, user=moderator, action="DELETE").save()
|
||||
|
||||
|
||||
class MergeForm(forms.Form):
|
||||
user1 = forms.ModelChoiceField(
|
||||
label=_("User that will be kept"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
user2 = forms.ModelChoiceField(
|
||||
label=_("User that will be deleted"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
|
||||
class SelectUserForm(forms.Form):
|
||||
user = forms.ModelChoiceField(
|
||||
label=_("User to be selected"),
|
||||
help_text=None,
|
||||
required=True,
|
||||
widget=AutoCompleteSelectUser,
|
||||
queryset=User.objects.all(),
|
||||
)
|
||||
|
||||
|
||||
class MergeUsersView(FormView):
|
||||
template_name = "rootplace/merge.jinja"
|
||||
form_class = MergeForm
|
||||
@ -233,3 +205,39 @@ class OperationLogListView(ListView, CanEditPropMixin):
|
||||
template_name = "rootplace/logs.jinja"
|
||||
ordering = ["-date"]
|
||||
paginate_by = 100
|
||||
|
||||
|
||||
class BanView(PermissionRequiredMixin, ListView):
|
||||
"""[UserBan][core.models.UserBan] management view.
|
||||
|
||||
Displays :
|
||||
|
||||
- the list of active bans with their main information,
|
||||
with a link to [BanDeleteView][rootplace.views.BanDeleteView] for each one
|
||||
- a link which redirects to [BanCreateView][rootplace.views.BanCreateView]
|
||||
"""
|
||||
|
||||
permission_required = "core.view_userban"
|
||||
template_name = "rootplace/userban.jinja"
|
||||
queryset = UserBan.objects.select_related("user", "user__profile_pict", "ban_group")
|
||||
ordering = "created_at"
|
||||
context_object_name = "user_bans"
|
||||
|
||||
|
||||
class BanCreateView(PermissionRequiredMixin, CreateView):
|
||||
"""[UserBan][core.models.UserBan] creation view."""
|
||||
|
||||
permission_required = "core.add_userban"
|
||||
form_class = BanForm
|
||||
template_name = "core/create.jinja"
|
||||
success_url = reverse_lazy("rootplace:ban_list")
|
||||
|
||||
|
||||
class BanDeleteView(PermissionRequiredMixin, DeleteView):
|
||||
"""[UserBan][core.models.UserBan] deletion view."""
|
||||
|
||||
permission_required = "core.delete_userban"
|
||||
pk_url_kwarg = "ban_id"
|
||||
model = UserBan
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("rootplace:ban_list")
|
||||
|
@ -163,6 +163,7 @@ TEMPLATES = [
|
||||
"ProductType": "counter.models.ProductType",
|
||||
"timezone": "django.utils.timezone",
|
||||
"get_sith": "com.views.sith",
|
||||
"get_language": "django.utils.translation.get_language",
|
||||
},
|
||||
"bytecode_cache": {
|
||||
"name": "default",
|
||||
@ -362,12 +363,13 @@ SITH_GROUP_OLD_SUBSCRIBERS_ID = 4
|
||||
SITH_GROUP_ACCOUNTING_ADMIN_ID = 5
|
||||
SITH_GROUP_COM_ADMIN_ID = 6
|
||||
SITH_GROUP_COUNTER_ADMIN_ID = 7
|
||||
SITH_GROUP_BANNED_ALCOHOL_ID = 8
|
||||
SITH_GROUP_BANNED_COUNTER_ID = 9
|
||||
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 10
|
||||
SITH_GROUP_SAS_ADMIN_ID = 11
|
||||
SITH_GROUP_FORUM_ADMIN_ID = 12
|
||||
SITH_GROUP_PEDAGOGY_ADMIN_ID = 13
|
||||
SITH_GROUP_SAS_ADMIN_ID = 8
|
||||
SITH_GROUP_FORUM_ADMIN_ID = 9
|
||||
SITH_GROUP_PEDAGOGY_ADMIN_ID = 10
|
||||
|
||||
SITH_GROUP_BANNED_ALCOHOL_ID = 11
|
||||
SITH_GROUP_BANNED_COUNTER_ID = 12
|
||||
SITH_GROUP_BANNED_SUBSCRIPTION_ID = 13
|
||||
|
||||
SITH_CLUB_REFOUND_ID = 89
|
||||
SITH_COUNTER_REFOUND_ID = 38
|
||||
|
@ -16,7 +16,8 @@
|
||||
"#openapi": ["./staticfiles/generated/openapi/index.ts"],
|
||||
"#core:*": ["./core/static/bundled/*"],
|
||||
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
|
||||
"#counter:*": ["./counter/static/bundled/*"]
|
||||
"#counter:*": ["./counter/static/bundled/*"],
|
||||
"#com:*": ["./com/static/bundled/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user