From 6c8a6008d591561367dde8cf646114de974adac4 Mon Sep 17 00:00:00 2001 From: imperosol Date: Fri, 13 Dec 2024 23:58:25 +0100 Subject: [PATCH] api route to search products with detailed infos. --- .../core/components/ajax-select-base.ts | 2 +- counter/api.py | 50 ++++++++++++----- counter/schemas.py | 48 +++++++++++++++- .../counter/components/ajax-select-index.ts | 6 +- counter/tests/test_product.py | 55 +++++++++++++++++++ counter/widgets/select.py | 6 +- 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/core/static/bundled/core/components/ajax-select-base.ts b/core/static/bundled/core/components/ajax-select-base.ts index 525de097..06c3508e 100644 --- a/core/static/bundled/core/components/ajax-select-base.ts +++ b/core/static/bundled/core/components/ajax-select-base.ts @@ -68,7 +68,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") { title: gettext("Remove"), }, // biome-ignore lint/style/useNamingConvention: this is required by the api - restore_on_backspace: {} + restore_on_backspace: {}, }, persist: false, maxItems: this.node.multiple ? this.max : 1, diff --git a/counter/api.py b/counter/api.py index f3f0f101..7c181aa0 100644 --- a/counter/api.py +++ b/counter/api.py @@ -12,21 +12,21 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # -from typing import Annotated - -from annotated_types import MinLen -from django.db.models import Q +from django.conf import settings +from django.db.models import F from ninja import Query from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from core.api_permissions import CanAccessLookup, CanView, IsRoot +from core.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from counter.models import Counter, Product from counter.schemas import ( CounterFilterSchema, CounterSchema, + ProductFilterSchema, ProductSchema, + SimpleProductSchema, SimplifiedCounterSchema, ) @@ -64,15 +64,39 @@ class CounterController(ControllerBase): class ProductController(ControllerBase): @route.get( "/search", - response=PaginatedResponseSchema[ProductSchema], + response=PaginatedResponseSchema[SimpleProductSchema], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) - def search_products(self, search: Annotated[str, MinLen(1)]): - return ( - Product.objects.filter( - Q(name__icontains=search) | Q(code__icontains=search) - ) - .filter(archived=False) - .values() + def search_products(self, filters: Query[ProductFilterSchema]): + return filters.filter( + Product.objects.order_by( + F("product_type__priority").desc(nulls_last=True), + "product_type", + "name", + ).values() + ) + + @route.get( + "/search/detailed", + response=PaginatedResponseSchema[ProductSchema], + permissions=[ + IsRoot + | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) + | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) + ], + url_name="search_products_detailed", + ) + @paginate(PageNumberPaginationExtra, page_size=50) + def search_products_detailed(self, filters: Query[ProductFilterSchema]): + """Get the detailed information about the products.""" + return filters.filter( + Product.objects.select_related("club") + .prefetch_related("buying_groups") + .select_related("product_type") + .order_by( + F("product_type__priority").desc(nulls_last=True), + "product_type", + "name", + ) ) diff --git a/counter/schemas.py b/counter/schemas.py index ec1a842d..7ecb346b 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,10 +1,12 @@ from typing import Annotated from annotated_types import MinLen +from django.urls import reverse from ninja import Field, FilterSchema, ModelSchema -from core.schemas import SimpleUserSchema -from counter.models import Counter, Product +from club.schemas import ClubSchema +from core.schemas import GroupSchema, SimpleUserSchema +from counter.models import Counter, Product, ProductType class CounterSchema(ModelSchema): @@ -26,7 +28,47 @@ class SimplifiedCounterSchema(ModelSchema): fields = ["id", "name"] -class ProductSchema(ModelSchema): +class ProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name"] + + +class SimpleProductSchema(ModelSchema): class Meta: model = Product fields = ["id", "name", "code"] + + +class ProductSchema(ModelSchema): + class Meta: + model = Product + fields = [ + "id", + "name", + "code", + "description", + "purchase_price", + "selling_price", + "icon", + "limit_age", + "archived", + ] + + buying_groups: list[GroupSchema] + club: ClubSchema + product_type: ProductTypeSchema | None + url: str + + @staticmethod + def resolve_url(obj: Product) -> str: + return reverse("counter:product_edit", kwargs={"product_id": obj.id}) + + +class ProductFilterSchema(FilterSchema): + search: Annotated[str, MinLen(1)] | None = Field( + None, q=["name__icontains", "code__icontains"] + ) + is_archived: bool | None = Field(None, q="archived") + buying_groups: set[int] | None = Field(None, q="buying_groups__in") + product_type: set[int] | None = Field(None, q="product_type__in") diff --git a/counter/static/bundled/counter/components/ajax-select-index.ts b/counter/static/bundled/counter/components/ajax-select-index.ts index 147e4733..a2d61a48 100644 --- a/counter/static/bundled/counter/components/ajax-select-index.ts +++ b/counter/static/bundled/counter/components/ajax-select-index.ts @@ -4,7 +4,7 @@ import type { TomOption } from "tom-select/dist/types/types"; import type { escape_html } from "tom-select/dist/types/utils"; import { type CounterSchema, - type ProductSchema, + type SimpleProductSchema, counterSearchCounter, productSearchProducts, } from "#openapi"; @@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect { return []; } - protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { + protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { return `
${sanitize(item.code)} - ${sanitize(item.name)}
`; } - protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { + protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { return `${sanitize(item.code)} - ${sanitize(item.name)}`; } } diff --git a/counter/tests/test_product.py b/counter/tests/test_product.py index d18c6f11..a5eb39c4 100644 --- a/counter/tests/test_product.py +++ b/counter/tests/test_product.py @@ -1,11 +1,19 @@ from io import BytesIO +from typing import Callable from uuid import uuid4 import pytest +from django.conf import settings +from django.core.cache import cache from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse from model_bakery import baker from PIL import Image +from pytest_django.asserts import assertNumQueries +from core.baker_recipes import board_user, subscriber_user +from core.models import RealGroup, User from counter.models import Product, ProductType @@ -31,3 +39,50 @@ def test_resize_product_icon(model): assert product.icon.height == 70 assert product.icon.name == f"products/{name}.webp" assert Image.open(product.icon).format == "WEBP" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (board_user.make, 403), + (subscriber_user.make, 403), + ( + lambda: baker.make( + User, + groups=[RealGroup.objects.get(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)], + ), + 200, + ), + ( + lambda: baker.make( + User, + groups=[ + RealGroup.objects.get(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) + ], + ), + 200, + ), + ], +) +def test_fetch_product_access( + client: Client, user_factory: Callable[[], User], status_code: int +): + """Test that only authorized users can use the `GET /product` route.""" + client.force_login(user_factory()) + assert ( + client.get(reverse("api:search_products_detailed")).status_code == status_code + ) + + +@pytest.mark.django_db +def test_fetch_product_nb_queries(client: Client): + client.force_login(baker.make(User, is_superuser=True)) + cache.clear() + with assertNumQueries(5): + # - 2 for authentication + # - 1 for pagination + # - 1 for the actual request + # - 1 to prefetch the related buying_groups + client.get(reverse("api:search_products_detailed")) diff --git a/counter/widgets/select.py b/counter/widgets/select.py index 68b0bfc1..78c92862 100644 --- a/counter/widgets/select.py +++ b/counter/widgets/select.py @@ -2,7 +2,7 @@ from pydantic import TypeAdapter from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple from counter.models import Counter, Product -from counter.schemas import ProductSchema, SimplifiedCounterSchema +from counter.schemas import SimpleProductSchema, SimplifiedCounterSchema _js = ["bundled/counter/components/ajax-select-index.ts"] @@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple): class AutoCompleteSelectProduct(AutoCompleteSelect): component_name = "product-ajax-select" model = Product - adapter = TypeAdapter(list[ProductSchema]) + adapter = TypeAdapter(list[SimpleProductSchema]) js = _js class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): component_name = "product-ajax-select" model = Product - adapter = TypeAdapter(list[ProductSchema]) + adapter = TypeAdapter(list[SimpleProductSchema]) js = _js