api route to search products with detailed infos.

This commit is contained in:
imperosol 2024-12-13 23:58:25 +01:00
parent e680124d7b
commit 6c8a6008d5
6 changed files with 144 additions and 23 deletions

View File

@ -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,

View File

@ -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",
)
)

View File

@ -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")

View File

@ -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 `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</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>`;
}
}

View File

@ -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"))

View File

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