mirror of
https://github.com/ae-utbm/sith.git
synced 2026-04-20 10:18:23 +00:00
Compare commits
92 Commits
photos
...
club-role-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3bac9f93 | ||
|
|
25c797fff8 | ||
|
|
eac730e7a9 | ||
|
|
b02ec4e812 | ||
|
|
0bf86583a9 | ||
|
|
3e9ed4e1ef | ||
|
|
a8904c51d7 | ||
|
|
636cb3f263 | ||
|
|
9b862cfefc | ||
|
|
937f1e2535 | ||
|
|
619136bbb9 | ||
|
|
0e1981408e | ||
|
|
1d43703b33 | ||
|
|
55f4306527 | ||
|
|
8799928c55 | ||
|
|
a4d4b3459a | ||
|
|
e39615ce74 | ||
|
|
d708815ef9 | ||
|
|
0e119f8b08 | ||
| 1d525ca6d4 | |||
|
4dea60ac66
|
|||
| 30df859039 | |||
|
9c3f846f98
|
|||
| 64ebe30f5e | |||
|
744ea20c33
|
|||
|
3e0a1c7334
|
|||
|
ef036c135c
|
|||
| 366d6c7f03 | |||
| 2151bbf4c7 | |||
|
64b3acff07
|
|||
|
6ef8b6b159
|
|||
|
39dee782cc
|
|||
|
|
bda65f39af | ||
|
|
dfe884484d | ||
|
a0cce91bd5
|
|||
|
|
0f00c91b59 | ||
|
|
b5d8db0187 | ||
|
|
26178f10c5 | ||
|
|
842ae5615d | ||
|
|
5cd748e313 | ||
|
|
2944804074 | ||
| fcce34fde5 | |||
|
a213bc058d
|
|||
|
71bfdf68b7
|
|||
|
2a9bb46e97
|
|||
|
efdf71d69e
|
|||
|
3bc4f1300e
|
|||
|
|
182cdbe590 | ||
|
|
ac33a5e6b2 | ||
|
|
068bb9ab83 | ||
|
|
f9910c3360 | ||
|
|
f0f8cc5604 | ||
|
|
2a8e810ad0 | ||
|
|
739a1bba47 | ||
|
|
180852a598 | ||
|
|
c3989a0016 | ||
|
|
435c8f9612 | ||
|
|
3d7f57b8da | ||
|
|
ffa0b94408 | ||
|
|
22a1f4ba07 | ||
|
|
76396cdeb0 | ||
|
|
1c0b89bfc7 | ||
|
|
d374ea9651 | ||
|
|
10a4e71b7a | ||
|
|
f1a60e589a | ||
|
|
00acda7ba3 | ||
|
|
1686a9da87 | ||
|
|
83255945c4 | ||
|
|
b4a6b6961b | ||
|
|
0f0702825e | ||
|
|
b74b1ac691 | ||
|
|
33d4a99a2c | ||
|
|
c154b311c3 | ||
|
|
fb8da93c68 | ||
|
|
1845a7cbcf | ||
|
|
f17f17d8de | ||
|
|
7bb3d064ee | ||
|
|
4f84ec09d7 | ||
|
|
7e649b40c5 | ||
|
|
296feb6e32 | ||
|
|
30663d87a4 | ||
|
|
b5ff9b4c13 | ||
|
|
e2f6671ad0 | ||
|
|
9a67926a49 | ||
|
|
78c373f84e | ||
|
|
a7c8b318bd | ||
|
|
1701ab5f33 | ||
|
|
09a98db786 | ||
|
|
84ed180c1e | ||
|
|
52759764a1 | ||
|
|
be1563f46f | ||
|
|
5d3d44ec67 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,6 +24,9 @@ node_modules/
|
||||
# compiled documentation
|
||||
site/
|
||||
|
||||
# rollup-bundle-visualizer report
|
||||
.bundle-size-report.html
|
||||
|
||||
### Redis ###
|
||||
|
||||
# Ignore redis binary dump (dump.rdb) files
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.0
|
||||
rev: v0.15.5
|
||||
hooks:
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
@@ -12,7 +12,7 @@ repos:
|
||||
rev: v0.6.1
|
||||
hooks:
|
||||
- id: biome-check
|
||||
additional_dependencies: ["@biomejs/biome@2.3.14"]
|
||||
additional_dependencies: ["@biomejs/biome@2.4.6"]
|
||||
- repo: https://github.com/rtts/djhtml
|
||||
rev: 3.0.10
|
||||
hooks:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**/static/**"]
|
||||
"includes": ["**/static/**", "vite.config.mts"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
#
|
||||
#
|
||||
from django.contrib import admin
|
||||
from django.forms.models import ModelForm
|
||||
from django.http import HttpRequest
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
|
||||
|
||||
@admin.register(Club)
|
||||
@@ -29,6 +31,31 @@ class ClubAdmin(admin.ModelAdmin):
|
||||
"page",
|
||||
)
|
||||
|
||||
def save_model(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
obj: Club,
|
||||
form: ModelForm,
|
||||
change: bool, # noqa: FBT001
|
||||
):
|
||||
super().save_model(request, obj, form, change)
|
||||
if not change:
|
||||
obj.create_default_roles()
|
||||
|
||||
|
||||
@admin.register(ClubRole)
|
||||
class ClubRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "club", "is_board", "is_presidency")
|
||||
search_fields = ("name",)
|
||||
autocomplete_fields = ("club",)
|
||||
list_select_related = ("club",)
|
||||
list_filter = (
|
||||
"is_board",
|
||||
"is_presidency",
|
||||
("club", admin.RelatedOnlyFieldListFilter),
|
||||
)
|
||||
show_facets = admin.ModelAdmin.show_facets.ALWAYS
|
||||
|
||||
|
||||
@admin.register(Membership)
|
||||
class MembershipAdmin(admin.ModelAdmin):
|
||||
|
||||
36
club/api.py
36
club/api.py
@@ -6,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, HasPerm
|
||||
from api.permissions import CanView, HasPerm
|
||||
from club.models import Club, Membership
|
||||
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
|
||||
from club.schemas import (
|
||||
ClubSchema,
|
||||
ClubSearchFilterSchema,
|
||||
SimpleClubSchema,
|
||||
UserMembershipSchema,
|
||||
)
|
||||
from core.models import User
|
||||
|
||||
|
||||
@api_controller("/club")
|
||||
@@ -16,13 +22,11 @@ class ClubController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
url_name="search_club",
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
||||
return filters.filter(Club.objects.all())
|
||||
return filters.filter(Club.objects.order_by("name")).values()
|
||||
|
||||
@route.get(
|
||||
"/{int:club_id}",
|
||||
@@ -33,8 +37,28 @@ class ClubController(ControllerBase):
|
||||
)
|
||||
def fetch_club(self, club_id: int):
|
||||
prefetch = Prefetch(
|
||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
||||
"members",
|
||||
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
||||
)
|
||||
return self.get_object_or_exception(
|
||||
Club.objects.prefetch_related(prefetch), id=club_id
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/user/{int:user_id}/club")
|
||||
class UserClubController(ControllerBase):
|
||||
@route.get(
|
||||
"",
|
||||
response=list[UserMembershipSchema],
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[CanView],
|
||||
url_name="fetch_user_clubs",
|
||||
)
|
||||
def fetch_user_clubs(self, user_id: int):
|
||||
"""Get all the active memberships of the given user."""
|
||||
user = self.get_object_or_exception(User, id=user_id)
|
||||
return (
|
||||
Membership.objects.ongoing()
|
||||
.filter(user=user)
|
||||
.select_related("club", "user", "role")
|
||||
)
|
||||
|
||||
130
club/forms.py
130
club/forms.py
@@ -23,13 +23,12 @@
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
@@ -215,9 +214,7 @@ class ClubOldMemberForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["members_old"].queryset = (
|
||||
Membership.objects.ongoing().filter(club=club).editable_by(user)
|
||||
)
|
||||
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
||||
|
||||
|
||||
class ClubMemberForm(forms.ModelForm):
|
||||
@@ -235,19 +232,14 @@ class ClubMemberForm(forms.ModelForm):
|
||||
self.request_user = request_user
|
||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["role"].required = True
|
||||
self.fields["role"].choices = [
|
||||
(value, name)
|
||||
for value, name in settings.SITH_CLUB_ROLES.items()
|
||||
if value <= self.max_available_role
|
||||
]
|
||||
self.fields["role"].queryset = self.available_roles
|
||||
self.instance.club = club
|
||||
|
||||
@property
|
||||
def max_available_role(self):
|
||||
"""The greatest role that will be obtainable with this form."""
|
||||
def available_roles(self) -> QuerySet[ClubRole]:
|
||||
"""The roles that will be obtainable with this form."""
|
||||
# this is unreachable, because it will be overridden by subclasses
|
||||
return -1 # pragma: no cover
|
||||
return ClubRole.objects.none() # pragma: no cover
|
||||
|
||||
|
||||
class ClubAddMemberForm(ClubMemberForm):
|
||||
@@ -258,21 +250,22 @@ class ClubAddMemberForm(ClubMemberForm):
|
||||
widgets = {"user": AutoCompleteSelectUser}
|
||||
|
||||
@cached_property
|
||||
def max_available_role(self):
|
||||
"""The greatest role that will be obtainable with this form.
|
||||
def available_roles(self):
|
||||
"""The roles that will be obtainable with this form.
|
||||
|
||||
Admins and the club president can attribute any role.
|
||||
Board members can attribute roles lower than their own.
|
||||
Other users cannot attribute roles with this form
|
||||
"""
|
||||
qs = self.club.roles.filter(is_active=True)
|
||||
if self.request_user.has_perm("club.add_membership"):
|
||||
return settings.SITH_CLUB_ROLES_ID["President"]
|
||||
return qs.all()
|
||||
membership = self.request_user_membership
|
||||
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
return -1
|
||||
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
|
||||
return membership.role
|
||||
return membership.role - 1
|
||||
if membership is None or not membership.role.is_board:
|
||||
return ClubRole.objects.none()
|
||||
if membership.role.is_presidency:
|
||||
return qs.all()
|
||||
return qs.above_instance(membership.role)
|
||||
|
||||
def clean_user(self):
|
||||
"""Check that the user is not trying to add a user already in the club.
|
||||
@@ -296,13 +289,11 @@ class JoinClubForm(ClubMemberForm):
|
||||
|
||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
||||
# this form doesn't manage the user who will join the club,
|
||||
# so we must set this here to avoid errors
|
||||
self.instance.user = self.request_user
|
||||
|
||||
@cached_property
|
||||
def max_available_role(self):
|
||||
return settings.SITH_MAXIMUM_FREE_ROLE
|
||||
def available_roles(self):
|
||||
return self.club.roles.filter(is_board=False, is_active=True)
|
||||
|
||||
def clean(self):
|
||||
"""Check that the user is subscribed and isn't already in the club."""
|
||||
@@ -315,3 +306,88 @@ class JoinClubForm(ClubMemberForm):
|
||||
_("You are already a member of this club"), code="invalid"
|
||||
)
|
||||
return super().clean()
|
||||
|
||||
|
||||
class ClubSearchForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["name"]
|
||||
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
|
||||
|
||||
club_status = forms.NullBooleanField(
|
||||
label=_("Club status"),
|
||||
widget=forms.RadioSelect(
|
||||
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
|
||||
),
|
||||
initial=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, data: dict | None = None, **kwargs):
|
||||
super().__init__(*args, data=data, **kwargs)
|
||||
if data is not None and "club_status" not in data:
|
||||
# if the key is missing, it is considered as None,
|
||||
# even though we want the default True value to be applied in such a case
|
||||
# so we enforce it.
|
||||
self.fields["club_status"].value = True
|
||||
self.fields["name"].required = False
|
||||
|
||||
|
||||
class ClubRoleForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
|
||||
widgets = {
|
||||
"is_presidency": forms.HiddenInput(),
|
||||
"is_board": forms.HiddenInput(),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if "ORDER" in cleaned_data:
|
||||
self.instance.order = cleaned_data["ORDER"]
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ClubRoleCreateForm(forms.ModelForm):
|
||||
"""Form to create a club role.
|
||||
|
||||
Notes:
|
||||
For UX purposes, users are not meant to fill `is_presidency`
|
||||
and `is_board`, so those values are required by the form constructor
|
||||
in order to initialize the instance properly.
|
||||
"""
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["name", "description"]
|
||||
|
||||
def __init__(
|
||||
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance.club = club
|
||||
self.instance.is_presidency = is_presidency
|
||||
self.instance.is_board = is_board
|
||||
|
||||
|
||||
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
|
||||
ordering_widget = forms.HiddenInput()
|
||||
|
||||
|
||||
ClubRoleFormSet = forms.inlineformset_factory(
|
||||
Club,
|
||||
ClubRole,
|
||||
ClubRoleForm,
|
||||
ClubRoleBaseFormSet,
|
||||
can_delete=False,
|
||||
can_order=True,
|
||||
edit_only=True,
|
||||
extra=0,
|
||||
)
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
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
|
||||
|
||||
# Before the club role rework, the maximum free role
|
||||
# was the hardcoded highest non-board role
|
||||
MAXIMUM_FREE_ROLE = 1
|
||||
|
||||
|
||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
"""Attach the existing meta groups to the clubs.
|
||||
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
).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)
|
||||
]
|
||||
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
161
club/migrations/0015_clubrole_alter_membership_role.py
Normal file
161
club/migrations/0015_clubrole_alter_membership_role.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# Generated by Django 5.2.3 on 2025-06-21 21:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.db.models import Case, When
|
||||
|
||||
PRESIDENCY_ROLES = [10, 9]
|
||||
MAXIMUM_FREE_ROLE = 1
|
||||
SITH_CLUB_ROLES = {
|
||||
10: "Président⸱e",
|
||||
9: "Vice-Président⸱e",
|
||||
7: "Trésorier⸱e",
|
||||
5: "Responsable communication",
|
||||
4: "Secrétaire",
|
||||
3: "Responsable info",
|
||||
2: "Membre du bureau",
|
||||
1: "Membre actif⸱ve",
|
||||
0: "Curieux⸱euse",
|
||||
}
|
||||
|
||||
|
||||
def migrate_roles(apps: StateApps, schema_editor):
|
||||
ClubRole = apps.get_model("club", "ClubRole")
|
||||
Membership = apps.get_model("club", "Membership")
|
||||
|
||||
updates = []
|
||||
for club_id, role in Membership.objects.values_list("club", "role").distinct():
|
||||
new_role = ClubRole.objects.create(
|
||||
name=SITH_CLUB_ROLES[role],
|
||||
is_board=role > MAXIMUM_FREE_ROLE,
|
||||
is_presidency=role in PRESIDENCY_ROLES,
|
||||
club_id=club_id,
|
||||
order=max(SITH_CLUB_ROLES) - role,
|
||||
)
|
||||
updates.append(When(role=role, then=new_role.id))
|
||||
# all updates must happen at the same time
|
||||
# otherwise, the 10 first created ClubRole would be
|
||||
# re-modified after their initial creation, and it would
|
||||
# result in an incoherent state.
|
||||
# To avoid that, all updates are wrapped in a single giant Case(When) statement
|
||||
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
|
||||
Membership.objects.update(role=Case(*updates))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||
("core", "0047_alter_notification_date_alter_notification_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="page",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.page",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubRole",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.PositiveIntegerField(
|
||||
db_index=True, editable=False, verbose_name="order"
|
||||
),
|
||||
),
|
||||
(
|
||||
"club",
|
||||
models.ForeignKey(
|
||||
help_text="The club with which this role is associated",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="roles",
|
||||
to="club.club",
|
||||
verbose_name="club",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=50, verbose_name="name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
default="", blank=True, verbose_name="description"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_board",
|
||||
models.BooleanField(default=False, verbose_name="Board role"),
|
||||
),
|
||||
(
|
||||
"is_presidency",
|
||||
models.BooleanField(default=False, verbose_name="Presidency role"),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"If the role is inactive, people joining the club "
|
||||
"won't be able to get it."
|
||||
),
|
||||
verbose_name="is active",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("order",),
|
||||
"verbose_name": "club role",
|
||||
"verbose_name_plural": "club roles",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="board_group",
|
||||
field=models.OneToOneField(
|
||||
editable=False,
|
||||
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(
|
||||
editable=False,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="clubrole",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("is_presidency", False), ("is_board", True), _connector="OR"
|
||||
),
|
||||
name="clubrole_presidency_implies_board",
|
||||
violation_error_message=(
|
||||
"A role cannot be in the presidency while not being in the board"
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
|
||||
# because Postgres migrations run in a single transaction,
|
||||
# we cannot change the actual values of Membership.role
|
||||
# and apply the FOREIGN KEY constraint in the same migration.
|
||||
# The constraint is created in the next migration
|
||||
]
|
||||
25
club/migrations/0016_clubrole_alter_membership_role.py
Normal file
25
club/migrations/0016_clubrole_alter_membership_role.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.3 on 2025-09-27 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0015_clubrole_alter_membership_role")]
|
||||
|
||||
operations = [
|
||||
# because Postgres migrations run in a single transaction,
|
||||
# we cannot change the actual values of Membership.role
|
||||
# and apply the FOREIGN KEY constraint in the same migration.
|
||||
# The data migration was made in the previous migration.
|
||||
migrations.AlterField(
|
||||
model_name="membership",
|
||||
name="role",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="members",
|
||||
to="club.clubrole",
|
||||
verbose_name="role",
|
||||
),
|
||||
),
|
||||
]
|
||||
191
club/models.py
191
club/models.py
@@ -28,15 +28,15 @@ from typing import Iterable, Self
|
||||
from django.conf import settings
|
||||
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, F, OuterRef, Q, Value
|
||||
from django.db.models.functions import Greatest
|
||||
from django.db import ProgrammingError, models, transaction
|
||||
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.text import slugify
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from ordered_model.models import OrderedModel
|
||||
|
||||
from core.fields import ResizedImageField
|
||||
from core.models import Group, Notification, Page, SithFile, User
|
||||
@@ -89,13 +89,13 @@ class Club(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
page = models.OneToOneField(
|
||||
Page, related_name="club", blank=True, on_delete=models.CASCADE
|
||||
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
members_group = models.OneToOneField(
|
||||
Group, related_name="club", on_delete=models.PROTECT
|
||||
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
||||
)
|
||||
board_group = models.OneToOneField(
|
||||
Group, related_name="club_board", on_delete=models.PROTECT
|
||||
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
||||
)
|
||||
|
||||
objects = ClubQuerySet.as_manager()
|
||||
@@ -138,9 +138,7 @@ class Club(models.Model):
|
||||
@cached_property
|
||||
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()
|
||||
return self.members.filter(end_date=None).order_by("role__order").first()
|
||||
|
||||
def check_loop(self):
|
||||
"""Raise a validation error when a loop is found within the parent list."""
|
||||
@@ -185,6 +183,40 @@ class Club(models.Model):
|
||||
self.page.parent = self.parent.page
|
||||
self.page.save(force_lock=True)
|
||||
|
||||
def create_default_roles(self):
|
||||
"""Create some roles that should exist by default for this club.
|
||||
|
||||
The created roles are : president, treasurer, active member and curious.
|
||||
|
||||
Warnings:
|
||||
When calling this method, no club must exist yet for this club.
|
||||
"""
|
||||
if self.roles.exists():
|
||||
raise ProgrammingError(
|
||||
"Default roles can be created only for clubs "
|
||||
"that don't have associated roles yet"
|
||||
)
|
||||
# The names are written in French, because there is no gettext involved
|
||||
# for strings stored in database, and the majority of users are french.
|
||||
roles = [
|
||||
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
|
||||
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
|
||||
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
|
||||
ClubRole(
|
||||
name="Curieux⸱euse",
|
||||
description=(
|
||||
"Les gens qui suivent l'activité "
|
||||
"du club sans forcément y participer"
|
||||
),
|
||||
is_board=False,
|
||||
is_presidency=False,
|
||||
),
|
||||
]
|
||||
for i, role in enumerate(roles):
|
||||
role.club = self
|
||||
role.order = i
|
||||
ClubRole.objects.bulk_create(roles)
|
||||
|
||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||
self.board_group.delete()
|
||||
self.members_group.delete()
|
||||
@@ -206,9 +238,20 @@ class Club(models.Model):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
return self.has_rights_in_club(user)
|
||||
|
||||
def can_roles_be_edited_by(self, user: User) -> bool:
|
||||
"""Return True if the given user can edit the roles of this club"""
|
||||
return user.is_authenticated and (
|
||||
user.has_perm("club.change_clubrole")
|
||||
or self.members.ongoing()
|
||||
.filter(user=user, role__is_presidency=True)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def current_members(self) -> list[Membership]:
|
||||
return list(self.members.ongoing().select_related("user").order_by("-role"))
|
||||
return list(
|
||||
self.members.ongoing().select_related("user", "role").order_by("-role")
|
||||
)
|
||||
|
||||
def get_membership_for(self, user: User) -> Membership | None:
|
||||
"""Return the current membership of the given user."""
|
||||
@@ -220,6 +263,95 @@ class Club(models.Model):
|
||||
return user.is_in_group(pk=self.board_group_id)
|
||||
|
||||
|
||||
class ClubRole(OrderedModel):
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
verbose_name=_("club"),
|
||||
help_text=_("The club with which this role is associated"),
|
||||
related_name="roles",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
name = models.CharField(_("name"), max_length=50)
|
||||
description = models.TextField(_("description"), blank=True, default="")
|
||||
is_board = models.BooleanField(_("Board role"), default=False)
|
||||
is_presidency = models.BooleanField(_("Presidency role"), default=False)
|
||||
is_active = models.BooleanField(
|
||||
_("is active"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"If the role is inactive, people joining the club won't be able to get it."
|
||||
),
|
||||
)
|
||||
|
||||
order_with_respect_to = "club"
|
||||
|
||||
class Meta(OrderedModel.Meta):
|
||||
verbose_name = _("club role")
|
||||
verbose_name_plural = _("club roles")
|
||||
constraints = [
|
||||
# presidency IMPLIES board <=> NOT presidency OR board
|
||||
# cf. MT1 :)
|
||||
models.CheckConstraint(
|
||||
condition=Q(is_presidency=False) | Q(is_board=True),
|
||||
name="clubrole_presidency_implies_board",
|
||||
violation_error_message=_(
|
||||
"A role cannot be in the presidency while not being in the board"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_display_name(self):
|
||||
return f"{self.name} - {self.club.name}"
|
||||
|
||||
def clean(self):
|
||||
errors = []
|
||||
roles = list(self.club.roles.all())
|
||||
if (
|
||||
self.is_board
|
||||
and self.order
|
||||
and any(r.order < self.order and not r.is_board for r in roles)
|
||||
):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
_("Role %(role)s cannot be placed below a member role")
|
||||
% {"role": self.name}
|
||||
)
|
||||
)
|
||||
if (
|
||||
self.is_presidency
|
||||
and self.order
|
||||
and any(r.order < self.order and not r.is_presidency for r in roles)
|
||||
):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
_("Role %(role)s cannot be placed below a non-presidency role")
|
||||
% {"role": self.name}
|
||||
)
|
||||
)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
return super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
auto_order = self.order is None and self.is_board
|
||||
if not auto_order:
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
# get the role that should be placed after the role we are dealing with.
|
||||
# So, if this is role is presidency, get the first board role ;
|
||||
# if it is a board role, get the first member role ;
|
||||
# and if it is a member role, get nothing (OrderedModel.save will
|
||||
# automatically put it in the last position anyway)
|
||||
filters = {"is_board": self.is_presidency, "is_presidency": False}
|
||||
next_role = self.club.roles.filter(**filters).order_by("order").first()
|
||||
super().save(*args, **kwargs)
|
||||
if next_role:
|
||||
self.above(next_role)
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
def ongoing(self) -> Self:
|
||||
"""Filter all memberships which are not finished yet."""
|
||||
@@ -232,9 +364,10 @@ class MembershipQuerySet(models.QuerySet):
|
||||
are included, even if there are no more members.
|
||||
|
||||
If you want to get the users who are currently in the board,
|
||||
mind combining this with the `ongoing` queryset method
|
||||
mind combining this with the [MembershipQuerySet.ongoing][]
|
||||
queryset method
|
||||
"""
|
||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
return self.filter(role__is_board=True)
|
||||
|
||||
def editable_by(self, user: User) -> Self:
|
||||
"""Filter Memberships that this user can edit.
|
||||
@@ -257,21 +390,16 @@ class MembershipQuerySet(models.QuerySet):
|
||||
"""
|
||||
if user.has_perm("club.change_membership"):
|
||||
return self.all()
|
||||
return self.filter(
|
||||
return self.ongoing().filter(
|
||||
Q(user=user)
|
||||
| Exists(
|
||||
Membership.objects.filter(
|
||||
Q(
|
||||
role__gt=Greatest(
|
||||
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
)
|
||||
),
|
||||
Membership.objects.ongoing().filter(
|
||||
user=user,
|
||||
end_date=None,
|
||||
club=OuterRef("club"),
|
||||
role__is_board=True,
|
||||
role__order__lt=OuterRef("role__order"),
|
||||
)
|
||||
)
|
||||
),
|
||||
end_date=None,
|
||||
)
|
||||
|
||||
def update(self, **kwargs) -> int:
|
||||
@@ -341,10 +469,11 @@ class Membership(models.Model):
|
||||
)
|
||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||
role = models.IntegerField(
|
||||
_("role"),
|
||||
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
||||
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
||||
role = models.ForeignKey(
|
||||
ClubRole,
|
||||
verbose_name=_("role"),
|
||||
related_name="members",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
description = models.CharField(
|
||||
_("description"), max_length=128, null=False, blank=True
|
||||
@@ -362,7 +491,7 @@ class Membership(models.Model):
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.club.name} - {self.user.username} "
|
||||
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
||||
f"- {self.role.name} "
|
||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||
)
|
||||
|
||||
@@ -391,7 +520,11 @@ class Membership(models.Model):
|
||||
if user.is_root or user.is_board_member:
|
||||
return True
|
||||
membership = self.club.get_membership_for(user)
|
||||
return membership is not None and membership.role >= self.role
|
||||
if not membership:
|
||||
return False
|
||||
return membership.user_id == user.id or (
|
||||
membership.is_board and membership.role.order < self.role.order
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self._remove_club_groups([self])
|
||||
@@ -467,7 +600,7 @@ class Membership(models.Model):
|
||||
group_id=membership.club.members_group_id,
|
||||
)
|
||||
)
|
||||
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
if membership.role.is_board:
|
||||
club_groups.append(
|
||||
User.groups.through(
|
||||
user_id=membership.user_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Annotated
|
||||
from django.db.models import Q
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
|
||||
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name", "logo"]
|
||||
fields = ["id", "name", "logo", "is_active", "short_description"]
|
||||
|
||||
url: str
|
||||
|
||||
@@ -39,12 +39,21 @@ class ClubProfileSchema(ModelSchema):
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class ClubRoleSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["id", "name", "is_presidency", "is_board"]
|
||||
|
||||
|
||||
class ClubMemberSchema(ModelSchema):
|
||||
"""A schema to represent all memberships in a club."""
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["start_date", "end_date", "role", "description"]
|
||||
fields = ["start_date", "end_date", "description"]
|
||||
|
||||
user: SimpleUserSchema
|
||||
role: ClubRoleSchema
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
@@ -53,3 +62,14 @@ class ClubSchema(ModelSchema):
|
||||
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
||||
|
||||
members: list[ClubMemberSchema]
|
||||
|
||||
|
||||
class UserMembershipSchema(ModelSchema):
|
||||
"""A schema to represent the active club memberships of a user."""
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["id", "start_date", "description"]
|
||||
|
||||
club: SimpleClubSchema
|
||||
role: ClubRoleSchema
|
||||
|
||||
61
club/static/bundled/club/role-list-index.ts
Normal file
61
club/static/bundled/club/role-list-index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { AlpineComponent } from "alpinejs";
|
||||
|
||||
interface RoleGroupData {
|
||||
isBoard: boolean;
|
||||
isPresidency: boolean;
|
||||
roleId: number;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
|
||||
confirmOnSubmit: false,
|
||||
|
||||
/**
|
||||
* Edit relevant item data after it has been moved by x-sort
|
||||
*/
|
||||
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
|
||||
item.isBoard = conf.isBoard;
|
||||
item.isPresidency = conf.isPresidency;
|
||||
// if the user has moved its own role outside the presidency,
|
||||
// submitting the form will require a confirmation
|
||||
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
|
||||
this.resetOrder();
|
||||
},
|
||||
/**
|
||||
* Reset the value of the ORDER input of all items in the list.
|
||||
* This is to be called after any reordering operation, in order to make sure
|
||||
* that the order that will be saved is coherent with what is displayed.
|
||||
*/
|
||||
resetOrder() {
|
||||
// When moving items with x-sort, the only information we truly have is
|
||||
// the end position in the target group, not the previous position nor
|
||||
// the position in the global list.
|
||||
// To overcome this, we loop through an enumeration of all inputs
|
||||
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||
"input[name^='roles'][name$='ORDER']",
|
||||
);
|
||||
for (const [i, elem] of inputs.entries()) {
|
||||
elem.value = (i + 1).toString();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If the user moved its role out of the presidency, ask a confirmation
|
||||
* before submitting the form
|
||||
*/
|
||||
confirmSubmission(event: SubmitEvent) {
|
||||
if (
|
||||
this.confirmOnSubmit &&
|
||||
!confirm(
|
||||
gettext(
|
||||
"You're going to remove your own role from the presidency. " +
|
||||
"You may lock yourself out of this page. Do you want to continue ? ",
|
||||
),
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
47
club/static/club/list.scss
Normal file
47
club/static/club/list.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
#club-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
padding: 2em;
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
background-color: unset;
|
||||
|
||||
.club-image {
|
||||
float: left;
|
||||
margin-right: 2rem;
|
||||
margin-bottom: .5rem;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 10%;
|
||||
background-color: rgba(173, 173, 173, 0.2);
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
i.club-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
club/static/club/roles.scss
Normal file
7
club/static/club/roles.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.fa-grip-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: .5em;
|
||||
}
|
||||
@@ -1,52 +1,76 @@
|
||||
{% if is_fragment %}
|
||||
{% extends "core/base_fragment.jinja" %}
|
||||
|
||||
{# Don't display tabs and errors #}
|
||||
{% block tabs %}
|
||||
{% endblock %}
|
||||
{% block errors %}
|
||||
{% endblock %}
|
||||
{% else %}
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title -%}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{%- endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
|
||||
{% endblock %}
|
||||
{% block description -%}
|
||||
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
|
||||
{%- endblock %}
|
||||
|
||||
{% macro display_club(club) -%}
|
||||
|
||||
{% if club.is_active or user.is_root %}
|
||||
|
||||
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
|
||||
|
||||
{% if not club.is_active %}
|
||||
({% trans %}inactive{% endtrans %})
|
||||
{% block title -%}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{%- endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
|
||||
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- if club.children.all()|length != 0 %}
|
||||
<ul>
|
||||
{%- for c in club.children.order_by('name').prefetch_related("children") %}
|
||||
{{ display_club(c) }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif -%}
|
||||
</li>
|
||||
{%- endmacro %}
|
||||
{% from "core/macros.jinja" import paginate_htmx %}
|
||||
|
||||
{% block content %}
|
||||
{% if user.is_root %}
|
||||
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if club_list %}
|
||||
<main>
|
||||
<h3>{% trans %}Filters{% endtrans %}</h3>
|
||||
<form
|
||||
id="club-list-filters"
|
||||
hx-get="{{ url("club:club_list") }}"
|
||||
hx-target="#content"
|
||||
hx-swap="outerHtml"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div class="row gap-4x">
|
||||
{{ form }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-blue margin-bottom">
|
||||
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<h3>{% trans %}Club list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{%- for club in club_list %}
|
||||
{{ display_club(club) }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no club in this website.{% endtrans %}
|
||||
{% if user.has_perm("club.add_club") %}
|
||||
<br>
|
||||
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
|
||||
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<section class="aria-busy-grow" id="club-list">
|
||||
{% for club in object_list %}
|
||||
<div class="card">
|
||||
{% set club_url = club.get_absolute_url() %}
|
||||
<a href="{{ club_url }}">
|
||||
{% if club.logo %}
|
||||
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
|
||||
{% else %}
|
||||
<i class="fa-regular fa-image fa-4x club-image"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="content">
|
||||
<a href="{{ club_url }}">
|
||||
<h4>
|
||||
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
|
||||
</h4>
|
||||
</a>
|
||||
{{ club.short_description|markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_htmx(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,6 +12,15 @@
|
||||
|
||||
<h2>{% trans %}Club members{% endtrans %}</h2>
|
||||
|
||||
{% if club.can_roles_be_edited_by(user) %}
|
||||
<a
|
||||
href="{{ url("club:club_roles", club_id=object.id) }}"
|
||||
class="btn btn-blue margin-bottom"
|
||||
>
|
||||
{% trans %}Manage roles{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if add_member_fragment %}
|
||||
<br />
|
||||
{{ add_member_fragment }}
|
||||
@@ -45,7 +50,7 @@
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(m.user) }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
{%- if can_end_membership -%}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{% for member in old_members %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(member.user) }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
||||
<td>{{ member.role.name }}</td>
|
||||
<td>{{ member.description }}</td>
|
||||
<td>{{ member.start_date }}</td>
|
||||
<td>{{ member.end_date }}</td>
|
||||
|
||||
165
club/templates/club/club_roles.jinja
Normal file
165
club/templates/club/club_roles.jinja
Normal file
@@ -0,0 +1,165 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_subform(subform) %}
|
||||
<div
|
||||
class="row"
|
||||
x-data="{
|
||||
isPresidency: {{ subform.is_presidency.value()|lower }},
|
||||
isBoard: {{ subform.is_board.value()|lower }},
|
||||
roleId: {{ subform.id.value() }},
|
||||
}"
|
||||
x-sort:item="$data"
|
||||
>
|
||||
{# hidden fields #}
|
||||
{{ subform.ORDER }}
|
||||
{{ subform.id }}
|
||||
{{ subform.club }}
|
||||
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
|
||||
{{ subform.is_board|add_attr("x-model=isBoard") }}
|
||||
<i class="fa fa-grip-vertical" x-sort:handle></i>
|
||||
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
|
||||
<summary>
|
||||
{{ subform.name.value() }}
|
||||
{% if not subform.instance.is_active -%}
|
||||
({% trans %}inactive{% endtrans %})
|
||||
{%- endif %}
|
||||
</summary>
|
||||
<div class="accordion-content">
|
||||
{{ subform.non_field_errors() }}
|
||||
<div class="form-group">
|
||||
{{ subform.name.as_field_group() }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ subform.description.as_field_group() }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div>
|
||||
{{ subform.is_active }}
|
||||
{{ subform.is_active.label_tag() }}
|
||||
</div>
|
||||
<span class="helptext">
|
||||
{{ subform.is_active.help_text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
Roles give rights on the club.
|
||||
Higher roles grant more rights, and the members having them are displayed higher
|
||||
in the club members list.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
On this page, you can edit their name and description, as well as their order.
|
||||
You can also drag roles from a category to another
|
||||
(e.g. a board role can be made into a presidency role).
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<form
|
||||
method="post"
|
||||
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
|
||||
@submit="confirmSubmission"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form.management_form }}
|
||||
{{ form.non_form_errors() }}
|
||||
<h3>{% trans %}Presidency{% endtrans %}</h3>
|
||||
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
{# The style we use for markdown rendering is quite nice for what we want to display,
|
||||
so we are just gonna reuse it. #}
|
||||
<div class="markdown">
|
||||
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
|
||||
<ul>
|
||||
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
|
||||
<li>{% trans %}manage the club counters{% endtrans %}</li>
|
||||
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
|
||||
</ul>
|
||||
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if subform.is_presidency.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<h3>{% trans %}Board{% endtrans %}</h3>
|
||||
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
<div class="markdown">
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
Board members can do most administrative actions in the club, including :
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>{% trans %}manage the club posters{% endtrans %}</li>
|
||||
<li>{% trans %}create news for the club{% endtrans %}</li>
|
||||
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
|
||||
<li>
|
||||
{% trans trimmed %}
|
||||
add new members and end active memberships
|
||||
for roles that are lower than their own.
|
||||
{% endtrans %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if subform.is_board.value() and not subform.is_presidency.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<h3>{% trans %}Members{% endtrans %}</h3>
|
||||
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
<div class="markdown">
|
||||
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if not subform.is_board.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -5,8 +5,19 @@
|
||||
<div>
|
||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
||||
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
||||
{% trans %}Create a news{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
|
||||
{% trans %}Post in the Weekmail{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% if object.can_roles_be_edited_by(user) %}
|
||||
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
|
||||
{% endif %}
|
||||
{% if object.trombi %}
|
||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||
{% else %}
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import User
|
||||
|
||||
@@ -43,6 +43,11 @@ class TestClub(TestCase):
|
||||
|
||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||
cls.club = baker.make(Club)
|
||||
cls.president_role = baker.make(
|
||||
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
|
||||
)
|
||||
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
|
||||
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
|
||||
cls.new_members_url = reverse(
|
||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||
)
|
||||
@@ -51,12 +56,17 @@ class TestClub(TestCase):
|
||||
yesterday = now() - timedelta(days=1)
|
||||
membership_recipe = Recipe(Membership, club=cls.club)
|
||||
membership_recipe.make(
|
||||
user=cls.simple_board_member, start_date=a_month_ago, role=3
|
||||
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
||||
)
|
||||
membership_recipe.make(user=cls.richard, role=cls.member_role)
|
||||
membership_recipe.make(
|
||||
user=cls.president, start_date=a_month_ago, role=cls.president_role
|
||||
)
|
||||
membership_recipe.make(user=cls.richard, role=1)
|
||||
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
|
||||
membership_recipe.make( # sli was a member but isn't anymore
|
||||
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
|
||||
user=cls.sli,
|
||||
start_date=a_month_ago,
|
||||
end_date=yesterday,
|
||||
role=cls.board_role,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.db import ProgrammingError
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -16,12 +22,106 @@ def test_club_queryset_having_board_member():
|
||||
membership_recipe = Recipe(
|
||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
||||
)
|
||||
membership_recipe.make(club=clubs[0], role=1)
|
||||
membership_recipe.make(club=clubs[1], role=3)
|
||||
membership_recipe.make(club=clubs[2], role=7)
|
||||
membership_recipe.make(
|
||||
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
|
||||
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[3],
|
||||
role=baker.make(ClubRole, club=clubs[3], is_board=True),
|
||||
end_date=localdate() - timedelta(days=1),
|
||||
)
|
||||
|
||||
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||
assert set(club_ids) == {clubs[1].id, clubs[2].id}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
|
||||
@pytest.mark.parametrize("is_fragment", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
|
||||
client.force_login(baker.make(User))
|
||||
baker.make(Club, _quantity=nb_additional_clubs)
|
||||
headers = {"HX-Request": True} if is_fragment else {}
|
||||
res = client.get(reverse("club:club_list"), headers=headers)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def assert_club_created(club_name: str):
|
||||
club = Club.objects.last()
|
||||
assert club.name == club_name
|
||||
assert club.board_group.name == f"{club_name} - Bureau"
|
||||
assert club.members_group.name == f"{club_name} - Membres"
|
||||
# default roles should be added on club creation,
|
||||
# whether the creation happens on the admin site or on the user site
|
||||
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
|
||||
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
|
||||
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
|
||||
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
|
||||
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(admin_client: Client):
|
||||
"""Test that the club creation view works well"""
|
||||
res = admin_client.get(reverse("club:club_new"))
|
||||
assert res.status_code == 200
|
||||
res = admin_client.post(
|
||||
reverse("club:club_new"),
|
||||
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
|
||||
)
|
||||
club = Club.objects.last()
|
||||
assertRedirects(res, club.get_absolute_url())
|
||||
assert_club_created("foo")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_default_roles_for_club_with_roles_fails():
|
||||
"""Test that an Error is raised if trying to create
|
||||
default roles for a club that already has roles.
|
||||
"""
|
||||
club = baker.make(Club)
|
||||
baker.make(ClubRole, club=club)
|
||||
with pytest.raises(ProgrammingError):
|
||||
club.create_default_roles()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAdminInterface:
|
||||
def test_create(self, admin_client: Client):
|
||||
"""Test the creation of a club via the admin interface."""
|
||||
res = admin_client.post(
|
||||
reverse("admin:club_club_add"),
|
||||
data={
|
||||
"name": "foo",
|
||||
"parent": settings.SITH_MAIN_CLUB_ID,
|
||||
"address": "Rome",
|
||||
},
|
||||
)
|
||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||
assert_club_created("foo")
|
||||
|
||||
def test_change(self, admin_client: Client):
|
||||
"""Test the edition of a club via the admin interface."""
|
||||
club = baker.make(Club)
|
||||
res = admin_client.post(
|
||||
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
|
||||
data={
|
||||
"name": "foo",
|
||||
"page": club.page_id,
|
||||
"home": club.home_id,
|
||||
"address": club.address,
|
||||
},
|
||||
)
|
||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||
club.refresh_from_db()
|
||||
assert club.name == "foo"
|
||||
# Club roles shouldn't be modified when editing the club on the admin interface
|
||||
# This club had no roles beforehand, therefore it shouldn't have roles now.
|
||||
assert not club.roles.exists()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +9,7 @@ from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, Page, User
|
||||
|
||||
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
Page.objects.exclude(club=None).delete()
|
||||
Membership.objects.all().delete()
|
||||
ClubRole.objects.all().delete()
|
||||
Club.objects.all().delete()
|
||||
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
||||
Group.objects.filter(id__in=groups).delete()
|
||||
|
||||
cls.clubs = baker.make(
|
||||
|
||||
254
club/tests/test_clubrole.py
Normal file
254
club/tests/test_clubrole.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker, seq
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubRoleFormSet
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
|
||||
|
||||
def make_club():
|
||||
# unittest-style tests cannot use fixture, so we create a function
|
||||
# that will be callable either by a pytest fixture or inside
|
||||
# a TestCase.setUpTestData method.
|
||||
club = baker.make(Club)
|
||||
recipe = Recipe(ClubRole, club=club, name=seq("role "))
|
||||
recipe.make(
|
||||
is_board=iter([True, True, False]),
|
||||
is_presidency=iter([True, False, False]),
|
||||
order=iter([1, 2, 3]),
|
||||
_quantity=3,
|
||||
_bulk_create=True,
|
||||
)
|
||||
return club
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def club(db):
|
||||
"""A club with a presidency role, a board role and a member role"""
|
||||
return make_club()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_auto(club):
|
||||
"""Test that newly created roles are put in the right place."""
|
||||
roles = list(club.roles.all())
|
||||
# create new roles one by one (like they will be in prod)
|
||||
# each new role should be placed at the end of its category
|
||||
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
|
||||
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
|
||||
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
|
||||
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
|
||||
assert list(club.roles.order_by("order")) == [
|
||||
roles[0],
|
||||
role_a,
|
||||
roles[1],
|
||||
role_b,
|
||||
roles[2],
|
||||
role_c,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "is_allowed"),
|
||||
[
|
||||
(
|
||||
lambda club: baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="change_clubrole")],
|
||||
),
|
||||
True,
|
||||
),
|
||||
( # user with presidency roles can edit the club roles
|
||||
lambda club: subscriber_user.make(
|
||||
memberships=[
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=club.roles.filter(is_presidency=True).first(),
|
||||
)
|
||||
]
|
||||
),
|
||||
True,
|
||||
),
|
||||
( # user in the board but not in the presidency cannot edit roles
|
||||
lambda club: subscriber_user.make(
|
||||
memberships=[
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=club.roles.filter(
|
||||
is_presidency=False, is_board=True
|
||||
).first(),
|
||||
)
|
||||
]
|
||||
),
|
||||
False,
|
||||
),
|
||||
(lambda _: AnonymousUser(), False),
|
||||
],
|
||||
)
|
||||
def test_can_roles_be_edited_by(
|
||||
club: Club, user_factory: Callable[[Club], User], is_allowed
|
||||
):
|
||||
"""Test that `Club.can_roles_be_edited_by` return the right value"""
|
||||
user = user_factory(club)
|
||||
assert club.can_roles_be_edited_by(user) == is_allowed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
["route", "is_presidency", "is_board"],
|
||||
[
|
||||
("club:new_role_president", True, True),
|
||||
("club:new_role_board", False, True),
|
||||
("club:new_role_member", False, False),
|
||||
],
|
||||
)
|
||||
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
|
||||
"""Test that the role creation views work."""
|
||||
club = baker.make(Club)
|
||||
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=club, role=role, user=user, end_date=None)
|
||||
url = reverse(route, kwargs={"club_id": club.id})
|
||||
client.force_login(user)
|
||||
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.post(url, data={"name": "foo"})
|
||||
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
|
||||
new_role = club.roles.last()
|
||||
assert new_role.name == "foo"
|
||||
assert new_role.is_presidency == is_presidency
|
||||
assert new_role.is_board == is_board
|
||||
|
||||
|
||||
class TestClubRoleUpdate(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
cls.roles = list(cls.club.roles.all())
|
||||
cls.user = subscriber_user.make()
|
||||
baker.make(
|
||||
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
|
||||
)
|
||||
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
|
||||
|
||||
def setUp(self):
|
||||
self.payload = {
|
||||
"roles-TOTAL_FORMS": 3,
|
||||
"roles-INITIAL_FORMS": 3,
|
||||
"roles-MIN_NUM_FORMS": 0,
|
||||
"roles-MAX_NUM_FORMS": 1000,
|
||||
"roles-0-ORDER": self.roles[0].order,
|
||||
"roles-0-id": self.roles[0].id,
|
||||
"roles-0-club": self.club.id,
|
||||
"roles-0-is_presidency": True,
|
||||
"roles-0-is_board": True,
|
||||
"roles-0-name": self.roles[0].name,
|
||||
"roles-0-description": self.roles[0].description,
|
||||
"roles-0-is_active": True,
|
||||
"roles-1-ORDER": self.roles[1].order,
|
||||
"roles-1-id": self.roles[1].id,
|
||||
"roles-1-club": self.club.id,
|
||||
"roles-1-is_presidency": False,
|
||||
"roles-1-is_board": True,
|
||||
"roles-1-name": self.roles[1].name,
|
||||
"roles-1-description": self.roles[1].description,
|
||||
"roles-1-is_active": True,
|
||||
"roles-2-ORDER": self.roles[2].order,
|
||||
"roles-2-id": self.roles[2].id,
|
||||
"roles-2-club": self.club.id,
|
||||
"roles-2-is_presidency": False,
|
||||
"roles-2-is_board": False,
|
||||
"roles-2-name": self.roles[2].name,
|
||||
"roles-2-description": self.roles[2].description,
|
||||
"roles-2-is_active": True,
|
||||
}
|
||||
|
||||
def test_view_ok(self):
|
||||
"""Basic test to check that the view works."""
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
self.payload["roles-2-name"] = "foo"
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
assertRedirects(res, self.url)
|
||||
self.roles[2].refresh_from_db()
|
||||
assert self.roles[2].name == "foo"
|
||||
|
||||
def test_incoherent_order(self):
|
||||
"""Test that placing a member role over a board role fails."""
|
||||
self.payload["roles-0-ORDER"] = 4
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert not formset.is_valid()
|
||||
assert formset.errors == [
|
||||
{
|
||||
"__all__": [
|
||||
f"Le rôle {self.roles[0].name} ne peut pas "
|
||||
"être placé en-dessous d'un rôle de membre.",
|
||||
f"Le rôle {self.roles[0].name} ne peut pas être placé "
|
||||
"en-dessous d'un rôle qui n'est pas de la présidence.",
|
||||
]
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def test_change_order_ok(self):
|
||||
"""Test that changing order the intended way works"""
|
||||
self.payload["roles-1-ORDER"] = 3
|
||||
self.payload["roles-1-is_board"] = False
|
||||
self.payload["roles-2-ORDER"] = 2
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
assert list(self.club.roles.order_by("order")) == [
|
||||
self.roles[0],
|
||||
self.roles[2],
|
||||
self.roles[1],
|
||||
]
|
||||
self.roles[1].refresh_from_db()
|
||||
assert not self.roles[1].is_board
|
||||
|
||||
def test_non_board_presidency_is_forbidden(self):
|
||||
"""Test that a role cannot be in the presidency without being in the board."""
|
||||
self.payload["roles-0-is_board"] = False
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert not formset.is_valid()
|
||||
assert formset.errors == [
|
||||
{
|
||||
"__all__": [
|
||||
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
|
||||
]
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def test_president_moves_itself_out_of_the_presidency(self):
|
||||
"""Test that if the user moves its own role out of the presidency,
|
||||
then it's redirected to another page and loses access to the update page."""
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
assertRedirects(
|
||||
res, reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||
)
|
||||
# When the user clicked that button, it still had the right to update roles,
|
||||
# so the modification should be applied
|
||||
self.roles[0].refresh_from_db()
|
||||
assert self.roles[0].is_presidency is False
|
||||
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
@@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ from core.baker_recipes import subscriber_user
|
||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||
user = subscriber_user.make()
|
||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||
baker.make(Membership, club=club, user=user, role=7)
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
user=user,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
res = client.post(
|
||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||
@@ -32,7 +37,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
|
||||
"""crash test for club:club_edit"""
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=club, user=user, role=3)
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
user=user,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
|
||||
@@ -3,9 +3,10 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
|
||||
from club.forms import MailingForm
|
||||
from club.models import Club, Mailing, Membership
|
||||
from club.models import Club, ClubRole, Mailing, Membership
|
||||
from core.models import User
|
||||
|
||||
|
||||
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
|
||||
user=cls.rbatsbak,
|
||||
club=cls.club,
|
||||
start_date=timezone.now(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
||||
).save()
|
||||
|
||||
def test_mailing_list_add_no_moderation(self):
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Max
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate, localtime, now
|
||||
from model_bakery import baker
|
||||
from model_bakery import baker, seq
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from club.tests.base import TestClub
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
@@ -75,17 +75,22 @@ class TestMembershipQuerySet(TestClub):
|
||||
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)
|
||||
board_role, member_role = baker.make(
|
||||
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
|
||||
)
|
||||
membership = baker.make(
|
||||
Membership, end_date=None, user=user, role=board_role, club=board_role.club
|
||||
)
|
||||
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
|
||||
user.memberships.update(role=member_role) # 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
|
||||
user.memberships.update(role=board_role) # from member to board
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
@@ -96,7 +101,17 @@ class TestMembershipQuerySet(TestClub):
|
||||
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 = baker.make(Club)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
is_board=iter([False, True]),
|
||||
club=club,
|
||||
_quantity=2,
|
||||
_bulk_create=True,
|
||||
)
|
||||
memberships = baker.make(
|
||||
Membership, club=club, role=iter(roles), user=user, _quantity=2
|
||||
)
|
||||
club_groups = {
|
||||
memberships[0].club.members_group,
|
||||
memberships[1].club.members_group,
|
||||
@@ -112,13 +127,20 @@ class TestMembershipEditableBy(TestCase):
|
||||
def setUpTestData(cls):
|
||||
Membership.objects.all().delete()
|
||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
is_presidency=itertools.cycle([True, False, False, False]),
|
||||
is_board=itertools.cycle([True, True, True, False]),
|
||||
order=itertools.cycle(range(4)),
|
||||
club=iter(
|
||||
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
|
||||
),
|
||||
_quantity=8,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.memberships = [
|
||||
*baker.make(
|
||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
|
||||
),
|
||||
*baker.make(
|
||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
|
||||
),
|
||||
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
||||
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
||||
]
|
||||
|
||||
def test_admin_user(self):
|
||||
@@ -140,7 +162,7 @@ class TestMembershipEditableBy(TestCase):
|
||||
|
||||
|
||||
class TestMembership(TestClub):
|
||||
def assert_membership_started_today(self, user: User, role: int):
|
||||
def assert_membership_started_today(self, user: User, role: ClubRole):
|
||||
"""Assert that the given membership is active and started today."""
|
||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||
assert membership is not None
|
||||
@@ -189,21 +211,27 @@ class TestMembership(TestClub):
|
||||
"Marquer comme ancien",
|
||||
]
|
||||
rows = table.find("tbody").find_all("tr")
|
||||
memberships = self.club.members.ongoing().order_by("-role")
|
||||
for row, membership in zip(
|
||||
rows, memberships.select_related("user"), strict=False
|
||||
):
|
||||
memberships = (
|
||||
self.club.members.ongoing()
|
||||
.order_by("role__order")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||
for row, membership in zip(rows, memberships, strict=False):
|
||||
user = membership.user
|
||||
user_url = reverse("core:user_profile", args=[user.id])
|
||||
cols = row.find_all("td")
|
||||
user_link = cols[0].find("a")
|
||||
assert user_link.attrs["href"] == user_url
|
||||
assert user_link.text == user.get_display_name()
|
||||
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
|
||||
assert cols[1].text == membership.role.name
|
||||
assert cols[2].text == membership.description
|
||||
assert cols[3].text == str(membership.start_date)
|
||||
|
||||
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
||||
if (
|
||||
membership.role.order > user_role.order
|
||||
or membership.user_id == self.simple_board_member.id
|
||||
):
|
||||
# 3 is the role of simple_board_member
|
||||
form_input = cols[4].find("input")
|
||||
expected_attrs = {
|
||||
@@ -219,14 +247,15 @@ class TestMembership(TestClub):
|
||||
"""Test that root users can add members to clubs"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.new_members_url, {"user": self.subscriber.id, "role": 3}
|
||||
self.new_members_url,
|
||||
{"user": self.subscriber.id, "role": self.board_role.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=3)
|
||||
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
||||
|
||||
def test_add_unauthorized_members(self):
|
||||
"""Test that users who are not currently subscribed
|
||||
@@ -234,7 +263,7 @@ class TestMembership(TestClub):
|
||||
"""
|
||||
for user in self.public, self.old_subscriber:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": user.id, "role": 1},
|
||||
data={"user": user.id, "role": self.member_role},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -255,7 +284,7 @@ class TestMembership(TestClub):
|
||||
nb_memberships = self.simple_board_member.memberships.count()
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
|
||||
{"users": self.simple_board_member.id, "role": self.member_role},
|
||||
)
|
||||
self.simple_board_member.refresh_from_db()
|
||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||
@@ -274,7 +303,7 @@ class TestMembership(TestClub):
|
||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": members, "role": 1},
|
||||
data={"user": members, "role": self.member_role},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -288,44 +317,6 @@ class TestMembership(TestClub):
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
|
||||
def test_president_add_members(self):
|
||||
"""Test that the president of the club can add members."""
|
||||
president = self.club.members.get(role=10).user
|
||||
nb_club_membership = self.club.members.count()
|
||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||
self.client.force_login(president)
|
||||
response = self.client.post(
|
||||
self.new_members_url, {"user": self.subscriber.id, "role": 9}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
self.subscriber.refresh_from_db()
|
||||
assert self.club.members.count() == nb_club_membership + 1
|
||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||
self.assert_membership_started_today(self.subscriber, role=9)
|
||||
|
||||
def test_add_member_greater_role(self):
|
||||
"""Test that a member of the club member cannot create
|
||||
a membership with a greater role than its own.
|
||||
"""
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.subscriber.id, "role": 10},
|
||||
request_user=self.simple_board_member,
|
||||
club=self.club,
|
||||
)
|
||||
nb_memberships = self.club.members.count()
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
|
||||
}
|
||||
self.club.refresh_from_db()
|
||||
assert nb_memberships == self.club.members.count()
|
||||
assert not self.subscriber.memberships.filter(club=self.club).exists()
|
||||
|
||||
def test_add_member_without_role(self):
|
||||
"""Test that trying to add members without specifying their role fails."""
|
||||
form = ClubAddMemberForm(
|
||||
@@ -336,8 +327,9 @@ class TestMembership(TestClub):
|
||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||
|
||||
def test_add_member_already_there(self):
|
||||
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.simple_board_member, "role": 3},
|
||||
data={"user": self.simple_board_member, "role": role.id},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -348,22 +340,27 @@ class TestMembership(TestClub):
|
||||
|
||||
def test_add_other_member_forbidden(self):
|
||||
non_member = subscriber_user.make()
|
||||
simple_member = baker.make(Membership, club=self.club, role=1).user
|
||||
simple_member = baker.make(
|
||||
Membership, club=self.club, role=self.member_role
|
||||
).user
|
||||
for user in non_member, simple_member:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": subscriber_user.make(), "role": 1},
|
||||
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
||||
request_user=user,
|
||||
club=self.club,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
}
|
||||
|
||||
def test_simple_members_dont_see_form_anymore(self):
|
||||
"""Test that simple club members don't see the form to add members"""
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=self.club, user=user, role=1)
|
||||
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
||||
self.client.force_login(user)
|
||||
res = self.client.get(self.members_url)
|
||||
assert res.status_code == 200
|
||||
@@ -382,9 +379,10 @@ class TestMembership(TestClub):
|
||||
"""Test that board members of the club can end memberships
|
||||
of users with lower roles.
|
||||
"""
|
||||
# reminder : simple_board_member has role 3
|
||||
self.client.force_login(self.simple_board_member)
|
||||
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
||||
role = baker.make(ClubRole, club=self.club, is_board=True)
|
||||
role.below(self.board_role)
|
||||
membership = baker.make(Membership, club=self.club, role=role)
|
||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
@@ -394,7 +392,9 @@ class TestMembership(TestClub):
|
||||
"""Test that board members of the club cannot end memberships
|
||||
of users with higher roles.
|
||||
"""
|
||||
membership = self.president.memberships.filter(club=self.club).first()
|
||||
membership = self.president.memberships.filter(
|
||||
club=self.club, end_date=None
|
||||
).first()
|
||||
self.client.force_login(self.simple_board_member)
|
||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.club.refresh_from_db()
|
||||
@@ -436,7 +436,9 @@ class TestMembership(TestClub):
|
||||
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)
|
||||
baker.make(
|
||||
Membership, user=user, club=self.club, end_date=None, role=self.board_role
|
||||
)
|
||||
assert user.groups.contains(self.club.members_group)
|
||||
assert user.groups.contains(self.club.board_group)
|
||||
user.memberships.update(end_date=localdate())
|
||||
@@ -447,18 +449,20 @@ class TestMembership(TestClub):
|
||||
"""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)
|
||||
baker.make(
|
||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
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
|
||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
membership.role = 1
|
||||
membership.role = self.member_role
|
||||
membership.save()
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
@@ -471,7 +475,11 @@ class TestMembership(TestClub):
|
||||
|
||||
# make sli a board member
|
||||
self.sli.memberships.all().delete()
|
||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||
Membership(
|
||||
club=self.ae,
|
||||
user=self.sli,
|
||||
role=baker.make(ClubRole, club=self.ae, is_board=True),
|
||||
).save()
|
||||
assert self.club.is_owned_by(self.sli)
|
||||
|
||||
def test_change_club_name(self):
|
||||
@@ -497,7 +505,7 @@ class TestMembership(TestClub):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_membership_set_old(client: Client):
|
||||
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
|
||||
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
|
||||
client.force_login(membership.user)
|
||||
response = client.post(
|
||||
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||
@@ -524,6 +532,50 @@ def test_membership_delete(client: Client):
|
||||
assert not Membership.objects.filter(id=membership.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAddMemberForm(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.roles = baker.make(
|
||||
ClubRole,
|
||||
club=cls.club,
|
||||
is_board=iter([True, True, True, True, False, False]),
|
||||
is_presidency=iter([True, True, False, False, False, False]),
|
||||
order=seq(0),
|
||||
_quantity=6,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.roles[-1].is_active = False
|
||||
cls.roles[-1].save()
|
||||
|
||||
def test_admin(self):
|
||||
"""Test that admin users can give any active role."""
|
||||
user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||
)
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||
|
||||
def test_president(self):
|
||||
"""Test that someone with a presidency role can give any active role."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||
|
||||
def test_board_member(self):
|
||||
"""Test that someone with a board role can give lower active role."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[3:-1]
|
||||
|
||||
def test_simple_member(self):
|
||||
"""Test that someone with a non-board role cannot give roles."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJoinClub:
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -531,55 +583,64 @@ class TestJoinClub:
|
||||
cache.clear()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "role", "errors"),
|
||||
("user_factory", "board_role", "errors"),
|
||||
[
|
||||
(
|
||||
subscriber_user.make,
|
||||
2,
|
||||
True,
|
||||
{
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
lambda: baker.make(User),
|
||||
1,
|
||||
False,
|
||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_join_club_errors(
|
||||
self, user_factory: Callable[[], User], role: int, errors: dict
|
||||
self, user_factory: Callable[[], User], board_role, errors: dict
|
||||
):
|
||||
club = baker.make(Club)
|
||||
user = user_factory()
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": role})
|
||||
role = baker.make(ClubRole, club=club, is_board=board_role)
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
||||
assert not form.is_valid()
|
||||
assert form.errors == errors
|
||||
|
||||
def test_user_already_in_club(self):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, user=user, club=club)
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||
role = baker.make(ClubRole, is_board=False)
|
||||
baker.make(Membership, user=user, club=role.club)
|
||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||
|
||||
def test_ok(self):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||
role = baker.make(ClubRole, is_board=False)
|
||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
|
||||
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
||||
|
||||
|
||||
class TestOldMembersView(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
club = baker.make(Club)
|
||||
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
club=club,
|
||||
is_board=itertools.cycle([True, True, False]),
|
||||
order=seq(0),
|
||||
_quantity=10,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.memberships = baker.make(
|
||||
Membership,
|
||||
role=iter(roles),
|
||||
@@ -604,3 +665,11 @@ class TestOldMembersView(TestCase):
|
||||
self.client.force_login(baker.make(User))
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_context_data(self):
|
||||
# mark a membership as not ended, to make sure it is excluded from the result
|
||||
self.memberships[0].end_date = None
|
||||
self.memberships[0].save()
|
||||
self.client.force_login(subscriber_user.make())
|
||||
res = self.client.get(self.url)
|
||||
assert list(res.context_data.get("old_members")) == self.memberships[1:]
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import PageRev, User
|
||||
@@ -59,7 +59,12 @@ def test_page_revision(client: Client):
|
||||
def test_edit_page(client: Client):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, user=user, club=club, role=3)
|
||||
baker.make(
|
||||
Membership,
|
||||
user=user,
|
||||
club=club,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||
content = "# foo\nLorem ipsum dolor sit amet"
|
||||
|
||||
53
club/tests/test_user_club_controller.py
Normal file
53
club/tests/test_user_club_controller.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from club.schemas import UserMembershipSchema
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Page
|
||||
|
||||
|
||||
class TestFetchClub(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = subscriber_user.make()
|
||||
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||
recipe = Recipe(
|
||||
Membership,
|
||||
user=cls.user,
|
||||
start_date=localdate() - timedelta(days=2),
|
||||
role=baker.make(ClubRole),
|
||||
)
|
||||
cls.members = Membership.objects.bulk_create(
|
||||
[
|
||||
recipe.prepare(club=clubs[0]),
|
||||
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
|
||||
recipe.prepare(club=clubs[1]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_fetch_memberships(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
res = self.client.get(
|
||||
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
|
||||
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
|
||||
]
|
||||
|
||||
def test_fetch_club_nb_queries(self):
|
||||
self.client.force_login(subscriber_user.make())
|
||||
with self.assertNumQueries(6):
|
||||
# - 5 queries for authentication
|
||||
# - 1 query for the actual data
|
||||
res = self.client.get(
|
||||
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
|
||||
)
|
||||
assert res.status_code == 200
|
||||
20
club/urls.py
20
club/urls.py
@@ -35,6 +35,10 @@ from club.views import (
|
||||
ClubPageEditView,
|
||||
ClubPageHistView,
|
||||
ClubRevView,
|
||||
ClubRoleBoardCreateView,
|
||||
ClubRoleMemberCreateView,
|
||||
ClubRolePresidencyCreateView,
|
||||
ClubRoleUpdateView,
|
||||
ClubSellingCSVView,
|
||||
ClubSellingView,
|
||||
ClubToolsView,
|
||||
@@ -71,6 +75,22 @@ urlpatterns = [
|
||||
ClubOldMembersView.as_view(),
|
||||
name="club_old_members",
|
||||
),
|
||||
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
|
||||
path(
|
||||
"<int:club_id>/role/new/president/",
|
||||
ClubRolePresidencyCreateView.as_view(),
|
||||
name="new_role_president",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/role/new/board/",
|
||||
ClubRoleBoardCreateView.as_view(),
|
||||
name="new_role_board",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/role/new/member/",
|
||||
ClubRoleMemberCreateView.as_view(),
|
||||
name="new_role_member",
|
||||
),
|
||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||
path(
|
||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||
|
||||
203
club/views.py
203
club/views.py
@@ -28,8 +28,11 @@ import csv
|
||||
import itertools
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin,
|
||||
PermissionRequiredMixin,
|
||||
UserPassesTestMixin,
|
||||
)
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
||||
from django.core.paginator import InvalidPage, Paginator
|
||||
@@ -44,18 +47,26 @@ from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
from django.views.generic.edit import (
|
||||
CreateView,
|
||||
DeleteView,
|
||||
FormMixin,
|
||||
UpdateView,
|
||||
)
|
||||
|
||||
from club.forms import (
|
||||
ClubAddMemberForm,
|
||||
ClubAdminEditForm,
|
||||
ClubEditForm,
|
||||
ClubOldMemberForm,
|
||||
ClubRoleCreateForm,
|
||||
ClubRoleFormSet,
|
||||
ClubSearchForm,
|
||||
JoinClubForm,
|
||||
MailingForm,
|
||||
SellingsForm,
|
||||
)
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
||||
from com.models import Poster
|
||||
from com.views import (
|
||||
PosterCreateBaseView,
|
||||
@@ -66,7 +77,12 @@ from com.views import (
|
||||
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
||||
from core.models import Page, PageRev
|
||||
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
|
||||
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
|
||||
from core.views.mixins import (
|
||||
AllowFragment,
|
||||
FragmentMixin,
|
||||
FragmentRenderer,
|
||||
TabedViewMixin,
|
||||
)
|
||||
from counter.models import Selling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -180,15 +196,41 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
return tab_list
|
||||
|
||||
|
||||
class ClubListView(ListView):
|
||||
"""List the Clubs."""
|
||||
class ClubListView(AllowFragment, FormMixin, ListView):
|
||||
"""List the clubs of the AE, with a form to perform basic search.
|
||||
|
||||
Notes:
|
||||
This view is fully public, because we want to advertise as much as possible
|
||||
the cultural life of the AE.
|
||||
In accordance with that matter, searching and listing the clubs is done
|
||||
entirely server-side (no AlpineJS involved) ;
|
||||
this is done this way in order to be sure the page is the most accessible
|
||||
and SEO-friendly possible, even if it makes the UX slightly less smooth.
|
||||
"""
|
||||
|
||||
model = Club
|
||||
template_name = "club/club_list.jinja"
|
||||
queryset = (
|
||||
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
|
||||
)
|
||||
context_object_name = "club_list"
|
||||
form_class = ClubSearchForm
|
||||
queryset = Club.objects.order_by("name")
|
||||
paginate_by = 20
|
||||
|
||||
def get_form_kwargs(self):
|
||||
res = super().get_form_kwargs()
|
||||
if self.request.method == "GET":
|
||||
res |= {"data": self.request.GET, "initial": self.request.GET}
|
||||
return res
|
||||
|
||||
def get_queryset(self):
|
||||
form: ClubSearchForm = self.get_form()
|
||||
qs = self.queryset
|
||||
if not form.is_bound:
|
||||
return qs.filter(is_active=True)
|
||||
if not form.is_valid():
|
||||
return qs.none()
|
||||
if name := form.cleaned_data.get("name"):
|
||||
qs = qs.filter(name__icontains=name)
|
||||
if (is_active := form.cleaned_data.get("club_status")) is not None:
|
||||
qs = qs.filter(is_active=is_active)
|
||||
return qs
|
||||
|
||||
|
||||
class ClubView(ClubTabsMixin, DetailView):
|
||||
@@ -318,7 +360,7 @@ class ClubMembersView(
|
||||
membership = self.object.get_membership_for(self.request.user)
|
||||
if (
|
||||
membership
|
||||
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||
and not membership.role.is_board
|
||||
and not self.request.user.has_perm("club.add_membership")
|
||||
):
|
||||
# Simple club members won't see the form anymore.
|
||||
@@ -343,8 +385,8 @@ class ClubMembersView(
|
||||
kwargs["members"] = list(
|
||||
self.object.members.ongoing()
|
||||
.annotate(is_editable=Q(id__in=editable))
|
||||
.order_by("-role")
|
||||
.select_related("user")
|
||||
.order_by("role__order")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
kwargs["can_end_membership"] = len(editable) > 0
|
||||
return kwargs
|
||||
@@ -372,12 +414,130 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"old_members": (
|
||||
self.object.members.exclude(end_date=None)
|
||||
.order_by("-role", "description", "-end_date")
|
||||
.select_related("user")
|
||||
.order_by("role__order", "description", "-end_date")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class ClubRoleUpdateView(
|
||||
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
|
||||
):
|
||||
form_class = ClubRoleFormSet
|
||||
model = Club
|
||||
template_name = "club/club_roles.jinja"
|
||||
pk_url_kwarg = "club_id"
|
||||
current_tab = "members"
|
||||
success_message = _("Club roles updated")
|
||||
|
||||
@cached_property
|
||||
def club(self) -> Club:
|
||||
return self.get_object()
|
||||
|
||||
def test_func(self):
|
||||
return self.club.can_roles_be_edited_by(self.request.user)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
|
||||
|
||||
def get_success_url(self):
|
||||
# if the user lost the right to view the role update page
|
||||
# (because it moved its own role out of the presidency),
|
||||
# redirect to the club member page, else stay on the same page.
|
||||
if self.club.can_roles_be_edited_by(self.request.user):
|
||||
return self.request.path
|
||||
return reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"user_role": ClubRole.objects.filter(
|
||||
club=self.object,
|
||||
members__user=self.request.user,
|
||||
members__end_date=None,
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
}
|
||||
|
||||
|
||||
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
|
||||
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
|
||||
|
||||
This view isn't meant to be called directly, but rather subclassed for each
|
||||
type of role that can exist :
|
||||
|
||||
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
|
||||
to create a presidency role
|
||||
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
|
||||
to create a board role
|
||||
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
|
||||
to create a member role
|
||||
|
||||
Each subclass have to override the following variables :
|
||||
|
||||
- `is_presidency` and `is_board`, indicating what type of role
|
||||
the view creates.
|
||||
- `role_description`, which is the title of the page, indication
|
||||
the user what kind of role is being created.
|
||||
|
||||
This way, we are making sure the correct type of role will
|
||||
be created, without bothering the user with the implementation details.
|
||||
"""
|
||||
|
||||
form_class = ClubRoleCreateForm
|
||||
model = ClubRole
|
||||
template_name = "core/create.jinja"
|
||||
success_message = _("Role %(name)s created")
|
||||
role_description = ""
|
||||
is_presidency: bool
|
||||
is_board: bool
|
||||
|
||||
@cached_property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, id=self.kwargs["club_id"])
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_authenticated and (
|
||||
self.request.user.has_perm("club.add_clubrole")
|
||||
or self.club.members.filter(
|
||||
user=self.request.user, role__is_presidency=True
|
||||
).exists()
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
"club": self.club,
|
||||
"is_presidency": self.is_presidency,
|
||||
"is_board": self.is_board,
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"object_name": self.role_description
|
||||
}
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
|
||||
|
||||
|
||||
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
|
||||
is_presidency = True
|
||||
is_board = True
|
||||
role_description = _("club role \u2013 presidency")
|
||||
|
||||
|
||||
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
|
||||
is_presidency = False
|
||||
is_board = True
|
||||
role_description = _("club role \u2013 board")
|
||||
|
||||
|
||||
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
|
||||
is_presidency = False
|
||||
is_board = False
|
||||
role_description = _("club role \u2013 member")
|
||||
|
||||
|
||||
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||
"""Sales of a club."""
|
||||
|
||||
@@ -544,6 +704,11 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
|
||||
template_name = "core/create.jinja"
|
||||
permission_required = "club.add_club"
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
self.object.create_default_roles()
|
||||
return res
|
||||
|
||||
|
||||
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
||||
"""Set a membership as being old."""
|
||||
@@ -724,9 +889,7 @@ class MailingAutoGenerationView(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
club = self.mailing.club
|
||||
self.mailing.subscriptions.all().delete()
|
||||
members = club.members.filter(
|
||||
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
|
||||
).exclude(end_date__lte=timezone.now())
|
||||
members = club.members.ongoing().filter(role__is_board=True)
|
||||
for member in members.all():
|
||||
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
||||
return redirect("club:mailing", club_id=club.id)
|
||||
|
||||
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, Group, User
|
||||
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.user = subscriber_user.make()
|
||||
baker.make(Membership, user=cls.user, club=cls.club, role=5)
|
||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@@ -244,9 +244,8 @@ class NewsListView(TemplateView):
|
||||
.filter(
|
||||
date_of_birth__month=localdate().month,
|
||||
date_of_birth__day=localdate().day,
|
||||
is_viewable=True,
|
||||
role__in=["STUDENT", "FORMER STUDENT"],
|
||||
)
|
||||
.filter(role__in=["STUDENT", "FORMER STUDENT"])
|
||||
.order_by("-date_of_birth"),
|
||||
key=lambda u: u.date_of_birth.year,
|
||||
)
|
||||
@@ -504,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
|
||||
self.object = form.instance
|
||||
form.is_valid() # Valid a first time to populate club field
|
||||
m = form.instance.club.get_membership_for(request.user)
|
||||
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
if m is None or not m.role.is_board:
|
||||
form.add_error(
|
||||
"club",
|
||||
ValidationError(
|
||||
|
||||
@@ -63,6 +63,7 @@ class UserAdmin(admin.ModelAdmin):
|
||||
"scrub_pict",
|
||||
"user_permissions",
|
||||
"groups",
|
||||
"whitelisted_users",
|
||||
)
|
||||
inlines = (UserBanInline,)
|
||||
search_fields = ["first_name", "last_name", "username"]
|
||||
@@ -98,9 +99,9 @@ class PageAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(SithFile)
|
||||
class SithFileAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "owner", "size", "date")
|
||||
list_display = ("name", "owner", "size", "date", "is_in_sas")
|
||||
autocomplete_fields = ("parent", "owner", "moderator")
|
||||
search_fields = ("name",)
|
||||
search_fields = ("name", "parent__name")
|
||||
|
||||
|
||||
@admin.register(OperationLog)
|
||||
|
||||
@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_files(self, search: Annotated[str, MinLen(1)]):
|
||||
return SithFile.objects.filter(name__icontains=search)
|
||||
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||
|
||||
|
||||
@api_controller("/group")
|
||||
|
||||
@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
|
||||
return False
|
||||
if super().has_permission():
|
||||
return True
|
||||
return self.club is not None and any(
|
||||
g.id == self.club.board_group_id for g in self.request.user.cached_groups
|
||||
return (
|
||||
self.club is not None
|
||||
and self.club.board_group_id in self.request.user.all_groups
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import localdate, now
|
||||
from model_bakery import seq
|
||||
from model_bakery.recipe import Recipe, related
|
||||
from model_bakery.recipe import Recipe, foreign_key, related
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import ClubRole, Membership
|
||||
from core.models import Group, User
|
||||
from subscription.models import Subscription
|
||||
|
||||
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club_id=settings.SITH_MAIN_CLUB_ID,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=foreign_key(
|
||||
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
|
||||
),
|
||||
)
|
||||
|
||||
board_user = Recipe(
|
||||
|
||||
@@ -39,12 +39,16 @@ class Command(BaseCommand):
|
||||
return None
|
||||
return xapian.version_string()
|
||||
|
||||
def _desired_version(self) -> str:
|
||||
def _desired_version(self) -> tuple[str, str, str]:
|
||||
with open(
|
||||
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
||||
) as f:
|
||||
pyproject = tomli.load(f)
|
||||
return pyproject["tool"]["xapian"]["version"]
|
||||
return (
|
||||
pyproject["tool"]["xapian"]["version"],
|
||||
pyproject["tool"]["xapian"]["core-sha256"],
|
||||
pyproject["tool"]["xapian"]["bindings-sha256"],
|
||||
)
|
||||
|
||||
def handle(self, *args, force: bool, **options):
|
||||
if not os.environ.get("VIRTUAL_ENV", None):
|
||||
@@ -53,7 +57,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
return
|
||||
|
||||
desired = self._desired_version()
|
||||
desired, core_checksum, bindings_checksum = self._desired_version()
|
||||
if desired == self._current_version():
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
@@ -65,7 +69,12 @@ class Command(BaseCommand):
|
||||
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
||||
)
|
||||
subprocess.run(
|
||||
[str(Path(__file__).parent / "install_xapian.sh"), desired],
|
||||
[
|
||||
str(Path(__file__).parent / "install_xapian.sh"),
|
||||
desired,
|
||||
core_checksum,
|
||||
bindings_checksum,
|
||||
],
|
||||
env=dict(os.environ),
|
||||
check=True,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
||||
# first argument of the script is Xapian version (e.g. 1.2.19)
|
||||
# second argument of the script is core sha256
|
||||
# second argument of the script is binding sha256
|
||||
VERSION="$1"
|
||||
CORE_SHA256="$2"
|
||||
BINDINGS_SHA256="$3"
|
||||
|
||||
# Cleanup env vars for auto discovery mechanism
|
||||
unset CPATH
|
||||
@@ -21,9 +25,15 @@ BINDINGS=xapian-bindings-$VERSION
|
||||
|
||||
# download
|
||||
echo "Downloading source..."
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
|
||||
|
||||
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
|
||||
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
||||
|
||||
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
|
||||
|
||||
|
||||
# extract
|
||||
echo "Extracting source..."
|
||||
tar xf "${CORE}.tar.xz"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# 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.
|
||||
#
|
||||
#
|
||||
@@ -36,7 +36,7 @@ from django.utils import timezone
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import Image
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate, Sith, Weekmail
|
||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
@@ -62,6 +62,13 @@ class PopulatedGroups(NamedTuple):
|
||||
campus_admin: Group
|
||||
|
||||
|
||||
class PopulatedClubs(NamedTuple):
|
||||
ae: Club
|
||||
troll: Club
|
||||
pdf: Club
|
||||
refound: Club
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||
@@ -110,28 +117,17 @@ class Command(BaseCommand):
|
||||
p.save(force_lock=True)
|
||||
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
main_club = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
main_club.board_group.permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=["view_subscription", "add_subscription"]
|
||||
)
|
||||
)
|
||||
bar_club = Club.objects.create(
|
||||
id=settings.SITH_PDF_CLUB_ID,
|
||||
name="PdF",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root, is_in_sas=True)
|
||||
clubs = self._create_clubs()
|
||||
|
||||
self.reset_index("club")
|
||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
||||
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
|
||||
self.reset_index("counter")
|
||||
counters = [
|
||||
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
|
||||
Counter(name="AE", club=clubs.ae, type="OFFICE"),
|
||||
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
|
||||
]
|
||||
Counter.objects.bulk_create(counters)
|
||||
bar_groups = []
|
||||
@@ -314,54 +310,41 @@ class Command(BaseCommand):
|
||||
self._create_subscription(tutu)
|
||||
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
||||
|
||||
# Clubs
|
||||
Club.objects.create(
|
||||
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
|
||||
Membership.objects.create(
|
||||
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
|
||||
)
|
||||
guyut = Club.objects.create(
|
||||
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
|
||||
)
|
||||
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
|
||||
troll = Club.objects.create(
|
||||
name="Troll Penché", address="Terre Du Milieu", parent=main_club
|
||||
)
|
||||
refound = Club.objects.create(
|
||||
name="Carte AE", address="Jamais imprimée", parent=main_club
|
||||
)
|
||||
|
||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
||||
Membership.objects.create(
|
||||
user=comunity,
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
start_date=localdate(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=clubs.pdf.roles.get(name="Membre du bureau"),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=sli,
|
||||
club=troll,
|
||||
role=9,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
|
||||
description="Padawan Troll",
|
||||
start_date=localdate() - timedelta(days=17),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=krophil,
|
||||
club=troll,
|
||||
role=10,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Président⸱e"),
|
||||
description="Maitre Troll",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=skia,
|
||||
club=troll,
|
||||
role=2,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||
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,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||
description="",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
end_date=localdate() - timedelta(days=100),
|
||||
@@ -378,7 +361,7 @@ class Command(BaseCommand):
|
||||
purchase_price="15",
|
||||
selling_price="15",
|
||||
special_selling_price="15",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
cotis2 = Product.objects.create(
|
||||
name="Cotis 2 semestres",
|
||||
@@ -387,7 +370,7 @@ class Command(BaseCommand):
|
||||
purchase_price="28",
|
||||
selling_price="28",
|
||||
special_selling_price="28",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
refill = Product.objects.create(
|
||||
name="Rechargement 15 €",
|
||||
@@ -396,7 +379,7 @@ class Command(BaseCommand):
|
||||
purchase_price="15",
|
||||
selling_price="15",
|
||||
special_selling_price="15",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
barb = Product.objects.create(
|
||||
name="Barbar",
|
||||
@@ -405,7 +388,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
cble = Product.objects.create(
|
||||
@@ -415,7 +398,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
cons = Product.objects.create(
|
||||
@@ -425,7 +408,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1",
|
||||
selling_price="1",
|
||||
special_selling_price="1",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
dcons = Product.objects.create(
|
||||
name="Déconsigne Eco-cup",
|
||||
@@ -434,7 +417,7 @@ class Command(BaseCommand):
|
||||
purchase_price="-1",
|
||||
selling_price="-1",
|
||||
special_selling_price="-1",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
cors = Product.objects.create(
|
||||
name="Corsendonk",
|
||||
@@ -443,7 +426,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
carolus = Product.objects.create(
|
||||
@@ -453,7 +436,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
Product.objects.create(
|
||||
@@ -462,7 +445,7 @@ class Command(BaseCommand):
|
||||
purchase_price="0",
|
||||
selling_price="0",
|
||||
special_selling_price="0",
|
||||
club=refound,
|
||||
club=clubs.refound,
|
||||
)
|
||||
groups.subscribers.products.add(
|
||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||
@@ -475,7 +458,7 @@ class Command(BaseCommand):
|
||||
eboutic = Counter.objects.get(name="Eboutic")
|
||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||
|
||||
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||
|
||||
ReturnableProduct.objects.create(
|
||||
product=cons, returned_product=dcons, max_return=3
|
||||
@@ -499,7 +482,7 @@ class Command(BaseCommand):
|
||||
end_date="7942-06-12 10:28:45+01",
|
||||
)
|
||||
el.view_groups.add(groups.public)
|
||||
el.edit_groups.add(main_club.board_group)
|
||||
el.edit_groups.add(clubs.ae.board_group)
|
||||
el.candidature_groups.add(groups.subscribers)
|
||||
el.vote_groups.add(groups.subscribers)
|
||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||
@@ -572,7 +555,7 @@ class Command(BaseCommand):
|
||||
title="Apero barman",
|
||||
summary="Viens boire un coup avec les barmans",
|
||||
content="Glou glou glou glou glou glou glou",
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -590,7 +573,7 @@ class Command(BaseCommand):
|
||||
content=(
|
||||
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
||||
),
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -606,7 +589,7 @@ class Command(BaseCommand):
|
||||
title="Repas fromager",
|
||||
summary="Wien manger du l'bon fromeug'",
|
||||
content="Fô viendre mangey d'la bonne fondue!",
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -622,7 +605,7 @@ class Command(BaseCommand):
|
||||
title="SdF",
|
||||
summary="Enjoy la fin des finaux!",
|
||||
content="Viens faire la fête avec tout plein de gens!",
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -640,7 +623,7 @@ class Command(BaseCommand):
|
||||
summary="Viens jouer!",
|
||||
content="Rejoins la fine équipe du Troll Penché et viens "
|
||||
"t'amuser le Vendredi soir!",
|
||||
club=troll,
|
||||
club=clubs.troll,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -693,21 +676,33 @@ class Command(BaseCommand):
|
||||
# SAS
|
||||
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
||||
if f.is_dir():
|
||||
album = Album.objects.create(name=f.name, is_moderated=True)
|
||||
album = Album(
|
||||
parent=sas,
|
||||
name=f.name,
|
||||
owner=root,
|
||||
is_folder=True,
|
||||
is_in_sas=True,
|
||||
is_moderated=True,
|
||||
)
|
||||
album.clean()
|
||||
album.save()
|
||||
for p in f.iterdir():
|
||||
file = resize_image(Image.open(p), 1000, "WEBP")
|
||||
pict = Picture(
|
||||
parent=album,
|
||||
name=p.name,
|
||||
original=file,
|
||||
file=file,
|
||||
owner=root,
|
||||
is_folder=False,
|
||||
is_in_sas=True,
|
||||
is_moderated=True,
|
||||
mime_type="image/webp",
|
||||
size=file.size,
|
||||
)
|
||||
pict.original.name = pict.name
|
||||
pict.generate_thumbnails()
|
||||
pict.file.name = p.name
|
||||
pict.full_clean()
|
||||
pict.generate_thumbnails()
|
||||
pict.save()
|
||||
album.generate_thumbnail()
|
||||
|
||||
img_skia = Picture.objects.get(name="skia.jpg")
|
||||
img_sli = Picture.objects.get(name="sli.jpg")
|
||||
@@ -765,6 +760,57 @@ class Command(BaseCommand):
|
||||
)
|
||||
s.save()
|
||||
|
||||
def _create_clubs(self) -> PopulatedClubs:
|
||||
ae = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
ae.board_group.permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=[
|
||||
"view_subscription",
|
||||
"add_subscription",
|
||||
"add_membership",
|
||||
"view_hidden_user",
|
||||
]
|
||||
)
|
||||
)
|
||||
pdf = Club.objects.create(
|
||||
id=settings.SITH_PDF_CLUB_ID,
|
||||
name="PdF",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
troll = Club.objects.create(
|
||||
name="Troll Penché", address="Terre Du Milieu", parent=ae
|
||||
)
|
||||
refound = Club.objects.create(
|
||||
name="Carte AE", address="Jamais imprimée", parent=ae
|
||||
)
|
||||
roles = []
|
||||
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
|
||||
board_roles = [
|
||||
"Trésorier⸱e",
|
||||
"Secrétaire",
|
||||
"Respo Info",
|
||||
"Respo Com",
|
||||
"Membre du bureau",
|
||||
]
|
||||
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
|
||||
for club in ae, pdf, troll, refound:
|
||||
for i, role in enumerate(presidency_roles):
|
||||
roles.append(
|
||||
ClubRole(
|
||||
club=club, order=i, name=role, is_presidency=True, is_board=True
|
||||
)
|
||||
)
|
||||
for i, role in enumerate(board_roles, start=len(presidency_roles)):
|
||||
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
|
||||
for i, role in enumerate(
|
||||
simple_roles, start=len(presidency_roles) + len(board_roles)
|
||||
):
|
||||
roles.append(ClubRole(club=club, order=i, name=role))
|
||||
ClubRole.objects.bulk_create(roles)
|
||||
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
|
||||
|
||||
def _create_groups(self) -> PopulatedGroups:
|
||||
perms = Permission.objects.all()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
|
||||
from django.utils.timezone import localdate, make_aware, now
|
||||
from faker import Faker
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.models import Group, User, UserBan
|
||||
from counter.models import (
|
||||
Counter,
|
||||
@@ -172,20 +172,25 @@ class Command(BaseCommand):
|
||||
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
||||
|
||||
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
|
||||
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
|
||||
roles: list[ClubRole] = list(club.roles.all())
|
||||
|
||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
|
||||
important_roles = [r for r in roles if r.is_board]
|
||||
important_roles.sort(key=lambda r: r.order)
|
||||
simple_board_role = important_roles.pop()
|
||||
member_roles = [r for r in roles if not r.is_board]
|
||||
user_idx = 0
|
||||
while (role := next(roles)) > 2:
|
||||
for _role in important_roles:
|
||||
# one member for each major role
|
||||
yield users[user_idx], role
|
||||
yield users[user_idx], _role
|
||||
user_idx += 1
|
||||
for _ in range(int(0.3 * (len(users) - user_idx))):
|
||||
# 30% of the remaining in the board
|
||||
yield users[user_idx], 2
|
||||
yield users[user_idx], simple_board_role
|
||||
user_idx += 1
|
||||
for remaining in users[user_idx + 1 :]:
|
||||
# everything else is a simple member
|
||||
yield remaining, 1
|
||||
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
|
||||
|
||||
memberships = []
|
||||
old_members = old_members.copy()
|
||||
@@ -197,19 +202,14 @@ class Command(BaseCommand):
|
||||
start_date=start,
|
||||
end_date=self.faker.past_date(start),
|
||||
user=old,
|
||||
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
|
||||
role=random.choice(roles),
|
||||
club=club,
|
||||
)
|
||||
)
|
||||
for member, role in zip_roles(members):
|
||||
start = self.faker.past_date("-1y")
|
||||
memberships.append(
|
||||
Membership(
|
||||
start_date=start,
|
||||
user=member,
|
||||
role=role,
|
||||
club=club,
|
||||
)
|
||||
Membership(start_date=start, user=member, role=role, club=club)
|
||||
)
|
||||
memberships = Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-26 15:01
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.migrations.state import StateApps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import core.models
|
||||
|
||||
|
||||
def remove_sas_sithfiles(apps: StateApps, schema_editor):
|
||||
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
|
||||
SithFile.objects.filter(is_in_sas=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0048_alter_user_options"),
|
||||
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
|
||||
)
|
||||
]
|
||||
37
core/migrations/0049_user_whitelisted_users.py
Normal file
37
core/migrations/0049_user_whitelisted_users.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-14 08:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0048_alter_user_options")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="whitelisted_users",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Even if this profile is hidden, "
|
||||
"the users in this list will still be able to see it."
|
||||
),
|
||||
related_name="visible_by_whitelist",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="whitelisted users",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="preferences",
|
||||
name="show_my_stats",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"Allow subscribers (or whitelisted users "
|
||||
"if your profile is hidden) to access your AE account stats."
|
||||
),
|
||||
verbose_name="show your stats to others",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
# Generated by Django 4.2.17 on 2025-02-14 11:58
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0049_remove_sithfiles")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
|
||||
120
core/models.py
120
core/models.py
@@ -131,7 +131,7 @@ class UserQuerySet(models.QuerySet):
|
||||
if user.has_perm("core.view_hidden_user"):
|
||||
return self
|
||||
if user.has_perm("core.view_user"):
|
||||
return self.filter(is_viewable=True)
|
||||
return self.filter(Q(is_viewable=True) | Q(whitelisted_users=user))
|
||||
if user.is_anonymous:
|
||||
return self.none()
|
||||
return self.filter(id=user.id)
|
||||
@@ -279,6 +279,16 @@ class User(AbstractUser):
|
||||
),
|
||||
default=True,
|
||||
)
|
||||
whitelisted_users = models.ManyToManyField(
|
||||
"User",
|
||||
related_name="visible_by_whitelist",
|
||||
verbose_name=_("whitelisted users"),
|
||||
help_text=_(
|
||||
"Even if this profile is hidden, "
|
||||
"the users in this list will still be able to see it."
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
|
||||
|
||||
objects = CustomUserManager()
|
||||
@@ -356,23 +366,27 @@ class User(AbstractUser):
|
||||
)
|
||||
if group_id is None:
|
||||
return False
|
||||
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
|
||||
return self.is_subscribed
|
||||
if group_id == settings.SITH_GROUP_ROOT_ID:
|
||||
return self.is_root
|
||||
return any(g.id == group_id for g in self.cached_groups)
|
||||
return group_id in self.all_groups
|
||||
|
||||
@cached_property
|
||||
def cached_groups(self) -> list[Group]:
|
||||
def all_groups(self) -> dict[int, Group]:
|
||||
"""Get the list of groups this user is in."""
|
||||
return list(self.groups.all())
|
||||
additional_groups = []
|
||||
if self.is_subscribed:
|
||||
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||
if self.is_superuser:
|
||||
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
|
||||
qs = self.groups.all()
|
||||
if additional_groups:
|
||||
# This is somewhat counter-intuitive, but this query runs way faster with
|
||||
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
|
||||
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
|
||||
qs = qs.union(Group.objects.filter(id__in=additional_groups))
|
||||
return {g.id: g for g in qs}
|
||||
|
||||
@cached_property
|
||||
def is_root(self) -> bool:
|
||||
if self.is_superuser:
|
||||
return True
|
||||
root_id = settings.SITH_GROUP_ROOT_ID
|
||||
return any(g.id == root_id for g in self.cached_groups)
|
||||
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
|
||||
|
||||
@cached_property
|
||||
def is_board_member(self) -> bool:
|
||||
@@ -514,7 +528,7 @@ class User(AbstractUser):
|
||||
self.username = user_name
|
||||
return user_name
|
||||
|
||||
def is_owner(self, obj):
|
||||
def is_owner(self, obj: models.Model):
|
||||
"""Determine if the object is owned by the user."""
|
||||
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
|
||||
return True
|
||||
@@ -522,7 +536,7 @@ class User(AbstractUser):
|
||||
return True
|
||||
return self.is_root
|
||||
|
||||
def can_edit(self, obj):
|
||||
def can_edit(self, obj: models.Model):
|
||||
"""Determine if the object can be edited by the user."""
|
||||
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
|
||||
return True
|
||||
@@ -536,11 +550,9 @@ class User(AbstractUser):
|
||||
pks = list(obj.edit_groups.values_list("id", flat=True))
|
||||
if any(self.is_in_group(pk=pk) for pk in pks):
|
||||
return True
|
||||
if isinstance(obj, User) and obj == self:
|
||||
return True
|
||||
return self.is_owner(obj)
|
||||
|
||||
def can_view(self, obj):
|
||||
def can_view(self, obj: models.Model):
|
||||
"""Determine if the object can be viewed by the user."""
|
||||
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
|
||||
return True
|
||||
@@ -559,14 +571,35 @@ class User(AbstractUser):
|
||||
return True
|
||||
return self.can_edit(obj)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
return user.is_root or user.is_board_member
|
||||
def can_be_edited_by(self, user: User):
|
||||
return user == self or user.is_root or user.is_board_member
|
||||
|
||||
def can_be_viewed_by(self, user: User) -> bool:
|
||||
"""Check if the given user can be viewed by this user.
|
||||
|
||||
Given users A and B. A can be viewed by B if :
|
||||
|
||||
- A and B are the same user
|
||||
- or B has the permission to view hidden users
|
||||
- or B can view users in general and A didn't hide its profile
|
||||
- or B is in A's whitelist.
|
||||
"""
|
||||
|
||||
def is_in_whitelist(u: User):
|
||||
if (
|
||||
hasattr(self, "_prefetched_objects_cache")
|
||||
and "whitelisted_users" in self._prefetched_objects_cache
|
||||
):
|
||||
return u in self.whitelisted_users.all()
|
||||
return self.whitelisted_users.contains(u)
|
||||
|
||||
return (
|
||||
user.id == self.id
|
||||
or user.has_perm("core.view_hidden_user")
|
||||
or (user.has_perm("core.view_user") and self.is_viewable)
|
||||
or (
|
||||
user.has_perm("core.view_user")
|
||||
and (self.is_viewable or is_in_whitelist(user))
|
||||
)
|
||||
)
|
||||
|
||||
def get_mini_item(self):
|
||||
@@ -746,7 +779,14 @@ class Preferences(models.Model):
|
||||
User, related_name="_preferences", on_delete=models.CASCADE
|
||||
)
|
||||
receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False)
|
||||
show_my_stats = models.BooleanField(_("show your stats to others"), default=False)
|
||||
show_my_stats = models.BooleanField(
|
||||
_("show your stats to others"),
|
||||
help_text=_(
|
||||
"Allow subscribers (or whitelisted users "
|
||||
"if your profile is hidden) to access your AE account stats."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
notify_on_click = models.BooleanField(
|
||||
_("get a notification for every click"), default=False
|
||||
)
|
||||
@@ -833,6 +873,9 @@ class SithFile(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||
is_in_sas = models.BooleanField(
|
||||
_("is in the SAS"), default=False, db_index=True
|
||||
) # Allows to query this flag, updated at each call to save()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("file")
|
||||
@@ -841,10 +884,22 @@ class SithFile(models.Model):
|
||||
return self.get_parent_path() + "/" + self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
|
||||
self.is_in_sas = sas in self.get_parent_list() or self == sas
|
||||
adding = self._state.adding
|
||||
super().save(*args, **kwargs)
|
||||
if adding:
|
||||
self.copy_rights()
|
||||
if self.is_in_sas:
|
||||
for user in User.objects.filter(
|
||||
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
||||
):
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("sas:moderation"),
|
||||
type="SAS_MODERATION",
|
||||
param="1",
|
||||
).save()
|
||||
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
if user.is_anonymous:
|
||||
@@ -857,6 +912,8 @@ class SithFile(models.Model):
|
||||
return user.is_board_member
|
||||
if user.is_com_admin:
|
||||
return True
|
||||
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||
return True
|
||||
return user.id == self.owner_id
|
||||
|
||||
def can_be_viewed_by(self, user: User) -> bool:
|
||||
@@ -883,6 +940,8 @@ class SithFile(models.Model):
|
||||
super().clean()
|
||||
if "/" in self.name:
|
||||
raise ValidationError(_("Character '/' not authorized in name"))
|
||||
if self == self.parent:
|
||||
raise ValidationError(_("Loop in folder tree"), code="loop")
|
||||
if self == self.parent or (
|
||||
self.parent is not None and self in self.get_parent_list()
|
||||
):
|
||||
@@ -963,6 +1022,18 @@ class SithFile(models.Model):
|
||||
def is_file(self):
|
||||
return not self.is_folder
|
||||
|
||||
@cached_property
|
||||
def as_picture(self):
|
||||
from sas.models import Picture
|
||||
|
||||
return Picture.objects.filter(id=self.id).first()
|
||||
|
||||
@cached_property
|
||||
def as_album(self):
|
||||
from sas.models import Album
|
||||
|
||||
return Album.objects.filter(id=self.id).first()
|
||||
|
||||
def get_parent_list(self):
|
||||
parents = []
|
||||
current = self.parent
|
||||
@@ -1068,10 +1139,7 @@ class PageQuerySet(models.QuerySet):
|
||||
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
|
||||
if user.has_perm("core.view_page"):
|
||||
return self.all()
|
||||
groups_ids = [g.id for g in user.cached_groups]
|
||||
if user.is_subscribed:
|
||||
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||
return self.filter(view_groups__in=groups_ids)
|
||||
return self.filter(view_groups__in=user.all_groups)
|
||||
|
||||
|
||||
# This function prevents generating migration upon settings change
|
||||
@@ -1345,7 +1413,7 @@ class PageRev(models.Model):
|
||||
return self.page.can_be_edited_by(user)
|
||||
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
|
||||
return self.page.owner_group_id in user.all_groups
|
||||
|
||||
def similarity_ratio(self, text: str) -> float:
|
||||
"""Similarity ratio between this revision's content and the given text.
|
||||
|
||||
@@ -1,18 +1,136 @@
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
|
||||
import {
|
||||
type InheritedHtmlElement,
|
||||
inheritHtmlElement,
|
||||
registerComponent,
|
||||
} from "#core:utils/web-components.ts";
|
||||
|
||||
/**
|
||||
* ElementOnce web components
|
||||
*
|
||||
* Those elements ensures that their content is always included only once on a document
|
||||
* They are compatible with elements that are not managed with our Web Components
|
||||
**/
|
||||
export interface ElementOnce<K extends keyof HTMLElementTagNameMap>
|
||||
extends InheritedHtmlElement<K> {
|
||||
getElementQuerySelector(): string;
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an abstract class for ElementOnce types Web Components
|
||||
**/
|
||||
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||
abstract class ElementOnceImpl
|
||||
extends inheritHtmlElement(tagName)
|
||||
implements ElementOnce<K>
|
||||
{
|
||||
abstract getElementQuerySelector(): string;
|
||||
|
||||
clearNode() {
|
||||
while (this.firstChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.clearNode();
|
||||
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// The MutationObserver can't see web components being removed
|
||||
// It also can't see if something is removed inside after the component gets deleted
|
||||
// We need to manually clear the containing node to trigger the observer
|
||||
this.clearNode();
|
||||
}
|
||||
}
|
||||
return ElementOnceImpl;
|
||||
}
|
||||
|
||||
// Set of ElementOnce type components to refresh with the observer
|
||||
const registeredComponents: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Helper to register ElementOnce types Web Components
|
||||
* It's a wrapper around registerComponent that registers that component on
|
||||
* a MutationObserver that activates a refresh on them when elements are removed
|
||||
*
|
||||
* You are not supposed to unregister an element
|
||||
**/
|
||||
export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
|
||||
registeredComponents.add(name);
|
||||
return registerComponent(name, options);
|
||||
}
|
||||
|
||||
// Refresh all ElementOnce components on the document based on the tag name of the removed element
|
||||
const refreshElement = <
|
||||
T extends keyof HTMLElementTagNameMap,
|
||||
K extends keyof HTMLElementTagNameMap,
|
||||
>(
|
||||
components: HTMLCollectionOf<ElementOnce<T>>,
|
||||
removedTagName: K,
|
||||
) => {
|
||||
for (const element of components) {
|
||||
// We can't guess if an element is compatible before we get one
|
||||
// We exit the function completely if it's not compatible
|
||||
if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Since we need to pause the observer, we make an helper to start it with consistent arguments
|
||||
const startObserver = (observer: MutationObserver) => {
|
||||
observer.observe(document, {
|
||||
// We want to also listen for elements contained in the header (eg: link)
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh ElementOnce components when changes happens
|
||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
|
||||
observer.disconnect();
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node.nodeType !== node.ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
for (const registered of registeredComponents) {
|
||||
refreshElement(
|
||||
document.getElementsByTagName(registered) as HTMLCollectionOf<
|
||||
ElementOnce<"html"> // The specific tag doesn't really matter
|
||||
>,
|
||||
(node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// We then resume the observer
|
||||
startObserver(observer);
|
||||
});
|
||||
|
||||
startObserver(observer);
|
||||
|
||||
/**
|
||||
* Web component used to import css files only once
|
||||
* If called multiple times or the file was already imported, it does nothing
|
||||
**/
|
||||
@registerComponent("link-once")
|
||||
export class LinkOnce extends inheritHtmlElement("link") {
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
@registerElementOnce("link-once")
|
||||
export class LinkOnce extends elementOnce("link") {
|
||||
getElementQuerySelector(): string {
|
||||
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
||||
const href = this.node.attributes.getNamedItem("href").nodeValue;
|
||||
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
|
||||
* Web component used to import javascript files only once
|
||||
* If called multiple times or the file was already imported, it does nothing
|
||||
**/
|
||||
@registerComponent("script-once")
|
||||
@registerElementOnce("script-once")
|
||||
export class ScriptOnce extends inheritHtmlElement("script") {
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
// We get src from node.attributes instead of node.src to avoid getting the domain part
|
||||
const src = this.node.attributes.getNamedItem("src").nodeValue;
|
||||
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
getElementQuerySelector(): string {
|
||||
// We get href from node.attributes instead of node.src to avoid getting the domain part
|
||||
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ export class NfcInput extends inheritHtmlElement("input") {
|
||||
window.alert(gettext("Unsupported NFC card"));
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/noUndeclaredVariables: browser API
|
||||
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
|
||||
this.removeAttribute("scan");
|
||||
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
|
||||
|
||||
77
core/static/bundled/core/dynamic-formset-index.ts
Normal file
77
core/static/bundled/core/dynamic-formset-index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
interface Config {
|
||||
/**
|
||||
* The prefix of the formset, in case it has been changed.
|
||||
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
|
||||
*/
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
|
||||
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
/**
|
||||
* Alpine data element to allow the dynamic addition of forms to a formset.
|
||||
*
|
||||
* To use this, you need :
|
||||
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
|
||||
* - a template containing the empty form
|
||||
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
|
||||
* noted by `x-ref="formTemplate"`
|
||||
* - a button with `@click="addForm"`
|
||||
* - you may also have one or more buttons with `@click="removeForm(element)"`,
|
||||
* where `element` is the HTML element containing the form.
|
||||
*
|
||||
* For an example of how this is used, you can have a look to
|
||||
* `counter/templates/counter/product_form.jinja`
|
||||
*/
|
||||
Alpine.data("dynamicFormSet", (config?: Config) => ({
|
||||
init() {
|
||||
this.formContainer = this.$refs.formContainer as HTMLElement;
|
||||
this.nbForms = this.formContainer.children.length as number;
|
||||
this.template = this.$refs.formTemplate as HTMLTemplateElement;
|
||||
const prefix = config?.prefix ?? "form";
|
||||
this.$root
|
||||
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
|
||||
.setAttribute(":value", "nbForms");
|
||||
},
|
||||
|
||||
addForm() {
|
||||
this.formContainer.appendChild(document.importNode(this.template.content, true));
|
||||
const newForm = this.formContainer.lastElementChild;
|
||||
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
|
||||
"input, select, textarea",
|
||||
);
|
||||
for (const el of inputs) {
|
||||
el.name = el.name.replace("__prefix__", this.nbForms.toString());
|
||||
el.id = el.id.replace("__prefix__", this.nbForms.toString());
|
||||
}
|
||||
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
|
||||
for (const el of labels) {
|
||||
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
|
||||
}
|
||||
inputs[0].focus();
|
||||
this.nbForms += 1;
|
||||
},
|
||||
|
||||
removeForm(container: HTMLDivElement) {
|
||||
container.remove();
|
||||
this.nbForms -= 1;
|
||||
// adjust the id of remaining forms
|
||||
for (let i = 0; i < this.nbForms; i++) {
|
||||
const form: HTMLDivElement = this.formContainer.children[i];
|
||||
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
|
||||
"input, select, textarea",
|
||||
);
|
||||
for (const el of inputs) {
|
||||
el.name = el.name.replace(/\d+/, i.toString());
|
||||
el.id = el.id.replace(/\d+/, i.toString());
|
||||
}
|
||||
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
|
||||
for (const el of labels) {
|
||||
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -23,10 +23,17 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
|
||||
* The technique is to:
|
||||
* create a new web component
|
||||
* create the desired type inside
|
||||
* pass all attributes to the child component
|
||||
* move all attributes to the child component
|
||||
* store is at as `node` inside the parent
|
||||
*
|
||||
* Since we can't use the generic type to instantiate the node, we create a generator function
|
||||
**/
|
||||
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
|
||||
extends HTMLElement {
|
||||
readonly inheritedTagName: K;
|
||||
node: HTMLElementTagNameMap[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function that creates an InheritedHtmlElement compatible class
|
||||
*
|
||||
* ```js
|
||||
* class MyClass extends inheritHtmlElement("select") {
|
||||
@@ -35,11 +42,15 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
|
||||
* ```
|
||||
**/
|
||||
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||
return class Inherited extends HTMLElement {
|
||||
protected node: HTMLElementTagNameMap[K];
|
||||
return class InheritedHtmlElementImpl
|
||||
extends HTMLElement
|
||||
implements InheritedHtmlElement<K>
|
||||
{
|
||||
readonly inheritedTagName = tagName;
|
||||
node: HTMLElementTagNameMap[K];
|
||||
|
||||
connectedCallback(autoAddNode?: boolean) {
|
||||
this.node = document.createElement(tagName);
|
||||
this.node = document.createElement(this.inheritedTagName);
|
||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||
for (const attr of this.attributes) {
|
||||
if (attr.name in this.node) {
|
||||
@@ -47,6 +58,10 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
||||
}
|
||||
}
|
||||
|
||||
// We move compatible attributes to the child element
|
||||
// This avoids weird inconsistencies between attributes
|
||||
// when we manipulate the dom in the future
|
||||
// This is especially important when using attribute based reactivity
|
||||
for (const attr of attributes) {
|
||||
this.removeAttributeNode(attr);
|
||||
this.node.setAttributeNode(attr);
|
||||
|
||||
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
|
||||
opacity: 0;
|
||||
|
||||
@supports (max-height: calc-size(max-content, size)) {
|
||||
max-height: 0px;
|
||||
max-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +71,12 @@ details.accordion>.accordion-content {
|
||||
}
|
||||
}
|
||||
|
||||
// ::details-content isn't available on firefox yet
|
||||
// ::details-content is available on firefox only since september 2025
|
||||
// (and wasn't available when this code was initially written)
|
||||
// we use .accordion-content as a workaround
|
||||
// But we need to use ::details-content for chrome because it's
|
||||
// not working correctly otherwise
|
||||
// it only happen in chrome, not safari or firefox
|
||||
// it only happens in chrome, not safari or firefox
|
||||
// Note: `selector` is not supported by scss so we comment it out to
|
||||
// avoid compiling it and sending it straight to the css
|
||||
// This is a trick that comes from here :
|
||||
|
||||
@@ -115,7 +115,6 @@ blockquote:before,
|
||||
blockquote:after,
|
||||
q:before,
|
||||
q:after {
|
||||
content: "";
|
||||
content: none;
|
||||
}
|
||||
table {
|
||||
|
||||
@@ -157,6 +157,7 @@ form {
|
||||
margin-bottom: .25rem;
|
||||
font-size: 80%;
|
||||
display: block;
|
||||
max-width: calc(100% - calc(var(--nf-input-size) * 2))
|
||||
}
|
||||
|
||||
fieldset {
|
||||
|
||||
@@ -5,17 +5,6 @@
|
||||
}
|
||||
|
||||
.profile {
|
||||
&-visible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding-top: 10px;
|
||||
input[type="checkbox"]+label {
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&-pictures {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
@@ -19,28 +19,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-cards,
|
||||
&-trombi {
|
||||
>p {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: justify;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
|
||||
>input,
|
||||
>select {
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-submit-btn {
|
||||
margin-top: 10px !important;
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.justify {
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
|
||||
|
||||
<script src="{{ url('javascript-catalog') }}"></script>
|
||||
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
|
||||
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
|
||||
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
|
||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
|
||||
<li><a href="{{ url("core:page", page_name="ae") }}">{% trans %}AE{% endtrans %}</a></li>
|
||||
<li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
<details name="navbar" class="menu">
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<div id="quick-notifications"
|
||||
x-data="{
|
||||
messages: [
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
{
|
||||
tag: '{{ message.tags }}',
|
||||
text: '{{ message }}',
|
||||
},
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{%- for message in messages -%}
|
||||
{%- if not message.extra_tags -%}
|
||||
{ tag: '{{ message.tags }}', text: '{{ message }}' },
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
]
|
||||
}"
|
||||
@quick-notification-add="(e) => messages.push(e?.detail)"
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{# if the template context has the `object_name` variable,
|
||||
then this one will be used in the page title,
|
||||
instead of the result of `str(object)` #}
|
||||
{% if not object_name %}
|
||||
{% set object_name=form.instance.__class__._meta.verbose_name %}
|
||||
{% endif %}
|
||||
|
||||
{% block title %}
|
||||
{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
|
||||
{% trans name=object_name %}Create {{ name }}{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
|
||||
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2>
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
|
||||
@@ -48,6 +48,6 @@
|
||||
>{% trans %}Delete{% endtrans %}</button></p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{{ paginate_htmx(page_obj, paginator) }}
|
||||
{{ paginate_htmx(request, page_obj, paginator) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
33
core/templates/core/fragment/user_visibility.jinja
Normal file
33
core/templates/core/fragment/user_visibility.jinja
Normal file
@@ -0,0 +1,33 @@
|
||||
<form
|
||||
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
|
||||
hx-disabled-elt="find input[type='submit']"
|
||||
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
|
||||
>
|
||||
{% for message in messages %}
|
||||
{% if message.extra_tags=="visibility" %}
|
||||
<div class="alert alert-success">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors() }}
|
||||
<fieldset class="form-group">
|
||||
{{ form.is_viewable|add_attr("x-model=isViewable") }}
|
||||
{{ form.is_viewable.label_tag() }}
|
||||
<span class="helptext">{{ form.is_viewable.help_text }}</span>
|
||||
{{ form.is_viewable.errors }}
|
||||
</fieldset>
|
||||
<fieldset class="form-group" x-show="!isViewable">
|
||||
{{ form.whitelisted_users.as_field_group() }}
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
{{ form.show_my_stats }}
|
||||
{{ form.show_my_stats.label_tag() }}
|
||||
<span class="helptext">
|
||||
{{ form.show_my_stats.help_text }}
|
||||
</span>
|
||||
{{ form.show_my_stats.errors }}
|
||||
</fieldset>
|
||||
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
||||
</form>
|
||||
@@ -118,20 +118,21 @@
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_jinja(current_page, paginator) %}
|
||||
{% macro paginate_jinja(request, current_page, paginator) %}
|
||||
{# Add pagination buttons for pages without Alpine.
|
||||
|
||||
This must be coupled with a view that handles pagination
|
||||
with the Django Paginator object.
|
||||
|
||||
Parameters:
|
||||
request (django.http.request.HttpRequest): the current django request
|
||||
current_page (django.core.paginator.Page): the current page object
|
||||
paginator (django.core.paginator.Paginator): the paginator object
|
||||
#}
|
||||
{{ paginate_server_side(current_page, paginator, False) }}
|
||||
{{ paginate_server_side(request, current_page, paginator, "") }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_htmx(current_page, paginator) %}
|
||||
{% macro paginate_htmx(request, current_page, paginator, htmx_target="#content") %}
|
||||
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
||||
|
||||
This must be coupled with a view that handles pagination
|
||||
@@ -140,24 +141,26 @@
|
||||
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||
|
||||
Parameters:
|
||||
request (django.http.request.HttpRequest): the current django request
|
||||
current_page (django.core.paginator.Page): the current page object
|
||||
paginator (django.core.paginator.Paginator): the paginator object
|
||||
htmx_target (string): htmx target selector (default '#content')
|
||||
#}
|
||||
{{ paginate_server_side(current_page, paginator, True) }}
|
||||
{{ paginate_server_side(request, current_page, paginator, htmx_target) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_server_side(current_page, paginator, use_htmx) %}
|
||||
{% macro paginate_server_side(request, current_page, paginator, htmx_target) %}
|
||||
<nav class="pagination">
|
||||
{% if current_page.has_previous() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
{% if htmx_target -%}
|
||||
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-target="{{ htmx_target }}"
|
||||
hx-push-url="true"
|
||||
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
|
||||
{%- else -%}
|
||||
href="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>
|
||||
@@ -174,13 +177,13 @@
|
||||
<strong>{{ paginator.ELLIPSIS }}</strong>
|
||||
{% else %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{ querystring(page=i) }}"
|
||||
{% if htmx_target -%}
|
||||
hx-get="?{{ querystring(request, page=i) }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-target="{{ htmx_target }}"
|
||||
hx-push-url="true"
|
||||
{%- else -%}
|
||||
href="?{{ querystring(page=i) }}"
|
||||
href="?{{ querystring(request, page=i) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>{{ i }}</button>
|
||||
@@ -189,14 +192,14 @@
|
||||
{% endfor %}
|
||||
{% if current_page.has_next() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{querystring(page=current_page.next_page_number())}}"
|
||||
{% if htmx_target -%}
|
||||
hx-get="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-target="{{ htmx_target }}"
|
||||
hx-push-url="true"
|
||||
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
|
||||
{%- else -%}
|
||||
href="?{{querystring(page=current_page.next_page_number())}}"
|
||||
href="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
{%- endif -%}
|
||||
><button>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
@@ -247,15 +250,8 @@
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro querystring() %}
|
||||
{%- for key, values in request.GET.lists() -%}
|
||||
{%- if key not in kwargs -%}
|
||||
{%- for value in values -%}
|
||||
{{ key }}={{ value }}&
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- for key, value in kwargs.items() -%}
|
||||
{{ key }}={{ value }}&
|
||||
{%- endfor -%}
|
||||
{% macro querystring(request) %}
|
||||
{%- set qs = request.GET.copy() -%}
|
||||
{%- do qs.update(kwargs) -%}
|
||||
{{- qs | urlencode -}}
|
||||
{% endmacro %}
|
||||
@@ -23,10 +23,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in profile.memberships.filter(end_date=None).all() %}
|
||||
{% for m in profile.memberships.ongoing().select_related("role") %}
|
||||
<tr>
|
||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
{% if m.can_be_edited_by(user) %}
|
||||
@@ -65,10 +65,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in profile.memberships.exclude(end_date=None).all() %}
|
||||
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
|
||||
<tr>
|
||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
<td>{{ m.end_date }}</td>
|
||||
|
||||
@@ -147,18 +147,7 @@
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
{# Checkboxes #}
|
||||
<div class="profile-visible">
|
||||
<div class="row">
|
||||
{{ form.is_viewable }}
|
||||
{{ form.is_viewable.label_tag() }}
|
||||
</div>
|
||||
<span class="helptext">
|
||||
{{ form.is_viewable.help_text }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="final-actions">
|
||||
|
||||
{%- if form.instance == user -%}
|
||||
<p>
|
||||
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
|
||||
@@ -170,7 +159,6 @@
|
||||
</a>
|
||||
</p>
|
||||
{%- endif -%}
|
||||
|
||||
<p>
|
||||
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
|
||||
</p>
|
||||
|
||||
@@ -11,30 +11,22 @@
|
||||
{% block content %}
|
||||
<div class="main">
|
||||
<h2>{% trans %}Preferences{% endtrans %}</h2>
|
||||
<h3>{% trans %}General{% endtrans %}</h3>
|
||||
<form class="form form-general" action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
|
||||
</form>
|
||||
|
||||
<h3>{% trans %}Trombi{% endtrans %}</h3>
|
||||
|
||||
{% if trombi_form %}
|
||||
<form class="form form-trombi" action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ trombi_form.as_p() }}
|
||||
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
||||
<br />
|
||||
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<h3>{% trans %}Notifications{% endtrans %}</h3>
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="form form-general">
|
||||
{{ form.as_p() }}
|
||||
</div>
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
|
||||
</form>
|
||||
|
||||
<br />
|
||||
<h3>{% trans %}Visibility{% endtrans %}</h3>
|
||||
|
||||
{{ user_visibility_fragment }}
|
||||
|
||||
<br />
|
||||
{% if student_card_fragment %}
|
||||
<h3>{% trans %}Student card{% endtrans %}</h3>
|
||||
{{ student_card_fragment }}
|
||||
@@ -43,5 +35,21 @@
|
||||
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
<h3>{% trans %}Trombi{% endtrans %}</h3>
|
||||
|
||||
{% if trombi_form %}
|
||||
<form action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ trombi_form.as_p() }}
|
||||
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
|
||||
</form>
|
||||
{% else %}
|
||||
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
|
||||
<br />
|
||||
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
|
||||
group_in = baker.make(Group)
|
||||
self.public_user.groups.add(group_in)
|
||||
|
||||
# clear the cached property `User.cached_groups`
|
||||
self.public_user.__dict__.pop("cached_groups", None)
|
||||
# clear the cached property `User.all_groups`
|
||||
self.public_user.__dict__.pop("all_groups", None)
|
||||
# Test when the user is in the group
|
||||
with self.assertNumQueries(1):
|
||||
with self.assertNumQueries(2):
|
||||
self.public_user.is_in_group(pk=group_in.id)
|
||||
with self.assertNumQueries(0):
|
||||
self.public_user.is_in_group(pk=group_in.id)
|
||||
|
||||
group_not_in = baker.make(Group)
|
||||
self.public_user.__dict__.pop("cached_groups", None)
|
||||
self.public_user.__dict__.pop("all_groups", None)
|
||||
# Test when the user is not in the group
|
||||
with self.assertNumQueries(1):
|
||||
self.public_user.is_in_group(pk=group_not_in.id)
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.test import Client, TestCase
|
||||
@@ -18,8 +17,8 @@ from pytest_django.asserts import assertNumQueries
|
||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.baker_recipes import picture_recipe
|
||||
from sas.models import Picture
|
||||
from sith import settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -31,19 +30,24 @@ class TestImageAccess:
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
),
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sas_image_access(self, user_factory: Callable[[], User]):
|
||||
"""Test that only authorized users can access the sas image."""
|
||||
user = user_factory()
|
||||
picture = picture_recipe.make()
|
||||
assert user.can_edit(picture)
|
||||
picture: SithFile = baker.make(
|
||||
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
)
|
||||
assert picture.is_owned_by(user)
|
||||
|
||||
def test_sas_image_access_owner(self):
|
||||
"""Test that the owner of the image can access it."""
|
||||
user = baker.make(User)
|
||||
picture = picture_recipe.make(owner=user)
|
||||
assert user.can_edit(picture)
|
||||
picture: Picture = baker.make(Picture, owner=user)
|
||||
assert picture.is_owned_by(user)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_factory",
|
||||
@@ -59,41 +63,7 @@ class TestImageAccess:
|
||||
user = user_factory()
|
||||
owner = baker.make(User)
|
||||
picture: Picture = baker.make(Picture, owner=owner)
|
||||
assert not user.can_edit(picture)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserPicture:
|
||||
def test_anonymous_user_unauthorized(self, client):
|
||||
"""An anonymous user shouldn't have access to an user's photo page."""
|
||||
response = client.get(
|
||||
reverse(
|
||||
"sas:user_pictures",
|
||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "status"),
|
||||
[
|
||||
("guy", 403),
|
||||
("root", 200),
|
||||
("skia", 200),
|
||||
("sli", 200),
|
||||
],
|
||||
)
|
||||
def test_page_is_working(self, client, username, status):
|
||||
"""Only user that subscribed (or admins) should be able to see the page."""
|
||||
# Test for simple user
|
||||
client.force_login(User.objects.get(username=username))
|
||||
response = client.get(
|
||||
reverse(
|
||||
"sas:user_pictures",
|
||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status
|
||||
assert not picture.is_owned_by(user)
|
||||
|
||||
|
||||
# TODO: many tests on the pages:
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Page, PageRev, User
|
||||
@@ -122,6 +122,9 @@ def test_page_revision_club_redirection(client: Client):
|
||||
@pytest.mark.django_db
|
||||
def test_viewable_by():
|
||||
# remove existing pages to prevent side effect
|
||||
# club pages are protected, so we must delete clubs first
|
||||
Membership.objects.all().delete()
|
||||
Club.objects.all().delete()
|
||||
Page.objects.all().delete()
|
||||
view_groups = [
|
||||
[settings.SITH_GROUP_PUBLIC_ID],
|
||||
|
||||
@@ -27,7 +27,6 @@ from counter.baker_recipes import sale_recipe
|
||||
from counter.models import Counter, Customer, Permanency, Refilling, Selling
|
||||
from counter.utils import is_logged_in_counter
|
||||
from eboutic.models import Invoice, InvoiceItem
|
||||
from sas.models import Picture
|
||||
|
||||
|
||||
class TestSearchUsers(TestCase):
|
||||
@@ -35,7 +34,6 @@ class TestSearchUsers(TestCase):
|
||||
def setUpTestData(cls):
|
||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||
News.objects.all().delete()
|
||||
Picture.objects.all().delete() # same for pictures
|
||||
User.objects.all().delete()
|
||||
user_recipe = Recipe(
|
||||
User,
|
||||
@@ -401,13 +399,12 @@ class TestUserQuerySetViewableBy:
|
||||
return [
|
||||
baker.make(User),
|
||||
subscriber_user.make(),
|
||||
subscriber_user.make(is_viewable=False),
|
||||
*subscriber_user.make(is_viewable=False, _quantity=2),
|
||||
]
|
||||
|
||||
def test_admin_user(self, users: list[User]):
|
||||
user = baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
|
||||
User, user_permissions=[Permission.objects.get(codename="view_hidden_user")]
|
||||
)
|
||||
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
|
||||
assert set(viewable) == set(users)
|
||||
@@ -420,6 +417,12 @@ class TestUserQuerySetViewableBy:
|
||||
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
|
||||
assert set(viewable) == {users[0], users[1]}
|
||||
|
||||
def test_whitelist(self, users: list[User]):
|
||||
user = subscriber_user.make()
|
||||
users[3].whitelisted_users.add(user)
|
||||
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
|
||||
assert set(viewable) == {users[0], users[1], users[3]}
|
||||
|
||||
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
|
||||
def test_not_subscriber(self, users: list[User], user_factory):
|
||||
user = user_factory()
|
||||
|
||||
@@ -69,7 +69,6 @@ from core.views import (
|
||||
UserCreationView,
|
||||
UserGodfathersTreeView,
|
||||
UserGodfathersView,
|
||||
UserListView,
|
||||
UserMeRedirect,
|
||||
UserMiniView,
|
||||
UserPreferencesView,
|
||||
@@ -78,6 +77,7 @@ from core.views import (
|
||||
UserUpdateGroupView,
|
||||
UserUpdateProfileView,
|
||||
UserView,
|
||||
UserVisibilityFormFragment,
|
||||
delete_user_godfather,
|
||||
logout,
|
||||
notification,
|
||||
@@ -136,7 +136,11 @@ urlpatterns = [
|
||||
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
|
||||
),
|
||||
# User views
|
||||
path("user/", UserListView.as_view(), name="user_list"),
|
||||
path(
|
||||
"fragment/user/<int:user_id>/",
|
||||
UserVisibilityFormFragment.as_view(),
|
||||
name="user_visibility_fragment",
|
||||
),
|
||||
path(
|
||||
"user/me/<path:remaining_path>/",
|
||||
UserMeRedirect.as_view(),
|
||||
|
||||
@@ -12,23 +12,18 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from dataclasses import dataclass
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import Any, Final, Unpack
|
||||
from typing import Final
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import models
|
||||
from django.forms import BaseForm
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.shortcuts import get_list_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import SafeString
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
@@ -47,21 +42,6 @@ to generate a dummy image that is considered valid nonetheless
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormFragmentTemplateData[T: BaseForm]:
|
||||
"""Dataclass used to pre-render form fragments"""
|
||||
|
||||
form: T
|
||||
template: str
|
||||
context: dict[str, Any]
|
||||
|
||||
def render(self, request: HttpRequest) -> SafeString:
|
||||
# Request is needed for csrf_tokens
|
||||
return render_to_string(
|
||||
self.template, context={"form": self.form, **self.context}, request=request
|
||||
)
|
||||
|
||||
|
||||
def get_start_of_semester(today: date | None = None) -> date:
|
||||
"""Return the date of the start of the semester of the given date.
|
||||
If no date is given, return the start date of the current semester.
|
||||
@@ -225,56 +205,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
Filterable = type[models.Model] | models.QuerySet | models.Manager
|
||||
ListFilter = dict[str, list | tuple | set]
|
||||
|
||||
|
||||
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
|
||||
"""Use filter() to return a list of objects from a list of unique keys (like ids)
|
||||
or raises Http404 if the list has not the same length as the given one.
|
||||
|
||||
Work like `get_object_or_404()` but for lists of objects, with some caveats :
|
||||
|
||||
- The filter must be a list, a tuple or a set.
|
||||
- There can't be more than exactly one filter.
|
||||
- There must be no duplicate in the filter.
|
||||
- The filter should consist in unique keys (like ids), or it could fail randomly.
|
||||
|
||||
klass may be a Model, Manager, or QuerySet object. All other passed
|
||||
arguments and keyword arguments are used in the filter() query.
|
||||
|
||||
Raises:
|
||||
Http404: If the list is empty or doesn't have as many elements as the keys list.
|
||||
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
|
||||
ValueError: If more than one filter is passed.
|
||||
TypeError: If the given filter is not a list, a tuple or a set.
|
||||
|
||||
Examples:
|
||||
Get all the products with ids 1, 2, 3: ::
|
||||
|
||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
|
||||
|
||||
Don't work with duplicate ids: ::
|
||||
|
||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
|
||||
# Raises Http404: "The list of keys must contain no duplicates."
|
||||
"""
|
||||
if len(kwargs) > 1:
|
||||
raise ValueError("get_list_exact_or_404() only accepts one filter.")
|
||||
key, list_filter = next(iter(kwargs.items()))
|
||||
if not isinstance(list_filter, (list, tuple, set)):
|
||||
raise TypeError(
|
||||
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
|
||||
)
|
||||
if len(list_filter) != len(set(list_filter)):
|
||||
raise ValueError("The list of keys must contain no duplicates.")
|
||||
kwargs = {key: list_filter}
|
||||
obj_list = get_list_or_404(klass, **kwargs)
|
||||
if len(obj_list) != len(list_filter):
|
||||
raise Http404(
|
||||
"The given list of keys doesn't match the number of objects found."
|
||||
f"Expected {len(list_filter)} items, got {len(obj_list)}."
|
||||
)
|
||||
return obj_list
|
||||
|
||||
@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||
class FileModerationView(AllowFragment, ListView):
|
||||
model = SithFile
|
||||
template_name = "core/file_moderation.jinja"
|
||||
queryset = SithFile.objects.filter(is_moderated=False)
|
||||
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
|
||||
ordering = "id"
|
||||
paginate_by = 100
|
||||
|
||||
|
||||
@@ -48,12 +48,13 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from PIL import Image
|
||||
|
||||
from antispam.forms import AntiSpamEmailField
|
||||
from core.models import Gift, Group, Page, PageRev, SithFile, User
|
||||
from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User
|
||||
from core.utils import resize_image
|
||||
from core.views.widgets.ajax_select import (
|
||||
AutoCompleteSelect,
|
||||
AutoCompleteSelectGroup,
|
||||
AutoCompleteSelectMultipleGroup,
|
||||
AutoCompleteSelectMultipleUser,
|
||||
AutoCompleteSelectUser,
|
||||
)
|
||||
from core.views.widgets.markdown import MarkdownInput
|
||||
@@ -179,7 +180,6 @@ class UserProfileForm(forms.ModelForm):
|
||||
"school",
|
||||
"promo",
|
||||
"forum_signature",
|
||||
"is_viewable",
|
||||
]
|
||||
widgets = {
|
||||
"date_of_birth": SelectDate,
|
||||
@@ -264,6 +264,38 @@ class UserProfileForm(forms.ModelForm):
|
||||
self._post_clean()
|
||||
|
||||
|
||||
class UserVisibilityForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["is_viewable", "whitelisted_users"]
|
||||
widgets = {
|
||||
"is_viewable": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
"whitelisted_users": AutoCompleteSelectMultipleUser,
|
||||
}
|
||||
|
||||
__preferences_fields = forms.fields_for_model(
|
||||
Preferences,
|
||||
["show_my_stats"],
|
||||
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
||||
)
|
||||
show_my_stats = __preferences_fields["show_my_stats"]
|
||||
|
||||
def __init__(
|
||||
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
||||
):
|
||||
if instance:
|
||||
initial = initial or {}
|
||||
initial["show_my_stats"] = instance.preferences.show_my_stats
|
||||
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
||||
|
||||
def save(self, commit=True) -> User: # noqa: FBT002
|
||||
instance = super().save(commit=commit)
|
||||
if commit:
|
||||
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
||||
instance.preferences.save()
|
||||
return instance
|
||||
|
||||
|
||||
class UserGroupsForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
@@ -28,10 +28,12 @@ from datetime import timedelta
|
||||
from operator import itemgetter
|
||||
from smtplib import SMTPException
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import login, views
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import DateField, F, QuerySet, Sum
|
||||
from django.db.models.functions import Trunc
|
||||
@@ -48,7 +50,6 @@ from django.views.generic import (
|
||||
CreateView,
|
||||
DeleteView,
|
||||
DetailView,
|
||||
ListView,
|
||||
RedirectView,
|
||||
TemplateView,
|
||||
)
|
||||
@@ -65,8 +66,9 @@ from core.views.forms import (
|
||||
UserGodfathersForm,
|
||||
UserGroupsForm,
|
||||
UserProfileForm,
|
||||
UserVisibilityForm,
|
||||
)
|
||||
from core.views.mixins import TabedViewMixin, UseFragmentsMixin
|
||||
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
|
||||
from counter.models import Refilling, Selling
|
||||
from eboutic.models import Invoice
|
||||
from trombi.views import UserTrombiForm
|
||||
@@ -248,14 +250,15 @@ class UserTabsMixin(TabedViewMixin):
|
||||
"name": _("Groups"),
|
||||
}
|
||||
)
|
||||
if (
|
||||
can_view_account = (
|
||||
hasattr(user, "customer")
|
||||
and user.customer
|
||||
and (
|
||||
user == self.request.user
|
||||
or self.request.user.has_perm("counter.view_customer")
|
||||
)
|
||||
):
|
||||
)
|
||||
if can_view_account or user.preferences.show_my_stats:
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
|
||||
@@ -263,6 +266,7 @@ class UserTabsMixin(TabedViewMixin):
|
||||
"name": _("Stats"),
|
||||
}
|
||||
)
|
||||
if can_view_account:
|
||||
tab_list.append(
|
||||
{
|
||||
"url": reverse("core:user_account", kwargs={"user_id": user.id}),
|
||||
@@ -349,7 +353,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
return kwargs
|
||||
|
||||
|
||||
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
|
||||
"""Display a user's stats."""
|
||||
|
||||
model = User
|
||||
@@ -357,15 +361,20 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
context_object_name = "profile"
|
||||
template_name = "core/user_stats.jinja"
|
||||
current_tab = "stats"
|
||||
queryset = User.objects.exclude(customer=None).select_related("customer")
|
||||
queryset = User.objects.exclude(customer=None).select_related(
|
||||
"customer", "_preferences"
|
||||
)
|
||||
|
||||
def dispatch(self, request, *arg, **kwargs):
|
||||
profile = self.get_object()
|
||||
if not (
|
||||
profile == request.user or request.user.has_perm("counter.view_customer")
|
||||
):
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *arg, **kwargs)
|
||||
def test_func(self):
|
||||
profile: User = self.get_object()
|
||||
return (
|
||||
profile == self.request.user
|
||||
or self.request.user.has_perm("counter.view_customer")
|
||||
or (
|
||||
self.request.user.can_view(profile)
|
||||
and profile.preferences.show_my_stats
|
||||
)
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
@@ -404,13 +413,6 @@ class UserMiniView(CanViewMixin, DetailView):
|
||||
template_name = "core/user_mini.jinja"
|
||||
|
||||
|
||||
class UserListView(ListView, CanEditPropMixin):
|
||||
"""Displays the user list."""
|
||||
|
||||
model = User
|
||||
template_name = "core/user_list.jinja"
|
||||
|
||||
|
||||
# FIXME: the edit_once fields aren't displayed to the user (as expected).
|
||||
# However, if the user re-add them manually in the form, they are saved.
|
||||
class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
|
||||
@@ -468,6 +470,30 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
|
||||
current_tab = "clubs"
|
||||
|
||||
|
||||
class UserVisibilityFormFragment(FragmentMixin, SuccessMessageMixin, UpdateView):
|
||||
model = User
|
||||
form_class = UserVisibilityForm
|
||||
template_name = "core/fragment/user_visibility.jinja"
|
||||
pk_url_kwarg = "user_id"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"label_suffix": ""}
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, _("Visibility parameters updated."), extra_tags="visibility"
|
||||
)
|
||||
return response
|
||||
|
||||
def render_fragment(self, request, **kwargs) -> SafeString:
|
||||
self.object = kwargs.get("user")
|
||||
return super().render_fragment(request, **kwargs)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return self.request.path
|
||||
|
||||
|
||||
class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView):
|
||||
"""Edit a user's preferences."""
|
||||
|
||||
@@ -481,7 +507,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
|
||||
current_tab = "prefs"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"instance": self.object.preferences}
|
||||
return super().get_form_kwargs() | {
|
||||
"instance": self.object.preferences,
|
||||
"label_suffix": "",
|
||||
}
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
@@ -491,6 +520,9 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
|
||||
from counter.views.student_card import StudentCardFormFragment
|
||||
|
||||
res = super().get_fragment_context_data()
|
||||
res["user_visibility_fragment"] = UserVisibilityFormFragment.as_fragment()(
|
||||
self.request, user=self.object
|
||||
)
|
||||
if hasattr(self.object, "customer"):
|
||||
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
|
||||
self.request, customer=self.object.customer
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import date, datetime, timezone
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.forms import BaseModelFormSet
|
||||
@@ -15,7 +16,7 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
|
||||
from club.models import Club
|
||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||
from core.models import User
|
||||
from core.models import User, UserQuerySet
|
||||
from core.views.forms import (
|
||||
FutureDateTimeField,
|
||||
NFCTextInput,
|
||||
@@ -32,6 +33,7 @@ from core.views.widgets.ajax_select import (
|
||||
from counter.models import (
|
||||
BillingInfo,
|
||||
Counter,
|
||||
CounterSellers,
|
||||
Customer,
|
||||
Eticket,
|
||||
InvoiceCall,
|
||||
@@ -170,14 +172,39 @@ class RefillForm(forms.ModelForm):
|
||||
class CounterEditForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Counter
|
||||
fields = ["sellers", "products"]
|
||||
widgets = {"sellers": AutoCompleteSelectMultipleUser}
|
||||
fields = ["products"]
|
||||
|
||||
sellers_regular = forms.ModelMultipleChoiceField(
|
||||
label=_("Regular barmen"),
|
||||
help_text=_(
|
||||
"Barmen having regular permanences "
|
||||
"or frequently giving a hand throughout the semester."
|
||||
),
|
||||
queryset=User.objects.all(),
|
||||
widget=AutoCompleteSelectMultipleUser,
|
||||
required=False,
|
||||
)
|
||||
sellers_temporary = forms.ModelMultipleChoiceField(
|
||||
label=_("Temporary barmen"),
|
||||
help_text=_(
|
||||
"Barmen who will be there only for a limited period (e.g. for one evening)"
|
||||
),
|
||||
queryset=User.objects.all(),
|
||||
widget=AutoCompleteSelectMultipleUser,
|
||||
required=False,
|
||||
)
|
||||
field_order = ["sellers_regular", "sellers_temporary", "products"]
|
||||
|
||||
def __init__(self, *args, user: User, instance: Counter, **kwargs):
|
||||
super().__init__(*args, instance=instance, **kwargs)
|
||||
# if the user is an admin, he will have access to all products,
|
||||
# else only to active products owned by the counter's club
|
||||
# or already on the counter
|
||||
if user.has_perm("counter.change_counter"):
|
||||
self.fields["products"].widget = AutoCompleteSelectMultipleProduct()
|
||||
else:
|
||||
# updating the queryset of the field also updates the choices of
|
||||
# the widget, so it's important to set the queryset after the widget
|
||||
self.fields["products"].widget = AutoCompleteSelectMultiple()
|
||||
self.fields["products"].queryset = Product.objects.filter(
|
||||
Q(club_id=instance.club_id) | Q(counters=instance), archived=False
|
||||
@@ -186,6 +213,61 @@ class CounterEditForm(forms.ModelForm):
|
||||
"If you want to add a product that is not owned by "
|
||||
"your club to this counter, you should ask an admin."
|
||||
)
|
||||
self.fields["sellers_regular"].initial = self.instance.sellers.filter(
|
||||
countersellers__is_regular=True
|
||||
).all()
|
||||
self.fields["sellers_temporary"].initial = self.instance.sellers.filter(
|
||||
countersellers__is_regular=False
|
||||
).all()
|
||||
|
||||
def clean(self):
|
||||
regular: UserQuerySet = self.cleaned_data["sellers_regular"]
|
||||
temporary: UserQuerySet = self.cleaned_data["sellers_temporary"]
|
||||
duplicates = list(regular.intersection(temporary))
|
||||
if duplicates:
|
||||
raise ValidationError(
|
||||
_(
|
||||
"A user cannot be a regular and a temporary barman "
|
||||
"at the same time, "
|
||||
"but the following users have been defined as both : %(users)s"
|
||||
)
|
||||
% {"users": ", ".join([u.get_display_name() for u in duplicates])}
|
||||
)
|
||||
return self.cleaned_data
|
||||
|
||||
def save_sellers(self):
|
||||
sellers = []
|
||||
for users, is_regular in (
|
||||
(self.cleaned_data["sellers_regular"], True),
|
||||
(self.cleaned_data["sellers_temporary"], False),
|
||||
):
|
||||
sellers.extend(
|
||||
[
|
||||
CounterSellers(counter=self.instance, user=u, is_regular=is_regular)
|
||||
for u in users
|
||||
]
|
||||
)
|
||||
# start by deleting removed CounterSellers objects
|
||||
user_ids = [seller.user.id for seller in sellers]
|
||||
CounterSellers.objects.filter(
|
||||
~Q(user_id__in=user_ids), counter=self.instance
|
||||
).delete()
|
||||
|
||||
# then create or update the new barmen
|
||||
CounterSellers.objects.bulk_create(
|
||||
sellers,
|
||||
update_conflicts=True,
|
||||
update_fields=["is_regular"],
|
||||
unique_fields=["user", "counter"],
|
||||
)
|
||||
|
||||
def save(self, commit=True): # noqa: FBT002
|
||||
self.instance = super().save(commit=commit)
|
||||
if commit and any(
|
||||
key in self.changed_data for key in ("sellers_regular", "sellers_temporary")
|
||||
):
|
||||
self.save_sellers()
|
||||
return self.instance
|
||||
|
||||
|
||||
class ScheduledProductActionForm(forms.ModelForm):
|
||||
@@ -291,7 +373,8 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
|
||||
absolute_max=None,
|
||||
can_delete=True,
|
||||
can_delete_extra=False,
|
||||
extra=2,
|
||||
extra=0,
|
||||
min_num=1,
|
||||
)
|
||||
|
||||
|
||||
|
||||
88
counter/migrations/0038_countersellers.py
Normal file
88
counter/migrations/0038_countersellers.py
Normal file
@@ -0,0 +1,88 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-04 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("counter", "0037_productformula"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# cf. https://docs.djangoproject.com/fr/stable/howto/writing-migrations/#changing-a-manytomanyfield-to-use-a-through-model
|
||||
migrations.SeparateDatabaseAndState(
|
||||
database_operations=[
|
||||
migrations.RunSQL(
|
||||
sql="ALTER TABLE counter_counter_sellers RENAME TO counter_countersellers",
|
||||
reverse_sql="ALTER TABLE counter_countersellers RENAME TO counter_counter_sellers",
|
||||
),
|
||||
],
|
||||
state_operations=[
|
||||
migrations.CreateModel(
|
||||
name="CounterSellers",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"counter",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="counter.counter",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("counter", "user"),
|
||||
name="counter_counter_sellers_counter_id_subscriber_id_key",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="counter",
|
||||
name="sellers",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="counters",
|
||||
through="counter.CounterSellers",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="sellers",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="countersellers",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="created at",
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="countersellers",
|
||||
name="is_regular",
|
||||
field=models.BooleanField(default=False, verbose_name="regular barman"),
|
||||
),
|
||||
]
|
||||
@@ -551,7 +551,11 @@ class Counter(models.Model):
|
||||
choices=[("BAR", _("Bar")), ("OFFICE", _("Office")), ("EBOUTIC", _("Eboutic"))],
|
||||
)
|
||||
sellers = models.ManyToManyField(
|
||||
User, verbose_name=_("sellers"), related_name="counters", blank=True
|
||||
User,
|
||||
verbose_name=_("sellers"),
|
||||
related_name="counters",
|
||||
blank=True,
|
||||
through="CounterSellers",
|
||||
)
|
||||
edit_groups = models.ManyToManyField(
|
||||
Group, related_name="editable_counters", blank=True
|
||||
@@ -579,7 +583,7 @@ class Counter(models.Model):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
mem = self.club.get_membership_for(user)
|
||||
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
if mem and mem.role.is_presidency:
|
||||
return True
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
@@ -743,6 +747,26 @@ class Counter(models.Model):
|
||||
]
|
||||
|
||||
|
||||
class CounterSellers(models.Model):
|
||||
"""Custom through model for the counter-sellers M2M relationship."""
|
||||
|
||||
counter = models.ForeignKey(Counter, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
is_regular = models.BooleanField(_("regular barman"), default=False)
|
||||
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["counter", "user"],
|
||||
name="counter_counter_sellers_counter_id_subscriber_id_key",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"counter {self.counter_id} - user {self.user_id}"
|
||||
|
||||
|
||||
class RefillingQuerySet(models.QuerySet):
|
||||
def annotate_total(self) -> Self:
|
||||
"""Annotate the Queryset with the total amount.
|
||||
|
||||
@@ -64,7 +64,7 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
checkFormulas() {
|
||||
const products = new Set(
|
||||
Object.keys(this.basket).map((i: string) => Number.parseInt(i)),
|
||||
Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)),
|
||||
);
|
||||
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
|
||||
return f.products.every((p: number) => products.has(p));
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</table>
|
||||
<br>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% trans %}There is no cash register summary in this website.{% endtrans %}
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% macro action_form(form) %}
|
||||
<fieldset x-data="{action: '{{ form.task.initial }}'}">
|
||||
{{ form.non_field_errors() }}
|
||||
<div class="row gap-2x margin-bottom">
|
||||
<div>
|
||||
{{ form.task.errors }}
|
||||
{{ form.task.label_tag() }}
|
||||
{{ form.task|add_attr("x-model=action") }}
|
||||
</div>
|
||||
<div>{{ form.trigger_at.as_field_group() }}</div>
|
||||
</div>
|
||||
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
|
||||
{{ form.counters.as_field_group() }}
|
||||
</div>
|
||||
{%- if form.DELETE -%}
|
||||
<div class="row gap">
|
||||
{{ form.DELETE.as_field_group() }}
|
||||
</div>
|
||||
{%- else -%}
|
||||
<button
|
||||
class="btn btn-grey"
|
||||
@click.prevent="removeForm($event.target.closest('fieldset'))"
|
||||
>
|
||||
<i class="fa fa-minus"></i>{% trans %}Remove this action{% endtrans %}
|
||||
</button>
|
||||
{%- endif -%}
|
||||
{%- for field in form.hidden_fields() -%}
|
||||
{{ field }}
|
||||
{%- endfor -%}
|
||||
<hr />
|
||||
</fieldset>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
{% if object %}
|
||||
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
|
||||
@@ -25,34 +64,20 @@
|
||||
</em>
|
||||
</p>
|
||||
|
||||
<div x-data="dynamicFormSet" class="margin-bottom">
|
||||
{{ form.action_formset.management_form }}
|
||||
{%- for action_form in form.action_formset.forms -%}
|
||||
<fieldset x-data="{action: '{{ action_form.task.initial }}'}">
|
||||
{{ action_form.non_field_errors() }}
|
||||
<div class="row gap-2x margin-bottom">
|
||||
<div>
|
||||
{{ action_form.task.errors }}
|
||||
{{ action_form.task.label_tag() }}
|
||||
{{ action_form.task|add_attr("x-model=action") }}
|
||||
</div>
|
||||
<div>{{ action_form.trigger_at.as_field_group() }}</div>
|
||||
</div>
|
||||
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
|
||||
{{ action_form.counters.as_field_group() }}
|
||||
</div>
|
||||
{%- if action_form.DELETE -%}
|
||||
<div class="row gap">
|
||||
{{ action_form.DELETE.as_field_group() }}
|
||||
</div>
|
||||
{%- endif -%}
|
||||
{%- for field in action_form.hidden_fields() -%}
|
||||
{{ field }}
|
||||
<div x-ref="formContainer">
|
||||
{%- for f in form.action_formset.forms -%}
|
||||
{{ action_form(f) }}
|
||||
{%- endfor -%}
|
||||
</fieldset>
|
||||
{%- if not loop.last -%}
|
||||
<hr class="margin-bottom">
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</div>
|
||||
<template x-ref="formTemplate">
|
||||
{{ action_form(form.action_formset.empty_form) }}
|
||||
</template>
|
||||
<button @click.prevent="addForm()" class="btn btn-grey">
|
||||
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -28,7 +28,7 @@
|
||||
{%- endfor %}
|
||||
</table>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import ClubRole, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||
from core.models import BanGroup, User
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
@@ -88,7 +88,7 @@ class TestFullClickBase(TestCase):
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club=cls.club_counter.club,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
|
||||
user=cls.club_admin,
|
||||
)
|
||||
|
||||
@@ -782,7 +782,13 @@ class TestClubCounterClickAccess(TestCase):
|
||||
"counter:click",
|
||||
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
||||
)
|
||||
|
||||
cls.board_role, cls.member_role = baker.make(
|
||||
ClubRole,
|
||||
club=cls.counter.club,
|
||||
is_board=iter([True, False]),
|
||||
_quantity=2,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.user = subscriber_user.make()
|
||||
|
||||
def setUp(self):
|
||||
@@ -797,13 +803,17 @@ class TestClubCounterClickAccess(TestCase):
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 403
|
||||
# being a member of the club, without being in the board, isn't enough
|
||||
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.member_role
|
||||
)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_board_member(self):
|
||||
"""By default, board members should be able to click on office counters"""
|
||||
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
@@ -818,7 +828,9 @@ class TestClubCounterClickAccess(TestCase):
|
||||
def test_both_barman_and_board_member(self):
|
||||
"""If the user is barman and board member, he should be authorized as well."""
|
||||
self.counter.sellers.add(self.user)
|
||||
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
|
||||
@@ -1,13 +1,132 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
from core.models import Group, User
|
||||
from counter.baker_recipes import product_recipe
|
||||
from counter.forms import CounterEditForm
|
||||
from counter.models import Counter
|
||||
from counter.models import Counter, CounterSellers
|
||||
|
||||
|
||||
class TestEditCounterSellers(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.counter = baker.make(Counter, type="BAR")
|
||||
cls.products = product_recipe.make(_quantity=2, _bulk_create=True)
|
||||
cls.counter.products.add(*cls.products)
|
||||
users = subscriber_user.make(_quantity=6, _bulk_create=True)
|
||||
cls.regular_barmen = users[:2]
|
||||
cls.tmp_barmen = users[2:4]
|
||||
cls.not_barmen = users[4:]
|
||||
CounterSellers.objects.bulk_create(
|
||||
[
|
||||
*baker.prepare(
|
||||
CounterSellers,
|
||||
counter=cls.counter,
|
||||
user=iter(cls.regular_barmen),
|
||||
is_regular=True,
|
||||
_quantity=len(cls.regular_barmen),
|
||||
),
|
||||
*baker.prepare(
|
||||
CounterSellers,
|
||||
counter=cls.counter,
|
||||
user=iter(cls.tmp_barmen),
|
||||
is_regular=False,
|
||||
_quantity=len(cls.tmp_barmen),
|
||||
),
|
||||
]
|
||||
)
|
||||
cls.operator = baker.make(
|
||||
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
|
||||
)
|
||||
|
||||
def test_view_ok(self):
|
||||
url = reverse("counter:admin", kwargs={"counter_id": self.counter.id})
|
||||
self.client.force_login(self.operator)
|
||||
res = self.client.get(url)
|
||||
assert res.status_code == 200
|
||||
res = self.client.post(
|
||||
url,
|
||||
data={
|
||||
"sellers_regular": [u.id for u in self.regular_barmen],
|
||||
"sellers_temporary": [u.id for u in self.tmp_barmen],
|
||||
"products": [p.id for p in self.products],
|
||||
},
|
||||
)
|
||||
self.assertRedirects(res, url)
|
||||
|
||||
def test_add_barmen(self):
|
||||
form = CounterEditForm(
|
||||
data={
|
||||
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
|
||||
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[1]],
|
||||
"products": self.products,
|
||||
},
|
||||
instance=self.counter,
|
||||
user=self.operator,
|
||||
)
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
|
||||
*self.regular_barmen,
|
||||
self.not_barmen[0],
|
||||
}
|
||||
assert set(self.counter.sellers.filter(countersellers__is_regular=False)) == {
|
||||
*self.tmp_barmen,
|
||||
self.not_barmen[1],
|
||||
}
|
||||
|
||||
def test_barman_change_status(self):
|
||||
"""Test when a barman goes from temporary to regular"""
|
||||
form = CounterEditForm(
|
||||
data={
|
||||
"sellers_regular": [*self.regular_barmen, self.tmp_barmen[0]],
|
||||
"sellers_temporary": [*self.tmp_barmen[1:]],
|
||||
"products": self.products,
|
||||
},
|
||||
instance=self.counter,
|
||||
user=self.operator,
|
||||
)
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == {
|
||||
*self.regular_barmen,
|
||||
self.tmp_barmen[0],
|
||||
}
|
||||
assert set(
|
||||
self.counter.sellers.filter(countersellers__is_regular=False)
|
||||
) == set(self.tmp_barmen[1:])
|
||||
|
||||
def test_barman_duplicate(self):
|
||||
"""Test that a barman cannot be regular and temporary at the same time."""
|
||||
form = CounterEditForm(
|
||||
data={
|
||||
"sellers_regular": [*self.regular_barmen, self.not_barmen[0]],
|
||||
"sellers_temporary": [*self.tmp_barmen, self.not_barmen[0]],
|
||||
"products": self.products,
|
||||
},
|
||||
instance=self.counter,
|
||||
user=self.operator,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"__all__": [
|
||||
"Un utilisateur ne peut pas être un barman "
|
||||
"régulier et temporaire en même temps, "
|
||||
"mais les utilisateurs suivants ont été définis "
|
||||
f"comme les deux : {self.not_barmen[0].get_display_name()}"
|
||||
],
|
||||
}
|
||||
assert set(self.counter.sellers.filter(countersellers__is_regular=True)) == set(
|
||||
self.regular_barmen
|
||||
)
|
||||
assert set(
|
||||
self.counter.sellers.filter(countersellers__is_regular=False)
|
||||
) == set(self.tmp_barmen)
|
||||
|
||||
|
||||
class TestEditCounterProducts(TestCase):
|
||||
|
||||
@@ -3,14 +3,13 @@ import string
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.base_user import make_password
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import ClubRole, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import User
|
||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||
@@ -42,11 +41,12 @@ class TestStudentCard(TestCase):
|
||||
cls.counter.sellers.add(cls.barmen)
|
||||
|
||||
cls.club_counter = baker.make(Counter)
|
||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
||||
baker.make(
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club=cls.club_counter.club,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=role,
|
||||
user=cls.club_admin,
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.forms import CheckboxSelectMultiple
|
||||
@@ -58,7 +59,9 @@ class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
|
||||
current_tab = "counters"
|
||||
|
||||
|
||||
class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
|
||||
class CounterEditView(
|
||||
CounterAdminTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
|
||||
):
|
||||
"""Edit a counter's main informations (for the counter's manager)."""
|
||||
|
||||
model = Counter
|
||||
@@ -66,6 +69,7 @@ class CounterEditView(CounterAdminTabsMixin, UserPassesTestMixin, UpdateView):
|
||||
pk_url_kwarg = "counter_id"
|
||||
template_name = "core/edit.jinja"
|
||||
current_tab = "counters"
|
||||
success_message = _("Counter update done")
|
||||
|
||||
def test_func(self):
|
||||
if self.request.user.has_perm("counter.change_counter"):
|
||||
|
||||
73
docs/howto/xapian.md
Normal file
73
docs/howto/xapian.md
Normal file
@@ -0,0 +1,73 @@
|
||||
## Pourquoi Xapian
|
||||
|
||||
Xapian permet de faire de la recherche fulltext.
|
||||
C'est une librairie écrite en C++ avec des bindings Python
|
||||
qu'on utilise avec la dépendance `django-haystack` via `xapian-haystack`.
|
||||
|
||||
Elle a les avantages suivants:
|
||||
|
||||
* C'est très rapide et ça correspond très bien à notre échelle
|
||||
* C'est performant
|
||||
* Pas besoin de service supplémentaire, c'est une librairie qui utilise des fichiers, comme sqlite
|
||||
|
||||
Mais elle a un défaut majeur: on ne peut pas « juste » la `pip install`,
|
||||
il faut installer une librairie système et des bindings et ça a toujours été
|
||||
l'étape la plus frustrante et buggée de notre process d'installation. C'est
|
||||
aussi la seule raison qui fait que le projet n'es pas compatible windows.
|
||||
|
||||
## Mettre à jour Xapian
|
||||
|
||||
Pour installer xapian le plus simplement possible, on le compile depuis les
|
||||
sources via la commande `./manage.py install_xapian` comme indiqué dans la
|
||||
documentation d'installation.
|
||||
|
||||
La version de xapian est contrôlée par le `pyproject.toml` dans la section
|
||||
`[tool.xapian]`.
|
||||
|
||||
Cette section ressemble à ceci:
|
||||
|
||||
```toml
|
||||
[tool.xapian]
|
||||
version = "x.y.z"
|
||||
core-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
|
||||
bindings-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
|
||||
```
|
||||
|
||||
Comme on peut le voir, il y a 3 variables différentes, une variable de version,
|
||||
qui sert à choisir la version à télécharger, et deux variables sha256.
|
||||
|
||||
Ces variables sha256 permettent de protéger des attaques par supply chain, un
|
||||
peu comme uv et npm font avec leurs respectifs `uv.lock` et `package-lock.json`
|
||||
. Elles permettent de vérifier que les fichiers téléchargés n'ont pas été
|
||||
altérés entre la configuration du fichier et l'installation par l'utilisateur
|
||||
et/ou le déploiement.
|
||||
|
||||
L'installation de xapian passe par deux fichiers, `xapian-core` et
|
||||
`xapian-bindings` disponibles sur [https://xapian.org/download](https://xapian.org/download).
|
||||
|
||||
Lorsque le script d'installation télécharge les fichiers, il vérifie leur
|
||||
signature sha256 contre celles contenues dans ces deux variables. Si la
|
||||
signature n'est pas la même, une erreur est levée, protégant l'utilisateur
|
||||
d'une potentielle attaque.
|
||||
|
||||
Pour mettre à jour, il faut donc changer la version ET modifier la signature !
|
||||
|
||||
Pour récupérer ces signatures, il suffit de télécharger soi-même les archives
|
||||
du logiciel sur ce site, utiliser la commande `sha256sum` dessus et, enfin,
|
||||
reporter la valeur sortie par cette commande.
|
||||
|
||||
Pour ce qui est de la correspondance, `core-sha256` correspond à la signature
|
||||
de `xapian-core` et `bindings-sha256` de `xapian-bindings`.
|
||||
|
||||
Voici un bout de script qui peut faciliter une mise à jour:
|
||||
|
||||
```bash
|
||||
VERSION="x.y.z" # À modifier avec la bonne version
|
||||
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-core-${VERSION}.tar.xz"
|
||||
sha256sum xapian-core-${VERSION}.tar.xz # Affiche la signature pour `core-sha256`
|
||||
rm -f xapian-core-${VERSION}
|
||||
|
||||
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-bindings-${VERSION}.tar.xz"
|
||||
sha256sum xapian-bindings-${VERSION}.tar.xz # Affiche la signature pour `bindingse-sha256`
|
||||
rm -f xapian-bindings-${VERSION}.tar.xz
|
||||
```
|
||||
@@ -263,35 +263,3 @@ avec un unique champ permettant de sélectionner des groupes.
|
||||
Par défaut, seuls les utilisateurs avec la permission
|
||||
`auth.change_permission` auront accès à ce formulaire
|
||||
(donc, normalement, uniquement les utilisateurs Root).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Utilisateur
|
||||
participant B as ReverseProxy
|
||||
participant C as MarkdownImage
|
||||
participant D as Model
|
||||
|
||||
A->>B: GET /page/foo
|
||||
B->>C: GET /page/foo
|
||||
C-->>B: La page, avec les urls
|
||||
B-->>A: La page, avec les urls
|
||||
alt image publique
|
||||
A->>B: GET markdown/public/2025/img.webp
|
||||
B-->>A: img.webp
|
||||
end
|
||||
alt image privée
|
||||
A->>B: GET markdown_image/{id}
|
||||
B->>C: GET markdown_image/{id}
|
||||
C->>D: user.can_view(image)
|
||||
alt l'utilisateur a le droit de voir l'image
|
||||
D-->>C: True
|
||||
C-->>B: 200 (avec le X-Accel-Redirect)
|
||||
B-->>A: img.webp
|
||||
end
|
||||
alt l'utilisateur n'a pas le droit de l'image
|
||||
D-->>C: False
|
||||
C-->>B: 403
|
||||
B-->>A: 403
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
@@ -56,6 +56,12 @@ Commencez par installer les dépendances système :
|
||||
sudo pacman -S postgresql nginx
|
||||
```
|
||||
|
||||
=== "Fedora/RHEL/AlmaLinux/Rocky"
|
||||
|
||||
```bash
|
||||
sudo dnf install postgresql libpq-devel nginx
|
||||
```
|
||||
|
||||
=== "macOS"
|
||||
|
||||
```bash
|
||||
@@ -100,9 +106,11 @@ PROCFILE_SERVICE=
|
||||
vous devez ouvrir une autre fenêtre de votre terminal
|
||||
et lancer la commande `npm run serve`
|
||||
|
||||
## Configurer Redis en service externe
|
||||
## Configurer Redis/Valkey en service externe
|
||||
|
||||
Redis est installé comme dépendance mais pas lancé par défaut.
|
||||
Redis est installé comme dépendance mais n'es pas lancé par défaut.
|
||||
|
||||
Si vous avez installé Valkey parce que Redis n'es pas disponible, remplacez juste `redis` par `valkey`.
|
||||
|
||||
En mode développement, le sith se charge de le démarrer mais
|
||||
pas en production !
|
||||
|
||||
@@ -79,6 +79,29 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
sudo pacman -S uv gcc git gettext pkgconf npm valkey
|
||||
```
|
||||
|
||||
=== "Fedora"
|
||||
```bash
|
||||
sudo dnf update
|
||||
sudo dnf install epel-release
|
||||
sudo dnf install python-devel uv git gettext pkgconf npm redis @c-development @development-tools
|
||||
```
|
||||
|
||||
=== "RHEL/AlmaLinux/Rocky"
|
||||
```bash
|
||||
dnf update
|
||||
dnf install epel-release
|
||||
dnf install python-devel uv git gettext pkgconf npm valkey
|
||||
dnf group install "Development Tools"
|
||||
```
|
||||
|
||||
La couche de compatibilitée valkey/redis est un package Fedora.
|
||||
Il est nécessaire de faire un alias nous même:
|
||||
|
||||
```bash
|
||||
ln -s /usr/bin/valkey-server /usr/bin/redis-server
|
||||
```
|
||||
|
||||
|
||||
=== "macOS"
|
||||
|
||||
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
|
||||
@@ -98,7 +121,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
!!!note
|
||||
|
||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||
installé par uv.
|
||||
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
|
||||
|
||||
## Finaliser l'installation
|
||||
|
||||
|
||||
@@ -116,6 +116,56 @@
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section>
|
||||
<div class="category-header">
|
||||
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
|
||||
{% if user.is_subscribed %}
|
||||
<div id="eurock-partner" style="
|
||||
min-height: 600px;
|
||||
background-color: lightgrey;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
">
|
||||
<p style="text-align: center;">
|
||||
{% trans trimmed %}
|
||||
Our partner uses Weezevent to sell tickets.
|
||||
Weezevent may collect user info according to
|
||||
its own privacy policy.
|
||||
By clicking the accept button you consent to
|
||||
their terms of services.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
|
||||
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
|
||||
|
||||
<button
|
||||
hx-get="{{ url("eboutic:eurock") }}"
|
||||
hx-target="#eurock-partner"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
|
||||
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
|
||||
>{% trans %}Accept{% endtrans %}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>
|
||||
{%- trans trimmed %}
|
||||
You must be subscribed to benefit from the partnership with the Eurockéennes.
|
||||
{% endtrans -%}
|
||||
</p>
|
||||
<p>
|
||||
{%- trans trimmed %}
|
||||
This partnership offers a discount of up to 33%
|
||||
on tickets for Friday, Saturday and Sunday,
|
||||
as well as the 3-day package from Friday to Sunday.
|
||||
{% endtrans -%}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% for priority_groups in products|groupby('order') %}
|
||||
{% for category, items in priority_groups.list|groupby('category') %}
|
||||
{% if items|count > 0 %}
|
||||
|
||||
16
eboutic/templates/eboutic/eurock_fragment.jinja
Normal file
16
eboutic/templates/eboutic/eurock_fragment.jinja
Normal file
@@ -0,0 +1,16 @@
|
||||
<a title="Logiciel billetterie en ligne"
|
||||
href="https://www.weezevent.com?c=sys_widget"
|
||||
class="weezevent-widget-integration"
|
||||
target="_blank"
|
||||
data-src="https://widget.weezevent.com/ticket/8aaba226-f7a3-4192-a64e-72ff8f5b35b7?id_evenement=1419869&locale=fr-FR&code=28747"
|
||||
data-width="650"
|
||||
data-height="600"
|
||||
data-resize="1"
|
||||
data-nopb="0"
|
||||
data-type="neo"
|
||||
data-width_auto="1"
|
||||
data-noscroll="0"
|
||||
data-id="1419869">
|
||||
Billetterie Weezevent
|
||||
</a>
|
||||
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
|
||||
@@ -1,17 +0,0 @@
|
||||
<a
|
||||
title="Logiciel billetterie en ligne"
|
||||
href="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
|
||||
class="weezevent-widget-integration"
|
||||
target="_blank"
|
||||
data-src="https://widget.weezevent.com/ticket/6ef65533-f5b0-4571-9d21-1f1bc63921f0?id_evenement=1211855&locale=fr-FR&code=34146"
|
||||
data-width="650"
|
||||
data-height="600"
|
||||
data-resize="1"
|
||||
data-nopb="0"
|
||||
data-type="neo"
|
||||
data-width_auto="1"
|
||||
data-noscroll="0"
|
||||
data-id="1211855">
|
||||
Billetterie Weezevent
|
||||
</a>
|
||||
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
|
||||
@@ -31,6 +31,7 @@ from eboutic.views import (
|
||||
EbouticMainView,
|
||||
EbouticPayWithSith,
|
||||
EtransactionAutoAnswer,
|
||||
EurockPartnerFragment,
|
||||
payment_result,
|
||||
)
|
||||
|
||||
@@ -50,4 +51,5 @@ urlpatterns = [
|
||||
EtransactionAutoAnswer.as_view(),
|
||||
name="etransation_autoanswer",
|
||||
),
|
||||
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
|
||||
]
|
||||
|
||||
@@ -42,11 +42,11 @@ from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_GET
|
||||
from django.views.generic import DetailView, FormView, UpdateView, View
|
||||
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
|
||||
from django.views.generic.edit import SingleObjectMixin
|
||||
from django_countries.fields import Country
|
||||
|
||||
from core.auth.mixins import CanViewMixin
|
||||
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
|
||||
from core.views.mixins import FragmentMixin, UseFragmentsMixin
|
||||
from counter.forms import BaseBasketForm, BasketProductForm, BillingInfoForm
|
||||
from counter.models import (
|
||||
@@ -350,3 +350,7 @@ class EtransactionAutoAnswer(View):
|
||||
return HttpResponse(
|
||||
"Payment failed with error: " + request.GET["Error"], status=202
|
||||
)
|
||||
|
||||
|
||||
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
|
||||
template_name = "eboutic/eurock_fragment.jinja"
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<label for="{{ input_id }}">
|
||||
{%- endif %}
|
||||
<figure>
|
||||
{%- if user.is_viewable %}
|
||||
{%- if user.can_view(candidature.user) %}
|
||||
{% if candidature.user.profile_pict %}
|
||||
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
|
||||
{% else %}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</section>
|
||||
{%- endfor %}
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, User
|
||||
@@ -52,6 +54,102 @@ class TestElectionUpdateView(TestElection):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestElectionForm(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.election = baker.make(Election, end_date=now() + timedelta(days=1))
|
||||
cls.group = baker.make(Group)
|
||||
cls.election.vote_groups.add(cls.group)
|
||||
cls.election.edit_groups.add(cls.group)
|
||||
lists = baker.make(
|
||||
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
|
||||
)
|
||||
cls.roles = baker.make(
|
||||
Role, election=cls.election, _quantity=2, _bulk_create=True
|
||||
)
|
||||
users = baker.make(User, _quantity=4, _bulk_create=True)
|
||||
recipe = Recipe(Candidature)
|
||||
cls.cand = [
|
||||
recipe.prepare(role=cls.roles[0], user=users[0], election_list=lists[0]),
|
||||
recipe.prepare(role=cls.roles[0], user=users[1], election_list=lists[1]),
|
||||
recipe.prepare(role=cls.roles[1], user=users[2], election_list=lists[0]),
|
||||
recipe.prepare(role=cls.roles[1], user=users[3], election_list=lists[1]),
|
||||
]
|
||||
Candidature.objects.bulk_create(cls.cand)
|
||||
cls.vote_url = reverse("election:vote", kwargs={"election_id": cls.election.id})
|
||||
cls.detail_url = reverse(
|
||||
"election:detail", kwargs={"election_id": cls.election.id}
|
||||
)
|
||||
|
||||
def test_election_good_form(self):
|
||||
postes = (self.roles[0].title, self.roles[1].title)
|
||||
votes = [
|
||||
{postes[0]: "", postes[1]: str(self.cand[2].id)},
|
||||
{postes[0]: "", postes[1]: ""},
|
||||
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[2].id)},
|
||||
{postes[0]: str(self.cand[0].id), postes[1]: str(self.cand[3].id)},
|
||||
]
|
||||
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
|
||||
self.group.users.set(voters)
|
||||
|
||||
for voter, vote in zip(voters, votes, strict=True):
|
||||
assert self.election.can_vote(voter)
|
||||
self.client.force_login(voter)
|
||||
response = self.client.post(self.vote_url, data=vote)
|
||||
assertRedirects(response, self.detail_url)
|
||||
|
||||
assert set(self.election.voters.all()) == set(voters)
|
||||
assert self.election.results == {
|
||||
postes[0]: {
|
||||
self.cand[0].user.username: {"percent": 50.0, "vote": 2},
|
||||
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
|
||||
"blank vote": {"percent": 50.0, "vote": 2},
|
||||
"total vote": 4,
|
||||
},
|
||||
postes[1]: {
|
||||
self.cand[2].user.username: {"percent": 50.0, "vote": 2},
|
||||
self.cand[3].user.username: {"percent": 25.0, "vote": 1},
|
||||
"blank vote": {"percent": 25.0, "vote": 1},
|
||||
"total vote": 4,
|
||||
},
|
||||
}
|
||||
|
||||
def test_election_bad_form(self):
|
||||
postes = (self.roles[0].title, self.roles[1].title)
|
||||
|
||||
votes = [
|
||||
{postes[0]: "", postes[1]: str(self.cand[0].id)}, # wrong candidate
|
||||
{postes[0]: ""},
|
||||
{
|
||||
postes[0]: "0123456789", # unknow users
|
||||
postes[1]: str(subscriber_user.make().id), # not a candidate
|
||||
},
|
||||
{},
|
||||
]
|
||||
voters = subscriber_user.make(_quantity=len(votes), _bulk_create=True)
|
||||
self.group.users.set(voters)
|
||||
|
||||
for voter, vote in zip(voters, votes, strict=True):
|
||||
self.client.force_login(voter)
|
||||
response = self.client.post(self.vote_url, data=vote)
|
||||
assertRedirects(response, self.detail_url)
|
||||
|
||||
assert self.election.results == {
|
||||
postes[0]: {
|
||||
self.cand[0].user.username: {"percent": 0.0, "vote": 0},
|
||||
self.cand[1].user.username: {"percent": 0.0, "vote": 0},
|
||||
"blank vote": {"percent": 100.0, "vote": 2},
|
||||
"total vote": 2,
|
||||
},
|
||||
postes[1]: {
|
||||
self.cand[2].user.username: {"percent": 0.0, "vote": 0},
|
||||
self.cand[3].user.username: {"percent": 0.0, "vote": 0},
|
||||
"blank vote": {"percent": 100.0, "vote": 2},
|
||||
"total vote": 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_election_create_list_permission(client: Client):
|
||||
election = baker.make(Election, end_candidature=now() + timedelta(hours=1))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cryptography.utils import cached_property
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin,
|
||||
@@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
|
||||
def test_func(self):
|
||||
if not self.election.can_vote(self.request.user):
|
||||
return False
|
||||
|
||||
groups = set(self.election.vote_groups.values_list("id", flat=True))
|
||||
if (
|
||||
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
|
||||
and self.request.user.is_subscribed
|
||||
):
|
||||
# the subscriber group isn't truly attached to users,
|
||||
# so it must be dealt with separately
|
||||
return True
|
||||
return self.request.user.groups.filter(id__in=groups).exists()
|
||||
return self.election.vote_groups.filter(
|
||||
id__in=self.request.user.all_groups
|
||||
).exists()
|
||||
|
||||
def vote(self, election_data):
|
||||
with transaction.atomic():
|
||||
@@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
|
||||
return False
|
||||
if self.request.user.has_perm("election.add_role"):
|
||||
return True
|
||||
groups = set(self.election.edit_groups.values_list("id", flat=True))
|
||||
if (
|
||||
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
|
||||
and self.request.user.is_subscribed
|
||||
):
|
||||
# the subscriber group isn't truly attached to users,
|
||||
# so it must be dealt with separately
|
||||
return True
|
||||
return self.request.user.groups.filter(id__in=groups).exists()
|
||||
return self.election.edit_groups.filter(
|
||||
id__in=self.request.user.all_groups
|
||||
).exists()
|
||||
|
||||
def get_initial(self):
|
||||
return {"election": self.election}
|
||||
@@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
|
||||
.union(self.election.edit_groups.values("id"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
if (
|
||||
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
|
||||
and self.request.user.is_subscribed
|
||||
):
|
||||
# the subscriber group isn't truly attached to users,
|
||||
# so it must be dealt with separately
|
||||
return True
|
||||
return self.request.user.groups.filter(id__in=groups).exists()
|
||||
return not groups.isdisjoint(self.request.user.all_groups.keys())
|
||||
|
||||
def get_initial(self):
|
||||
return {"election": self.election}
|
||||
|
||||
@@ -183,7 +183,7 @@ class Forum(models.Model):
|
||||
Forum._club_memberships[self.id] = {}
|
||||
Forum._club_memberships[self.id][user.id] = m
|
||||
if m:
|
||||
return m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||
return m.role.is_board
|
||||
return False
|
||||
|
||||
def check_loop(self):
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</p>
|
||||
|
||||
{{ display_search_bar(request) }}
|
||||
{{ paginate_jinja(msgs, msgs.paginator) }}
|
||||
{{ paginate_jinja(request, msgs, msgs.paginator) }}
|
||||
|
||||
<main class="message-list">
|
||||
{% for m in msgs %}
|
||||
@@ -44,7 +44,9 @@
|
||||
|
||||
<p><a class="ib button" href="{{ url('forum:new_message', topic_id=topic.id) }}">{% trans %}Reply{% endtrans %}</a></p>
|
||||
|
||||
{{ paginate_jinja(msgs, msgs.paginator) }}
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(request, msgs, msgs.paginator) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -25,12 +25,14 @@ import warnings
|
||||
from datetime import timedelta
|
||||
from typing import Final, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.models import Group, Page, User
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.models import Group, Page, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from subscription.models import Subscription
|
||||
@@ -90,8 +92,13 @@ class Command(BaseCommand):
|
||||
self.NB_CLUBS = options["club_count"]
|
||||
|
||||
root = User.objects.filter(username="root").first()
|
||||
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
self.galaxy_album = Album.objects.create(
|
||||
name="galaxy-register-file", owner=root, is_moderated=True
|
||||
name="galaxy-register-file",
|
||||
owner=root,
|
||||
is_moderated=True,
|
||||
is_in_sas=True,
|
||||
parent=sas,
|
||||
)
|
||||
|
||||
self.make_clubs()
|
||||
@@ -211,11 +218,19 @@ class Command(BaseCommand):
|
||||
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
||||
)
|
||||
memberships = []
|
||||
roles = {
|
||||
r.club_id: r.id
|
||||
for r in baker.make(
|
||||
ClubRole,
|
||||
club=iter(self.clubs),
|
||||
_quantity=len(self.clubs),
|
||||
_bulk_create=True,
|
||||
)
|
||||
}
|
||||
for i in range(1, 11): # users can be in up to 20 clubs
|
||||
self.logger.info(f"Club membership, pass {i}")
|
||||
for uid in range(
|
||||
i, self.NB_USERS, i
|
||||
): # Pass #1 will make sure every user is at least in one club
|
||||
for uid in range(i, self.NB_USERS, i):
|
||||
# Pass #1 will make sure every user is at least in one club
|
||||
user = self.users[uid]
|
||||
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
||||
|
||||
@@ -230,7 +245,7 @@ class Command(BaseCommand):
|
||||
Membership(
|
||||
user=user,
|
||||
club=club,
|
||||
role=(uid + i) % 10 + 1, # spread the different roles
|
||||
role_id=roles[club.id],
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
)
|
||||
@@ -253,7 +268,7 @@ class Command(BaseCommand):
|
||||
Membership(
|
||||
user=user,
|
||||
club=club,
|
||||
role=((uid // 10) + i) % 10 + 1, # spread the different roles
|
||||
role_id=roles[club.id],
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
)
|
||||
@@ -279,10 +294,14 @@ class Command(BaseCommand):
|
||||
owner=u,
|
||||
name=f"galaxy-picture {u} {i // self.NB_USERS}",
|
||||
is_moderated=True,
|
||||
is_folder=False,
|
||||
parent=self.galaxy_album,
|
||||
original=ContentFile(RED_PIXEL_PNG),
|
||||
is_in_sas=True,
|
||||
file=ContentFile(RED_PIXEL_PNG),
|
||||
compressed=ContentFile(RED_PIXEL_PNG),
|
||||
thumbnail=ContentFile(RED_PIXEL_PNG),
|
||||
mime_type="image/png",
|
||||
size=len(RED_PIXEL_PNG),
|
||||
)
|
||||
)
|
||||
self.picts[i].file.name = self.picts[i].name
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user