diff --git a/bbs/api/mutations/products.py b/bbs/api/mutations/products.py new file mode 100644 index 0000000..19c80df --- /dev/null +++ b/bbs/api/mutations/products.py @@ -0,0 +1,101 @@ +from decimal import Decimal +from typing import Annotated + +import strawberry +from django.core.exceptions import ValidationError +from django.db import transaction +from strawberry import ID, Info + +from bbs import models +from bbs.api.errors import FieldError, FormErrors +from bbs.api.permissions import can_add_products, can_change_products +from bbs.api.types.shop import Product + + +@strawberry.input +class ProductInput: + name: str + sale_price_int: Decimal + sale_price_ext: Decimal + volume_liter: Decimal | None = None + alcohol_percent: Decimal | None = None + description: str = "" + active: bool = True + + +# The stock changes through inventory transactions and sales, and the image through the admin +EDITABLE_FIELDS = [ + "name", + "sale_price_int", + "sale_price_ext", + "volume_liter", + "alcohol_percent", + "description", + "active", +] +NON_NEGATIVE_FIELDS = ["sale_price_int", "sale_price_ext", "volume_liter", "alcohol_percent"] + + +@strawberry.type +class SaveProductSuccess: + product: Product + + +SaveProductResponse = Annotated[SaveProductSuccess | FormErrors, strawberry.union("SaveProductResponse")] + + +def _access_denied(user) -> FormErrors: + message = "Du har ikke tilgang til å endre varer." if user.is_authenticated else "Logg inn for å endre varer." + return FormErrors(message=message, fields=[FieldError(field="user", message=message)]) + + +def _save(product: models.Product, data: ProductInput) -> SaveProductResponse: + for field in EDITABLE_FIELDS: + setattr(product, field, getattr(data, field)) + + errors: dict[str, list[str]] = {} + try: + product.full_clean() + except ValidationError as e: + errors = e.message_dict + for field in NON_NEGATIVE_FIELDS: + value = getattr(product, field) + if value is not None and value < 0: + errors.setdefault(field, []).append("Kan ikke være negativ.") + + if errors: + return FormErrors( + message="Varen ble ikke lagret. Rett opp feilene i skjemaet.", + fields=[FieldError(field=field, message=" ".join(messages)) for field, messages in errors.items()], + ) + product.save() + return SaveProductSuccess(product=product) + + +@strawberry.mutation +@transaction.atomic +def create_product(info: Info, data: ProductInput) -> SaveProductResponse: + user = info.context.request.user + if not can_add_products(user): + return _access_denied(user) + return _save(models.Product(), data) + + +@strawberry.mutation +@transaction.atomic +def update_product(info: Info, product_id: ID, data: ProductInput) -> SaveProductResponse: + user = info.context.request.user + if not can_change_products(user): + return _access_denied(user) + try: + product = models.Product.objects.select_for_update().get(pk=product_id) + except models.Product.DoesNotExist, ValueError: + message = "Fant ikke varen." + return FormErrors(message=message, fields=[FieldError(field="product_id", message=message)]) + return _save(product, data) + + +@strawberry.type +class ProductMutations: + create_product = create_product + update_product = update_product diff --git a/bbs/api/permissions.py b/bbs/api/permissions.py new file mode 100644 index 0000000..4a596e9 --- /dev/null +++ b/bbs/api/permissions.py @@ -0,0 +1,14 @@ +"""Who may do what through the API, using the same rules as the Django admin.""" + + +def _admin_can(user, permission): + # The admin only lets in staff, and then checks the model permission (superusers have all of them) + return user.is_authenticated and user.is_staff and user.has_perm(permission) + + +def can_add_products(user): + return _admin_can(user, "bbs.add_product") + + +def can_change_products(user): + return _admin_can(user, "bbs.change_product") diff --git a/bbs/api/queries/shop.py b/bbs/api/queries/shop.py index c7e6d2d..7d041f8 100644 --- a/bbs/api/queries/shop.py +++ b/bbs/api/queries/shop.py @@ -19,6 +19,10 @@ class ShopQueries: all_orders: list[Order] = strawberry_django.field() order_list: list[Order] = strawberry_django.field(pagination=True, ordering=OrderOrdering) + @strawberry.field + def product(self, product_id: strawberry.ID) -> Product: + return models.Product.objects.get(pk=product_id) + @strawberry.field def order_stats(self) -> OrderStats: return OrderStats( diff --git a/bbs/api/queries/users.py b/bbs/api/queries/users.py index 8c435eb..28bc04e 100644 --- a/bbs/api/queries/users.py +++ b/bbs/api/queries/users.py @@ -1,8 +1,9 @@ import strawberry from django.contrib.auth.models import User as DjangoUser -from strawberry import ID +from strawberry import ID, Info -from bbs.api.types.users import User +from bbs.api.permissions import can_add_products, can_change_products +from bbs.api.types.users import User, Viewer @strawberry.type @@ -14,3 +15,15 @@ class UsersQueries: @strawberry.field def user(self, user_id: ID) -> User: return DjangoUser.objects.get(pk=user_id) + + @strawberry.field(description="The logged in user, or null when nobody is logged in") + def me(self, info: Info) -> Viewer | None: + user = info.context.request.user + if not user.is_authenticated: + return None + return Viewer( + id=user.pk, + first_name=user.first_name, + can_add_products=can_add_products(user), + can_change_products=can_change_products(user), + ) diff --git a/bbs/api/schema.py b/bbs/api/schema.py index 857c8f5..94c9db5 100644 --- a/bbs/api/schema.py +++ b/bbs/api/schema.py @@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist from strawberry.extensions import SchemaExtension from bbs.api.mutations.inventory import InventoryMutations +from bbs.api.mutations.products import ProductMutations from bbs.api.mutations.shop import ShopMutations from bbs.api.mutations.users import UsersMutations from bbs.api.queries.inventory import InventoryQueries @@ -16,7 +17,7 @@ class Query(InventoryQueries, ShopQueries, UsersQueries): @strawberry.type -class Mutation(InventoryMutations, ShopMutations, UsersMutations): +class Mutation(InventoryMutations, ProductMutations, ShopMutations, UsersMutations): pass diff --git a/bbs/api/types/shop.py b/bbs/api/types/shop.py index 5815673..aa39e39 100644 --- a/bbs/api/types/shop.py +++ b/bbs/api/types/shop.py @@ -22,6 +22,9 @@ class ProductFilter: class Product: id: auto name: auto + description: auto + volume_liter: auto + alcohol_percent: auto image_url: str sale_price_int: auto sale_price_ext: auto diff --git a/bbs/api/types/users.py b/bbs/api/types/users.py index 29334ae..1329926 100644 --- a/bbs/api/types/users.py +++ b/bbs/api/types/users.py @@ -43,6 +43,14 @@ class UserProfile: ] +@strawberry.type +class Viewer: + id: strawberry.ID + first_name: str + can_add_products: bool + can_change_products: bool + + @strawberry_django.type(DjangoUser) class User: id: auto diff --git a/bbs/tests/test_products.py b/bbs/tests/test_products.py new file mode 100644 index 0000000..beeb61c --- /dev/null +++ b/bbs/tests/test_products.py @@ -0,0 +1,175 @@ +from decimal import Decimal + +import pytest +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User as DjangoUser + +from bbs.models import Product + +ME = "query { me { firstName canAddProducts canChangeProducts } }" + +SAVE_RESULT = """ + ... on SaveProductSuccess { + product { id name salePriceInt salePriceExt volumeLiter alcoholPercent description active inventoryAmount } + } + ... on FormErrors { message fields { field message } } +""" +CREATE_PRODUCT = f"mutation CreateProduct($data: ProductInput!) {{ createProduct(data: $data) {{ {SAVE_RESULT} }} }}" +UPDATE_PRODUCT = f""" +mutation UpdateProduct($productId: ID!, $data: ProductInput!) {{ + updateProduct(productId: $productId, data: $data) {{ {SAVE_RESULT} }} +}} +""" + +PRODUCT_DATA = { + "name": "Ny pils", + "salePriceInt": "25.0", + "salePriceExt": "30.0", + "volumeLiter": "0.5", + "alcoholPercent": "4.7", + "description": "Frisk", + "active": True, +} + + +def make_user(username, *, staff=False, superuser=False, permissions=()): + user = DjangoUser.objects.create_user(username=username, first_name=username.title()) + user.is_staff = staff + user.is_superuser = superuser + user.save() + user.user_permissions.set(Permission.objects.filter(content_type__app_label="bbs", codename__in=permissions)) + return user + + +@pytest.fixture +def staff_with_product_access(db): # noqa: ARG001 + return make_user("lager", staff=True, permissions=["add_product", "change_product"]) + + +# me +# == + + +def test_me_is_null_when_logged_out(db, gql_client): # noqa: ARG001 + res = gql_client.query(ME) + assert not res.errors + assert res.data["me"] is None + + +@pytest.mark.parametrize( + ("staff", "superuser", "permissions", "can_add", "can_change"), + [ + (False, False, ["add_product", "change_product"], False, False), # permissions without admin access + (True, False, [], False, False), + (True, False, ["change_product"], False, True), + (True, False, ["add_product", "change_product"], True, True), + (True, True, [], True, True), + ], +) +def test_me_reports_product_access(db, gql_client, client, staff, superuser, permissions, can_add, can_change): # noqa: ARG001, PLR0913, PLR0917 + client.force_login(make_user("someone", staff=staff, superuser=superuser, permissions=permissions)) + res = gql_client.query(ME) + assert not res.errors + assert res.data["me"] == {"firstName": "Someone", "canAddProducts": can_add, "canChangeProducts": can_change} + + +# createProduct +# ============= + + +def test_create_product(gql_client, client, staff_with_product_access): + client.force_login(staff_with_product_access) + res = gql_client.query(CREATE_PRODUCT, {"data": PRODUCT_DATA}) + assert not res.errors + product = res.data["createProduct"]["product"] + assert product["name"] == "Ny pils" + assert product["inventoryAmount"] == 0 + + saved = Product.objects.get(pk=product["id"]) + assert saved.sale_price_int == Decimal("25.0") + assert saved.volume_liter == Decimal("0.5") + assert saved.active + + +@pytest.mark.parametrize( + ("user_kwargs", "message"), + [ + (None, "Logg inn for å endre varer."), + ({}, "Du har ikke tilgang til å endre varer."), + ({"permissions": ["add_product"]}, "Du har ikke tilgang til å endre varer."), # not staff + ({"staff": True, "permissions": ["change_product"]}, "Du har ikke tilgang til å endre varer."), + ], +) +def test_create_product_requires_admin_access(db, gql_client, client, user_kwargs, message): # noqa: ARG001 + if user_kwargs is not None: + client.force_login(make_user("someone", **user_kwargs)) + res = gql_client.query(CREATE_PRODUCT, {"data": PRODUCT_DATA}) + assert not res.errors + assert res.data["createProduct"]["message"] == message + assert not Product.objects.exists() + + +@pytest.mark.parametrize( + ("changes", "field"), + [ + ({"name": ""}, "name"), + ({"salePriceInt": "-1"}, "sale_price_int"), + ({"salePriceExt": "12.55"}, "sale_price_ext"), # one decimal at most + ({"volumeLiter": None}, "volume_liter"), + ({"alcoholPercent": "-0.5"}, "alcohol_percent"), + ], +) +def test_create_product_validates(gql_client, client, staff_with_product_access, changes, field): + client.force_login(staff_with_product_access) + res = gql_client.query(CREATE_PRODUCT, {"data": {**PRODUCT_DATA, **changes}}) + assert not res.errors + errors = res.data["createProduct"] + assert errors["message"] == "Varen ble ikke lagret. Rett opp feilene i skjemaet." + assert [error["field"] for error in errors["fields"]] == [field] + assert not Product.objects.exists() + + +# updateProduct +# ============= + + +def test_update_product(product, gql_client, client): + product.inventory_amount = 7 + product.save() + client.force_login(make_user("admin", staff=True, superuser=True)) + + res = gql_client.query(UPDATE_PRODUCT, {"productId": str(product.id), "data": {**PRODUCT_DATA, "active": False}}) + assert not res.errors + assert res.data["updateProduct"]["product"]["name"] == "Ny pils" + + product.refresh_from_db() + assert product.name == "Ny pils" + assert product.sale_price_ext == Decimal("30.0") + assert not product.active + assert product.inventory_amount == 7 # stock only changes through transactions and sales + + +def test_update_product_requires_change_permission(product, gql_client, client): + client.force_login(make_user("someone", staff=True, permissions=["add_product"])) + res = gql_client.query(UPDATE_PRODUCT, {"productId": str(product.id), "data": PRODUCT_DATA}) + assert not res.errors + assert res.data["updateProduct"]["message"] == "Du har ikke tilgang til å endre varer." + product.refresh_from_db() + assert product.name == "borg" + + +def test_update_missing_product(gql_client, client, staff_with_product_access): + client.force_login(staff_with_product_access) + res = gql_client.query(UPDATE_PRODUCT, {"productId": "999", "data": PRODUCT_DATA}) + assert not res.errors + assert res.data["updateProduct"]["message"] == "Fant ikke varen." + + +# product +# ======= + + +def test_product_query(product, gql_client): + res = gql_client.query("query P($id: ID!) { product(productId: $id) { name } }", {"id": str(product.id)}) + assert not res.errors + assert res.data["product"]["name"] == "borg" diff --git a/frontend/src/Routes.tsx b/frontend/src/Routes.tsx index 0fed929..e0b6281 100644 --- a/frontend/src/Routes.tsx +++ b/frontend/src/Routes.tsx @@ -7,6 +7,7 @@ const Deposit = React.lazy(() => import("./features/deposit/Deposit")); const Inventory = React.lazy(() => import("./features/inventory/Inventory")); const InventoryAdd = React.lazy(() => import("./features/inventory/InventoryAdd")); const InventoryReport = React.lazy(() => import("./features/inventory/InventoryReport")); +const ProductEdit = React.lazy(() => import("./features/inventory/ProductEdit")); const Log = React.lazy(() => import("./features/log/Log")); const Register = React.lazy(() => import("./features/register/Register")); const OrderStats = React.lazy(() => import("./features/stats/OrderStats")); @@ -22,6 +23,8 @@ export const Routes = (): React.JSX.Element => ( } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/auth/viewer.graphql b/frontend/src/features/auth/viewer.graphql new file mode 100644 index 0000000..96c0ab1 --- /dev/null +++ b/frontend/src/features/auth/viewer.graphql @@ -0,0 +1,8 @@ +query viewer { + me { + id + firstName + canAddProducts + canChangeProducts + } +} diff --git a/frontend/src/features/inventory/Inventory.module.css b/frontend/src/features/inventory/Inventory.module.css index 46c36bc..299c368 100644 --- a/frontend/src/features/inventory/Inventory.module.css +++ b/frontend/src/features/inventory/Inventory.module.css @@ -50,6 +50,12 @@ min-height: 6rem; } +.form label.checkbox { + display: flex; + align-items: center; + gap: 0.5rem; +} + .summary { display: flex; flex-wrap: wrap; @@ -70,3 +76,13 @@ min-width: 42rem; } } + +/* Read by screen readers, not shown */ +.visuallyHidden { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/frontend/src/features/inventory/Inventory.tsx b/frontend/src/features/inventory/Inventory.tsx index d90709a..dfc6173 100644 --- a/frontend/src/features/inventory/Inventory.tsx +++ b/frontend/src/features/inventory/Inventory.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router-dom"; import styles from "./Inventory.module.css"; import { GQLError } from "../../components/GQLError"; -import { InventoryPageDocument } from "../../types"; +import { InventoryPageDocument, ViewerDocument } from "../../types"; import { formatCurrency } from "../../utils/currency"; import { formatTime } from "../../utils/time"; @@ -15,6 +15,8 @@ const Inventory = () => { const { data, loading, error } = useQuery(InventoryPageDocument, { variables: { limit: PAGE_SIZE, offset }, }); + const { data: viewerData } = useQuery(ViewerDocument); + const canChangeProducts = !!viewerData?.me?.canChangeProducts; if (loading) return
Loading...
; if (error) return ; @@ -42,6 +44,8 @@ const Inventory = () => { Registrer varetransaksjon + {/* Shown to everyone; the page asks for a login, and for admin access, when needed */} + Ny vare Åpne rapport
@@ -54,6 +58,11 @@ const Inventory = () => { Verdi I salg Sist oppdatert + {canChangeProducts && ( + + Endre + + )} @@ -65,6 +74,11 @@ const Inventory = () => { {formatCurrency(Number(product.wholesaleValue))} kr {product.active ? "Ja" : "Nei"} {formatTime(product.updated)} + {canChangeProducts && ( + + Rediger + + )} ))} diff --git a/frontend/src/features/inventory/ProductEdit.tsx b/frontend/src/features/inventory/ProductEdit.tsx new file mode 100644 index 0000000..34eb244 --- /dev/null +++ b/frontend/src/features/inventory/ProductEdit.tsx @@ -0,0 +1,206 @@ +import { useMutation, useQuery } from "@apollo/client/react"; +import React, { useEffect, useState } from "react"; +import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; + +import styles from "./Inventory.module.css"; +import { GQLError } from "../../components/GQLError"; +import { useToast } from "../../hooks/useToast"; +import { + CreateProductDocument, + ProductFormDocument, + ProductFormQuery, + ProductInput, + UpdateProductDocument, + ViewerDocument, +} from "../../types"; +import { loginUrl } from "../../utils/api"; + +type FormValues = { + name: string; + salePriceInt: string; + salePriceExt: string; + volumeLiter: string; + alcoholPercent: string; + description: string; + active: boolean; +}; + +const emptyValues: FormValues = { + name: "", + salePriceInt: "", + salePriceExt: "", + volumeLiter: "", + alcoholPercent: "", + description: "", + active: true, +}; + +const toFormValues = (product: ProductFormQuery["product"]): FormValues => ({ + name: product.name, + salePriceInt: product.salePriceInt, + salePriceExt: product.salePriceExt, + volumeLiter: product.volumeLiter ?? "", + alcoholPercent: product.alcoholPercent ?? "", + description: product.description, + active: product.active, +}); + +const toInput = (values: FormValues): ProductInput => ({ + ...values, + volumeLiter: values.volumeLiter || null, + alcoholPercent: values.alcoholPercent || null, +}); + +// Keyed by the API's field names, e.g. sale_price_int +type FieldErrors = Record; + +const FieldError = ({ errors, field }: { errors: FieldErrors; field: string }) => + errors[field] ? {errors[field]} : null; + +const ProductForm = ({ productId, initialValues }: { productId?: string; initialValues: FormValues }) => { + const navigate = useNavigate(); + const { showToast } = useToast(); + const [values, setValues] = useState(initialValues); + const [fieldErrors, setFieldErrors] = useState({}); + const [createProduct, { loading: creating }] = useMutation(CreateProductDocument, { + // The product lists don't know about the new product; have them load again + update: (cache) => { + cache.evict({ id: "ROOT_QUERY", fieldName: "allProducts" }); + cache.gc(); + }, + }); + const [updateProduct, { loading: updating }] = useMutation(UpdateProductDocument); + const saving = creating || updating; + + const set = + (field: Exclude) => + (event: React.ChangeEvent) => + setValues({ ...values, [field]: event.target.value }); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + const data = toInput(values); + try { + const response = productId + ? (await updateProduct({ variables: { productId, data } })).data?.updateProduct + : (await createProduct({ variables: { data } })).data?.createProduct; + if (response?.__typename === "SaveProductSuccess") { + showToast(`Lagret ${response.product.name}.`); + navigate("/inventory"); + } else if (response?.__typename === "FormErrors") { + setFieldErrors(Object.fromEntries(response.fields.map(({ field, message }) => [field, message]))); + showToast(response.message, "danger"); + } else { + showToast("Kunne ikke lagre varen.", "danger"); + } + } catch (mutationError) { + showToast(mutationError instanceof Error ? mutationError.message : "Kunne ikke lagre varen.", "danger"); + } + }; + + return ( +
+ + + + + +