From c79c251ba7657adf641a42dcf2a537bc626952a1 Mon Sep 17 00:00:00 2001 From: imperosol Date: Mon, 16 Dec 2024 19:46:34 +0100 Subject: [PATCH] Add `ProductTypeController` --- counter/api.py | 54 ++++++++++++++++-- counter/schemas.py | 32 ++++++++++- counter/tests/test_product_type.py | 91 ++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 9 deletions(-) create mode 100644 counter/tests/test_product_type.py diff --git a/counter/api.py b/counter/api.py index 8c37d0ac..43fada5a 100644 --- a/counter/api.py +++ b/counter/api.py @@ -14,22 +14,31 @@ # from django.conf import settings from django.db.models import F +from django.shortcuts import get_object_or_404 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, IsInGroup, IsRoot -from counter.models import Counter, Product +from counter.models import Counter, Product, ProductType from counter.schemas import ( CounterFilterSchema, CounterSchema, ProductFilterSchema, ProductSchema, + ProductTypeSchema, + ReorderProductTypeSchema, SimpleProductSchema, SimplifiedCounterSchema, ) +IsCounterAdmin = ( + IsRoot + | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) + | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) +) + @api_controller("/counter") class CounterController(ControllerBase): @@ -80,11 +89,7 @@ class ProductController(ControllerBase): @route.get( "/search/detailed", response=PaginatedResponseSchema[ProductSchema], - permissions=[ - IsRoot - | IsInGroup(settings.SITH_GROUP_COUNTER_ADMIN_ID) - | IsInGroup(settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) - ], + permissions=[IsCounterAdmin], url_name="search_products_detailed", ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -100,3 +105,40 @@ class ProductController(ControllerBase): "name", ) ) + + +@api_controller("/product-type", permissions=[IsCounterAdmin]) +class ProductTypeController(ControllerBase): + @route.get("", response=list[ProductTypeSchema], url_name="fetch-product-types") + def fetch_all(self): + return ProductType.objects.order_by("order") + + @route.patch("/{type_id}/move") + def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]): + """Change the order of a product type. + + To use this route, give either the id of the product type + this one should be above of, + of the id of the product type this one should be below of. + + Order affects the display order of the product types. + + Examples: + ``` + GET /api/counter/product-type + => [<1: type A>, <2: type B>, <3: type C>] + + PATCH /api/counter/product-type/3/move?below=1 + + GET /api/counter/product-type + => [<1: type A>, <3: type C>, <2: type B>] + ``` + """ + product_type: ProductType = self.get_object_or_exception( + ProductType, pk=type_id + ) + other = get_object_or_404(ProductType, pk=other_id.above or other_id.below) + if other_id.below is not None: + product_type.below(other) + else: + product_type.above(other) diff --git a/counter/schemas.py b/counter/schemas.py index 7ecb346b..1b770a3d 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -1,8 +1,9 @@ -from typing import Annotated +from typing import Annotated, Self from annotated_types import MinLen from django.urls import reverse -from ninja import Field, FilterSchema, ModelSchema +from ninja import Field, FilterSchema, ModelSchema, Schema +from pydantic import model_validator from club.schemas import ClubSchema from core.schemas import GroupSchema, SimpleUserSchema @@ -29,11 +30,36 @@ class SimplifiedCounterSchema(ModelSchema): class ProductTypeSchema(ModelSchema): + class Meta: + model = ProductType + fields = ["id", "name", "description", "comment", "icon", "order"] + + url: str + + @staticmethod + def resolve_url(obj: ProductType) -> str: + return reverse("counter:producttype_edit", kwargs={"type_id": obj.id}) + + +class SimpleProductTypeSchema(ModelSchema): class Meta: model = ProductType fields = ["id", "name"] +class ReorderProductTypeSchema(Schema): + below: int | None = None + above: int | None = None + + @model_validator(mode="after") + def validate_exclusive(self) -> Self: + if self.below is None and self.above is None: + raise ValueError("Either 'below' or 'above' must be set.") + if self.below is not None and self.above is not None: + raise ValueError("Only one of 'below' or 'above' must be set.") + return self + + class SimpleProductSchema(ModelSchema): class Meta: model = Product @@ -57,7 +83,7 @@ class ProductSchema(ModelSchema): buying_groups: list[GroupSchema] club: ClubSchema - product_type: ProductTypeSchema | None + product_type: SimpleProductTypeSchema | None url: str @staticmethod diff --git a/counter/tests/test_product_type.py b/counter/tests/test_product_type.py new file mode 100644 index 00000000..45ed5797 --- /dev/null +++ b/counter/tests/test_product_type.py @@ -0,0 +1,91 @@ +import pytest +from django.conf import settings +from django.test import Client +from django.urls import reverse +from model_bakery import baker, seq +from ninja_extra.testing import TestClient + +from core.baker_recipes import board_user, subscriber_user +from core.models import RealGroup, User +from counter.api import ProductTypeController +from counter.models import ProductType + + +@pytest.fixture +def product_types(db) -> list[ProductType]: + """All existing product types, ordered by their `order` field""" + # delete product types that have been created in the `populate` command + ProductType.objects.all().delete() + return baker.make(ProductType, _quantity=5, order=seq(0)) + + +@pytest.mark.django_db +def test_fetch_product_types(product_types: list[ProductType]): + """Test that the API returns the right products in the right order""" + client = TestClient(ProductTypeController) + response = client.get("") + assert response.status_code == 200 + assert [i["id"] for i in response.json()] == [t.id for t in product_types] + + +@pytest.mark.django_db +def test_move_below_product_type(product_types: list[ProductType]): + """Test that moving a product below another works""" + client = TestClient(ProductTypeController) + response = client.patch( + f"/{product_types[-1].id}/move", query={"below": product_types[0].id} + ) + assert response.status_code == 200 + new_order = [i["id"] for i in client.get("").json()] + assert new_order == [ + product_types[0].id, + product_types[-1].id, + *[t.id for t in product_types[1:-1]], + ] + + +@pytest.mark.django_db +def test_move_above_product_type(product_types: list[ProductType]): + """Test that moving a product above another works""" + client = TestClient(ProductTypeController) + response = client.patch( + f"/{product_types[1].id}/move", query={"above": product_types[0].id} + ) + assert response.status_code == 200 + new_order = [i["id"] for i in client.get("").json()] + assert new_order == [ + product_types[1].id, + product_types[0].id, + *[t.id for t in product_types[2:]], + ] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("user_factory", "status_code"), + [ + (lambda: baker.make(User, is_superuser=True), 200), + (subscriber_user.make, 403), + (board_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_controller_permissions(client: Client, user_factory, status_code): + client.force_login(user_factory()) + response = client.get(reverse("api:fetch-product-types")) + assert response.status_code == status_code