diff --git a/club/api.py b/club/api.py
index 3ed425bf..cde007c2 100644
--- a/club/api.py
+++ b/club/api.py
@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
-from api.permissions import CanAccessLookup, CanView, HasPerm
+from api.permissions import CanView, HasPerm
from club.models import Club, Membership
from club.schemas import (
ClubSchema,
@@ -22,13 +22,11 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
- auth=[ApiKeyAuth(), SessionAuth()],
- permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
- return filters.filter(Club.objects.all())
+ return filters.filter(Club.objects.order_by("name")).values()
@route.get(
"/{int:club_id}",
diff --git a/club/forms.py b/club/forms.py
index dcd270e7..3c10dfce 100644
--- a/club/forms.py
+++ b/club/forms.py
@@ -315,3 +315,27 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid"
)
return super().clean()
+
+
+class ClubSearchForm(forms.ModelForm):
+ class Meta:
+ model = Club
+ fields = ["name"]
+ widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
+
+ club_status = forms.NullBooleanField(
+ label=_("Club status"),
+ widget=forms.RadioSelect(
+ choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
+ ),
+ initial=True,
+ )
+
+ def __init__(self, *args, data: dict | None = None, **kwargs):
+ super().__init__(*args, data=data, **kwargs)
+ if data is not None and "club_status" not in data:
+ # if the key is missing, it is considered as None,
+ # even though we want the default True value to be applied in such a case
+ # so we enforce it.
+ self.fields["club_status"].value = True
+ self.fields["name"].required = False
diff --git a/club/schemas.py b/club/schemas.py
index 08488c31..02622110 100644
--- a/club/schemas.py
+++ b/club/schemas.py
@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
class Meta:
model = Club
- fields = ["id", "name", "logo"]
+ fields = ["id", "name", "logo", "is_active", "short_description"]
url: str
diff --git a/club/static/club/list.scss b/club/static/club/list.scss
new file mode 100644
index 00000000..9fbf952f
--- /dev/null
+++ b/club/static/club/list.scss
@@ -0,0 +1,47 @@
+#club-list {
+ display: flex;
+ flex-direction: column;
+ gap: 2em;
+ padding: 2em;
+
+ .card {
+ display: block;
+ background-color: unset;
+
+ .club-image {
+ float: left;
+ margin-right: 2rem;
+ margin-bottom: .5rem;
+ width: 150px;
+ height: 150px;
+ border-radius: 10%;
+ background-color: rgba(173, 173, 173, 0.2);
+
+ @media screen and (max-width: 500px) {
+ width: 100px;
+ height: 100px;
+ }
+ }
+
+ i.club-image {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ color: black;
+ }
+
+ .content {
+ display: block;
+ text-align: justify;
+
+ h4 {
+ margin-top: 0;
+ margin-right: .5rem;
+ }
+
+ p {
+ font-size: 100%;
+ }
+ }
+ }
+}
diff --git a/club/templates/club/club_list.jinja b/club/templates/club/club_list.jinja
index da0a54be..8ae08bf3 100644
--- a/club/templates/club/club_list.jinja
+++ b/club/templates/club/club_list.jinja
@@ -1,52 +1,76 @@
-{% extends "core/base.jinja" %}
+{% if is_fragment %}
+ {% extends "core/base_fragment.jinja" %}
-{% block title -%}
- {% trans %}Club list{% endtrans %}
-{%- endblock %}
+ {# Don't display tabs and errors #}
+ {% block tabs %}
+ {% endblock %}
+ {% block errors %}
+ {% endblock %}
+{% else %}
+ {% extends "core/base.jinja" %}
+ {% block additional_css %}
+
+ {% endblock %}
+ {% block description -%}
+ {% trans %}The list of all clubs existing at UTBM.{% endtrans %}
+ {%- endblock %}
+ {% block title -%}
+ {% trans %}Club list{% endtrans %}
+ {%- endblock %}
+{% endif %}
-{% 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 %}
-
-
{{ club.name }}
-
- {% if not club.is_active %}
- ({% trans %}inactive{% endtrans %})
- {% endif %}
-
- {% if club.president %} - {{ club.president.user }} {% endif %}
- {% if club.short_description %}{{ club.short_description|markdown }}
{% endif %}
-
- {% endif %}
-
- {%- if club.children.all()|length != 0 %}
-
- {%- for c in club.children.order_by('name').prefetch_related("children") %}
- {{ display_club(c) }}
- {%- endfor %}
-
- {%- endif -%}
-
-{%- endmacro %}
+{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
- {% if user.is_root %}
- {% trans %}New club{% endtrans %}
- {% endif %}
- {% if club_list %}
+
+ {% trans %}Filters{% endtrans %}
+
{% trans %}Club list{% endtrans %}
-
- {%- for club in club_list %}
- {{ display_club(club) }}
- {%- endfor %}
-
- {% else %}
- {% trans %}There is no club in this website.{% endtrans %}
- {% endif %}
+ {% if user.has_perm("club.add_club") %}
+
+
+ {% trans %}New club{% endtrans %}
+
+ {% endif %}
+
+ {% for club in object_list %}
+
+ {% endfor %}
+
+ {% if is_paginated %}
+ {{ paginate_htmx(request, page_obj, paginator) }}
+ {% endif %}
+
{% endblock %}
diff --git a/club/templates/club/club_members.jinja b/club/templates/club/club_members.jinja
index 3aa43d56..7e37b04e 100644
--- a/club/templates/club/club_members.jinja
+++ b/club/templates/club/club_members.jinja
@@ -1,11 +1,7 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
-{% block additional_js %}
-
-{% endblock %}
{% block additional_css %}
-
{% endblock %}
diff --git a/club/tests/test_club.py b/club/tests/test_club.py
index 2a232b19..4c69b2c4 100644
--- a/club/tests/test_club.py
+++ b/club/tests/test_club.py
@@ -1,12 +1,15 @@
from datetime import timedelta
import pytest
+from django.test import Client
+from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
+from core.models import User
@pytest.mark.django_db
@@ -25,3 +28,14 @@ def test_club_queryset_having_board_member():
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}
+
+
+@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
+@pytest.mark.parametrize("is_fragment", [True, False])
+@pytest.mark.django_db
+def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
+ client.force_login(baker.make(User))
+ baker.make(Club, _quantity=nb_additional_clubs)
+ headers = {"HX-Request": True} if is_fragment else {}
+ res = client.get(reverse("club:club_list"), headers=headers)
+ assert res.status_code == 200
diff --git a/club/views.py b/club/views.py
index 9077d0d7..6a9963a9 100644
--- a/club/views.py
+++ b/club/views.py
@@ -44,13 +44,19 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
-from django.views.generic.edit import CreateView, DeleteView, UpdateView
+from django.views.generic.edit import (
+ CreateView,
+ DeleteView,
+ FormMixin,
+ UpdateView,
+)
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
+ ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
@@ -66,7 +72,12 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
-from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
+from core.views.mixins import (
+ AllowFragment,
+ FragmentMixin,
+ FragmentRenderer,
+ TabedViewMixin,
+)
from counter.models import Selling
if TYPE_CHECKING:
@@ -180,15 +191,41 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
-class ClubListView(ListView):
- """List the Clubs."""
+class ClubListView(AllowFragment, FormMixin, ListView):
+ """List the clubs of the AE, with a form to perform basic search.
+
+ Notes:
+ This view is fully public, because we want to advertise as much as possible
+ the cultural life of the AE.
+ In accordance with that matter, searching and listing the clubs is done
+ entirely server-side (no AlpineJS involved) ;
+ this is done this way in order to be sure the page is the most accessible
+ and SEO-friendly possible, even if it makes the UX slightly less smooth.
+ """
- model = Club
template_name = "club/club_list.jinja"
- queryset = (
- Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
- )
- context_object_name = "club_list"
+ form_class = ClubSearchForm
+ queryset = Club.objects.order_by("name")
+ paginate_by = 20
+
+ def get_form_kwargs(self):
+ res = super().get_form_kwargs()
+ if self.request.method == "GET":
+ res |= {"data": self.request.GET, "initial": self.request.GET}
+ return res
+
+ def get_queryset(self):
+ form: ClubSearchForm = self.get_form()
+ qs = self.queryset
+ if not form.is_bound:
+ return qs.filter(is_active=True)
+ if not form.is_valid():
+ return qs.none()
+ if name := form.cleaned_data.get("name"):
+ qs = qs.filter(name__icontains=name)
+ if (is_active := form.cleaned_data.get("club_status")) is not None:
+ qs = qs.filter(is_active=is_active)
+ return qs
class ClubView(ClubTabsMixin, DetailView):
diff --git a/core/api.py b/core/api.py
index 2f2c0fb1..08aefa6f 100644
--- a/core/api.py
+++ b/core/api.py
@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]):
- return Group.objects.filter(name__icontains=search).values()
+ return Group.objects.filter(name__icontains=search).order_by("name").values()
DepthValue = Annotated[int, Ge(0), Le(10)]
diff --git a/core/management/commands/install_xapian.py b/core/management/commands/install_xapian.py
index 4be1d907..3f7fe3cc 100644
--- a/core/management/commands/install_xapian.py
+++ b/core/management/commands/install_xapian.py
@@ -39,12 +39,16 @@ class Command(BaseCommand):
return None
return xapian.version_string()
- def _desired_version(self) -> str:
+ def _desired_version(self) -> tuple[str, str, str]:
with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f:
pyproject = tomli.load(f)
- return pyproject["tool"]["xapian"]["version"]
+ return (
+ pyproject["tool"]["xapian"]["version"],
+ pyproject["tool"]["xapian"]["core-sha256"],
+ pyproject["tool"]["xapian"]["bindings-sha256"],
+ )
def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None):
@@ -53,7 +57,7 @@ class Command(BaseCommand):
)
return
- desired = self._desired_version()
+ desired, core_checksum, bindings_checksum = self._desired_version()
if desired == self._current_version():
if not force:
self.stdout.write(
@@ -65,7 +69,12 @@ class Command(BaseCommand):
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run(
- [str(Path(__file__).parent / "install_xapian.sh"), desired],
+ [
+ str(Path(__file__).parent / "install_xapian.sh"),
+ desired,
+ core_checksum,
+ bindings_checksum,
+ ],
env=dict(os.environ),
check=True,
)
diff --git a/core/management/commands/install_xapian.sh b/core/management/commands/install_xapian.sh
index 2c97f120..5bfeef07 100755
--- a/core/management/commands/install_xapian.sh
+++ b/core/management/commands/install_xapian.sh
@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
# first argument of the script is Xapian version (e.g. 1.2.19)
+# second argument of the script is core sha256
+# second argument of the script is binding sha256
VERSION="$1"
+CORE_SHA256="$2"
+BINDINGS_SHA256="$3"
# Cleanup env vars for auto discovery mechanism
unset CPATH
@@ -21,9 +25,15 @@ BINDINGS=xapian-bindings-$VERSION
# download
echo "Downloading source..."
-curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
+curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
+
+echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
+
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
+echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
+
+
# extract
echo "Extracting source..."
tar xf "${CORE}.tar.xz"
diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py
index 4c1c5379..a326b8ed 100644
--- a/core/management/commands/populate.py
+++ b/core/management/commands/populate.py
@@ -16,7 +16,7 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
-# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
+# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
@@ -41,7 +41,14 @@ from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
-from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
+from counter.models import (
+ Counter,
+ Price,
+ Product,
+ ProductType,
+ ReturnableProduct,
+ StudentCard,
+)
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UE
@@ -110,7 +117,9 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
- sas = SithFile.objects.create(name="SAS", owner=root)
+ sas = SithFile.objects.create(
+ name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
+ )
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
@@ -372,125 +381,15 @@ class Command(BaseCommand):
end_date=localdate() - timedelta(days=100),
)
- p = ProductType.objects.create(name="Bières bouteilles")
- c = ProductType.objects.create(name="Cotisations")
- r = ProductType.objects.create(name="Rechargements")
- verre = ProductType.objects.create(name="Verre")
- cotis = Product.objects.create(
- name="Cotis 1 semestre",
- code="1SCOTIZ",
- product_type=c,
- purchase_price="15",
- selling_price="15",
- special_selling_price="15",
- club=main_club,
- )
- cotis2 = Product.objects.create(
- name="Cotis 2 semestres",
- code="2SCOTIZ",
- product_type=c,
- purchase_price="28",
- selling_price="28",
- special_selling_price="28",
- club=main_club,
- )
- refill = Product.objects.create(
- name="Rechargement 15 €",
- code="15REFILL",
- product_type=r,
- purchase_price="15",
- selling_price="15",
- special_selling_price="15",
- club=main_club,
- )
- barb = Product.objects.create(
- name="Barbar",
- code="BARB",
- product_type=p,
- purchase_price="1.50",
- selling_price="1.7",
- special_selling_price="1.6",
- club=main_club,
- limit_age=18,
- )
- cble = Product.objects.create(
- name="Chimay Bleue",
- code="CBLE",
- product_type=p,
- purchase_price="1.50",
- selling_price="1.7",
- special_selling_price="1.6",
- club=main_club,
- limit_age=18,
- )
- cons = Product.objects.create(
- name="Consigne Eco-cup",
- code="CONS",
- product_type=verre,
- purchase_price="1",
- selling_price="1",
- special_selling_price="1",
- club=main_club,
- )
- dcons = Product.objects.create(
- name="Déconsigne Eco-cup",
- code="DECO",
- product_type=verre,
- purchase_price="-1",
- selling_price="-1",
- special_selling_price="-1",
- club=main_club,
- )
- cors = Product.objects.create(
- name="Corsendonk",
- code="CORS",
- product_type=p,
- purchase_price="1.50",
- selling_price="1.7",
- special_selling_price="1.6",
- club=main_club,
- limit_age=18,
- )
- carolus = Product.objects.create(
- name="Carolus",
- code="CARO",
- product_type=p,
- purchase_price="1.50",
- selling_price="1.7",
- special_selling_price="1.6",
- club=main_club,
- limit_age=18,
- )
- Product.objects.create(
- name="remboursement",
- code="REMBOURS",
- purchase_price="0",
- selling_price="0",
- special_selling_price="0",
- club=refound,
- )
- groups.subscribers.products.add(
- cotis, cotis2, refill, barb, cble, cors, carolus
- )
- groups.old_subscribers.products.add(cotis, cotis2)
-
- mde = Counter.objects.get(name="MDE")
- mde.products.add(barb, cble, cons, dcons)
-
- eboutic = Counter.objects.get(name="Eboutic")
- eboutic.products.add(barb, cotis, cotis2, refill)
+ self._create_products(groups, main_club, refound)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
- ReturnableProduct.objects.create(
- product=cons, returned_product=dcons, max_return=3
- )
-
# Add barman to counter
Counter.sellers.through.objects.bulk_create(
[
- Counter.sellers.through(counter_id=2, user=krophil),
- Counter.sellers.through(counter=mde, user=skia),
+ Counter.sellers.through(counter_id=1, user=skia), # MDE
+ Counter.sellers.through(counter_id=2, user=krophil), # Foyer
]
)
@@ -746,6 +645,131 @@ class Command(BaseCommand):
]
)
+ def _create_products(
+ self, groups: PopulatedGroups, main_club: Club, refound_club: Club
+ ):
+ beers_type, cotis_type, refill_type, verre_type = (
+ ProductType.objects.bulk_create(
+ [
+ ProductType(name="Bières bouteilles"),
+ ProductType(name="Cotisations"),
+ ProductType(name="Rechargements"),
+ ProductType(name="Verre"),
+ ]
+ )
+ )
+ cotis = Product.objects.create(
+ name="Cotis 1 semestre",
+ code="1SCOTIZ",
+ product_type=cotis_type,
+ purchase_price=15,
+ club=main_club,
+ )
+ cotis2 = Product.objects.create(
+ name="Cotis 2 semestres",
+ code="2SCOTIZ",
+ product_type=cotis_type,
+ purchase_price="28",
+ club=main_club,
+ )
+ refill = Product.objects.create(
+ name="Rechargement 15 €",
+ code="15REFILL",
+ product_type=refill_type,
+ purchase_price=15,
+ club=main_club,
+ )
+ barb = Product.objects.create(
+ name="Barbar",
+ code="BARB",
+ product_type=beers_type,
+ purchase_price="1.50",
+ club=main_club,
+ limit_age=18,
+ )
+ cble = Product.objects.create(
+ name="Chimay Bleue",
+ code="CBLE",
+ product_type=beers_type,
+ purchase_price="1.50",
+ club=main_club,
+ limit_age=18,
+ )
+ cons = Product.objects.create(
+ name="Consigne Eco-cup",
+ code="CONS",
+ product_type=verre_type,
+ purchase_price="1",
+ club=main_club,
+ )
+ dcons = Product.objects.create(
+ name="Déconsigne Eco-cup",
+ code="DECO",
+ product_type=verre_type,
+ purchase_price="-1",
+ club=main_club,
+ )
+ cors = Product.objects.create(
+ name="Corsendonk",
+ code="CORS",
+ product_type=beers_type,
+ purchase_price="1.50",
+ club=main_club,
+ limit_age=18,
+ )
+ carolus = Product.objects.create(
+ name="Carolus",
+ code="CARO",
+ product_type=beers_type,
+ purchase_price="1.50",
+ club=main_club,
+ limit_age=18,
+ )
+ Product.objects.create(
+ name="remboursement",
+ code="REMBOURS",
+ purchase_price=0,
+ club=refound_club,
+ )
+ ReturnableProduct.objects.create(
+ product=cons, returned_product=dcons, max_return=3
+ )
+ mde = Counter.objects.get(name="MDE")
+ mde.products.add(barb, cble, cons, dcons)
+ eboutic = Counter.objects.get(name="Eboutic")
+ eboutic.products.add(barb, cotis, cotis2, refill)
+
+ cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
+ Price.objects.bulk_create(
+ [
+ Price(product=cotis, amount=15),
+ Price(product=cotis2, amount=28),
+ Price(product=refill, amount=15),
+ Price(product=barb, amount=1.7),
+ Price(product=cble, amount=1.7),
+ Price(product=cors, amount=1.7),
+ Price(product=carolus, amount=1.7),
+ Price(product=cons, amount=1),
+ Price(product=dcons, amount=-1),
+ ]
+ )
+ )
+ Price.groups.through.objects.bulk_create(
+ [
+ Price.groups.through(price=cotis, group=groups.subscribers),
+ Price.groups.through(price=cotis2, group=groups.subscribers),
+ Price.groups.through(price=refill, group=groups.subscribers),
+ Price.groups.through(price=barb, group=groups.subscribers),
+ Price.groups.through(price=cble, group=groups.subscribers),
+ Price.groups.through(price=cors, group=groups.subscribers),
+ Price.groups.through(price=carolus, group=groups.subscribers),
+ Price.groups.through(price=cotis, group=groups.old_subscribers),
+ Price.groups.through(price=cotis2, group=groups.old_subscribers),
+ Price.groups.through(price=cons, group=groups.old_subscribers),
+ Price.groups.through(price=dcons, group=groups.old_subscribers),
+ ]
+ )
+
def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP")
diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py
index 562a46ad..47cb75d5 100644
--- a/core/management/commands/populate_more.py
+++ b/core/management/commands/populate_more.py
@@ -17,6 +17,7 @@ from counter.models import (
Counter,
Customer,
Permanency,
+ Price,
Product,
ProductType,
Refilling,
@@ -278,6 +279,7 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = []
+ prices = []
buying_groups = []
selling_places = []
for _ in range(200):
@@ -288,25 +290,28 @@ class Command(BaseCommand):
product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price,
- selling_price=price,
- special_selling_price=price - min(0.5, price),
club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18,
- archived=bool(random.random() > 0.7),
+ archived=self.faker.boolean(60),
)
products.append(product)
- # there will be products without buying groups
- # but there are also such products in the real database
- buying_groups.extend(
- Product.buying_groups.through(product=product, group=group)
- for group in random.sample(groups, k=random.randint(0, 3))
- )
+ for i in range(random.randint(0, 3)):
+ product_price = Price(
+ amount=price, product=product, is_always_shown=self.faker.boolean()
+ )
+ # prices for non-subscribers will be higher than for subscribers
+ price *= 1.2
+ prices.append(product_price)
+ buying_groups.append(
+ Price.groups.through(price=product_price, group=groups[i])
+ )
selling_places.extend(
Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4))
)
Product.objects.bulk_create(products)
- Product.buying_groups.through.objects.bulk_create(buying_groups)
+ Price.objects.bulk_create(prices)
+ Price.groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]):
@@ -320,7 +325,7 @@ class Command(BaseCommand):
)
)
)
- products = list(Product.objects.all())
+ prices = list(Price.objects.select_related("product").all())
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
)
@@ -330,14 +335,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
- favoured_products = random.sample(products, k=(random.randint(1, 5)))
+ favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
favoured_counter = random.choice(counters)
this_customer_sales = []
for _ in range(nb_sales):
- product = (
- random.choice(favoured_products)
+ price = (
+ random.choice(favoured_prices)
if random.random() > 0.7
- else random.choice(products)
+ else random.choice(prices)
)
counter = (
favoured_counter
@@ -346,11 +351,11 @@ class Command(BaseCommand):
)
this_customer_sales.append(
Selling(
- product=product,
+ product=price.product,
counter=counter,
- club_id=product.club_id,
+ club_id=price.product.club_id,
quantity=random.randint(1, 5),
- unit_price=product.selling_price,
+ unit_price=price.amount,
seller=random.choice(sellers),
customer=customer,
date=make_aware(
diff --git a/core/models.py b/core/models.py
index 0f0ef10e..ffbdebfc 100644
--- a/core/models.py
+++ b/core/models.py
@@ -131,7 +131,9 @@ class UserQuerySet(models.QuerySet):
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
- return self.filter(Q(is_viewable=True) | Q(whitelisted_users=user))
+ return self.filter(
+ Q(is_viewable=True) | Q(whitelisted_users=user) | Q(pk=user.pk)
+ )
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
@@ -884,8 +886,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
- sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
- self.is_in_sas = sas in self.get_parent_list() or self == sas
+ sas_id = settings.SITH_SAS_ROOT_DIR_ID
+ self.is_in_sas = self.id == sas_id or any(
+ p.id == sas_id for p in self.get_parent_list()
+ )
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
diff --git a/core/static/bundled/core/components/include-index.ts b/core/static/bundled/core/components/include-index.ts
index c1989f4b..9ee44bb8 100644
--- a/core/static/bundled/core/components/include-index.ts
+++ b/core/static/bundled/core/components/include-index.ts
@@ -1,18 +1,136 @@
-import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
+import {
+ type InheritedHtmlElement,
+ inheritHtmlElement,
+ registerComponent,
+} from "#core:utils/web-components.ts";
+
+/**
+ * ElementOnce web components
+ *
+ * Those elements ensures that their content is always included only once on a document
+ * They are compatible with elements that are not managed with our Web Components
+ **/
+export interface ElementOnce
+ extends InheritedHtmlElement {
+ getElementQuerySelector(): string;
+ refresh(): void;
+}
+
+/**
+ * Create an abstract class for ElementOnce types Web Components
+ **/
+export function elementOnce(tagName: K) {
+ abstract class ElementOnceImpl
+ extends inheritHtmlElement(tagName)
+ implements ElementOnce
+ {
+ 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 = 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>,
+ removedTagName: K,
+) => {
+ for (const element of components) {
+ // We can't guess if an element is compatible before we get one
+ // We exit the function completely if it's not compatible
+ if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
+ return;
+ }
+
+ element.refresh();
+ }
+};
+
+// Since we need to pause the observer, we make an helper to start it with consistent arguments
+const startObserver = (observer: MutationObserver) => {
+ observer.observe(document, {
+ // We want to also listen for elements contained in the header (eg: link)
+ subtree: true,
+ childList: true,
+ });
+};
+
+// Refresh ElementOnce components when changes happens
+const observer = new MutationObserver((mutations: MutationRecord[]) => {
+ // To avoid infinite recursion, we need to pause the observer while manipulation nodes
+ observer.disconnect();
+ for (const mutation of mutations) {
+ for (const node of mutation.removedNodes) {
+ if (node.nodeType !== node.ELEMENT_NODE) {
+ continue;
+ }
+ for (const registered of registeredComponents) {
+ refreshElement(
+ document.getElementsByTagName(registered) as HTMLCollectionOf<
+ ElementOnce<"html"> // The specific tag doesn't really matter
+ >,
+ (node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
+ );
+ }
+ }
+ }
+ // We then resume the observer
+ startObserver(observer);
+});
+
+startObserver(observer);
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
-@registerComponent("link-once")
-export class LinkOnce extends inheritHtmlElement("link") {
- connectedCallback() {
- super.connectedCallback(false);
+@registerElementOnce("link-once")
+export class LinkOnce extends elementOnce("link") {
+ getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part
- const href = this.node.attributes.getNamedItem("href").nodeValue;
- if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
- this.appendChild(this.node);
- }
+ return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
}
}
@@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
* Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing
**/
-@registerComponent("script-once")
+@registerElementOnce("script-once")
export class ScriptOnce extends inheritHtmlElement("script") {
- connectedCallback() {
- super.connectedCallback(false);
- // We get src from node.attributes instead of node.src to avoid getting the domain part
- const src = this.node.attributes.getNamedItem("src").nodeValue;
- if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
- this.appendChild(this.node);
- }
+ getElementQuerySelector(): string {
+ // We get href from node.attributes instead of node.src to avoid getting the domain part
+ return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
}
}
diff --git a/core/static/bundled/utils/web-components.ts b/core/static/bundled/utils/web-components.ts
index 8d1dbc47..d7792e76 100644
--- a/core/static/bundled/utils/web-components.ts
+++ b/core/static/bundled/utils/web-components.ts
@@ -23,10 +23,17 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* The technique is to:
* create a new web component
* create the desired type inside
- * pass all attributes to the child component
+ * move all attributes to the child component
* store is at as `node` inside the parent
- *
- * Since we can't use the generic type to instantiate the node, we create a generator function
+ **/
+export interface InheritedHtmlElement
+ extends HTMLElement {
+ readonly inheritedTagName: K;
+ node: HTMLElementTagNameMap[K];
+}
+
+/**
+ * Generator function that creates an InheritedHtmlElement compatible class
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
@@ -35,11 +42,15 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* ```
**/
export function inheritHtmlElement(tagName: K) {
- return class Inherited extends HTMLElement {
- protected node: HTMLElementTagNameMap[K];
+ return class InheritedHtmlElementImpl
+ extends HTMLElement
+ implements InheritedHtmlElement
+ {
+ readonly inheritedTagName = tagName;
+ node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
- this.node = document.createElement(tagName);
+ this.node = document.createElement(this.inheritedTagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
if (attr.name in this.node) {
@@ -47,6 +58,10 @@ export function inheritHtmlElement(tagNam
}
}
+ // We move compatible attributes to the child element
+ // This avoids weird inconsistencies between attributes
+ // when we manipulate the dom in the future
+ // This is especially important when using attribute based reactivity
for (const attr of attributes) {
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);
diff --git a/core/templates/core/base/navbar.jinja b/core/templates/core/base/navbar.jinja
index fd1e6ddc..64ca9721 100644
--- a/core/templates/core/base/navbar.jinja
+++ b/core/templates/core/base/navbar.jinja
@@ -5,9 +5,8 @@