Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bdf26c4f6
|
||
|
|
060ea46bc4
|
||
|
|
b2684d537e
|
||
|
|
edacf81401
|
||
|
|
60108b9d89
|
||
|
|
6fcdaca833
|
||
|
|
3ade11111b
|
||
|
|
336f6afa5d
|
||
|
|
0a65bb2c90
|
||
|
|
ff7698f9c1
|
||
|
|
b9e93a5ea2
|
||
|
|
c2f30c8ca4
|
||
|
|
6ae99528dd
|
||
|
|
58a415494a
|
||
|
|
5a12bf951a
|
||
|
|
1c9a7411f8
|
||
|
|
d5a548cfb3
|
||
|
|
a5ba3bb1d3 | ||
|
|
53968fa949
|
||
|
|
d979558905
|
||
|
|
78d5b06258
|
||
|
|
4848dda4a4
|
||
|
|
1d507d1976
|
||
|
|
ec5e8dc791
|
||
|
|
ac2ceddf78
|
||
|
|
c05820f61b
|
||
|
|
4d64126581
|
||
|
|
9ad7b8aa28
|
||
|
|
64b77217f4
|
||
|
|
65e532be30
|
||
|
|
407523b61a
|
||
|
|
e23cbb1d27
|
||
|
|
098c6701f2
|
||
|
|
18da8335a8
|
||
|
|
4a36cc140c | ||
|
|
f1ea46188f | ||
|
|
dd6a559ac6 | ||
|
|
5d889ee32c | ||
|
|
2b1cdb28ea | ||
|
|
fbd55bd3a2 | ||
|
|
71f158da6e | ||
|
|
8b364b0887 | ||
|
|
28d747d74d | ||
|
|
6f964a0c57 | ||
|
|
d256e0eb7e | ||
|
|
cac189003a
|
No files matched your search
@@ -0,0 +1,14 @@
|
|||||||
|
.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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
name: "Lint: prek"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prek:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: j178/prek-action@v1
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
@@ -15,3 +15,4 @@ static/assets
|
|||||||
static/index.html
|
static/index.html
|
||||||
.python-version
|
.python-version
|
||||||
.env
|
.env
|
||||||
|
.coverage
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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]
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
extends: default
|
||||||
|
|
||||||
|
rules:
|
||||||
|
document-start: disable
|
||||||
|
line-length:
|
||||||
|
max: 120
|
||||||
|
truthy:
|
||||||
|
allowed-values: ["true", "false", "on", "off"]
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
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"]
|
||||||
@@ -1,37 +1,52 @@
|
|||||||
# Getting started
|
# Getting started
|
||||||
|
|
||||||
Before starting, make sure you have a working python and nodejs setup with pyenv and nvm.
|
Before starting, make sure you have [mise](https://mise.jdx.dev) installed. It manages Python, Node, uv and prek.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
apt-get install libmysqlclient-dev
|
mise install
|
||||||
poetry install
|
prek install
|
||||||
poetry shell
|
uv run manage.py migrate
|
||||||
pre-commit install
|
|
||||||
python manage.py migrate
|
|
||||||
bin/run
|
|
||||||
cp .env-default .env
|
cp .env-default .env
|
||||||
|
uv run manage.py seed_demo_data
|
||||||
|
|
||||||
# in another terminal
|
# Optional: create an admin login
|
||||||
cd frontend
|
uv run manage.py createsuperuser
|
||||||
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
|
|
||||||
- Stats: Implement users tab
|
|
||||||
|
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
Executable → Regular
File mode changed.
+9
-2
@@ -1,13 +1,20 @@
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
+1
-3
@@ -1,5 +1,3 @@
|
|||||||
from typing import List
|
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +17,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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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
|
||||||
+20
-14
@@ -1,4 +1,4 @@
|
|||||||
from typing import List
|
from typing import Annotated, Union
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -27,20 +27,22 @@ class CreateOrderSuccess:
|
|||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class InsufficientFunds(Error):
|
class InsufficientFunds(Error): # noqa: N818
|
||||||
amount_lacking: float
|
amount_lacking: float
|
||||||
|
|
||||||
|
|
||||||
CreateOrderResponse = strawberry.union("CreateOrderResponse", [CreateOrderSuccess, InsufficientFunds, FormErrors])
|
CreateOrderResponse = Annotated[
|
||||||
|
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:
|
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: # noqa: FBT001
|
||||||
# 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:
|
||||||
@@ -63,15 +65,17 @@ 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.", amount_lacking=abs(order_sum - user.profile.balance)
|
message="Du har ikke nok penger på bok.",
|
||||||
|
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
|
||||||
@@ -91,12 +95,14 @@ class CreateDepositSuccess:
|
|||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class AboveMaxSaldo(Error):
|
class AboveMaxSaldo(Error): # noqa: N818
|
||||||
max_saldo: float
|
max_saldo: float
|
||||||
above: float
|
above: float
|
||||||
|
|
||||||
|
|
||||||
CreateDepositResponse = strawberry.union("CreateDepositResponse", [CreateDepositSuccess, AboveMaxSaldo, FormErrors])
|
CreateDepositResponse = Annotated[
|
||||||
|
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
@@ -105,7 +111,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,
|
||||||
@@ -118,11 +124,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:
|
if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337: # noqa: PLR2004
|
||||||
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=amount + user.profile.balance - settings.BBS_SALDO_MAX,
|
above=float(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)
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth.models import User as DjangoUser
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from strawberry_django import auto
|
from strawberry 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:
|
||||||
@@ -19,9 +17,12 @@ class UserInput:
|
|||||||
|
|
||||||
@strawberry.mutation
|
@strawberry.mutation
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def register_mutation(input: UserInput) -> User:
|
def register_mutation(input: UserInput) -> User: # noqa: A002
|
||||||
user = DjangoUser.objects.create_user(
|
user = DjangoUser.objects.create_user(
|
||||||
first_name=input.first_name, last_name=input.last_name, email=input.email, username=input.email
|
first_name=input.first_name,
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,11 +1,105 @@
|
|||||||
from typing import List
|
from datetime import datetime
|
||||||
|
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.api.types.inventory import Transaction, TransactionOrdering
|
from bbs import models
|
||||||
|
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, order=TransactionOrdering)
|
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=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,
|
||||||
|
)
|
||||||
+17
-8
@@ -1,17 +1,23 @@
|
|||||||
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 Order, OrderOrdering, OrderStats, OrderStatsByTime, Point, Product, ProductOrderStat
|
from bbs.api.types.shop import (
|
||||||
|
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, order=OrderOrdering)
|
order_list: list[Order] = strawberry_django.field(pagination=True, ordering=OrderOrdering)
|
||||||
|
|
||||||
@strawberry.field
|
@strawberry.field
|
||||||
def order_stats(self) -> OrderStats:
|
def order_stats(self) -> OrderStats:
|
||||||
@@ -26,8 +32,11 @@ class ShopQueries:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@strawberry.field
|
@strawberry.field
|
||||||
def product_stats(self) -> List[ProductOrderStat]:
|
def product_stats(self) -> list[ProductOrderStat]:
|
||||||
return [
|
return [
|
||||||
ProductOrderStat(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]])
|
ProductOrderStat(
|
||||||
|
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()
|
||||||
]
|
]
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -10,7 +7,9 @@ from bbs.api.types.users import User
|
|||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class UsersQueries:
|
class UsersQueries:
|
||||||
all_users: List[User] = strawberry_django.field()
|
@strawberry.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:
|
||||||
|
|||||||
+6
-4
@@ -1,7 +1,8 @@
|
|||||||
import strawberry
|
import strawberry
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from strawberry.extensions import Extension
|
from strawberry.extensions import SchemaExtension
|
||||||
|
|
||||||
|
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
|
||||||
@@ -15,12 +16,13 @@ class Query(InventoryQueries, ShopQueries, UsersQueries):
|
|||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class Mutation(ShopMutations, UsersMutations):
|
class Mutation(InventoryMutations, ShopMutations, UsersMutations):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HandleNotFound(Extension):
|
class HandleNotFound(SchemaExtension):
|
||||||
def on_request_end(self):
|
def on_operation(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:
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
from strawberry_django import auto
|
from strawberry import ID, 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.ordering.order(models.Transaction)
|
@strawberry_django.order_type(models.Transaction)
|
||||||
class TransactionOrdering:
|
class TransactionOrdering:
|
||||||
created: auto
|
created: auto
|
||||||
|
|
||||||
@@ -13,6 +20,43 @@ class TransactionOrdering:
|
|||||||
@strawberry_django.type(models.Transaction)
|
@strawberry_django.type(models.Transaction)
|
||||||
class Transaction:
|
class Transaction:
|
||||||
id: auto
|
id: auto
|
||||||
user: "User"
|
user: Annotated["User", strawberry.lazy(".users")]
|
||||||
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
|
||||||
+22
-13
@@ -1,14 +1,18 @@
|
|||||||
from typing import List
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING, Annotated
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
from strawberry_django import auto
|
from strawberry 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.filters.filter(models.Product)
|
@strawberry_django.filter_type(models.Product)
|
||||||
class ProductFilter:
|
class ProductFilter:
|
||||||
id: auto
|
id: auto
|
||||||
active: auto
|
active: auto
|
||||||
@@ -21,11 +25,16 @@ 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: JSONScalar
|
user_counts: JSON
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.ordering.order(models.Order)
|
@strawberry_django.order_type(models.Order)
|
||||||
class OrderOrdering:
|
class OrderOrdering:
|
||||||
created: auto
|
created: auto
|
||||||
|
|
||||||
@@ -33,10 +42,10 @@ class OrderOrdering:
|
|||||||
@strawberry_django.type(models.Order)
|
@strawberry_django.type(models.Order)
|
||||||
class Order:
|
class Order:
|
||||||
id: auto
|
id: auto
|
||||||
customer: strawberry.LazyType["User", "bbs.api.types.users"]
|
customer: Annotated["User", strawberry.lazy(".users")] | None
|
||||||
order_sum: auto
|
order_sum: auto
|
||||||
created: auto
|
created: auto
|
||||||
orderlines: List["OrderLine"]
|
orderlines: list["OrderLine"]
|
||||||
is_external: bool
|
is_external: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -57,10 +66,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
|
||||||
@@ -72,4 +81,4 @@ class Point:
|
|||||||
@strawberry.type
|
@strawberry.type
|
||||||
class ProductOrderStat:
|
class ProductOrderStat:
|
||||||
product_name: str
|
product_name: str
|
||||||
data: List[Point]
|
data: list[Point]
|
||||||
+15
-12
@@ -1,23 +1,20 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth.models import User as DjangoUser
|
||||||
from strawberry_django import auto
|
from strawberry 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: int
|
sum: float
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(models.UserProfile)
|
@strawberry_django.type(models.UserProfile)
|
||||||
@@ -29,20 +26,26 @@ class UserProfile:
|
|||||||
order_sum_total: Decimal
|
order_sum_total: Decimal
|
||||||
|
|
||||||
@strawberry.field()
|
@strawberry.field()
|
||||||
def top_months(self) -> List[TopMonth]:
|
def top_months(self) -> list[TopMonth]:
|
||||||
return [TopMonth(**month) for month in self.top_months()]
|
return [
|
||||||
|
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(product_name=stat["product_name"], data=[Point(**p) for p in stat["data"]])
|
ProductOrderStat(
|
||||||
|
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(UserModel)
|
@strawberry_django.type(DjangoUser)
|
||||||
class User:
|
class User:
|
||||||
id: auto
|
id: auto
|
||||||
first_name: auto
|
first_name: auto
|
||||||
last_name: auto
|
last_name: auto
|
||||||
profile: UserProfile
|
profile: UserProfile | None
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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.",
|
|
||||||
)
|
|
||||||
+8
-2
@@ -5,12 +5,18 @@ from bbs.models import InventoryTransaction, Product
|
|||||||
MAX_DIGITS = 9
|
MAX_DIGITS = 9
|
||||||
DECIMAL_PLACES = 1
|
DECIMAL_PLACES = 1
|
||||||
|
|
||||||
|
|
||||||
class InventoryTransactionForm(forms.ModelForm):
|
class InventoryTransactionForm(forms.ModelForm):
|
||||||
unit_price = forms.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, required=True, localize=True)
|
unit_price = forms.DecimalField(
|
||||||
|
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(InventoryTransactionForm, self).__init__(*args, **kwargs)
|
super().__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:
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ from bbs.api.schema import schema as schema_symbol
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = " Write graphql schema to file or stdout"
|
help = "Write graphql schema to file or stdout"
|
||||||
|
|
||||||
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):
|
def handle(self, *args, filename=None, **options): # noqa: ARG002
|
||||||
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:
|
||||||
print(schema_output)
|
pass
|
||||||
@@ -10,11 +10,10 @@ 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):
|
def handle(self, *args, dry_run=False, force=False, **options): # noqa: ARG002
|
||||||
for profile in UserProfile.objects.all().order_by("id"):
|
for profile in UserProfile.objects.all().order_by("id"):
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(profile.user.email, profile.image_url())
|
pass
|
||||||
else:
|
elif force or not profile.image:
|
||||||
if force or not profile.image:
|
|
||||||
profile.image = profile.image_url()
|
profile.image = profile.image_url()
|
||||||
profile.save()
|
profile.save()
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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"])
|
||||||
@@ -3,7 +3,6 @@ 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),
|
||||||
]
|
]
|
||||||
@@ -180,6 +179,6 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name="orderline",
|
name="orderline",
|
||||||
unique_together=set([("order", "product")]),
|
unique_together={("order", "product")},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -4,7 +4,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("bbs", "0001_initial"),
|
("bbs", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('bbs', '0002_alter_inventorytransaction_amount_and_more'),
|
("bbs", "0002_alter_inventorytransaction_amount_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='order',
|
model_name="order",
|
||||||
name='order_sum',
|
name="order_sum",
|
||||||
field=models.DecimalField(db_column='sum', decimal_places=1, max_digits=9),
|
field=models.DecimalField(db_column="sum", decimal_places=1, max_digits=9),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
+26
-21
@@ -1,8 +1,7 @@
|
|||||||
import hashlib
|
import hashlib # noqa: EXE002
|
||||||
from datetime import datetime
|
from datetime import UTC, datetime
|
||||||
from itertools import groupby
|
|
||||||
from typing import Type
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from itertools import groupby
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -87,21 +86,26 @@ class Product(models.Model):
|
|||||||
|
|
||||||
return _format_counts(per_user)
|
return _format_counts(per_user)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self): # noqa: DJ012
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
class Meta:
|
class Meta: # noqa: DJ012
|
||||||
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 = {"yearly": ExtractYear, "monthly": ExtractMonth, "daily": ExtractIsoWeekDay, "hourly": ExtractHour}
|
periods = {
|
||||||
assert period in periods
|
"yearly": ExtractYear,
|
||||||
|
"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])
|
||||||
|
|
||||||
|
|
||||||
@@ -124,7 +128,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):
|
def __str__(self): # noqa: DJ012
|
||||||
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
|
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
|
||||||
|
|
||||||
|
|
||||||
@@ -163,11 +167,13 @@ 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):
|
def __str__(self): # noqa: DJ012
|
||||||
return f"{self.amount} {self.product} ({self.unit_price} kr per)"
|
return f"{self.amount} {self.product} ({self.unit_price} kr per)"
|
||||||
|
|
||||||
class Meta:
|
class Meta: # noqa: DJ012
|
||||||
unique_together = ("order", "product")
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=["order", "product"], name="unique_order_product"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class Transaction(models.Model):
|
class Transaction(models.Model):
|
||||||
@@ -178,7 +184,7 @@ class Transaction(models.Model):
|
|||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.id}: { self.user}: {self.amount}"
|
return f"{self.id}: {self.user}: {self.amount}"
|
||||||
|
|
||||||
|
|
||||||
class InventoryTransaction(models.Model):
|
class InventoryTransaction(models.Model):
|
||||||
@@ -206,7 +212,7 @@ 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):
|
def __str__(self): # noqa: DJ012
|
||||||
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)"
|
||||||
|
|
||||||
|
|
||||||
@@ -220,8 +226,7 @@ class UserProfile(models.Model):
|
|||||||
|
|
||||||
def last_purchase_date(self) -> datetime:
|
def last_purchase_date(self) -> datetime:
|
||||||
last = self.last_purchase()
|
last = self.last_purchase()
|
||||||
last_p = last.created if last else datetime.min
|
return last.created if last else datetime.min.replace(tzinfo=UTC)
|
||||||
return last_p
|
|
||||||
|
|
||||||
def top_months(self, limit=5):
|
def top_months(self, limit=5):
|
||||||
return (
|
return (
|
||||||
@@ -240,16 +245,16 @@ class UserProfile(models.Model):
|
|||||||
|
|
||||||
def image_url(self) -> str:
|
def image_url(self) -> str:
|
||||||
if self.user.email:
|
if self.user.email:
|
||||||
md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest()
|
sha256_hash = hashlib.sha256(self.user.email.lower().strip().encode()).hexdigest()
|
||||||
return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon"
|
return f"https://www.gravatar.com/avatar/{sha256_hash}?d=identicon"
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs): # noqa: DJ012
|
||||||
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):
|
def __str__(self): # noqa: DJ012
|
||||||
return f"{self.user}"
|
return f"{self.user}"
|
||||||
+17
-15
@@ -1,12 +1,13 @@
|
|||||||
# Django settings for bbs project.
|
# Django settings for bbs project. # noqa: EXE002
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ADMINS = (
|
ADMINS = (
|
||||||
# ('Your Name', 'your_email@example.com'),
|
# ('Your Name', 'your_email@example.com'), # noqa: ERA001
|
||||||
)
|
)
|
||||||
MANAGERS = ADMINS
|
MANAGERS = ADMINS
|
||||||
|
|
||||||
@@ -20,18 +21,18 @@ DATABASES = {
|
|||||||
TIME_ZONE = "Europe/Oslo"
|
TIME_ZONE = "Europe/Oslo"
|
||||||
LANGUAGE_CODE = "nb"
|
LANGUAGE_CODE = "nb"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_L10N = True
|
USE_TZ = True
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
# Media and uploads
|
# Media and uploads
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
|
|
||||||
# Static files
|
# Static files
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)
|
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118
|
||||||
|
|
||||||
STATICFILES_FINDERS = (
|
STATICFILES_FINDERS = (
|
||||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
@@ -39,13 +40,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"
|
SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss" # noqa: S105
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [
|
"DIRS": [
|
||||||
os.path.join(BASE_DIR, "templates"),
|
os.path.join(BASE_DIR, "templates"), # noqa: PTH118
|
||||||
],
|
],
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
@@ -85,7 +86,6 @@ INSTALLED_APPS = [
|
|||||||
"django_extensions",
|
"django_extensions",
|
||||||
"easy_thumbnails",
|
"easy_thumbnails",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"django_rename_app",
|
|
||||||
]
|
]
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
"bbs",
|
"bbs",
|
||||||
@@ -94,10 +94,14 @@ 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 = 600
|
BBS_SALDO_MAX = 1000
|
||||||
BBS_LIMIT_DEPOSITS = False
|
BBS_LIMIT_DEPOSITS = False
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
@@ -141,7 +145,5 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
with contextlib.suppress(ImportError):
|
||||||
from .local_settings import *
|
from .local_settings import * # noqa: F403
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,40 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 == ""
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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"
|
||||||
+325
-12
@@ -1,23 +1,80 @@
|
|||||||
import pytest
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import Client
|
from django.utils import timezone
|
||||||
from strawberry.django.test import GraphQLTestClient
|
|
||||||
|
|
||||||
from bbs.models import Order, Product
|
from bbs.models import InventoryTransaction, Order, OrderLine, Transaction, UserProfile
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="gql_client")
|
def test_user_not_found(db, gql_client): # noqa: ARG001
|
||||||
def gql_client_fixture(client: Client):
|
result = gql_client.query(
|
||||||
yield GraphQLTestClient(client)
|
"""
|
||||||
|
query User($userId: ID!) {
|
||||||
|
user(userId: $userId) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
{"userId": "999"},
|
||||||
|
assert_no_errors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.errors
|
||||||
@pytest.fixture(name="product")
|
error = result.errors[0]
|
||||||
def product_fix(db):
|
assert error["path"] == ["user"]
|
||||||
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
|
assert error["extensions"]["code"] == "NOT_FOUND"
|
||||||
|
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 = dict(customerId="external", isExternal=True, orderLines=[{"productId": product.id, "amount": 1}])
|
variables = {
|
||||||
|
"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) {
|
||||||
@@ -46,3 +103,259 @@ 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
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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("", "") == ""
|
||||||
+37
-12
@@ -2,24 +2,49 @@ 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 path, re_path
|
from django.urls import include, 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.reports import inventory, inventory_add, report
|
from bbs.views.spa import spa_index
|
||||||
|
|
||||||
admin.autodiscover()
|
admin.autodiscover()
|
||||||
|
|
||||||
urlpatterns = [
|
api_patterns = [
|
||||||
# TODO: migrate reports to SPA
|
# bbs has no login template of its own; the admin's works for any user
|
||||||
re_path(r"^inventory/$", inventory, name="inventory"),
|
path(
|
||||||
re_path(r"^inventory/add/$", inventory_add, name="inventory-add"),
|
"accounts/login/",
|
||||||
re_path(r"^report$", report, name="report"),
|
LoginView.as_view(template_name="admin/login.html", extra_context={"site_header": "BBS"}),
|
||||||
path("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
|
name="django.contrib.auth.views.login",
|
||||||
path("accounts/logout/", LogoutView.as_view(), name="django.contrib.auth.views.logout"),
|
),
|
||||||
|
path(
|
||||||
|
"accounts/logout/",
|
||||||
|
LogoutView.as_view(),
|
||||||
|
name="django.contrib.auth.views.logout",
|
||||||
|
),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("graphql/", GraphQLView.as_view(schema=schema), name="graphql"),
|
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += static("/media", document_root=settings.MEDIA_ROOT)
|
urlpatterns = [
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
path("inventory", spa_index, name="inventory"),
|
||||||
|
path("inventory/", spa_index),
|
||||||
|
path("inventory/add", spa_index, name="inventory-add"),
|
||||||
|
path("inventory/add/", spa_index),
|
||||||
|
path("report", spa_index, name="report"),
|
||||||
|
path("report/", spa_index),
|
||||||
|
path(settings.BBS_API_PREFIX, include(api_patterns)),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static("/media", document_root=settings.MEDIA_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
@@ -1,8 +1,9 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import 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
|
||||||
@@ -10,12 +11,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 = datetime.now()
|
datetime_now = timezone.localtime()
|
||||||
start_time = datetime.now() - timedelta(days=30)
|
start_time = timezone.localtime() - 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 = datetime.now().replace(hour=23, minute=59, second=59, microsecond=999)
|
end_time = timezone.localtime().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)
|
||||||
@@ -50,14 +51,14 @@ def report(request):
|
|||||||
k: {
|
k: {
|
||||||
"name": k,
|
"name": k,
|
||||||
"transactions": group,
|
"transactions": group,
|
||||||
"transactions_sum": sum(map(lambda x: x["transactions__price"], group)),
|
"transactions_sum": sum(x["transactions__price"] for x in group),
|
||||||
"transactions_units": sum(map(lambda x: x["transactions__amount"], group)),
|
"transactions_units": sum(x["transactions__amount"] for x in group),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
|
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
|
||||||
inv_in = sum(map(lambda x: x[1].get("transactions_sum", 0), inventory_products.items()))
|
inv_in = sum(x[1].get("transactions_sum", 0) for x in inventory_products.items())
|
||||||
|
|
||||||
# out
|
# out
|
||||||
out_products = (
|
out_products = (
|
||||||
@@ -75,8 +76,8 @@ def report(request):
|
|||||||
product = {
|
product = {
|
||||||
"name": k,
|
"name": k,
|
||||||
"orderlines": group,
|
"orderlines": group,
|
||||||
"orderlines_sum": sum(map(lambda x: x["orderlines__price"], group)),
|
"orderlines_sum": sum(x["orderlines__price"] for x in group),
|
||||||
"orderlines_units": sum(map(lambda x: x["orderlines__amount"], group)),
|
"orderlines_units": sum(x["orderlines__amount"] for x in group),
|
||||||
}
|
}
|
||||||
if k in inventory_products:
|
if k in inventory_products:
|
||||||
# add diff
|
# add diff
|
||||||
@@ -88,7 +89,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(map(lambda x: x[1].get("orderlines_sum", 0), inventory_products.items()))
|
inv_out = sum(x[1].get("orderlines_sum", 0) for x in inventory_products.items())
|
||||||
|
|
||||||
inv_diff = inv_in - inv_out
|
inv_diff = inv_in - inv_out
|
||||||
|
|
||||||
@@ -110,7 +111,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())
|
return render(request, "report.html", locals()) # noqa: DJ003
|
||||||
|
|
||||||
|
|
||||||
def inventory(request):
|
def inventory(request):
|
||||||
@@ -120,7 +121,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())
|
return render(request, "inventory.html", locals()) # noqa: DJ003
|
||||||
|
|
||||||
|
|
||||||
def inventory_add(request):
|
def inventory_add(request):
|
||||||
@@ -133,13 +134,12 @@ 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, "Satt inn {0} stk {1}.".format(itrans.amount, itrans.product))
|
messages.success(request, f"Satt inn {itrans.amount} stk {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())
|
return render(request, "inventory_add.html", locals()) # noqa: DJ003
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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")
|
||||||
+5
-3
@@ -1,6 +1,8 @@
|
|||||||
#!/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"
|
||||||
@@ -10,9 +12,9 @@ cd "$PROJECT_PATH" || exit 1
|
|||||||
git pull
|
git pull
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
poetry install
|
uv sync
|
||||||
umask 022; poetry run python manage.py collectstatic --noinput # Collect static
|
umask 022; uv run manage.py collectstatic --noinput # Collect static
|
||||||
poetry run python manage.py migrate # Run DB migrations
|
uv run manage.py migrate # Run DB migrations
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
bin/build-frontend
|
bin/build-frontend
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
SRC_DIR="bbs"
|
|
||||||
|
|
||||||
black $SRC_DIR
|
|
||||||
isort $SRC_DIR
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
python manage.py export_schema
|
|
||||||
cd frontend && npm run generate
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
SRC_DIR="bbs"
|
|
||||||
|
|
||||||
black --check $SRC_DIR
|
|
||||||
isort --diff $SRC_DIR
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
cd frontend && npm run start
|
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
src/types.ts
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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
@@ -1 +1 @@
|
|||||||
v16
|
v26
|
||||||
+24
-2
@@ -3,6 +3,28 @@ documents: ./src/**/*.graphql
|
|||||||
generates:
|
generates:
|
||||||
./src/types.ts:
|
./src/types.ts:
|
||||||
plugins:
|
plugins:
|
||||||
- typescript
|
- typescript:
|
||||||
- typescript-operations
|
typesPrefix: Schema
|
||||||
|
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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
Generated
+5752
-18799
File diff suppressed because it is too large.
Load diff
+49
-47
@@ -3,64 +3,66 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"check-types": "tsc",
|
"check-types": "tsc -b",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"format:js": "eslint --fix src",
|
"format:js": "eslint --fix src",
|
||||||
"format:styles": "stylelint --fix src",
|
"format:styles": "prettier --write \"src/**/*.css\"",
|
||||||
"generate": "graphql-codegen",
|
"generate": "node scripts/generate-types.mjs",
|
||||||
"lint:js": "eslint src",
|
"lint:js": "eslint src",
|
||||||
"lint:styles": "stylelint src",
|
"lint:styles": "prettier --check \"src/**/*.css\"",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"test": "jest"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.5.10",
|
"@apollo/client": "^4.2.0",
|
||||||
"@nivo/bar": "^0.79.1",
|
"@nivo/bar": "^0.99.0",
|
||||||
"@nivo/line": "^0.79.1",
|
"@nivo/line": "^0.99.0",
|
||||||
"@nivo/pie": "^0.79.1",
|
"@nivo/pie": "^0.99.0",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^2.28.0",
|
"date-fns": "^4.3.0",
|
||||||
"graphql": "^16.3.0",
|
"graphql": "^16.14.0",
|
||||||
"react": "^17.0.2",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.30.0",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^7.18.4",
|
||||||
"use-url-search-params": "^2.5.1"
|
"use-url-search-params": "^2.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^2.6.2",
|
"@babel/core": "^7.29.7",
|
||||||
"@graphql-codegen/typed-document-node": "^2.2.8",
|
"@eslint/js": "^10.0.1",
|
||||||
"@graphql-codegen/typescript-operations": "^2.3.5",
|
"@graphql-codegen/core": "^6.2.2",
|
||||||
|
"@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",
|
||||||
"@types/react": "^17.0.44",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@types/react-dom": "^17.0.15",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/node": "^26.6.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.19.0",
|
"@types/react": "^19.2.15",
|
||||||
"@typescript-eslint/parser": "^5.19.0",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^1.3.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@vitejs/plugin-react-refresh": "^1.3.6",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"eslint": "^8.13.0",
|
"eslint": "^10.4.0",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import-x": "^4.16.2",
|
||||||
"eslint-plugin-prettier": "^4.0.0",
|
"eslint-plugin-prettier": "^5.5.7",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-hooks": "^4.4.0",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"jest": "^27.5.1",
|
"globals": "^17.6.0",
|
||||||
"prettier": "^2.6.2",
|
"prettier": "^3.6.2",
|
||||||
"stylelint": "^14.7.0",
|
"typescript": "~6.0.3",
|
||||||
"stylelint-config-idiomatic-order": "^8.1.0",
|
"typescript-eslint": "^8.60.0",
|
||||||
"stylelint-config-prettier": "^9.0.3",
|
"vite": "^8.0.14",
|
||||||
"stylelint-config-recommended": "^7.0.0",
|
"vite-plugin-svgr": "^5.2.0",
|
||||||
"stylelint-order": "^5.0.0",
|
"yaml": "^2.8.1"
|
||||||
"stylelint-prettier": "^2.0.0",
|
|
||||||
"typescript": "^4.6.3",
|
|
||||||
"vite": "^2.9.5",
|
|
||||||
"vite-plugin-svgr": "^1.1.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16",
|
"node": "26"
|
||||||
"npm": ">=7"
|
},
|
||||||
}
|
"overrides": {
|
||||||
|
"@whatwg-node/promise-helpers": "^1.3.2"
|
||||||
|
},
|
||||||
|
"type": "module"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
.app {
|
.app {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
width: min(100% - 2rem, 1220px);
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
max-width: 1220px;
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApolloProvider } from "@apollo/client";
|
import { ApolloProvider } from "@apollo/client/react";
|
||||||
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 = (): JSX.Element => (
|
const App = (): React.JSX.Element => (
|
||||||
<ApolloProvider client={client}>
|
<ApolloProvider client={client}>
|
||||||
<Router>
|
<Router>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
|||||||
@@ -4,22 +4,30 @@ 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 = (): JSX.Element => (
|
export const Routes = (): React.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 />} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
|
||||||
|
|
||||||
const client = new ApolloClient({
|
const client = new ApolloClient({
|
||||||
uri: `${import.meta.env.VITE_API_URL}/graphql/`,
|
link: new HttpLink({ uri: import.meta.env.DEV ? "/graphql/" : `${import.meta.env.VITE_API_URL}/graphql/` }),
|
||||||
cache: new InMemoryCache(),
|
cache: new InMemoryCache(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
.footer {
|
.footer {
|
||||||
position: absolute;
|
flex-shrink: 0;
|
||||||
right: 0;
|
padding: 0 0 1rem;
|
||||||
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;
|
||||||
|
|||||||
@@ -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 = (): JSX.Element => {
|
export const Footer = (): React.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{" "}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
.errorMessage {
|
.errorMessage {
|
||||||
font-family: Rocket Rinder, monospace;
|
|
||||||
font-size: 3rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--brand);
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 30vh;
|
margin-top: 30vh;
|
||||||
|
color: var(--brand);
|
||||||
|
font-family:
|
||||||
|
Rocket Rinder,
|
||||||
|
monospace;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApolloError } from "@apollo/client";
|
import type { ErrorLike } from "@apollo/client";
|
||||||
import React from "react";
|
import { CombinedGraphQLErrors } from "@apollo/client/errors";
|
||||||
|
|
||||||
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: ApolloError }) => {
|
export const GQLError = ({ error }: { error: ErrorLike }) => {
|
||||||
if (error?.graphQLErrors?.length == 1) {
|
if (error instanceof CombinedGraphQLErrors && error.errors.length === 1) {
|
||||||
const single = error?.graphQLErrors[0];
|
const single = error.errors[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>;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
.header {
|
.header {
|
||||||
margin: 1rem auto;
|
width: min(100% - 2rem, 1220px);
|
||||||
max-width: 1220px;
|
max-width: 1220px;
|
||||||
|
margin: 1rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 5rem;
|
min-height: 5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,14 +20,21 @@
|
|||||||
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: 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);
|
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);
|
||||||
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: 0 0 4px var(--brand), 0 0 11px var(--brand), 0 0 19px var(--brand), 0 0 30px var(--brand);
|
box-shadow:
|
||||||
|
0 0 4px var(--brand),
|
||||||
|
0 0 11px var(--brand),
|
||||||
|
0 0 19px var(--brand),
|
||||||
|
0 0 30px var(--brand);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoNav {
|
.logoNav {
|
||||||
@@ -34,6 +44,8 @@
|
|||||||
.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;
|
||||||
@@ -51,16 +63,47 @@
|
|||||||
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: Rocket Rinder, monospace;
|
font-family:
|
||||||
|
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: 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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
@@ -68,7 +111,9 @@
|
|||||||
left: -8px;
|
left: -8px;
|
||||||
animation: flicker 1.5s infinite alternate;
|
animation: flicker 1.5s infinite alternate;
|
||||||
color: var(--interlace);
|
color: var(--interlace);
|
||||||
font-family: Rocket Clouds DEMO, serif;
|
font-family:
|
||||||
|
Rocket Clouds DEMO,
|
||||||
|
serif;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -83,7 +128,10 @@
|
|||||||
53%,
|
53%,
|
||||||
57%,
|
57%,
|
||||||
100% {
|
100% {
|
||||||
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
20%,
|
20%,
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { NavLink } from "react-router-dom";
|
|||||||
import styles from "./Header.module.css";
|
import styles from "./Header.module.css";
|
||||||
|
|
||||||
const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
|
const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
|
||||||
const LOGOUT_URL = `${import.meta.env.VITE_API_URL}/`.replace("://", "://none:none@");
|
|
||||||
|
|
||||||
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 = (): JSX.Element => {
|
export const Header = (): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
@@ -33,6 +32,16 @@ export const Header = (): 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
|
||||||
@@ -49,11 +58,6 @@ export const Header = (): JSX.Element => {
|
|||||||
Admin
|
Admin
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a href={LOGOUT_URL} className={styles.navLink}>
|
|
||||||
Logg ut
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -18,22 +18,31 @@
|
|||||||
.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%);
|
||||||
border-radius: 50% 50%;
|
box-shadow:
|
||||||
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun), 0 0 30px var(--sun);
|
0 0 4px 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: Rocket Rinder, monospace;
|
font-family:
|
||||||
|
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: 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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
@@ -42,7 +51,9 @@
|
|||||||
left: 0rem;
|
left: 0rem;
|
||||||
animation: flicker 1.5s infinite alternate;
|
animation: flicker 1.5s infinite alternate;
|
||||||
color: var(--interlace);
|
color: var(--interlace);
|
||||||
font-family: Rocket Clouds DEMO, serif;
|
font-family:
|
||||||
|
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;
|
||||||
@@ -57,7 +68,10 @@
|
|||||||
53%,
|
53%,
|
||||||
57%,
|
57%,
|
||||||
100% {
|
100% {
|
||||||
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
20%,
|
20%,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface SplashProps {
|
|||||||
noOverlay?: boolean;
|
noOverlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): JSX.Element => (
|
const Splash = ({ brandText = "BBS", noOverlay = false }: SplashProps): React.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>
|
||||||
|
|||||||
@@ -16,11 +16,17 @@
|
|||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
|
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
|
||||||
box-shadow: 0 0 4px var(--sun), 0 0 11px var(--sun), 0 0 19px var(--sun);
|
box-shadow:
|
||||||
|
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: 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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
|
|||||||
@@ -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 "./ToastProvider";
|
import { useToast } from "../hooks/useToast";
|
||||||
|
|
||||||
export const Toast = (): JSX.Element => {
|
export const Toast = (): React.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 });
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import React, { ReactNode, useContext, useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
type Background = "primary" | "danger";
|
import { ToastContext, defaultToastState } from "../contexts/ToastContext";
|
||||||
|
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 => {
|
|
||||||
const [visible, setVisible] = useState(defaultState.visible);
|
export const ToastProvider = ({ children }: ToastProviderProps): React.JSX.Element => {
|
||||||
const [message, setMessage] = useState(defaultState.message);
|
const [visible, setVisible] = useState(defaultToastState.visible);
|
||||||
const [background, setBackground] = useState(defaultState.background);
|
const [message, setMessage] = useState(defaultToastState.message);
|
||||||
|
const [background, setBackground] = useState(defaultToastState.background);
|
||||||
|
|
||||||
const showToast = (newMessage: string, background: Background = "primary") => {
|
const showToast = (newMessage: string, background: Background = "primary") => {
|
||||||
setMessage(newMessage);
|
setMessage(newMessage);
|
||||||
@@ -49,5 +31,3 @@ export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element =>
|
|||||||
<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);
|
|
||||||
@@ -1,33 +1,47 @@
|
|||||||
|
.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;
|
||||||
display: grid;
|
animation-duration: 10s;
|
||||||
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;
|
||||||
pointer-events: none;
|
grid-template-columns: repeat(8, 128px);
|
||||||
z-index: -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
border-bottom: 2px solid var(--sun);
|
|
||||||
border-right: 2px solid var(--sun);
|
border-right: 2px solid var(--sun);
|
||||||
|
border-bottom: 2px solid var(--sun);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell:nth-child(8n) {
|
.cell:nth-child(8n) {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell:nth-child(n+57) {
|
.cell:nth-child(n + 57) {
|
||||||
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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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,3 +3,8 @@
|
|||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMutation } from "@apollo/client";
|
import { useMutation } from "@apollo/client/react";
|
||||||
import React from "react";
|
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { CreateUserDocument } from "../../types";
|
|
||||||
import styles from "./UserCreate.module.css";
|
import styles from "./UserCreate.module.css";
|
||||||
|
import { CreateUserDocument } from "../../types";
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
|
|||||||
@@ -1,17 +1,113 @@
|
|||||||
|
.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;
|
||||||
height: 800px;
|
max-height: min(62vh, 680px);
|
||||||
padding-top: 2rem;
|
padding: 0.5rem 0.5rem 0.5rem 0;
|
||||||
grid-template-columns: repeat(5, 220px);
|
gap: 1rem;
|
||||||
|
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 {
|
||||||
margin-top: 32px;
|
position: sticky;
|
||||||
|
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 {
|
||||||
margin-top: 32px;
|
display: flex;
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
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 = (): JSX.Element => {
|
const Deposit = (): React.JSX.Element => {
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
setSelectedUser,
|
toggleSelectedUser,
|
||||||
amount,
|
amount,
|
||||||
setAmount,
|
setAmount,
|
||||||
userQuery,
|
userQuery,
|
||||||
setUserQuery,
|
setUserQuery,
|
||||||
filteredUsers,
|
filteredUsers,
|
||||||
|
selectedUserDetails,
|
||||||
reset,
|
reset,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
} = useDeposit();
|
} = useDeposit();
|
||||||
@@ -23,45 +24,67 @@ const Deposit = (): JSX.Element => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.deposit}>
|
||||||
<section>
|
<h2 className={styles.heading}>Hvem skal sette inn penger?</h2>
|
||||||
<h2>Hvem skal sette inn penger?</h2>
|
<div className={styles.search}>
|
||||||
<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={() => {
|
onUser={() => toggleSelectedUser(user.id)}
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery } from "@apollo/client";
|
import { useMutation, useQuery } from "@apollo/client/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useToast } from "../../components/ToastProvider";
|
import { useToast } from "../../hooks/useToast";
|
||||||
import { AllUsersDocument, CreateDepositDocument } from "../../types";
|
import { AllUsersDocument, CreateDepositDocument } from "../../types";
|
||||||
|
|
||||||
export const useDeposit = () => {
|
export const useDeposit = () => {
|
||||||
@@ -10,9 +10,13 @@ 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 () => {
|
||||||
@@ -28,9 +32,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;
|
||||||
@@ -42,7 +46,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",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -55,12 +59,13 @@ export const useDeposit = () => {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
setSelectedUser,
|
toggleSelectedUser,
|
||||||
amount,
|
amount,
|
||||||
setAmount,
|
setAmount,
|
||||||
userQuery,
|
userQuery,
|
||||||
setUserQuery,
|
setUserQuery,
|
||||||
filteredUsers,
|
filteredUsers,
|
||||||
|
selectedUserDetails,
|
||||||
reset,
|
reset,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
mutation createInventoryTransaction($data: InventoryTransactionInput!) {
|
||||||
|
createInventoryTransaction(data: $data) {
|
||||||
|
... on CreateInventoryTransactionSuccess {
|
||||||
|
inventoryTransaction {
|
||||||
|
id
|
||||||
|
product {
|
||||||
|
name
|
||||||
|
inventoryAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on FormErrors {
|
||||||
|
message
|
||||||
|
fields {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
query inventoryProducts {
|
||||||
|
allProducts(filters: { active: true }) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
inventoryAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
query inventoryReport($startTime: DateTime!, $endTime: DateTime!) {
|
||||||
|
inventoryReport(startTime: $startTime, endTime: $endTime) {
|
||||||
|
products {
|
||||||
|
productId
|
||||||
|
productName
|
||||||
|
purchaseUnits
|
||||||
|
purchaseValue
|
||||||
|
salesUnits
|
||||||
|
salesValue
|
||||||
|
}
|
||||||
|
inventoryPurchases
|
||||||
|
productSales
|
||||||
|
deposits
|
||||||
|
orderPurchases
|
||||||
|
balanceAtStart
|
||||||
|
balanceAtEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
.log {
|
.log {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrap table {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.disabled,
|
.disabled,
|
||||||
.disabled a:link {
|
.disabled a:link {
|
||||||
@@ -20,8 +30,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);
|
||||||
margin-top: 1rem;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.pagination a:hover {
|
.pagination a:hover {
|
||||||
color: white;
|
color: white;
|
||||||
@@ -30,3 +40,20 @@
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client/react";
|
||||||
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;
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ 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>
|
||||||
@@ -60,6 +60,7 @@ const Log = () => {
|
|||||||
))}
|
))}
|
||||||
</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 })}>
|
||||||
@@ -89,6 +90,7 @@ const Log = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2>Transaksjoner</h2>
|
<h2>Transaksjoner</h2>
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -109,6 +111,7 @@ const Log = () => {
|
|||||||
))}
|
))}
|
||||||
</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,5 +1,5 @@
|
|||||||
query orderList($limit: Int = 10, $offset: Int = 0) {
|
query orderList($limit: Int = 10, $offset: Int = 0) {
|
||||||
orderList(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
|
orderList(ordering: [{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(order: {created: DESC}, pagination: {limit: $limit, offset: $offset}) {
|
transactionList(ordering: [{created: DESC}], pagination: {limit: $limit, offset: $offset}) {
|
||||||
id
|
id
|
||||||
created
|
created
|
||||||
amount
|
amount
|
||||||
|
|||||||
@@ -1,51 +1,56 @@
|
|||||||
.product {
|
.product {
|
||||||
display: flex;
|
|
||||||
border: none;
|
|
||||||
background: rgb(35, 15, 41);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background-color 0.1s ease-out, border 0.1s ease-out;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgb(88, 59, 99);
|
||||||
|
transition:
|
||||||
|
background-color 0.1s ease-out,
|
||||||
|
border 0.1s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product:hover,
|
.product:hover,
|
||||||
.active.product {
|
.active.product {
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
box-shadow: 0 0 4px var(--background), 0 0 11px var(--background), 0 0 19px var(--background);
|
box-shadow:
|
||||||
|
0 0 4px var(--background),
|
||||||
|
0 0 11px var(--background),
|
||||||
|
0 0 19px var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.productText {
|
.productText {
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 1.2rem;
|
|
||||||
text-shadow: none;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-shadow: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
.amount {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: none;
|
||||||
padding: 12px 12px 8px 8px;
|
padding: 12px 12px 8px 8px;
|
||||||
border-left: 1px solid transparent;
|
border-left: 1px solid transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
background-color: transparent;
|
|
||||||
border-top-right-radius: 4px;
|
|
||||||
border-bottom-right-radius: 4px;
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active .amount {
|
.active .amount {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--background);
|
|
||||||
text-shadow: none;
|
|
||||||
border-left-color: rgb(35, 15, 41);
|
border-left-color: rgb(35, 15, 41);
|
||||||
background-color: var(--sun-end);
|
background-color: var(--sun-end);
|
||||||
|
color: var(--background);
|
||||||
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user