Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bdf26c4f6
|
||
|
|
060ea46bc4
|
||
|
|
b2684d537e
|
||
|
|
edacf81401
|
||
|
|
60108b9d89
|
||
|
|
6fcdaca833
|
||
|
|
3ade11111b
|
||
|
|
336f6afa5d
|
||
|
|
0a65bb2c90
|
||
|
|
ff7698f9c1
|
||
|
|
b9e93a5ea2
|
||
|
|
c2f30c8ca4
|
||
|
|
6ae99528dd
|
||
|
|
58a415494a
|
||
|
|
5a12bf951a
|
||
|
|
1c9a7411f8
|
||
|
|
d5a548cfb3
|
||
|
|
a5ba3bb1d3 |
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
|
||||
@@ -1,43 +0,0 @@
|
||||
variables:
|
||||
PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip"
|
||||
|
||||
default:
|
||||
image: nikolaik/python-nodejs:python3.11-nodejs20
|
||||
cache:
|
||||
paths:
|
||||
- .cache/pip
|
||||
- .venv
|
||||
|
||||
django-checks:
|
||||
stage: build
|
||||
script:
|
||||
- uv run manage.py migrate
|
||||
- uv run manage.py check
|
||||
|
||||
tests:
|
||||
stage: test
|
||||
script:
|
||||
- uv run bin/test
|
||||
|
||||
lint:
|
||||
stage: test
|
||||
script:
|
||||
- uv run bin/pre-commit
|
||||
|
||||
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,12 +0,0 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.11.7'
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: "1.24.0"
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version, "5.2"]
|
||||
@@ -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,32 +1,33 @@
|
||||
# Getting started
|
||||
|
||||
Before starting, make sure you have Python 3.11 or newer and Node.js 20 available.
|
||||
Before starting, make sure you have [mise](https://mise.jdx.dev) installed. It manages Python, Node, uv and prek.
|
||||
|
||||
```bash
|
||||
mise install
|
||||
prek install
|
||||
uv run manage.py migrate
|
||||
cp .env-default .env
|
||||
uv run manage.py seed_demo_data
|
||||
|
||||
# Optional: create an admin login
|
||||
uv run manage.py createsuperuser
|
||||
|
||||
# Install frontend packages once
|
||||
cd frontend && npm install
|
||||
```
|
||||
|
||||
Start the backend and frontend in separate terminals from the project root:
|
||||
|
||||
```bash
|
||||
uv run bin/run
|
||||
mise run server
|
||||
```
|
||||
|
||||
```bash
|
||||
uv run bin/run-frontend
|
||||
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 uv run bin/run-frontend`. This affects the development proxy only; use caution with a live backend, since app changes and submissions may modify production data.
|
||||
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.
|
||||
|
||||
@@ -36,6 +37,10 @@ To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` f
|
||||
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
|
||||
|
||||
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
|
||||
|
||||
@@ -42,7 +42,7 @@ def create_inventory_transaction(
|
||||
|
||||
try:
|
||||
product = models.Product.objects.get(pk=data.product_id, active=True)
|
||||
except (models.Product.DoesNotExist, ValueError):
|
||||
except models.Product.DoesNotExist, ValueError:
|
||||
message = "Velg et aktivt produkt."
|
||||
return FormErrors(message=message, fields=[FieldError(field="product_id", message=message)])
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external
|
||||
# clean and validate
|
||||
try:
|
||||
user = UserModel.objects.get(pk=customer_id)
|
||||
except (UserModel.DoesNotExist, ValueError):
|
||||
except UserModel.DoesNotExist, ValueError:
|
||||
user = None
|
||||
|
||||
if user is None and not is_external:
|
||||
@@ -65,16 +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
|
||||
ols.append(models.OrderLine(product=product, amount=ol.amount, unit_price=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(
|
||||
message="Du har ikke nok penger på bok.",
|
||||
amount_lacking=abs(order_sum - user.profile.balance),
|
||||
amount_lacking=float(abs(order_sum - user.profile.balance)),
|
||||
)
|
||||
|
||||
# create
|
||||
for ol in ols:
|
||||
ol.product.inventory_amount -= ol.amount
|
||||
ol.product.save()
|
||||
order = models.Order.objects.create(customer=user, order_sum=order_sum)
|
||||
for ol in ols:
|
||||
ol.order = order
|
||||
@@ -110,7 +111,7 @@ def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
|
||||
# clean and validate
|
||||
try:
|
||||
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."
|
||||
return FormErrors(
|
||||
message=message,
|
||||
@@ -127,7 +128,7 @@ def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
|
||||
return AboveMaxSaldo(
|
||||
message="Du prøver å sette inn for mye penger",
|
||||
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)
|
||||
|
||||
@@ -75,32 +75,24 @@ class InventoryQueries:
|
||||
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"))[
|
||||
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)
|
||||
)
|
||||
order_purchases = (
|
||||
models.Order.objects.filter(created__range=(start_time, end_time)).aggregate(total=Sum("order_sum"))[
|
||||
] or Decimal(0)
|
||||
orders_start = models.Order.objects.filter(created__lt=start_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)
|
||||
)
|
||||
] 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],
|
||||
|
||||
@@ -1,31 +1,128 @@
|
||||
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
|
||||
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")),
|
||||
)
|
||||
|
||||
DEMO_PRODUCTS = (
|
||||
("Demo Cola", Decimal("25.0"), Decimal("30.0"), 30),
|
||||
("Demo Kaffe", Decimal("15.0"), Decimal("20.0"), 30),
|
||||
("Demo Sjokolade", Decimal("20.0"), Decimal("25.0"), 30),
|
||||
("Demo Energidrikk", Decimal("35.0"), Decimal("40.0"), 30),
|
||||
# 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])
|
||||
@@ -34,6 +131,22 @@ class Command(BaseCommand):
|
||||
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")
|
||||
@@ -47,21 +160,23 @@ class Command(BaseCommand):
|
||||
username=username,
|
||||
defaults={"first_name": first_name, "last_name": last_name, "email": email},
|
||||
)
|
||||
UserProfile.objects.get_or_create(user=user)
|
||||
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 name, sale_price_int, sale_price_ext, stock in DEMO_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": sale_price_int,
|
||||
"sale_price_ext": sale_price_ext,
|
||||
"inventory_amount": stock,
|
||||
"description": "Demo product for local development.",
|
||||
"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
|
||||
@@ -70,7 +185,7 @@ class Command(BaseCommand):
|
||||
user=stock_user,
|
||||
product=product,
|
||||
comment="Demo opening stock",
|
||||
defaults={"amount": stock, "unit_price": Decimal("10.0")},
|
||||
defaults={"amount": DEMO_STOCK, "unit_price": Decimal(sale_price_int) * Decimal("0.6")},
|
||||
)
|
||||
return products
|
||||
|
||||
@@ -87,19 +202,12 @@ class Command(BaseCommand):
|
||||
deposit.save(update_fields=["created"])
|
||||
|
||||
def seed_orders(self, users, products):
|
||||
orders = (
|
||||
(0, 24, (("Demo Cola", 2), ("Demo Sjokolade", 1))),
|
||||
(1, 19, (("Demo Kaffe", 2), ("Demo Sjokolade", 1))),
|
||||
(2, 11, (("Demo Energidrikk", 1),)),
|
||||
(0, 4, (("Demo Kaffe", 1), ("Demo Cola", 1))),
|
||||
(1, 1, (("Demo Cola", 2),)),
|
||||
)
|
||||
for user_index, days_ago, lines in orders:
|
||||
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"),
|
||||
start=Decimal(0),
|
||||
)
|
||||
order, created = Order.objects.get_or_create(
|
||||
customer=user,
|
||||
@@ -123,8 +231,8 @@ class Command(BaseCommand):
|
||||
@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")
|
||||
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"])
|
||||
@@ -149,3 +257,132 @@ class Command(BaseCommand):
|
||||
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"])
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
+5
-3
@@ -171,7 +171,9 @@ class OrderLine(models.Model):
|
||||
return f"{self.amount} {self.product} ({self.unit_price} kr per)"
|
||||
|
||||
class Meta: # noqa: DJ012
|
||||
unique_together = ("order", "product")
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["order", "product"], name="unique_order_product"),
|
||||
]
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
@@ -243,8 +245,8 @@ class UserProfile(models.Model):
|
||||
|
||||
def image_url(self) -> str:
|
||||
if self.user.email:
|
||||
md5_hash = hashlib.md5(self.user.email.lower().strip().encode()).hexdigest() # noqa: S324
|
||||
return f"https://www.gravatar.com/avatar/{md5_hash}?d=identicon"
|
||||
sha256_hash = hashlib.sha256(self.user.email.lower().strip().encode()).hexdigest()
|
||||
return f"https://www.gravatar.com/avatar/{sha256_hash}?d=identicon"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
+4
-1
@@ -86,7 +86,6 @@ INSTALLED_APPS = [
|
||||
"django_extensions",
|
||||
"easy_thumbnails",
|
||||
"corsheaders",
|
||||
"django_rename_app",
|
||||
]
|
||||
LOCAL_APPS = [
|
||||
"bbs",
|
||||
@@ -95,6 +94,10 @@ INSTALLED_APPS += LOCAL_APPS
|
||||
|
||||
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_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -1,17 +1,9 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
|
||||
from bbs.models import UserProfile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db): # noqa: ARG001
|
||||
user = get_user_model().objects.create_user(username="profile-image-user", email="image@example.com")
|
||||
UserProfile.objects.create(user=user)
|
||||
return user
|
||||
|
||||
|
||||
def test_profile_images_updates_missing_image(user):
|
||||
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
|
||||
|
||||
|
||||
+307
-6
@@ -1,12 +1,313 @@
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
import pytest
|
||||
from django.contrib.auth.models import User as DjangoUser
|
||||
|
||||
from bbs.models import UserProfile
|
||||
from bbs.models import (
|
||||
EXTERNAL_USER,
|
||||
InventoryTransaction,
|
||||
Order,
|
||||
OrderLine,
|
||||
Product,
|
||||
UserProfile,
|
||||
_format_counts,
|
||||
)
|
||||
|
||||
# _format_counts
|
||||
# ==============
|
||||
|
||||
|
||||
def test_last_purchase_date_without_orders_is_timezone_aware(db): # noqa: ARG001
|
||||
user = get_user_model().objects.create_user(username="no-purchases")
|
||||
profile = UserProfile.objects.create(user=user)
|
||||
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"
|
||||
|
||||
assert profile.last_purchase_date() == datetime.min.replace(tzinfo=UTC)
|
||||
|
||||
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"
|
||||
+164
-11
@@ -1,20 +1,51 @@
|
||||
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 InventoryTransaction, Order, OrderLine, Product, Transaction
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@pytest.fixture(name="gql_client")
|
||||
def gql_client_fixture(client: Client):
|
||||
return GraphQLTestClient(client)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(name="product")
|
||||
def product_fix(db): # noqa: ARG001
|
||||
return Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
|
||||
CREATE_USER = """
|
||||
mutation CreateUser($input: UserInput!) {
|
||||
createUser(input: $input) {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def test_user_not_found(db, gql_client): # noqa: ARG001
|
||||
@@ -206,3 +237,125 @@ def test_inventory_report_aggregates_period_data(product, gql_client):
|
||||
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
|
||||
@@ -4,7 +4,10 @@ from django.core.management import call_command
|
||||
from django.test import Client
|
||||
from strawberry.django.test import GraphQLTestClient
|
||||
|
||||
from bbs.models import InventoryTransaction, Order, Product, Transaction
|
||||
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
|
||||
@@ -12,12 +15,14 @@ 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() == 3
|
||||
assert Product.objects.filter(name__startswith="Demo ").count() == 4
|
||||
assert Order.objects.count() == 5
|
||||
assert Transaction.objects.count() == 3
|
||||
assert InventoryTransaction.objects.count() == 4
|
||||
assert sum(product.inventory_amount for product in Product.objects.filter(name__startswith="Demo ")) == 109
|
||||
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
|
||||
@@ -38,8 +43,8 @@ def test_demo_data_is_available_when_an_admin_has_no_profile():
|
||||
|
||||
assert not result.errors
|
||||
assert result.data
|
||||
assert len(result.data["allProducts"]) == 4
|
||||
assert len(result.data["allUsers"]) == 3
|
||||
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(
|
||||
@@ -63,3 +68,26 @@ def test_demo_data_is_available_when_an_admin_has_no_profile():
|
||||
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,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"
|
||||
+27
-9
@@ -2,8 +2,9 @@ from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.urls import 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 bbs.api.schema import schema
|
||||
@@ -11,14 +12,13 @@ from bbs.views.spa import spa_index
|
||||
|
||||
admin.autodiscover()
|
||||
|
||||
urlpatterns = [
|
||||
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("accounts/login/", LoginView.as_view(), name="django.contrib.auth.views.login"),
|
||||
api_patterns = [
|
||||
# bbs has no login template of its own; the admin's works for any user
|
||||
path(
|
||||
"accounts/login/",
|
||||
LoginView.as_view(template_name="admin/login.html", extra_context={"site_header": "BBS"}),
|
||||
name="django.contrib.auth.views.login",
|
||||
),
|
||||
path(
|
||||
"accounts/logout/",
|
||||
LogoutView.as_view(),
|
||||
@@ -28,5 +28,23 @@ urlpatterns = [
|
||||
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
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),
|
||||
]
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
python manage.py export_schema
|
||||
cd frontend && npm run generate
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
uvx pre-commit run --all-files
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd frontend && npm run start -- --host 127.0.0.1
|
||||
@@ -1 +0,0 @@
|
||||
src/types.ts
|
||||
@@ -1,54 +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-unresolved": ["error", { ignore: ["\\?react$"] }],
|
||||
"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 @@
|
||||
v20
|
||||
v26
|
||||
@@ -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
+2701
-4361
File diff suppressed because it is too large.
Load diff
+30
-22
@@ -3,8 +3,8 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"check-types": "tsc",
|
||||
"build": "tsc -b && vite build",
|
||||
"check-types": "tsc -b",
|
||||
"dev": "vite",
|
||||
"format:js": "eslint --fix src",
|
||||
"format:styles": "prettier --write \"src/**/*.css\"",
|
||||
@@ -16,45 +16,53 @@
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.14.1",
|
||||
"@apollo/client": "^4.2.0",
|
||||
"@nivo/bar": "^0.99.0",
|
||||
"@nivo/line": "^0.99.0",
|
||||
"@nivo/pie": "^0.99.0",
|
||||
"clsx": "^1.1.1",
|
||||
"date-fns": "^2.28.0",
|
||||
"graphql": "^16.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.30.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"graphql": "^16.14.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"use-url-search-params": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@graphql-codegen/core": "^6.2.2",
|
||||
"@graphql-codegen/typescript": "^6.1.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",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"eslint": "^8.57.1",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-prettier": "^5.5.7",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.4.5",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.60.0",
|
||||
"vite": "^8.0.14",
|
||||
"vite-plugin-svgr": "^5.2.0",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20"
|
||||
"node": "26"
|
||||
},
|
||||
"overrides": {
|
||||
"@whatwg-node/promise-helpers": "^1.3.2"
|
||||
}
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
import { ApolloProvider } from "@apollo/client/react";
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Toast } from "./components/Toast";
|
||||
import { ToastProvider } from "./components/ToastProvider";
|
||||
import { Wireframe } from "./components/Wireframe";
|
||||
|
||||
const App = (): JSX.Element => (
|
||||
const App = (): React.JSX.Element => (
|
||||
<ApolloProvider client={client}>
|
||||
<Router>
|
||||
<ToastProvider>
|
||||
|
||||
@@ -16,7 +16,7 @@ const UserStats = React.lazy(() => import("./features/stats/UserStats"));
|
||||
const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
|
||||
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>}>
|
||||
<ReactRouterRoutes>
|
||||
<Route path="/deposit" element={<Deposit />} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApolloClient, InMemoryCache } from "@apollo/client";
|
||||
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
|
||||
|
||||
const client = new ApolloClient({
|
||||
uri: import.meta.env.DEV ? "/graphql/" : `${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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
import Brick from "../assets/brick.svg?react";
|
||||
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 (
|
||||
<footer className={styles.footer}>
|
||||
Made with <span className={styles.heart}>❤</span> by{" "}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApolloError } from "@apollo/client";
|
||||
import React from "react";
|
||||
import type { ErrorLike } from "@apollo/client";
|
||||
import { CombinedGraphQLErrors } from "@apollo/client/errors";
|
||||
|
||||
import styles from "./GQLError.module.css";
|
||||
|
||||
@@ -11,9 +11,9 @@ const formatModel = (text: string) => {
|
||||
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
|
||||
};
|
||||
|
||||
export const GQLError = ({ error }: { error: ApolloError }) => {
|
||||
if (error?.graphQLErrors?.length == 1) {
|
||||
const single = error?.graphQLErrors[0];
|
||||
export const GQLError = ({ error }: { error: ErrorLike }) => {
|
||||
if (error instanceof CombinedGraphQLErrors && error.errors.length === 1) {
|
||||
const single = error.errors[0];
|
||||
if (single?.extensions?.code === "NOT_FOUND") {
|
||||
const modelPretty = formatModel(single?.extensions?.model as string);
|
||||
return <div className={styles.errorMessage}>{`${modelPretty} not found`}</div>;
|
||||
|
||||
@@ -8,7 +8,7 @@ const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
|
||||
|
||||
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
|
||||
|
||||
export const Header = (): JSX.Element => {
|
||||
export const Header = (): React.JSX.Element => {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<nav className={styles.nav}>
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SplashProps {
|
||||
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={styles.wrap}>
|
||||
<div className={styles.sun}></div>
|
||||
|
||||
@@ -2,9 +2,9 @@ import clsx from "clsx";
|
||||
import React from "react";
|
||||
|
||||
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 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";
|
||||
|
||||
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);
|
||||
import { ToastContext, defaultToastState } from "../contexts/ToastContext";
|
||||
import type { Background } from "../contexts/ToastContext";
|
||||
|
||||
type ToastProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element => {
|
||||
const [visible, setVisible] = useState(defaultState.visible);
|
||||
const [message, setMessage] = useState(defaultState.message);
|
||||
const [background, setBackground] = useState(defaultState.background);
|
||||
|
||||
export const ToastProvider = ({ children }: ToastProviderProps): React.JSX.Element => {
|
||||
const [visible, setVisible] = useState(defaultToastState.visible);
|
||||
const [message, setMessage] = useState(defaultToastState.message);
|
||||
const [background, setBackground] = useState(defaultToastState.background);
|
||||
|
||||
const showToast = (newMessage: string, background: Background = "primary") => {
|
||||
setMessage(newMessage);
|
||||
@@ -49,5 +31,3 @@ export const ToastProvider = ({ children }: ToastProviderProps): JSX.Element =>
|
||||
<ToastContext.Provider value={{ visible, showToast, message, hide, background }}>{children}</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = (): ToastProps => useContext(ToastContext);
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
import styles from "./Wireframe.module.css";
|
||||
|
||||
export const Wireframe = () => (
|
||||
|
||||
@@ -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);
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useMutation } from "@apollo/client";
|
||||
import React from "react";
|
||||
import { useMutation } from "@apollo/client/react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
|
||||
import { CreateUserDocument } from "../../types";
|
||||
import styles from "./UserCreate.module.css";
|
||||
import { CreateUserDocument } from "../../types";
|
||||
|
||||
type FormValues = {
|
||||
firstName: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import { UserCard } from "../users/UserCard";
|
||||
import styles from "./Deposit.module.css";
|
||||
import { useDeposit } from "./useDeposit";
|
||||
import { UserCard } from "../users/UserCard";
|
||||
|
||||
const Deposit = (): JSX.Element => {
|
||||
const Deposit = (): React.JSX.Element => {
|
||||
const {
|
||||
loading,
|
||||
selectedUser,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { AllUsersDocument, CreateDepositDocument } from "../../types";
|
||||
|
||||
export const useDeposit = () => {
|
||||
@@ -32,7 +32,7 @@ export const useDeposit = () => {
|
||||
}
|
||||
|
||||
if (res.data?.createDeposit?.__typename === "CreateDepositSuccess") {
|
||||
const { transaction } = res.data?.createDeposit;
|
||||
const { transaction } = res.data.createDeposit;
|
||||
showToast(
|
||||
`${transaction.user.firstName} har satt inn ${transaction.amount}. Ny saldo er ${transaction.user.profile?.balance}.`,
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import React, { useState } from "react";
|
||||
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";
|
||||
import styles from "./Inventory.module.css";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import React, { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { GQLError } from "../../components/GQLError";
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import { CreateInventoryTransactionDocument, InventoryProductsDocument } from "../../types";
|
||||
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();
|
||||
@@ -55,7 +57,7 @@ const InventoryAdd = () => {
|
||||
<div>
|
||||
<h2>Registrer varetransaksjon</h2>
|
||||
<p>
|
||||
Du må være innlogget for å registrere varer. <a href="/accounts/login/">Logg inn</a>.
|
||||
Du må være innlogget for å registrere varer. <a href={LOGIN_URL}>Logg inn</a>.
|
||||
</p>
|
||||
<form className={styles.form} onSubmit={onSubmit}>
|
||||
<label>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
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";
|
||||
import styles from "./Inventory.module.css";
|
||||
|
||||
const today = () => format(new Date(), "yyyy-MM-dd");
|
||||
const monthAgo = () => format(subDays(new Date(), 30), "yyyy-MM-dd");
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { useUrlSearchParams } from "use-url-search-params";
|
||||
|
||||
import styles from "./Log.module.css";
|
||||
import { OrderListDocument, TransactionListDocument } from "../../types";
|
||||
import { formatTime } from "../../utils/time";
|
||||
import styles from "./Log.module.css";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import React, { MouseEventHandler } from "react";
|
||||
|
||||
import { AllProductsQuery } from "../../types";
|
||||
import styles from "./ProductCard.module.css";
|
||||
import { AllProductsQuery } from "../../types";
|
||||
|
||||
type ProductCardProps = {
|
||||
product: AllProductsQuery["allProducts"][number];
|
||||
@@ -11,7 +11,7 @@ type ProductCardProps = {
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export const ProductCard = ({ product, onProduct, amount, active = false }: ProductCardProps): JSX.Element => {
|
||||
export const ProductCard = ({ product, onProduct, amount, active = false }: ProductCardProps): React.JSX.Element => {
|
||||
const classes = clsx(styles.product, { [styles.active]: active });
|
||||
return (
|
||||
<button className={classes} type="button" onClick={onProduct} title={product.name}>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
|
||||
import { AllProductsQuery, OrderLineInput } from "../../types";
|
||||
import { UserCard } from "../users/UserCard";
|
||||
import { ProductCard } from "./ProductCard";
|
||||
import styles from "./Register.module.css";
|
||||
import { useRegister } from "./useRegister";
|
||||
import { AllProductsQuery, OrderLineInput } from "../../types";
|
||||
import { UserCard } from "../users/UserCard";
|
||||
|
||||
const USER_EXTERNAL = "external";
|
||||
|
||||
const Register = (): JSX.Element => {
|
||||
const Register = (): React.JSX.Element => {
|
||||
const {
|
||||
users,
|
||||
products,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery } from "@apollo/client";
|
||||
import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useToast } from "../../components/ToastProvider";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import {
|
||||
AllProductsDocument,
|
||||
AllProductsQuery,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { BarDatum, ResponsiveBar } from "@nivo/bar";
|
||||
import React from "react";
|
||||
|
||||
import { OrderStatsDocument, SchemaOrderStatsByTime } from "../../types";
|
||||
import styles from "./Stats.module.css";
|
||||
import { StatsView } from "./StatsView";
|
||||
import { chartTheme } from "./chartTheme";
|
||||
import { OrderStatsDocument, SchemaOrderStatsByTime } from "../../types";
|
||||
|
||||
const days = { 1: "Mandag", 2: "Tirsdag", 3: "Onsdag", 4: "Torsdag", 5: "Fredag", 6: "Lørdag", 7: "Søndag" };
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { ResponsivePie } from "@nivo/pie";
|
||||
import React from "react";
|
||||
|
||||
import { AllProductStatsDocument } from "../../types";
|
||||
import styles from "./Stats.module.css";
|
||||
import { StatsView } from "./StatsView";
|
||||
import { chartTheme } from "./chartTheme";
|
||||
import { AllProductStatsDocument } from "../../types";
|
||||
|
||||
type UserCount = {
|
||||
name: string;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { ResponsiveLine } from "@nivo/line";
|
||||
import React from "react";
|
||||
|
||||
import { ProductStatsDocument } from "../../types";
|
||||
import styles from "./Stats.module.css";
|
||||
import { StatsView } from "./StatsView";
|
||||
import { chartTheme, chartTooltipStyle } from "./chartTheme";
|
||||
import { ProductStatsDocument } from "../../types";
|
||||
|
||||
const Stats = () => {
|
||||
const { data, loading } = useQuery(ProductStatsDocument);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { ResponsiveBar } from "@nivo/bar";
|
||||
import React from "react";
|
||||
|
||||
import { GQLError } from "../../components/GQLError";
|
||||
import { UserStatsDocument } from "../../types";
|
||||
import styles from "./Stats.module.css";
|
||||
import { StatsView } from "./StatsView";
|
||||
import { chartTheme } from "./chartTheme";
|
||||
import { GQLError } from "../../components/GQLError";
|
||||
import { UserStatsDocument } from "../../types";
|
||||
|
||||
const UserStats = () => {
|
||||
const { data, loading, error } = useQuery(UserStatsDocument);
|
||||
@@ -20,7 +19,9 @@ const UserStats = () => {
|
||||
total: Number(user.profile?.orderSumTotal || 0),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 10);
|
||||
.slice(0, 10)
|
||||
// nivo draws the first item of a horizontal chart at the bottom
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<StatsView loading={loading}>
|
||||
@@ -41,7 +42,7 @@ const UserStats = () => {
|
||||
legendPosition: "middle",
|
||||
legendOffset: 32,
|
||||
}}
|
||||
axisLeft={null}
|
||||
axisLeft={{ tickSize: 0, tickPadding: 8 }}
|
||||
valueFormat={(value) => `${Number(value).toLocaleString("no")} kr`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import React, { MouseEventHandler } from "react";
|
||||
|
||||
import styles from "./UserCard.module.css";
|
||||
import { AllUsersQuery } from "../../types";
|
||||
import { formatCurrency } from "../../utils/currency";
|
||||
import styles from "./UserCard.module.css";
|
||||
|
||||
type UserCardProps = {
|
||||
user: AllUsersQuery["allUsers"][0] | null;
|
||||
@@ -12,7 +12,7 @@ type UserCardProps = {
|
||||
};
|
||||
|
||||
/** If user is null, then it's the special external user */
|
||||
export const UserCard = ({ user, onUser, active = false }: UserCardProps): JSX.Element => {
|
||||
export const UserCard = ({ user, onUser, active = false }: UserCardProps): React.JSX.Element => {
|
||||
const classes = clsx(styles.user, { [styles.active]: active });
|
||||
const userBalanceString = formatCurrency(Number(user?.profile?.balance));
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { ResponsiveLine } from "@nivo/line";
|
||||
import React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import styles from "./UserDetail.module.css";
|
||||
import { GQLError } from "../../components/GQLError";
|
||||
import { SchemaUser as User, UserDetailDocument, SchemaUserProfile as UserProfile } from "../../types";
|
||||
import { formatMonth, formatTime } from "../../utils/time";
|
||||
import { chartTheme, chartTooltipStyle } from "../stats/chartTheme";
|
||||
import styles from "./UserDetail.module.css";
|
||||
|
||||
const UserDetail = () => {
|
||||
const { userId } = useParams();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@apollo/client";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { AllUsersDocument } from "../../types";
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
export function usePrevious<T>(value: T) {
|
||||
// The ref object is a generic container whose current property is mutable ...
|
||||
// ... and can hold any value, similar to an instance property on a class
|
||||
const ref = useRef<T | undefined>(undefined);
|
||||
|
||||
// Store current value in ref
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]); // Only re-run if value changes
|
||||
// Return previous value (happens before update in useEffect above)
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const show = useCallback(() => {
|
||||
console.log("showing toast with element from root");
|
||||
}, []);
|
||||
return { show };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { ToastContext } from "../contexts/ToastContext";
|
||||
|
||||
export const useToast = () => useContext(ToastContext);
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
|
||||
import "./global.css";
|
||||
|
||||
ReactDOM.render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById("root"),
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["stylelint-config-recommended", "stylelint-prettier/recommended", "stylelint-config-idiomatic-order"],
|
||||
plugins: ["stylelint-order"],
|
||||
rules: {
|
||||
// Useful?
|
||||
"no-descending-specificity": null,
|
||||
},
|
||||
ignoreFiles: ["**/*.md", "**/*.svg", "**/*.jpg", "**/*.graphql", "**/*.tsx", "**/*.ts"],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+4
-24
@@ -1,27 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ES2021"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ES2020",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
},
|
||||
"include": [
|
||||
"./src",
|
||||
"node_modules/vite/types/importMeta.d.ts"
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Strictness */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { defineConfig } from "vite";
|
||||
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||
import babel from "@rolldown/plugin-babel";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [svgr()],
|
||||
plugins: [react(), babel({ presets: [reactCompilerPreset()] }), svgr()],
|
||||
define: {
|
||||
__DEV__: process.env.NODE_ENV !== "production",
|
||||
},
|
||||
@@ -1,6 +1,40 @@
|
||||
[tools]
|
||||
python = "3.11"
|
||||
node = "26"
|
||||
prek = "latest"
|
||||
python = "3.14"
|
||||
uv = "latest"
|
||||
|
||||
[tasks.server]
|
||||
run = "uv run manage.py runserver 127.0.0.1:3000"
|
||||
|
||||
[tasks.frontend-install]
|
||||
run = "npm install"
|
||||
dir = "frontend"
|
||||
sources = ["package.json", "package-lock.json"]
|
||||
outputs = ["node_modules/.package-lock.json"]
|
||||
|
||||
[tasks.frontend-run]
|
||||
depends = ["frontend-install"]
|
||||
run = "npm run start"
|
||||
dir = "frontend"
|
||||
|
||||
[tasks.frontend-build]
|
||||
depends = ["frontend-install"]
|
||||
run = "npm run build"
|
||||
dir = "frontend"
|
||||
|
||||
[tasks.generate-schema]
|
||||
depends = ["frontend-install"]
|
||||
run = "uv run manage.py export_schema && cd frontend && npm run generate"
|
||||
|
||||
[tasks.pre-commit]
|
||||
run = "prek run --all-files"
|
||||
|
||||
[tasks.test]
|
||||
run = "uv run pytest --cov"
|
||||
|
||||
[tasks.lint]
|
||||
run = "uv run ruff check ."
|
||||
|
||||
[tasks.fmt]
|
||||
run = "uv run ruff format ."
|
||||
@@ -0,0 +1,23 @@
|
||||
#:schema https://www.schemastore.org/prek.json
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/astral-sh/ruff-pre-commit"
|
||||
rev = "v0.17.2"
|
||||
hooks = [
|
||||
{ id = "ruff", args = ["--fix", "--exit-non-zero-on-fix"] },
|
||||
{ id = "ruff-format" },
|
||||
]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/adamchainz/django-upgrade"
|
||||
rev = "1.33.0"
|
||||
hooks = [
|
||||
{ id = "django-upgrade", args = ["--target-version", "6.1"] },
|
||||
]
|
||||
|
||||
[[repos]]
|
||||
repo = "https://github.com/adrienverge/yamllint"
|
||||
rev = "v1.38.0"
|
||||
hooks = [
|
||||
{ id = "yamllint" },
|
||||
]
|
||||
+12
-8
@@ -3,20 +3,20 @@ name = "bbs"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = [{ name = "Nikolai R Kristiansen", email = "nikolaik@gmail.com" }]
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.14"
|
||||
license = "MIT"
|
||||
dependencies = [
|
||||
"django-cors-headers>=4.9.0,<5",
|
||||
"django-extensions>=4.1,<5",
|
||||
"django-rename-app>=0.1.3,<0.2",
|
||||
"Django==5.2.*",
|
||||
"Django>=6.0,<7",
|
||||
"easy-thumbnails>=2.8.1,<3",
|
||||
"gunicorn>=22.0.0,<27",
|
||||
"mysqlclient>=2.0.3,<3",
|
||||
"gunicorn>=26.0,<27",
|
||||
"mysqlclient>=2.2,<3",
|
||||
"Pillow>=13.0.0,<14",
|
||||
"sentry-sdk>=1.5.5,<2",
|
||||
"sentry-sdk>=2.61,<3",
|
||||
"strawberry-graphql-django>=0.93,<0.94",
|
||||
"strawberry-graphql>=0.330.3,<0.337",
|
||||
"whitenoise>=6.12,<7",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
@@ -32,7 +32,7 @@ show_missing = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff==0.11.7",
|
||||
"ruff==0.17.2",
|
||||
"pytest-django",
|
||||
"pytest",
|
||||
"pytest-cov>=7.1.0",
|
||||
@@ -40,7 +40,7 @@ dev = [
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
target-version = "py314"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
@@ -55,6 +55,8 @@ ignore = [
|
||||
"TRY003", # Allow long exception messages
|
||||
"E501", # Allow long lines
|
||||
"COM812", "ISC001", # Avoid conflicting with ruff formatter
|
||||
"CPY", # No copyright headers
|
||||
"TC", # Strawberry/Django process annotations at runtime, can't defer imports
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
@@ -62,3 +64,5 @@ ignore = [
|
||||
"bbs/tests/*.py" = ["S101", "S106", "PLR2004"]
|
||||
"**/test_*.py" = ["S101", "S106", "PLR2004"]
|
||||
"**/tests.py" = ["S101", "S106", "PLR2004"]
|
||||
# strawberry.lazy with TYPE_CHECKING-guarded imports requires string annotations
|
||||
"bbs/api/types/*.py" = ["UP037"]
|
||||
File mode changed.
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
File mode changed.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,6 +1,6 @@
|
||||
version = 1
|
||||
revision = 5
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "asgiref"
|
||||
@@ -19,7 +19,6 @@ dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-extensions" },
|
||||
{ name = "django-rename-app" },
|
||||
{ name = "easy-thumbnails" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "mysqlclient" },
|
||||
@@ -27,6 +26,7 @@ dependencies = [
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "strawberry-graphql" },
|
||||
{ name = "strawberry-graphql-django" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -39,17 +39,17 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = "==5.2.*" },
|
||||
{ name = "django", specifier = ">=6.0,<7" },
|
||||
{ name = "django-cors-headers", specifier = ">=4.9.0,<5" },
|
||||
{ name = "django-extensions", specifier = ">=4.1,<5" },
|
||||
{ name = "django-rename-app", specifier = ">=0.1.3,<0.2" },
|
||||
{ name = "easy-thumbnails", specifier = ">=2.8.1,<3" },
|
||||
{ name = "gunicorn", specifier = ">=22.0.0,<27" },
|
||||
{ name = "mysqlclient", specifier = ">=2.0.3,<3" },
|
||||
{ name = "gunicorn", specifier = ">=26.0,<27" },
|
||||
{ name = "mysqlclient", specifier = ">=2.2,<3" },
|
||||
{ name = "pillow", specifier = ">=13.0.0,<14" },
|
||||
{ name = "sentry-sdk", specifier = ">=1.5.5,<2" },
|
||||
{ name = "sentry-sdk", specifier = ">=2.61,<3" },
|
||||
{ name = "strawberry-graphql", specifier = ">=0.330.3,<0.337" },
|
||||
{ name = "strawberry-graphql-django", specifier = ">=0.93,<0.94" },
|
||||
{ name = "whitenoise", specifier = ">=6.12,<7" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -57,7 +57,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "ruff", specifier = "==0.11.7" },
|
||||
{ name = "ruff", specifier = "==0.17.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -84,55 +84,6 @@ version = "7.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/75/25/79a66272edd250031aeee6db87d500a0bf76390d0d5f8c2f431fe354715f/coverage-7.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:99a7b390cbccc6bf029ca4da3f0f607c218bb4085e9cd6b58a90d14ca12993c3", size = 225837, upload-time = "2026-10-22T16:03:55.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a6/f5f6fefb2fc1305363927edad1d0a60ce6ef80d6c1a74a79e825f0f670eb/coverage-7.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72640e02a470b57b15cd655820133f6892760209283a363a60e2df932d5f0fe", size = 226257, upload-time = "2026-10-22T16:03:57.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/61/72889bac8eb44c51f8043edb227df551dfdca1fc4819a6c6d67b3f69427b/coverage-7.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2dc9233f39149b5a4f4aba17a4b6bc5cca22e52cfc4b06fdb07d014439b8b2ea", size = 258282, upload-time = "2026-10-22T16:03:58.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/e4/03e4a5644df9b73de0d44d1b82acd18b06c42b5abc3f1433be8363fa983d/coverage-7.17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e8113d3852e9377421a61de1917df27b3abb25404a4ecf69f9e64612c4345b5", size = 260506, upload-time = "2026-10-22T16:04:00.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/4e/949022286df7e59e8633a3a7e6635e0e6b53af7d57074cb0bf8f500373fb/coverage-7.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86a39539d10d666bb819c16c94d7edfc1aa2e0531b937f7c549b83d15177c6e", size = 262600, upload-time = "2026-10-22T16:04:01.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/cf/7d67f60df081006315699b7edb9402c1b47142caf1383f4e56ae8ff1a501/coverage-7.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:559f748446ee799803686b5784e3deb54c70d655ac386a860e06b11672504ba7", size = 264609, upload-time = "2026-10-22T16:04:03.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/e0/9293d94afbc099990a085e88229a51c79b00828aba0f2adda32dc4cc16d1/coverage-7.17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d65c9e8e257f48a654908005046fbf1bc48d9540ae3776ad08c0acd010e10c", size = 258382, upload-time = "2026-10-22T16:04:04.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/c8/bcd28efedf262c074dae76f61098739eb02b15f3f716f80cc86f9885b8a9/coverage-7.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b7fa0997b8eba7378ac66f31ec7f70164490353ab79b9667d37c0d365e50177", size = 260299, upload-time = "2026-10-22T16:04:06.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/dc/503f55e5e2200933403c90d619d9f779b5060ddd338b1bdc63cd235592cb/coverage-7.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ae0453a3390e67f7bfbe34417bbdf5b4ebfc21db23fb446a0a41115eeeca6ca", size = 258314, upload-time = "2026-10-22T16:04:07.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/b1/f1a57c36077cc516240636f7eff0d1427939ed795f096dd2c7769213a56c/coverage-7.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:96151262832f3c230ebae23b2d6911ad549e1738465946c97004913ff06112ba", size = 262239, upload-time = "2026-10-22T16:04:09.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/4f/c2e59d4a132936c7c207b9b69ab99c0f27b14c3c93266e9922aa3b562b37/coverage-7.17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cd7397de6cc782f00fe5ac1d75e9b306bcfa49c8b6b8dd6ed1f7709393ad40b3", size = 257841, upload-time = "2026-10-22T16:04:10.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/04/50742c0ee8b6c85826ff11737c93ed957fea4c9acf4b4b99804bdac1dd84/coverage-7.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d1fdf82c6fea57b5e9dada509f9ce9638c8334664e2c98c7f6f1ccf3d143ad2", size = 259039, upload-time = "2026-10-22T16:04:11.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/cd/83ad4bbbfcb6d045a33c14cdca313f6c9275737c67f3b7300222e3239a37/coverage-7.17.0-cp311-cp311-win32.whl", hash = "sha256:38c86e5d74bbfd88a393fa3f91257c1225c63aa6db6d5918de3e49ab04f91c6e", size = 227687, upload-time = "2026-10-22T16:04:13.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/c8/dcc5a7effa79707eb8a61e699b141cff7c9b46036771274cfb48ea7b765f/coverage-7.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ed3097c6817bc1a7fc8ae0a6131db74aa1150d863cccc308237ff362d3226b2", size = 228290, upload-time = "2026-10-22T16:04:14.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/58/a8553e0dcd38d8136df63964638d2b588e0a4a916bff1bca996244fda693/coverage-7.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:6052e878b628abcd38fa5f2b64abebc686eed4e2267da42434017a9c6d24f241", size = 228199, upload-time = "2026-10-22T16:04:16.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/25/989fd3b495c4274ac0cf54cb6664326415e60183a7f8a73dd9bbb0b11e34/coverage-7.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0cfdc938ca3319d873d1f1252e60620a39aee4ebf9dbd7cc58238109ebda9c51", size = 225938, upload-time = "2026-10-22T16:04:18.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/cb/14726426bf3241657facbf0f7df7babd8f7d3fcf8e4609b7929f9dceecbc/coverage-7.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d523da3c2e4bca49417768bcdb1a443c5b359dc44b8b93a3102e118fce912c40", size = 226166, upload-time = "2026-10-22T16:04:19.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/97/f95ed32cd3cfbe261a3598e694cb770cefc7b2d13ff013418d6f70a44dde/coverage-7.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:adbf3450c0f3f2b2854e282a8758f2b3eddf787eeff24ec9c9bc2cbc38328cc1", size = 258462, upload-time = "2026-10-22T16:04:21.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ac/eb4c31f741509a9ef48cf6f28385e98e9de0785599282252eee252ff8af7/coverage-7.17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85932227e9dd38e62cd29c17fefdfecfb919eead6af1e3259522d03cf85de983", size = 261789, upload-time = "2026-10-22T16:04:23.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/3e/d9cd006d1a3cc5deab07beb29b86cb4a10dd84d66b330820363fb8d310d6/coverage-7.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f5477aac9c9e4a0a641c55f8f8fddd7f3bd91176c912c50f40ffced96b4034", size = 262322, upload-time = "2026-10-22T16:04:24.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3a/4c872c6ae0762b09cdb8e856891bb3f584848cb60b6dda2bacb85fe2f3f0/coverage-7.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b8bb651a4406fb6a983a905309fec1783443c65e1771b41e92740a0f4a1176b", size = 264600, upload-time = "2026-10-22T16:04:26.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/98/1d41a2373fa98b423bcd70adb91d75e38eafb417955426dbb920b10016f3/coverage-7.17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0edeb6594f080c8de1263cccb239cb316ef77aeb959e2e0f38f2953fcc72a49e", size = 258678, upload-time = "2026-10-22T16:04:27.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/6a/fe04b5e63e76506daa63d90bdfb00a53767fe5df18546b437d6226918d6b/coverage-7.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da94ec0484e84fce5c7bf8d0f54b97767a90d992653a129b35634699d516e0a5", size = 260210, upload-time = "2026-10-22T16:04:29.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/c3/5c5a78dc19dd4799be9c6ec022316fb25ababaeacfaf0f52f24c56a57b43/coverage-7.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a25cc5e8c23a590815c6a7aa43624234d25cb23b21b484adb8a0daed59860db5", size = 258375, upload-time = "2026-10-22T16:04:30.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/e7/4c1d8eaff4d756fefd15d58c4953a78ab656782fd0e32c5a3465dba61aa0/coverage-7.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db645c81cf4d6bcb392ea1894aad65d55d1588a8472c834b25ca575da3029a56", size = 262422, upload-time = "2026-10-22T16:04:32.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/2a/0b17c3e2e7b6ff1a5d92eb58915c0d1026668928f961d14dac73cf1b6096/coverage-7.17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6a80a11b90c4a03df1502304cf24ff953255b6b5d076ef79066ecf88baaff31f", size = 257875, upload-time = "2026-10-22T16:04:33.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/2d/eb0cbde0535342df0d17a667163030a3afb67e5eb9e3ad94af9540041584/coverage-7.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4eaf11d6b0f2147f46cfbf06fe7edcca6f095d0120da00ddc2458995ee869fc", size = 260361, upload-time = "2026-10-22T16:04:35.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/47/1be2a6db7eb655d0a1d899217c3f2469f9121cbbeddac35c1c8173b1482e/coverage-7.17.0-cp312-cp312-win32.whl", hash = "sha256:912a29f338798541d795e2e25903c6fe7a99dee70a40fe4706a9a124649b523e", size = 227747, upload-time = "2026-10-22T16:04:37.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c5/201c894c57de81875d8b8cc527ba222aa350e8cfb183eb3f6df45d06ad8e/coverage-7.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:88a1a9fb0075b83389dd9b403bb3fce976031b3c2aa0e05e3e57143cf639de69", size = 228325, upload-time = "2026-10-22T16:04:38.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/196095339b19758cf83f507fb598bc42820ae124745feb3084662729e17a/coverage-7.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:2c680e838ff9e68d491158625ca2c1ddb17d78d791bba96a31f8b839b4990438", size = 228131, upload-time = "2026-10-22T16:04:40.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e6/1f01afbc9f4fa8ca5e1fa6fdf537a5c58b5765d1b4167ee284cddb9acc72/coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2", size = 227080, upload-time = "2026-10-22T16:04:42.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/7a/bdb7e6260c4a211389aeac9b89dc31662ebe122153ad0ecfd156897912df/coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5", size = 227152, upload-time = "2026-10-22T16:04:43.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/4e/276e7d5b3cfb124871bdb544c27b004a8b4e270e6bf8e7414149c54b5e5b/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398", size = 225397, upload-time = "2026-10-22T16:04:45.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/7d/dac073db12f3c131598a6693253aded581e4d7d622a89864f11bfb0ca2f5/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb", size = 225705, upload-time = "2026-10-22T16:04:46.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/45/877b0a9248e921080ae91d115c13192d17e308f5aee86bcc7f7bc2a54ed1/coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429", size = 225828, upload-time = "2026-10-22T16:04:48.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/f5/2c48d47abec0e4590b302920334be5ee3854f96bbd22524feabb8d92b220/coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094", size = 226088, upload-time = "2026-10-22T16:04:49.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/a0/ec0442f3f372c7fd742f49b3022edd392f79e37a0048e1fa333f56d01ec3/coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5", size = 257257, upload-time = "2026-10-22T16:04:50.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e2/eb0927ba93809f76eee69fa1e47999825fc7dbbabc3e0e1a2744f2677acb/coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669", size = 260165, upload-time = "2026-10-22T16:04:52.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a2/8d4c6055efb32099b15c9cc33d007b8aabebc4832f372a3274de5ba0afc5/coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd", size = 261098, upload-time = "2026-10-22T16:04:53.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/93412288688a7b03f152a734c8ef086e395108a0a6a746985d3967e220f2/coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722", size = 263164, upload-time = "2026-10-22T16:04:55.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/20/7eb8d805c4f1a212245cef20181dfbf39535357985073d49730ee9cf08d4/coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7", size = 257287, upload-time = "2026-10-22T16:04:56.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c1/1df29630b4ed17e135c76390afcc6bd273c3dc5ba2a21a76813a600fe98c/coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398", size = 259256, upload-time = "2026-10-22T16:04:58.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/79/2365d1008d28aafe468a450a24c6c63363de794b5140223d67685cd0e205/coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e", size = 257178, upload-time = "2026-10-22T16:05:00.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/9f/f002952d295d772688dffce0d991e3822795b1e3aad158073712a5a5e86e/coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28", size = 261275, upload-time = "2026-10-22T16:05:01.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/fe/f9e15fbb426be97cd679ee5d359151e046f103bcd76b5092c318c26ed596/coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95", size = 257029, upload-time = "2026-10-22T16:05:02.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4a/3c398f38694387334ff4c9f71afc8e15b4fa7db7bc0bea4d5b6032eb0df0/coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9", size = 259109, upload-time = "2026-10-22T16:05:04.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a9/908a3cf1a2335aec1c788682a06c2f742a62a4f987033f080de1c5fd1997/coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d", size = 227691, upload-time = "2026-10-22T16:05:05.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f7/8cdd7bfd8a63b3e6b133fc395ecfc7b1cb6f561926e7e2ad050de85b83e7/coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d", size = 228418, upload-time = "2026-10-22T16:05:07.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/cf/0b3fc57f735cd3831ca13d0be5312a5477d5934066683c5d34018cab7541/coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83", size = 228142, upload-time = "2026-10-22T16:05:08.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" },
|
||||
@@ -218,16 +169,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.18"
|
||||
version = "6.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/fe/79e692b430c7721bf4b812c328bfe687e73e3f28f56611a6a18a97e887cc/django-5.2.18.tar.gz", hash = "sha256:461c5dd06d2ea16bd5ca37d3f46e4def1d6b0fe7588c6f4e2119517bb0af8b2d", size = 10910238, upload-time = "2026-10-06T13:01:09.371Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/24/20d187a22ac821288b53c53b8f696019dcb0c7b4f973f052ade19392c74d/django-6.1.2.tar.gz", hash = "sha256:a1e92451ccb8b514e91bbb3b6d186d20b4030558f116b5d9de6535455ff210b7", size = 11251425, upload-time = "2026-10-06T12:53:29.622Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/01/6568ec52b26548ca1b83d33249f7274db3bf74698f42c0da42ec9db1a24a/django-5.2.18-py3-none-any.whl", hash = "sha256:92ed81d500be6408ecd704d7bd1366c534f30427bffcc63c5fefb129561aec7c", size = 8316071, upload-time = "2026-10-06T13:01:04.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/83/90ff2dbfac7b519ed77620bdbb827cd2d1a83faf00d076bf95056428bce7/django-6.1.2-py3-none-any.whl", hash = "sha256:141efee6ec64d1db6db90683bf734c550102450f444fb099063b0be1bd27d991", size = 8420684, upload-time = "2026-10-06T12:53:17.381Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -255,18 +206,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/96/d967ca440d6a8e3861120f51985d8e5aec79b9a8bdda16041206adfe7adc/django_extensions-4.1-py3-none-any.whl", hash = "sha256:0699a7af28f2523bf8db309a80278519362cd4b6e1fd0a8cd4bf063e1e023336", size = 232980, upload-time = "2025-04-11T01:15:37.701Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-rename-app"
|
||||
version = "0.1.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/f6/0cb0c7169584e8f87c229e6336ce98f90cbb911ae374e2a7683c25e872b4/django_rename_app-0.1.7.tar.gz", hash = "sha256:f1236dacfbaf06d63aa53a665333c9e0b4943ef41019aae7626dd5fb5ffa69b5", size = 8291, upload-time = "2023-11-08T08:22:05.25Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/4b/6a15f0a25003f7c563218722ee02968f008bedbc4e9d0c08fe9344421d5c/django_rename_app-0.1.7-py2.py3-none-any.whl", hash = "sha256:68ed0dbc9fc167b72cf2f3ba6b464d7981a42a0e09070476cf94f1f603f7a433", size = 8455, upload-time = "2023-11-08T08:22:03.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "easy-thumbnails"
|
||||
version = "2.10.1"
|
||||
@@ -313,12 +252,6 @@ version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/8f/b9488795d21a76c1520905feba5afb6233f16510797a28b51f2b2688c93e/mysqlclient-2.3.0.tar.gz", hash = "sha256:bea8294964266f6486f1ca514ccfcdbc54d4fe0d32882b38c1d4594df870be8b", size = 102185, upload-time = "2026-09-14T15:38:30.784Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9a/49c257f71e187f37ec24cc4a7c071b1788a819054ee3b3133332c074b2cb/mysqlclient-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a691aab1a6d22fb04aa08977ced9ecf0c1c781d0d985f7814972c3626db9390", size = 217316, upload-time = "2026-09-14T15:38:15.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/91/cdf42e08f3188ec4efb5e89883afb78cda3db50cd68616bce5814a27b769/mysqlclient-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b3a5ebc0a2983bc252116dd132ab4c746085fa727b5c98c09edcda09dc09d0f", size = 208447, upload-time = "2026-09-14T15:38:16.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/48/5474ffb47d1f27600737ae6b4860fc4bbc17b7e7f1dec9b8b765a75d66e5/mysqlclient-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:effb81eb6d1f1df6c1d95f63f117bec5007e9c64487e9fa17d5696f2a5338358", size = 217531, upload-time = "2026-09-14T15:38:17.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/37/9205f687a60196e77d9bc4dd94fdb3c88a24b5cb920fdaf1acf363791bdd/mysqlclient-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:7c3c4b3edcc7dc50d23fb5634e0e82bce8ab7de5b83d03fddc1c9979c18607a4", size = 208548, upload-time = "2026-09-14T15:38:18.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/12/cf11b4df3ee7ca9957487a5e4c99f33611119b086b1d94e14a2ceb21797e/mysqlclient-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:60365cce6765b94eeb621aa0f9505044ec9b4ea191cd68500f0a35b2d6d2758b", size = 217521, upload-time = "2026-09-14T15:38:19.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/ed/4ef9c56dd78a5b6f4ede1574629e532e3f2b97bdd764a6d172fb7ed20fc4/mysqlclient-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:a6beb9ca67a9224ff4b45f7f9118f0932038fa38d59ca2e38ae35b5225984d7d", size = 208546, upload-time = "2026-09-14T15:38:21.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/5f/f62d1263a942e97ca34ea8bf151c13a638fea7a5a544aa9db3d6dad7671d/mysqlclient-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:5d4c53eb9c5625dd68b6fed32c8cf00ba19cd1c3073645dec309a9d16bd029e2", size = 222543, upload-time = "2026-09-14T15:38:22.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/e5/1de3e1fc27009a6da30a52167e98f52ab4277d089eaff7152f6156745af9/mysqlclient-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:cfe14103280d5a4968fe8a3ace2a8939ef68a1d881aec9872d06746106b49f7f", size = 213120, upload-time = "2026-09-14T15:38:23.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/ea/d7ebc53af9d3341e5c049795ec5e88946d83cbd26ee468475ae0c3b21d7f/mysqlclient-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:673891700dafbc66a6a8df12059b53a65905ec6e8dec615392fb984299880c0d", size = 222969, upload-time = "2026-09-14T15:38:24.168Z" },
|
||||
@@ -344,36 +277,6 @@ version = "13.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" },
|
||||
@@ -416,16 +319,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -503,40 +396,40 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.11.7"
|
||||
version = "0.17.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/89/6f9c9674818ac2e9cc2f2b35b704b7768656e6b7c139064fc7ba8fbc99f1/ruff-0.11.7.tar.gz", hash = "sha256:655089ad3224070736dc32844fde783454f8558e71f501cb207485fe4eee23d4", size = 4054861, upload-time = "2025-04-24T18:49:37.007Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ec/21927cb906c5614b786d1621dba405e3d44f6e473872e6df5d1a6bca0455/ruff-0.11.7-py3-none-linux_armv6l.whl", hash = "sha256:d29e909d9a8d02f928d72ab7837b5cbc450a5bdf578ab9ebee3263d0a525091c", size = 10245403, upload-time = "2025-04-24T18:48:40.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/af/fec85b6c2c725bcb062a354dd7cbc1eed53c33ff3aa665165871c9c16ddf/ruff-0.11.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dd1fb86b168ae349fb01dd497d83537b2c5541fe0626e70c786427dd8363aaee", size = 11007166, upload-time = "2025-04-24T18:48:44.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/9a/2d0d260a58e81f388800343a45898fd8df73c608b8261c370058b675319a/ruff-0.11.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d3d7d2e140a6fbbc09033bce65bd7ea29d6a0adeb90b8430262fbacd58c38ada", size = 10378076, upload-time = "2025-04-24T18:48:47.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/9b09b45051404d2e7dd6d9dbcbabaa5ab0093f9febcae664876a77b9ad53/ruff-0.11.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4809df77de390a1c2077d9b7945d82f44b95d19ceccf0c287c56e4dc9b91ca64", size = 10557138, upload-time = "2025-04-24T18:48:51.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/5e/f62a1b6669870a591ed7db771c332fabb30f83c967f376b05e7c91bccd14/ruff-0.11.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3a0c2e169e6b545f8e2dba185eabbd9db4f08880032e75aa0e285a6d3f48201", size = 10095726, upload-time = "2025-04-24T18:48:54.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/59/a7aa8e716f4cbe07c3500a391e58c52caf665bb242bf8be42c62adef649c/ruff-0.11.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49b888200a320dd96a68e86736cf531d6afba03e4f6cf098401406a257fcf3d6", size = 11672265, upload-time = "2025-04-24T18:48:57.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/e3/101a8b707481f37aca5f0fcc3e42932fa38b51add87bfbd8e41ab14adb24/ruff-0.11.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2b19cdb9cf7dae00d5ee2e7c013540cdc3b31c4f281f1dacb5a799d610e90db4", size = 12331418, upload-time = "2025-04-24T18:49:00.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/71/037f76cbe712f5cbc7b852e4916cd3cf32301a30351818d32ab71580d1c0/ruff-0.11.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64e0ee994c9e326b43539d133a36a455dbaab477bc84fe7bfbd528abe2f05c1e", size = 11794506, upload-time = "2025-04-24T18:49:03.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/de/e450b6bab1fc60ef263ef8fcda077fb4977601184877dce1c59109356084/ruff-0.11.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bad82052311479a5865f52c76ecee5d468a58ba44fb23ee15079f17dd4c8fd63", size = 13939084, upload-time = "2025-04-24T18:49:07.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2c/1e364cc92970075d7d04c69c928430b23e43a433f044474f57e425cbed37/ruff-0.11.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7940665e74e7b65d427b82bffc1e46710ec7f30d58b4b2d5016e3f0321436502", size = 11450441, upload-time = "2025-04-24T18:49:11.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/7d/1b048eb460517ff9accd78bca0fa6ae61df2b276010538e586f834f5e402/ruff-0.11.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:169027e31c52c0e36c44ae9a9c7db35e505fee0b39f8d9fca7274a6305295a92", size = 10441060, upload-time = "2025-04-24T18:49:14.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/57/8dc6ccfd8380e5ca3d13ff7591e8ba46a3b330323515a4996b991b10bd5d/ruff-0.11.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:305b93f9798aee582e91e34437810439acb28b5fc1fee6b8205c78c806845a94", size = 10058689, upload-time = "2025-04-24T18:49:17.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/bf/20487561ed72654147817885559ba2aa705272d8b5dee7654d3ef2dbf912/ruff-0.11.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a681db041ef55550c371f9cd52a3cf17a0da4c75d6bd691092dfc38170ebc4b6", size = 11073703, upload-time = "2025-04-24T18:49:20.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/27/04f2db95f4ef73dccedd0c21daf9991cc3b7f29901a4362057b132075aa4/ruff-0.11.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:07f1496ad00a4a139f4de220b0c97da6d4c85e0e4aa9b2624167b7d4d44fd6b6", size = 11532822, upload-time = "2025-04-24T18:49:23.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/72/43b123e4db52144c8add336581de52185097545981ff6e9e58a21861c250/ruff-0.11.7-py3-none-win32.whl", hash = "sha256:f25dfb853ad217e6e5f1924ae8a5b3f6709051a13e9dad18690de6c8ff299e26", size = 10362436, upload-time = "2025-04-24T18:49:27.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a0/3e58cd76fdee53d5c8ce7a56d84540833f924ccdf2c7d657cb009e604d82/ruff-0.11.7-py3-none-win_amd64.whl", hash = "sha256:0a931d85959ceb77e92aea4bbedfded0a31534ce191252721128f77e5ae1f98a", size = 11566676, upload-time = "2025-04-24T18:49:30.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ca/69d7c7752bce162d1516e5592b1cc6b6668e9328c0d270609ddbeeadd7cf/ruff-0.11.7-py3-none-win_arm64.whl", hash = "sha256:778c1e5d6f9e91034142dfd06110534ca13220bfaad5c3735f6cb844654f6177", size = 10677936, upload-time = "2025-04-24T18:49:34.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "1.45.1"
|
||||
version = "2.73.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/28/02c0cd9184f9108e3c52519f9628b215077a3854240e0b17ae845e664855/sentry_sdk-1.45.1.tar.gz", hash = "sha256:a16c997c0f4e3df63c0fc5e4207ccb1ab37900433e0f72fef88315d317829a26", size = 244774, upload-time = "2024-07-26T13:48:32.375Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/3b/1e47acf2456781c26e12007379ecc154bf310b8f64d40390d78b791d55c3/sentry_sdk-2.73.0.tar.gz", hash = "sha256:4a2ab97b16e4693c325ab361c7bbbb346dbc7d0bc0150922d795a25bac8d5280", size = 1079078, upload-time = "2026-10-22T09:08:49.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/9f/105366a122efa93f0cb1914f841747d160788e4d022d0488d2d44c2ba26c/sentry_sdk-1.45.1-py2.py3-none-any.whl", hash = "sha256:608887855ccfe39032bfd03936e3a1c4f4fc99b3a4ac49ced54a4220de61c9c1", size = 267163, upload-time = "2024-07-26T13:48:29.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/88/cabf98d017d7b940720e514e5f8ee02e3e6f7495caf93c30735a38387ec4/sentry_sdk-2.73.0-py3-none-any.whl", hash = "sha256:0bcfa6928ea5adbb89591858b514d27adac27c7a3cccc31b20ed10e0b09eda9e", size = 549011, upload-time = "2026-10-22T09:08:47.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -613,3 +506,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whitenoise"
|
||||
version = "6.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
|
||||
]
|
||||
Reference in new issue
Block a user