From 7ab2a0f1f24579bef4d4ec1d26198e4680e45d1b Mon Sep 17 00:00:00 2001 From: Leander Furumo Date: Thu, 5 Nov 2026 03:44:12 +0100 Subject: [PATCH] feat(inventory): add and edit products without the admin The inventory page gets a "Ny vare" link and a "Rediger" link per product, leading to a form for name, prices, volume, alcohol, description and whether the product is for sale. Stock still changes only through inventory transactions and sales, and the image through the admin. Saving needs the same access as the Django admin: staff, plus the add or change product permission (superusers have both). The server checks this on every save and validates with the model's rules. Logged out visitors are sent to log in and back to the form. New in the API: createProduct and updateProduct mutations, a product query, and a me query so the frontend knows who is logged in. Co-Authored-By: Claude Opus 5.5 --- bbs/api/mutations/products.py | 101 +++++++++ bbs/api/permissions.py | 14 ++ bbs/api/queries/shop.py | 4 + bbs/api/queries/users.py | 17 +- bbs/api/schema.py | 3 +- bbs/api/types/shop.py | 3 + bbs/api/types/users.py | 8 + bbs/tests/test_products.py | 175 +++++++++++++++ frontend/src/Routes.tsx | 3 + frontend/src/features/auth/viewer.graphql | 8 + .../features/inventory/Inventory.module.css | 16 ++ frontend/src/features/inventory/Inventory.tsx | 16 +- .../src/features/inventory/ProductEdit.tsx | 206 ++++++++++++++++++ .../features/inventory/createProduct.graphql | 25 +++ .../features/inventory/productForm.graphql | 12 + .../features/inventory/updateProduct.graphql | 25 +++ frontend/src/types.ts | 96 ++++++++ schema.graphql | 32 +++ 18 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 bbs/api/mutations/products.py create mode 100644 bbs/api/permissions.py create mode 100644 bbs/tests/test_products.py create mode 100644 frontend/src/features/auth/viewer.graphql create mode 100644 frontend/src/features/inventory/ProductEdit.tsx create mode 100644 frontend/src/features/inventory/createProduct.graphql create mode 100644 frontend/src/features/inventory/productForm.graphql create mode 100644 frontend/src/features/inventory/updateProduct.graphql 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 ( +
+ + + + + +