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 %} - - {%- 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 %}

    +
    +
    + {{ form }} +
    + +

    {% trans %}Club list{% endtrans %}

    - - {% 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 @@