mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
api route to search products with detailed infos.
This commit is contained in:
parent
e680124d7b
commit
6c8a6008d5
@ -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,
|
||||
|
@ -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)
|
||||
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",
|
||||
)
|
||||
.filter(archived=False)
|
||||
.values()
|
||||
)
|
||||
|
@ -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")
|
||||
|
@ -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>`;
|
||||
}
|
||||
}
|
||||
|
@ -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"))
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user