mirror of
https://github.com/ae-utbm/sith.git
synced 2026-04-22 11:13:14 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e57c29a7b5 | |||
| ca37996d6a | |||
| 173311c1d5 | |||
| 2995823d6e |
+1
-28
@@ -13,10 +13,8 @@
|
|||||||
#
|
#
|
||||||
#
|
#
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.forms.models import ModelForm
|
|
||||||
from django.http import HttpRequest
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Club)
|
@admin.register(Club)
|
||||||
@@ -31,31 +29,6 @@ class ClubAdmin(admin.ModelAdmin):
|
|||||||
"page",
|
"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)
|
@admin.register(Membership)
|
||||||
class MembershipAdmin(admin.ModelAdmin):
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
+6
-5
@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
|||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from api.auth import ApiKeyAuth
|
from api.auth import ApiKeyAuth
|
||||||
from api.permissions import CanView, HasPerm
|
from api.permissions import CanAccessLookup, CanView, HasPerm
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
from club.schemas import (
|
from club.schemas import (
|
||||||
ClubSchema,
|
ClubSchema,
|
||||||
@@ -22,11 +22,13 @@ class ClubController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||||
|
auth=[ApiKeyAuth(), SessionAuth()],
|
||||||
|
permissions=[CanAccessLookup],
|
||||||
url_name="search_club",
|
url_name="search_club",
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
||||||
return filters.filter(Club.objects.order_by("name")).values()
|
return filters.filter(Club.objects.all())
|
||||||
|
|
||||||
@route.get(
|
@route.get(
|
||||||
"/{int:club_id}",
|
"/{int:club_id}",
|
||||||
@@ -37,8 +39,7 @@ class ClubController(ControllerBase):
|
|||||||
)
|
)
|
||||||
def fetch_club(self, club_id: int):
|
def fetch_club(self, club_id: int):
|
||||||
prefetch = Prefetch(
|
prefetch = Prefetch(
|
||||||
"members",
|
"members", queryset=Membership.objects.ongoing().select_related("user")
|
||||||
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
|
||||||
)
|
)
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related(prefetch), id=club_id
|
||||||
@@ -60,5 +61,5 @@ class UserClubController(ControllerBase):
|
|||||||
return (
|
return (
|
||||||
Membership.objects.ongoing()
|
Membership.objects.ongoing()
|
||||||
.filter(user=user)
|
.filter(user=user)
|
||||||
.select_related("club", "user", "role")
|
.select_related("club", "user")
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-103
@@ -23,12 +23,13 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
from django.conf import settings
|
||||||
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from core.views.forms import SelectDateTime
|
from core.views.forms import SelectDateTime
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
@@ -214,7 +215,9 @@ class ClubOldMemberForm(forms.Form):
|
|||||||
|
|
||||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
self.fields["members_old"].queryset = (
|
||||||
|
Membership.objects.ongoing().filter(club=club).editable_by(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberForm(forms.ModelForm):
|
class ClubMemberForm(forms.ModelForm):
|
||||||
@@ -232,14 +235,19 @@ class ClubMemberForm(forms.ModelForm):
|
|||||||
self.request_user = request_user
|
self.request_user = request_user
|
||||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields["role"].queryset = self.available_roles
|
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.instance.club = club
|
self.instance.club = club
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available_roles(self) -> QuerySet[ClubRole]:
|
def max_available_role(self):
|
||||||
"""The roles that will be obtainable with this form."""
|
"""The greatest role that will be obtainable with this form."""
|
||||||
# this is unreachable, because it will be overridden by subclasses
|
# this is unreachable, because it will be overridden by subclasses
|
||||||
return ClubRole.objects.none() # pragma: no cover
|
return -1 # pragma: no cover
|
||||||
|
|
||||||
|
|
||||||
class ClubAddMemberForm(ClubMemberForm):
|
class ClubAddMemberForm(ClubMemberForm):
|
||||||
@@ -250,22 +258,21 @@ class ClubAddMemberForm(ClubMemberForm):
|
|||||||
widgets = {"user": AutoCompleteSelectUser}
|
widgets = {"user": AutoCompleteSelectUser}
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def available_roles(self):
|
def max_available_role(self):
|
||||||
"""The roles that will be obtainable with this form.
|
"""The greatest role that will be obtainable with this form.
|
||||||
|
|
||||||
Admins and the club president can attribute any role.
|
Admins and the club president can attribute any role.
|
||||||
Board members can attribute roles lower than their own.
|
Board members can attribute roles lower than their own.
|
||||||
Other users cannot attribute roles with this form
|
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"):
|
if self.request_user.has_perm("club.add_membership"):
|
||||||
return qs.all()
|
return settings.SITH_CLUB_ROLES_ID["President"]
|
||||||
membership = self.request_user_membership
|
membership = self.request_user_membership
|
||||||
if membership is None or not membership.role.is_board:
|
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
return ClubRole.objects.none()
|
return -1
|
||||||
if membership.role.is_presidency:
|
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
|
||||||
return qs.all()
|
return membership.role
|
||||||
return qs.above_instance(membership.role)
|
return membership.role - 1
|
||||||
|
|
||||||
def clean_user(self):
|
def clean_user(self):
|
||||||
"""Check that the user is not trying to add a user already in the club.
|
"""Check that the user is not trying to add a user already in the club.
|
||||||
@@ -289,11 +296,13 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
|
|
||||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||||
super().__init__(*args, club=club, request_user=request_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
|
self.instance.user = self.request_user
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def available_roles(self):
|
def max_available_role(self):
|
||||||
return self.club.roles.filter(is_board=False, is_active=True)
|
return settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Check that the user is subscribed and isn't already in the club."""
|
"""Check that the user is subscribed and isn't already in the club."""
|
||||||
@@ -306,88 +315,3 @@ class JoinClubForm(ClubMemberForm):
|
|||||||
_("You are already a member of this club"), code="invalid"
|
_("You are already a member of this club"), code="invalid"
|
||||||
)
|
)
|
||||||
return super().clean()
|
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"] - 1
|
|
||||||
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,15 +2,12 @@
|
|||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import django.db.models.functions.datetime
|
import django.db.models.functions.datetime
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.migrations.state import StateApps
|
from django.db.migrations.state import StateApps
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.timezone import localdate
|
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):
|
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||||
"""Attach the existing meta groups to the clubs.
|
"""Attach the existing meta groups to the clubs.
|
||||||
@@ -49,7 +46,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
|||||||
).select_related("user")
|
).select_related("user")
|
||||||
club.members_group.users.set([m.user for m in memberships])
|
club.members_group.users.set([m.user for m in memberships])
|
||||||
club.board_group.users.set(
|
club.board_group.users.set(
|
||||||
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
|
[
|
||||||
|
m.user
|
||||||
|
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
# 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
|
|
||||||
]
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# 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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+29
-162
@@ -28,15 +28,15 @@ from typing import Iterable, Self
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
from django.core.validators import RegexValidator, validate_email
|
from django.core.validators import RegexValidator, validate_email
|
||||||
from django.db import ProgrammingError, models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import Exists, F, OuterRef, Q
|
from django.db.models import Exists, F, OuterRef, Q, Value
|
||||||
|
from django.db.models.functions import Greatest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from ordered_model.models import OrderedModel
|
|
||||||
|
|
||||||
from core.fields import ResizedImageField
|
from core.fields import ResizedImageField
|
||||||
from core.models import Group, Notification, Page, SithFile, User
|
from core.models import Group, Notification, Page, SithFile, User
|
||||||
@@ -89,13 +89,13 @@ class Club(models.Model):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
page = models.OneToOneField(
|
page = models.OneToOneField(
|
||||||
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
Page, related_name="club", blank=True, on_delete=models.CASCADE
|
||||||
)
|
)
|
||||||
members_group = models.OneToOneField(
|
members_group = models.OneToOneField(
|
||||||
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
Group, related_name="club", on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
board_group = models.OneToOneField(
|
board_group = models.OneToOneField(
|
||||||
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
Group, related_name="club_board", on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
|
|
||||||
objects = ClubQuerySet.as_manager()
|
objects = ClubQuerySet.as_manager()
|
||||||
@@ -138,7 +138,9 @@ class Club(models.Model):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def president(self) -> Membership | None:
|
def president(self) -> Membership | None:
|
||||||
"""Fetch the membership of the current president of this club."""
|
"""Fetch the membership of the current president of this club."""
|
||||||
return self.members.filter(end_date=None).order_by("role__order").first()
|
return self.members.filter(
|
||||||
|
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||||
|
).first()
|
||||||
|
|
||||||
def check_loop(self):
|
def check_loop(self):
|
||||||
"""Raise a validation error when a loop is found within the parent list."""
|
"""Raise a validation error when a loop is found within the parent list."""
|
||||||
@@ -183,40 +185,6 @@ class Club(models.Model):
|
|||||||
self.page.parent = self.parent.page
|
self.page.parent = self.parent.page
|
||||||
self.page.save(force_lock=True)
|
self.page.save(force_lock=True)
|
||||||
|
|
||||||
def 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]]:
|
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||||
self.board_group.delete()
|
self.board_group.delete()
|
||||||
self.members_group.delete()
|
self.members_group.delete()
|
||||||
@@ -238,20 +206,9 @@ class Club(models.Model):
|
|||||||
"""Method to see if that object can be edited by the given user."""
|
"""Method to see if that object can be edited by the given user."""
|
||||||
return self.has_rights_in_club(user)
|
return self.has_rights_in_club(user)
|
||||||
|
|
||||||
def can_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
|
@cached_property
|
||||||
def current_members(self) -> list[Membership]:
|
def current_members(self) -> list[Membership]:
|
||||||
return list(
|
return list(self.members.ongoing().select_related("user").order_by("-role"))
|
||||||
self.members.ongoing().select_related("user", "role").order_by("-role")
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_membership_for(self, user: User) -> Membership | None:
|
def get_membership_for(self, user: User) -> Membership | None:
|
||||||
"""Return the current membership of the given user."""
|
"""Return the current membership of the given user."""
|
||||||
@@ -263,95 +220,6 @@ class Club(models.Model):
|
|||||||
return user.is_in_group(pk=self.board_group_id)
|
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):
|
class MembershipQuerySet(models.QuerySet):
|
||||||
def ongoing(self) -> Self:
|
def ongoing(self) -> Self:
|
||||||
"""Filter all memberships which are not finished yet."""
|
"""Filter all memberships which are not finished yet."""
|
||||||
@@ -364,10 +232,9 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
are included, even if there are no more members.
|
are included, even if there are no more members.
|
||||||
|
|
||||||
If you want to get the users who are currently in the board,
|
If you want to get the users who are currently in the board,
|
||||||
mind combining this with the [MembershipQuerySet.ongoing][]
|
mind combining this with the `ongoing` queryset method
|
||||||
queryset method
|
|
||||||
"""
|
"""
|
||||||
return self.filter(role__is_board=True)
|
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
|
||||||
def editable_by(self, user: User) -> Self:
|
def editable_by(self, user: User) -> Self:
|
||||||
"""Filter Memberships that this user can edit.
|
"""Filter Memberships that this user can edit.
|
||||||
@@ -390,16 +257,21 @@ class MembershipQuerySet(models.QuerySet):
|
|||||||
"""
|
"""
|
||||||
if user.has_perm("club.change_membership"):
|
if user.has_perm("club.change_membership"):
|
||||||
return self.all()
|
return self.all()
|
||||||
return self.ongoing().filter(
|
return self.filter(
|
||||||
Q(user=user)
|
Q(user=user)
|
||||||
| Exists(
|
| Exists(
|
||||||
Membership.objects.ongoing().filter(
|
Membership.objects.filter(
|
||||||
|
Q(
|
||||||
|
role__gt=Greatest(
|
||||||
|
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
|
||||||
|
)
|
||||||
|
),
|
||||||
user=user,
|
user=user,
|
||||||
|
end_date=None,
|
||||||
club=OuterRef("club"),
|
club=OuterRef("club"),
|
||||||
role__is_board=True,
|
|
||||||
role__order__lt=OuterRef("role__order"),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
end_date=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def update(self, **kwargs) -> int:
|
def update(self, **kwargs) -> int:
|
||||||
@@ -469,11 +341,10 @@ class Membership(models.Model):
|
|||||||
)
|
)
|
||||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||||
role = models.ForeignKey(
|
role = models.IntegerField(
|
||||||
ClubRole,
|
_("role"),
|
||||||
verbose_name=_("role"),
|
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
||||||
related_name="members",
|
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
||||||
on_delete=models.PROTECT,
|
|
||||||
)
|
)
|
||||||
description = models.CharField(
|
description = models.CharField(
|
||||||
_("description"), max_length=128, null=False, blank=True
|
_("description"), max_length=128, null=False, blank=True
|
||||||
@@ -491,7 +362,7 @@ class Membership(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return (
|
return (
|
||||||
f"{self.club.name} - {self.user.username} "
|
f"{self.club.name} - {self.user.username} "
|
||||||
f"- {self.role.name} "
|
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
||||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -520,11 +391,7 @@ class Membership(models.Model):
|
|||||||
if user.is_root or user.is_board_member:
|
if user.is_root or user.is_board_member:
|
||||||
return True
|
return True
|
||||||
membership = self.club.get_membership_for(user)
|
membership = self.club.get_membership_for(user)
|
||||||
if not membership:
|
return membership is not None and membership.role >= self.role
|
||||||
return False
|
|
||||||
return membership.user_id == user.id or (
|
|
||||||
membership.is_board and membership.role.order < self.role.order
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
self._remove_club_groups([self])
|
self._remove_club_groups([self])
|
||||||
@@ -600,7 +467,7 @@ class Membership(models.Model):
|
|||||||
group_id=membership.club.members_group_id,
|
group_id=membership.club.members_group_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if membership.role.is_board:
|
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
club_groups.append(
|
club_groups.append(
|
||||||
User.groups.through(
|
User.groups.through(
|
||||||
user_id=membership.user_id,
|
user_id=membership.user_id,
|
||||||
|
|||||||
+4
-12
@@ -3,7 +3,7 @@ from typing import Annotated
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Club
|
model = Club
|
||||||
fields = ["id", "name", "logo", "is_active", "short_description"]
|
fields = ["id", "name", "logo"]
|
||||||
|
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
@@ -39,21 +39,14 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
return obj.get_absolute_url()
|
return obj.get_absolute_url()
|
||||||
|
|
||||||
|
|
||||||
class ClubRoleSchema(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = ClubRole
|
|
||||||
fields = ["id", "name", "is_presidency", "is_board"]
|
|
||||||
|
|
||||||
|
|
||||||
class ClubMemberSchema(ModelSchema):
|
class ClubMemberSchema(ModelSchema):
|
||||||
"""A schema to represent all memberships in a club."""
|
"""A schema to represent all memberships in a club."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["start_date", "end_date", "description"]
|
fields = ["start_date", "end_date", "role", "description"]
|
||||||
|
|
||||||
user: SimpleUserSchema
|
user: SimpleUserSchema
|
||||||
role: ClubRoleSchema
|
|
||||||
|
|
||||||
|
|
||||||
class ClubSchema(ModelSchema):
|
class ClubSchema(ModelSchema):
|
||||||
@@ -69,7 +62,6 @@ class UserMembershipSchema(ModelSchema):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Membership
|
model = Membership
|
||||||
fields = ["id", "start_date", "description"]
|
fields = ["id", "start_date", "role", "description"]
|
||||||
|
|
||||||
club: SimpleClubSchema
|
club: SimpleClubSchema
|
||||||
role: ClubRoleSchema
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
.fa-grip-vertical {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: .5em;
|
|
||||||
}
|
|
||||||
@@ -1,76 +1,52 @@
|
|||||||
{% if is_fragment %}
|
{% extends "core/base.jinja" %}
|
||||||
{% extends "core/base_fragment.jinja" %}
|
|
||||||
|
|
||||||
{# Don't display tabs and errors #}
|
{% block title -%}
|
||||||
{% block tabs %}
|
|
||||||
{% endblock %}
|
|
||||||
{% block errors %}
|
|
||||||
{% endblock %}
|
|
||||||
{% else %}
|
|
||||||
{% extends "core/base.jinja" %}
|
|
||||||
{% 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 %}
|
|
||||||
{% block title -%}
|
|
||||||
{% trans %}Club list{% endtrans %}
|
{% trans %}Club list{% endtrans %}
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% from "core/macros.jinja" import paginate_htmx %}
|
{% 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 %})
|
||||||
|
{% 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 %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
{% if user.is_root %}
|
||||||
<h3>{% trans %}Filters{% endtrans %}</h3>
|
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
|
||||||
<form
|
{% endif %}
|
||||||
id="club-list-filters"
|
{% if club_list %}
|
||||||
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>
|
<h3>{% trans %}Club list{% endtrans %}</h3>
|
||||||
{% if user.has_perm("club.add_club") %}
|
<ul>
|
||||||
<br>
|
{%- for club in club_list %}
|
||||||
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
|
{{ display_club(club) }}
|
||||||
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
|
{%- endfor %}
|
||||||
</a>
|
</ul>
|
||||||
{% 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 %}
|
{% else %}
|
||||||
<i class="fa-regular fa-image fa-4x club-image"></i>
|
{% trans %}There is no club in this website.{% endtrans %}
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
|
{% 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 %}
|
{% block additional_css %}
|
||||||
|
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||||
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -12,15 +16,6 @@
|
|||||||
|
|
||||||
<h2>{% trans %}Club members{% endtrans %}</h2>
|
<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"
|
|
||||||
>
|
|
||||||
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if add_member_fragment %}
|
{% if add_member_fragment %}
|
||||||
<br />
|
<br />
|
||||||
{{ add_member_fragment }}
|
{{ add_member_fragment }}
|
||||||
@@ -50,7 +45,7 @@
|
|||||||
{% for m in members %}
|
{% for m in members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(m.user) }}</td>
|
<td>{{ user_profile_link(m.user) }}</td>
|
||||||
<td>{{ m.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{%- if can_end_membership -%}
|
{%- if can_end_membership -%}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{% for member in old_members %}
|
{% for member in old_members %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ user_profile_link(member.user) }}</td>
|
<td>{{ user_profile_link(member.user) }}</td>
|
||||||
<td>{{ member.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
||||||
<td>{{ member.description }}</td>
|
<td>{{ member.description }}</td>
|
||||||
<td>{{ member.start_date }}</td>
|
<td>{{ member.start_date }}</td>
|
||||||
<td>{{ member.end_date }}</td>
|
<td>{{ member.end_date }}</td>
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
{% extends "core/base.jinja" %}
|
|
||||||
|
|
||||||
{% block additional_js %}
|
|
||||||
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></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-grey 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>
|
|
||||||
<br>
|
|
||||||
<h3>{% trans %}Board{% endtrans %}</h3>
|
|
||||||
<a class="btn btn-grey 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>
|
|
||||||
<br>
|
|
||||||
<h3>{% trans %}Members{% endtrans %}</h3>
|
|
||||||
<a class="btn btn-grey 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>
|
|
||||||
<br>
|
|
||||||
<p>
|
|
||||||
<button type="submit" class="btn btn-blue">
|
|
||||||
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -5,19 +5,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
||||||
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
||||||
{% 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 %}
|
{% if object.trombi %}
|
||||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
+5
-15
@@ -8,7 +8,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -43,11 +43,6 @@ class TestClub(TestCase):
|
|||||||
|
|
||||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||||
cls.club = baker.make(Club)
|
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(
|
cls.new_members_url = reverse(
|
||||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||||
)
|
)
|
||||||
@@ -56,17 +51,12 @@ class TestClub(TestCase):
|
|||||||
yesterday = now() - timedelta(days=1)
|
yesterday = now() - timedelta(days=1)
|
||||||
membership_recipe = Recipe(Membership, club=cls.club)
|
membership_recipe = Recipe(Membership, club=cls.club)
|
||||||
membership_recipe.make(
|
membership_recipe.make(
|
||||||
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
user=cls.simple_board_member, start_date=a_month_ago, role=3
|
||||||
)
|
|
||||||
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
|
membership_recipe.make( # sli was a member but isn't anymore
|
||||||
user=cls.sli,
|
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
|
||||||
start_date=a_month_ago,
|
|
||||||
end_date=yesterday,
|
|
||||||
role=cls.board_role,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
+5
-105
@@ -1,18 +1,12 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
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 django.utils.timezone import localdate
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import User
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -22,106 +16,12 @@ def test_club_queryset_having_board_member():
|
|||||||
membership_recipe = Recipe(
|
membership_recipe = Recipe(
|
||||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
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(
|
membership_recipe.make(
|
||||||
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
|
||||||
)
|
|
||||||
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)
|
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||||
assert set(club_ids) == {clubs[1].id, clubs[2].id}
|
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,7 +1,6 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -9,7 +8,7 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Group, Page, User
|
from core.models import Group, Page, User
|
||||||
|
|
||||||
@@ -27,10 +26,8 @@ class TestClubSearch(TestCase):
|
|||||||
"id", flat=True
|
"id", flat=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Membership.objects.all().delete()
|
Page.objects.exclude(club=None).delete()
|
||||||
ClubRole.objects.all().delete()
|
|
||||||
Club.objects.all().delete()
|
Club.objects.all().delete()
|
||||||
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
|
||||||
Group.objects.filter(id__in=groups).delete()
|
Group.objects.filter(id__in=groups).delete()
|
||||||
|
|
||||||
cls.clubs = baker.make(
|
cls.clubs = baker.make(
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
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([0, 1, 2]),
|
|
||||||
_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})
|
|
||||||
cls.redirect_url = reverse("club:club_members", 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": 1,
|
|
||||||
"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": 2,
|
|
||||||
"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": 3,
|
|
||||||
"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.redirect_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, self.redirect_url)
|
|
||||||
# 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
|
|
||||||
+3
-13
@@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +12,7 @@ from core.baker_recipes import subscriber_user
|
|||||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||||
baker.make(
|
baker.make(Membership, club=club, user=user, role=7)
|
||||||
Membership,
|
|
||||||
club=club,
|
|
||||||
user=user,
|
|
||||||
role=baker.make(ClubRole, club=club, is_board=True),
|
|
||||||
)
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.post(
|
res = client.post(
|
||||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||||
@@ -37,12 +32,7 @@ def test_edit_club_page_doesnt_crash(client: Client):
|
|||||||
"""crash test for club:club_edit"""
|
"""crash test for club:club_edit"""
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(
|
baker.make(Membership, club=club, user=user, role=3)
|
||||||
Membership,
|
|
||||||
club=club,
|
|
||||||
user=user,
|
|
||||||
role=baker.make(ClubRole, club=club, is_board=True),
|
|
||||||
)
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from club.forms import MailingForm
|
from club.forms import MailingForm
|
||||||
from club.models import Club, ClubRole, Mailing, Membership
|
from club.models import Club, Mailing, Membership
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ class TestMailingForm(TestCase):
|
|||||||
user=cls.rbatsbak,
|
user=cls.rbatsbak,
|
||||||
club=cls.club,
|
club=cls.club,
|
||||||
start_date=timezone.now(),
|
start_date=timezone.now(),
|
||||||
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
def test_mailing_list_add_no_moderation(self):
|
def test_mailing_list_add_no_moderation(self):
|
||||||
|
|||||||
+90
-159
@@ -1,20 +1,20 @@
|
|||||||
import itertools
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import localdate, localtime, now
|
from django.utils.timezone import localdate, localtime, now
|
||||||
from model_bakery import baker, seq
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from club.tests.base import TestClub
|
from club.tests.base import TestClub
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, User
|
from core.models import AnonymousUser, User
|
||||||
@@ -75,22 +75,17 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
def test_update_change_club_groups(self):
|
def test_update_change_club_groups(self):
|
||||||
"""Test that `update` set the user groups accordingly."""
|
"""Test that `update` set the user groups accordingly."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
board_role, member_role = baker.make(
|
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
||||||
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
|
members_group = membership.club.members_group
|
||||||
board_group = membership.club.board_group
|
board_group = membership.club.board_group
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=member_role) # from board to simple member
|
user.memberships.update(role=1) # from board to simple member
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert not user.groups.contains(board_group)
|
assert not user.groups.contains(board_group)
|
||||||
|
|
||||||
user.memberships.update(role=board_role) # from member to board
|
user.memberships.update(role=5) # from member to board
|
||||||
assert user.groups.contains(members_group)
|
assert user.groups.contains(members_group)
|
||||||
assert user.groups.contains(board_group)
|
assert user.groups.contains(board_group)
|
||||||
|
|
||||||
@@ -101,17 +96,7 @@ class TestMembershipQuerySet(TestClub):
|
|||||||
def test_delete_remove_from_groups(self):
|
def test_delete_remove_from_groups(self):
|
||||||
"""Test that `delete` removes from club groups"""
|
"""Test that `delete` removes from club groups"""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
club = baker.make(Club)
|
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
||||||
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 = {
|
club_groups = {
|
||||||
memberships[0].club.members_group,
|
memberships[0].club.members_group,
|
||||||
memberships[1].club.members_group,
|
memberships[1].club.members_group,
|
||||||
@@ -127,20 +112,13 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
Membership.objects.all().delete()
|
Membership.objects.all().delete()
|
||||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
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 = [
|
cls.memberships = [
|
||||||
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
*baker.make(
|
||||||
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
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
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_admin_user(self):
|
def test_admin_user(self):
|
||||||
@@ -162,7 +140,7 @@ class TestMembershipEditableBy(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestMembership(TestClub):
|
class TestMembership(TestClub):
|
||||||
def assert_membership_started_today(self, user: User, role: ClubRole):
|
def assert_membership_started_today(self, user: User, role: int):
|
||||||
"""Assert that the given membership is active and started today."""
|
"""Assert that the given membership is active and started today."""
|
||||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||||
assert membership is not None
|
assert membership is not None
|
||||||
@@ -211,27 +189,21 @@ class TestMembership(TestClub):
|
|||||||
"Marquer comme ancien",
|
"Marquer comme ancien",
|
||||||
]
|
]
|
||||||
rows = table.find("tbody").find_all("tr")
|
rows = table.find("tbody").find_all("tr")
|
||||||
memberships = (
|
memberships = self.club.members.ongoing().order_by("-role")
|
||||||
self.club.members.ongoing()
|
for row, membership in zip(
|
||||||
.order_by("role__order")
|
rows, memberships.select_related("user"), strict=False
|
||||||
.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 = membership.user
|
||||||
user_url = reverse("core:user_profile", args=[user.id])
|
user_url = reverse("core:user_profile", args=[user.id])
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
user_link = cols[0].find("a")
|
user_link = cols[0].find("a")
|
||||||
assert user_link.attrs["href"] == user_url
|
assert user_link.attrs["href"] == user_url
|
||||||
assert user_link.text == user.get_display_name()
|
assert user_link.text == user.get_display_name()
|
||||||
assert cols[1].text == membership.role.name
|
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
|
||||||
assert cols[2].text == membership.description
|
assert cols[2].text == membership.description
|
||||||
assert cols[3].text == str(membership.start_date)
|
assert cols[3].text == str(membership.start_date)
|
||||||
|
|
||||||
if (
|
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
||||||
membership.role.order > user_role.order
|
|
||||||
or membership.user_id == self.simple_board_member.id
|
|
||||||
):
|
|
||||||
# 3 is the role of simple_board_member
|
# 3 is the role of simple_board_member
|
||||||
form_input = cols[4].find("input")
|
form_input = cols[4].find("input")
|
||||||
expected_attrs = {
|
expected_attrs = {
|
||||||
@@ -247,15 +219,14 @@ class TestMembership(TestClub):
|
|||||||
"""Test that root users can add members to clubs"""
|
"""Test that root users can add members to clubs"""
|
||||||
self.client.force_login(self.root)
|
self.client.force_login(self.root)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
self.new_members_url,
|
self.new_members_url, {"user": self.subscriber.id, "role": 3}
|
||||||
{"user": self.subscriber.id, "role": self.board_role.id},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||||
"club:club_members", kwargs={"club_id": self.club.id}
|
"club:club_members", kwargs={"club_id": self.club.id}
|
||||||
)
|
)
|
||||||
self.subscriber.refresh_from_db()
|
self.subscriber.refresh_from_db()
|
||||||
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
self.assert_membership_started_today(self.subscriber, role=3)
|
||||||
|
|
||||||
def test_add_unauthorized_members(self):
|
def test_add_unauthorized_members(self):
|
||||||
"""Test that users who are not currently subscribed
|
"""Test that users who are not currently subscribed
|
||||||
@@ -263,7 +234,7 @@ class TestMembership(TestClub):
|
|||||||
"""
|
"""
|
||||||
for user in self.public, self.old_subscriber:
|
for user in self.public, self.old_subscriber:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": user.id, "role": self.member_role},
|
data={"user": user.id, "role": 1},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -284,7 +255,7 @@ class TestMembership(TestClub):
|
|||||||
nb_memberships = self.simple_board_member.memberships.count()
|
nb_memberships = self.simple_board_member.memberships.count()
|
||||||
self.client.post(
|
self.client.post(
|
||||||
self.members_url,
|
self.members_url,
|
||||||
{"users": self.simple_board_member.id, "role": self.member_role},
|
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
|
||||||
)
|
)
|
||||||
self.simple_board_member.refresh_from_db()
|
self.simple_board_member.refresh_from_db()
|
||||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||||
@@ -303,7 +274,7 @@ class TestMembership(TestClub):
|
|||||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": members, "role": self.member_role},
|
data={"user": members, "role": 1},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -317,6 +288,44 @@ class TestMembership(TestClub):
|
|||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
assert self.club.members.count() == nb_memberships
|
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):
|
def test_add_member_without_role(self):
|
||||||
"""Test that trying to add members without specifying their role fails."""
|
"""Test that trying to add members without specifying their role fails."""
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
@@ -327,9 +336,8 @@ class TestMembership(TestClub):
|
|||||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||||
|
|
||||||
def test_add_member_already_there(self):
|
def test_add_member_already_there(self):
|
||||||
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": self.simple_board_member, "role": role.id},
|
data={"user": self.simple_board_member, "role": 3},
|
||||||
request_user=self.root,
|
request_user=self.root,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
@@ -340,27 +348,22 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
def test_add_other_member_forbidden(self):
|
def test_add_other_member_forbidden(self):
|
||||||
non_member = subscriber_user.make()
|
non_member = subscriber_user.make()
|
||||||
simple_member = baker.make(
|
simple_member = baker.make(Membership, club=self.club, role=1).user
|
||||||
Membership, club=self.club, role=self.member_role
|
|
||||||
).user
|
|
||||||
for user in non_member, simple_member:
|
for user in non_member, simple_member:
|
||||||
form = ClubAddMemberForm(
|
form = ClubAddMemberForm(
|
||||||
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
data={"user": subscriber_user.make(), "role": 1},
|
||||||
request_user=user,
|
request_user=user,
|
||||||
club=self.club,
|
club=self.club,
|
||||||
)
|
)
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {
|
assert form.errors == {
|
||||||
"role": [
|
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
|
||||||
"Sélectionnez un choix valide. "
|
|
||||||
"Ce choix ne fait pas partie de ceux disponibles."
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_simple_members_dont_see_form_anymore(self):
|
def test_simple_members_dont_see_form_anymore(self):
|
||||||
"""Test that simple club members don't see the form to add members"""
|
"""Test that simple club members don't see the form to add members"""
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
baker.make(Membership, club=self.club, user=user, role=1)
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
res = self.client.get(self.members_url)
|
res = self.client.get(self.members_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -379,10 +382,9 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club can end memberships
|
"""Test that board members of the club can end memberships
|
||||||
of users with lower roles.
|
of users with lower roles.
|
||||||
"""
|
"""
|
||||||
|
# reminder : simple_board_member has role 3
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
role = baker.make(ClubRole, club=self.club, is_board=True)
|
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
||||||
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]})
|
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.assertRedirects(response, self.members_url)
|
self.assertRedirects(response, self.members_url)
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -392,9 +394,7 @@ class TestMembership(TestClub):
|
|||||||
"""Test that board members of the club cannot end memberships
|
"""Test that board members of the club cannot end memberships
|
||||||
of users with higher roles.
|
of users with higher roles.
|
||||||
"""
|
"""
|
||||||
membership = self.president.memberships.filter(
|
membership = self.president.memberships.filter(club=self.club).first()
|
||||||
club=self.club, end_date=None
|
|
||||||
).first()
|
|
||||||
self.client.force_login(self.simple_board_member)
|
self.client.force_login(self.simple_board_member)
|
||||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||||
self.club.refresh_from_db()
|
self.club.refresh_from_db()
|
||||||
@@ -436,9 +436,7 @@ class TestMembership(TestClub):
|
|||||||
def test_remove_from_club_group(self):
|
def test_remove_from_club_group(self):
|
||||||
"""Test that when a membership ends, the user is removed from club groups."""
|
"""Test that when a membership ends, the user is removed from club groups."""
|
||||||
user = baker.make(User)
|
user = baker.make(User)
|
||||||
baker.make(
|
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||||
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.members_group)
|
||||||
assert user.groups.contains(self.club.board_group)
|
assert user.groups.contains(self.club.board_group)
|
||||||
user.memberships.update(end_date=localdate())
|
user.memberships.update(end_date=localdate())
|
||||||
@@ -449,20 +447,18 @@ class TestMembership(TestClub):
|
|||||||
"""Test that when a membership begins, the user is added to the club group."""
|
"""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.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
baker.make(
|
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.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
|
|
||||||
def test_change_position_in_club(self):
|
def test_change_position_in_club(self):
|
||||||
"""Test that when moving from board to members, club group change"""
|
"""Test that when moving from board to members, club group change"""
|
||||||
membership = baker.make(
|
membership = baker.make(
|
||||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
Membership, club=self.club, user=self.subscriber, role=3
|
||||||
)
|
)
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert self.subscriber.groups.contains(self.club.board_group)
|
assert self.subscriber.groups.contains(self.club.board_group)
|
||||||
membership.role = self.member_role
|
membership.role = 1
|
||||||
membership.save()
|
membership.save()
|
||||||
assert self.subscriber.groups.contains(self.club.members_group)
|
assert self.subscriber.groups.contains(self.club.members_group)
|
||||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||||
@@ -475,11 +471,7 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
# make sli a board member
|
# make sli a board member
|
||||||
self.sli.memberships.all().delete()
|
self.sli.memberships.all().delete()
|
||||||
Membership(
|
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||||
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)
|
assert self.club.is_owned_by(self.sli)
|
||||||
|
|
||||||
def test_change_club_name(self):
|
def test_change_club_name(self):
|
||||||
@@ -505,7 +497,7 @@ class TestMembership(TestClub):
|
|||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_membership_set_old(client: Client):
|
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)
|
client.force_login(membership.user)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||||
@@ -532,50 +524,6 @@ def test_membership_delete(client: Client):
|
|||||||
assert not Membership.objects.filter(id=membership.id).exists()
|
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
|
@pytest.mark.django_db
|
||||||
class TestJoinClub:
|
class TestJoinClub:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -583,64 +531,55 @@ class TestJoinClub:
|
|||||||
cache.clear()
|
cache.clear()
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("user_factory", "board_role", "errors"),
|
("user_factory", "role", "errors"),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
subscriber_user.make,
|
subscriber_user.make,
|
||||||
True,
|
2,
|
||||||
{
|
{
|
||||||
"role": [
|
"role": [
|
||||||
"Sélectionnez un choix valide. "
|
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
|
||||||
"Ce choix ne fait pas partie de ceux disponibles."
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
lambda: baker.make(User),
|
lambda: baker.make(User),
|
||||||
False,
|
1,
|
||||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_join_club_errors(
|
def test_join_club_errors(
|
||||||
self, user_factory: Callable[[], User], board_role, errors: dict
|
self, user_factory: Callable[[], User], role: int, errors: dict
|
||||||
):
|
):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = user_factory()
|
user = user_factory()
|
||||||
role = baker.make(ClubRole, club=club, is_board=board_role)
|
form = JoinClubForm(club=club, request_user=user, data={"role": role})
|
||||||
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == errors
|
assert form.errors == errors
|
||||||
|
|
||||||
def test_user_already_in_club(self):
|
def test_user_already_in_club(self):
|
||||||
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
role = baker.make(ClubRole, is_board=False)
|
baker.make(Membership, user=user, club=club)
|
||||||
baker.make(Membership, user=user, club=role.club)
|
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
|
||||||
assert not form.is_valid()
|
assert not form.is_valid()
|
||||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||||
|
|
||||||
def test_ok(self):
|
def test_ok(self):
|
||||||
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
role = baker.make(ClubRole, is_board=False)
|
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
form.save()
|
form.save()
|
||||||
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
|
||||||
|
|
||||||
|
|
||||||
class TestOldMembersView(TestCase):
|
class TestOldMembersView(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
roles = baker.make(
|
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
|
||||||
ClubRole,
|
|
||||||
club=club,
|
|
||||||
is_board=itertools.cycle([True, True, False]),
|
|
||||||
order=seq(0),
|
|
||||||
_quantity=10,
|
|
||||||
_bulk_create=True,
|
|
||||||
)
|
|
||||||
cls.memberships = baker.make(
|
cls.memberships = baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
role=iter(roles),
|
role=iter(roles),
|
||||||
@@ -665,11 +604,3 @@ class TestOldMembersView(TestCase):
|
|||||||
self.client.force_login(baker.make(User))
|
self.client.force_login(baker.make(User))
|
||||||
res = self.client.get(self.url)
|
res = self.client.get(self.url)
|
||||||
assert res.status_code == 403
|
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 model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import PageRev, User
|
from core.models import PageRev, User
|
||||||
@@ -59,12 +59,7 @@ def test_page_revision(client: Client):
|
|||||||
def test_edit_page(client: Client):
|
def test_edit_page(client: Client):
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
baker.make(
|
baker.make(Membership, user=user, club=club, role=3)
|
||||||
Membership,
|
|
||||||
user=user,
|
|
||||||
club=club,
|
|
||||||
role=baker.make(ClubRole, club=club, is_board=True),
|
|
||||||
)
|
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||||
content = "# foo\nLorem ipsum dolor sit amet"
|
content = "# foo\nLorem ipsum dolor sit amet"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from club.schemas import UserMembershipSchema
|
from club.schemas import UserMembershipSchema
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import Page
|
from core.models import Page
|
||||||
@@ -19,10 +19,7 @@ class TestFetchClub(TestCase):
|
|||||||
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||||
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||||
recipe = Recipe(
|
recipe = Recipe(
|
||||||
Membership,
|
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
|
||||||
user=cls.user,
|
|
||||||
start_date=localdate() - timedelta(days=2),
|
|
||||||
role=baker.make(ClubRole),
|
|
||||||
)
|
)
|
||||||
cls.members = Membership.objects.bulk_create(
|
cls.members = Membership.objects.bulk_create(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ from club.views import (
|
|||||||
ClubPageEditView,
|
ClubPageEditView,
|
||||||
ClubPageHistView,
|
ClubPageHistView,
|
||||||
ClubRevView,
|
ClubRevView,
|
||||||
ClubRoleBoardCreateView,
|
|
||||||
ClubRoleMemberCreateView,
|
|
||||||
ClubRolePresidencyCreateView,
|
|
||||||
ClubRoleUpdateView,
|
|
||||||
ClubSellingCSVView,
|
ClubSellingCSVView,
|
||||||
ClubSellingView,
|
ClubSellingView,
|
||||||
ClubToolsView,
|
ClubToolsView,
|
||||||
@@ -75,22 +71,6 @@ urlpatterns = [
|
|||||||
ClubOldMembersView.as_view(),
|
ClubOldMembersView.as_view(),
|
||||||
name="club_old_members",
|
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/", ClubSellingView.as_view(), name="club_sellings"),
|
||||||
path(
|
path(
|
||||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||||
|
|||||||
+20
-178
@@ -28,11 +28,8 @@ import csv
|
|||||||
import itertools
|
import itertools
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from django.contrib.auth.mixins import (
|
from django.conf import settings
|
||||||
LoginRequiredMixin,
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||||
PermissionRequiredMixin,
|
|
||||||
UserPassesTestMixin,
|
|
||||||
)
|
|
||||||
from django.contrib.messages.views import SuccessMessageMixin
|
from django.contrib.messages.views import SuccessMessageMixin
|
||||||
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
|
||||||
from django.core.paginator import InvalidPage, Paginator
|
from django.core.paginator import InvalidPage, Paginator
|
||||||
@@ -47,26 +44,18 @@ from django.utils.translation import gettext
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView, View
|
from django.views.generic import DetailView, ListView, View
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
from django.views.generic.edit import (
|
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||||
CreateView,
|
|
||||||
DeleteView,
|
|
||||||
FormMixin,
|
|
||||||
UpdateView,
|
|
||||||
)
|
|
||||||
|
|
||||||
from club.forms import (
|
from club.forms import (
|
||||||
ClubAddMemberForm,
|
ClubAddMemberForm,
|
||||||
ClubAdminEditForm,
|
ClubAdminEditForm,
|
||||||
ClubEditForm,
|
ClubEditForm,
|
||||||
ClubOldMemberForm,
|
ClubOldMemberForm,
|
||||||
ClubRoleCreateForm,
|
|
||||||
ClubRoleFormSet,
|
|
||||||
ClubSearchForm,
|
|
||||||
JoinClubForm,
|
JoinClubForm,
|
||||||
MailingForm,
|
MailingForm,
|
||||||
SellingsForm,
|
SellingsForm,
|
||||||
)
|
)
|
||||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||||
from com.models import Poster
|
from com.models import Poster
|
||||||
from com.views import (
|
from com.views import (
|
||||||
PosterCreateBaseView,
|
PosterCreateBaseView,
|
||||||
@@ -77,12 +66,7 @@ from com.views import (
|
|||||||
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
||||||
from core.models import Page, PageRev
|
from core.models import Page, PageRev
|
||||||
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
|
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
|
||||||
from core.views.mixins import (
|
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
|
||||||
AllowFragment,
|
|
||||||
FragmentMixin,
|
|
||||||
FragmentRenderer,
|
|
||||||
TabedViewMixin,
|
|
||||||
)
|
|
||||||
from counter.models import Selling
|
from counter.models import Selling
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -196,41 +180,15 @@ class ClubTabsMixin(TabedViewMixin):
|
|||||||
return tab_list
|
return tab_list
|
||||||
|
|
||||||
|
|
||||||
class ClubListView(AllowFragment, FormMixin, ListView):
|
class ClubListView(ListView):
|
||||||
"""List the clubs of the AE, with a form to perform basic search.
|
"""List the Clubs."""
|
||||||
|
|
||||||
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"
|
template_name = "club/club_list.jinja"
|
||||||
form_class = ClubSearchForm
|
queryset = (
|
||||||
queryset = Club.objects.order_by("name")
|
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
|
||||||
paginate_by = 20
|
)
|
||||||
|
context_object_name = "club_list"
|
||||||
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):
|
class ClubView(ClubTabsMixin, DetailView):
|
||||||
@@ -360,7 +318,7 @@ class ClubMembersView(
|
|||||||
membership = self.object.get_membership_for(self.request.user)
|
membership = self.object.get_membership_for(self.request.user)
|
||||||
if (
|
if (
|
||||||
membership
|
membership
|
||||||
and not membership.role.is_board
|
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
and not self.request.user.has_perm("club.add_membership")
|
and not self.request.user.has_perm("club.add_membership")
|
||||||
):
|
):
|
||||||
# Simple club members won't see the form anymore.
|
# Simple club members won't see the form anymore.
|
||||||
@@ -385,8 +343,8 @@ class ClubMembersView(
|
|||||||
kwargs["members"] = list(
|
kwargs["members"] = list(
|
||||||
self.object.members.ongoing()
|
self.object.members.ongoing()
|
||||||
.annotate(is_editable=Q(id__in=editable))
|
.annotate(is_editable=Q(id__in=editable))
|
||||||
.order_by("role__order")
|
.order_by("-role")
|
||||||
.select_related("user", "role")
|
.select_related("user")
|
||||||
)
|
)
|
||||||
kwargs["can_end_membership"] = len(editable) > 0
|
kwargs["can_end_membership"] = len(editable) > 0
|
||||||
return kwargs
|
return kwargs
|
||||||
@@ -414,125 +372,12 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
|||||||
return super().get_context_data(**kwargs) | {
|
return super().get_context_data(**kwargs) | {
|
||||||
"old_members": (
|
"old_members": (
|
||||||
self.object.members.exclude(end_date=None)
|
self.object.members.exclude(end_date=None)
|
||||||
.order_by("role__order", "description", "-end_date")
|
.order_by("-role", "description", "-end_date")
|
||||||
.select_related("user", "role")
|
.select_related("user")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
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.club,
|
|
||||||
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):
|
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||||
"""Sales of a club."""
|
"""Sales of a club."""
|
||||||
|
|
||||||
@@ -699,11 +544,6 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
|
|||||||
template_name = "core/create.jinja"
|
template_name = "core/create.jinja"
|
||||||
permission_required = "club.add_club"
|
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):
|
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
||||||
"""Set a membership as being old."""
|
"""Set a membership as being old."""
|
||||||
@@ -884,7 +724,9 @@ class MailingAutoGenerationView(View):
|
|||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
club = self.mailing.club
|
club = self.mailing.club
|
||||||
self.mailing.subscriptions.all().delete()
|
self.mailing.subscriptions.all().delete()
|
||||||
members = club.members.ongoing().filter(role__is_board=True)
|
members = club.members.filter(
|
||||||
|
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
|
||||||
|
).exclude(end_date__lte=timezone.now())
|
||||||
for member in members.all():
|
for member in members.all():
|
||||||
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
MailingSubscription(user=member.user, mailing=self.mailing).save()
|
||||||
return redirect("club:mailing", club_id=club.id)
|
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 model_bakery import baker
|
||||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import AnonymousUser, Group, User
|
from core.models import AnonymousUser, Group, User
|
||||||
@@ -214,8 +214,7 @@ class TestNewsCreation(TestCase):
|
|||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
cls.user = subscriber_user.make()
|
cls.user = subscriber_user.make()
|
||||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
baker.make(Membership, user=cls.user, club=cls.club, role=5)
|
||||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|||||||
+1
-1
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
|
|||||||
self.object = form.instance
|
self.object = form.instance
|
||||||
form.is_valid() # Valid a first time to populate club field
|
form.is_valid() # Valid a first time to populate club field
|
||||||
m = form.instance.club.get_membership_for(request.user)
|
m = form.instance.club.get_membership_for(request.user)
|
||||||
if m is None or not m.role.is_board:
|
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||||
form.add_error(
|
form.add_error(
|
||||||
"club",
|
"club",
|
||||||
ValidationError(
|
ValidationError(
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.timezone import localdate, now
|
from django.utils.timezone import localdate, now
|
||||||
from model_bakery import seq
|
from model_bakery import seq
|
||||||
from model_bakery.recipe import Recipe, foreign_key, related
|
from model_bakery.recipe import Recipe, related
|
||||||
|
|
||||||
from club.models import ClubRole, Membership
|
from club.models import Membership
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from subscription.models import Subscription
|
from subscription.models import Subscription
|
||||||
|
|
||||||
@@ -52,9 +52,7 @@ ae_board_membership = Recipe(
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club_id=settings.SITH_MAIN_CLUB_ID,
|
club_id=settings.SITH_MAIN_CLUB_ID,
|
||||||
role=foreign_key(
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
board_user = Recipe(
|
board_user = Recipe(
|
||||||
|
|||||||
@@ -39,16 +39,12 @@ class Command(BaseCommand):
|
|||||||
return None
|
return None
|
||||||
return xapian.version_string()
|
return xapian.version_string()
|
||||||
|
|
||||||
def _desired_version(self) -> tuple[str, str, str]:
|
def _desired_version(self) -> str:
|
||||||
with open(
|
with open(
|
||||||
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
||||||
) as f:
|
) as f:
|
||||||
pyproject = tomli.load(f)
|
pyproject = tomli.load(f)
|
||||||
return (
|
return pyproject["tool"]["xapian"]["version"]
|
||||||
pyproject["tool"]["xapian"]["version"],
|
|
||||||
pyproject["tool"]["xapian"]["core-sha256"],
|
|
||||||
pyproject["tool"]["xapian"]["bindings-sha256"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, force: bool, **options):
|
def handle(self, *args, force: bool, **options):
|
||||||
if not os.environ.get("VIRTUAL_ENV", None):
|
if not os.environ.get("VIRTUAL_ENV", None):
|
||||||
@@ -57,7 +53,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
desired, core_checksum, bindings_checksum = self._desired_version()
|
desired = self._desired_version()
|
||||||
if desired == self._current_version():
|
if desired == self._current_version():
|
||||||
if not force:
|
if not force:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
@@ -69,12 +65,7 @@ class Command(BaseCommand):
|
|||||||
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
||||||
)
|
)
|
||||||
subprocess.run(
|
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),
|
env=dict(os.environ),
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
||||||
# first argument of the script is Xapian version (e.g. 1.2.19)
|
# 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"
|
VERSION="$1"
|
||||||
CORE_SHA256="$2"
|
|
||||||
BINDINGS_SHA256="$3"
|
|
||||||
|
|
||||||
# Cleanup env vars for auto discovery mechanism
|
# Cleanup env vars for auto discovery mechanism
|
||||||
unset CPATH
|
unset CPATH
|
||||||
@@ -25,15 +21,9 @@ BINDINGS=xapian-bindings-$VERSION
|
|||||||
|
|
||||||
# download
|
# download
|
||||||
echo "Downloading source..."
|
echo "Downloading source..."
|
||||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
|
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
|
||||||
|
|
||||||
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
|
|
||||||
|
|
||||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
||||||
|
|
||||||
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
|
|
||||||
|
|
||||||
|
|
||||||
# extract
|
# extract
|
||||||
echo "Extracting source..."
|
echo "Extracting source..."
|
||||||
tar xf "${CORE}.tar.xz"
|
tar xf "${CORE}.tar.xz"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU General Public License along with
|
||||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
@@ -36,7 +36,7 @@ from django.utils import timezone
|
|||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from com.ics_calendar import IcsCalendar
|
from com.ics_calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate, Sith, Weekmail
|
from com.models import News, NewsDate, Sith, Weekmail
|
||||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||||
@@ -62,13 +62,6 @@ class PopulatedGroups(NamedTuple):
|
|||||||
campus_admin: Group
|
campus_admin: Group
|
||||||
|
|
||||||
|
|
||||||
class PopulatedClubs(NamedTuple):
|
|
||||||
ae: Club
|
|
||||||
troll: Club
|
|
||||||
pdf: Club
|
|
||||||
refound: Club
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||||
@@ -117,17 +110,33 @@ class Command(BaseCommand):
|
|||||||
p.save(force_lock=True)
|
p.save(force_lock=True)
|
||||||
|
|
||||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||||
sas = SithFile.objects.create(name="SAS", owner=root, is_in_sas=True)
|
sas = SithFile.objects.create(name="SAS", owner=root)
|
||||||
clubs = self._create_clubs()
|
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",
|
||||||
|
"view_hidden_user",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bar_club = Club.objects.create(
|
||||||
|
id=settings.SITH_PDF_CLUB_ID,
|
||||||
|
name="PdF",
|
||||||
|
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||||
|
)
|
||||||
|
|
||||||
self.reset_index("club")
|
self.reset_index("club")
|
||||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||||
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
|
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
||||||
self.reset_index("counter")
|
self.reset_index("counter")
|
||||||
counters = [
|
counters = [
|
||||||
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
|
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
||||||
Counter(name="AE", club=clubs.ae, type="OFFICE"),
|
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||||
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
|
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||||
]
|
]
|
||||||
Counter.objects.bulk_create(counters)
|
Counter.objects.bulk_create(counters)
|
||||||
bar_groups = []
|
bar_groups = []
|
||||||
@@ -310,41 +319,54 @@ class Command(BaseCommand):
|
|||||||
self._create_subscription(tutu)
|
self._create_subscription(tutu)
|
||||||
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
||||||
|
|
||||||
Membership.objects.create(
|
# Clubs
|
||||||
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
|
Club.objects.create(
|
||||||
|
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
|
||||||
)
|
)
|
||||||
|
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(
|
Membership.objects.create(
|
||||||
user=comunity,
|
user=comunity,
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
start_date=localdate(),
|
start_date=localdate(),
|
||||||
role=clubs.pdf.roles.get(name="Membre du bureau"),
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=sli,
|
user=sli,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
|
role=9,
|
||||||
description="Padawan Troll",
|
description="Padawan Troll",
|
||||||
start_date=localdate() - timedelta(days=17),
|
start_date=localdate() - timedelta(days=17),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=krophil,
|
user=krophil,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Président⸱e"),
|
role=10,
|
||||||
description="Maitre Troll",
|
description="Maitre Troll",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=skia,
|
user=skia,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
role=2,
|
||||||
description="Grand Ancien Troll",
|
description="Grand Ancien Troll",
|
||||||
start_date=localdate() - timedelta(days=400),
|
start_date=localdate() - timedelta(days=400),
|
||||||
end_date=localdate() - timedelta(days=86),
|
end_date=localdate() - timedelta(days=86),
|
||||||
)
|
)
|
||||||
Membership.objects.create(
|
Membership.objects.create(
|
||||||
user=richard,
|
user=richard,
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
role=2,
|
||||||
description="",
|
description="",
|
||||||
start_date=localdate() - timedelta(days=200),
|
start_date=localdate() - timedelta(days=200),
|
||||||
end_date=localdate() - timedelta(days=100),
|
end_date=localdate() - timedelta(days=100),
|
||||||
@@ -361,7 +383,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="15",
|
purchase_price="15",
|
||||||
selling_price="15",
|
selling_price="15",
|
||||||
special_selling_price="15",
|
special_selling_price="15",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
cotis2 = Product.objects.create(
|
cotis2 = Product.objects.create(
|
||||||
name="Cotis 2 semestres",
|
name="Cotis 2 semestres",
|
||||||
@@ -370,7 +392,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="28",
|
purchase_price="28",
|
||||||
selling_price="28",
|
selling_price="28",
|
||||||
special_selling_price="28",
|
special_selling_price="28",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
refill = Product.objects.create(
|
refill = Product.objects.create(
|
||||||
name="Rechargement 15 €",
|
name="Rechargement 15 €",
|
||||||
@@ -379,7 +401,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="15",
|
purchase_price="15",
|
||||||
selling_price="15",
|
selling_price="15",
|
||||||
special_selling_price="15",
|
special_selling_price="15",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
barb = Product.objects.create(
|
barb = Product.objects.create(
|
||||||
name="Barbar",
|
name="Barbar",
|
||||||
@@ -388,7 +410,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
selling_price="1.7",
|
selling_price="1.7",
|
||||||
special_selling_price="1.6",
|
special_selling_price="1.6",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
cble = Product.objects.create(
|
cble = Product.objects.create(
|
||||||
@@ -398,7 +420,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
selling_price="1.7",
|
selling_price="1.7",
|
||||||
special_selling_price="1.6",
|
special_selling_price="1.6",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
cons = Product.objects.create(
|
cons = Product.objects.create(
|
||||||
@@ -408,7 +430,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="1",
|
purchase_price="1",
|
||||||
selling_price="1",
|
selling_price="1",
|
||||||
special_selling_price="1",
|
special_selling_price="1",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
dcons = Product.objects.create(
|
dcons = Product.objects.create(
|
||||||
name="Déconsigne Eco-cup",
|
name="Déconsigne Eco-cup",
|
||||||
@@ -417,7 +439,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="-1",
|
purchase_price="-1",
|
||||||
selling_price="-1",
|
selling_price="-1",
|
||||||
special_selling_price="-1",
|
special_selling_price="-1",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
)
|
)
|
||||||
cors = Product.objects.create(
|
cors = Product.objects.create(
|
||||||
name="Corsendonk",
|
name="Corsendonk",
|
||||||
@@ -426,7 +448,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
selling_price="1.7",
|
selling_price="1.7",
|
||||||
special_selling_price="1.6",
|
special_selling_price="1.6",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
carolus = Product.objects.create(
|
carolus = Product.objects.create(
|
||||||
@@ -436,7 +458,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="1.50",
|
purchase_price="1.50",
|
||||||
selling_price="1.7",
|
selling_price="1.7",
|
||||||
special_selling_price="1.6",
|
special_selling_price="1.6",
|
||||||
club=clubs.ae,
|
club=main_club,
|
||||||
limit_age=18,
|
limit_age=18,
|
||||||
)
|
)
|
||||||
Product.objects.create(
|
Product.objects.create(
|
||||||
@@ -445,7 +467,7 @@ class Command(BaseCommand):
|
|||||||
purchase_price="0",
|
purchase_price="0",
|
||||||
selling_price="0",
|
selling_price="0",
|
||||||
special_selling_price="0",
|
special_selling_price="0",
|
||||||
club=clubs.refound,
|
club=refound,
|
||||||
)
|
)
|
||||||
groups.subscribers.products.add(
|
groups.subscribers.products.add(
|
||||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||||
@@ -458,7 +480,7 @@ class Command(BaseCommand):
|
|||||||
eboutic = Counter.objects.get(name="Eboutic")
|
eboutic = Counter.objects.get(name="Eboutic")
|
||||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||||
|
|
||||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
||||||
|
|
||||||
ReturnableProduct.objects.create(
|
ReturnableProduct.objects.create(
|
||||||
product=cons, returned_product=dcons, max_return=3
|
product=cons, returned_product=dcons, max_return=3
|
||||||
@@ -482,7 +504,7 @@ class Command(BaseCommand):
|
|||||||
end_date="7942-06-12 10:28:45+01",
|
end_date="7942-06-12 10:28:45+01",
|
||||||
)
|
)
|
||||||
el.view_groups.add(groups.public)
|
el.view_groups.add(groups.public)
|
||||||
el.edit_groups.add(clubs.ae.board_group)
|
el.edit_groups.add(main_club.board_group)
|
||||||
el.candidature_groups.add(groups.subscribers)
|
el.candidature_groups.add(groups.subscribers)
|
||||||
el.vote_groups.add(groups.subscribers)
|
el.vote_groups.add(groups.subscribers)
|
||||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||||
@@ -555,7 +577,7 @@ class Command(BaseCommand):
|
|||||||
title="Apero barman",
|
title="Apero barman",
|
||||||
summary="Viens boire un coup avec les barmans",
|
summary="Viens boire un coup avec les barmans",
|
||||||
content="Glou glou glou glou glou glou glou",
|
content="Glou glou glou glou glou glou glou",
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -573,7 +595,7 @@ class Command(BaseCommand):
|
|||||||
content=(
|
content=(
|
||||||
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
||||||
),
|
),
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -589,7 +611,7 @@ class Command(BaseCommand):
|
|||||||
title="Repas fromager",
|
title="Repas fromager",
|
||||||
summary="Wien manger du l'bon fromeug'",
|
summary="Wien manger du l'bon fromeug'",
|
||||||
content="Fô viendre mangey d'la bonne fondue!",
|
content="Fô viendre mangey d'la bonne fondue!",
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -605,7 +627,7 @@ class Command(BaseCommand):
|
|||||||
title="SdF",
|
title="SdF",
|
||||||
summary="Enjoy la fin des finaux!",
|
summary="Enjoy la fin des finaux!",
|
||||||
content="Viens faire la fête avec tout plein de gens!",
|
content="Viens faire la fête avec tout plein de gens!",
|
||||||
club=clubs.pdf,
|
club=bar_club,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -623,7 +645,7 @@ class Command(BaseCommand):
|
|||||||
summary="Viens jouer!",
|
summary="Viens jouer!",
|
||||||
content="Rejoins la fine équipe du Troll Penché et viens "
|
content="Rejoins la fine équipe du Troll Penché et viens "
|
||||||
"t'amuser le Vendredi soir!",
|
"t'amuser le Vendredi soir!",
|
||||||
club=clubs.troll,
|
club=troll,
|
||||||
author=subscriber,
|
author=subscriber,
|
||||||
is_published=True,
|
is_published=True,
|
||||||
moderator=skia,
|
moderator=skia,
|
||||||
@@ -760,57 +782,6 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
s.save()
|
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:
|
def _create_groups(self) -> PopulatedGroups:
|
||||||
perms = Permission.objects.all()
|
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 django.utils.timezone import localdate, make_aware, now
|
||||||
from faker import Faker
|
from faker import Faker
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import Group, User, UserBan
|
from core.models import Group, User, UserBan
|
||||||
from counter.models import (
|
from counter.models import (
|
||||||
Counter,
|
Counter,
|
||||||
@@ -172,25 +172,20 @@ class Command(BaseCommand):
|
|||||||
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
||||||
|
|
||||||
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
||||||
roles: list[ClubRole] = list(club.roles.all())
|
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
|
||||||
|
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
|
||||||
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
|
user_idx = 0
|
||||||
for _role in important_roles:
|
while (role := next(roles)) > 2:
|
||||||
# one member for each major role
|
# one member for each major role
|
||||||
yield users[user_idx], _role
|
yield users[user_idx], role
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for _ in range(int(0.3 * (len(users) - user_idx))):
|
for _ in range(int(0.3 * (len(users) - user_idx))):
|
||||||
# 30% of the remaining in the board
|
# 30% of the remaining in the board
|
||||||
yield users[user_idx], simple_board_role
|
yield users[user_idx], 2
|
||||||
user_idx += 1
|
user_idx += 1
|
||||||
for remaining in users[user_idx + 1 :]:
|
for remaining in users[user_idx + 1 :]:
|
||||||
# everything else is a simple member
|
# everything else is a simple member
|
||||||
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
|
yield remaining, 1
|
||||||
|
|
||||||
memberships = []
|
memberships = []
|
||||||
old_members = old_members.copy()
|
old_members = old_members.copy()
|
||||||
@@ -202,14 +197,19 @@ class Command(BaseCommand):
|
|||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=self.faker.past_date(start),
|
end_date=self.faker.past_date(start),
|
||||||
user=old,
|
user=old,
|
||||||
role=random.choice(roles),
|
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
|
||||||
club=club,
|
club=club,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for member, role in zip_roles(members):
|
for member, role in zip_roles(members):
|
||||||
start = self.faker.past_date("-1y")
|
start = self.faker.past_date("-1y")
|
||||||
memberships.append(
|
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)
|
memberships = Membership.objects.bulk_create(memberships)
|
||||||
Membership._add_club_groups(memberships)
|
Membership._add_club_groups(memberships)
|
||||||
|
|||||||
@@ -1,136 +1,18 @@
|
|||||||
import {
|
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
|
||||||
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
|
* Web component used to import css files only once
|
||||||
* If called multiple times or the file was already imported, it does nothing
|
* If called multiple times or the file was already imported, it does nothing
|
||||||
**/
|
**/
|
||||||
@registerElementOnce("link-once")
|
@registerComponent("link-once")
|
||||||
export class LinkOnce extends elementOnce("link") {
|
export class LinkOnce extends inheritHtmlElement("link") {
|
||||||
getElementQuerySelector(): string {
|
connectedCallback() {
|
||||||
|
super.connectedCallback(false);
|
||||||
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
||||||
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
|
const href = this.node.attributes.getNamedItem("href").nodeValue;
|
||||||
|
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
|
||||||
|
this.appendChild(this.node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +20,14 @@ export class LinkOnce extends elementOnce("link") {
|
|||||||
* Web component used to import javascript files only once
|
* Web component used to import javascript files only once
|
||||||
* If called multiple times or the file was already imported, it does nothing
|
* If called multiple times or the file was already imported, it does nothing
|
||||||
**/
|
**/
|
||||||
@registerElementOnce("script-once")
|
@registerComponent("script-once")
|
||||||
export class ScriptOnce extends inheritHtmlElement("script") {
|
export class ScriptOnce extends inheritHtmlElement("script") {
|
||||||
getElementQuerySelector(): string {
|
connectedCallback() {
|
||||||
// We get href from node.attributes instead of node.src to avoid getting the domain part
|
super.connectedCallback(false);
|
||||||
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,10 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
|
|||||||
* The technique is to:
|
* The technique is to:
|
||||||
* create a new web component
|
* create a new web component
|
||||||
* create the desired type inside
|
* create the desired type inside
|
||||||
* move all attributes to the child component
|
* pass all attributes to the child component
|
||||||
* store is at as `node` inside the parent
|
* store is at as `node` inside the parent
|
||||||
**/
|
*
|
||||||
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
|
* Since we can't use the generic type to instantiate the node, we create a generator function
|
||||||
extends HTMLElement {
|
|
||||||
readonly inheritedTagName: K;
|
|
||||||
node: HTMLElementTagNameMap[K];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generator function that creates an InheritedHtmlElement compatible class
|
|
||||||
*
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* class MyClass extends inheritHtmlElement("select") {
|
* class MyClass extends inheritHtmlElement("select") {
|
||||||
@@ -42,15 +35,11 @@ export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
|
|||||||
* ```
|
* ```
|
||||||
**/
|
**/
|
||||||
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||||
return class InheritedHtmlElementImpl
|
return class Inherited extends HTMLElement {
|
||||||
extends HTMLElement
|
protected node: HTMLElementTagNameMap[K];
|
||||||
implements InheritedHtmlElement<K>
|
|
||||||
{
|
|
||||||
readonly inheritedTagName = tagName;
|
|
||||||
node: HTMLElementTagNameMap[K];
|
|
||||||
|
|
||||||
connectedCallback(autoAddNode?: boolean) {
|
connectedCallback(autoAddNode?: boolean) {
|
||||||
this.node = document.createElement(this.inheritedTagName);
|
this.node = document.createElement(tagName);
|
||||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||||
for (const attr of this.attributes) {
|
for (const attr of this.attributes) {
|
||||||
if (attr.name in this.node) {
|
if (attr.name in this.node) {
|
||||||
@@ -58,10 +47,6 @@ 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) {
|
for (const attr of attributes) {
|
||||||
this.removeAttributeNode(attr);
|
this.removeAttributeNode(attr);
|
||||||
this.node.setAttributeNode(attr);
|
this.node.setAttributeNode(attr);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
||||||
@supports (max-height: calc-size(max-content, size)) {
|
@supports (max-height: calc-size(max-content, size)) {
|
||||||
max-height: 0;
|
max-height: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,12 +71,11 @@ details.accordion>.accordion-content {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ::details-content is available on firefox only since september 2025
|
// ::details-content isn't available on firefox yet
|
||||||
// (and wasn't available when this code was initially written)
|
|
||||||
// we use .accordion-content as a workaround
|
// we use .accordion-content as a workaround
|
||||||
// But we need to use ::details-content for chrome because it's
|
// But we need to use ::details-content for chrome because it's
|
||||||
// not working correctly otherwise
|
// not working correctly otherwise
|
||||||
// it only happens in chrome, not safari or firefox
|
// it only happen in chrome, not safari or firefox
|
||||||
// Note: `selector` is not supported by scss so we comment it out to
|
// Note: `selector` is not supported by scss so we comment it out to
|
||||||
// avoid compiling it and sending it straight to the css
|
// avoid compiling it and sending it straight to the css
|
||||||
// This is a trick that comes from here :
|
// This is a trick that comes from here :
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
<details name="navbar" class="menu">
|
<details name="navbar" class="menu">
|
||||||
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
|
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
|
||||||
<ul class="content">
|
<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='ae') }}">{% trans %}AE{% endtrans %}</a></li>
|
||||||
<li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% 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>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
<details name="navbar" class="menu">
|
<details name="navbar" class="menu">
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% 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 %}
|
{% block title %}
|
||||||
{% trans name=object_name %}Create {{ name }}{% endtrans %}
|
{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2>
|
<h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
|
||||||
<form action="" method="post" enctype="multipart/form-data">
|
<form action="" method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p() }}
|
{{ form.as_p() }}
|
||||||
|
|||||||
@@ -129,10 +129,10 @@
|
|||||||
current_page (django.core.paginator.Page): the current page object
|
current_page (django.core.paginator.Page): the current page object
|
||||||
paginator (django.core.paginator.Paginator): the paginator object
|
paginator (django.core.paginator.Paginator): the paginator object
|
||||||
#}
|
#}
|
||||||
{{ paginate_server_side(request, current_page, paginator, "") }}
|
{{ paginate_server_side(request, current_page, paginator, False) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro paginate_htmx(request, current_page, paginator, htmx_target="#content") %}
|
{% macro paginate_htmx(request, current_page, paginator) %}
|
||||||
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
||||||
|
|
||||||
This must be coupled with a view that handles pagination
|
This must be coupled with a view that handles pagination
|
||||||
@@ -144,19 +144,18 @@
|
|||||||
request (django.http.request.HttpRequest): the current django request
|
request (django.http.request.HttpRequest): the current django request
|
||||||
current_page (django.core.paginator.Page): the current page object
|
current_page (django.core.paginator.Page): the current page object
|
||||||
paginator (django.core.paginator.Paginator): the paginator object
|
paginator (django.core.paginator.Paginator): the paginator object
|
||||||
htmx_target (string): htmx target selector (default '#content')
|
|
||||||
#}
|
#}
|
||||||
{{ paginate_server_side(request, current_page, paginator, htmx_target) }}
|
{{ paginate_server_side(request, current_page, paginator, True) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro paginate_server_side(request, current_page, paginator, htmx_target) %}
|
{% macro paginate_server_side(request, current_page, paginator, use_htmx) %}
|
||||||
<nav class="pagination">
|
<nav class="pagination">
|
||||||
{% if current_page.has_previous() %}
|
{% if current_page.has_previous() %}
|
||||||
<a
|
<a
|
||||||
{% if htmx_target -%}
|
{% if use_htmx -%}
|
||||||
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-target="{{ htmx_target }}"
|
hx-target="#content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
|
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
@@ -177,10 +176,10 @@
|
|||||||
<strong>{{ paginator.ELLIPSIS }}</strong>
|
<strong>{{ paginator.ELLIPSIS }}</strong>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a
|
<a
|
||||||
{% if htmx_target -%}
|
{% if use_htmx -%}
|
||||||
hx-get="?{{ querystring(request, page=i) }}"
|
hx-get="?{{ querystring(request, page=i) }}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-target="{{ htmx_target }}"
|
hx-target="#content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
href="?{{ querystring(request, page=i) }}"
|
href="?{{ querystring(request, page=i) }}"
|
||||||
@@ -192,10 +191,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if current_page.has_next() %}
|
{% if current_page.has_next() %}
|
||||||
<a
|
<a
|
||||||
{% if htmx_target -%}
|
{% if use_htmx -%}
|
||||||
hx-get="?{{querystring(request, page=current_page.next_page_number())}}"
|
hx-get="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-target="{{ htmx_target }}"
|
hx-target="#content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
|
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
|
||||||
{%- else -%}
|
{%- else -%}
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in profile.memberships.ongoing().select_related("role") %}
|
{% for m in profile.memberships.filter(end_date=None).all() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||||
<td>{{ m.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
{% if m.can_be_edited_by(user) %}
|
{% if m.can_be_edited_by(user) %}
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
|
{% for m in profile.memberships.exclude(end_date=None).all() %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||||
<td>{{ m.role.name }}</td>
|
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||||
<td>{{ m.description }}</td>
|
<td>{{ m.description }}</td>
|
||||||
<td>{{ m.start_date }}</td>
|
<td>{{ m.start_date }}</td>
|
||||||
<td>{{ m.end_date }}</td>
|
<td>{{ m.end_date }}</td>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
{% extends "core/base.jinja" %}
|
{% extends "core/base.jinja" %}
|
||||||
|
|
||||||
|
{%- block additional_js -%}
|
||||||
|
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||||
|
{%- endblock -%}
|
||||||
|
|
||||||
{%- block additional_css -%}
|
{%- block additional_css -%}
|
||||||
<link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}">
|
<link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}">
|
||||||
|
{# importing ajax-select-index is necessary for it to be applied after HTMX reload #}
|
||||||
|
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||||
|
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||||
{%- endblock -%}
|
{%- endblock -%}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.utils.timezone import now
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Page, PageRev, User
|
from core.models import AnonymousUser, Page, PageRev, User
|
||||||
@@ -122,9 +122,6 @@ def test_page_revision_club_redirection(client: Client):
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_viewable_by():
|
def test_viewable_by():
|
||||||
# remove existing pages to prevent side effect
|
# 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()
|
Page.objects.all().delete()
|
||||||
view_groups = [
|
view_groups = [
|
||||||
[settings.SITH_GROUP_PUBLIC_ID],
|
[settings.SITH_GROUP_PUBLIC_ID],
|
||||||
|
|||||||
+1
-1
@@ -583,7 +583,7 @@ class Counter(models.Model):
|
|||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return False
|
return False
|
||||||
mem = self.club.get_membership_for(user)
|
mem = self.club.get_membership_for(user)
|
||||||
if mem and mem.role.is_presidency:
|
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||||
return True
|
return True
|
||||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from model_bakery import baker
|
|||||||
from model_bakery.recipe import Recipe
|
from model_bakery.recipe import Recipe
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import ClubRole, Membership
|
from club.models import Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||||
from core.models import BanGroup, User
|
from core.models import BanGroup, User
|
||||||
from counter.baker_recipes import product_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, sale_recipe
|
||||||
@@ -88,7 +88,7 @@ class TestFullClickBase(TestCase):
|
|||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club=cls.club_counter.club,
|
club=cls.club_counter.club,
|
||||||
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
user=cls.club_admin,
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -782,13 +782,7 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
"counter:click",
|
"counter:click",
|
||||||
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
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()
|
cls.user = subscriber_user.make()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -803,17 +797,13 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
# being a member of the club, without being in the board, isn't enough
|
# being a member of the club, without being in the board, isn't enough
|
||||||
baker.make(
|
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.member_role
|
|
||||||
)
|
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
|
|
||||||
def test_board_member(self):
|
def test_board_member(self):
|
||||||
"""By default, board members should be able to click on office counters"""
|
"""By default, board members should be able to click on office counters"""
|
||||||
baker.make(
|
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
|
||||||
)
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -828,9 +818,7 @@ class TestClubCounterClickAccess(TestCase):
|
|||||||
def test_both_barman_and_board_member(self):
|
def test_both_barman_and_board_member(self):
|
||||||
"""If the user is barman and board member, he should be authorized as well."""
|
"""If the user is barman and board member, he should be authorized as well."""
|
||||||
self.counter.sellers.add(self.user)
|
self.counter.sellers.add(self.user)
|
||||||
baker.make(
|
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
|
||||||
)
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
res = self.client.get(self.click_url)
|
res = self.client.get(self.click_url)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import string
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.base_user import make_password
|
from django.contrib.auth.base_user import make_password
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
|
|
||||||
from club.models import ClubRole, Membership
|
from club.models import Membership
|
||||||
from core.baker_recipes import board_user, subscriber_user
|
from core.baker_recipes import board_user, subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||||
@@ -41,12 +42,11 @@ class TestStudentCard(TestCase):
|
|||||||
cls.counter.sellers.add(cls.barmen)
|
cls.counter.sellers.add(cls.barmen)
|
||||||
|
|
||||||
cls.club_counter = baker.make(Counter)
|
cls.club_counter = baker.make(Counter)
|
||||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
|
||||||
baker.make(
|
baker.make(
|
||||||
Membership,
|
Membership,
|
||||||
start_date=now() - timedelta(days=30),
|
start_date=now() - timedelta(days=30),
|
||||||
club=cls.club_counter.club,
|
club=cls.club_counter.club,
|
||||||
role=role,
|
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||||
user=cls.club_admin,
|
user=cls.club_admin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
## 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
|
|
||||||
```
|
|
||||||
@@ -56,12 +56,6 @@ Commencez par installer les dépendances système :
|
|||||||
sudo pacman -S postgresql nginx
|
sudo pacman -S postgresql nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Fedora/RHEL/AlmaLinux/Rocky"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo dnf install postgresql libpq-devel nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
=== "macOS"
|
=== "macOS"
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -106,11 +100,9 @@ PROCFILE_SERVICE=
|
|||||||
vous devez ouvrir une autre fenêtre de votre terminal
|
vous devez ouvrir une autre fenêtre de votre terminal
|
||||||
et lancer la commande `npm run serve`
|
et lancer la commande `npm run serve`
|
||||||
|
|
||||||
## Configurer Redis/Valkey en service externe
|
## Configurer Redis en service externe
|
||||||
|
|
||||||
Redis est installé comme dépendance mais n'es pas lancé par défaut.
|
Redis est installé comme dépendance mais 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
|
En mode développement, le sith se charge de le démarrer mais
|
||||||
pas en production !
|
pas en production !
|
||||||
|
|||||||
@@ -79,29 +79,6 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
|||||||
sudo pacman -S uv gcc git gettext pkgconf npm valkey
|
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"
|
=== "macOS"
|
||||||
|
|
||||||
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
|
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
|
||||||
@@ -121,7 +98,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
|||||||
!!!note
|
!!!note
|
||||||
|
|
||||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||||
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
|
installé par uv.
|
||||||
|
|
||||||
## Finaliser l'installation
|
## Finaliser l'installation
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -183,7 +183,7 @@ class Forum(models.Model):
|
|||||||
Forum._club_memberships[self.id] = {}
|
Forum._club_memberships[self.id] = {}
|
||||||
Forum._club_memberships[self.id][user.id] = m
|
Forum._club_memberships[self.id][user.id] = m
|
||||||
if m:
|
if m:
|
||||||
return m.role.is_board
|
return m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def check_loop(self):
|
def check_loop(self):
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ from django.conf import settings
|
|||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from model_bakery import baker
|
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.models import Group, Page, SithFile, User
|
from core.models import Group, Page, SithFile, User
|
||||||
from core.utils import RED_PIXEL_PNG
|
from core.utils import RED_PIXEL_PNG
|
||||||
from sas.models import Album, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
@@ -218,19 +217,11 @@ class Command(BaseCommand):
|
|||||||
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
||||||
)
|
)
|
||||||
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
|
for i in range(1, 11): # users can be in up to 20 clubs
|
||||||
self.logger.info(f"Club membership, pass {i}")
|
self.logger.info(f"Club membership, pass {i}")
|
||||||
for uid in range(i, self.NB_USERS, i):
|
for uid in range(
|
||||||
# Pass #1 will make sure every user is at least in one club
|
i, self.NB_USERS, i
|
||||||
|
): # Pass #1 will make sure every user is at least in one club
|
||||||
user = self.users[uid]
|
user = self.users[uid]
|
||||||
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
||||||
|
|
||||||
@@ -245,7 +236,7 @@ class Command(BaseCommand):
|
|||||||
Membership(
|
Membership(
|
||||||
user=user,
|
user=user,
|
||||||
club=club,
|
club=club,
|
||||||
role_id=roles[club.id],
|
role=(uid + i) % 10 + 1, # spread the different roles
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
)
|
)
|
||||||
@@ -268,7 +259,7 @@ class Command(BaseCommand):
|
|||||||
Membership(
|
Membership(
|
||||||
user=user,
|
user=user,
|
||||||
club=club,
|
club=club,
|
||||||
role_id=roles[club.id],
|
role=((uid // 10) + i) % 10 + 1, # spread the different roles
|
||||||
start_date=start,
|
start_date=start,
|
||||||
end_date=end,
|
end_date=end,
|
||||||
)
|
)
|
||||||
|
|||||||
+132
-264
@@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-18 11:38+0200\n"
|
"POT-Creation-Date: 2026-03-23 22:21+0100\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@@ -181,22 +181,6 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
|
|||||||
msgid "You are already a member of this club"
|
msgid "You are already a member of this club"
|
||||||
msgstr "Vous êtes déjà membre de ce club."
|
msgstr "Vous êtes déjà membre de ce club."
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Club status"
|
|
||||||
msgstr "État du club"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Active"
|
|
||||||
msgstr "Actif"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "Inactive"
|
|
||||||
msgstr "Inactif"
|
|
||||||
|
|
||||||
#: club/forms.py
|
|
||||||
msgid "All clubs"
|
|
||||||
msgstr "Tous les clubs"
|
|
||||||
|
|
||||||
#: club/models.py
|
#: club/models.py
|
||||||
msgid "slug name"
|
msgid "slug name"
|
||||||
msgstr "nom slug"
|
msgstr "nom slug"
|
||||||
@@ -233,65 +217,15 @@ msgstr "home"
|
|||||||
msgid "You can not make loops in clubs"
|
msgid "You can not make loops in clubs"
|
||||||
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
|
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
|
||||||
|
|
||||||
#: club/models.py com/models.py counter/models.py trombi/models.py
|
|
||||||
msgid "club"
|
|
||||||
msgstr "club"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "The club with which this role is associated"
|
|
||||||
msgstr "Le club auquel ce rôle est attaché."
|
|
||||||
|
|
||||||
#: club/models.py core/models.py counter/models.py election/models.py
|
|
||||||
#: forum/models.py
|
|
||||||
msgid "description"
|
|
||||||
msgstr "description"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "Board role"
|
|
||||||
msgstr "Rôle du bureau"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "Presidency role"
|
|
||||||
msgstr "Rôle de la présidence"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid ""
|
|
||||||
"If the role is inactive, people joining the club won't be able to get it."
|
|
||||||
msgstr ""
|
|
||||||
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
|
||||||
"rejoignent le club."
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "club role"
|
|
||||||
msgstr "rôle de club"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "club roles"
|
|
||||||
msgstr "rôles de club"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
msgid "A role cannot be in the presidency while not being in the board"
|
|
||||||
msgstr ""
|
|
||||||
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau"
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
#, python-format
|
|
||||||
msgid "Role %(role)s cannot be placed below a member role"
|
|
||||||
msgstr ""
|
|
||||||
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
|
|
||||||
|
|
||||||
#: club/models.py
|
|
||||||
#, python-format
|
|
||||||
msgid "Role %(role)s cannot be placed below a non-presidency role"
|
|
||||||
msgstr ""
|
|
||||||
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle qui n'est pas "
|
|
||||||
"de la présidence."
|
|
||||||
|
|
||||||
#: club/models.py core/models.py counter/models.py eboutic/models.py
|
#: club/models.py core/models.py counter/models.py eboutic/models.py
|
||||||
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
|
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
|
#: club/models.py com/models.py counter/models.py trombi/models.py
|
||||||
|
msgid "club"
|
||||||
|
msgstr "club"
|
||||||
|
|
||||||
#: club/models.py counter/models.py election/models.py
|
#: club/models.py counter/models.py election/models.py
|
||||||
msgid "start date"
|
msgid "start date"
|
||||||
msgstr "date de début"
|
msgstr "date de début"
|
||||||
@@ -304,6 +238,11 @@ msgstr "date de fin"
|
|||||||
msgid "role"
|
msgid "role"
|
||||||
msgstr "rôle"
|
msgstr "rôle"
|
||||||
|
|
||||||
|
#: club/models.py core/models.py counter/models.py election/models.py
|
||||||
|
#: forum/models.py
|
||||||
|
msgid "description"
|
||||||
|
msgstr "description"
|
||||||
|
|
||||||
#: club/models.py
|
#: club/models.py
|
||||||
msgid "past member"
|
msgid "past member"
|
||||||
msgstr "ancien membre"
|
msgstr "ancien membre"
|
||||||
@@ -362,39 +301,30 @@ msgstr "Cet email est déjà abonné à cette mailing"
|
|||||||
msgid "Unregistered user"
|
msgid "Unregistered user"
|
||||||
msgstr "Utilisateur non enregistré"
|
msgstr "Utilisateur non enregistré"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
|
||||||
msgid "The list of all clubs existing at UTBM."
|
|
||||||
msgstr "La liste de tous les clubs existants à l'UTBM"
|
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "Club list"
|
msgid "Club list"
|
||||||
msgstr "Liste des clubs"
|
msgstr "Liste des clubs"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "Filters"
|
msgid "The list of all clubs existing at UTBM."
|
||||||
msgstr "Filtres"
|
msgstr "La liste de tous les clubs existants à l'UTBM"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
|
msgid "inactive"
|
||||||
msgid "Search"
|
msgstr "inactif"
|
||||||
msgstr "Recherche"
|
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
||||||
msgid "New club"
|
msgid "New club"
|
||||||
msgstr "Nouveau club"
|
msgstr "Nouveau club"
|
||||||
|
|
||||||
#: club/templates/club/club_list.jinja club/templates/club/club_roles.jinja
|
#: club/templates/club/club_list.jinja
|
||||||
msgid "inactive"
|
msgid "There is no club in this website."
|
||||||
msgstr "inactif"
|
msgstr "Il n'y a pas de club dans ce site web."
|
||||||
|
|
||||||
#: club/templates/club/club_members.jinja
|
#: club/templates/club/club_members.jinja
|
||||||
msgid "Club members"
|
msgid "Club members"
|
||||||
msgstr "Membres du club"
|
msgstr "Membres du club"
|
||||||
|
|
||||||
#: club/templates/club/club_members.jinja
|
|
||||||
msgid "Manage roles"
|
|
||||||
msgstr "Gérer les rôles"
|
|
||||||
|
|
||||||
#: club/templates/club/club_members.jinja
|
#: club/templates/club/club_members.jinja
|
||||||
#: club/templates/club/club_old_members.jinja
|
#: club/templates/club/club_old_members.jinja
|
||||||
#: core/templates/core/user_clubs.jinja
|
#: core/templates/core/user_clubs.jinja
|
||||||
@@ -433,120 +363,6 @@ msgstr "Du"
|
|||||||
msgid "To"
|
msgid "To"
|
||||||
msgstr "Au"
|
msgstr "Au"
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid ""
|
|
||||||
"Roles give rights on the club. Higher roles grant more rights, and the "
|
|
||||||
"members having them are displayed higher in the club members list."
|
|
||||||
msgstr ""
|
|
||||||
"Les rôles donnent des droits sur le club. Les rôles plus élevés donnent plus "
|
|
||||||
"de droit, et les membres qui les possèdent sont affichés plus haut dans la "
|
|
||||||
"liste des membres."
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid ""
|
|
||||||
"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)."
|
|
||||||
msgstr ""
|
|
||||||
"Sur cette page, vous pouvez éditer leur nom et leur description, ainsi que "
|
|
||||||
"leur ordre. Vous pouvez également déplacer des rôles d'une catégorie à "
|
|
||||||
"l'autre (par exemple, un rôle du bureau peut devenir un rôle de présidence)."
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "Presidency"
|
|
||||||
msgstr "Présidence"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "add role"
|
|
||||||
msgstr "ajouter un rôle"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja core/templates/core/base/navbar.jinja
|
|
||||||
msgid "Help"
|
|
||||||
msgstr "Aide"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "Users with a presidency role can :"
|
|
||||||
msgstr "Les utilisateurs avec un rôle de présidence peuvent :"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "create new club roles and edit existing ones"
|
|
||||||
msgstr "créer de nouveaux rôles et modifier ceux qui existent"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "manage the club counters"
|
|
||||||
msgstr "gérer les comptoirs du club"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "add new members with any active role and end any membership"
|
|
||||||
msgstr ""
|
|
||||||
"ajouter de nouveaux membres avec n'importe quel rôle et mettre fin à "
|
|
||||||
"n'importe quelle adhésion au club."
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "They also have all the rights of the club board."
|
|
||||||
msgstr "Ils possèdent également tous les droits du bureau."
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "Board"
|
|
||||||
msgstr "Bureau"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid ""
|
|
||||||
"Board members can do most administrative actions in the club, including :"
|
|
||||||
msgstr ""
|
|
||||||
"Les membres du bureau peuvent effectuer la plupart des actions "
|
|
||||||
"administratives dans le club, incluant :"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "manage the club posters"
|
|
||||||
msgstr "gérer les affiches du club"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "create news for the club"
|
|
||||||
msgstr "créer des nouvelles pour le club"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "click users on the club's counters"
|
|
||||||
msgstr "cliquer des utilisateurs sur les comptoirs du club"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid ""
|
|
||||||
"add new members and end active memberships for roles that are lower than "
|
|
||||||
"their own."
|
|
||||||
msgstr ""
|
|
||||||
"ajouter de nouveaux membres et mettre fin à des adhésions en cours, pour des "
|
|
||||||
"rôles plus bas que le leur."
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja club/views.py
|
|
||||||
msgid "Members"
|
|
||||||
msgstr "Membres"
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja
|
|
||||||
msgid "Simple members cannot perform administrative actions."
|
|
||||||
msgstr ""
|
|
||||||
"Les simples membres ne peuvent pas effectuer d'actions administratives."
|
|
||||||
|
|
||||||
#: club/templates/club/club_roles.jinja club/templates/club/edit_club.jinja
|
|
||||||
#: club/templates/club/pagerev_edit.jinja com/templates/com/news_edit.jinja
|
|
||||||
#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
|
|
||||||
#: com/templates/com/weekmail.jinja core/templates/core/create.jinja
|
|
||||||
#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja
|
|
||||||
#: core/templates/core/fragment/user_visibility.jinja
|
|
||||||
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
|
|
||||||
#: core/templates/core/user_godfathers.jinja
|
|
||||||
#: core/templates/core/user_godfathers_tree.jinja
|
|
||||||
#: core/templates/core/user_preferences.jinja
|
|
||||||
#: counter/templates/counter/cash_register_summary.jinja
|
|
||||||
#: counter/templates/counter/invoices_call.jinja
|
|
||||||
#: counter/templates/counter/product_form.jinja
|
|
||||||
#: forum/templates/forum/reply.jinja
|
|
||||||
#: subscription/templates/subscription/fragments/creation_form.jinja
|
|
||||||
#: trombi/templates/trombi/comment.jinja
|
|
||||||
#: trombi/templates/trombi/edit_profile.jinja
|
|
||||||
#: trombi/templates/trombi/user_tools.jinja
|
|
||||||
msgid "Save"
|
|
||||||
msgstr "Sauver"
|
|
||||||
|
|
||||||
#: club/templates/club/club_sellings.jinja
|
#: club/templates/club/club_sellings.jinja
|
||||||
msgid "Previous"
|
msgid "Previous"
|
||||||
msgstr "Précédent"
|
msgstr "Précédent"
|
||||||
@@ -731,6 +547,27 @@ msgstr ""
|
|||||||
"Les champs de formulaire suivants sont liées à la description basique d'un "
|
"Les champs de formulaire suivants sont liées à la description basique d'un "
|
||||||
"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
|
"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
|
||||||
|
|
||||||
|
#: club/templates/club/edit_club.jinja club/templates/club/pagerev_edit.jinja
|
||||||
|
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
|
||||||
|
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
|
||||||
|
#: core/templates/core/create.jinja core/templates/core/edit.jinja
|
||||||
|
#: core/templates/core/file_edit.jinja
|
||||||
|
#: core/templates/core/fragment/user_visibility.jinja
|
||||||
|
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
|
||||||
|
#: core/templates/core/user_godfathers.jinja
|
||||||
|
#: core/templates/core/user_godfathers_tree.jinja
|
||||||
|
#: core/templates/core/user_preferences.jinja
|
||||||
|
#: counter/templates/counter/cash_register_summary.jinja
|
||||||
|
#: counter/templates/counter/invoices_call.jinja
|
||||||
|
#: counter/templates/counter/product_form.jinja
|
||||||
|
#: forum/templates/forum/reply.jinja
|
||||||
|
#: subscription/templates/subscription/fragments/creation_form.jinja
|
||||||
|
#: trombi/templates/trombi/comment.jinja
|
||||||
|
#: trombi/templates/trombi/edit_profile.jinja
|
||||||
|
#: trombi/templates/trombi/user_tools.jinja
|
||||||
|
msgid "Save"
|
||||||
|
msgstr "Sauver"
|
||||||
|
|
||||||
#: club/templates/club/fragments/add_member.jinja
|
#: club/templates/club/fragments/add_member.jinja
|
||||||
msgid "Add a new member"
|
msgid "Add a new member"
|
||||||
msgstr "Ajouter un nouveau membre"
|
msgstr "Ajouter un nouveau membre"
|
||||||
@@ -811,6 +648,10 @@ msgstr "Éditer la page"
|
|||||||
msgid "Infos"
|
msgid "Infos"
|
||||||
msgstr "Infos"
|
msgstr "Infos"
|
||||||
|
|
||||||
|
#: club/views.py
|
||||||
|
msgid "Members"
|
||||||
|
msgstr "Membres"
|
||||||
|
|
||||||
#: club/views.py
|
#: club/views.py
|
||||||
msgid "Old members"
|
msgid "Old members"
|
||||||
msgstr "Anciens membres"
|
msgstr "Anciens membres"
|
||||||
@@ -860,27 +701,6 @@ msgstr "Vous êtes maintenant membre de ce club."
|
|||||||
msgid "%(user)s has been added to club."
|
msgid "%(user)s has been added to club."
|
||||||
msgstr "%(user)s a été ajouté au club."
|
msgstr "%(user)s a été ajouté au club."
|
||||||
|
|
||||||
#: club/views.py
|
|
||||||
msgid "Club roles updated"
|
|
||||||
msgstr "Rôles de club mis à jour"
|
|
||||||
|
|
||||||
#: club/views.py
|
|
||||||
#, python-format
|
|
||||||
msgid "Role %(name)s created"
|
|
||||||
msgstr "Rôle %(name)s créé"
|
|
||||||
|
|
||||||
#: club/views.py
|
|
||||||
msgid "club role – presidency"
|
|
||||||
msgstr "rôle de club – présidence"
|
|
||||||
|
|
||||||
#: club/views.py
|
|
||||||
msgid "club role – board"
|
|
||||||
msgstr "rôle de club – bureau"
|
|
||||||
|
|
||||||
#: club/views.py
|
|
||||||
msgid "club role – member"
|
|
||||||
msgstr "rôle de club – membre"
|
|
||||||
|
|
||||||
#: club/views.py
|
#: club/views.py
|
||||||
msgid "Benefit"
|
msgid "Benefit"
|
||||||
msgstr "Bénéfice"
|
msgstr "Bénéfice"
|
||||||
@@ -2023,6 +1843,11 @@ msgstr "Connexion"
|
|||||||
msgid "Register"
|
msgid "Register"
|
||||||
msgstr "Inscription"
|
msgstr "Inscription"
|
||||||
|
|
||||||
|
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
|
||||||
|
#: matmat/templates/matmat/search_form.jinja
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Recherche"
|
||||||
|
|
||||||
#: core/templates/core/base/header.jinja
|
#: core/templates/core/base/header.jinja
|
||||||
msgid "Logout"
|
msgid "Logout"
|
||||||
msgstr "Déconnexion"
|
msgstr "Déconnexion"
|
||||||
@@ -2056,6 +1881,10 @@ msgstr "L'AE"
|
|||||||
msgid "AE's clubs"
|
msgid "AE's clubs"
|
||||||
msgstr "Les clubs de L'AE"
|
msgstr "Les clubs de L'AE"
|
||||||
|
|
||||||
|
#: core/templates/core/base/navbar.jinja
|
||||||
|
msgid "Others UTBM's Associations"
|
||||||
|
msgstr "Les autres associations de l'UTBM"
|
||||||
|
|
||||||
#: core/templates/core/base/navbar.jinja
|
#: core/templates/core/base/navbar.jinja
|
||||||
msgid "Big event"
|
msgid "Big event"
|
||||||
msgstr "Grandes Activités"
|
msgstr "Grandes Activités"
|
||||||
@@ -2104,6 +1933,10 @@ msgstr "Partenaires"
|
|||||||
msgid "Subscriber benefits"
|
msgid "Subscriber benefits"
|
||||||
msgstr "Les avantages cotisants"
|
msgstr "Les avantages cotisants"
|
||||||
|
|
||||||
|
#: core/templates/core/base/navbar.jinja
|
||||||
|
msgid "Help"
|
||||||
|
msgstr "Aide"
|
||||||
|
|
||||||
#: core/templates/core/base/navbar.jinja
|
#: core/templates/core/base/navbar.jinja
|
||||||
msgid "FAQ"
|
msgid "FAQ"
|
||||||
msgstr "FAQ"
|
msgstr "FAQ"
|
||||||
@@ -4363,47 +4196,6 @@ msgstr ""
|
|||||||
msgid "this page"
|
msgid "this page"
|
||||||
msgstr "cette page"
|
msgstr "cette page"
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid "Eurockéennes 2025 partnership"
|
|
||||||
msgstr "Partenariat Eurockéennes 2025"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid ""
|
|
||||||
"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."
|
|
||||||
msgstr ""
|
|
||||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
|
||||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
|
||||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
|
||||||
"leurs termes de service."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid "Privacy policy"
|
|
||||||
msgstr "Politique de confidentialité"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#: trombi/templates/trombi/comment_moderation.jinja
|
|
||||||
msgid "Accept"
|
|
||||||
msgstr "Accepter"
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
msgid ""
|
|
||||||
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
|
||||||
msgstr ""
|
|
||||||
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
|
||||||
"Eurockéennes."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
|
||||||
#, python-format
|
|
||||||
msgid ""
|
|
||||||
"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."
|
|
||||||
msgstr ""
|
|
||||||
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
|
||||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
|
||||||
"du vendredi au dimanche."
|
|
||||||
|
|
||||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
msgid "There are no items available for sale"
|
msgid "There are no items available for sale"
|
||||||
msgstr "Aucun article n'est disponible à la vente"
|
msgstr "Aucun article n'est disponible à la vente"
|
||||||
@@ -5558,6 +5350,42 @@ msgstr "Cursus alternant (-20%)"
|
|||||||
msgid "One year for free(CA offer)"
|
msgid "One year for free(CA offer)"
|
||||||
msgstr "Une année offerte (Offre CA)"
|
msgstr "Une année offerte (Offre CA)"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "President"
|
||||||
|
msgstr "Président⸱e"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Vice-President"
|
||||||
|
msgstr "Vice-Président⸱e"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Treasurer"
|
||||||
|
msgstr "Trésorier⸱e"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Communication supervisor"
|
||||||
|
msgstr "Responsable communication"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Secretary"
|
||||||
|
msgstr "Secrétaire"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "IT supervisor"
|
||||||
|
msgstr "Responsable info"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Board member"
|
||||||
|
msgstr "Membre du bureau"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Active member"
|
||||||
|
msgstr "Membre actif⸱ve"
|
||||||
|
|
||||||
|
#: sith/settings.py
|
||||||
|
msgid "Curious"
|
||||||
|
msgstr "Curieux⸱euse"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
msgid "A new poster needs to be moderated"
|
msgid "A new poster needs to be moderated"
|
||||||
msgstr "Une nouvelle affiche a besoin d'être modérée"
|
msgstr "Une nouvelle affiche a besoin d'être modérée"
|
||||||
@@ -5794,6 +5622,10 @@ msgstr "fin"
|
|||||||
msgid "Moderate Trombi comments"
|
msgid "Moderate Trombi comments"
|
||||||
msgstr "Modérer les commentaires du Trombi"
|
msgstr "Modérer les commentaires du Trombi"
|
||||||
|
|
||||||
|
#: trombi/templates/trombi/comment_moderation.jinja
|
||||||
|
msgid "Accept"
|
||||||
|
msgstr "Accepter"
|
||||||
|
|
||||||
#: trombi/templates/trombi/comment_moderation.jinja
|
#: trombi/templates/trombi/comment_moderation.jinja
|
||||||
msgid "Reject"
|
msgid "Reject"
|
||||||
msgstr "Refuser"
|
msgstr "Refuser"
|
||||||
@@ -6035,3 +5867,39 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
|
|||||||
#, python-format
|
#, python-format
|
||||||
msgid "Maximum characters: %(max_length)s"
|
msgid "Maximum characters: %(max_length)s"
|
||||||
msgstr "Nombre de caractères max: %(max_length)s"
|
msgstr "Nombre de caractères max: %(max_length)s"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid "Eurockéennes 2025 partnership"
|
||||||
|
msgstr "Partenariat Eurockéennes 2025"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid ""
|
||||||
|
"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."
|
||||||
|
msgstr ""
|
||||||
|
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||||
|
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||||
|
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||||
|
"leurs termes de service."
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid "Privacy policy"
|
||||||
|
msgstr "Politique de confidentialité"
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
msgid ""
|
||||||
|
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
||||||
|
msgstr ""
|
||||||
|
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
||||||
|
"Eurockéennes."
|
||||||
|
|
||||||
|
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"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."
|
||||||
|
msgstr ""
|
||||||
|
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
||||||
|
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||||
|
"du vendredi au dimanche."
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-04-17 22:42+0200\n"
|
"POT-Creation-Date: 2025-11-26 15:45+0100\n"
|
||||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@@ -17,14 +17,6 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
|
|
||||||
#: club/static/bundled/club/role-list-index.ts
|
|
||||||
msgid ""
|
|
||||||
"You're going to remove your own role from the presidency. You may lock "
|
|
||||||
"yourself out of this page. Do you want to continue ? "
|
|
||||||
msgstr ""
|
|
||||||
"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous risquez "
|
|
||||||
"de perdre l'accès à cette page. Voulez-vous continuer ?"
|
|
||||||
|
|
||||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||||
msgid "More info"
|
msgid "More info"
|
||||||
msgstr "Plus d'informations"
|
msgstr "Plus d'informations"
|
||||||
@@ -279,5 +271,4 @@ msgstr "Il n'a pas été possible de supprimer l'image"
|
|||||||
msgid ""
|
msgid ""
|
||||||
"Wrong timetable format. Make sure you copied if from your student folder."
|
"Wrong timetable format. Make sure you copied if from your student folder."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis "
|
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."
|
||||||
"votre dossier étudiant."
|
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ nav:
|
|||||||
- Ajouter un logo de promo: howto/logo.md
|
- Ajouter un logo de promo: howto/logo.md
|
||||||
- Ajouter une cotisation: howto/subscriptions.md
|
- Ajouter une cotisation: howto/subscriptions.md
|
||||||
- Modifier le weekmail: howto/weekmail.md
|
- Modifier le weekmail: howto/weekmail.md
|
||||||
- Mettre à jour xapian: howto/xapian.md
|
|
||||||
- Terminal: howto/terminal.md
|
- Terminal: howto/terminal.md
|
||||||
- Direnv: howto/direnv.md
|
- Direnv: howto/direnv.md
|
||||||
- Reference:
|
- Reference:
|
||||||
|
|||||||
+2
-6
@@ -78,7 +78,7 @@ tests = [
|
|||||||
"pytest-django<5.0.0,>=4.12.0",
|
"pytest-django<5.0.0,>=4.12.0",
|
||||||
"model-bakery<2.0.0,>=1.23.3",
|
"model-bakery<2.0.0,>=1.23.3",
|
||||||
"beautifulsoup4>=4.14.3,<5",
|
"beautifulsoup4>=4.14.3,<5",
|
||||||
"lxml>=6.0.2,<7",
|
"lxml>=6.1.0,<7",
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"mkdocs<2.0.0,>=1.6.1",
|
"mkdocs<2.0.0,>=1.6.1",
|
||||||
@@ -92,11 +92,7 @@ docs = [
|
|||||||
default-groups = ["dev", "tests", "docs"]
|
default-groups = ["dev", "tests", "docs"]
|
||||||
|
|
||||||
[tool.xapian]
|
[tool.xapian]
|
||||||
version = "1.4.31"
|
version = "1.4.29"
|
||||||
# Those hashes are here to protect against supply chains attacks
|
|
||||||
# See `https://ae-utbm.github.io/sith/howto/xapian/` for more information
|
|
||||||
core-sha256 = "fecf609ea2efdc8a64be369715aac733336a11f7480a6545244964ae6bc80811"
|
|
||||||
bindings-sha256 = "a38cc7ba4188cc0bd27dc7369f03906772047087a1c54f1b93355d5e9103c304"
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
output-format = "concise" # makes ruff error logs easier to read
|
output-format = "concise" # makes ruff error logs easier to read
|
||||||
|
|||||||
+1
-3
@@ -50,15 +50,13 @@ class AlbumEditForm(forms.ModelForm):
|
|||||||
model = Album
|
model = Album
|
||||||
fields = ["name", "date", "file", "parent", "edit_groups"]
|
fields = ["name", "date", "file", "parent", "edit_groups"]
|
||||||
widgets = {
|
widgets = {
|
||||||
|
"parent": AutoCompleteSelectAlbum,
|
||||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||||
}
|
}
|
||||||
|
|
||||||
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
|
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
|
||||||
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
|
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
|
||||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||||
parent = forms.ModelChoiceField(
|
|
||||||
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PictureModerationRequestForm(forms.ModelForm):
|
class PictureModerationRequestForm(forms.ModelForm):
|
||||||
|
|||||||
+1
-7
@@ -205,13 +205,7 @@ class AlbumQuerySet(models.QuerySet):
|
|||||||
|
|
||||||
class SASAlbumManager(models.Manager):
|
class SASAlbumManager(models.Manager):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
|
||||||
super()
|
|
||||||
.get_queryset()
|
|
||||||
.filter(
|
|
||||||
Q(id=settings.SITH_SAS_ROOT_DIR_ID) | Q(is_in_sas=True, is_folder=True)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Album(SasFile):
|
class Album(SasFile):
|
||||||
|
|||||||
@@ -20,14 +20,12 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
||||||
|
|
||||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||||
from core.models import Group, User
|
from core.models import Group, User
|
||||||
from sas.baker_recipes import picture_recipe
|
from sas.baker_recipes import picture_recipe
|
||||||
from sas.forms import AlbumEditForm
|
|
||||||
from sas.models import Album, Picture
|
from sas.models import Album, Picture
|
||||||
|
|
||||||
# Create your tests here.
|
# Create your tests here.
|
||||||
@@ -135,171 +133,6 @@ class TestAlbumUpload:
|
|||||||
assert not album.children.exists()
|
assert not album.children.exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestAlbumEdit:
|
|
||||||
@pytest.fixture
|
|
||||||
def sas_root(self) -> Album:
|
|
||||||
return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def album(self) -> Album:
|
|
||||||
return baker.make(
|
|
||||||
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user",
|
|
||||||
[
|
|
||||||
None,
|
|
||||||
lambda: baker.make(User),
|
|
||||||
subscriber_user.make,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_permission_denied(
|
|
||||||
self,
|
|
||||||
client: Client,
|
|
||||||
album: Album,
|
|
||||||
user: Callable[[], User] | None,
|
|
||||||
):
|
|
||||||
if user:
|
|
||||||
client.force_login(user())
|
|
||||||
|
|
||||||
response = client.get(reverse("sas:album_edit", kwargs={"album_id": album.pk}))
|
|
||||||
assert response.status_code == 403
|
|
||||||
response = client.post(reverse("sas:album_edit", kwargs={"album_id": album.pk}))
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
def test_sas_root_read_only(self, client: Client, sas_root: Album):
|
|
||||||
moderator = baker.make(
|
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
|
||||||
)
|
|
||||||
client.force_login(moderator)
|
|
||||||
response = client.get(
|
|
||||||
reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
response = client.post(
|
|
||||||
reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("excluded", "is_valid"),
|
|
||||||
[
|
|
||||||
("name", False),
|
|
||||||
("date", False),
|
|
||||||
("file", True),
|
|
||||||
("parent", False),
|
|
||||||
("edit_groups", True),
|
|
||||||
("recursive", True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_form_required(self, album: Album, excluded: str, is_valid: bool): # noqa: FBT001
|
|
||||||
data = {
|
|
||||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
|
||||||
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
|
|
||||||
"date": timezone.now().strftime("%Y-%m-%d"),
|
|
||||||
"file": "/random/path",
|
|
||||||
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
|
|
||||||
"recursive": False,
|
|
||||||
}
|
|
||||||
del data[excluded]
|
|
||||||
assert AlbumEditForm(data=data).is_valid() == is_valid
|
|
||||||
|
|
||||||
def test_form_album_name(self, album: Album):
|
|
||||||
data = {
|
|
||||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
|
||||||
"parent": album.pk,
|
|
||||||
"date": timezone.now().strftime("%Y-%m-%d"),
|
|
||||||
}
|
|
||||||
assert AlbumEditForm(data=data).is_valid()
|
|
||||||
|
|
||||||
data["name"] = album.name[: Album.NAME_MAX_LENGTH + 1]
|
|
||||||
assert not AlbumEditForm(data=data).is_valid()
|
|
||||||
|
|
||||||
def test_update_recursive_parent(self, client: Client, album: Album):
|
|
||||||
client.force_login(baker.make(User, is_superuser=True))
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
|
||||||
"parent": album.pk,
|
|
||||||
"date": timezone.now().strftime("%Y-%m-%d"),
|
|
||||||
}
|
|
||||||
response = client.post(
|
|
||||||
reverse(
|
|
||||||
"sas:album_edit",
|
|
||||||
kwargs={"album_id": album.pk},
|
|
||||||
),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
assertInHTML(
|
|
||||||
"<li>Boucle dans l'arborescence des dossiers</li>",
|
|
||||||
response.text,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"user",
|
|
||||||
[
|
|
||||||
lambda: baker.make(User, is_superuser=True),
|
|
||||||
lambda: baker.make(
|
|
||||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_update(
|
|
||||||
self,
|
|
||||||
client: Client,
|
|
||||||
album: Album,
|
|
||||||
sas_root: Album,
|
|
||||||
user: Callable[[], User],
|
|
||||||
):
|
|
||||||
client.force_login(user())
|
|
||||||
|
|
||||||
# Prepare a good payload
|
|
||||||
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
|
|
||||||
expected_date = timezone.now()
|
|
||||||
payload = {
|
|
||||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
|
||||||
"parent": baker.make(Album, parent=sas_root, is_moderated=True).pk,
|
|
||||||
"date": expected_date.strftime("%Y-%m-%d"),
|
|
||||||
"recursive": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test successful update
|
|
||||||
response = client.post(
|
|
||||||
reverse(
|
|
||||||
"sas:album_edit",
|
|
||||||
kwargs={"album_id": album.pk},
|
|
||||||
),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
assertRedirects(response, expected_redirect)
|
|
||||||
updated_album = Album.objects.get(id=album.pk)
|
|
||||||
assert updated_album.name == payload["name"]
|
|
||||||
assert updated_album.parent.id == payload["parent"]
|
|
||||||
assert timezone.localdate(updated_album.date) == timezone.localdate(
|
|
||||||
expected_date
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test root album can be used as parent
|
|
||||||
payload["parent"] = sas_root.pk
|
|
||||||
response = client.post(
|
|
||||||
reverse(
|
|
||||||
"sas:album_edit",
|
|
||||||
kwargs={"album_id": album.pk},
|
|
||||||
),
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
assertRedirects(response, expected_redirect)
|
|
||||||
updated_album = Album.objects.get(id=album.pk)
|
|
||||||
assert updated_album.name == payload["name"]
|
|
||||||
assert updated_album.parent.id == payload["parent"]
|
|
||||||
assert timezone.localdate(updated_album.date) == timezone.localdate(
|
|
||||||
expected_date
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSasModeration(TestCase):
|
class TestSasModeration(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
|||||||
+1
-4
@@ -37,7 +37,7 @@ from sas.forms import (
|
|||||||
PictureModerationRequestForm,
|
PictureModerationRequestForm,
|
||||||
PictureUploadForm,
|
PictureUploadForm,
|
||||||
)
|
)
|
||||||
from sas.models import Album, AlbumQuerySet, PeoplePictureRelation, Picture
|
from sas.models import Album, PeoplePictureRelation, Picture
|
||||||
|
|
||||||
|
|
||||||
class AlbumCreateFragment(FragmentMixin, CreateView):
|
class AlbumCreateFragment(FragmentMixin, CreateView):
|
||||||
@@ -266,9 +266,6 @@ class AlbumEditView(CanEditMixin, UpdateView):
|
|||||||
template_name = "core/edit.jinja"
|
template_name = "core/edit.jinja"
|
||||||
pk_url_kwarg = "album_id"
|
pk_url_kwarg = "album_id"
|
||||||
|
|
||||||
def get_queryset(self) -> AlbumQuerySet:
|
|
||||||
return super().get_queryset().exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
ret = super().form_valid(form)
|
ret = super().form_valid(form)
|
||||||
if form.cleaned_data["recursive"]:
|
if form.cleaned_data["recursive"]:
|
||||||
|
|||||||
@@ -574,6 +574,35 @@ SITH_SUBSCRIPTIONS = {
|
|||||||
# To be completed....
|
# To be completed....
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SITH_CLUB_ROLES_ID = {
|
||||||
|
"President": 10,
|
||||||
|
"Vice-President": 9,
|
||||||
|
"Treasurer": 7,
|
||||||
|
"Communication supervisor": 5,
|
||||||
|
"Secretary": 4,
|
||||||
|
"IT supervisor": 3,
|
||||||
|
"Board member": 2,
|
||||||
|
"Active member": 1,
|
||||||
|
"Curious": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
SITH_CLUB_ROLES = {
|
||||||
|
10: _("President"),
|
||||||
|
9: _("Vice-President"),
|
||||||
|
7: _("Treasurer"),
|
||||||
|
5: _("Communication supervisor"),
|
||||||
|
4: _("Secretary"),
|
||||||
|
3: _("IT supervisor"),
|
||||||
|
2: _("Board member"),
|
||||||
|
1: _("Active member"),
|
||||||
|
0: _("Curious"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# This corresponds to the maximum role a user can freely subscribe to
|
||||||
|
# In this case, SITH_MAXIMUM_FREE_ROLE=1 means that a user can
|
||||||
|
# set himself as "Membre actif" or "Curieux", but not higher
|
||||||
|
SITH_MAXIMUM_FREE_ROLE = 1
|
||||||
|
|
||||||
# Minutes to timeout the logged barmen
|
# Minutes to timeout the logged barmen
|
||||||
SITH_BARMAN_TIMEOUT = 30
|
SITH_BARMAN_TIMEOUT = 30
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,14 @@
|
|||||||
{% trans %}New subscription{% endtrans %}
|
{% trans %}New subscription{% endtrans %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{# The following statics are bundled with our autocomplete select.
|
||||||
|
However, if one tries to swap a form by another, then the urls in script-once
|
||||||
|
and link-once disappear.
|
||||||
|
So we give them here.
|
||||||
|
If the aforementioned bug is resolved, you can remove this. #}
|
||||||
{% block additional_js %}
|
{% block additional_js %}
|
||||||
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
|
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
|
||||||
|
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
src="{{ static("bundled/subscription/creation-form-existing-user-index.ts") }}"
|
src="{{ static("bundled/subscription/creation-form-existing-user-index.ts") }}"
|
||||||
@@ -15,6 +21,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block additional_css %}
|
{% block additional_css %}
|
||||||
<link rel="stylesheet" href="{{ static("core/components/tabs.scss") }}">
|
<link rel="stylesheet" href="{{ static("core/components/tabs.scss") }}">
|
||||||
|
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||||
|
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||||
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
|
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.urls import reverse
|
|||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertRedirects
|
from pytest_django.asserts import assertRedirects
|
||||||
|
|
||||||
from club.models import Club, ClubRole, Membership
|
from club.models import Club, Membership
|
||||||
from core.baker_recipes import subscriber_user
|
from core.baker_recipes import subscriber_user
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
@@ -15,8 +15,7 @@ class TestSubscriptionPermission(TestCase):
|
|||||||
cls.user: User = subscriber_user.make()
|
cls.user: User = subscriber_user.make()
|
||||||
cls.admin = baker.make(User, is_superuser=True)
|
cls.admin = baker.make(User, is_superuser=True)
|
||||||
cls.club = baker.make(Club)
|
cls.club = baker.make(Club)
|
||||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
baker.make(Membership, user=cls.user, club=cls.club, role=7)
|
||||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
|
||||||
|
|
||||||
def test_give_permission(self):
|
def test_give_permission(self):
|
||||||
self.client.force_login(self.admin)
|
self.client.force_login(self.admin)
|
||||||
|
|||||||
+5
-6
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -151,12 +152,10 @@ class TrombiUser(models.Model):
|
|||||||
|
|
||||||
def make_memberships(self):
|
def make_memberships(self):
|
||||||
self.memberships.all().delete()
|
self.memberships.all().delete()
|
||||||
for m in (
|
for m in self.user.memberships.filter(
|
||||||
self.user.memberships.filter(role__is_board=True)
|
role__gt=settings.SITH_MAXIMUM_FREE_ROLE
|
||||||
.select_related("role")
|
).order_by("end_date"):
|
||||||
.order_by("end_date")
|
role = str(settings.SITH_CLUB_ROLES[m.role])
|
||||||
):
|
|
||||||
role = m.role.name
|
|
||||||
if m.description:
|
if m.description:
|
||||||
role += " (%s)" % m.description
|
role += " (%s)" % m.description
|
||||||
end_date = get_semester_code(m.end_date) if m.end_date else ""
|
end_date = get_semester_code(m.end_date) if m.end_date else ""
|
||||||
|
|||||||
@@ -817,6 +817,7 @@ wheels = [
|
|||||||
name = "griffelib"
|
name = "griffelib"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ad/06/eccbd311c9e2b3ca45dbc063b93134c57a1ccc7607c5e545264ad092c4a9/griffelib-2.0.0.tar.gz", hash = "sha256:e504d637a089f5cab9b5daf18f7645970509bf4f53eda8d79ed71cce8bd97934", size = 166312, upload-time = "2026-03-23T21:06:55.954Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||||
]
|
]
|
||||||
@@ -1044,82 +1045,82 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lxml"
|
name = "lxml"
|
||||||
version = "6.0.2"
|
version = "6.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
{ url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
{ url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2142,7 +2143,7 @@ prod = [{ name = "psycopg", extras = ["c"], specifier = ">=3.3.3,<4.0.0" }]
|
|||||||
tests = [
|
tests = [
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.14.3,<5" },
|
{ name = "beautifulsoup4", specifier = ">=4.14.3,<5" },
|
||||||
{ name = "freezegun", specifier = ">=1.5.5,<2.0.0" },
|
{ name = "freezegun", specifier = ">=1.5.5,<2.0.0" },
|
||||||
{ name = "lxml", specifier = ">=6.0.2,<7" },
|
{ name = "lxml", specifier = ">=6.1.0,<7" },
|
||||||
{ name = "model-bakery", specifier = ">=1.23.3,<2.0.0" },
|
{ name = "model-bakery", specifier = ">=1.23.3,<2.0.0" },
|
||||||
{ name = "pytest", specifier = ">=9.0.2,<10.0.0" },
|
{ name = "pytest", specifier = ">=9.0.2,<10.0.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" },
|
{ name = "pytest-cov", specifier = ">=7.0.0,<8.0.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user