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"),
|
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
|
||||||
|
Loading…
Reference in New Issue
Block a user