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