diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51b4f75d..e96a456f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.0 + rev: v0.15.5 hooks: - id: ruff-check # just check the code, and print the errors - id: ruff-check # actually fix the fixable errors, but print nothing @@ -12,7 +12,7 @@ repos: rev: v0.6.1 hooks: - id: biome-check - additional_dependencies: ["@biomejs/biome@2.3.14"] + additional_dependencies: ["@biomejs/biome@2.4.6"] - repo: https://github.com/rtts/djhtml rev: 3.0.10 hooks: diff --git a/club/api.py b/club/api.py index 1479ee5c..3ed425bf 100644 --- a/club/api.py +++ b/club/api.py @@ -6,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema from api.auth import ApiKeyAuth -from api.permissions import CanAccessLookup, HasPerm +from api.permissions import CanAccessLookup, CanView, HasPerm from club.models import Club, Membership -from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema +from club.schemas import ( + ClubSchema, + ClubSearchFilterSchema, + SimpleClubSchema, + UserMembershipSchema, +) +from core.models import User @api_controller("/club") @@ -38,3 +44,22 @@ class ClubController(ControllerBase): return self.get_object_or_exception( Club.objects.prefetch_related(prefetch), id=club_id ) + + +@api_controller("/user/{int:user_id}/club") +class UserClubController(ControllerBase): + @route.get( + "", + response=list[UserMembershipSchema], + auth=[ApiKeyAuth(), SessionAuth()], + permissions=[CanView], + url_name="fetch_user_clubs", + ) + def fetch_user_clubs(self, user_id: int): + """Get all the active memberships of the given user.""" + user = self.get_object_or_exception(User, id=user_id) + return ( + Membership.objects.ongoing() + .filter(user=user) + .select_related("club", "user") + ) diff --git a/club/schemas.py b/club/schemas.py index 9483d4c6..08488c31 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -40,6 +40,8 @@ class ClubProfileSchema(ModelSchema): class ClubMemberSchema(ModelSchema): + """A schema to represent all memberships in a club.""" + class Meta: model = Membership fields = ["start_date", "end_date", "role", "description"] @@ -53,3 +55,13 @@ class ClubSchema(ModelSchema): fields = ["id", "name", "logo", "is_active", "short_description", "address"] members: list[ClubMemberSchema] + + +class UserMembershipSchema(ModelSchema): + """A schema to represent the active club memberships of a user.""" + + class Meta: + model = Membership + fields = ["id", "start_date", "role", "description"] + + club: SimpleClubSchema diff --git a/club/templates/club/club_sellings.jinja b/club/templates/club/club_sellings.jinja index 59edd18e..5ed8afc6 100644 --- a/club/templates/club/club_sellings.jinja +++ b/club/templates/club/club_sellings.jinja @@ -35,7 +35,7 @@ TODO : rewrite the pagination used in this template an Alpine one {% csrf_token %} {{ form }}
- +
{% trans %}Quantity: {% endtrans %}{{ total_quantity }} {% trans %}units{% endtrans %}
diff --git a/club/tests/test_user_club_controller.py b/club/tests/test_user_club_controller.py
new file mode 100644
index 00000000..2aba7225
--- /dev/null
+++ b/club/tests/test_user_club_controller.py
@@ -0,0 +1,50 @@
+from datetime import timedelta
+
+from django.test import TestCase
+from django.urls import reverse
+from django.utils.timezone import localdate
+from model_bakery import baker
+from model_bakery.recipe import Recipe
+
+from club.models import Club, Membership
+from club.schemas import UserMembershipSchema
+from core.baker_recipes import subscriber_user
+from core.models import Page
+
+
+class TestFetchClub(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = subscriber_user.make()
+ pages = baker.make(Page, _quantity=3, _bulk_create=True)
+ clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
+ recipe = Recipe(
+ Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
+ )
+ cls.members = Membership.objects.bulk_create(
+ [
+ recipe.prepare(club=clubs[0]),
+ recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
+ recipe.prepare(club=clubs[1]),
+ ]
+ )
+
+ def test_fetch_memberships(self):
+ self.client.force_login(subscriber_user.make())
+ res = self.client.get(
+ reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
+ )
+ assert res.status_code == 200
+ assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
+ UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
+ ]
+
+ def test_fetch_club_nb_queries(self):
+ self.client.force_login(subscriber_user.make())
+ with self.assertNumQueries(6):
+ # - 5 queries for authentication
+ # - 1 query for the actual data
+ res = self.client.get(
+ reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
+ )
+ assert res.status_code == 200
diff --git a/core/auth/mixins.py b/core/auth/mixins.py
index 917200ed..28012d50 100644
--- a/core/auth/mixins.py
+++ b/core/auth/mixins.py
@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False
if super().has_permission():
return True
- return self.club is not None and any(
- g.id == self.club.board_group_id for g in self.request.user.cached_groups
+ return (
+ self.club is not None
+ and self.club.board_group_id in self.request.user.all_groups
)
diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py
index 34f51c80..562a46ad 100644
--- a/core/management/commands/populate_more.py
+++ b/core/management/commands/populate_more.py
@@ -12,7 +12,7 @@ from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
-from core.models import Group, User
+from core.models import Group, User, UserBan
from counter.models import (
Counter,
Customer,
@@ -40,6 +40,7 @@ class Command(BaseCommand):
self.stdout.write("Creating users...")
users = self.create_users()
+ self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers)
@@ -88,6 +89,8 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
+ # Create a single password hash for all users to make it faster.
+ # It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
users = [
User(
@@ -114,14 +117,33 @@ class Command(BaseCommand):
public_group.users.add(*users)
return users
+ def create_bans(self, users: list[User]):
+ ban_groups = [
+ settings.SITH_GROUP_BANNED_COUNTER_ID,
+ settings.SITH_GROUP_BANNED_SUBSCRIPTION_ID,
+ settings.SITH_GROUP_BANNED_ALCOHOL_ID,
+ ]
+ UserBan.objects.bulk_create(
+ [
+ UserBan(
+ user=user,
+ ban_group_id=i,
+ reason=self.faker.sentence(),
+ expires_at=make_aware(self.faker.future_datetime("+1y")),
+ )
+ for user in users
+ for i in random.sample(ban_groups, k=random.randint(1, len(ban_groups)))
+ ]
+ )
+
def create_subscriptions(self, users: list[User]):
def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
- sub = Subscription(member=_user, payment_method=payment_method)
- sub.subscription_start = sub.compute_start(d=start_date, duration=duration)
- sub.subscription_end = sub.compute_end(duration)
- return sub
+ s = Subscription(member=_user, payment_method=payment_method)
+ s.subscription_start = s.compute_start(d=start_date, duration=duration)
+ s.subscription_end = s.compute_end(duration)
+ return s
subscriptions = []
customers = []
diff --git a/core/models.py b/core/models.py
index 27744775..3b533751 100644
--- a/core/models.py
+++ b/core/models.py
@@ -356,23 +356,27 @@ class User(AbstractUser):
)
if group_id is None:
return False
- if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
- return self.is_subscribed
- if group_id == settings.SITH_GROUP_ROOT_ID:
- return self.is_root
- return any(g.id == group_id for g in self.cached_groups)
+ return group_id in self.all_groups
@cached_property
- def cached_groups(self) -> list[Group]:
+ def all_groups(self) -> dict[int, Group]:
"""Get the list of groups this user is in."""
- return list(self.groups.all())
+ additional_groups = []
+ if self.is_subscribed:
+ additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
+ if self.is_superuser:
+ additional_groups.append(settings.SITH_GROUP_ROOT_ID)
+ qs = self.groups.all()
+ if additional_groups:
+ # This is somewhat counter-intuitive, but this query runs way faster with
+ # a UNION rather than a OR (in average, 0.25ms vs 14ms).
+ # For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
+ qs = qs.union(Group.objects.filter(id__in=additional_groups))
+ return {g.id: g for g in qs}
@cached_property
def is_root(self) -> bool:
- if self.is_superuser:
- return True
- root_id = settings.SITH_GROUP_ROOT_ID
- return any(g.id == root_id for g in self.cached_groups)
+ return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
@cached_property
def is_board_member(self) -> bool:
@@ -1099,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
- groups_ids = [g.id for g in user.cached_groups]
- if user.is_subscribed:
- groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
- return self.filter(view_groups__in=groups_ids)
+ return self.filter(view_groups__in=user.all_groups)
# This function prevents generating migration upon settings change
@@ -1376,7 +1377,7 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
- return any(g.id == self.page.owner_group_id for g in user.cached_groups)
+ return self.page.owner_group_id in user.all_groups
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
diff --git a/core/static/bundled/core/dynamic-formset-index.ts b/core/static/bundled/core/dynamic-formset-index.ts
new file mode 100644
index 00000000..6b71e25f
--- /dev/null
+++ b/core/static/bundled/core/dynamic-formset-index.ts
@@ -0,0 +1,77 @@
+interface Config {
+ /**
+ * The prefix of the formset, in case it has been changed.
+ * See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
+ */
+ prefix?: string;
+}
+
+// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
+type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
+
+document.addEventListener("alpine:init", () => {
+ /**
+ * Alpine data element to allow the dynamic addition of forms to a formset.
+ *
+ * To use this, you need :
+ * - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
+ * - a template containing the empty form
+ * (that you can obtain jinja-side with `{{ formset.empty_form }}`),
+ * noted by `x-ref="formTemplate"`
+ * - a button with `@click="addForm"`
+ * - you may also have one or more buttons with `@click="removeForm(element)"`,
+ * where `element` is the HTML element containing the form.
+ *
+ * For an example of how this is used, you can have a look to
+ * `counter/templates/counter/product_form.jinja`
+ */
+ Alpine.data("dynamicFormSet", (config?: Config) => ({
+ init() {
+ this.formContainer = this.$refs.formContainer as HTMLElement;
+ this.nbForms = this.formContainer.children.length as number;
+ this.template = this.$refs.formTemplate as HTMLTemplateElement;
+ const prefix = config?.prefix ?? "form";
+ this.$root
+ .querySelector(`#id_${prefix}-TOTAL_FORMS`)
+ .setAttribute(":value", "nbForms");
+ },
+
+ addForm() {
+ this.formContainer.appendChild(document.importNode(this.template.content, true));
+ const newForm = this.formContainer.lastElementChild;
+ const inputs: NodeListOf{% trans %}Delete confirmation{% endtrans %}