2 Commits
140 changed files with 21419 additions and 11108 deletions

No files matched your search

-14
View File
@@ -1,14 +0,0 @@
.git
.venv
**/__pycache__
**/node_modules
static/assets
static/index.html
staticfiles
media
db.sqlite3
.env
bbs/local_settings.py
bbs/management/commands/generate_testdata.py
.coverage
coverage.xml
-60
View File
@@ -1,60 +0,0 @@
name: Deploy
on:
push:
branches: [main]
jobs:
# The runner gives jobs no docker socket, so kaniko builds the image unprivileged.
# Its debug image has busybox but no node, so it clones the tested commit itself
# instead of using actions/checkout.
image:
if: vars.DEPLOY_AMIGA == 'true'
runs-on: ubuntu-latest
needs:
- tests
- prek
container: ghcr.io/osscontainertools/kaniko:v1.28.6-debug
defaults:
run:
shell: sh
steps:
- name: Build and push
env:
GIT_USERNAME: ${{ github.actor }}
GIT_PASSWORD: ${{ secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
auth=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 | tr -d '\n')
printf '{"auths":{"git.neuf.no":{"auth":"%s"}}}' "$auth" > /kaniko/.docker/config.json
/kaniko/executor \
--context "git://git.neuf.no/${{ github.repository }}.git#${{ github.ref }}#${{ github.sha }}" \
--destination "git.neuf.no/edb/bbs:${{ github.sha }}" \
--build-arg "SENTRY_RELEASE=${{ github.sha }}" \
--cache=true --cache-repo git.neuf.no/edb/bbs-cache \
--snapshot-mode=redo --use-new-run \
--reproducible
# amiga's authorized_keys sends this key to bbs-deploy, which reads the sha from the
# ssh command line and deploys it to every bbs tenant on the host.
deploy-amiga:
if: vars.DEPLOY_AMIGA == 'true'
runs-on: ubuntu-latest
needs:
- image
steps:
- name: Deploy
run: |
command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )
eval $(ssh-agent -s)
echo "${{ secrets.SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo "${{ secrets.SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
ssh bbs-deploy@amiga.neuf.no "${{ github.sha }}"
prek:
uses: ./.gitea/workflows/prek.yml
tests:
uses: ./.gitea/workflows/tests.yml
-12
View File
@@ -1,12 +0,0 @@
name: "Lint: prek"
on:
pull_request:
workflow_call:
jobs:
prek:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: j178/prek-action@v1
-42
View File
@@ -1,42 +0,0 @@
name: Tests
on:
pull_request:
workflow_call:
jobs:
build_frontend:
runs-on: ubuntu-latest
container: node:26-alpine
steps:
- uses: actions/checkout@v4
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
tests:
runs-on: ubuntu-latest
container: nikolaik/python-nodejs:python3.14-nodejs26
steps:
- uses: actions/checkout@v4
# The SPA tests serve the built static/index.html
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Run migrations
run: uv run --locked manage.py migrate
- name: Run Django checks
run: uv run manage.py check
- name: Run tests with coverage
run: uv run pytest -v --cov bbs --cov-report term --cov-report xml:coverage.xml
- name: Upload coverage reports
uses: actions/upload-artifact@v3
with:
name: coverage-report
path: coverage.xml
-1
View File
@@ -15,4 +15,3 @@ static/assets
static/index.html static/index.html
.python-version .python-version
.env .env
.coverage
+46
View File
@@ -0,0 +1,46 @@
variables:
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
default:
image: nikolaik/python-nodejs:python3.8-nodejs16
cache:
paths:
- .cache/pip
- .venv
before_script:
- poetry config virtualenvs.in-project true
- poetry install
django-checks:
stage: build
script:
- poetry run python manage.py migrate
- poetry run python manage.py check
tests:
stage: test
script:
- poetry run bin/test
lint:
stage: test
script:
- poetry run bin/lint
deploy:
stage: deploy
before_script:
# Install ssh if not already
- 'command -v ssh-agent >/dev/null || ( apt-get update -y && apt-get install openssh-client -y )'
- eval $(ssh-agent -s)
# Make SSH key available
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
# Add known host keys
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- ssh gitdeploy@dreamcast.neuf.no "$(< bin/deploy)"
only:
- main
+18
View File
@@ -0,0 +1,18 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: local
hooks:
- id: black
name: black
entry: black
args: [--check]
language: system
require_serial: true
types: [python]
- id: isort
name: isort
entry: isort
args: [--check]
language: system
types: [python]
-8
View File
@@ -1,8 +0,0 @@
extends: default
rules:
document-start: disable
line-length:
max: 120
truthy:
allowed-values: ["true", "false", "on", "off"]
-44
View File
@@ -1,44 +0,0 @@
FROM node:26-trixie-slim AS frontend
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
# Django serves its routes under /api/ in the image (BBS_API_PREFIX in settings_prod.py)
ENV VITE_API_URL=/api
RUN npm run build
FROM python:3.14-slim-trixie AS build
COPY --from=ghcr.io/astral-sh/uv:0.13.4 /uv /bin/uv
# mysqlclient ships no wheels; it stays until no database is copied from MySQL
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential pkg-config default-libmysqlclient-dev \
&& rm -rf /var/lib/apt/lists/*
# The venv lives outside /app so the final stage copies it as a layer of its own, which
# only a dependency change replaces.
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_PROJECT_ENVIRONMENT=/venv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY . .
# Vite writes into static/, beside the files already tracked there
COPY --from=frontend /src/static/ static/
RUN DJANGO_SETTINGS_MODULE=bbs.settings_prod SECRET_KEY=collectstatic /venv/bin/python manage.py collectstatic --noinput
FROM python:3.14-slim-trixie
RUN apt-get update \
&& apt-get install -y --no-install-recommends libmariadb3 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --uid 932 --home-dir /app --no-create-home bbs \
&& mkdir /data && chown bbs:bbs /data
COPY --from=build /venv /venv
COPY --from=build /app /app
WORKDIR /app
ARG SENTRY_RELEASE=N/A
ENV PATH=/venv/bin:$PATH \
DJANGO_SETTINGS_MODULE=bbs.settings_prod \
PYTHONUNBUFFERED=1 \
SENTRY_RELEASE=$SENTRY_RELEASE
USER bbs
VOLUME /data
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "--access-logfile", "-", "bbs.wsgi"]
+20 -36
View File
@@ -1,52 +1,36 @@
# Getting started # Getting started
Before starting, make sure you have [mise](https://mise.jdx.dev) installed. It manages Python, Node, uv and prek. Before starting, make sure you have a working python and nodejs setup with pyenv and nvm.
```bash ```bash
mise install apt-get install libmysqlclient-dev
prek install poetry install
uv run manage.py migrate poetry shell
pre-commit install
python manage.py migrate
bin/run
cp .env-default .env cp .env-default .env
uv run manage.py seed_demo_data
# Optional: create an admin login # in another terminal
uv run manage.py createsuperuser cd frontend
npm install
npm start
``` ```
Start the backend and frontend in separate terminals from the project root:
```bash
mise run server
```
```bash
mise run frontend-run
```
Vite prints the frontend URL when it starts (normally <http://localhost:5173/>; if that port is busy, it selects another). Django runs at <http://localhost:3000/>; open the admin directly at <http://localhost:3000/admin/> or through the frontend dev proxy at the printed Vite URL followed by `/admin/`. The GraphQL API is at <http://localhost:3000/graphql/>; the frontend uses Vite's proxy for development API requests.
To test the frontend against a different backend, set `VITE_DEV_API_PROXY` when starting Vite, for example `VITE_DEV_API_PROXY=https://bbs.neuf.no mise run frontend-run`. This affects the development proxy only; use caution with a live backend, since app changes and submissions may modify production data.
For more realistic statistics, `uv run manage.py seed_demo_data --size medium` also generates random users, orders and deposits spread over time (`small`, `medium` or `large`; add `--seed N` for reproducible output). It adds more data on every run.
To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` for that command; the command only creates a local `admin` superuser if one does not already exist.
## Testing
```bash
mise run test
```
## Deployment
Every push to main that passes the tests is built into `git.neuf.no/edb/bbs:<sha>` and deployed to amiga by `.gitea/workflows/deploy.yml`, while the repository variable `DEPLOY_AMIGA` is `true`. The image runs with `bbs.settings_prod`: SQLite and uploads in `/data`, Django under `/api/` and the frontend at the root. The stacks on amiga, one per tenant, are the `bbs` role in [edb-infra](https://git.neuf.no/edb/edb-infra).
## References ## References
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node> - We use JS code generation to work with queries/mutations in our graphql API: https://github.com/dotansimha/graphql-typed-document-node
## TODO
- improve product listing (light purple background)
- Stats: products over time
- inventory views
## TODO-old ## TODO-old
- Larger pictures - Larger pictures
- Compact GUI for max 768 vertical pixels - Compact GUI for max 768 vertical pixels
- Search right of in title - Search right of in title
Regular → Executable
View File
File mode changed.
+9 -9
View File
@@ -1,22 +1,22 @@
from django.contrib import admin from django.contrib import admin
from bbs.models import ( from bbs.models import *
InventoryTransaction,
Order,
OrderLine,
Product,
Transaction,
UserProfile,
)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin):
pass pass
class CardAdmin(admin.ModelAdmin):
list_display = ("name", "user", "card_id", "active")
list_filter = ("active",)
search_fields = ("user__username", "name")
admin.site.register(Product, ProductAdmin)
admin.site.register(InventoryTransaction) admin.site.register(InventoryTransaction)
admin.site.register(Order) admin.site.register(Order)
admin.site.register(OrderLine) admin.site.register(OrderLine)
admin.site.register(Transaction) admin.site.register(Transaction)
admin.site.register(UserProfile) admin.site.register(UserProfile)
admin.site.register(Card, CardAdmin)
+3 -1
View File
@@ -1,3 +1,5 @@
from typing import List
import strawberry import strawberry
@@ -17,6 +19,6 @@ class FieldError(Error):
@strawberry.type @strawberry.type
class FormErrors: class FormErrors:
fields: list[FieldError] fields: List[FieldError]
message: str = "" message: str = ""
error: bool = True error: bool = True
-63
View File
@@ -1,63 +0,0 @@
from decimal import Decimal
from typing import Annotated
import strawberry
from django.db import transaction
from strawberry import ID, Info
from bbs import models
from bbs.api.errors import FieldError, FormErrors
from bbs.api.types.inventory import InventoryTransaction
@strawberry.input
class InventoryTransactionInput:
product_id: ID
amount: int
unit_price: Decimal
comment: str = ""
@strawberry.type
class CreateInventoryTransactionSuccess:
inventory_transaction: InventoryTransaction
CreateInventoryTransactionResponse = Annotated[
CreateInventoryTransactionSuccess | FormErrors,
strawberry.union("CreateInventoryTransactionResponse"),
]
@strawberry.mutation
@transaction.atomic
def create_inventory_transaction(
info: Info,
data: InventoryTransactionInput,
) -> CreateInventoryTransactionResponse:
user = info.context.request.user
if not user.is_authenticated:
message = "Logg inn for å registrere varetransaksjoner."
return FormErrors(message=message, fields=[FieldError(field="user", message=message)])
try:
product = models.Product.objects.get(pk=data.product_id, active=True)
except models.Product.DoesNotExist, ValueError:
message = "Velg et aktivt produkt."
return FormErrors(message=message, fields=[FieldError(field="product_id", message=message)])
product.inventory_amount += data.amount
product.save()
inventory_transaction = models.InventoryTransaction.objects.create(
user=user,
product=product,
amount=data.amount,
unit_price=data.unit_price,
comment=data.comment,
)
return CreateInventoryTransactionSuccess(inventory_transaction=inventory_transaction)
@strawberry.type
class InventoryMutations:
create_inventory_transaction = create_inventory_transaction
+14 -20
View File
@@ -1,4 +1,4 @@
from typing import Annotated, Union from typing import List
import strawberry import strawberry
from django.conf import settings from django.conf import settings
@@ -27,22 +27,20 @@ class CreateOrderSuccess:
@strawberry.type @strawberry.type
class InsufficientFunds(Error): # noqa: N818 class InsufficientFunds(Error):
amount_lacking: float amount_lacking: float
CreateOrderResponse = Annotated[ CreateOrderResponse = strawberry.union("CreateOrderResponse", [CreateOrderSuccess, InsufficientFunds, FormErrors])
Union[CreateOrderSuccess, InsufficientFunds, FormErrors], strawberry.union("CreateOrderResponse") # noqa: UP007
]
@strawberry.mutation @strawberry.mutation
@transaction.atomic @transaction.atomic
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: # noqa: FBT001 def create_order(customer_id: ID, order_lines: List[OrderLineInput], is_external: bool = False) -> CreateOrderResponse:
# clean and validate # clean and validate
try: try:
user = UserModel.objects.get(pk=customer_id) user = UserModel.objects.get(pk=customer_id)
except UserModel.DoesNotExist, ValueError: except (UserModel.DoesNotExist, ValueError):
user = None user = None
if user is None and not is_external: if user is None and not is_external:
@@ -65,17 +63,15 @@ def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external
price = product.sale_price_ext if is_external else product.sale_price_int price = product.sale_price_ext if is_external else product.sale_price_int
ols.append(models.OrderLine(product=product, amount=ol.amount, unit_price=price)) ols.append(models.OrderLine(product=product, amount=ol.amount, unit_price=price))
order_sum += ol.amount * price order_sum += ol.amount * price
product.inventory_amount -= ol.amount
product.save()
if not is_external and user.profile.balance < order_sum: if not is_external and user.profile.balance <= order_sum:
return InsufficientFunds( return InsufficientFunds(
message="Du har ikke nok penger på bok.", message="Du har ikke nok penger på bok.", amount_lacking=abs(order_sum - user.profile.balance)
amount_lacking=float(abs(order_sum - user.profile.balance)),
) )
# create # create
for ol in ols:
ol.product.inventory_amount -= ol.amount
ol.product.save()
order = models.Order.objects.create(customer=user, order_sum=order_sum) order = models.Order.objects.create(customer=user, order_sum=order_sum)
for ol in ols: for ol in ols:
ol.order = order ol.order = order
@@ -95,14 +91,12 @@ class CreateDepositSuccess:
@strawberry.type @strawberry.type
class AboveMaxSaldo(Error): # noqa: N818 class AboveMaxSaldo(Error):
max_saldo: float max_saldo: float
above: float above: float
CreateDepositResponse = Annotated[ CreateDepositResponse = strawberry.union("CreateDepositResponse", [CreateDepositSuccess, AboveMaxSaldo, FormErrors])
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
]
@strawberry.mutation @strawberry.mutation
@@ -111,7 +105,7 @@ def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
# clean and validate # clean and validate
try: try:
user = UserModel.objects.get(pk=user_id) user = UserModel.objects.get(pk=user_id)
except UserModel.DoesNotExist, ValueError: except (UserModel.DoesNotExist, ValueError):
message = "Du har ikke valgt hvem som sette inn penger." message = "Du har ikke valgt hvem som sette inn penger."
return FormErrors( return FormErrors(
message=message, message=message,
@@ -124,11 +118,11 @@ def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
fields=[FieldError(field="amount", message=message)], fields=[FieldError(field="amount", message=message)],
) )
# 1337 is special <3 # 1337 is special <3
if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337: # noqa: PLR2004 if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337:
return AboveMaxSaldo( return AboveMaxSaldo(
message="Du prøver å sette inn for mye penger", message="Du prøver å sette inn for mye penger",
max_saldo=settings.BBS_SALDO_MAX, max_saldo=settings.BBS_SALDO_MAX,
above=float(amount + user.profile.balance - settings.BBS_SALDO_MAX), above=amount + user.profile.balance - settings.BBS_SALDO_MAX,
) )
trans = models.Transaction.objects.create(user=user, amount=amount) trans = models.Transaction.objects.create(user=user, amount=amount)
+6 -7
View File
@@ -1,12 +1,14 @@
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth import get_user_model
from django.db import transaction from django.db import transaction
from strawberry import auto from strawberry_django import auto
from bbs import models from bbs import models
from bbs.api.types.users import User from bbs.api.types.users import User
DjangoUser = get_user_model()
@strawberry_django.input(DjangoUser) @strawberry_django.input(DjangoUser)
class UserInput: class UserInput:
@@ -17,12 +19,9 @@ class UserInput:
@strawberry.mutation @strawberry.mutation
@transaction.atomic @transaction.atomic
def register_mutation(input: UserInput) -> User: # noqa: A002 def register_mutation(input: UserInput) -> User:
user = DjangoUser.objects.create_user( user = DjangoUser.objects.create_user(
first_name=input.first_name, first_name=input.first_name, last_name=input.last_name, email=input.email, username=input.email
last_name=input.last_name,
email=input.email,
username=input.email,
) )
models.UserProfile.objects.create(user=user) models.UserProfile.objects.create(user=user)
return user return user
+3 -97
View File
@@ -1,105 +1,11 @@
from datetime import datetime from typing import List
from decimal import Decimal
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.db.models import F, Sum
from django.utils import timezone
from bbs import models from bbs.api.types.inventory import Transaction, TransactionOrdering
from bbs.api.types.inventory import (
InventoryReport,
InventoryReportProduct,
InventoryTransaction,
InventoryTransactionOrdering,
Transaction,
TransactionOrdering,
)
@strawberry.type @strawberry.type
class InventoryQueries: class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering) transaction_list: List[Transaction] = strawberry_django.field(pagination=True, order=TransactionOrdering)
inventory_transaction_list: list[InventoryTransaction] = strawberry_django.field(
pagination=True,
ordering=InventoryTransactionOrdering,
)
@strawberry.field
def inventory_report(self, start_time: datetime, end_time: datetime) -> InventoryReport:
if timezone.is_naive(start_time):
start_time = timezone.make_aware(start_time)
if timezone.is_naive(end_time):
end_time = timezone.make_aware(end_time)
if start_time > end_time:
raise ValueError("The report start date must not be after the end date.")
purchases = (
models.InventoryTransaction.objects.filter(created__range=(start_time, end_time))
.values("product_id", "product__name")
.annotate(units=Sum("amount"), value=Sum(F("amount") * F("unit_price")))
)
sales = (
models.OrderLine.objects.filter(order__created__range=(start_time, end_time))
.values("product_id", "product__name")
.annotate(units=Sum("amount"), value=Sum(F("amount") * F("unit_price")))
)
product_totals = {}
for product in purchases:
product_totals[product["product_id"]] = {
"product_id": product["product_id"],
"product_name": product["product__name"],
"purchase_units": product["units"] or 0,
"purchase_value": product["value"] or Decimal(0),
"sales_units": 0,
"sales_value": Decimal(0),
}
for product in sales:
product_data = product_totals.setdefault(
product["product_id"],
{
"product_id": product["product_id"],
"product_name": product["product__name"],
"purchase_units": 0,
"purchase_value": Decimal(0),
"sales_units": 0,
"sales_value": Decimal(0),
},
)
product_data["sales_units"] = product["units"] or 0
product_data["sales_value"] = product["value"] or Decimal(0)
product_data = sorted(product_totals.values(), key=lambda product: product["product_name"])
inventory_purchases = sum((product["purchase_value"] for product in product_data), Decimal(0))
product_sales = sum((product["sales_value"] for product in product_data), Decimal(0))
deposits = models.Transaction.objects.filter(created__range=(start_time, end_time)).aggregate(
total=Sum("amount")
)["total"] or Decimal(0)
order_purchases = models.Order.objects.filter(created__range=(start_time, end_time)).aggregate(
total=Sum("order_sum")
)["total"] or Decimal(0)
deposits_start = models.Transaction.objects.filter(created__lt=start_time).aggregate(total=Sum("amount"))[
"total"
] or Decimal(0)
orders_start = models.Order.objects.filter(created__lt=start_time).aggregate(total=Sum("order_sum"))[
"total"
] or Decimal(0)
deposits_end = models.Transaction.objects.filter(created__lte=end_time).aggregate(total=Sum("amount"))[
"total"
] or Decimal(0)
orders_end = models.Order.objects.filter(created__lte=end_time).aggregate(total=Sum("order_sum"))[
"total"
] or Decimal(0)
return InventoryReport(
products=[InventoryReportProduct(**product) for product in product_data],
inventory_purchases=inventory_purchases,
product_sales=product_sales,
deposits=deposits,
order_purchases=order_purchases,
balance_at_start=deposits_start - orders_start,
balance_at_end=deposits_end - orders_end,
)
+8 -17
View File
@@ -1,23 +1,17 @@
from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
from bbs import models from bbs import models
from bbs.api.types.shop import ( from bbs.api.types.shop import Order, OrderOrdering, OrderStats, OrderStatsByTime, Point, Product, ProductOrderStat
Order,
OrderOrdering,
OrderStats,
OrderStatsByTime,
Point,
Product,
ProductOrderStat,
)
@strawberry.type @strawberry.type
class ShopQueries: class ShopQueries:
all_products: list[Product] = strawberry_django.field() all_products: List[Product] = strawberry_django.field()
all_orders: list[Order] = strawberry_django.field() all_orders: List[Order] = strawberry_django.field()
order_list: list[Order] = strawberry_django.field(pagination=True, ordering=OrderOrdering) order_list: List[Order] = strawberry_django.field(pagination=True, order=OrderOrdering)
@strawberry.field @strawberry.field
def order_stats(self) -> OrderStats: def order_stats(self) -> OrderStats:
@@ -32,11 +26,8 @@ class ShopQueries:
) )
@strawberry.field @strawberry.field
def product_stats(self) -> list[ProductOrderStat]: def product_stats(self) -> List[ProductOrderStat]:
return [ return [
ProductOrderStat( ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]])
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in models.OrderLine.objects.product_order_stats().as_series() for stat in models.OrderLine.objects.product_order_stats().as_series()
] ]
+4 -3
View File
@@ -1,4 +1,7 @@
from typing import List
import strawberry import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth.models import User as DjangoUser
from strawberry import ID from strawberry import ID
@@ -7,9 +10,7 @@ from bbs.api.types.users import User
@strawberry.type @strawberry.type
class UsersQueries: class UsersQueries:
@strawberry.field all_users: List[User] = strawberry_django.field()
def all_users(self) -> list[User]:
return DjangoUser.objects.filter(profile__isnull=False)
@strawberry.field @strawberry.field
def user(self, user_id: ID) -> User: def user(self, user_id: ID) -> User:
+4 -6
View File
@@ -1,8 +1,7 @@
import strawberry import strawberry
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import SchemaExtension from strawberry.extensions import Extension
from bbs.api.mutations.inventory import InventoryMutations
from bbs.api.mutations.shop import ShopMutations from bbs.api.mutations.shop import ShopMutations
from bbs.api.mutations.users import UsersMutations from bbs.api.mutations.users import UsersMutations
from bbs.api.queries.inventory import InventoryQueries from bbs.api.queries.inventory import InventoryQueries
@@ -16,13 +15,12 @@ class Query(InventoryQueries, ShopQueries, UsersQueries):
@strawberry.type @strawberry.type
class Mutation(InventoryMutations, ShopMutations, UsersMutations): class Mutation(ShopMutations, UsersMutations):
pass pass
class HandleNotFound(SchemaExtension): class HandleNotFound(Extension):
def on_operation(self): def on_request_end(self):
yield
result = self.execution_context.result result = self.execution_context.result
if result.errors: if result.errors:
for error in result.errors: for error in result.errors:
+4 -48
View File
@@ -1,18 +1,11 @@
from decimal import Decimal
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django import strawberry_django
from strawberry import ID, auto from strawberry_django import auto
from bbs import models from bbs import models
from bbs.api.types.users import User
if TYPE_CHECKING:
from .shop import Product
from .users import User
@strawberry_django.order_type(models.Transaction) @strawberry_django.ordering.order(models.Transaction)
class TransactionOrdering: class TransactionOrdering:
created: auto created: auto
@@ -20,43 +13,6 @@ class TransactionOrdering:
@strawberry_django.type(models.Transaction) @strawberry_django.type(models.Transaction)
class Transaction: class Transaction:
id: auto id: auto
user: Annotated["User", strawberry.lazy(".users")] user: "User"
amount: auto amount: auto
created: auto created: auto
@strawberry_django.type(models.InventoryTransaction)
class InventoryTransaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
product: Annotated["Product", strawberry.lazy(".shop")]
amount: auto
unit_price: auto
comment: auto
created: auto
@strawberry_django.order_type(models.InventoryTransaction)
class InventoryTransactionOrdering:
created: auto
@strawberry.type
class InventoryReportProduct:
product_id: ID
product_name: str
purchase_units: int
purchase_value: Decimal
sales_units: int
sales_value: Decimal
@strawberry.type
class InventoryReport:
products: list[InventoryReportProduct]
inventory_purchases: Decimal
product_sales: Decimal
deposits: Decimal
order_purchases: Decimal
balance_at_start: Decimal
balance_at_end: Decimal
+13 -22
View File
@@ -1,18 +1,14 @@
from decimal import Decimal from typing import List
from typing import TYPE_CHECKING, Annotated
import strawberry import strawberry
import strawberry_django import strawberry_django
from strawberry import auto from strawberry_django import auto
from strawberry.scalars import JSON
from bbs import models from bbs import models
from bbs.api.types.utils import JSONScalar
if TYPE_CHECKING:
from .users import User
@strawberry_django.filter_type(models.Product) @strawberry_django.filters.filter(models.Product)
class ProductFilter: class ProductFilter:
id: auto id: auto
active: auto active: auto
@@ -25,16 +21,11 @@ class Product:
image_url: str image_url: str
sale_price_int: auto sale_price_int: auto
sale_price_ext: auto sale_price_ext: auto
active: auto
inventory_amount: auto
updated: auto
wholesale_unit_price: Decimal
wholesale_value: Decimal
# FIXME: stricter types # FIXME: stricter types
user_counts: JSON user_counts: JSONScalar
@strawberry_django.order_type(models.Order) @strawberry_django.ordering.order(models.Order)
class OrderOrdering: class OrderOrdering:
created: auto created: auto
@@ -42,10 +33,10 @@ class OrderOrdering:
@strawberry_django.type(models.Order) @strawberry_django.type(models.Order)
class Order: class Order:
id: auto id: auto
customer: Annotated["User", strawberry.lazy(".users")] | None customer: strawberry.LazyType["User", "bbs.api.types.users"]
order_sum: auto order_sum: auto
created: auto created: auto
orderlines: list["OrderLine"] orderlines: List["OrderLine"]
is_external: bool is_external: bool
@@ -66,10 +57,10 @@ class OrderStatsByTime:
@strawberry.type @strawberry.type
class OrderStats: class OrderStats:
yearly: list[OrderStatsByTime] yearly: List[OrderStatsByTime]
monthly: list[OrderStatsByTime] monthly: List[OrderStatsByTime]
daily: list[OrderStatsByTime] daily: List[OrderStatsByTime]
hourly: list[OrderStatsByTime] hourly: List[OrderStatsByTime]
@strawberry.type @strawberry.type
@@ -81,4 +72,4 @@ class Point:
@strawberry.type @strawberry.type
class ProductOrderStat: class ProductOrderStat:
product_name: str product_name: str
data: list[Point] data: List[Point]
+13 -17
View File
@@ -1,20 +1,22 @@
from datetime import datetime from datetime import datetime
from decimal import Decimal from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth import get_user_model
from strawberry import auto from strawberry_django import auto
from bbs import models from bbs import models
from bbs.api.types.shop import Point, ProductOrderStat from bbs.api.types.shop import Point, ProductOrderStat
UserModel = get_user_model()
@strawberry.type @strawberry.type
class TopMonth: class TopMonth:
period: str period: str
count: int count: int
sum: float sum: int
@strawberry_django.type(models.UserProfile) @strawberry_django.type(models.UserProfile)
@@ -23,29 +25,23 @@ class UserProfile:
balance: auto balance: auto
image: auto image: auto
last_purchase_date: datetime last_purchase_date: datetime
order_sum_total: Decimal order_sum_total: float
@strawberry.field() @strawberry.field()
def top_months(self) -> list[TopMonth]: def top_months(self) -> List[TopMonth]:
return [ return [TopMonth(**month) for month in self.top_months()]
TopMonth(period=month["period"], count=month["count"], sum=float(month["sum"]))
for month in self.top_months()
]
@strawberry.field() @strawberry.field()
def product_order_stats(self) -> list[ProductOrderStat]: def product_order_stats(self) -> List[ProductOrderStat]:
return [ return [
ProductOrderStat( ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]])
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in self.product_order_stats().as_series() for stat in self.product_order_stats().as_series()
] ]
@strawberry_django.type(DjangoUser) @strawberry_django.type(UserModel)
class User: class User:
id: auto id: auto
first_name: auto first_name: auto
last_name: auto last_name: auto
profile: UserProfile | None profile: UserProfile
+11
View File
@@ -0,0 +1,11 @@
import json
from typing import Any, NewType
import strawberry
JSONScalar = strawberry.scalar(
NewType("JSONScalar", Any),
serialize=lambda v: v,
parse_value=lambda v: json.loads(v),
description="The GenericScalar scalar type represents a generic GraphQL scalar value that could be: List or Object.",
)
+2 -10
View File
@@ -2,21 +2,13 @@ from django import forms
from bbs.models import InventoryTransaction, Product from bbs.models import InventoryTransaction, Product
MAX_DIGITS = 9
DECIMAL_PLACES = 1
class InventoryTransactionForm(forms.ModelForm): class InventoryTransactionForm(forms.ModelForm):
unit_price = forms.DecimalField( unit_price = forms.FloatField(required=True, localize=True)
max_digits=MAX_DIGITS,
decimal_places=DECIMAL_PLACES,
required=True,
localize=True,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
# Only show active products # Only show active products
super().__init__(*args, **kwargs) super(InventoryTransactionForm, self).__init__(*args, **kwargs)
self.fields["product"].queryset = Product.objects.filter(active=True) self.fields["product"].queryset = Product.objects.filter(active=True)
class Meta: class Meta:
+2 -2
View File
@@ -13,11 +13,11 @@ class Command(BaseCommand):
def add_arguments(self, parser: CommandParser): def add_arguments(self, parser: CommandParser):
parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout") parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout")
def handle(self, *args, filename=None, **options): # noqa: ARG002 def handle(self, *args, filename=None, **options):
schema_output = print_schema(schema_symbol) schema_output = print_schema(schema_symbol)
if filename != "-": if filename != "-":
with Path(settings.BASE_DIR, filename).open("w+") as fp: with Path(settings.BASE_DIR, filename).open("w+") as fp:
fp.write(schema_output) fp.write(schema_output)
self.stdout.write(self.style.SUCCESS("OK")) self.stdout.write(self.style.SUCCESS("OK"))
else: else:
pass print(schema_output)
+4 -3
View File
@@ -10,10 +10,11 @@ class Command(BaseCommand):
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true") parser.add_argument("--force", action="store_true")
def handle(self, *args, dry_run=False, force=False, **options): # noqa: ARG002 def handle(self, *args, dry_run=False, force=False, **options):
for profile in UserProfile.objects.all().order_by("id"): for profile in UserProfile.objects.all().order_by("id"):
if dry_run: if dry_run:
pass print(profile.user.email, profile.image_url())
elif force or not profile.image: else:
if force or not profile.image:
profile.image = profile.image_url() profile.image = profile.image_url()
profile.save() profile.save()
-388
View File
@@ -1,388 +0,0 @@
import os
import random
from datetime import datetime, time, timedelta
from decimal import Decimal
from urllib.parse import quote
from django.contrib.auth import get_user_model
from django.core.management import BaseCommand, CommandParser
from django.db import transaction as db_transaction
from django.db.models import Sum
from django.utils import timezone
from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction, UserProfile
# Some users go by first name only, which the frontend handles differently.
DEMO_USERS = (
("demo-ada", "Ada", "Nord", "ada@example.test", Decimal("300.0")),
("demo-bjørn", "Bjørn", "Berg", "bjorn@example.test", Decimal("250.0")),
("demo-casey", "Casey", "Sol", "casey@example.test", Decimal("200.0")),
("demo-kari", "Kari", "Nordmann", "kari@example.test", Decimal("400.0")),
("demo-ole-martin", "Ole-Martin", "Ødegård", "ole-martin@example.test", Decimal("300.0")),
("demo-solveig", "Solveig", "", "solveig@example.test", Decimal("250.0")),
("demo-trygve", "Trygve", "Halvorsen", "trygve@example.test", Decimal("200.0")),
("demo-ingrid", "Ingrid", "Bakken", "ingrid@example.test", Decimal("350.0")),
("demo-håkon", "Håkon", "", "hakon@example.test", Decimal("150.0")),
)
# name, volume_liter, alcohol_percent, sale_price_int, sale_price_ext
BEERS = (
("Frydenlund Fatøl", "0.5", "4.5", "25", "35"),
("Ringnes Pilsner", "0.5", "4.7", "25", "35"),
("Nøgne Ø India Pale Ale", "0.5", "7.5", "45", "60"),
("Lervig Lucky Jack", "0.33", "4.7", "40", "55"),
("Munkholm Alkoholfri", "0.5", "0.5", "20", "28"),
("Haandbryggeriet Mjød", "0.5", "8.0", "55", "70"),
("Hansa Bayer", "0.5", "4.6", "27", "37"),
("Aass Bayer", "0.5", "4.5", "28", "38"),
("Mack Pilsner", "0.5", "4.7", "28", "38"),
("Dahls Pils", "0.5", "4.5", "25", "35"),
("Borg Bryggeri Pils", "0.5", "4.7", "30", "40"),
("Grans Bare Pils", "0.5", "4.7", "24", "34"),
("CB Pilsner", "0.5", "4.7", "25", "35"),
("7 Fjell Hopyards IPA", "0.33", "6.5", "48", "63"),
("Tuborg Grønn", "0.5", "4.6", "28", "38"),
("Carlsberg Pilsner", "0.5", "4.6", "28", "38"),
("Frydenlund Bayer", "0.5", "4.5", "26", "36"),
("Berentsens Julebrygg", "0.5", "4.7", "32", "42"),
("Ægir Sumble", "0.5", "4.7", "42", "57"),
("Kinn Vestkyst", "0.5", "5.2", "46", "61"),
("Trondhjem Pilsner", "0.5", "4.7", "30", "40"),
("Nøgne Ø Pale Ale", "0.5", "4.5", "44", "59"),
("Voss Bryggeri Pils", "0.5", "4.7", "38", "50"),
("Lervig Pils", "0.33", "4.7", "30", "40"),
)
DEMO_PRODUCTS = BEERS[:6]
DEMO_STOCK = 30
DESCRIPTIONS = (
"Klassisk norsk pils med frisk og rund smak.",
"Fyldig og maltrik, perfekt på en kald kveld.",
"Lett og forfriskende, godt selskap til maten.",
"Humlerik og bitter, for den som vil ha litt mer.",
"Tradisjonsrik bryggekunst fra ærverdig bryggeri.",
)
# user index, days ago, (product name, amount) lines
DEMO_ORDERS = (
(0, 26, (("Ringnes Pilsner", 2), ("Munkholm Alkoholfri", 1))),
(1, 21, (("Frydenlund Fatøl", 2), ("Lervig Lucky Jack", 1))),
(2, 13, (("Nøgne Ø India Pale Ale", 1),)),
(3, 24, (("Frydenlund Fatøl", 2), ("Munkholm Alkoholfri", 1))),
(4, 19, (("Nøgne Ø India Pale Ale", 2),)),
(5, 15, (("Ringnes Pilsner", 1), ("Lervig Lucky Jack", 1))),
(6, 11, (("Haandbryggeriet Mjød", 1),)),
(7, 8, (("Lervig Lucky Jack", 2), ("Frydenlund Fatøl", 1))),
(3, 4, (("Ringnes Pilsner", 1), ("Frydenlund Fatøl", 1))),
(8, 2, (("Munkholm Alkoholfri", 3),)),
(4, 1, (("Frydenlund Fatøl", 2),)),
)
# Extra random users for --size
FIRST_NAMES = (
"Ola", "Kari", "Per", "Anne", "Lars", "Ingrid", "Bjørn", "Marit", "Geir", "Hanne", "Knut", "Liv",
"Nils", "Solveig", "Erik", "Astrid", "Håkon", "Sigrid", "Trygve", "Randi", "Espen", "Tone", "Magnus",
"Silje", "Henrik", "Maren", "Sander", "Ida", "Jonas", "Emma", "Mathias", "Nora", "Tobias", "Thea",
"Vegard", "Kristin", "Ole-Martin", "Anne-Lise", "Frode", "Gunnhild",
) # fmt: skip
LAST_NAMES = (
"Hansen", "Johansen", "Olsen", "Larsen", "Andersen", "Pedersen", "Nilsen", "Kristiansen", "Jensen",
"Karlsen", "Berg", "Haugen", "Hagen", "Johnsen", "Andreassen", "Jakobsen", "Dahl", "Lund", "Halvorsen",
"Eriksen", "Bakken", "Moen", "Strand", "Ødegård",
) # fmt: skip
SIZE_PRESETS = {
"small": {"users": 6, "orders": 40, "days": 30},
"medium": {"users": 15, "orders": 300, "days": 180},
"large": {"users": 40, "orders": 1500, "days": 365},
}
FIRST_NAME_ONLY_PROB = 0.35 # users registered with just a first name
EXTERNAL_ORDER_PROB = 0.10 # orders with no customer (paid in cash)
WITHDRAWAL_PROB = 0.10 # users who have had money paid back out
def avatar_url(email: str) -> str:
return f"https://i.pravatar.cc/150?u={quote(email)}"
def ascii_name(name: str) -> str:
return name.lower().replace("ø", "o").replace("å", "a").replace("æ", "ae")
class Command(BaseCommand):
help = "Create repeatable demo users, products, deposits, and orders for local testing."
def add_arguments(self, parser: CommandParser):
parser.add_argument(
"--size",
choices=sorted(SIZE_PRESETS),
help="Also generate random users, orders and deposits on top of the demo data. Adds more on every run.",
)
parser.add_argument("--users", type=int, help="Number of random users (overrides --size).")
parser.add_argument("--orders", type=int, help="Number of random orders (overrides --size).")
parser.add_argument("--days", type=int, help="Spread random activity over this many past days.")
parser.add_argument("--seed", type=int, help="Seed for the random data, for reproducible output.")
def handle(self, *args, **options): # noqa: ARG002
users = self.seed_users()
products = self.seed_products(users[0][0])
self.seed_deposits(users)
self.seed_orders(users, products)
self.update_balances(users)
self.create_admin()
if options["size"]:
preset = SIZE_PRESETS[options["size"]]
generator = RandomData(
random.Random(options["seed"]), # noqa: S311
days=options["days"] or preset["days"],
)
with db_transaction.atomic():
generator.run(
n_users=options["users"] or preset["users"],
n_orders=options["orders"] or preset["orders"],
)
self.stdout.write(
f"Generated {generator.n_users} random users and {generator.n_orders} random orders "
f"over {generator.days} days.",
)
self.stdout.write(self.style.SUCCESS("Demo data is ready."))
if not os.environ.get("BBS_SEED_ADMIN_PASSWORD"):
self.stdout.write("Create an admin login with: uv run manage.py createsuperuser")
@staticmethod
def seed_users():
user_model = get_user_model()
users = []
for username, first_name, last_name, email, deposit_amount in DEMO_USERS:
user, _ = user_model.objects.get_or_create(
username=username,
defaults={"first_name": first_name, "last_name": last_name, "email": email},
)
UserProfile.objects.get_or_create(user=user, defaults={"image": avatar_url(email)})
users.append((user, deposit_amount))
return users
@staticmethod
def seed_products(stock_user):
products = {}
for index, (name, volume, alcohol, sale_price_int, sale_price_ext) in enumerate(DEMO_PRODUCTS):
product, created = Product.objects.get_or_create(
name=name,
defaults={
"sale_price_int": Decimal(sale_price_int),
"sale_price_ext": Decimal(sale_price_ext),
"volume_liter": Decimal(volume),
"alcohol_percent": Decimal(alcohol),
"inventory_amount": DEMO_STOCK,
"description": DESCRIPTIONS[index % len(DESCRIPTIONS)],
},
)
products[name] = product
if created:
InventoryTransaction.objects.get_or_create(
user=stock_user,
product=product,
comment="Demo opening stock",
defaults={"amount": DEMO_STOCK, "unit_price": Decimal(sale_price_int) * Decimal("0.6")},
)
return products
def seed_deposits(self, users):
for index, (user, deposit_amount) in enumerate(users):
created_at = self.timestamp(days_ago=32 + index)
deposit, created = Transaction.objects.get_or_create(
user=user,
created=created_at,
defaults={"amount": deposit_amount},
)
if created:
deposit.created = created_at
deposit.save(update_fields=["created"])
def seed_orders(self, users, products):
for user_index, days_ago, lines in DEMO_ORDERS:
user = users[user_index][0]
created_at = self.timestamp(days_ago=days_ago)
total = sum(
(products[name].sale_price_int * amount for name, amount in lines),
start=Decimal(0),
)
order, created = Order.objects.get_or_create(
customer=user,
created=created_at,
defaults={"order_sum": total},
)
if created:
order.created = created_at
order.save(update_fields=["created"])
for name, amount in lines:
product = products[name]
OrderLine.objects.create(
order=order,
product=product,
amount=amount,
unit_price=product.sale_price_int,
)
product.inventory_amount -= amount
product.save(update_fields=["inventory_amount"])
@staticmethod
def update_balances(users):
for user, _ in users:
deposits = Transaction.objects.filter(user=user).aggregate(total=Sum("amount"))["total"] or Decimal(0)
purchases = Order.objects.filter(customer=user).aggregate(total=Sum("order_sum"))["total"] or Decimal(0)
profile = user.profile
profile.balance = deposits - purchases
profile.save(update_fields=["balance"])
def create_admin(self):
admin_password = os.environ.get("BBS_SEED_ADMIN_PASSWORD")
if not admin_password:
return
user_model = get_user_model()
if user_model.objects.filter(username="admin").exists():
self.stdout.write("Existing 'admin' account left unchanged.")
else:
user_model.objects.create_superuser(
username="admin",
email="admin@example.test",
password=admin_password,
)
self.stdout.write("Created superuser 'admin'.")
@staticmethod
def timestamp(days_ago: int) -> datetime:
date = timezone.localdate() - timedelta(days=days_ago)
return timezone.make_aware(datetime.combine(date, time(hour=12)))
class RandomData:
"""Random users, restocks, orders and deposits spread over time, for realistic stats."""
def __init__(self, rng: random.Random, days: int):
self.rng = rng
self.days = days
self.end = timezone.now()
self.start = self.end - timedelta(days=days)
self.n_users = 0
self.n_orders = 0
def run(self, n_users: int, n_orders: int):
users = self.create_users(n_users)
products = self.create_products()
self.restock(products, users)
spent = self.create_orders(users, products, n_orders)
self.create_money(users, spent)
def random_time(self):
span = int((self.end - self.start).total_seconds())
return self.start + timedelta(seconds=self.rng.randint(0, max(span, 1)))
@staticmethod
def set_created(obj, created):
# created is auto_now_add, so backdate with an update
type(obj).objects.filter(pk=obj.pk).update(created=created)
def create_users(self, n: int) -> list:
user_model = get_user_model()
users = []
for _ in range(n):
first = self.rng.choice(FIRST_NAMES)
last = "" if self.rng.random() < FIRST_NAME_ONLY_PROB else self.rng.choice(LAST_NAMES)
base = ".".join(ascii_name(part) for part in (first, last) if part)
email = f"{base}@example.test"
suffix = 1
while user_model.objects.filter(username=email).exists():
suffix += 1
email = f"{base}{suffix}@example.test"
user = user_model.objects.create_user(username=email, email=email, first_name=first, last_name=last)
UserProfile.objects.create(user=user, image=avatar_url(email))
users.append(user)
self.n_users = len(users)
return users
def create_products(self) -> list:
products = []
for name, volume, alcohol, sale_price_int, sale_price_ext in BEERS:
product, _ = Product.objects.get_or_create(
name=name,
defaults={
"sale_price_int": Decimal(sale_price_int),
"sale_price_ext": Decimal(sale_price_ext),
"volume_liter": Decimal(volume),
"alcohol_percent": Decimal(alcohol),
"description": self.rng.choice(DESCRIPTIONS),
},
)
products.append(product)
return products
def restock(self, products: list, users: list):
for product in products:
wholesale = (product.sale_price_int * Decimal("0.6")).quantize(Decimal("0.1"))
for _ in range(self.rng.randint(1, 3)):
amount = self.rng.choice([24, 48, 60, 120])
restock = InventoryTransaction.objects.create(
user=self.rng.choice(users),
product=product,
amount=amount,
unit_price=wholesale,
comment="Innkjøp",
)
self.set_created(restock, self.random_time())
product.inventory_amount += amount
product.save(update_fields=["inventory_amount"])
def create_orders(self, users: list, products: list, n_orders: int) -> dict:
spent = {user.pk: Decimal(0) for user in users}
for _ in range(n_orders):
customer = None if self.rng.random() < EXTERNAL_ORDER_PROB else self.rng.choice(users)
# Mostly a single product, occasionally two or three
n_lines = self.rng.choices([1, 2, 3], weights=[60, 30, 10])[0]
lines = []
order_sum = Decimal(0)
for product in self.rng.sample(products, n_lines):
amount = self.rng.choices([1, 2, 3, 6], weights=[55, 30, 10, 5])[0]
unit_price = product.sale_price_int if customer else product.sale_price_ext
lines.append(OrderLine(product=product, amount=amount, unit_price=unit_price))
order_sum += unit_price * amount
product.inventory_amount = max(0, product.inventory_amount - amount)
order = Order.objects.create(customer=customer, order_sum=order_sum)
for line in lines:
line.order = order
OrderLine.objects.bulk_create(lines)
self.set_created(order, self.random_time())
if customer:
spent[customer.pk] += order_sum
for product in products:
product.save(update_fields=["inventory_amount"])
self.n_orders = n_orders
return spent
def create_money(self, users: list, spent: dict):
"""Round deposits covering what each user spent, plus rare withdrawals within the leftover buffer."""
for user in users:
buffer = Decimal(self.rng.choice([0, 50, 100, 200, 300, 400]))
remaining = spent[user.pk] + buffer
net = Decimal(0)
while remaining > 0:
amount = min(Decimal(self.rng.choice([100, 200, 200, 300, 500])), remaining)
deposit = Transaction.objects.create(user=user, amount=amount)
self.set_created(deposit, self.random_time())
net += amount
remaining -= amount
if buffer > 0 and self.rng.random() < WITHDRAWAL_PROB:
withdrawal = Decimal(self.rng.randint(1, int(buffer)))
payout = Transaction.objects.create(user=user, amount=-withdrawal)
self.set_created(payout, self.random_time())
net -= withdrawal
user.profile.balance = net - spent[user.pk]
user.profile.save(update_fields=["balance"])
+2 -1
View File
@@ -3,6 +3,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -179,6 +180,6 @@ class Migration(migrations.Migration):
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name="orderline", name="orderline",
unique_together={("order", "product")}, unique_together=set([("order", "product")]),
), ),
] ]
@@ -1,62 +0,0 @@
# Generated by Django 4.0.2 on 2022-03-05 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="inventorytransaction",
name="amount",
field=models.IntegerField(verbose_name="Antall"),
),
migrations.AlterField(
model_name="inventorytransaction",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="orderline",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="product",
name="alcohol_percent",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="product",
name="inventory_amount",
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name="product",
name="sale_price_ext",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="sale_price_int",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="volume_liter",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="transaction",
name="amount",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="userprofile",
name="balance",
field=models.DecimalField(decimal_places=1, default=0, max_digits=9),
),
]
@@ -1,17 +0,0 @@
# Generated by Django 4.0.2 on 2023-06-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0002_alter_inventorytransaction_amount_and_more"),
]
operations = [
migrations.AlterField(
model_name="order",
name="order_sum",
field=models.DecimalField(db_column="sum", decimal_places=1, max_digits=9),
),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.2.18 on 2026-10-22 23:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0003_alter_order_order_sum"),
]
operations = [
migrations.AlterUniqueTogether(
name="orderline",
unique_together=set(),
),
migrations.AddConstraint(
model_name="orderline",
constraint=models.UniqueConstraint(fields=("order", "product"), name="unique_order_product"),
),
]
+44 -44
View File
@@ -1,7 +1,7 @@
import hashlib # noqa: EXE002 import hashlib
from datetime import UTC, datetime from datetime import datetime
from decimal import Decimal
from itertools import groupby from itertools import groupby
from typing import Type
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@@ -17,8 +17,6 @@ from django.db.models.functions import (
) )
EXTERNAL_USER = "Ekstern" EXTERNAL_USER = "Ekstern"
MAX_DIGITS = 9
DECIMAL_PLACES = 1
def _format_counts(per_user): def _format_counts(per_user):
@@ -38,15 +36,14 @@ def _format_counts(per_user):
class Product(models.Model): class Product(models.Model):
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES) sale_price_int = models.FloatField()
sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES) sale_price_ext = models.FloatField()
description = models.CharField(max_length=256, blank=True) description = models.CharField(max_length=256, blank=True)
volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True) volume_liter = models.FloatField(null=True)
alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True) alcohol_percent = models.FloatField(null=True)
image = models.ImageField(upload_to="products", blank=True) image = models.ImageField(upload_to="products", blank=True)
customer_support = models.CharField(max_length=32, blank=True) customer_support = models.CharField(max_length=32, blank=True)
# FIXME: rename to inventory_quantity inventory_amount = models.FloatField(default=0)
inventory_amount = models.IntegerField(default=0)
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True) updated = models.DateTimeField(auto_now=True)
@@ -86,26 +83,21 @@ class Product(models.Model):
return _format_counts(per_user) return _format_counts(per_user)
def __str__(self): # noqa: DJ012 def __str__(self):
return self.name return self.name
class Meta: # noqa: DJ012 class Meta:
ordering = ["name"] ordering = ["name"]
class OrderManager(models.Manager): class OrderManager(models.Manager):
def count_by_created(self, extract_class: type[Extract]): def count_by_created(self, extract_class: Type[Extract]):
period = extract_class("created") period = extract_class("created")
return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id")) return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id"))
def count_by_created_period(self, period: str): def count_by_created_period(self, period: str):
periods = { periods = {"yearly": ExtractYear, "monthly": ExtractMonth, "daily": ExtractIsoWeekDay, "hourly": ExtractHour}
"yearly": ExtractYear, assert period in periods
"monthly": ExtractMonth,
"daily": ExtractIsoWeekDay,
"hourly": ExtractHour,
}
assert period in periods # noqa: S101
return self.count_by_created(periods[period]) return self.count_by_created(periods[period])
@@ -117,7 +109,7 @@ class Order(models.Model):
blank=True, blank=True,
related_name="orders", related_name="orders",
) )
order_sum = models.DecimalField(db_column="sum", max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES) order_sum = models.FloatField(db_column="sum")
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
objects = OrderManager() objects = OrderManager()
@@ -128,7 +120,7 @@ class Order(models.Model):
def reciept(self): def reciept(self):
return ", ".join([str(ol) for ol in self.orderlines.all()]) return ", ".join([str(ol) for ol in self.orderlines.all()])
def __str__(self): # noqa: DJ012 def __str__(self):
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr" return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
@@ -156,10 +148,8 @@ class OrderLineManager(models.QuerySet):
class OrderLine(models.Model): class OrderLine(models.Model):
order = models.ForeignKey("bbs.Order", on_delete=models.CASCADE, related_name="orderlines") order = models.ForeignKey("bbs.Order", on_delete=models.CASCADE, related_name="orderlines")
product = models.ForeignKey("bbs.Product", on_delete=models.CASCADE, related_name="orderlines") product = models.ForeignKey("bbs.Product", on_delete=models.CASCADE, related_name="orderlines")
# FIXME: rename to quantity
amount = models.IntegerField() amount = models.IntegerField()
# FIXME: rename to price unit_price = models.FloatField(verbose_name="Enhetspris")
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
objects = OrderLineManager.as_manager() objects = OrderLineManager.as_manager()
@@ -167,20 +157,18 @@ class OrderLine(models.Model):
def price(self): def price(self):
return self.amount * self.unit_price return self.amount * self.unit_price
def __str__(self): # noqa: DJ012 def __str__(self):
return f"{self.amount} {self.product} ({self.unit_price} kr per)" return f"{self.amount} {self.product} ({self.unit_price} kr per)"
class Meta: # noqa: DJ012 class Meta:
constraints = [ unique_together = ("order", "product")
models.UniqueConstraint(fields=["order", "product"], name="unique_order_product"),
]
class Transaction(models.Model): class Transaction(models.Model):
"""Money""" """Money"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions") user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES) amount = models.FloatField()
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
@@ -201,10 +189,8 @@ class InventoryTransaction(models.Model):
related_name="transactions", related_name="transactions",
verbose_name="Produkt", verbose_name="Produkt",
) )
# FIXME: rename to quantity amount = models.FloatField(verbose_name="Antall")
amount = models.IntegerField(verbose_name="Antall") unit_price = models.FloatField(verbose_name="Enhetspris")
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
comment = models.TextField(verbose_name="Kommentar", blank=True) comment = models.TextField(verbose_name="Kommentar", blank=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
@@ -212,13 +198,13 @@ class InventoryTransaction(models.Model):
def price(self): def price(self):
return self.amount * self.unit_price return self.amount * self.unit_price
def __str__(self): # noqa: DJ012 def __str__(self):
return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)" return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)"
class UserProfile(models.Model): class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile") user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
balance = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, default=0) balance = models.FloatField(default=0.0)
image = models.URLField(blank=True) image = models.URLField(blank=True)
def last_purchase(self): def last_purchase(self):
@@ -226,7 +212,8 @@ class UserProfile(models.Model):
def last_purchase_date(self) -> datetime: def last_purchase_date(self) -> datetime:
last = self.last_purchase() last = self.last_purchase()
return last.created if last else datetime.min.replace(tzinfo=UTC) last_p = last.created if last else datetime.min
return last_p
def top_months(self, limit=5): def top_months(self, limit=5):
return ( return (
@@ -240,21 +227,34 @@ class UserProfile(models.Model):
def product_order_stats(self): def product_order_stats(self):
return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats() return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats()
def order_sum_total(self) -> Decimal: def order_sum_total(self) -> float:
return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0 return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0
def image_url(self) -> str: def image_url(self) -> str:
if self.user.email: if self.user.email:
sha256_hash = hashlib.sha256(self.user.email.lower().strip().encode()).hexdigest() md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest()
return f"https://www.gravatar.com/avatar/{sha256_hash}?d=identicon" return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon"
return "" return ""
def save(self, **kwargs): # noqa: DJ012 def save(self, **kwargs):
if not self.image: if not self.image:
self.image = self.image_url() self.image = self.image_url()
super().save(**kwargs) super().save(**kwargs)
def __str__(self): # noqa: DJ012 def __str__(self):
return f"{self.user}" return f"{self.user}"
class Card(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="cards")
card_id = models.CharField(max_length=32)
name = models.CharField(max_length=64)
# pin = models.IntegerField(max_length=4, blank=True, null=True)
# valid_until = models.DateTimeField(blank=True, null=True)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name}"
+15 -17
View File
@@ -1,13 +1,12 @@
# Django settings for bbs project. # noqa: EXE002 # Django settings for bbs project.
import contextlib
import os import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120 BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DEBUG = True DEBUG = True
ADMINS = ( ADMINS = (
# ('Your Name', 'your_email@example.com'), # noqa: ERA001 # ('Your Name', 'your_email@example.com'),
) )
MANAGERS = ADMINS MANAGERS = ADMINS
@@ -21,18 +20,18 @@ DATABASES = {
TIME_ZONE = "Europe/Oslo" TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = "nb" LANGUAGE_CODE = "nb"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_L10N = True
SITE_ID = 1 SITE_ID = 1
# Media and uploads # Media and uploads
MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118 MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
# Static files # Static files
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118 STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
STATIC_URL = "/static/" STATIC_URL = "/static/"
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118 STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.FileSystemFinder",
@@ -40,13 +39,13 @@ STATICFILES_FINDERS = (
) )
# Make this unique, and don't share it with anybody. # Make this unique, and don't share it with anybody.
SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss" # noqa: S105 SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss"
TEMPLATES = [ TEMPLATES = [
{ {
"BACKEND": "django.template.backends.django.DjangoTemplates", "BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [ "DIRS": [
os.path.join(BASE_DIR, "templates"), # noqa: PTH118 os.path.join(BASE_DIR, "templates"),
], ],
"APP_DIRS": True, "APP_DIRS": True,
"OPTIONS": { "OPTIONS": {
@@ -86,6 +85,7 @@ INSTALLED_APPS = [
"django_extensions", "django_extensions",
"easy_thumbnails", "easy_thumbnails",
"corsheaders", "corsheaders",
"django_rename_app",
] ]
LOCAL_APPS = [ LOCAL_APPS = [
"bbs", "bbs",
@@ -94,14 +94,10 @@ INSTALLED_APPS += LOCAL_APPS
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
# Where Django's own routes (graphql, admin, accounts) are mounted. Production serves
# them under api/ beside the frontend; in development Vite proxies them at the root.
BBS_API_PREFIX = ""
SESSION_COOKIE_AGE = 1209600 # 15 minutes SESSION_COOKIE_AGE = 1209600 # 15 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True SESSION_EXPIRE_AT_BROWSER_CLOSE = True
BBS_SALDO_MAX = 1000 BBS_SALDO_MAX = 600
BBS_LIMIT_DEPOSITS = False BBS_LIMIT_DEPOSITS = False
# Thumbnails # Thumbnails
@@ -145,5 +141,7 @@ CORS_ALLOWED_ORIGINS = [
"http://localhost:3000", "http://localhost:3000",
] ]
with contextlib.suppress(ImportError): try:
from .local_settings import * # noqa: F403 from .local_settings import *
except ImportError:
pass
-68
View File
@@ -1,68 +0,0 @@
"""Settings for the container image, from environment variables."""
import os
from bbs.settings import * # noqa: F403
from bbs.settings import BASE_DIR, MIDDLEWARE
DEBUG = False
SECRET_KEY = os.environ["SECRET_KEY"]
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
CSRF_TRUSTED_ORIGINS = [f"https://{host}" for host in ALLOWED_HOSTS if host]
# Django's routes live under /api/, the frontend at the root
BBS_API_PREFIX = "api/"
LOGIN_URL = "django.contrib.auth.views.login"
DATA_DIR = os.getenv("DATA_DIR", "/data")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(DATA_DIR, "db.sqlite3"), # noqa: PTH118
# WAL lets gunicorn's workers read while one writes; IMMEDIATE makes a writer
# wait for the lock up to `timeout` instead of failing mid-transaction.
"OPTIONS": {
"init_command": "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;",
"transaction_mode": "IMMEDIATE",
"timeout": 20,
},
},
}
MEDIA_ROOT = os.path.join(DATA_DIR, "media") # noqa: PTH118
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
# whitenoise serves Django's static files at STATIC_URL, and the built frontend
# (index.html, assets/) at the root
# SecurityMiddleware applies the TLS settings below
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
*MIDDLEWARE,
]
WHITENOISE_ROOT = os.path.join(BASE_DIR, "static") # noqa: PTH118
WHITENOISE_INDEX_FILE = True
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
# TLS ends at the host's nginx
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
if dsn := os.getenv("SENTRY_DSN"):
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn=dsn,
environment=os.getenv("SENTRY_ENVIRONMENT", "production"),
release=os.getenv("SENTRY_RELEASE", "N/A"),
integrations=[DjangoIntegration()],
send_default_pii=True,
traces_sample_rate=0,
send_client_reports=False,
auto_session_tracking=False,
)
View File
Whitespace-only changes.
-40
View File
@@ -1,40 +0,0 @@
import pytest
from django.contrib.auth.models import User as DjangoUser
from django.test import Client
from strawberry.django.test import GraphQLTestClient
from bbs.models import Order, OrderLine, Product, Transaction, UserProfile
@pytest.fixture
def product(db): # noqa: ARG001
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
@pytest.fixture
def user(db): # noqa: ARG001
u = DjangoUser.objects.create_user(
username="test@example.com",
email="test@example.com",
first_name="Test",
last_name="User",
)
UserProfile.objects.create(user=u, balance=10)
return u
@pytest.fixture
def order(product):
o = Order.objects.create(order_sum=35)
OrderLine.objects.create(order=o, product=product, amount=1, unit_price=35)
return o
@pytest.fixture
def transaction(user):
return Transaction.objects.create(user=user, amount=100)
@pytest.fixture
def gql_client(client: Client):
return GraphQLTestClient(client)
-38
View File
@@ -1,38 +0,0 @@
from bbs.forms import DateRangeForm, InventoryTransactionForm
from bbs.models import Product
def test_inventory_form_filters_active_products(db): # noqa: ARG001
active = Product.objects.create(name="active", sale_price_int=10, sale_price_ext=12, active=True)
inactive = Product.objects.create(name="inactive", sale_price_int=10, sale_price_ext=12, active=False)
product_queryset = InventoryTransactionForm().fields["product"].queryset
assert active in product_queryset
assert inactive not in product_queryset
def test_inventory_form_valid(db): # noqa: ARG001
product = Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
form = InventoryTransactionForm(
data={"product": product.pk, "amount": 5, "unit_price": "12.5", "comment": ""},
)
assert form.is_valid()
def test_inventory_form_invalid_missing_required_fields(db): # noqa: ARG001
form = InventoryTransactionForm(data={})
assert not form.is_valid()
assert "product" in form.errors
def test_date_range_form_allows_missing_dates():
assert DateRangeForm(data={}).is_valid()
def test_date_range_form_accepts_dates():
form = DateRangeForm(data={"start_time": "2026-01-01 00:00:00", "end_time": "2026-01-31 23:59:59"})
assert form.is_valid()
assert form.cleaned_data["start_time"] is not None
-52
View File
@@ -1,52 +0,0 @@
import pytest
from django.core.management import call_command
from bbs.models import UserProfile
def test_profile_images_updates_missing_image(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
call_command("profile_images")
user.profile.refresh_from_db()
assert user.profile.image != ""
def test_profile_images_dry_run_skips_update(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
call_command("profile_images", dry_run=True)
user.profile.refresh_from_db()
assert user.profile.image == ""
def test_profile_images_force_overwrites_existing_image(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="https://example.com/old.jpg")
call_command("profile_images", force=True)
user.profile.refresh_from_db()
assert user.profile.image not in ("", "https://example.com/old.jpg")
@pytest.mark.django_db
def test_export_schema_writes_file(tmp_path, settings):
settings.BASE_DIR = str(tmp_path)
call_command("export_schema")
schema_file = tmp_path / "schema.graphql"
assert schema_file.exists()
assert "type Query" in schema_file.read_text()
@pytest.mark.django_db
def test_export_schema_stdout_does_not_write_file(tmp_path, settings, capsys):
settings.BASE_DIR = str(tmp_path)
call_command("export_schema", filename="-")
assert not (tmp_path / "schema.graphql").exists()
assert capsys.readouterr().out == ""
-313
View File
@@ -1,313 +0,0 @@
import hashlib
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from django.contrib.auth.models import User as DjangoUser
from bbs.models import (
EXTERNAL_USER,
InventoryTransaction,
Order,
OrderLine,
Product,
UserProfile,
_format_counts,
)
# _format_counts
# ==============
def test_format_counts_with_last_name():
rows = [{"order__customer__first_name": "Alice", "order__customer__last_name": "Smith", "count": 3}]
result = _format_counts(rows)
assert result[0]["name"] == "Alice S"
def test_format_counts_no_last_name():
rows = [{"order__customer__first_name": "Bob", "order__customer__last_name": "", "count": 1}]
result = _format_counts(rows)
assert result[0]["name"] == "Bob"
def test_format_counts_external():
rows = [{"order__customer__first_name": None, "order__customer__last_name": None, "count": 2}]
result = _format_counts(rows)
assert result[0]["name"] == EXTERNAL_USER
# Product
# =======
def test_product_str(product):
assert str(product) == "borg"
def test_image_url_without_image(product):
assert product.image_url == ""
def test_image_url_with_image(product):
product.image = "products/test.jpg"
assert product.image_url == product.image.url
def test_wholesale_unit_price_no_inventory(product):
product.inventory_amount = 0
assert product.wholesale_unit_price == 0
def test_wholesale_unit_price_no_transactions(product):
product.inventory_amount = 5
assert product.wholesale_unit_price == 0
def test_wholesale_unit_price_within_batch(user, product):
product.inventory_amount = 5
product.save()
InventoryTransaction.objects.create(user=user, product=product, amount=10, unit_price=Decimal("8.0"))
assert product.wholesale_unit_price == Decimal("8.0")
def test_wholesale_unit_price_exceeds_all_batches(user, product):
product.inventory_amount = 20
product.save()
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=Decimal("8.0"))
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=Decimal("6.0"))
# inventory(20) > total transactions(10), falls back to first transaction's unit_price
assert product.wholesale_unit_price == Decimal("8.0")
def test_wholesale_value(user, product):
product.inventory_amount = 5
product.save()
InventoryTransaction.objects.create(user=user, product=product, amount=10, unit_price=Decimal("8.0"))
assert product.wholesale_value == product.wholesale_unit_price * 5
def test_user_counts(user, product):
o = Order.objects.create(customer=user, order_sum=60)
OrderLine.objects.create(order=o, product=product, amount=2, unit_price=30)
counts = product.user_counts()
assert len(counts) == 1
assert counts[0]["name"] == "Test U"
assert counts[0]["count"] == 2
# OrderManager
# ============
def test_count_by_created_period_yearly(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("yearly"))
assert len(qs) == 1
assert "period" in qs[0]
def test_count_by_created_period_monthly(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("monthly"))
assert len(qs) == 1
def test_count_by_created_period_daily(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("daily"))
assert len(qs) == 1
def test_count_by_created_period_hourly(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("hourly"))
assert len(qs) == 1
def test_count_by_created_period_invalid():
with pytest.raises(AssertionError):
Order.objects.count_by_created_period("weekly")
# Order
# =====
def test_order_is_external_true(db): # noqa: ARG001
o = Order.objects.create(order_sum=10)
assert o.is_external() is True
def test_order_is_external_false(user):
o = Order.objects.create(customer=user, order_sum=10)
assert o.is_external() is False
def test_order_receipt_single_line(order):
ol = order.orderlines.first()
assert order.reciept() == str(ol)
def test_order_receipt_multiple_lines(product):
product2 = Product.objects.create(name="cola", sale_price_ext=20, sale_price_int=15)
o = Order.objects.create(order_sum=55)
OrderLine.objects.create(order=o, product=product, amount=1, unit_price=35)
OrderLine.objects.create(order=o, product=product2, amount=1, unit_price=20)
receipt = o.reciept()
assert "borg" in receipt
assert "cola" in receipt
assert ", " in receipt
def test_order_str_external(db): # noqa: ARG001
o = Order.objects.create(order_sum=Decimal("15.0"))
assert EXTERNAL_USER in str(o)
assert "15.0" in str(o)
def test_order_str_with_customer(user):
o = Order.objects.create(customer=user, order_sum=Decimal("20.0"))
assert str(user) in str(o)
# OrderLine / OrderLineManager
# ============================
def test_orderline_price(order):
ol = order.orderlines.first()
assert ol.price == ol.amount * ol.unit_price
def test_orderline_str(order):
ol = order.orderlines.first()
assert str(ol) == f"{ol.amount} {ol.product} ({ol.unit_price} kr per)"
def test_product_order_stats(order): # noqa: ARG001
qs = list(OrderLine.objects.all().product_order_stats())
assert len(qs) == 1
assert qs[0]["product_name"] == "borg"
assert qs[0]["count"] == 1
def test_as_series_single_product(order): # noqa: ARG001
qs = OrderLine.objects.all().product_order_stats()
series = qs.as_series()
assert len(series) == 1
assert series[0]["product_name"] == "borg"
assert len(series[0]["data"]) == 1
assert "x" in series[0]["data"][0]
assert "y" in series[0]["data"][0]
def test_as_series_multiple_products(user, product):
product2 = Product.objects.create(name="cola", sale_price_ext=20, sale_price_int=15)
o = Order.objects.create(customer=user, order_sum=55)
OrderLine.objects.create(order=o, product=product, amount=2, unit_price=30)
OrderLine.objects.create(order=o, product=product2, amount=1, unit_price=20)
qs = OrderLine.objects.all().product_order_stats()
series = qs.as_series()
assert {s["product_name"] for s in series} == {"borg", "cola"}
# Transaction
# ===========
def test_transaction_str(transaction, user):
result = str(transaction)
assert str(transaction.id) in result
assert str(user) in result
assert str(transaction.amount) in result
# InventoryTransaction
# ====================
def test_inventory_transaction_price(user, product):
it = InventoryTransaction.objects.create(user=user, product=product, amount=3, unit_price=Decimal("10.0"))
assert it.price == Decimal("30.0")
def test_inventory_transaction_str(user, product):
it = InventoryTransaction.objects.create(user=user, product=product, amount=3, unit_price=Decimal("10.0"))
result = str(it)
assert str(it.id) in result
assert str(user) in result
assert "borg" in result
# UserProfile
# ===========
def test_userprofile_str(user):
assert str(user.profile) == str(user)
def test_last_purchase_none(user):
assert user.profile.last_purchase() is None
def test_last_purchase_returns_latest(user):
Order.objects.create(customer=user, order_sum=10)
o2 = Order.objects.create(customer=user, order_sum=20)
assert user.profile.last_purchase() == o2
def test_last_purchase_date_no_orders(user):
assert user.profile.last_purchase_date() == datetime.min.replace(tzinfo=UTC)
def test_last_purchase_date_with_orders(user):
o = Order.objects.create(customer=user, order_sum=10)
assert user.profile.last_purchase_date() == o.created
def test_top_months(user):
Order.objects.create(customer=user, order_sum=50)
Order.objects.create(customer=user, order_sum=30)
result = list(user.profile.top_months())
assert len(result) == 1
assert result[0]["sum"] == Decimal("80.0")
def test_order_sum_total_no_orders(user):
assert user.profile.order_sum_total() == 0
def test_order_sum_total_with_orders(user):
Order.objects.create(customer=user, order_sum=Decimal("25.0"))
Order.objects.create(customer=user, order_sum=Decimal("15.0"))
assert user.profile.order_sum_total() == Decimal("40.0")
def test_userprofile_product_order_stats(user, product):
o = Order.objects.create(customer=user, order_sum=30)
OrderLine.objects.create(order=o, product=product, amount=1, unit_price=30)
result = list(user.profile.product_order_stats())
assert len(result) == 1
assert result[0]["product_name"] == "borg"
def test_image_url_with_email(user):
expected_hash = hashlib.sha256(user.email.lower().strip().encode()).hexdigest()
assert expected_hash in user.profile.image_url()
def test_image_url_no_email(db): # noqa: ARG001
u = DjangoUser.objects.create_user(username="noemail", email="")
profile = UserProfile.objects.create(user=u, balance=0)
assert profile.image_url() == ""
def test_save_auto_populates_image(db): # noqa: ARG001
u = DjangoUser.objects.create_user(username="auto@test.com", email="auto@test.com")
profile = UserProfile.objects.create(user=u, balance=0)
assert profile.image != ""
assert "gravatar" in profile.image
def test_save_preserves_existing_image(db): # noqa: ARG001
u = DjangoUser.objects.create_user(username="img@test.com", email="img@test.com")
profile = UserProfile.objects.create(user=u, balance=0, image="https://example.com/custom.jpg")
assert profile.image == "https://example.com/custom.jpg"
+12 -325
View File
@@ -1,80 +1,23 @@
from django.contrib.auth import get_user_model import pytest
from django.utils import timezone from django.test import Client
from strawberry.django.test import GraphQLTestClient
from bbs.models import InventoryTransaction, Order, OrderLine, Transaction, UserProfile from bbs.models import Order, Product
CREATE_ORDER = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
... on CreateOrderSuccess {
order { id orderSum orderlines { id product { id } } }
}
... on InsufficientFunds {
amountLacking
error
}
... on FormErrors {
message
}
}
}
"""
CREATE_DEPOSIT = """
mutation CreateDeposit($userId: ID!, $amount: Int!) {
createDeposit(userId: $userId, amount: $amount) {
... on CreateDepositSuccess {
transaction { id amount }
}
... on AboveMaxSaldo {
maxSaldo
above
}
... on FormErrors {
message
}
}
}
"""
CREATE_USER = """
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
firstName
lastName
}
}
"""
def test_user_not_found(db, gql_client): # noqa: ARG001 @pytest.fixture(name="gql_client")
result = gql_client.query( def gql_client_fixture(client: Client):
""" yield GraphQLTestClient(client)
query User($userId: ID!) {
user(userId: $userId) {
id
}
}
""",
{"userId": "999"},
assert_no_errors=False,
)
assert result.errors
error = result.errors[0] @pytest.fixture(name="product")
assert error["path"] == ["user"] def product_fix(db):
assert error["extensions"]["code"] == "NOT_FOUND" return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
assert error["extensions"]["model"] == "User"
def test_create_order(product, gql_client): def test_create_order(product, gql_client):
assert Order.objects.count() == 0 assert Order.objects.count() == 0
variables = { variables = dict(customerId="external", isExternal=True, orderLines=[{"productId": product.id, "amount": 1}])
"customerId": "external",
"isExternal": True,
"orderLines": [{"productId": product.id, "amount": 1}],
}
query = """ query = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) { mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) { createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
@@ -103,259 +46,3 @@ def test_create_order(product, gql_client):
assert Order.objects.count() == 1 assert Order.objects.count() == 1
o = Order.objects.first() o = Order.objects.first()
assert o.orderlines.first().product.id == product.id assert o.orderlines.first().product.id == product.id
def test_create_inventory_transaction_requires_login(product, gql_client):
result = gql_client.query(
"""
mutation CreateInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on FormErrors { message }
}
}
""",
variables={"data": {"productId": str(product.id), "amount": 5, "unitPrice": "12.5"}},
)
assert not result.errors
assert result.data["createInventoryTransaction"]["message"] == "Logg inn for å registrere varetransaksjoner."
product.refresh_from_db()
assert product.inventory_amount == 0
assert InventoryTransaction.objects.count() == 0
def test_create_inventory_transaction_updates_stock(product, gql_client, client):
user = get_user_model().objects.create_user(username="stock-manager")
client.force_login(user)
result = gql_client.query(
"""
mutation CreateInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on CreateInventoryTransactionSuccess {
inventoryTransaction {
amount
unitPrice
product { id inventoryAmount }
user { id }
}
}
}
}
""",
variables={
"data": {
"productId": str(product.id),
"amount": 5,
"unitPrice": "12.5",
"comment": "Restock",
}
},
)
assert not result.errors
transaction = result.data["createInventoryTransaction"]["inventoryTransaction"]
assert transaction["amount"] == 5
assert transaction["unitPrice"] == "12.5"
assert transaction["product"]["inventoryAmount"] == 5
assert transaction["user"]["id"] == str(user.id)
product.refresh_from_db()
assert product.inventory_amount == 5
def test_inventory_report_aggregates_period_data(product, gql_client):
user = get_user_model().objects.create_user(username="report-user")
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=10)
transaction = Transaction.objects.create(user=user, amount=100)
order = Order.objects.create(order_sum=30)
OrderLine.objects.create(order=order, product=product, amount=2, unit_price=15)
start_time = timezone.make_naive(timezone.now() - timezone.timedelta(days=1))
end_time = timezone.make_naive(timezone.now() + timezone.timedelta(days=1))
result = gql_client.query(
"""
query InventoryReport($startTime: DateTime!, $endTime: DateTime!) {
inventoryReport(startTime: $startTime, endTime: $endTime) {
products {
productId
purchaseUnits
purchaseValue
salesUnits
salesValue
}
inventoryPurchases
productSales
deposits
orderPurchases
balanceAtStart
balanceAtEnd
}
}
""",
variables={"startTime": start_time.isoformat(), "endTime": end_time.isoformat()},
)
assert not result.errors
report = result.data["inventoryReport"]
assert report["products"] == [
{
"productId": str(product.id),
"purchaseUnits": 5,
"purchaseValue": "50",
"salesUnits": 2,
"salesValue": "30",
}
]
assert report["inventoryPurchases"] == "50"
assert report["productSales"] == "30"
assert report["deposits"] == "100"
assert report["orderPurchases"] == "30"
assert report["balanceAtStart"] == "0"
assert report["balanceAtEnd"] == "70"
assert transaction.amount == 100
products_result = gql_client.query(
"""
query InventoryProducts {
allProducts {
id
active
inventoryAmount
wholesaleUnitPrice
wholesaleValue
}
inventoryTransactionList(ordering: [{created: DESC}], pagination: {limit: 20, offset: 0}) {
amount
unitPrice
product { name }
user { firstName lastName }
}
}
"""
)
assert not products_result.errors
assert products_result.data["allProducts"][0]["active"]
assert products_result.data["inventoryTransactionList"][0]["amount"] == 5
def test_create_order_no_customer(product, gql_client):
res = gql_client.query(
CREATE_ORDER,
{
"customerId": "999",
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["message"]
def test_create_order_empty_lines(product, gql_client): # noqa: ARG001
res = gql_client.query(
CREATE_ORDER,
{
"customerId": "external",
"isExternal": True,
"orderLines": [],
},
)
assert not res.errors
assert res.data["createOrder"]["message"]
def test_create_order_insufficient_funds(user, product, gql_client):
res = gql_client.query(
CREATE_ORDER,
{
"customerId": str(user.id),
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["amountLacking"] == 20
assert res.data["createOrder"]["error"] is True
assert Order.objects.count() == 0
product.refresh_from_db()
assert product.inventory_amount == 0
def test_create_order_internal_success(user, product, gql_client):
user.profile.balance = 100
user.profile.save()
res = gql_client.query(
CREATE_ORDER,
{
"customerId": str(user.id),
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["order"]
user.profile.refresh_from_db()
assert user.profile.balance == 70
product.refresh_from_db()
assert product.inventory_amount == -1
def test_create_order_exact_balance(user, product, gql_client):
user.profile.balance = 30
user.profile.save()
res = gql_client.query(
CREATE_ORDER,
{
"customerId": str(user.id),
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["order"]
user.profile.refresh_from_db()
assert user.profile.balance == 0
def test_create_deposit(user, gql_client):
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 100})
assert not res.errors
assert res.data["createDeposit"]["transaction"]
assert Transaction.objects.count() == 1
user.profile.refresh_from_db()
assert user.profile.balance == 110
def test_create_deposit_invalid_user(db, gql_client): # noqa: ARG001
res = gql_client.query(CREATE_DEPOSIT, {"userId": "999", "amount": 100})
assert not res.errors
assert res.data["createDeposit"]["message"]
def test_create_deposit_invalid_amount(user, gql_client):
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 0})
assert not res.errors
assert res.data["createDeposit"]["message"]
def test_create_deposit_above_max_saldo(user, gql_client):
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 1000})
assert not res.errors
assert res.data["createDeposit"]["maxSaldo"] is not None
assert res.data["createDeposit"]["above"] == 10
assert Transaction.objects.count() == 0
def test_create_deposit_1337(user, gql_client):
# balance=10, so amount=1327 makes balance+amount==1337, bypassing max saldo
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 1327})
assert not res.errors
assert res.data["createDeposit"]["transaction"]
def test_create_user(db, gql_client): # noqa: ARG001
res = gql_client.query(CREATE_USER, {"input": {"email": "new@example.com", "firstName": "New", "lastName": "User"}})
assert not res.errors
assert get_user_model().objects.filter(username="new@example.com").exists()
assert UserProfile.objects.count() == 1
-63
View File
@@ -1,63 +0,0 @@
ALL_USERS = "query { allUsers { id firstName lastName } }"
USER = "query User($userId: ID!) { user(userId: $userId) { id firstName lastName } }"
USER_PROFILE = "query User($userId: ID!) { user(userId: $userId) { profile { balance topMonths { period count sum } productOrderStats { productName data { x y } } } } }"
ALL_PRODUCTS = "query { allProducts { id name } }"
ALL_ORDERS = "query { allOrders { id orderSum } }"
ORDER_LIST = "query { orderList { id orderSum } }"
ORDER_STATS = "query { orderStats { yearly { period count } monthly { period count } daily { period count } hourly { period count } } }"
PRODUCT_STATS = "query { productStats { productName data { x y } } }"
TRANSACTION_LIST = "query { transactionList { id amount } }"
def test_user(user, gql_client):
res = gql_client.query(USER, {"userId": str(user.id)})
assert not res.errors
assert res.data["user"]["id"] == str(user.id)
def test_all_users(user, gql_client): # noqa: ARG001
res = gql_client.query(ALL_USERS)
assert not res.errors
assert len(res.data["allUsers"]) == 1
def test_all_products(product, gql_client): # noqa: ARG001
res = gql_client.query(ALL_PRODUCTS)
assert not res.errors
assert len(res.data["allProducts"]) == 1
def test_all_orders(order, gql_client): # noqa: ARG001
res = gql_client.query(ALL_ORDERS)
assert not res.errors
assert len(res.data["allOrders"]) == 1
def test_order_list(order, gql_client): # noqa: ARG001
res = gql_client.query(ORDER_LIST)
assert not res.errors
assert len(res.data["orderList"]) == 1
def test_order_stats(db, gql_client): # noqa: ARG001
res = gql_client.query(ORDER_STATS)
assert not res.errors
assert "daily" in res.data["orderStats"]
def test_product_stats(order, gql_client): # noqa: ARG001
res = gql_client.query(PRODUCT_STATS)
assert not res.errors
assert res.data["productStats"]
def test_transaction_list(transaction, gql_client): # noqa: ARG001
res = gql_client.query(TRANSACTION_LIST)
assert not res.errors
assert len(res.data["transactionList"]) == 1
def test_user_profile(user, gql_client):
res = gql_client.query(USER_PROFILE, {"userId": str(user.id)})
assert not res.errors
assert res.data["user"]["profile"]["balance"] is not None
-93
View File
@@ -1,93 +0,0 @@
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import Client
from strawberry.django.test import GraphQLTestClient
from bbs.management.commands.seed_demo_data import DEMO_PRODUCTS
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
DEMO_PRODUCT_NAMES = [name for name, *_ in DEMO_PRODUCTS]
@pytest.mark.django_db
def test_seed_demo_data_is_repeatable():
call_command("seed_demo_data")
call_command("seed_demo_data")
assert get_user_model().objects.filter(username__startswith="demo-").count() == 9
assert Product.objects.filter(name__in=DEMO_PRODUCT_NAMES).count() == 6
assert Order.objects.count() == 11
assert Transaction.objects.count() == 9
assert InventoryTransaction.objects.count() == 6
assert sum(product.inventory_amount for product in Product.objects.filter(name__in=DEMO_PRODUCT_NAMES)) == 155
assert all(profile.balance >= 0 for profile in UserProfile.objects.all())
assert all("pravatar.cc" in profile.image for profile in UserProfile.objects.all())
@pytest.mark.django_db
def test_demo_data_is_available_when_an_admin_has_no_profile():
user_model = get_user_model()
user_model.objects.create_superuser(username="admin", email="admin@example.test", password="password")
call_command("seed_demo_data")
gql_client = GraphQLTestClient(Client())
result = gql_client.query(
"""
{
allProducts(filters: {active: true}) { name }
allUsers { firstName lastName profile { balance } }
}
"""
)
assert not result.errors
assert result.data
assert len(result.data["allProducts"]) == 6
assert len(result.data["allUsers"]) == 9
ada = user_model.objects.get(username="demo-ada")
detail_result = gql_client.query(
"""
query UserDetail($userId: ID!) {
user(userId: $userId) {
firstName
profile {
balance
lastPurchaseDate
orderSumTotal
topMonths { period sum count }
productOrderStats { productName data { x y } }
}
}
}
""",
variables={"userId": str(ada.pk)},
)
assert not detail_result.errors
assert detail_result.data["user"]["profile"]["topMonths"]
assert isinstance(detail_result.data["user"]["profile"]["topMonths"][0]["sum"], float)
@pytest.mark.django_db
def test_seed_demo_data_random_size_adds_consistent_data():
call_command("seed_demo_data", size="small", seed=1, users=5, orders=30)
random_users = get_user_model().objects.exclude(username__startswith="demo-")
assert random_users.count() == 5
assert Order.objects.count() == 11 + 30
for user in random_users:
deposits = sum(t.amount for t in Transaction.objects.filter(user=user))
purchases = sum(o.order_sum for o in Order.objects.filter(customer=user))
assert user.profile.balance == deposits - purchases
assert user.profile.balance >= 0
assert all(product.inventory_amount >= 0 for product in Product.objects.all())
@pytest.mark.django_db
def test_seed_demo_data_random_size_can_run_twice():
call_command("seed_demo_data", size="small", seed=1, users=5, orders=5)
call_command("seed_demo_data", size="small", seed=1, users=5, orders=5)
assert get_user_model().objects.exclude(username__startswith="demo-").count() == 10
-10
View File
@@ -1,10 +0,0 @@
import pytest
from django.test import Client
@pytest.mark.parametrize("path", ["/inventory", "/inventory/add", "/report"])
def test_inventory_pages_serve_the_spa_entry(client: Client, path: str):
response = client.get(path)
assert response.status_code == 200
assert b'<div id="root"></div>' in b"".join(response.streaming_content)
-53
View File
@@ -1,53 +0,0 @@
import importlib
import pytest
from django.test import Client
from django.urls import clear_url_caches
import bbs.urls
@pytest.fixture
def prod_urls(settings, tmp_path):
"""bbs.urls as the image loads it: routes under api/, uploads and frontend routes served."""
settings.DEBUG = False
settings.BBS_API_PREFIX = "api/"
settings.MEDIA_ROOT = str(tmp_path)
(tmp_path / "products").mkdir()
(tmp_path / "products" / "beer.png").write_bytes(b"png")
clear_url_caches()
importlib.reload(bbs.urls)
yield
settings.DEBUG = True
settings.BBS_API_PREFIX = ""
clear_url_caches()
importlib.reload(bbs.urls)
def test_graphql_under_api_prefix(prod_urls, client: Client, db): # noqa: ARG001
response = client.post("/api/graphql/", {"query": "{ allUsers { id } }"}, content_type="application/json")
assert response.status_code == 200
assert response.json() == {"data": {"allUsers": []}}
def test_login_page_renders(prod_urls, client: Client, db): # noqa: ARG001
response = client.get("/api/accounts/login/")
assert response.status_code == 200
assert b"csrfmiddlewaretoken" in response.content
@pytest.mark.parametrize("path", ["/deposit", "/stats/users", "/user/1"])
def test_frontend_routes_serve_the_spa_entry(prod_urls, client: Client, path: str): # noqa: ARG001
response = client.get(path)
assert response.status_code == 200
assert b'<div id="root"></div>' in b"".join(response.streaming_content)
def test_uploads_are_served(prod_urls, client: Client): # noqa: ARG001
response = client.get("/media/products/beer.png")
assert response.status_code == 200
assert b"".join(response.streaming_content) == b"png"
-21
View File
@@ -1,21 +0,0 @@
from bbs.utils import format_username
def test_format_username():
assert format_username("Ada", "Lovelace") == "adalovela"
def test_format_username_truncates_names():
assert format_username("Aleksandar", "Vukovic") == "aleksavukovi"
def test_format_username_removes_spaces():
assert format_username("Mary Jane", "van Dijk") == "maryjavandij"
def test_format_username_drops_non_ascii_characters():
assert format_username("Ångström", "Ø") == "ngstr"
def test_format_username_allows_empty_names():
assert format_username("", "") == ""
+10 -35
View File
@@ -2,49 +2,24 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LoginView, LogoutView from django.contrib.auth.views import LoginView, LogoutView
from django.urls import include, path, re_path from django.urls import path, re_path
from django.views.decorators.csrf import csrf_exempt
from django.views.static import serve
from strawberry.django.views import GraphQLView from strawberry.django.views import GraphQLView
from bbs.api.schema import schema from bbs.api.schema import schema
from bbs.views.spa import spa_index from bbs.views.reports import inventory, inventory_add, report
admin.autodiscover() admin.autodiscover()
api_patterns = [
# bbs has no login template of its own; the admin's works for any user
path(
"accounts/login/",
LoginView.as_view(template_name="admin/login.html", extra_context={"site_header": "BBS"}),
name="django.contrib.auth.views.login",
),
path(
"accounts/logout/",
LogoutView.as_view(),
name="django.contrib.auth.views.logout",
),
path("admin/", admin.site.urls),
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
]
urlpatterns = [ urlpatterns = [
path("inventory", spa_index, name="inventory"), # TODO: migrate reports to SPA
path("inventory/", spa_index), re_path(r"^inventory/$", inventory, name="inventory"),
path("inventory/add", spa_index, name="inventory-add"), re_path(r"^inventory/add/$", inventory_add, name="inventory-add"),
path("inventory/add/", spa_index), re_path(r"^report$", report, name="report"),
path("report", spa_index, name="report"), path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
path("report/", spa_index), path("accounts/logout/", LogoutView.as_view(), name="django.contrib.auth.views.logout"),
path(settings.BBS_API_PREFIX, include(api_patterns)), path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(schema=schema), name="graphql"),
] ]
if settings.DEBUG:
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT) urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
else:
# The container has no web server of its own for uploads, and there are few of them.
# Any other path is a frontend route, which index.html renders.
urlpatterns += [
re_path(r"^media/(?P<path>.*)$", serve, {"document_root": settings.MEDIA_ROOT}),
re_path(r"^(?!media/|static/).*$", spa_index),
]
+15 -15
View File
@@ -1,9 +1,8 @@
from datetime import timedelta from datetime import datetime, timedelta
from itertools import groupby from itertools import groupby
from django.contrib import messages from django.contrib import messages
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils import timezone
from bbs.forms import DateRangeForm, InventoryTransactionForm from bbs.forms import DateRangeForm, InventoryTransactionForm
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
@@ -11,12 +10,12 @@ from bbs.models import InventoryTransaction, Order, Product, Transaction, UserPr
def report(request): def report(request):
# FIXME: split this up in smaller pieces # FIXME: split this up in smaller pieces
datetime_now = timezone.localtime() datetime_now = datetime.now()
start_time = timezone.localtime() - timedelta(days=30) start_time = datetime.now() - timedelta(days=30)
# start of day, 1 month # start of day, 1 month
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0) start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
# end of day # end of day
end_time = timezone.localtime().replace(hour=23, minute=59, second=59, microsecond=999) end_time = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999)
if request.GET.get("start_time", False) or request.GET.get("end_time", False): if request.GET.get("start_time", False) or request.GET.get("end_time", False):
form = DateRangeForm(request.GET) form = DateRangeForm(request.GET)
@@ -51,14 +50,14 @@ def report(request):
k: { k: {
"name": k, "name": k,
"transactions": group, "transactions": group,
"transactions_sum": sum(x["transactions__price"] for x in group), "transactions_sum": sum(map(lambda x: x["transactions__price"], group)),
"transactions_units": sum(x["transactions__amount"] for x in group), "transactions_units": sum(map(lambda x: x["transactions__amount"], group)),
} }
} }
) )
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time]) itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
inv_in = sum(x[1].get("transactions_sum", 0) for x in inventory_products.items()) inv_in = sum(map(lambda x: x[1].get("transactions_sum", 0), inventory_products.items()))
# out # out
out_products = ( out_products = (
@@ -76,8 +75,8 @@ def report(request):
product = { product = {
"name": k, "name": k,
"orderlines": group, "orderlines": group,
"orderlines_sum": sum(x["orderlines__price"] for x in group), "orderlines_sum": sum(map(lambda x: x["orderlines__price"], group)),
"orderlines_units": sum(x["orderlines__amount"] for x in group), "orderlines_units": sum(map(lambda x: x["orderlines__amount"], group)),
} }
if k in inventory_products: if k in inventory_products:
# add diff # add diff
@@ -89,7 +88,7 @@ def report(request):
p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0) p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0)
p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0) p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0)
inv_out = sum(x[1].get("orderlines_sum", 0) for x in inventory_products.items()) inv_out = sum(map(lambda x: x[1].get("orderlines_sum", 0), inventory_products.items()))
inv_diff = inv_in - inv_out inv_diff = inv_in - inv_out
@@ -111,7 +110,7 @@ def report(request):
purchases_diff = purchases_end - purchases_start purchases_diff = purchases_end - purchases_start
saldo_diff = saldo_end - saldo_start saldo_diff = saldo_end - saldo_start
return render(request, "report.html", locals()) # noqa: DJ003 return render(request, "report.html", locals())
def inventory(request): def inventory(request):
@@ -121,7 +120,7 @@ def inventory(request):
wholesale_value = sum([p.wholesale_value for p in products_active]) wholesale_value = sum([p.wholesale_value for p in products_active])
num_products = sum(products_active.values_list("inventory_amount", flat=True)) num_products = sum(products_active.values_list("inventory_amount", flat=True))
return render(request, "inventory.html", locals()) # noqa: DJ003 return render(request, "inventory.html", locals())
def inventory_add(request): def inventory_add(request):
@@ -134,12 +133,13 @@ def inventory_add(request):
itrans.user = request.user itrans.user = request.user
itrans.product.save() itrans.product.save()
itrans.save() itrans.save()
messages.success(request, f"Satt inn {itrans.amount} stk {itrans.product}.") messages.success(request, "Satt inn {0} stk {1}.".format(itrans.amount, itrans.product))
return redirect("inventory-add") return redirect("inventory-add")
else:
messages.error(request, "Feil med skjemaet, se under.") messages.error(request, "Feil med skjemaet, se under.")
form = InventoryTransactionForm(data=request.POST) form = InventoryTransactionForm(data=request.POST)
else: else:
form = InventoryTransactionForm() form = InventoryTransactionForm()
return render(request, "inventory_add.html", locals()) # noqa: DJ003 return render(request, "inventory_add.html", locals())
-11
View File
@@ -1,11 +0,0 @@
from pathlib import Path
from django.contrib.staticfiles import finders
from django.http import FileResponse, Http404
def spa_index(_request):
index_path = finders.find("index.html")
if not index_path:
raise Http404("The frontend has not been built.")
return FileResponse(Path(index_path).open("rb"), content_type="text/html")
+3 -5
View File
@@ -1,8 +1,6 @@
#!/bin/bash #!/bin/bash
set -eu set -eu
source "$HOME/.local/bin/env"
export VITE_API_URL="https://bbs.neuf.no/api" export VITE_API_URL="https://bbs.neuf.no/api"
PROJECT_PATH=/var/www/neuf.no/bbs PROJECT_PATH=/var/www/neuf.no/bbs
PROCESS_NAME="bbs.neuf.no" PROCESS_NAME="bbs.neuf.no"
@@ -12,9 +10,9 @@ cd "$PROJECT_PATH" || exit 1
git pull git pull
# Backend # Backend
uv sync poetry install
umask 022; uv run manage.py collectstatic --noinput # Collect static umask 022; poetry run python manage.py collectstatic --noinput # Collect static
uv run manage.py migrate # Run DB migrations poetry run python manage.py migrate # Run DB migrations
# Frontend # Frontend
bin/build-frontend bin/build-frontend
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
SRC_DIR="bbs"
black $SRC_DIR
isort $SRC_DIR
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
python manage.py export_schema
cd frontend && npm run generate
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
SRC_DIR="bbs"
black --check $SRC_DIR
isort --diff $SRC_DIR
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
python manage.py runserver
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd frontend && npm run start
Executable
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
pytest -v
+1
View File
@@ -0,0 +1 @@
src/types.ts
+53
View File
@@ -0,0 +1,53 @@
module.exports = {
env: {
browser: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
},
extends: [
"plugin:prettier/recommended",
"plugin:react/recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:react/recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
"no-console": "off",
"prefer-destructuring": "off",
"import/no-extraneous-dependencies": [
"error",
{
devDependencies: ["**/*.test.js", "**/*.spec.js", "**/__tests__/**"],
},
],
"import/prefer-default-export": "off",
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", ["parent", "sibling", "index"]],
"newlines-between": "always",
alphabetize: { order: "asc" },
},
],
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
"react/button-has-type": "error",
"react/jsx-filename-extension": ["warn", { extensions: [".js", ".jsx", ".ts", ".tsx"] }],
"react/jsx-uses-react": "off",
"react/prefer-stateless-function": "warn",
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"react/sort-comp": "off",
},
plugins: ["prettier", "react", "react-hooks"],
settings: {
react: {
version: "detect",
},
},
};
+1 -1
View File
@@ -1 +1 @@
v26 v16
+2 -24
View File
@@ -3,28 +3,6 @@ documents: ./src/**/*.graphql
generates: generates:
./src/types.ts: ./src/types.ts:
plugins: plugins:
- typescript: - typescript
typesPrefix: Schema - typescript-operations
scalars:
DateTime:
input: string
output: string
Decimal:
input: string
output: string
JSON:
input: unknown
output: "Array<{ name: string; count: number }>"
- typescript-operations:
nonOptionalTypename: true
scalars:
DateTime:
input: string
output: string
Decimal:
input: string
output: string
JSON:
input: unknown
output: "Array<{ name: string; count: number }>"
- typed-document-node - typed-document-node
-54
View File
@@ -1,54 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import importPlugin from "eslint-plugin-import-x";
import prettier from "eslint-plugin-prettier/recommended";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist", "src/types.ts"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
prettier,
],
plugins: {
"import-x": importPlugin,
},
languageOptions: {
globals: globals.browser,
},
settings: {
react: {
version: "detect",
},
},
rules: {
"no-console": "off",
"prefer-destructuring": "off",
"import-x/no-extraneous-dependencies": [
"error",
{
devDependencies: ["**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"],
},
],
"import-x/prefer-default-export": "off",
"import-x/order": [
"error",
{
groups: ["builtin", "external", "internal", ["parent", "sibling", "index"]],
"newlines-between": "always",
alphabetize: { order: "asc" },
},
],
"react-hooks/exhaustive-deps": "error",
"react-hooks/rules-of-hooks": "error",
},
},
]);
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/brick.svg" /> <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
+19265 -6218
View File
File diff suppressed because it is too large. Load diff
+47 -49
View File
@@ -3,66 +3,64 @@
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc -b && vite build", "build": "tsc && vite build",
"check-types": "tsc -b", "check-types": "tsc",
"dev": "vite", "dev": "vite",
"format:js": "eslint --fix src", "format:js": "eslint --fix src",
"format:styles": "prettier --write \"src/**/*.css\"", "format:styles": "stylelint --fix src",
"generate": "node scripts/generate-types.mjs", "generate": "graphql-codegen",
"lint:js": "eslint src", "lint:js": "eslint src",
"lint:styles": "prettier --check \"src/**/*.css\"", "lint:styles": "stylelint src",
"preview": "vite preview", "preview": "vite preview",
"start": "vite", "start": "vite",
"test": "node --test" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^4.2.0", "@apollo/client": "^3.5.10",
"@nivo/bar": "^0.99.0", "@nivo/bar": "^0.79.1",
"@nivo/line": "^0.99.0", "@nivo/line": "^0.79.1",
"@nivo/pie": "^0.99.0", "@nivo/pie": "^0.79.1",
"clsx": "^2.1.1", "clsx": "^1.1.1",
"date-fns": "^4.3.0", "date-fns": "^2.28.0",
"graphql": "^16.14.0", "graphql": "^16.3.0",
"react": "^19.2.6", "react": "^17.0.2",
"react-dom": "^19.2.6", "react-dom": "^17.0.2",
"react-hook-form": "^7.76.1", "react-hook-form": "^7.30.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^6.3.0",
"use-url-search-params": "^2.5.1" "use-url-search-params": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.7", "@graphql-codegen/cli": "^2.6.2",
"@eslint/js": "^10.0.1", "@graphql-codegen/typed-document-node": "^2.2.8",
"@graphql-codegen/core": "^6.2.2", "@graphql-codegen/typescript-operations": "^2.3.5",
"@graphql-codegen/typed-document-node": "^7.2.0",
"@graphql-codegen/typescript": "^6.1.2",
"@graphql-codegen/typescript-operations": "^6.1.10",
"@graphql-typed-document-node/core": "^3.1.1", "@graphql-typed-document-node/core": "^3.1.1",
"@rolldown/plugin-babel": "^0.2.3", "@types/react": "^17.0.44",
"@types/babel__core": "^7.20.5", "@types/react-dom": "^17.0.15",
"@types/node": "^26.6.5", "@types/react-router-dom": "^5.3.3",
"@types/react": "^19.2.15", "@typescript-eslint/eslint-plugin": "^5.19.0",
"@types/react-dom": "^19.2.3", "@typescript-eslint/parser": "^5.19.0",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^1.3.1",
"babel-plugin-react-compiler": "^1.0.0", "@vitejs/plugin-react-refresh": "^1.3.6",
"eslint": "^10.4.0", "eslint": "^8.13.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-import-x": "^4.16.2", "eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^5.5.7", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-hooks": "^4.4.0",
"globals": "^17.6.0", "jest": "^27.5.1",
"prettier": "^3.6.2", "prettier": "^2.6.2",
"typescript": "~6.0.3", "stylelint": "^14.7.0",
"typescript-eslint": "^8.60.0", "stylelint-config-idiomatic-order": "^8.1.0",
"vite": "^8.0.14", "stylelint-config-prettier": "^9.0.3",
"vite-plugin-svgr": "^5.2.0", "stylelint-config-recommended": "^7.0.0",
"yaml": "^2.8.1" "stylelint-order": "^5.0.0",
"stylelint-prettier": "^2.0.0",
"typescript": "^4.6.3",
"vite": "^2.9.5",
"vite-plugin-svgr": "^1.1.0"
}, },
"engines": { "engines": {
"node": "26" "node": ">=16",
}, "npm": ">=7"
"overrides": { }
"@whatwg-node/promise-helpers": "^1.3.2"
},
"type": "module"
} }
-66
View File
@@ -1,66 +0,0 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { codegen } from "@graphql-codegen/core";
import { plugin as typedDocumentNodePlugin } from "@graphql-codegen/typed-document-node";
import { plugin as typescriptOperationsPlugin } from "@graphql-codegen/typescript-operations";
import { plugin as typescriptPlugin } from "@graphql-codegen/typescript";
import { buildASTSchema, parse } from "graphql";
import { parse as parseYaml } from "yaml";
const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const configPath = path.join(frontendRoot, "codegen.yml");
const config = parseYaml(await readFile(configPath, "utf8"));
const schemaDocument = parse(await readFile(path.resolve(frontendRoot, config.schema), "utf8"));
const outputEntries = Object.entries(config.generates ?? {});
if (outputEntries.length !== 1) {
throw new Error(`Expected one GraphQL Codegen output in ${configPath}`);
}
const [outputName, outputConfig] = outputEntries[0];
const outputPath = path.resolve(frontendRoot, outputName);
const sourceRoot = path.join(frontendRoot, "src");
const plugins = outputConfig.plugins.map((plugin) =>
typeof plugin === "string" ? { [plugin]: {} } : plugin,
);
async function findGraphqlFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nestedFiles = await Promise.all(
entries.map((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return findGraphqlFiles(entryPath);
return entry.isFile() && entry.name.endsWith(".graphql") ? [entryPath] : [];
}),
);
return nestedFiles.flat();
}
const documents = await Promise.all(
(await findGraphqlFiles(sourceRoot))
.sort()
.map(async (location) => ({
location,
document: parse(await readFile(location, "utf8")),
})),
);
const pluginMap = {
typescript: { plugin: typescriptPlugin },
"typescript-operations": { plugin: typescriptOperationsPlugin },
"typed-document-node": { plugin: typedDocumentNodePlugin },
};
const generated = await codegen({
filename: outputPath,
schema: schemaDocument,
schemaAst: buildASTSchema(schemaDocument),
documents,
config: {},
plugins,
pluginMap,
});
await writeFile(outputPath, generated);
+1 -2
View File
@@ -1,5 +1,4 @@
.app { .app {
flex: 1 0 auto;
width: min(100% - 2rem, 1220px);
margin: 1rem auto; margin: 1rem auto;
max-width: 1220px;
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { ApolloProvider } from "@apollo/client/react"; import { ApolloProvider } from "@apollo/client";
import React from "react"; import React from "react";
import { BrowserRouter as Router } from "react-router-dom"; import { BrowserRouter as Router } from "react-router-dom";
@@ -11,7 +11,7 @@ import { Toast } from "./components/Toast";
import { ToastProvider } from "./components/ToastProvider"; import { ToastProvider } from "./components/ToastProvider";
import { Wireframe } from "./components/Wireframe"; import { Wireframe } from "./components/Wireframe";
const App = (): React.JSX.Element => ( const App = (): JSX.Element => (
<ApolloProvider client={client}> <ApolloProvider client={client}>
<Router> <Router>
<ToastProvider> <ToastProvider>
+1 -9
View File
@@ -4,30 +4,22 @@ import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
const Splash = React.lazy(() => import("./components/Splash")); const Splash = React.lazy(() => import("./components/Splash"));
const UserCreate = React.lazy(() => import("./features/auth/UserCreate")); const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
const Deposit = React.lazy(() => import("./features/deposit/Deposit")); 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 Log = React.lazy(() => import("./features/log/Log")); const Log = React.lazy(() => import("./features/log/Log"));
const Register = React.lazy(() => import("./features/register/Register")); const Register = React.lazy(() => import("./features/register/Register"));
const OrderStats = React.lazy(() => import("./features/stats/OrderStats")); const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
const ProductStats = React.lazy(() => import("./features/stats/ProductStats")); const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
const Stats = React.lazy(() => import("./features/stats/Stats")); const Stats = React.lazy(() => import("./features/stats/Stats"));
const UserStats = React.lazy(() => import("./features/stats/UserStats"));
const UserDetail = React.lazy(() => import("./features/users/UserDetail")); const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
const UserList = React.lazy(() => import("./features/users/UserList")); const UserList = React.lazy(() => import("./features/users/UserList"));
export const Routes = (): React.JSX.Element => ( export const Routes = (): JSX.Element => (
<React.Suspense fallback={<div>Loading...</div>}> <React.Suspense fallback={<div>Loading...</div>}>
<ReactRouterRoutes> <ReactRouterRoutes>
<Route path="/deposit" element={<Deposit />} /> <Route path="/deposit" element={<Deposit />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/inventory/add" element={<InventoryAdd />} />
<Route path="/report" element={<InventoryReport />} />
<Route path="/log" element={<Log />} /> <Route path="/log" element={<Log />} />
<Route path="/stats" element={<Stats />} /> <Route path="/stats" element={<Stats />} />
<Route path="/stats/orders" element={<OrderStats />} /> <Route path="/stats/orders" element={<OrderStats />} />
<Route path="/stats/products" element={<ProductStats />} /> <Route path="/stats/products" element={<ProductStats />} />
<Route path="/stats/users" element={<UserStats />} />
<Route path="/users" element={<UserList />} /> <Route path="/users" element={<UserList />} />
<Route path="/users/create" element={<UserCreate />} /> <Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} /> <Route path="/user/:userId" element={<UserDetail />} />
+2 -2
View File
@@ -1,7 +1,7 @@
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; import { ApolloClient, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({ const client = new ApolloClient({
link: new HttpLink({ uri: import.meta.env.DEV ? "/graphql/" : `${import.meta.env.VITE_API_URL}/graphql/` }), uri: `${import.meta.env.VITE_API_URL}/graphql/`,
cache: new InMemoryCache(), cache: new InMemoryCache(),
}); });
+4 -2
View File
@@ -1,6 +1,8 @@
.footer { .footer {
flex-shrink: 0; position: absolute;
padding: 0 0 1rem; right: 0;
bottom: 1rem;
left: 0;
height: 20px; height: 20px;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem; font-size: 0.8rem;
+2 -2
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { ReactComponent as Brick } from "../assets/brick.svg";
import styles from "./Footer.module.css"; import styles from "./Footer.module.css";
import Brick from "../assets/brick.svg?react";
export const Footer = (): React.JSX.Element => { export const Footer = (): JSX.Element => {
return ( return (
<footer className={styles.footer}> <footer className={styles.footer}>
Made with <span className={styles.heart}>❤</span> by{" "} Made with <span className={styles.heart}>❤</span> by{" "}
+4 -6
View File
@@ -1,11 +1,9 @@
.errorMessage { .errorMessage {
width: 100%; font-family: Rocket Rinder, monospace;
margin-top: 30vh;
color: var(--brand);
font-family:
Rocket Rinder,
monospace;
font-size: 3rem; font-size: 3rem;
font-style: italic; font-style: italic;
color: var(--brand);
text-align: center; text-align: center;
width: 100%;
margin-top: 30vh;
} }
+5 -5
View File
@@ -1,5 +1,5 @@
import type { ErrorLike } from "@apollo/client"; import { ApolloError } from "@apollo/client";
import { CombinedGraphQLErrors } from "@apollo/client/errors"; import React from "react";
import styles from "./GQLError.module.css"; import styles from "./GQLError.module.css";
@@ -11,9 +11,9 @@ const formatModel = (text: string) => {
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1); return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
}; };
export const GQLError = ({ error }: { error: ErrorLike }) => { export const GQLError = ({ error }: { error: ApolloError }) => {
if (error instanceof CombinedGraphQLErrors && error.errors.length === 1) { if (error?.graphQLErrors?.length == 1) {
const single = error.errors[0]; const single = error?.graphQLErrors[0];
if (single?.extensions?.code === "NOT_FOUND") { if (single?.extensions?.code === "NOT_FOUND") {
const modelPretty = formatModel(single?.extensions?.model as string); const modelPretty = formatModel(single?.extensions?.model as string);
return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>; return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>;
+8 -56
View File
@@ -1,15 +1,12 @@
.header { .header {
width: min(100% - 2rem, 1220px);
max-width: 1220px;
margin: 1rem auto; margin: 1rem auto;
max-width: 1220px;
} }
.nav { .nav {
display: flex; display: flex;
min-height: 5rem; height: 5rem;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 0.5rem 1rem;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -20,21 +17,14 @@
display: block; display: block;
height: 1px; height: 1px;
background-color: rgba(243, 33, 61, 0.4); background-color: rgba(243, 33, 61, 0.4);
box-shadow: box-shadow: 0 0 4px rgba(243, 33, 61, 0.4), 0 0 11px rgba(243, 33, 61, 0.4), 0 0 19px rgba(243, 33, 61, 0.4);
0 0 4px rgba(243, 33, 61, 0.4),
0 0 11px rgba(243, 33, 61, 0.4),
0 0 19px rgba(243, 33, 61, 0.4);
content: ""; content: "";
transform: rotate(-2.5deg); transform: rotate(-2.5deg);
} }
.navLink:hover:after { .navLink:hover:after {
background-color: var(--brand); background-color: var(--brand);
box-shadow: box-shadow: 0 0 4px var(--brand), 0 0 11px var(--brand), 0 0 19px var(--brand), 0 0 30px var(--brand);
0 0 4px var(--brand),
0 0 11px var(--brand),
0 0 19px var(--brand),
0 0 30px var(--brand);
} }
.logoNav { .logoNav {
@@ -44,8 +34,6 @@
.navItems { .navItems {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 1;
flex-wrap: wrap;
padding: 0; padding: 0;
margin: 0; margin: 0;
list-style: none; list-style: none;
@@ -63,47 +51,16 @@
color: white; color: white;
} }
@media (max-width: 640px) {
.nav {
align-items: flex-start;
}
.logoNav {
margin: 0 auto;
}
.navItems {
justify-content: center;
gap: 0.25rem;
}
.navLink {
display: inline-block;
padding: 0.5rem;
font-size: 0.8rem;
}
.brand {
padding-left: 0;
font-size: 2.5rem;
}
}
/* FIXME: Duplicates Splash styles */ /* FIXME: Duplicates Splash styles */
.brand { .brand {
padding-left: 1rem; padding-left: 1rem;
color: var(--brand); color: var(--brand);
font-family: font-family: Rocket Rinder, monospace;
Rocket Rinder,
monospace;
font-size: 3rem; font-size: 3rem;
font-style: italic; font-style: italic;
letter-spacing: 2px; letter-spacing: 2px;
line-height: 1; line-height: 1;
text-shadow: text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
0 0 4px rgba(243, 33, 61, 0.8),
0 0 11px rgba(243, 33, 61, 0.8),
0 0 19px rgba(243, 33, 61, 0.8);
} }
.subtitle { .subtitle {
@@ -111,9 +68,7 @@
left: -8px; left: -8px;
animation: flicker 1.5s infinite alternate; animation: flicker 1.5s infinite alternate;
color: var(--interlace); color: var(--interlace);
font-family: font-family: Rocket Clouds DEMO, serif;
Rocket Clouds DEMO,
serif;
font-size: 10px; font-size: 10px;
font-weight: bold; font-weight: bold;
line-height: 1; line-height: 1;
@@ -128,10 +83,7 @@
53%, 53%,
57%, 57%,
100% { 100% {
text-shadow: text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
0 0 4px rgba(121, 152, 238, 0.8),
0 0 11px rgba(121, 152, 238, 0.8),
0 0 19px rgba(121, 152, 238, 0.8);
} }
20%, 20%,
+1 -11
View File
@@ -8,7 +8,7 @@ const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive }); const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
export const Header = (): React.JSX.Element => { export const Header = (): JSX.Element => {
return ( return (
<header className={styles.header}> <header className={styles.header}>
<nav className={styles.nav}> <nav className={styles.nav}>
@@ -32,16 +32,6 @@ export const Header = (): React.JSX.Element => {
Logg Logg
</NavLink> </NavLink>
</li> </li>
<li>
<NavLink to="/inventory" className={navLinkStyles}>
Varebeholdning
</NavLink>
</li>
<li>
<NavLink to="/report" className={navLinkStyles}>
Rapport
</NavLink>
</li>
<li> <li>
<NavLink to="/stats" className={navLinkStyles}> <NavLink to="/stats" className={navLinkStyles}>
Statistikk Statistikk
+6 -20
View File
@@ -18,31 +18,22 @@
.sun { .sun {
width: clamp(100px, 65vw, 600px); width: clamp(100px, 65vw, 600px);
height: clamp(100px, 65vw, 600px); height: clamp(100px, 65vw, 600px);
border-radius: 50% 50%;
margin: 0 auto; margin: 0 auto;
background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%); background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%);
box-shadow: border-radius: 50% 50%;
0 0 4px var(--sun), box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun), 0 0 30px var(--sun);
0 0 11px var(--sun),
0 0 19px var(--sun),
0 0 30px var(--sun);
} }
.brand { .brand {
position: relative; position: relative;
left: -0.6rem; left: -0.6rem;
padding-top: 3rem; padding-top: 3rem;
color: var(--brand); color: var(--brand);
font-family: font-family: Rocket Rinder, monospace;
Rocket Rinder,
monospace;
font-size: clamp(20px, 20vw, 200px); font-size: clamp(20px, 20vw, 200px);
font-style: italic; font-style: italic;
letter-spacing: 1rem; letter-spacing: 1rem;
line-height: 1; line-height: 1;
text-shadow: text-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
0 0 4px rgba(243, 33, 61, 0.8),
0 0 11px rgba(243, 33, 61, 0.8),
0 0 19px rgba(243, 33, 61, 0.8);
} }
.subtitle { .subtitle {
@@ -51,9 +42,7 @@
left: 0rem; left: 0rem;
animation: flicker 1.5s infinite alternate; animation: flicker 1.5s infinite alternate;
color: var(--interlace); color: var(--interlace);
font-family: font-family: Rocket Clouds DEMO, serif;
Rocket Clouds DEMO,
serif;
font-size: clamp(20px, 5vw, 50px); font-size: clamp(20px, 5vw, 50px);
font-weight: bold; font-weight: bold;
line-height: 1; line-height: 1;
@@ -68,10 +57,7 @@
53%, 53%,
57%, 57%,
100% { 100% {
text-shadow: text-shadow: 0 0 4px rgba(121, 152, 238, 0.8), 0 0 11px rgba(121, 152, 238, 0.8), 0 0 19px rgba(121, 152, 238, 0.8);
0 0 4px rgba(121, 152, 238, 0.8),
0 0 11px rgba(121, 152, 238, 0.8),
0 0 19px rgba(121, 152, 238, 0.8);
} }
20%, 20%,
+1 -1
View File
@@ -8,7 +8,7 @@ interface SplashProps {
noOverlay?: boolean; noOverlay?: boolean;
} }
const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): React.JSX.Element => ( const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): JSX.Element => (
<div className={clsx(styles.splash, { [styles.overlay]: !noOverlay })}> <div className={clsx(styles.splash, { [styles.overlay]: !noOverlay })}>
<div className={styles.wrap}> <div className={styles.wrap}>
<div className={styles.sun}></div> <div className={styles.sun}></div>
+2 -8
View File
@@ -16,17 +16,11 @@
.primary { .primary {
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%); background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
box-shadow: box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
0 0 4px var(--sun),
0 0 11px var(--sun),
0 0 19px var(--sun);
} }
.danger { .danger {
background: var(--brand); background: var(--brand);
box-shadow: box-shadow: 0 0 4px rgba(243, 33, 61, 0.8), 0 0 11px rgba(243, 33, 61, 0.8), 0 0 19px rgba(243, 33, 61, 0.8);
0 0 4px rgba(243, 33, 61, 0.8),
0 0 11px rgba(243, 33, 61, 0.8),
0 0 19px rgba(243, 33, 61, 0.8);
} }
.close { .close {
+2 -2
View File
@@ -2,9 +2,9 @@ import clsx from "clsx";
import React from "react"; import React from "react";
import styles from "./Toast.module.css"; import styles from "./Toast.module.css";
import { useToast } from "../hooks/useToast"; import { useToast } from "./ToastProvider";
export const Toast = (): React.JSX.Element => { export const Toast = (): JSX.Element => {
const { showToast, message, background, visible } = useToast(); const { showToast, message, background, visible } = useToast();
const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible }); const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible });
+29 -9
View File
@@ -1,17 +1,35 @@
import React, { useState } from "react"; import React, { ReactNode, useContext, useState } from "react";
import type { ReactNode } from "react";
import { ToastContext, defaultToastState } from "../contexts/ToastContext"; type Background = "primary" | "danger";
import type { Background } from "../contexts/ToastContext";
interface ToastProps {
visible: boolean;
message: string;
showToast: (message: string, background?: Background) => void;
hide: () => void;
background: Background;
}
const defaultState = {
// FIXME: revert
visible: false,
message: "",
// eslint-disable-next-line @typescript-eslint/no-empty-function
showToast: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
hide: () => {},
background: "primary" as Background,
};
const ToastContext = React.createContext<ToastProps>(defaultState);
type ToastProviderProps = { type ToastProviderProps = {
children: ReactNode; children: ReactNode;
}; };
export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element => {
export const ToastProvider = ({ children }: ToastProviderProps): React.JSX.Element => { const [visible, setVisible] = useState(defaultState.visible);
const [visible, setVisible] = useState(defaultToastState.visible); const [message, setMessage] = useState(defaultState.message);
const [message, setMessage] = useState(defaultToastState.message); const [background, setBackground] = useState(defaultState.background);
const [background, setBackground] = useState(defaultToastState.background);
const showToast = (newMessage: string, background: Background = "primary") => { const showToast = (newMessage: string, background: Background = "primary") => {
setMessage(newMessage); setMessage(newMessage);
@@ -31,3 +49,5 @@ export const ToastProvider = ({ children }: ToastProviderProps): React.JSX.Eleme
<ToastContext.Provider value={{ visible, showToast, message, hide, background }}>{children}</ToastContext.Provider> <ToastContext.Provider value={{ visible, showToast, message, hide, background }}>{children}</ToastContext.Provider>
); );
}; };
export const useToast = (): ToastProps => useContext(ToastContext);
+7 -21
View File
@@ -1,31 +1,23 @@
.decoration {
position: absolute;
z-index: -1;
top: 0;
right: 0;
left: 0;
height: 100vh;
overflow: clip;
pointer-events: none;
}
.wireframe { .wireframe {
position: absolute; position: absolute;
bottom: calc(22% - 512px); bottom: calc(22% - 512px);
left: calc(50% - 512px); left: calc(50% - 512px);
display: grid;
width: 1024px; width: 1024px;
height: 1024px; height: 1024px;
animation-duration: 10s; display: grid;
grid-template-columns: repeat(8, 128px);
animation-iteration-count: infinite; animation-iteration-count: infinite;
animation-name: isometric-rotate; animation-name: isometric-rotate;
animation-duration: 10s;
animation-timing-function: linear; animation-timing-function: linear;
grid-template-columns: repeat(8, 128px); pointer-events: none;
z-index: -1;
} }
.cell { .cell {
border-right: 2px solid var(--sun);
border-bottom: 2px solid var(--sun); border-bottom: 2px solid var(--sun);
border-right: 2px solid var(--sun);
} }
.cell:nth-child(8n) { .cell:nth-child(8n) {
@@ -36,12 +28,6 @@
border-bottom: none; border-bottom: none;
} }
@media (max-width: 640px) {
.decoration {
display: none;
}
}
@keyframes isometric-rotate { @keyframes isometric-rotate {
0% { 0% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg); transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg);
+2 -2
View File
@@ -1,11 +1,11 @@
import React from "react";
import styles from "./Wireframe.module.css"; import styles from "./Wireframe.module.css";
export const Wireframe = () => ( export const Wireframe = () => (
<div className={styles.decoration}>
<div className={styles.wireframe}> <div className={styles.wireframe}>
{[...Array(64)].map((_, idx) => ( {[...Array(64)].map((_, idx) => (
<div key={idx} className={styles.cell} /> <div key={idx} className={styles.cell} />
))} ))}
</div> </div>
</div>
); );
-22
View File
@@ -1,22 +0,0 @@
import React from "react";
export type Background = "primary" | "danger";
export interface ToastProps {
visible: boolean;
message: string;
showToast: (message: string, background?: Background) => void;
hide: () => void;
background: Background;
}
export const defaultToastState: ToastProps = {
// FIXME: revert
visible: false,
message: "",
showToast: () => {},
hide: () => {},
background: "primary",
};
export const ToastContext = React.createContext<ToastProps>(defaultToastState);
@@ -3,8 +3,3 @@
max-width: 500px; max-width: 500px;
gap: 1rem; gap: 1rem;
} }
.form input {
width: 100%;
min-width: 0;
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { useMutation } from "@apollo/client/react"; import { useMutation } from "@apollo/client";
import React from "react";
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./UserCreate.module.css";
import { CreateUserDocument } from "../../types"; import { CreateUserDocument } from "../../types";
import styles from "./UserCreate.module.css";
type FormValues = { type FormValues = {
firstName: string; firstName: string;
@@ -1,113 +1,17 @@
.deposit {
display: grid;
align-items: start;
gap: 1rem 2rem;
grid-template:
"heading amount" auto
"search amount" auto
"users amount" 1fr
/ minmax(0, 1fr) minmax(260px, 320px);
}
.heading {
grid-area: heading;
margin-bottom: 0;
}
.search {
display: flex;
align-items: center;
gap: 0.75rem;
grid-area: search;
}
.search input {
width: min(100%, 420px);
}
.userList { .userList {
display: grid; display: grid;
max-height: min(62vh, 680px); height: 800px;
padding: 0.5rem 0.5rem 0.5rem 0; padding-top: 2rem;
gap: 1rem; grid-template-columns: repeat(5, 220px);
grid-area: users;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
overflow-y: auto; overflow-y: auto;
column-gap: 16px;
row-gap: 24px;
} }
.amountSection { .amountSection {
position: sticky; margin-top: 32px;
top: 1rem;
padding: 1.25rem;
border: 1px solid rgba(221, 81, 127, 0.45);
border-radius: 8px;
background: rgba(35, 15, 41, 0.84);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
grid-area: amount;
}
.amountSection h2 {
margin-top: 0;
}
.selectedUser {
min-height: 1.5em;
margin: 0 0 1.25rem;
color: rgba(255, 255, 255, 0.78);
text-shadow: none;
}
.selectedUser strong {
color: var(--text);
}
.amountLabel {
display: block;
margin-bottom: 0.4rem;
color: var(--sun);
}
.amountInput {
display: flex;
align-items: center;
gap: 0.75rem;
}
.amountInput input {
width: 100%;
min-width: 0;
font-size: 1.8rem;
}
.amountInput span {
color: rgba(255, 255, 255, 0.8);
text-shadow: none;
} }
.checkout { .checkout {
display: flex; margin-top: 32px;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
@media (max-width: 760px) {
.deposit {
gap: 0.75rem;
grid-template:
"heading" auto
"search" auto
"amount" auto
"users" auto
/ minmax(0, 1fr);
}
.amountSection {
position: static;
}
.userList {
max-height: 45vh;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
} }
+22 -45
View File
@@ -1,20 +1,19 @@
import React from "react"; import React from "react";
import { UserCard } from "../users/UserCard";
import styles from "./Deposit.module.css"; import styles from "./Deposit.module.css";
import { useDeposit } from "./useDeposit"; import { useDeposit } from "./useDeposit";
import { UserCard } from "../users/UserCard";
const Deposit = (): React.JSX.Element => { const Deposit = (): JSX.Element => {
const { const {
loading, loading,
selectedUser, selectedUser,
toggleSelectedUser, setSelectedUser,
amount, amount,
setAmount, setAmount,
userQuery, userQuery,
setUserQuery, setUserQuery,
filteredUsers, filteredUsers,
selectedUserDetails,
reset, reset,
onSubmit, onSubmit,
} = useDeposit(); } = useDeposit();
@@ -24,67 +23,45 @@ const Deposit = (): React.JSX.Element => {
} }
return ( return (
<div className={styles.deposit}> <div>
<h2 className={styles.heading}>Hvem skal sette inn penger?</h2> <section>
<div className={styles.search}> <h2>Hvem skal sette inn penger?</h2>
<input <input
name="userQuery" name="userQuery"
onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)} onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)}
value={userQuery} value={userQuery}
placeholder="Filtrér på navn" placeholder="Filtrér på navn"
aria-label="Filtrér personer på navn"
/> />
{userQuery !== "" && ( {userQuery !== "" && (
<button type="button" onClick={() => setUserQuery("")}> <button type="button" onClick={() => setUserQuery("")}>
Vis alle Vis alle
</button> </button>
)} )}
</div>
<section className={styles.amountSection} aria-label="Innskudd">
<h2>Hvor mye?</h2>
{selectedUserDetails ? (
<p className={styles.selectedUser}>
Innskudd til{" "}
<strong>
{selectedUserDetails.firstName} {selectedUserDetails.lastName}
</strong>
</p>
) : (
<p className={styles.selectedUser}>Velg en person fra listen</p>
)}
<label className={styles.amountLabel} htmlFor="deposit-amount">
Beløp
</label>
<div className={styles.amountInput}>
<input
id="deposit-amount"
name="amount"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
placeholder="0"
/>
<span>kr</span>
</div>
<div className={styles.checkout}>
<button type="button" onClick={onSubmit} className="primary">
Sett inn
</button>
<button type="button" onClick={reset}>
Start på nytt
</button>
</div>
</section>
<div className={styles.userList}> <div className={styles.userList}>
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<UserCard <UserCard
key={user.id} key={user.id}
active={selectedUser === user.id} active={selectedUser === user.id}
onUser={() => toggleSelectedUser(user.id)} onUser={() => {
setSelectedUser(user.id);
}}
user={user} user={user}
/> />
))} ))}
</div> </div>
</section>
<section className={styles.amountSection}>
<h2>Hvor mye?</h2>
<input name="amount" value={amount} onChange={(event) => setAmount(event.target.value)} placeholder="0" />
</section>
<section className={styles.checkout}>
<button type="button" onClick={onSubmit} className="primary">
Sett inn
</button>{" "}
<button type="button" onClick={reset}>
Start på nytt
</button>
</section>
</div> </div>
); );
}; };
+7 -12
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client";
import { useState } from "react"; import { useState } from "react";
import { useToast } from "../../hooks/useToast"; import { useToast } from "../../components/ToastProvider";
import { AllUsersDocument, CreateDepositDocument } from "../../types"; import { AllUsersDocument, CreateDepositDocument } from "../../types";
export const useDeposit = () => { export const useDeposit = () => {
@@ -10,13 +10,9 @@ export const useDeposit = () => {
const [selectedUser, setSelectedUser] = useState(""); const [selectedUser, setSelectedUser] = useState("");
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [userQuery, setUserQuery] = useState(""); const [userQuery, setUserQuery] = useState("");
const toggleSelectedUser = (userId: string) => {
setSelectedUser((currentUserId) => (currentUserId === userId ? "" : userId));
};
const filteredUsers = (data?.allUsers || []).filter((user) => const filteredUsers = (data?.allUsers || []).filter((user) =>
`${user.firstName.toLowerCase()} ${user.lastName.toLowerCase()}`.includes(userQuery.toLowerCase()), `${user.firstName.toLowerCase()} ${user.lastName.toLowerCase()}`.includes(userQuery.toLowerCase())
); );
const selectedUserDetails = data?.allUsers.find((user) => user.id === selectedUser);
const { showToast } = useToast(); const { showToast } = useToast();
const onSubmit = async () => { const onSubmit = async () => {
@@ -32,9 +28,9 @@ export const useDeposit = () => {
} }
if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") { if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") {
const { transaction } = res.data.createDeposit; const { transaction } = res.data?.createDeposit;
showToast( showToast(
`${transaction.user.firstName} har satt inn ${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.`, `${transaction.user.firstName} har satt inn${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.`
); );
reset(); reset();
return; return;
@@ -46,7 +42,7 @@ export const useDeposit = () => {
if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") { if (res.data?.createDeposit?.__typename === "AboveMaxSaldo") {
showToast( showToast(
`${res.data.createDeposit.message}. Ny saldo ville vært ${res.data.createDeposit.above} mer enn maksimalt ${res.data.createDeposit.maxSaldo}.`, `${res.data.createDeposit.message}. Ny saldo ville vært ${res.data.createDeposit.above} mer enn maksimalt ${res.data.createDeposit.maxSaldo}.`,
"danger", "danger"
); );
} }
}; };
@@ -59,13 +55,12 @@ export const useDeposit = () => {
return { return {
loading, loading,
selectedUser, selectedUser,
toggleSelectedUser, setSelectedUser,
amount, amount,
setAmount, setAmount,
userQuery, userQuery,
setUserQuery, setUserQuery,
filteredUsers, filteredUsers,
selectedUserDetails,
reset, reset,
onSubmit, onSubmit,
}; };
@@ -1,71 +0,0 @@
.section {
margin-top: 2rem;
}
.tableWrap {
max-width: 100%;
overflow-x: auto;
}
.tableWrap table {
width: 100%;
}
.inactive {
opacity: 0.65;
}
.transactions {
margin-top: 2rem;
}
.pagination {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.form {
display: grid;
max-width: 36rem;
gap: 1rem;
}
.form label {
display: grid;
gap: 0.35rem;
}
.form textarea,
.form select {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: rgb(35, 15, 41);
color: var(--text);
font: inherit;
}
.form textarea {
min-height: 6rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 1rem 2rem;
}
.report {
margin-top: 2rem;
}
.error {
color: var(--sun);
}
@media (max-width: 640px) {
.tableWrap table {
min-width: 42rem;
}
}
@@ -1,126 +0,0 @@
import { useQuery } from "@apollo/client/react";
import { useState } from "react";
import { Link } from "react-router-dom";
import styles from "./Inventory.module.css";
import { GQLError } from "../../components/GQLError";
import { InventoryPageDocument } from "../../types";
import { formatCurrency } from "../../utils/currency";
import { formatTime } from "../../utils/time";
const PAGE_SIZE = 20;
const Inventory = () => {
const [offset, setOffset] = useState(0);
const { data, loading, error } = useQuery(InventoryPageDocument, {
variables: { limit: PAGE_SIZE, offset },
});
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const products = [...(data?.allProducts || [])].sort(
(a, b) =>
Number(b.active) - Number(a.active) || a.inventoryAmount - b.inventoryAmount || a.name.localeCompare(b.name),
);
const activeProducts = products.filter((product) => product.active);
const transactions = data?.inventoryTransactionList || [];
return (
<div>
<h2>Varebeholdning</h2>
<div className={styles.summary}>
<div>Antall aktive varer: {activeProducts.reduce((total, product) => total + product.inventoryAmount, 0)}</div>
<div>
Innkjøpsverdi:{" "}
{formatCurrency(activeProducts.reduce((total, product) => total + Number(product.wholesaleValue), 0))} kr
</div>
</div>
<section className={styles.section}>
<div className={styles.summary}>
<h3>Varer</h3>
<Link className="btn-primary" to="/inventory/add">
Registrer varetransaksjon
</Link>
<Link to="/report">Åpne rapport</Link>
</div>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Vare</th>
<th scope="col">Beholdning</th>
<th scope="col">Innkjøpspris</th>
<th scope="col">Verdi</th>
<th scope="col">I salg</th>
<th scope="col">Sist oppdatert</th>
</tr>
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id} className={!product.active ? styles.inactive : undefined}>
<td>{product.name}</td>
<td>{product.inventoryAmount}</td>
<td>{formatCurrency(Number(product.wholesaleUnitPrice))} kr</td>
<td>{formatCurrency(Number(product.wholesaleValue))} kr</td>
<td>{product.active ? "Ja" : "Nei"}</td>
<td>{formatTime(product.updated)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className={styles.transactions}>
<h3>Varetransaksjoner</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Når</th>
<th scope="col">Vare</th>
<th scope="col">Antall</th>
<th scope="col">Enhetspris</th>
<th scope="col">Hvem</th>
<th scope="col">Kommentar</th>
</tr>
</thead>
<tbody>
{transactions.map((transaction) => (
<tr key={transaction.id}>
<td>{formatTime(transaction.created)}</td>
<td>{transaction.product.name}</td>
<td>{transaction.amount}</td>
<td>{formatCurrency(Number(transaction.unitPrice))} kr</td>
<td>
{transaction.user.firstName} {transaction.user.lastName}
</td>
<td>{transaction.comment}</td>
</tr>
))}
{!transactions.length && (
<tr>
<td colSpan={6}>Ingen varetransaksjoner ennå.</td>
</tr>
)}
</tbody>
</table>
</div>
<div className={styles.pagination}>
<button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Forrige
</button>
<button
type="button"
disabled={transactions.length < PAGE_SIZE}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Neste
</button>
</div>
</section>
</div>
);
};
export default Inventory;
@@ -1,104 +0,0 @@
import { useMutation, useQuery } from "@apollo/client/react";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import styles from "./Inventory.module.css";
import { GQLError } from "../../components/GQLError";
import { useToast } from "../../hooks/useToast";
import { CreateInventoryTransactionDocument, InventoryProductsDocument } from "../../types";
const LOGIN_URL = `${import.meta.env.VITE_API_URL}/accounts/login/?next=/inventory/add`;
const InventoryAdd = () => {
const navigate = useNavigate();
const { showToast } = useToast();
const { data, loading, error } = useQuery(InventoryProductsDocument);
const [createInventoryTransaction, { loading: saving }] = useMutation(CreateInventoryTransactionDocument);
const [productId, setProductId] = useState("");
const [amount, setAmount] = useState("");
const [unitPrice, setUnitPrice] = useState("");
const [comment, setComment] = useState("");
const products = data?.allProducts || [];
if (loading) return <div>Loading...</div>;
if (error) return <GQLError error={error} />;
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const result = await createInventoryTransaction({
variables: {
data: {
productId,
amount: Number(amount),
unitPrice,
comment,
},
},
});
const response = result.data?.createInventoryTransaction;
if (response?.__typename === "CreateInventoryTransactionSuccess") {
showToast(`Registrerte ${amount} stk ${response.inventoryTransaction.product.name}.`);
navigate("/inventory");
} else if (response?.__typename === "FormErrors") {
showToast(response.message, "danger");
} else {
showToast("Kunne ikke registrere varetransaksjonen.", "danger");
}
} catch (mutationError) {
showToast(
mutationError instanceof Error ? mutationError.message : "Kunne ikke registrere varetransaksjonen.",
"danger",
);
}
};
return (
<div>
<h2>Registrer varetransaksjon</h2>
<p>
Du må være innlogget for å registrere varer. <a href={LOGIN_URL}>Logg inn</a>.
</p>
<form className={styles.form} onSubmit={onSubmit}>
<label>
Vare
<select required value={productId} onChange={(event) => setProductId(event.target.value)}>
<option value="">Velg vare</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.name} ({product.inventoryAmount} på lager)
</option>
))}
</select>
</label>
<label>
Antall
<input required type="number" step="1" value={amount} onChange={(event) => setAmount(event.target.value)} />
</label>
<label>
Innkjøpspris per enhet (kr)
<input
required
step="0.1"
type="number"
value={unitPrice}
onChange={(event) => setUnitPrice(event.target.value)}
/>
</label>
<label>
Kommentar
<textarea value={comment} onChange={(event) => setComment(event.target.value)} />
</label>
<div className={styles.summary}>
<button type="submit" className="primary" disabled={saving || !products.length}>
{saving ? "Lagrer..." : "Registrer"}
</button>
<Link to="/inventory">Avbryt</Link>
</div>
</form>
{!products.length && <p>Ingen aktive varer å registrere.</p>}
</div>
);
};
export default InventoryAdd;
@@ -1,138 +0,0 @@
import { useQuery } from "@apollo/client/react";
import { format, subDays } from "date-fns";
import React, { useState } from "react";
import styles from "./Inventory.module.css";
import { GQLError } from "../../components/GQLError";
import { InventoryReportDocument } from "../../types";
import { formatCurrency } from "../../utils/currency";
const today = () => format(new Date(), "yyyy-MM-dd");
const monthAgo = () => format(subDays(new Date(), 30), "yyyy-MM-dd");
const timestampAt = (date: string, endOfDay = false) => `${date}T${endOfDay ? "23:59:59.999999" : "00:00:00"}`;
const InventoryReport = () => {
const [startDate, setStartDate] = useState(monthAgo);
const [endDate, setEndDate] = useState(today);
const [appliedRange, setAppliedRange] = useState({ startDate: monthAgo(), endDate: today() });
const invalidRange = startDate > endDate;
const { data, loading, error } = useQuery(InventoryReportDocument, {
variables: {
startTime: timestampAt(appliedRange.startDate),
endTime: timestampAt(appliedRange.endDate, true),
},
});
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!invalidRange) setAppliedRange({ startDate, endDate });
};
if (error) return <GQLError error={error} />;
const report = data?.inventoryReport;
const balanceChange = report ? Number(report.balanceAtEnd) - Number(report.balanceAtStart) : 0;
const inventoryChange = report ? Number(report.inventoryPurchases) - Number(report.productSales) : 0;
return (
<div>
<h2>Varerapport</h2>
<form className={styles.summary} onSubmit={onSubmit}>
<label>
Fra
<input type="date" required value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</label>
<label>
Til
<input type="date" required value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</label>
<button type="submit" disabled={invalidRange}>
Oppdater
</button>
</form>
{invalidRange && <p className={styles.error}>Startdato må være før eller lik sluttdato.</p>}
{loading && <div>Loading...</div>}
{report && (
<>
<section className={styles.report}>
<h3>Beholdningsendring</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Vare</th>
<th scope="col">Innkjøp</th>
<th scope="col">Salg</th>
<th scope="col">Endring verdi</th>
</tr>
</thead>
<tbody>
{report.products.map((product) => (
<tr key={product.productId}>
<td>{product.productName}</td>
<td>
{formatCurrency(Number(product.purchaseValue))} kr ({product.purchaseUnits} stk)
</td>
<td>
{formatCurrency(Number(product.salesValue))} kr ({product.salesUnits} stk)
</td>
<td>{formatCurrency(Number(product.purchaseValue) - Number(product.salesValue))} kr</td>
</tr>
))}
{!report.products.length && (
<tr>
<td colSpan={4}>Ingen varetransaksjoner i perioden.</td>
</tr>
)}
<tr>
<th scope="row">Totalt</th>
<td>{formatCurrency(Number(report.inventoryPurchases))} kr</td>
<td>{formatCurrency(Number(report.productSales))} kr</td>
<td>{formatCurrency(inventoryChange)} kr</td>
</tr>
</tbody>
</table>
</div>
</section>
<section className={styles.report}>
<h3>Innskudd og kjøp</h3>
<div className={styles.tableWrap}>
<table>
<thead>
<tr>
<th scope="col">Post</th>
<th scope="col">Saldo</th>
<th scope="col">Innskudd</th>
<th scope="col">Kjøp</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Saldo ved periodestart</th>
<td>{formatCurrency(Number(report.balanceAtStart))} kr</td>
<td />
<td />
</tr>
<tr>
<th scope="row">Endring i perioden</th>
<td>{formatCurrency(balanceChange)} kr</td>
<td>{formatCurrency(Number(report.deposits))} kr</td>
<td>{formatCurrency(Number(report.orderPurchases))} kr</td>
</tr>
<tr>
<th scope="row">Saldo ved periodeslutt</th>
<td>{formatCurrency(Number(report.balanceAtEnd))} kr</td>
<td />
<td />
</tr>
</tbody>
</table>
</div>
</section>
</>
)}
</div>
);
};
export default InventoryReport;
@@ -1,20 +0,0 @@
mutation createInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on CreateInventoryTransactionSuccess {
inventoryTransaction {
id
product {
name
inventoryAmount
}
}
}
... on FormErrors {
message
fields {
field
message
}
}
}
}
@@ -1,25 +0,0 @@
query inventoryPage($limit: Int = 20, $offset: Int = 0) {
allProducts {
id
name
active
inventoryAmount
updated
wholesaleUnitPrice
wholesaleValue
}
inventoryTransactionList(ordering: [{ created: DESC }], pagination: { limit: $limit, offset: $offset }) {
id
created
amount
unitPrice
comment
product {
name
}
user {
firstName
lastName
}
}
}
@@ -1,7 +0,0 @@
query inventoryProducts {
allProducts(filters: { active: true }) {
id
name
inventoryAmount
}
}
@@ -1,18 +0,0 @@
query inventoryReport($startTime: DateTime!, $endTime: DateTime!) {
inventoryReport(startTime: $startTime, endTime: $endTime) {
products {
productId
productName
purchaseUnits
purchaseValue
salesUnits
salesValue
}
inventoryPurchases
productSales
deposits
orderPurchases
balanceAtStart
balanceAtEnd
}
}
+2 -29
View File
@@ -1,17 +1,7 @@
.log { .log {
display: grid; display: grid;
min-width: 0;
gap: 1rem; gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, 1fr);
}
.tableWrap {
max-width: 100%;
overflow-x: auto;
}
.tableWrap table {
width: 100%;
} }
.disabled, .disabled,
.disabled a:link { .disabled a:link {
@@ -30,8 +20,8 @@
.pagination a { .pagination a {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px;
margin-top: 1rem; margin-top: 1rem;
border-radius: 4px;
} }
.pagination a:hover { .pagination a:hover {
color: white; color: white;
@@ -40,20 +30,3 @@
.disabled a:hover { .disabled a:hover {
color: rgba(121, 152, 238, 0.5); color: rgba(121, 152, 238, 0.5);
} }
@media (max-width: 760px) {
.log {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 480px) {
.pagination {
gap: 0 0.5rem;
}
.pagination a {
display: inline-block;
padding: 0.6rem 0.8rem;
}
}
+6 -9
View File
@@ -1,10 +1,11 @@
import { useQuery } from "@apollo/client/react"; import { useQuery } from "@apollo/client";
import clsx from "clsx"; import clsx from "clsx";
import React from "react";
import { useUrlSearchParams } from "use-url-search-params"; import { useUrlSearchParams } from "use-url-search-params";
import styles from "./Log.module.css";
import { OrderListDocument, TransactionListDocument } from "../../types"; import { OrderListDocument, TransactionListDocument } from "../../types";
import { formatTime } from "../../utils/time"; import { formatTime } from "../../utils/time";
import styles from "./Log.module.css";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -33,7 +34,6 @@ const Log = () => {
<div className={styles.log}> <div className={styles.log}>
<div> <div>
<h2>Ordre</h2> <h2>Ordre</h2>
<div className={styles.tableWrap}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -51,16 +51,15 @@ const Log = () => {
<td> <td>
{(order.orderlines ?? []).map((line) => ( {(order.orderlines ?? []).map((line) => (
<div key={line.id}> <div key={line.id}>
{Number(line.amount)}x {line.product.name} {line.amount}x {line.product.name}
</div> </div>
))} ))}
</td> </td>
<td>{Number(order.orderSum)}</td> <td>{order.orderSum}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
<nav aria-label="Order pagination"> <nav aria-label="Order pagination">
<ul className={styles.pagination}> <ul className={styles.pagination}>
<li className={clsx({ [styles.disabled]: orderOffset <= 0 })}> <li className={clsx({ [styles.disabled]: orderOffset <= 0 })}>
@@ -90,7 +89,6 @@ const Log = () => {
</div> </div>
<div> <div>
<h2>Transaksjoner</h2> <h2>Transaksjoner</h2>
<div className={styles.tableWrap}>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -106,12 +104,11 @@ const Log = () => {
<td> <td>
{trans.user.firstName} {trans.user.lastName} {trans.user.firstName} {trans.user.lastName}
</td> </td>
<td>{Number(trans.amount)}</td> <td>{trans.amount}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
<nav aria-label="Transaction pagination"> <nav aria-label="Transaction pagination">
<ul className={styles.pagination}> <ul className={styles.pagination}>
<li className={clsx({ [styles.disabled]: transactionOffset <= 0 })}> <li className={clsx({ [styles.disabled]: transactionOffset <= 0 })}>
+1 -1
View File
@@ -1,5 +1,5 @@
query orderList($limit: Int = 10, $offset: Int = 0) { query orderList($limit: Int = 10, $offset: Int = 0) {
orderList(ordering: [{created: DESC}], pagination: {limit: $limit, offset: $offset}) { orderList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
id id
isExternal isExternal
created created
@@ -1,5 +1,5 @@
query transactionList($limit: Int = 10, $offset: Int = 0) { query transactionList($limit: Int = 10, $offset: Int = 0) {
transactionList(ordering: [{created: DESC}], pagination: {limit: $limit, offset: $offset}) { transactionList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
id id
created created
amount amount
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more