mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-30 16:43:55 +00:00 
			
		
		
		
	api route to search products with detailed infos.
This commit is contained in:
		| @@ -68,7 +68,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") { | |||||||
|           title: gettext("Remove"), |           title: gettext("Remove"), | ||||||
|         }, |         }, | ||||||
|         // biome-ignore lint/style/useNamingConvention: this is required by the api |         // biome-ignore lint/style/useNamingConvention: this is required by the api | ||||||
|         restore_on_backspace: {} |         restore_on_backspace: {}, | ||||||
|       }, |       }, | ||||||
|       persist: false, |       persist: false, | ||||||
|       maxItems: this.node.multiple ? this.max : 1, |       maxItems: this.node.multiple ? this.max : 1, | ||||||
|   | |||||||
| @@ -12,21 +12,21 @@ | |||||||
| # OR WITHIN THE LOCAL FILE "LICENSE" | # OR WITHIN THE LOCAL FILE "LICENSE" | ||||||
| # | # | ||||||
| # | # | ||||||
| from typing import Annotated | from django.conf import settings | ||||||
|  | from django.db.models import F | ||||||
| from annotated_types import MinLen |  | ||||||
| from django.db.models import Q |  | ||||||
| from ninja import Query | from ninja import Query | ||||||
| from ninja_extra import ControllerBase, api_controller, paginate, route | from ninja_extra import ControllerBase, api_controller, paginate, route | ||||||
| from ninja_extra.pagination import PageNumberPaginationExtra | from ninja_extra.pagination import PageNumberPaginationExtra | ||||||
| from ninja_extra.schemas import PaginatedResponseSchema | 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.models import Counter, Product | ||||||
| from counter.schemas import ( | from counter.schemas import ( | ||||||
|     CounterFilterSchema, |     CounterFilterSchema, | ||||||
|     CounterSchema, |     CounterSchema, | ||||||
|  |     ProductFilterSchema, | ||||||
|     ProductSchema, |     ProductSchema, | ||||||
|  |     SimpleProductSchema, | ||||||
|     SimplifiedCounterSchema, |     SimplifiedCounterSchema, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| @@ -64,15 +64,39 @@ class CounterController(ControllerBase): | |||||||
| class ProductController(ControllerBase): | class ProductController(ControllerBase): | ||||||
|     @route.get( |     @route.get( | ||||||
|         "/search", |         "/search", | ||||||
|         response=PaginatedResponseSchema[ProductSchema], |         response=PaginatedResponseSchema[SimpleProductSchema], | ||||||
|         permissions=[CanAccessLookup], |         permissions=[CanAccessLookup], | ||||||
|     ) |     ) | ||||||
|     @paginate(PageNumberPaginationExtra, page_size=50) |     @paginate(PageNumberPaginationExtra, page_size=50) | ||||||
|     def search_products(self, search: Annotated[str, MinLen(1)]): |     def search_products(self, filters: Query[ProductFilterSchema]): | ||||||
|         return ( |         return filters.filter( | ||||||
|             Product.objects.filter( |             Product.objects.order_by( | ||||||
|                 Q(name__icontains=search) | Q(code__icontains=search) |                 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", | ||||||
|             ) |             ) | ||||||
|             .filter(archived=False) |  | ||||||
|             .values() |  | ||||||
|         ) |         ) | ||||||
|   | |||||||
| @@ -1,10 +1,12 @@ | |||||||
| from typing import Annotated | from typing import Annotated | ||||||
|  |  | ||||||
| from annotated_types import MinLen | from annotated_types import MinLen | ||||||
|  | from django.urls import reverse | ||||||
| from ninja import Field, FilterSchema, ModelSchema | from ninja import Field, FilterSchema, ModelSchema | ||||||
|  |  | ||||||
| from core.schemas import SimpleUserSchema | from club.schemas import ClubSchema | ||||||
| from counter.models import Counter, Product | from core.schemas import GroupSchema, SimpleUserSchema | ||||||
|  | from counter.models import Counter, Product, ProductType | ||||||
|  |  | ||||||
|  |  | ||||||
| class CounterSchema(ModelSchema): | class CounterSchema(ModelSchema): | ||||||
| @@ -26,7 +28,47 @@ class SimplifiedCounterSchema(ModelSchema): | |||||||
|         fields = ["id", "name"] |         fields = ["id", "name"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class ProductSchema(ModelSchema): | class ProductTypeSchema(ModelSchema): | ||||||
|  |     class Meta: | ||||||
|  |         model = ProductType | ||||||
|  |         fields = ["id", "name"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SimpleProductSchema(ModelSchema): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Product |         model = Product | ||||||
|         fields = ["id", "name", "code"] |         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") | ||||||
|   | |||||||
| @@ -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 { escape_html } from "tom-select/dist/types/utils"; | ||||||
| import { | import { | ||||||
|   type CounterSchema, |   type CounterSchema, | ||||||
|   type ProductSchema, |   type SimpleProductSchema, | ||||||
|   counterSearchCounter, |   counterSearchCounter, | ||||||
|   productSearchProducts, |   productSearchProducts, | ||||||
| } from "#openapi"; | } from "#openapi"; | ||||||
| @@ -23,13 +23,13 @@ export class ProductAjaxSelect extends AjaxSelect { | |||||||
|     return []; |     return []; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   protected renderOption(item: ProductSchema, sanitize: typeof escape_html) { |   protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { | ||||||
|     return `<div class="select-item"> |     return `<div class="select-item"> | ||||||
|             <span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span> |             <span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span> | ||||||
|           </div>`; |           </div>`; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   protected renderItem(item: ProductSchema, sanitize: typeof escape_html) { |   protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { | ||||||
|     return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`; |     return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,11 +1,19 @@ | |||||||
| from io import BytesIO | from io import BytesIO | ||||||
|  | from typing import Callable | ||||||
| from uuid import uuid4 | from uuid import uuid4 | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  | from django.conf import settings | ||||||
|  | from django.core.cache import cache | ||||||
| from django.core.files.uploadedfile import SimpleUploadedFile | from django.core.files.uploadedfile import SimpleUploadedFile | ||||||
|  | from django.test import Client | ||||||
|  | from django.urls import reverse | ||||||
| from model_bakery import baker | from model_bakery import baker | ||||||
| from PIL import Image | 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 | from counter.models import Product, ProductType | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -31,3 +39,50 @@ def test_resize_product_icon(model): | |||||||
|     assert product.icon.height == 70 |     assert product.icon.height == 70 | ||||||
|     assert product.icon.name == f"products/{name}.webp" |     assert product.icon.name == f"products/{name}.webp" | ||||||
|     assert Image.open(product.icon).format == "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")) | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ from pydantic import TypeAdapter | |||||||
|  |  | ||||||
| from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple | from core.views.widgets.select import AutoCompleteSelect, AutoCompleteSelectMultiple | ||||||
| from counter.models import Counter, Product | 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"] | _js = ["bundled/counter/components/ajax-select-index.ts"] | ||||||
|  |  | ||||||
| @@ -24,12 +24,12 @@ class AutoCompleteSelectMultipleCounter(AutoCompleteSelectMultiple): | |||||||
| class AutoCompleteSelectProduct(AutoCompleteSelect): | class AutoCompleteSelectProduct(AutoCompleteSelect): | ||||||
|     component_name = "product-ajax-select" |     component_name = "product-ajax-select" | ||||||
|     model = Product |     model = Product | ||||||
|     adapter = TypeAdapter(list[ProductSchema]) |     adapter = TypeAdapter(list[SimpleProductSchema]) | ||||||
|     js = _js |     js = _js | ||||||
|  |  | ||||||
|  |  | ||||||
| class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): | class AutoCompleteSelectMultipleProduct(AutoCompleteSelectMultiple): | ||||||
|     component_name = "product-ajax-select" |     component_name = "product-ajax-select" | ||||||
|     model = Product |     model = Product | ||||||
|     adapter = TypeAdapter(list[ProductSchema]) |     adapter = TypeAdapter(list[SimpleProductSchema]) | ||||||
|     js = _js |     js = _js | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user