186 Commits
Author SHA1 Message Date
ponas 0bdf26c4f6 Make the glyphicons images non-executable
Lint: prek / prek (push) Successful in 8s
Deploy / prek (push) Successful in 8s
Tests / build_frontend (push) Successful in 32s
Tests / tests (push) Successful in 47s
Deploy / tests (push) Successful in 55s
Deploy / image (push) Successful in 2m8s
Deploy / deploy-amiga (push) Successful in 15s
2026-10-23 03:21:54 +02:00
ponas 060ea46bc4 Deploy to amiga from CI
Deploy / image (push) Successful in 2m34s
Deploy / prek (push) Successful in 14s
Lint: prek / prek (push) Successful in 14s
Deploy / tests (push) Successful in 1m52s
Tests / build_frontend (push) Successful in 42s
Tests / tests (push) Successful in 1m45s
Deploy / deploy-amiga (push) Successful in 25s
2026-10-23 02:58:15 +02:00
ponas b2684d537e Serve bbs from a container image with SQLite 2026-10-23 02:58:15 +02:00
ponas edacf81401 Fix the login page and the inventory page's login link 2026-10-23 02:58:10 +02:00
ponas 60108b9d89 Build the frontend before running tests in CI 2026-10-23 02:20:55 +02:00
ponas 6fcdaca833 Add Norwegian demo data, avatars and a random mode to seed_demo_data 2026-10-23 02:19:40 +02:00
ponas 3ade11111b Show user names in the user stats chart, top spender first 2026-10-23 02:19:40 +02:00
ponas 336f6afa5d Upgrade frontend to Node 26, React 19, Vite 8, Apollo Client 4 and ESLint 10 2026-10-23 02:19:33 +02:00
ponas 0a65bb2c90 Bump to Python 3.14, Django 6.1 and ruff 0.17
Django 6.1 requires MySQL 8.4 or MariaDB 10.11 or newer.
2026-10-23 02:19:19 +02:00
ponas ff7698f9c1 Use mise tasks instead of bin scripts 2026-10-23 02:19:03 +02:00
ponas b9e93a5ea2 Replace GitLab CI with Gitea workflows
The GitLab deploy job has no replacement yet; deploy with bin/deploy by hand.
2026-10-23 02:18:56 +02:00
ponas c2f30c8ca4 Replace pre-commit with prek and lint YAML 2026-10-23 02:18:50 +02:00
ponas 6ae99528dd Share test fixtures and expand model, query and mutation tests 2026-10-23 02:18:35 +02:00
ponas 58a415494a Remove unused django-rename-app 2026-10-23 02:18:29 +02:00
ponas 5a12bf951a Hash Gravatar emails with SHA-256 2026-10-23 02:18:16 +02:00
ponas 1c9a7411f8 Replace unique_together with UniqueConstraint on OrderLine 2026-10-23 02:18:16 +02:00
ponas d5a548cfb3 Fix refused orders reducing stock, exact-balance purchases and Decimal float fields 2026-10-23 02:17:54 +02:00
leander a5ba3bb1d3 Merge pull request 'Slop/sloppy fixes' (#1) from slop/sloppy-fixes into main
Reviewed-on: #1
2026-10-22 22:15:39 +02:00
leanderandCopilot 53968fa949 Integrate compatible tests-and-bumps updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 21:13:26 +02:00
leanderandCopilot d979558905 Toggle selected users in register and deposit
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 20:57:27 +02:00
leanderandCopilot 78d5b06258 Add user purchase statistics tab
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:39:45 +02:00
leanderandCopilot 4848dda4a4 Migrate inventory and reports to the SPA
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:38:49 +02:00
leanderandCopilot 1d507d1976 Refresh README TODOs and product card styling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:22:47 +02:00
leanderandCopilot ec5e8dc791 Prevent rotating background from shifting layout
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:12:17 +02:00
leanderandCopilot ac2ceddf78 Improve mobile responsiveness across pages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 05:05:44 +02:00
leanderandCopilot c05820f61b Adjust chart x-axis legend placement
Move the date legend inward to align with the y-axis label on overview and user detail charts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:59:14 +02:00
leanderandCopilot 4d64126581 Keep statistics charts clear of the footer
Use a full-height flex layout and normal footer flow so long chart content scrolls without overlapping the footer. Keep the page gradient fixed while scrolling.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:54:28 +02:00
leanderandCopilot 9ad7b8aa28 Keep deposit entry visible while selecting a user
Place the amount entry and actions in a clear adjacent panel on larger screens and above the scrollable user list on mobile.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:50:02 +02:00
leanderandCopilot 64b77217f4 Improve chart text contrast
Use a brighter shared chart theme and a dark styled tooltip, with high-contrast pie slice labels.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:47:27 +02:00
leanderandCopilot 65e532be30 Fix user detail GraphQL query
Return monthly purchase totals with the correct GraphQL scalar and align frontend queries and generated types with the upgraded schema.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:40:53 +02:00
leanderandCopilot 407523b61a Upgrade vulnerable Python dependencies
Raise the supported Python floor to 3.11 for patched dependency releases and update CI/runtime metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:33:15 +02:00
leanderandCopilot e23cbb1d27 Make local frontend backend configurable
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:21:44 +02:00
leanderandCopilot 098c6701f2 Add repeatable demo database seed
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:00:46 +02:00
leanderandCopilot 18da8335a8 Upgrade frontend dependencies and tooling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-22 04:00:31 +02:00
ponas 4a36cc140c Sort imports 2025-06-05 19:53:56 +02:00
ponas f1ea46188f Make the graphql endpoint CSRF exempt 2025-06-05 19:38:17 +02:00
ponas dd6a559ac6 Add mise.toml 2025-06-05 19:37:59 +02:00
nikolark 5d889ee32c chore(ci): Fix deploy
Ensure uv is on path
2025-05-31 23:13:54 +02:00
nikolark 2b1cdb28ea fix: Fix union syntax 2025-05-30 16:17:45 +02:00
nikolark fbd55bd3a2 fix: strawberry type things 2025-05-30 16:15:18 +02:00
nikolark 71f158da6e chore: Fix most ruff issues
ignore the rest using `uvx ruff check --add-noqa`
2025-05-30 15:43:57 +02:00
nikolark 8b364b0887 chore: Add pre-commit ruff config++ 2025-05-30 15:43:20 +02:00
nikolark 28d747d74d chore(frontend): Roll node to v20 2025-05-30 15:32:55 +02:00
nikolark 6f964a0c57 chore: Roll to python 3.9 to get uv in image 2025-05-30 15:29:41 +02:00
nikolark d256e0eb7e chore: Switch to uv 2025-05-30 15:26:51 +02:00
leander cac189003a increase max saldo to 1000 NOK 2025-01-16 18:25:33 +01:00
alve ff09985944 🥷 Hide users navigation tab 2024-02-15 19:29:13 +00:00
Emma Eilefsen Glenna d76c911d4b fix rendering of register total 2024-01-25 21:45:17 +01:00
Emma Eilefsen Glenna e60473e7ed fix rendering of user balance 2024-01-25 21:44:03 +01:00
Emma Eilefsen Glenna d596c31353 extract function 2024-01-25 21:43:32 +01:00
Emma Eilefsen Glenna 90558a3a28 add comment 2024-01-25 19:01:45 +00:00
alve 57a6c412d5 💄 Hide trailing decimal zeros 2024-01-25 19:01:45 +00:00
alve 19f58211e7 🗃 Make order sum a decimal field 2024-01-25 19:01:45 +00:00
nikolark 0726754678 💡 add some FIXMEs 2024-01-25 19:01:45 +00:00
nikolark 480fba870a 🗃 Replace float fields with int or decimals 2024-01-25 19:01:45 +00:00
alve ed08c60380 💄Fix favicon path 2023-07-19 19:46:01 +02:00
nikolark 79fe5cac0f 🔒 Audit 2022-04-17 21:29:02 +02:00
nikolark 95d7d13f38 ✨ Handle user not found 2022-04-17 21:13:22 +02:00
nikolark ce2cdfd409 ⚡ Code split routes 2022-04-17 16:59:00 +02:00
nikolark b56d103ae7 Merge branch 'feature/synthwave' into 'main'
✨ BBS 2222 synthwave theme

See merge request edb/bbs!3
2022-04-17 14:39:44 +00:00
nikolark 56efe6760e 💄 Fix deposit 2022-04-17 16:37:42 +02:00
nikolark ce31ef84cc 💄 Pretty deposit user select 2022-04-17 16:29:18 +02:00
nikolark 491604c720 🚚 Move ProductCard and UserCard
closer to their feature
2022-04-17 16:04:54 +02:00
nikolark 05d3dcbb3e 💄 Style product grid, rm images
♻ Move Wireframe to own component
2022-04-17 16:01:09 +02:00
nikolark 8577fb9f11 ⬆ Bump minor frontend deps
♻ Stop using React.FC
2022-04-17 15:00:10 +02:00
nikolark a038340e4a 📝 TODO 2022-03-27 21:42:27 +02:00
nikolark 34a01e9293 💄 move grid behind everything else 2022-03-27 21:41:30 +02:00
nikolark 54dd21f1eb 💄 Make you nauseous 2022-03-22 23:59:31 +01:00
nikolark 53ba5a05a1 ✨ Add credits
🐛 Fix navigating below page zero
2022-03-06 21:16:53 +01:00
nikolark f775446224 💄 Style Navlink, primary buttons and headings 2022-03-06 15:16:16 +01:00
nikolark 13cc0d92a2 💄 Put new brand in header and fix toasts 2022-03-06 14:27:52 +01:00
nikolark 7bfa587a77 💄 Avatar rhombus 2022-03-06 13:34:05 +01:00
nikolark 3d91148b7e 💄 First round of styling
✨ Added new splash
2022-03-06 13:13:08 +01:00
nikolark 54bbd75730 🔥 Remove all things bootstrap 2022-03-05 20:00:03 +01:00
nikolark 06f43b06b8 ⚡ Avoid redirect 2022-02-20 12:36:15 +01:00
nikolark ad116c2c28 💚 Update pytest config with new app name 2022-02-20 01:33:16 +01:00
nikolark eb960bdb84 🔧 Update scripts with new path 2022-02-20 01:32:36 +01:00
nikolark f4c99153ca 🔧 Point VITE_API_URL to the right place 2022-02-20 01:31:03 +01:00
nikolark 3e457de416 🐛 Fix VITE_API_URL reference 2022-02-20 01:25:55 +01:00
nikolark faca98b7f9 🔧 Reference static assests from / 2022-02-20 01:20:41 +01:00
nikolark 53ef262ba4 🔧 Build frontend on deploy 2022-02-20 01:16:13 +01:00
nikolark f4749e6a6e 🔧 output frontend build to root static folder 2022-02-20 01:12:37 +01:00
nikolark e5de16f227 🐛 Remove 3.9 type syntax 2022-02-20 00:57:12 +01:00
nikolark eace79f012 ⬇ Downgrade to python 3.8 2022-02-20 00:54:00 +01:00
nikolark 9b903f42d6 🚚 Rename app main to bbs
🚚 Move settings into main
➕ Add django-rename-app
2022-02-20 00:39:16 +01:00
nikolark e1eaf3df98 💚 Try fixing deploy script 2022-02-20 00:17:51 +01:00
nikolark b79d9fc9c8 👷 Add ssh config 2022-02-20 00:08:15 +01:00
nikolark 42f23b1d77 Merge branch 'chore/bbs-bumps' into 'master'
💥 Rewrite in react

See merge request edb/bbs!1
2022-02-19 22:39:07 +00:00
nikolark 6f6fbc648c ✨ Add stats for all purchases over time 2022-02-19 22:45:47 +01:00
nikolark 7474885f14 ✨ User product purchases over time 2022-02-19 22:21:11 +01:00
nikolark f3a6e563bb ➕ Add @nivo/line 2022-02-19 17:35:19 +01:00
nikolark af45b29b1c ✨ Link to create user form 2022-02-19 17:33:02 +01:00
nikolark 64eb6c717c ✨ Add link to legacy admin 2022-02-19 17:30:12 +01:00
nikolark 2e5fbb6fa6 ✨ Split out stats into multiple sub views 2022-02-19 17:21:44 +01:00
nikolark 158f5c5652 📝 Update TODOs 2022-02-19 16:11:21 +01:00
nikolark cfd0d94537 ✨ Working user create form 2022-02-19 15:47:38 +01:00
nikolark b5c9094b64 ✨ Add user register mutation
🔥 Remove a bunch of legacy code
2022-02-19 15:26:30 +01:00
nikolark a47de48243 🔧 Misc 2022-02-19 14:32:33 +01:00
nikolark 41638e605f ♻ Split types, mutations, queries by model 2022-02-19 14:31:05 +01:00
Nikolai R Kristiansen 3274cb96c0 🚧 Start on UserCreate 2022-02-17 09:58:12 +01:00
Nikolai R Kristiansen 5f332cd365 🏷 Simplify types 2022-02-14 10:22:01 +01:00
Nikolai R Kristiansen aa66694fd5 ♻ Refactor Deposit and Register
🏷 Pick a part query types to simplify typing and fix
incompatible types
2022-02-14 10:19:34 +01:00
Nikolai R Kristiansen 8b0aaa287e 🌐 Norwegian formatting 2022-02-12 13:08:19 +01:00
Nikolai R Kristiansen 2e8b732798 💄 Bold numbers 2022-02-12 13:07:00 +01:00
Nikolai R Kristiansen 87396874c9 🔥 Remove legacy profile 2022-02-12 13:05:09 +01:00
Nikolai R Kristiansen e2a84c25a6 ✨ UserDetail and UserList views 2022-02-12 12:58:04 +01:00
Nikolai R Kristiansen ec0549154d ⬆ Bump node deps 2022-02-12 11:39:36 +01:00
Nikolai R Kristiansen ad96873493 🐛 Week starts on monday, not sunday 2022-02-10 09:47:01 +01:00
Nikolai R Kristiansen 9fa6cfe0ee 🔥 Delete legacy register and deposit
♻ Replace facebook avatar related stuff with gravatars
♻ Replace format-strings with f-strings
✨ Add profile_images command
2022-02-10 09:42:57 +01:00
Nikolai R Kristiansen a9f81737d5 ♻ Refactor Bar charts 2022-02-08 22:10:27 +01:00
Nikolai R Kristiansen 5f7a4e6ff2 ♻ Refactor orderStats
✏ Fix daily typo
2022-02-06 22:27:52 +01:00
Nikolai R Kristiansen d5ed841c9c ⬆ Bump dev deps 2022-02-06 22:16:04 +01:00
Nikolai R Kristiansen 9c7fe11bfc 🚸 Stats: Only show active products 2022-02-06 22:11:14 +01:00
Nikolai R Kristiansen df3aff7fb2 💄 Pointer cursor for pagination links 2022-02-06 22:09:24 +01:00
Nikolai R Kristiansen 264e189698 🔥 remove drf from django settings 2022-02-06 22:05:38 +01:00
Nikolai R Kristiansen 7ba3ead688 🔥 Remove stat and log views
➖ Remove djangorestframework
2022-02-06 21:59:13 +01:00
Nikolai R Kristiansen d7513f389e ✨ Make python aware stat and log are SPA views 2022-02-06 21:48:33 +01:00
Nikolai R Kristiansen bd8dbd750a 🏷 Make tsc happy 2022-02-06 21:45:11 +01:00
Nikolai R Kristiansen 7aea1ead45 🐛 Active styles for log and stats 2022-02-06 21:10:37 +01:00
Nikolai R Kristiansen 771e0c2a3b ✨ Add order stats 2022-02-06 21:05:40 +01:00
Nikolai R Kristiansen 6fa6bf1334 💄 Improve product pies 2022-02-06 16:31:18 +01:00
Nikolai R Kristiansen 9e80669a1f ✨ Add some pies 2022-02-06 12:02:09 +01:00
Nikolai R Kristiansen aead00a893 ✨ Add basic pagination in log view 2022-02-05 22:23:24 +01:00
Nikolai R Kristiansen e240eda1cb 🚸 Format datetimes 2022-02-05 22:02:33 +01:00
Nikolai R Kristiansen 1bd8fddf73 ✨ Sort orders and transactions newest first
🔧 Don't run linting using pipenv
2022-02-05 21:56:56 +01:00
Nikolai R Kristiansen ed99dadcb0 ⬆ Bump frontend deps
♻ Replace vite svg plugin
react-router v6 fixes
2022-02-05 21:44:25 +01:00
Nikolai R Kristiansen 87e2f2292c 👷 Deploy from CI using SSH 2022-02-05 21:10:14 +01:00
Nikolai R Kristiansen a2cfd0ceaf ✨ Start log view with orders & transactions 2022-02-02 23:06:33 +01:00
Nikolai R Kristiansen 85698deba5 🔇 Remove deprecated url usage 2022-01-26 23:42:37 +01:00
Nikolai R Kristiansen 1bd06b7e81 ♻️ Remove test helper 2022-01-26 23:41:13 +01:00
Nikolai R Kristiansen 6791000dea 🔇 Stop using deprecated translation functions 2022-01-26 23:27:49 +01:00
Nikolai R Kristiansen d3016a3925 ✨ Make the SPA load for home and register
Link to legacy routes
2022-01-26 23:23:33 +01:00
Nikolai R Kristiansen 717752ddae 🚨 Lint on CI 2022-01-16 22:53:29 +01:00
Nikolai R Kristiansen dcd8edaaa6 🚨 Add pre-commit
Format code
Add bin/run
2022-01-16 22:49:21 +01:00
nikolark 9d3afadbff ✅ Add basic create order test
🎨 Add MutationTest helper to help write mutation tests with strawberry
➕ add pytest-django
⬆ Bump some python deps
2022-01-16 22:30:47 +01:00
nikolark 549b0cfdd6 Update .gitlab-ci.yml file 2021-12-05 00:13:50 +00:00
nikolark 35d4334627 Update .gitlab-ci.yml file 2021-12-05 00:11:18 +00:00
nikolark 5969e70e89 Update .gitlab-ci.yml file 2021-12-05 00:05:57 +00:00
nikolark db7bdd7181 🚛 Move CI config in the right place 2021-12-05 00:49:03 +01:00
nikolark dbda43077e ✨ Make new frontend available under /modern
👷 Start adding deploy config and CI
2021-12-05 00:45:36 +01:00
nikolark bc4414473c ✨ Search as you type
🐛 Fix regression in register mutation
2021-12-04 23:48:36 +01:00
nikolark 2bcc993aaa ✨ Show details about purchase
🐛 Update user balance
2021-12-04 23:30:44 +01:00
nikolark 3d93a645ad ✨ Add basic deposit functionality 2021-11-18 22:58:32 +01:00
nikolark 40b3cb119f 🚸 Make errors red 2021-11-18 22:58:30 +01:00
Nikolai Kristiansen 9576becb62 🚨 Lint styles 2021-11-13 11:48:01 +01:00
Nikolai Kristiansen cf9c23bf7b ⬆️. Bump deps
🛠 Configure npm and node versions
2021-11-13 11:48:01 +01:00
nikolark 576392ba5f 🚸 improve error handling 2021-11-07 17:24:10 +01:00
nikolark 0a435d3224 ✨ Start on error handling
♻ Use TypedDocumentNode's instead of apollo-react-typescript codegen
💄 Add toast
2021-11-06 21:49:28 +01:00
nikolark ed82ca3891 💄 Fixed grid users 2021-11-02 22:34:55 +01:00
nikolark c44e582706 💄 Working register styles 2021-11-02 22:32:50 +01:00
nikolark d5bff90ee3 ✨ Register: working order sum logic
💄 Add more icons and load them as react components
2021-11-01 20:12:30 +01:00
nikolark d86afd9392 ✨ Add product images and basic nav 2021-10-30 19:05:50 +02:00
nikolark 350d31d8d8 ✨ Dont show inactive users in register view 2021-10-30 18:21:16 +02:00
nikolark 46c52b7618 ⬆ Bump deps 2021-10-30 17:48:03 +02:00
nikolark 2ed0c57b97 🚨 Add eslint and stylelint and format 2021-10-30 17:42:21 +02:00
nikolark f0656b34e9 ✨ Register: Show list of active products
🛠 Generate apollo client typescript helpers from graphql schema
2021-10-30 17:29:16 +02:00
nikolark fa89353525 ✨ Add basic graphql API using strawberry 🍓
✨ Add allProducts query
2021-10-30 16:54:28 +02:00
nikolark e2db9052bc ➕ Add pytest and black 2021-10-30 16:05:14 +02:00
Nikolai R Kristiansen aef0509056 🚧 Start adding new frontend boilerplate 2021-10-30 15:53:52 +02:00
nikolark 0e4ef1b75f 🔥 Remove widget_tweaks and bootstrapform use 2021-10-30 15:50:42 +02:00
nikolark 49870595b0 🔥 Remove legacy sentry client 2021-10-30 15:44:43 +02:00
nikolark 799548a90b ♻ Start using poetry 2021-10-30 15:38:36 +02:00
Nikolai R Kristiansen 2a3188667f 🐛 Use correct template tag 2021-10-27 19:25:58 +02:00
Nikolai R Kristiansen a052397990 🚨 format python code using black 2021-10-27 19:22:25 +02:00
Nikolai R Kristiansen 9c4a5b3d84 🔥 Remove legacy stuff 2021-10-27 19:18:07 +02:00
Nikolai R Kristiansen 1a27e3a521 🐛 fix migrations 2021-10-27 19:15:26 +02:00
Nikolai R Kristiansen d311e65058 ⬆️ Major bumps and django checks
Notably to django 3.2
2021-10-27 19:12:07 +02:00
Nikolai R Kristiansen a1437ab5d2 ⬆️ bump deps 2018-08-12 22:14:11 +02:00
Nikolai R Kristiansen 079af129d7 Minor fixes 2018-03-17 14:05:37 +01:00
Nikolai R Kristiansen 90eae21bc7 Add sentry and fabfile 2018-03-17 13:59:04 +01:00
Nikolai R Kristiansen cd11bd580b ⬆️ Bump most deps 2018-03-17 13:41:19 +01:00
Nikolai R Kristiansen 72562c931d Try again 2017-09-27 22:04:27 +02:00
Nikolai R Kristiansen a84c94e23b Highcharts is back 2017-09-27 22:01:15 +02:00
Nikolai R Kristiansen 16bc430369 Remove bower, bump some deps, fix log img size 2017-09-27 21:59:17 +02:00
Nikolai R Kristiansen 8fb2389d2c Fix some Django 1.11 stuff 2017-09-27 21:15:27 +02:00
Nikolai R Kristiansen c1ef6f6678 pur 2017-09-27 20:59:30 +02:00
Toralf Neby Berge f0e6331630 fix 2017-05-03 22:15:00 +02:00
Toralf Neby Berge 505b9d73d5 majur bug in admin interface 2017-05-03 21:41:28 +02:00
nikolark 2100408b72 Cheat 2016-04-13 21:20:26 +02:00
nikolark 8a4a9fcb26 User creation fixes 2016-04-13 21:17:02 +02:00
nikolark 21c4f11ebc go back 2015-11-30 11:31:00 +01:00
nikolark 35835fc64f ? 2015-11-30 11:29:25 +01:00
nikolark 6e11410972 Open up product stats 2015-11-30 11:24:29 +01:00
nikolark 63f0056898 Oops 2015-11-29 18:56:20 +01:00
nikolark e260b3d5bd Merge branch 'restfest' 2015-11-29 18:54:02 +01:00
nikolark ff72b8dd0b Allow exact purchases 2015-11-25 20:31:21 +01:00
220 changed files with 17610 additions and 4529 deletions

No files matched your search

+14
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:3000
+60
View File
@@ -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
+12
View File
@@ -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
+42
View File
@@ -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
+12 -6
View File
@@ -4,9 +4,15 @@ local_settings.py
db.sqlite3
static/.sass-cache
uploads/[^.]*
venv
node_modules
bower_components
.idea
/CACHE
media
venv/
node_modules/
.idea/
CACHE/
media/
tmp/
frontend/dist
static/assets
static/index.html
.python-version
.env
.coverage
+8
View File
@@ -0,0 +1,8 @@
extends: default
rules:
document-start: disable
line-length:
max: 120
truthy:
allowed-values: ["true", "false", "on", "off"]
+44
View File
@@ -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"]
+50 -14
View File
@@ -1,16 +1,52 @@
# Installation
sudo apt-get install libmysqlclient-dev
sudo apt-get build-dep python-imaging
virtualenv --distribute venv
. venv/bin/active
pip install -r requirements
python manage.py syncdb
python manage.py runserver
# Getting started
# TODO
* gulpfile.js: reload on js update, csso
* Larger pictures
* Compact GUI for max 768 vertical pixels
* Search right of in title
* Light on dark colors
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
```
Start the backend and frontend in separate terminals from the project root:
```bash
mise run server
```
```bash
mise run frontend-run
```
Vite prints the frontend URL when it starts (normally <http://localhost:5173/>; if that port is busy, it selects another). Django runs at <http://localhost:3000/>; open the admin directly at <http://localhost:3000/admin/> or through the frontend dev proxy at the printed Vite URL followed by `/admin/`. The GraphQL API is at <http://localhost:3000/graphql/>; the frontend uses Vite's proxy for development API requests.
To test the frontend against a different backend, set `VITE_DEV_API_PROXY` when starting Vite, for example `VITE_DEV_API_PROXY=https://bbs.neuf.no mise run frontend-run`. This affects the development proxy only; use caution with a live backend, since app changes and submissions may modify production data.
For more realistic statistics, `uv run manage.py seed_demo_data --size medium` also generates random users, orders and deposits spread over time (`small`, `medium` or `large`; add `--seed N` for reproducible output). It adds more data on every run.
To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` for that command; the command only creates a local `admin` superuser if one does not already exist.
## Testing
```bash
mise run test
```
## Deployment
Every push to main that passes the tests is built into `git.neuf.no/edb/bbs:<sha>` and deployed to amiga by `.gitea/workflows/deploy.yml`, while the repository variable `DEPLOY_AMIGA` is `true`. The image runs with `bbs.settings_prod`: SQLite and uploads in `/data`, Django under `/api/` and the frontend at the root. The stacks on amiga, one per tenant, are the `bbs` role in [edb-infra](https://git.neuf.no/edb/edb-infra).
## References
- We use JS code generation to work with queries/mutations in our graphql API: <https://github.com/dotansimha/graphql-typed-document-node>
## TODO-old
- Larger pictures
- Compact GUI for max 768 vertical pixels
- Search right of in title
File renamed without changes.
+11 -2
View File
@@ -1,11 +1,20 @@
from django.contrib import admin
from main.models import *
from bbs.models import (
InventoryTransaction,
Order,
OrderLine,
Product,
Transaction,
UserProfile,
)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
pass
admin.site.register(Product, ProductAdmin)
admin.site.register(InventoryTransaction)
admin.site.register(Order)
admin.site.register(OrderLine)
View File
File renamed without changes.
+22
View File
@@ -0,0 +1,22 @@
import strawberry
@strawberry.interface
class Error:
message: str
@strawberry.field()
def error(self) -> bool:
return True
@strawberry.type
class FieldError(Error):
field: str
@strawberry.type
class FormErrors:
fields: list[FieldError]
message: str = ""
error: bool = True
File renamed without changes.
+63
View File
@@ -0,0 +1,63 @@
from decimal import Decimal
from typing import Annotated
import strawberry
from django.db import transaction
from strawberry import ID, Info
from bbs import models
from bbs.api.errors import FieldError, FormErrors
from bbs.api.types.inventory import InventoryTransaction
@strawberry.input
class InventoryTransactionInput:
product_id: ID
amount: int
unit_price: Decimal
comment: str = ""
@strawberry.type
class CreateInventoryTransactionSuccess:
inventory_transaction: InventoryTransaction
CreateInventoryTransactionResponse = Annotated[
CreateInventoryTransactionSuccess | FormErrors,
strawberry.union("CreateInventoryTransactionResponse"),
]
@strawberry.mutation
@transaction.atomic
def create_inventory_transaction(
info: Info,
data: InventoryTransactionInput,
) -> CreateInventoryTransactionResponse:
user = info.context.request.user
if not user.is_authenticated:
message = "Logg inn for å registrere varetransaksjoner."
return FormErrors(message=message, fields=[FieldError(field="user", message=message)])
try:
product = models.Product.objects.get(pk=data.product_id, active=True)
except models.Product.DoesNotExist, ValueError:
message = "Velg et aktivt produkt."
return FormErrors(message=message, fields=[FieldError(field="product_id", message=message)])
product.inventory_amount += data.amount
product.save()
inventory_transaction = models.InventoryTransaction.objects.create(
user=user,
product=product,
amount=data.amount,
unit_price=data.unit_price,
comment=data.comment,
)
return CreateInventoryTransactionSuccess(inventory_transaction=inventory_transaction)
@strawberry.type
class InventoryMutations:
create_inventory_transaction = create_inventory_transaction
+145
View File
@@ -0,0 +1,145 @@
from typing import Annotated, Union
import strawberry
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.shortcuts import get_object_or_404
from strawberry import ID
from bbs import models
from bbs.api.errors import Error, FieldError, FormErrors
from bbs.api.types.inventory import Transaction
from bbs.api.types.shop import Order
UserModel = get_user_model()
@strawberry.input
class OrderLineInput:
product_id: ID
amount: int
@strawberry.type
class CreateOrderSuccess:
order: Order
@strawberry.type
class InsufficientFunds(Error): # noqa: N818
amount_lacking: float
CreateOrderResponse = Annotated[
Union[CreateOrderSuccess, InsufficientFunds, FormErrors], strawberry.union("CreateOrderResponse") # noqa: UP007
]
@strawberry.mutation
@transaction.atomic
def create_order(customer_id: ID, order_lines: list[OrderLineInput], is_external: bool = False) -> CreateOrderResponse: # noqa: FBT001
# clean and validate
try:
user = UserModel.objects.get(pk=customer_id)
except UserModel.DoesNotExist, ValueError:
user = None
if user is None and not is_external:
message = "Du har ikke valgt hvem som skal kjøpe."
return FormErrors(
message=message,
fields=[FieldError(field="customer_id", message=message)],
)
if not order_lines:
message = "Du har ikke valgt hva du skal kjøpe."
return FormErrors(
message=message,
fields=[FieldError(field="order_lines", message=message)],
)
ols = []
order_sum = 0
for ol in order_lines:
product = get_object_or_404(models.Product, pk=ol.product_id)
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
if not is_external and user.profile.balance < order_sum:
return InsufficientFunds(
message="Du har ikke nok penger på bok.",
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
models.OrderLine.objects.bulk_create(ols)
# update balance
if not is_external:
user.profile.balance -= order_sum
user.profile.save()
return CreateOrderSuccess(order=order)
@strawberry.type
class CreateDepositSuccess:
transaction: Transaction
@strawberry.type
class AboveMaxSaldo(Error): # noqa: N818
max_saldo: float
above: float
CreateDepositResponse = Annotated[
Union[CreateDepositSuccess, AboveMaxSaldo, FormErrors], strawberry.union("CreateDepositResponse") # noqa: UP007
]
@strawberry.mutation
@transaction.atomic
def create_deposit(user_id: ID, amount: int) -> CreateDepositResponse:
# clean and validate
try:
user = UserModel.objects.get(pk=user_id)
except UserModel.DoesNotExist, ValueError:
message = "Du har ikke valgt hvem som sette inn penger."
return FormErrors(
message=message,
fields=[FieldError(field="user_id", message=message)],
)
if amount <= 0:
message = "Du har ikke valgt beløp som skal settes inn."
return FormErrors(
message=message,
fields=[FieldError(field="amount", message=message)],
)
# 1337 is special <3
if amount + user.profile.balance > settings.BBS_SALDO_MAX and amount + user.profile.balance != 1337: # noqa: PLR2004
return AboveMaxSaldo(
message="Du prøver å sette inn for mye penger",
max_saldo=settings.BBS_SALDO_MAX,
above=float(amount + user.profile.balance - settings.BBS_SALDO_MAX),
)
trans = models.Transaction.objects.create(user=user, amount=amount)
profile = trans.user.profile
profile.balance += trans.amount
profile.save()
return CreateDepositSuccess(transaction=trans)
@strawberry.type
class ShopMutations:
create_order = create_order
create_deposit = create_deposit
+33
View File
@@ -0,0 +1,33 @@
import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser
from django.db import transaction
from strawberry import auto
from bbs import models
from bbs.api.types.users import User
@strawberry_django.input(DjangoUser)
class UserInput:
email: auto
first_name: auto
last_name: auto
@strawberry.mutation
@transaction.atomic
def register_mutation(input: UserInput) -> User: # noqa: A002
user = DjangoUser.objects.create_user(
first_name=input.first_name,
last_name=input.last_name,
email=input.email,
username=input.email,
)
models.UserProfile.objects.create(user=user)
return user
@strawberry.type
class UsersMutations:
create_user = register_mutation
File renamed without changes.
+105
View File
@@ -0,0 +1,105 @@
from datetime import datetime
from decimal import Decimal
import strawberry
import strawberry_django
from django.db.models import F, Sum
from django.utils import timezone
from bbs import models
from bbs.api.types.inventory import (
InventoryReport,
InventoryReportProduct,
InventoryTransaction,
InventoryTransactionOrdering,
Transaction,
TransactionOrdering,
)
@strawberry.type
class InventoryQueries:
transaction_list: list[Transaction] = strawberry_django.field(pagination=True, ordering=TransactionOrdering)
inventory_transaction_list: list[InventoryTransaction] = strawberry_django.field(
pagination=True,
ordering=InventoryTransactionOrdering,
)
@strawberry.field
def inventory_report(self, start_time: datetime, end_time: datetime) -> InventoryReport:
if timezone.is_naive(start_time):
start_time = timezone.make_aware(start_time)
if timezone.is_naive(end_time):
end_time = timezone.make_aware(end_time)
if start_time > end_time:
raise ValueError("The report start date must not be after the end date.")
purchases = (
models.InventoryTransaction.objects.filter(created__range=(start_time, end_time))
.values("product_id", "product__name")
.annotate(units=Sum("amount"), value=Sum(F("amount") * F("unit_price")))
)
sales = (
models.OrderLine.objects.filter(order__created__range=(start_time, end_time))
.values("product_id", "product__name")
.annotate(units=Sum("amount"), value=Sum(F("amount") * F("unit_price")))
)
product_totals = {}
for product in purchases:
product_totals[product["product_id"]] = {
"product_id": product["product_id"],
"product_name": product["product__name"],
"purchase_units": product["units"] or 0,
"purchase_value": product["value"] or Decimal(0),
"sales_units": 0,
"sales_value": Decimal(0),
}
for product in sales:
product_data = product_totals.setdefault(
product["product_id"],
{
"product_id": product["product_id"],
"product_name": product["product__name"],
"purchase_units": 0,
"purchase_value": Decimal(0),
"sales_units": 0,
"sales_value": Decimal(0),
},
)
product_data["sales_units"] = product["units"] or 0
product_data["sales_value"] = product["value"] or Decimal(0)
product_data = sorted(product_totals.values(), key=lambda product: product["product_name"])
inventory_purchases = sum((product["purchase_value"] for product in product_data), Decimal(0))
product_sales = sum((product["sales_value"] for product in product_data), Decimal(0))
deposits = models.Transaction.objects.filter(created__range=(start_time, end_time)).aggregate(
total=Sum("amount")
)["total"] or Decimal(0)
order_purchases = models.Order.objects.filter(created__range=(start_time, end_time)).aggregate(
total=Sum("order_sum")
)["total"] or Decimal(0)
deposits_start = models.Transaction.objects.filter(created__lt=start_time).aggregate(total=Sum("amount"))[
"total"
] or Decimal(0)
orders_start = models.Order.objects.filter(created__lt=start_time).aggregate(total=Sum("order_sum"))[
"total"
] or Decimal(0)
deposits_end = models.Transaction.objects.filter(created__lte=end_time).aggregate(total=Sum("amount"))[
"total"
] or Decimal(0)
orders_end = models.Order.objects.filter(created__lte=end_time).aggregate(total=Sum("order_sum"))[
"total"
] or Decimal(0)
return InventoryReport(
products=[InventoryReportProduct(**product) for product in product_data],
inventory_purchases=inventory_purchases,
product_sales=product_sales,
deposits=deposits,
order_purchases=order_purchases,
balance_at_start=deposits_start - orders_start,
balance_at_end=deposits_end - orders_end,
)
+42
View File
@@ -0,0 +1,42 @@
import strawberry
import strawberry_django
from bbs import models
from bbs.api.types.shop import (
Order,
OrderOrdering,
OrderStats,
OrderStatsByTime,
Point,
Product,
ProductOrderStat,
)
@strawberry.type
class ShopQueries:
all_products: list[Product] = strawberry_django.field()
all_orders: list[Order] = strawberry_django.field()
order_list: list[Order] = strawberry_django.field(pagination=True, ordering=OrderOrdering)
@strawberry.field
def order_stats(self) -> OrderStats:
return OrderStats(
**{
period: [
OrderStatsByTime(**stat)
for stat in models.Order.objects.count_by_created_period(period).order_by("period")
]
for period in ["yearly", "monthly", "daily", "hourly"]
}
)
@strawberry.field
def product_stats(self) -> list[ProductOrderStat]:
return [
ProductOrderStat(
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in models.OrderLine.objects.product_order_stats().as_series()
]
+16
View File
@@ -0,0 +1,16 @@
import strawberry
from django.contrib.auth.models import User as DjangoUser
from strawberry import ID
from bbs.api.types.users import User
@strawberry.type
class UsersQueries:
@strawberry.field
def all_users(self) -> list[User]:
return DjangoUser.objects.filter(profile__isnull=False)
@strawberry.field
def user(self, user_id: ID) -> User:
return DjangoUser.objects.get(pk=user_id)
+36
View File
@@ -0,0 +1,36 @@
import strawberry
from django.core.exceptions import ObjectDoesNotExist
from strawberry.extensions import SchemaExtension
from bbs.api.mutations.inventory import InventoryMutations
from bbs.api.mutations.shop import ShopMutations
from bbs.api.mutations.users import UsersMutations
from bbs.api.queries.inventory import InventoryQueries
from bbs.api.queries.shop import ShopQueries
from bbs.api.queries.users import UsersQueries
@strawberry.type
class Query(InventoryQueries, ShopQueries, UsersQueries):
pass
@strawberry.type
class Mutation(InventoryMutations, ShopMutations, UsersMutations):
pass
class HandleNotFound(SchemaExtension):
def on_operation(self):
yield
result = self.execution_context.result
if result.errors:
for error in result.errors:
if isinstance(error.original_error, ObjectDoesNotExist):
error.extensions = {
"code": "NOT_FOUND",
"model": type(error.original_error).__qualname__.replace(".DoesNotExist", ""),
}
schema = strawberry.Schema(query=Query, mutation=Mutation, extensions=[HandleNotFound])
File renamed without changes.
+62
View File
@@ -0,0 +1,62 @@
from decimal import Decimal
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import ID, auto
from bbs import models
if TYPE_CHECKING:
from .shop import Product
from .users import User
@strawberry_django.order_type(models.Transaction)
class TransactionOrdering:
created: auto
@strawberry_django.type(models.Transaction)
class Transaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
amount: auto
created: auto
@strawberry_django.type(models.InventoryTransaction)
class InventoryTransaction:
id: auto
user: Annotated["User", strawberry.lazy(".users")]
product: Annotated["Product", strawberry.lazy(".shop")]
amount: auto
unit_price: auto
comment: auto
created: auto
@strawberry_django.order_type(models.InventoryTransaction)
class InventoryTransactionOrdering:
created: auto
@strawberry.type
class InventoryReportProduct:
product_id: ID
product_name: str
purchase_units: int
purchase_value: Decimal
sales_units: int
sales_value: Decimal
@strawberry.type
class InventoryReport:
products: list[InventoryReportProduct]
inventory_purchases: Decimal
product_sales: Decimal
deposits: Decimal
order_purchases: Decimal
balance_at_start: Decimal
balance_at_end: Decimal
+84
View File
@@ -0,0 +1,84 @@
from decimal import Decimal
from typing import TYPE_CHECKING, Annotated
import strawberry
import strawberry_django
from strawberry import auto
from strawberry.scalars import JSON
from bbs import models
if TYPE_CHECKING:
from .users import User
@strawberry_django.filter_type(models.Product)
class ProductFilter:
id: auto
active: auto
@strawberry_django.type(models.Product, filters=ProductFilter)
class Product:
id: auto
name: auto
image_url: str
sale_price_int: auto
sale_price_ext: auto
active: auto
inventory_amount: auto
updated: auto
wholesale_unit_price: Decimal
wholesale_value: Decimal
# FIXME: stricter types
user_counts: JSON
@strawberry_django.order_type(models.Order)
class OrderOrdering:
created: auto
@strawberry_django.type(models.Order)
class Order:
id: auto
customer: Annotated["User", strawberry.lazy(".users")] | None
order_sum: auto
created: auto
orderlines: list["OrderLine"]
is_external: bool
@strawberry_django.type(models.OrderLine)
class OrderLine:
id: auto
order: Order
product: Product
amount: auto
unit_price: auto
@strawberry.type
class OrderStatsByTime:
period: int
count: str
@strawberry.type
class OrderStats:
yearly: list[OrderStatsByTime]
monthly: list[OrderStatsByTime]
daily: list[OrderStatsByTime]
hourly: list[OrderStatsByTime]
@strawberry.type
class Point:
x: str
y: int
@strawberry.type
class ProductOrderStat:
product_name: str
data: list[Point]
+51
View File
@@ -0,0 +1,51 @@
from datetime import datetime
from decimal import Decimal
import strawberry
import strawberry_django
from django.contrib.auth.models import User as DjangoUser
from strawberry import auto
from bbs import models
from bbs.api.types.shop import Point, ProductOrderStat
@strawberry.type
class TopMonth:
period: str
count: int
sum: float
@strawberry_django.type(models.UserProfile)
class UserProfile:
id: auto
balance: auto
image: auto
last_purchase_date: datetime
order_sum_total: Decimal
@strawberry.field()
def top_months(self) -> list[TopMonth]:
return [
TopMonth(period=month["period"], count=month["count"], sum=float(month["sum"]))
for month in self.top_months()
]
@strawberry.field()
def product_order_stats(self) -> list[ProductOrderStat]:
return [
ProductOrderStat(
product_name=stat["product_name"],
data=[Point(**p) for p in stat["data"]],
)
for stat in self.product_order_stats().as_series()
]
@strawberry_django.type(DjangoUser)
class User:
id: auto
first_name: auto
last_name: auto
profile: UserProfile | None
+10
View File
@@ -0,0 +1,10 @@
from django.apps import AppConfig
class BBSAppConfig(AppConfig):
name = "bbs"
verbose_name = "Biceps Bar System"
def ready(self):
# all models are loaded, could attach signals
pass
+29
View File
@@ -0,0 +1,29 @@
from django import forms
from bbs.models import InventoryTransaction, Product
MAX_DIGITS = 9
DECIMAL_PLACES = 1
class InventoryTransactionForm(forms.ModelForm):
unit_price = forms.DecimalField(
max_digits=MAX_DIGITS,
decimal_places=DECIMAL_PLACES,
required=True,
localize=True,
)
def __init__(self, *args, **kwargs):
# Only show active products
super().__init__(*args, **kwargs)
self.fields["product"].queryset = Product.objects.filter(active=True)
class Meta:
model = InventoryTransaction
fields = ["product", "amount", "unit_price", "comment"]
class DateRangeForm(forms.Form):
start_time = forms.DateTimeField(required=False, label="Fra")
end_time = forms.DateTimeField(required=False, label="Til")
+1
View File
@@ -0,0 +1 @@
__author__ = "Nikolai"
+1
View File
@@ -0,0 +1 @@
__author__ = "Nikolai"
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand, CommandParser
from strawberry.printer import print_schema
from bbs.api.schema import schema as schema_symbol
class Command(BaseCommand):
help = "Write graphql schema to file or stdout"
def add_arguments(self, parser: CommandParser):
parser.add_argument("--filename", default="schema.graphql", help="`-` prints to stdout")
def handle(self, *args, filename=None, **options): # noqa: ARG002
schema_output = print_schema(schema_symbol)
if filename != "-":
with Path(settings.BASE_DIR, filename).open("w+") as fp:
fp.write(schema_output)
self.stdout.write(self.style.SUCCESS("OK"))
else:
pass
+19
View File
@@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand, CommandParser
from bbs.models import UserProfile
class Command(BaseCommand):
"""Set profile image URL for all users"""
def add_arguments(self, parser: CommandParser):
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--force", action="store_true")
def handle(self, *args, dry_run=False, force=False, **options): # noqa: ARG002
for profile in UserProfile.objects.all().order_by("id"):
if dry_run:
pass
elif force or not profile.image:
profile.image = profile.image_url()
profile.save()
+388
View File
@@ -0,0 +1,388 @@
import os
import random
from datetime import datetime, time, timedelta
from decimal import Decimal
from urllib.parse import quote
from django.contrib.auth import get_user_model
from django.core.management import BaseCommand, CommandParser
from django.db import transaction as db_transaction
from django.db.models import Sum
from django.utils import timezone
from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction, UserProfile
# Some users go by first name only, which the frontend handles differently.
DEMO_USERS = (
("demo-ada", "Ada", "Nord", "ada@example.test", Decimal("300.0")),
("demo-bjørn", "Bjørn", "Berg", "bjorn@example.test", Decimal("250.0")),
("demo-casey", "Casey", "Sol", "casey@example.test", Decimal("200.0")),
("demo-kari", "Kari", "Nordmann", "kari@example.test", Decimal("400.0")),
("demo-ole-martin", "Ole-Martin", "Ødegård", "ole-martin@example.test", Decimal("300.0")),
("demo-solveig", "Solveig", "", "solveig@example.test", Decimal("250.0")),
("demo-trygve", "Trygve", "Halvorsen", "trygve@example.test", Decimal("200.0")),
("demo-ingrid", "Ingrid", "Bakken", "ingrid@example.test", Decimal("350.0")),
("demo-håkon", "Håkon", "", "hakon@example.test", Decimal("150.0")),
)
# name, volume_liter, alcohol_percent, sale_price_int, sale_price_ext
BEERS = (
("Frydenlund Fatøl", "0.5", "4.5", "25", "35"),
("Ringnes Pilsner", "0.5", "4.7", "25", "35"),
("Nøgne Ø India Pale Ale", "0.5", "7.5", "45", "60"),
("Lervig Lucky Jack", "0.33", "4.7", "40", "55"),
("Munkholm Alkoholfri", "0.5", "0.5", "20", "28"),
("Haandbryggeriet Mjød", "0.5", "8.0", "55", "70"),
("Hansa Bayer", "0.5", "4.6", "27", "37"),
("Aass Bayer", "0.5", "4.5", "28", "38"),
("Mack Pilsner", "0.5", "4.7", "28", "38"),
("Dahls Pils", "0.5", "4.5", "25", "35"),
("Borg Bryggeri Pils", "0.5", "4.7", "30", "40"),
("Grans Bare Pils", "0.5", "4.7", "24", "34"),
("CB Pilsner", "0.5", "4.7", "25", "35"),
("7 Fjell Hopyards IPA", "0.33", "6.5", "48", "63"),
("Tuborg Grønn", "0.5", "4.6", "28", "38"),
("Carlsberg Pilsner", "0.5", "4.6", "28", "38"),
("Frydenlund Bayer", "0.5", "4.5", "26", "36"),
("Berentsens Julebrygg", "0.5", "4.7", "32", "42"),
("Ægir Sumble", "0.5", "4.7", "42", "57"),
("Kinn Vestkyst", "0.5", "5.2", "46", "61"),
("Trondhjem Pilsner", "0.5", "4.7", "30", "40"),
("Nøgne Ø Pale Ale", "0.5", "4.5", "44", "59"),
("Voss Bryggeri Pils", "0.5", "4.7", "38", "50"),
("Lervig Pils", "0.33", "4.7", "30", "40"),
)
DEMO_PRODUCTS = BEERS[:6]
DEMO_STOCK = 30
DESCRIPTIONS = (
"Klassisk norsk pils med frisk og rund smak.",
"Fyldig og maltrik, perfekt på en kald kveld.",
"Lett og forfriskende, godt selskap til maten.",
"Humlerik og bitter, for den som vil ha litt mer.",
"Tradisjonsrik bryggekunst fra ærverdig bryggeri.",
)
# user index, days ago, (product name, amount) lines
DEMO_ORDERS = (
(0, 26, (("Ringnes Pilsner", 2), ("Munkholm Alkoholfri", 1))),
(1, 21, (("Frydenlund Fatøl", 2), ("Lervig Lucky Jack", 1))),
(2, 13, (("Nøgne Ø India Pale Ale", 1),)),
(3, 24, (("Frydenlund Fatøl", 2), ("Munkholm Alkoholfri", 1))),
(4, 19, (("Nøgne Ø India Pale Ale", 2),)),
(5, 15, (("Ringnes Pilsner", 1), ("Lervig Lucky Jack", 1))),
(6, 11, (("Haandbryggeriet Mjød", 1),)),
(7, 8, (("Lervig Lucky Jack", 2), ("Frydenlund Fatøl", 1))),
(3, 4, (("Ringnes Pilsner", 1), ("Frydenlund Fatøl", 1))),
(8, 2, (("Munkholm Alkoholfri", 3),)),
(4, 1, (("Frydenlund Fatøl", 2),)),
)
# Extra random users for --size
FIRST_NAMES = (
"Ola", "Kari", "Per", "Anne", "Lars", "Ingrid", "Bjørn", "Marit", "Geir", "Hanne", "Knut", "Liv",
"Nils", "Solveig", "Erik", "Astrid", "Håkon", "Sigrid", "Trygve", "Randi", "Espen", "Tone", "Magnus",
"Silje", "Henrik", "Maren", "Sander", "Ida", "Jonas", "Emma", "Mathias", "Nora", "Tobias", "Thea",
"Vegard", "Kristin", "Ole-Martin", "Anne-Lise", "Frode", "Gunnhild",
) # fmt: skip
LAST_NAMES = (
"Hansen", "Johansen", "Olsen", "Larsen", "Andersen", "Pedersen", "Nilsen", "Kristiansen", "Jensen",
"Karlsen", "Berg", "Haugen", "Hagen", "Johnsen", "Andreassen", "Jakobsen", "Dahl", "Lund", "Halvorsen",
"Eriksen", "Bakken", "Moen", "Strand", "Ødegård",
) # fmt: skip
SIZE_PRESETS = {
"small": {"users": 6, "orders": 40, "days": 30},
"medium": {"users": 15, "orders": 300, "days": 180},
"large": {"users": 40, "orders": 1500, "days": 365},
}
FIRST_NAME_ONLY_PROB = 0.35 # users registered with just a first name
EXTERNAL_ORDER_PROB = 0.10 # orders with no customer (paid in cash)
WITHDRAWAL_PROB = 0.10 # users who have had money paid back out
def avatar_url(email: str) -> str:
return f"https://i.pravatar.cc/150?u={quote(email)}"
def ascii_name(name: str) -> str:
return name.lower().replace("ø", "o").replace("å", "a").replace("æ", "ae")
class Command(BaseCommand):
help = "Create repeatable demo users, products, deposits, and orders for local testing."
def add_arguments(self, parser: CommandParser):
parser.add_argument(
"--size",
choices=sorted(SIZE_PRESETS),
help="Also generate random users, orders and deposits on top of the demo data. Adds more on every run.",
)
parser.add_argument("--users", type=int, help="Number of random users (overrides --size).")
parser.add_argument("--orders", type=int, help="Number of random orders (overrides --size).")
parser.add_argument("--days", type=int, help="Spread random activity over this many past days.")
parser.add_argument("--seed", type=int, help="Seed for the random data, for reproducible output.")
def handle(self, *args, **options): # noqa: ARG002
users = self.seed_users()
products = self.seed_products(users[0][0])
self.seed_deposits(users)
self.seed_orders(users, products)
self.update_balances(users)
self.create_admin()
if options["size"]:
preset = SIZE_PRESETS[options["size"]]
generator = RandomData(
random.Random(options["seed"]), # noqa: S311
days=options["days"] or preset["days"],
)
with db_transaction.atomic():
generator.run(
n_users=options["users"] or preset["users"],
n_orders=options["orders"] or preset["orders"],
)
self.stdout.write(
f"Generated {generator.n_users} random users and {generator.n_orders} random orders "
f"over {generator.days} days.",
)
self.stdout.write(self.style.SUCCESS("Demo data is ready."))
if not os.environ.get("BBS_SEED_ADMIN_PASSWORD"):
self.stdout.write("Create an admin login with: uv run manage.py createsuperuser")
@staticmethod
def seed_users():
user_model = get_user_model()
users = []
for username, first_name, last_name, email, deposit_amount in DEMO_USERS:
user, _ = user_model.objects.get_or_create(
username=username,
defaults={"first_name": first_name, "last_name": last_name, "email": email},
)
UserProfile.objects.get_or_create(user=user, defaults={"image": avatar_url(email)})
users.append((user, deposit_amount))
return users
@staticmethod
def seed_products(stock_user):
products = {}
for index, (name, volume, alcohol, sale_price_int, sale_price_ext) in enumerate(DEMO_PRODUCTS):
product, created = Product.objects.get_or_create(
name=name,
defaults={
"sale_price_int": Decimal(sale_price_int),
"sale_price_ext": Decimal(sale_price_ext),
"volume_liter": Decimal(volume),
"alcohol_percent": Decimal(alcohol),
"inventory_amount": DEMO_STOCK,
"description": DESCRIPTIONS[index % len(DESCRIPTIONS)],
},
)
products[name] = product
if created:
InventoryTransaction.objects.get_or_create(
user=stock_user,
product=product,
comment="Demo opening stock",
defaults={"amount": DEMO_STOCK, "unit_price": Decimal(sale_price_int) * Decimal("0.6")},
)
return products
def seed_deposits(self, users):
for index, (user, deposit_amount) in enumerate(users):
created_at = self.timestamp(days_ago=32 + index)
deposit, created = Transaction.objects.get_or_create(
user=user,
created=created_at,
defaults={"amount": deposit_amount},
)
if created:
deposit.created = created_at
deposit.save(update_fields=["created"])
def seed_orders(self, users, products):
for user_index, days_ago, lines in DEMO_ORDERS:
user = users[user_index][0]
created_at = self.timestamp(days_ago=days_ago)
total = sum(
(products[name].sale_price_int * amount for name, amount in lines),
start=Decimal(0),
)
order, created = Order.objects.get_or_create(
customer=user,
created=created_at,
defaults={"order_sum": total},
)
if created:
order.created = created_at
order.save(update_fields=["created"])
for name, amount in lines:
product = products[name]
OrderLine.objects.create(
order=order,
product=product,
amount=amount,
unit_price=product.sale_price_int,
)
product.inventory_amount -= amount
product.save(update_fields=["inventory_amount"])
@staticmethod
def update_balances(users):
for user, _ in users:
deposits = Transaction.objects.filter(user=user).aggregate(total=Sum("amount"))["total"] or Decimal(0)
purchases = Order.objects.filter(customer=user).aggregate(total=Sum("order_sum"))["total"] or Decimal(0)
profile = user.profile
profile.balance = deposits - purchases
profile.save(update_fields=["balance"])
def create_admin(self):
admin_password = os.environ.get("BBS_SEED_ADMIN_PASSWORD")
if not admin_password:
return
user_model = get_user_model()
if user_model.objects.filter(username="admin").exists():
self.stdout.write("Existing 'admin' account left unchanged.")
else:
user_model.objects.create_superuser(
username="admin",
email="admin@example.test",
password=admin_password,
)
self.stdout.write("Created superuser 'admin'.")
@staticmethod
def timestamp(days_ago: int) -> datetime:
date = timezone.localdate() - timedelta(days=days_ago)
return timezone.make_aware(datetime.combine(date, time(hour=12)))
class RandomData:
"""Random users, restocks, orders and deposits spread over time, for realistic stats."""
def __init__(self, rng: random.Random, days: int):
self.rng = rng
self.days = days
self.end = timezone.now()
self.start = self.end - timedelta(days=days)
self.n_users = 0
self.n_orders = 0
def run(self, n_users: int, n_orders: int):
users = self.create_users(n_users)
products = self.create_products()
self.restock(products, users)
spent = self.create_orders(users, products, n_orders)
self.create_money(users, spent)
def random_time(self):
span = int((self.end - self.start).total_seconds())
return self.start + timedelta(seconds=self.rng.randint(0, max(span, 1)))
@staticmethod
def set_created(obj, created):
# created is auto_now_add, so backdate with an update
type(obj).objects.filter(pk=obj.pk).update(created=created)
def create_users(self, n: int) -> list:
user_model = get_user_model()
users = []
for _ in range(n):
first = self.rng.choice(FIRST_NAMES)
last = "" if self.rng.random() < FIRST_NAME_ONLY_PROB else self.rng.choice(LAST_NAMES)
base = ".".join(ascii_name(part) for part in (first, last) if part)
email = f"{base}@example.test"
suffix = 1
while user_model.objects.filter(username=email).exists():
suffix += 1
email = f"{base}{suffix}@example.test"
user = user_model.objects.create_user(username=email, email=email, first_name=first, last_name=last)
UserProfile.objects.create(user=user, image=avatar_url(email))
users.append(user)
self.n_users = len(users)
return users
def create_products(self) -> list:
products = []
for name, volume, alcohol, sale_price_int, sale_price_ext in BEERS:
product, _ = Product.objects.get_or_create(
name=name,
defaults={
"sale_price_int": Decimal(sale_price_int),
"sale_price_ext": Decimal(sale_price_ext),
"volume_liter": Decimal(volume),
"alcohol_percent": Decimal(alcohol),
"description": self.rng.choice(DESCRIPTIONS),
},
)
products.append(product)
return products
def restock(self, products: list, users: list):
for product in products:
wholesale = (product.sale_price_int * Decimal("0.6")).quantize(Decimal("0.1"))
for _ in range(self.rng.randint(1, 3)):
amount = self.rng.choice([24, 48, 60, 120])
restock = InventoryTransaction.objects.create(
user=self.rng.choice(users),
product=product,
amount=amount,
unit_price=wholesale,
comment="Innkjøp",
)
self.set_created(restock, self.random_time())
product.inventory_amount += amount
product.save(update_fields=["inventory_amount"])
def create_orders(self, users: list, products: list, n_orders: int) -> dict:
spent = {user.pk: Decimal(0) for user in users}
for _ in range(n_orders):
customer = None if self.rng.random() < EXTERNAL_ORDER_PROB else self.rng.choice(users)
# Mostly a single product, occasionally two or three
n_lines = self.rng.choices([1, 2, 3], weights=[60, 30, 10])[0]
lines = []
order_sum = Decimal(0)
for product in self.rng.sample(products, n_lines):
amount = self.rng.choices([1, 2, 3, 6], weights=[55, 30, 10, 5])[0]
unit_price = product.sale_price_int if customer else product.sale_price_ext
lines.append(OrderLine(product=product, amount=amount, unit_price=unit_price))
order_sum += unit_price * amount
product.inventory_amount = max(0, product.inventory_amount - amount)
order = Order.objects.create(customer=customer, order_sum=order_sum)
for line in lines:
line.order = order
OrderLine.objects.bulk_create(lines)
self.set_created(order, self.random_time())
if customer:
spent[customer.pk] += order_sum
for product in products:
product.save(update_fields=["inventory_amount"])
self.n_orders = n_orders
return spent
def create_money(self, users: list, spent: dict):
"""Round deposits covering what each user spent, plus rare withdrawals within the leftover buffer."""
for user in users:
buffer = Decimal(self.rng.choice([0, 50, 100, 200, 300, 400]))
remaining = spent[user.pk] + buffer
net = Decimal(0)
while remaining > 0:
amount = min(Decimal(self.rng.choice([100, 200, 200, 300, 500])), remaining)
deposit = Transaction.objects.create(user=user, amount=amount)
self.set_created(deposit, self.random_time())
net += amount
remaining -= amount
if buffer > 0 and self.rng.random() < WITHDRAWAL_PROB:
withdrawal = Decimal(self.rng.randint(1, int(buffer)))
payout = Transaction.objects.create(user=user, amount=-withdrawal)
self.set_created(payout, self.random_time())
net -= withdrawal
user.profile.balance = net - spent[user.pk]
user.profile.save(update_fields=["balance"])
+184
View File
@@ -0,0 +1,184 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="InventoryTransaction",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("amount", models.FloatField(verbose_name="Antall")),
("unit_price", models.FloatField(verbose_name="Enhetspris")),
("comment", models.TextField(verbose_name="Kommentar", blank=True)),
("created", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="Order",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("order_sum", models.FloatField(db_column="sum")),
("created", models.DateTimeField(auto_now_add=True)),
(
"customer",
models.ForeignKey(
related_name="orders",
on_delete=models.CASCADE,
blank=True,
to=settings.AUTH_USER_MODEL,
null=True,
),
),
],
),
migrations.CreateModel(
name="OrderLine",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("amount", models.IntegerField()),
("unit_price", models.FloatField(verbose_name="Enhetspris")),
(
"order",
models.ForeignKey(
on_delete=models.CASCADE,
related_name="orderlines",
to="bbs.Order",
),
),
],
),
migrations.CreateModel(
name="Product",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("name", models.CharField(max_length=64)),
("sale_price_int", models.FloatField()),
("sale_price_ext", models.FloatField()),
("description", models.CharField(max_length=256, blank=True)),
("volume_liter", models.FloatField(null=True)),
("alcohol_percent", models.FloatField(null=True)),
("image", models.ImageField(upload_to="products", blank=True)),
("customer_support", models.CharField(max_length=32, blank=True)),
("inventory_amount", models.FloatField(default=0)),
("active", models.BooleanField(default=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Transaction",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("amount", models.FloatField()),
("created", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
on_delete=models.CASCADE,
related_name="transactions",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("balance", models.FloatField(default=0.0)),
("image", models.URLField(blank=True)),
(
"user",
models.OneToOneField(
on_delete=models.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddField(
model_name="orderline",
name="product",
field=models.ForeignKey(on_delete=models.CASCADE, related_name="orderlines", to="bbs.Product"),
),
migrations.AddField(
model_name="inventorytransaction",
name="product",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="transactions",
verbose_name="Produkt",
to="bbs.Product",
),
),
migrations.AddField(
model_name="inventorytransaction",
name="user",
field=models.ForeignKey(
on_delete=models.CASCADE,
related_name="inventory_transactions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterUniqueTogether(
name="orderline",
unique_together={("order", "product")},
),
]
@@ -0,0 +1,62 @@
# Generated by Django 4.0.2 on 2022-03-05 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="inventorytransaction",
name="amount",
field=models.IntegerField(verbose_name="Antall"),
),
migrations.AlterField(
model_name="inventorytransaction",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="orderline",
name="unit_price",
field=models.DecimalField(decimal_places=1, max_digits=9, verbose_name="Enhetspris"),
),
migrations.AlterField(
model_name="product",
name="alcohol_percent",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="product",
name="inventory_amount",
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name="product",
name="sale_price_ext",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="sale_price_int",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="product",
name="volume_liter",
field=models.DecimalField(decimal_places=1, max_digits=9, null=True),
),
migrations.AlterField(
model_name="transaction",
name="amount",
field=models.DecimalField(decimal_places=1, max_digits=9),
),
migrations.AlterField(
model_name="userprofile",
name="balance",
field=models.DecimalField(decimal_places=1, default=0, max_digits=9),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.0.2 on 2023-06-28 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bbs", "0002_alter_inventorytransaction_amount_and_more"),
]
operations = [
migrations.AlterField(
model_name="order",
name="order_sum",
field=models.DecimalField(db_column="sum", decimal_places=1, max_digits=9),
),
]
@@ -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"),
),
]
View File
Whitespace-only changes.
Executable
+260
View File
@@ -0,0 +1,260 @@
import hashlib # noqa: EXE002
from datetime import UTC, datetime
from decimal import Decimal
from itertools import groupby
from django.conf import settings
from django.db import models
from django.db.models import Count, F, Sum
from django.db.models.functions import (
Extract,
ExtractHour,
ExtractIsoWeekDay,
ExtractMonth,
ExtractYear,
TruncDay,
TruncMonth,
)
EXTERNAL_USER = "Ekstern"
MAX_DIGITS = 9
DECIMAL_PLACES = 1
def _format_counts(per_user):
per_user = list(per_user)
for row in per_user:
if row.get("order__customer__first_name") is None:
who = EXTERNAL_USER
else:
first_name = row.pop("order__customer__first_name")
last_name = row.pop("order__customer__last_name")
last_name_initial = f" {last_name[0]}" if last_name else ""
who = f"{first_name}{last_name_initial}"
row["name"] = who
return per_user
class Product(models.Model):
name = models.CharField(max_length=64)
sale_price_int = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
sale_price_ext = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
description = models.CharField(max_length=256, blank=True)
volume_liter = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
alcohol_percent = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, null=True)
image = models.ImageField(upload_to="products", blank=True)
customer_support = models.CharField(max_length=32, blank=True)
# FIXME: rename to inventory_quantity
inventory_amount = models.IntegerField(default=0)
active = models.BooleanField(default=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@property
def wholesale_unit_price(self):
"""
This is not accurate.
"""
if self.inventory_amount <= 0:
return 0
unit_prices = self.transactions.order_by("created").values("amount", "unit_price")
sum_units = 0
for up in unit_prices:
sum_units += up["amount"]
if self.inventory_amount <= sum_units:
return up["unit_price"]
if len(unit_prices) > 0:
return unit_prices[0]["unit_price"]
return 0
@property
def wholesale_value(self):
return self.wholesale_unit_price * self.inventory_amount
@property
def image_url(self):
return self.image.url if self.image else ""
def user_counts(self):
name_fields = ["order__customer__first_name", "order__customer__last_name"]
per_user = OrderLine.objects.filter(product=self)
per_user = per_user.values(*name_fields).annotate(count=Sum("amount")).order_by("-count")
return _format_counts(per_user)
def __str__(self): # noqa: DJ012
return self.name
class Meta: # noqa: DJ012
ordering = ["name"]
class OrderManager(models.Manager):
def count_by_created(self, extract_class: type[Extract]):
period = extract_class("created")
return self.get_queryset().annotate(period=period).values("period").annotate(count=Count("id"))
def count_by_created_period(self, period: str):
periods = {
"yearly": ExtractYear,
"monthly": ExtractMonth,
"daily": ExtractIsoWeekDay,
"hourly": ExtractHour,
}
assert period in periods # noqa: S101
return self.count_by_created(periods[period])
class Order(models.Model):
customer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="orders",
)
order_sum = models.DecimalField(db_column="sum", max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True)
objects = OrderManager()
def is_external(self):
return self.customer is None
def reciept(self):
return ", ".join([str(ol) for ol in self.orderlines.all()])
def __str__(self): # noqa: DJ012
return f"{self.customer or EXTERNAL_USER}: {self.order_sum} kr"
class OrderLineManager(models.QuerySet):
def product_order_stats(self):
return (
self.annotate(date=TruncDay("order__created"))
.values("date", product_name=F("product__name"))
.annotate(count=Sum("amount"))
.order_by("date")
)
def as_series(self):
datas = list(self)
datas.sort(key=lambda x: x["product_name"])
return [
{
"product_name": key,
"data": [{"x": item["date"].date().isoformat(), "y": item["count"]} for item in list(group)],
}
for key, group in groupby(datas, key=lambda x: x["product_name"])
]
class OrderLine(models.Model):
order = models.ForeignKey("bbs.Order", on_delete=models.CASCADE, related_name="orderlines")
product = models.ForeignKey("bbs.Product", on_delete=models.CASCADE, related_name="orderlines")
# FIXME: rename to quantity
amount = models.IntegerField()
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
objects = OrderLineManager.as_manager()
@property
def price(self):
return self.amount * self.unit_price
def __str__(self): # noqa: DJ012
return f"{self.amount} {self.product} ({self.unit_price} kr per)"
class Meta: # noqa: DJ012
constraints = [
models.UniqueConstraint(fields=["order", "product"], name="unique_order_product"),
]
class Transaction(models.Model):
"""Money"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="transactions")
amount = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES)
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.id}: {self.user}: {self.amount}"
class InventoryTransaction(models.Model):
"""Goods"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="inventory_transactions",
)
product = models.ForeignKey(
"bbs.Product",
on_delete=models.CASCADE,
related_name="transactions",
verbose_name="Produkt",
)
# FIXME: rename to quantity
amount = models.IntegerField(verbose_name="Antall")
# FIXME: rename to price
unit_price = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, verbose_name="Enhetspris")
comment = models.TextField(verbose_name="Kommentar", blank=True)
created = models.DateTimeField(auto_now_add=True)
@property
def price(self):
return self.amount * self.unit_price
def __str__(self): # noqa: DJ012
return f"{self.id}: {self.user}: {self.product}. {self.amount:.0f} stykker ({self.unit_price} kr per)"
class UserProfile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
balance = models.DecimalField(max_digits=MAX_DIGITS, decimal_places=DECIMAL_PLACES, default=0)
image = models.URLField(blank=True)
def last_purchase(self):
return self.user.orders.order_by("-created").first()
def last_purchase_date(self) -> datetime:
last = self.last_purchase()
return last.created if last else datetime.min.replace(tzinfo=UTC)
def top_months(self, limit=5):
return (
Order.objects.filter(customer_id=self.user_id)
.annotate(period=TruncMonth("created"))
.values("period")
.annotate(sum=Sum("order_sum"), count=Count("pk"))
.order_by("-sum")[:limit]
)
def product_order_stats(self):
return OrderLine.objects.filter(order__customer__pk=self.pk).product_order_stats()
def order_sum_total(self) -> Decimal:
return Order.objects.filter(customer_id=self.user_id).aggregate(sum=Sum("order_sum"))["sum"] or 0
def image_url(self) -> str:
if self.user.email:
sha256_hash = hashlib.sha256(self.user.email.lower().strip().encode()).hexdigest()
return f"https://www.gravatar.com/avatar/{sha256_hash}?d=identicon"
return ""
def save(self, **kwargs): # noqa: DJ012
if not self.image:
self.image = self.image_url()
super().save(**kwargs)
def __str__(self): # noqa: DJ012
return f"{self.user}"
+149
View File
@@ -0,0 +1,149 @@
# Django settings for bbs project. # noqa: EXE002
import contextlib
import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # noqa: PTH120
DEBUG = True
ADMINS = (
# ('Your Name', 'your_email@example.com'), # noqa: ERA001
)
MANAGERS = ADMINS
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "db.sqlite3",
},
}
TIME_ZONE = "Europe/Oslo"
LANGUAGE_CODE = "nb"
USE_I18N = True
USE_TZ = True
SITE_ID = 1
# Media and uploads
MEDIA_ROOT = os.path.join(BASE_DIR, "media") # noqa: PTH118
MEDIA_URL = "/media/"
# Static files
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") # noqa: PTH118
STATIC_URL = "/static/"
STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),) # noqa: PTH118
STATICFILES_FINDERS = (
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
)
# Make this unique, and don't share it with anybody.
SECRET_KEY = "osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss" # noqa: S105
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "templates"), # noqa: PTH118
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.request",
"django.template.context_processors.debug",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
]
},
},
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
ROOT_URLCONF = "bbs.urls"
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admin",
"django_extensions",
"easy_thumbnails",
"corsheaders",
]
LOCAL_APPS = [
"bbs",
]
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
BBS_SALDO_MAX = 1000
BBS_LIMIT_DEPOSITS = False
# Thumbnails
THUMBNAIL_ALIASES = {
"": {
"product": {"size": (64, 64), "crop": True},
},
}
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"root": {
"level": "INFO",
"handlers": ["console"],
},
"formatters": {
"verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"},
},
"handlers": {
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"loggers": {
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CORS_ALLOWED_ORIGINS = [
"https://bbs.neuf.no",
"http://localhost:8000",
"http://localhost:3000",
]
with contextlib.suppress(ImportError):
from .local_settings import * # noqa: F403
+68
View File
@@ -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,
)
View File
Whitespace-only changes.
+40
View File
@@ -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)
+38
View File
@@ -0,0 +1,38 @@
from bbs.forms import DateRangeForm, InventoryTransactionForm
from bbs.models import Product
def test_inventory_form_filters_active_products(db): # noqa: ARG001
active = Product.objects.create(name="active", sale_price_int=10, sale_price_ext=12, active=True)
inactive = Product.objects.create(name="inactive", sale_price_int=10, sale_price_ext=12, active=False)
product_queryset = InventoryTransactionForm().fields["product"].queryset
assert active in product_queryset
assert inactive not in product_queryset
def test_inventory_form_valid(db): # noqa: ARG001
product = Product.objects.create(name="borg", sale_price_ext=35, sale_price_int=30)
form = InventoryTransactionForm(
data={"product": product.pk, "amount": 5, "unit_price": "12.5", "comment": ""},
)
assert form.is_valid()
def test_inventory_form_invalid_missing_required_fields(db): # noqa: ARG001
form = InventoryTransactionForm(data={})
assert not form.is_valid()
assert "product" in form.errors
def test_date_range_form_allows_missing_dates():
assert DateRangeForm(data={}).is_valid()
def test_date_range_form_accepts_dates():
form = DateRangeForm(data={"start_time": "2026-01-01 00:00:00", "end_time": "2026-01-31 23:59:59"})
assert form.is_valid()
assert form.cleaned_data["start_time"] is not None
+52
View File
@@ -0,0 +1,52 @@
import pytest
from django.core.management import call_command
from bbs.models import UserProfile
def test_profile_images_updates_missing_image(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
call_command("profile_images")
user.profile.refresh_from_db()
assert user.profile.image != ""
def test_profile_images_dry_run_skips_update(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="")
call_command("profile_images", dry_run=True)
user.profile.refresh_from_db()
assert user.profile.image == ""
def test_profile_images_force_overwrites_existing_image(user):
UserProfile.objects.filter(pk=user.profile.pk).update(image="https://example.com/old.jpg")
call_command("profile_images", force=True)
user.profile.refresh_from_db()
assert user.profile.image not in ("", "https://example.com/old.jpg")
@pytest.mark.django_db
def test_export_schema_writes_file(tmp_path, settings):
settings.BASE_DIR = str(tmp_path)
call_command("export_schema")
schema_file = tmp_path / "schema.graphql"
assert schema_file.exists()
assert "type Query" in schema_file.read_text()
@pytest.mark.django_db
def test_export_schema_stdout_does_not_write_file(tmp_path, settings, capsys):
settings.BASE_DIR = str(tmp_path)
call_command("export_schema", filename="-")
assert not (tmp_path / "schema.graphql").exists()
assert capsys.readouterr().out == ""
+313
View File
@@ -0,0 +1,313 @@
import hashlib
from datetime import UTC, datetime
from decimal import Decimal
import pytest
from django.contrib.auth.models import User as DjangoUser
from bbs.models import (
EXTERNAL_USER,
InventoryTransaction,
Order,
OrderLine,
Product,
UserProfile,
_format_counts,
)
# _format_counts
# ==============
def test_format_counts_with_last_name():
rows = [{"order__customer__first_name": "Alice", "order__customer__last_name": "Smith", "count": 3}]
result = _format_counts(rows)
assert result[0]["name"] == "Alice S"
def test_format_counts_no_last_name():
rows = [{"order__customer__first_name": "Bob", "order__customer__last_name": "", "count": 1}]
result = _format_counts(rows)
assert result[0]["name"] == "Bob"
def test_format_counts_external():
rows = [{"order__customer__first_name": None, "order__customer__last_name": None, "count": 2}]
result = _format_counts(rows)
assert result[0]["name"] == EXTERNAL_USER
# Product
# =======
def test_product_str(product):
assert str(product) == "borg"
def test_image_url_without_image(product):
assert product.image_url == ""
def test_image_url_with_image(product):
product.image = "products/test.jpg"
assert product.image_url == product.image.url
def test_wholesale_unit_price_no_inventory(product):
product.inventory_amount = 0
assert product.wholesale_unit_price == 0
def test_wholesale_unit_price_no_transactions(product):
product.inventory_amount = 5
assert product.wholesale_unit_price == 0
def test_wholesale_unit_price_within_batch(user, product):
product.inventory_amount = 5
product.save()
InventoryTransaction.objects.create(user=user, product=product, amount=10, unit_price=Decimal("8.0"))
assert product.wholesale_unit_price == Decimal("8.0")
def test_wholesale_unit_price_exceeds_all_batches(user, product):
product.inventory_amount = 20
product.save()
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=Decimal("8.0"))
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=Decimal("6.0"))
# inventory(20) > total transactions(10), falls back to first transaction's unit_price
assert product.wholesale_unit_price == Decimal("8.0")
def test_wholesale_value(user, product):
product.inventory_amount = 5
product.save()
InventoryTransaction.objects.create(user=user, product=product, amount=10, unit_price=Decimal("8.0"))
assert product.wholesale_value == product.wholesale_unit_price * 5
def test_user_counts(user, product):
o = Order.objects.create(customer=user, order_sum=60)
OrderLine.objects.create(order=o, product=product, amount=2, unit_price=30)
counts = product.user_counts()
assert len(counts) == 1
assert counts[0]["name"] == "Test U"
assert counts[0]["count"] == 2
# OrderManager
# ============
def test_count_by_created_period_yearly(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("yearly"))
assert len(qs) == 1
assert "period" in qs[0]
def test_count_by_created_period_monthly(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("monthly"))
assert len(qs) == 1
def test_count_by_created_period_daily(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("daily"))
assert len(qs) == 1
def test_count_by_created_period_hourly(order): # noqa: ARG001
qs = list(Order.objects.count_by_created_period("hourly"))
assert len(qs) == 1
def test_count_by_created_period_invalid():
with pytest.raises(AssertionError):
Order.objects.count_by_created_period("weekly")
# Order
# =====
def test_order_is_external_true(db): # noqa: ARG001
o = Order.objects.create(order_sum=10)
assert o.is_external() is True
def test_order_is_external_false(user):
o = Order.objects.create(customer=user, order_sum=10)
assert o.is_external() is False
def test_order_receipt_single_line(order):
ol = order.orderlines.first()
assert order.reciept() == str(ol)
def test_order_receipt_multiple_lines(product):
product2 = Product.objects.create(name="cola", sale_price_ext=20, sale_price_int=15)
o = Order.objects.create(order_sum=55)
OrderLine.objects.create(order=o, product=product, amount=1, unit_price=35)
OrderLine.objects.create(order=o, product=product2, amount=1, unit_price=20)
receipt = o.reciept()
assert "borg" in receipt
assert "cola" in receipt
assert ", " in receipt
def test_order_str_external(db): # noqa: ARG001
o = Order.objects.create(order_sum=Decimal("15.0"))
assert EXTERNAL_USER in str(o)
assert "15.0" in str(o)
def test_order_str_with_customer(user):
o = Order.objects.create(customer=user, order_sum=Decimal("20.0"))
assert str(user) in str(o)
# OrderLine / OrderLineManager
# ============================
def test_orderline_price(order):
ol = order.orderlines.first()
assert ol.price == ol.amount * ol.unit_price
def test_orderline_str(order):
ol = order.orderlines.first()
assert str(ol) == f"{ol.amount} {ol.product} ({ol.unit_price} kr per)"
def test_product_order_stats(order): # noqa: ARG001
qs = list(OrderLine.objects.all().product_order_stats())
assert len(qs) == 1
assert qs[0]["product_name"] == "borg"
assert qs[0]["count"] == 1
def test_as_series_single_product(order): # noqa: ARG001
qs = OrderLine.objects.all().product_order_stats()
series = qs.as_series()
assert len(series) == 1
assert series[0]["product_name"] == "borg"
assert len(series[0]["data"]) == 1
assert "x" in series[0]["data"][0]
assert "y" in series[0]["data"][0]
def test_as_series_multiple_products(user, product):
product2 = Product.objects.create(name="cola", sale_price_ext=20, sale_price_int=15)
o = Order.objects.create(customer=user, order_sum=55)
OrderLine.objects.create(order=o, product=product, amount=2, unit_price=30)
OrderLine.objects.create(order=o, product=product2, amount=1, unit_price=20)
qs = OrderLine.objects.all().product_order_stats()
series = qs.as_series()
assert {s["product_name"] for s in series} == {"borg", "cola"}
# Transaction
# ===========
def test_transaction_str(transaction, user):
result = str(transaction)
assert str(transaction.id) in result
assert str(user) in result
assert str(transaction.amount) in result
# InventoryTransaction
# ====================
def test_inventory_transaction_price(user, product):
it = InventoryTransaction.objects.create(user=user, product=product, amount=3, unit_price=Decimal("10.0"))
assert it.price == Decimal("30.0")
def test_inventory_transaction_str(user, product):
it = InventoryTransaction.objects.create(user=user, product=product, amount=3, unit_price=Decimal("10.0"))
result = str(it)
assert str(it.id) in result
assert str(user) in result
assert "borg" in result
# UserProfile
# ===========
def test_userprofile_str(user):
assert str(user.profile) == str(user)
def test_last_purchase_none(user):
assert user.profile.last_purchase() is None
def test_last_purchase_returns_latest(user):
Order.objects.create(customer=user, order_sum=10)
o2 = Order.objects.create(customer=user, order_sum=20)
assert user.profile.last_purchase() == o2
def test_last_purchase_date_no_orders(user):
assert user.profile.last_purchase_date() == datetime.min.replace(tzinfo=UTC)
def test_last_purchase_date_with_orders(user):
o = Order.objects.create(customer=user, order_sum=10)
assert user.profile.last_purchase_date() == o.created
def test_top_months(user):
Order.objects.create(customer=user, order_sum=50)
Order.objects.create(customer=user, order_sum=30)
result = list(user.profile.top_months())
assert len(result) == 1
assert result[0]["sum"] == Decimal("80.0")
def test_order_sum_total_no_orders(user):
assert user.profile.order_sum_total() == 0
def test_order_sum_total_with_orders(user):
Order.objects.create(customer=user, order_sum=Decimal("25.0"))
Order.objects.create(customer=user, order_sum=Decimal("15.0"))
assert user.profile.order_sum_total() == Decimal("40.0")
def test_userprofile_product_order_stats(user, product):
o = Order.objects.create(customer=user, order_sum=30)
OrderLine.objects.create(order=o, product=product, amount=1, unit_price=30)
result = list(user.profile.product_order_stats())
assert len(result) == 1
assert result[0]["product_name"] == "borg"
def test_image_url_with_email(user):
expected_hash = hashlib.sha256(user.email.lower().strip().encode()).hexdigest()
assert expected_hash in user.profile.image_url()
def test_image_url_no_email(db): # noqa: ARG001
u = DjangoUser.objects.create_user(username="noemail", email="")
profile = UserProfile.objects.create(user=u, balance=0)
assert profile.image_url() == ""
def test_save_auto_populates_image(db): # noqa: ARG001
u = DjangoUser.objects.create_user(username="auto@test.com", email="auto@test.com")
profile = UserProfile.objects.create(user=u, balance=0)
assert profile.image != ""
assert "gravatar" in profile.image
def test_save_preserves_existing_image(db): # noqa: ARG001
u = DjangoUser.objects.create_user(username="img@test.com", email="img@test.com")
profile = UserProfile.objects.create(user=u, balance=0, image="https://example.com/custom.jpg")
assert profile.image == "https://example.com/custom.jpg"
+361
View File
@@ -0,0 +1,361 @@
from django.contrib.auth import get_user_model
from django.utils import timezone
from bbs.models import InventoryTransaction, Order, OrderLine, Transaction, UserProfile
CREATE_ORDER = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
... on CreateOrderSuccess {
order { id orderSum orderlines { id product { id } } }
}
... on InsufficientFunds {
amountLacking
error
}
... on FormErrors {
message
}
}
}
"""
CREATE_DEPOSIT = """
mutation CreateDeposit($userId: ID!, $amount: Int!) {
createDeposit(userId: $userId, amount: $amount) {
... on CreateDepositSuccess {
transaction { id amount }
}
... on AboveMaxSaldo {
maxSaldo
above
}
... on FormErrors {
message
}
}
}
"""
CREATE_USER = """
mutation CreateUser($input: UserInput!) {
createUser(input: $input) {
id
firstName
lastName
}
}
"""
def test_user_not_found(db, gql_client): # noqa: ARG001
result = gql_client.query(
"""
query User($userId: ID!) {
user(userId: $userId) {
id
}
}
""",
{"userId": "999"},
assert_no_errors=False,
)
assert result.errors
error = result.errors[0]
assert error["path"] == ["user"]
assert error["extensions"]["code"] == "NOT_FOUND"
assert error["extensions"]["model"] == "User"
def test_create_order(product, gql_client):
assert Order.objects.count() == 0
variables = {
"customerId": "external",
"isExternal": True,
"orderLines": [{"productId": product.id, "amount": 1}],
}
query = """
mutation CreateOrder($customerId: ID!, $isExternal: Boolean!, $orderLines: [OrderLineInput!]!) {
createOrder(customerId: $customerId, isExternal: $isExternal, orderLines: $orderLines) {
... on CreateOrderSuccess {
order {
id
orderSum
orderlines {
id
product {
id
}
}
}
}
}
}
"""
res = gql_client.query(query, variables)
assert res
assert not res.errors
assert res.data
order_lines = res.data.get("createOrder", {}).get("order", {}).get("orderlines", [])
assert order_lines
assert order_lines[0]["product"]["id"] == str(product.id)
assert Order.objects.count() == 1
o = Order.objects.first()
assert o.orderlines.first().product.id == product.id
def test_create_inventory_transaction_requires_login(product, gql_client):
result = gql_client.query(
"""
mutation CreateInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on FormErrors { message }
}
}
""",
variables={"data": {"productId": str(product.id), "amount": 5, "unitPrice": "12.5"}},
)
assert not result.errors
assert result.data["createInventoryTransaction"]["message"] == "Logg inn for å registrere varetransaksjoner."
product.refresh_from_db()
assert product.inventory_amount == 0
assert InventoryTransaction.objects.count() == 0
def test_create_inventory_transaction_updates_stock(product, gql_client, client):
user = get_user_model().objects.create_user(username="stock-manager")
client.force_login(user)
result = gql_client.query(
"""
mutation CreateInventoryTransaction($data: InventoryTransactionInput!) {
createInventoryTransaction(data: $data) {
... on CreateInventoryTransactionSuccess {
inventoryTransaction {
amount
unitPrice
product { id inventoryAmount }
user { id }
}
}
}
}
""",
variables={
"data": {
"productId": str(product.id),
"amount": 5,
"unitPrice": "12.5",
"comment": "Restock",
}
},
)
assert not result.errors
transaction = result.data["createInventoryTransaction"]["inventoryTransaction"]
assert transaction["amount"] == 5
assert transaction["unitPrice"] == "12.5"
assert transaction["product"]["inventoryAmount"] == 5
assert transaction["user"]["id"] == str(user.id)
product.refresh_from_db()
assert product.inventory_amount == 5
def test_inventory_report_aggregates_period_data(product, gql_client):
user = get_user_model().objects.create_user(username="report-user")
InventoryTransaction.objects.create(user=user, product=product, amount=5, unit_price=10)
transaction = Transaction.objects.create(user=user, amount=100)
order = Order.objects.create(order_sum=30)
OrderLine.objects.create(order=order, product=product, amount=2, unit_price=15)
start_time = timezone.make_naive(timezone.now() - timezone.timedelta(days=1))
end_time = timezone.make_naive(timezone.now() + timezone.timedelta(days=1))
result = gql_client.query(
"""
query InventoryReport($startTime: DateTime!, $endTime: DateTime!) {
inventoryReport(startTime: $startTime, endTime: $endTime) {
products {
productId
purchaseUnits
purchaseValue
salesUnits
salesValue
}
inventoryPurchases
productSales
deposits
orderPurchases
balanceAtStart
balanceAtEnd
}
}
""",
variables={"startTime": start_time.isoformat(), "endTime": end_time.isoformat()},
)
assert not result.errors
report = result.data["inventoryReport"]
assert report["products"] == [
{
"productId": str(product.id),
"purchaseUnits": 5,
"purchaseValue": "50",
"salesUnits": 2,
"salesValue": "30",
}
]
assert report["inventoryPurchases"] == "50"
assert report["productSales"] == "30"
assert report["deposits"] == "100"
assert report["orderPurchases"] == "30"
assert report["balanceAtStart"] == "0"
assert report["balanceAtEnd"] == "70"
assert transaction.amount == 100
products_result = gql_client.query(
"""
query InventoryProducts {
allProducts {
id
active
inventoryAmount
wholesaleUnitPrice
wholesaleValue
}
inventoryTransactionList(ordering: [{created: DESC}], pagination: {limit: 20, offset: 0}) {
amount
unitPrice
product { name }
user { firstName lastName }
}
}
"""
)
assert not products_result.errors
assert products_result.data["allProducts"][0]["active"]
assert products_result.data["inventoryTransactionList"][0]["amount"] == 5
def test_create_order_no_customer(product, gql_client):
res = gql_client.query(
CREATE_ORDER,
{
"customerId": "999",
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["message"]
def test_create_order_empty_lines(product, gql_client): # noqa: ARG001
res = gql_client.query(
CREATE_ORDER,
{
"customerId": "external",
"isExternal": True,
"orderLines": [],
},
)
assert not res.errors
assert res.data["createOrder"]["message"]
def test_create_order_insufficient_funds(user, product, gql_client):
res = gql_client.query(
CREATE_ORDER,
{
"customerId": str(user.id),
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["amountLacking"] == 20
assert res.data["createOrder"]["error"] is True
assert Order.objects.count() == 0
product.refresh_from_db()
assert product.inventory_amount == 0
def test_create_order_internal_success(user, product, gql_client):
user.profile.balance = 100
user.profile.save()
res = gql_client.query(
CREATE_ORDER,
{
"customerId": str(user.id),
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["order"]
user.profile.refresh_from_db()
assert user.profile.balance == 70
product.refresh_from_db()
assert product.inventory_amount == -1
def test_create_order_exact_balance(user, product, gql_client):
user.profile.balance = 30
user.profile.save()
res = gql_client.query(
CREATE_ORDER,
{
"customerId": str(user.id),
"isExternal": False,
"orderLines": [{"productId": product.id, "amount": 1}],
},
)
assert not res.errors
assert res.data["createOrder"]["order"]
user.profile.refresh_from_db()
assert user.profile.balance == 0
def test_create_deposit(user, gql_client):
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 100})
assert not res.errors
assert res.data["createDeposit"]["transaction"]
assert Transaction.objects.count() == 1
user.profile.refresh_from_db()
assert user.profile.balance == 110
def test_create_deposit_invalid_user(db, gql_client): # noqa: ARG001
res = gql_client.query(CREATE_DEPOSIT, {"userId": "999", "amount": 100})
assert not res.errors
assert res.data["createDeposit"]["message"]
def test_create_deposit_invalid_amount(user, gql_client):
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 0})
assert not res.errors
assert res.data["createDeposit"]["message"]
def test_create_deposit_above_max_saldo(user, gql_client):
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 1000})
assert not res.errors
assert res.data["createDeposit"]["maxSaldo"] is not None
assert res.data["createDeposit"]["above"] == 10
assert Transaction.objects.count() == 0
def test_create_deposit_1337(user, gql_client):
# balance=10, so amount=1327 makes balance+amount==1337, bypassing max saldo
res = gql_client.query(CREATE_DEPOSIT, {"userId": str(user.id), "amount": 1327})
assert not res.errors
assert res.data["createDeposit"]["transaction"]
def test_create_user(db, gql_client): # noqa: ARG001
res = gql_client.query(CREATE_USER, {"input": {"email": "new@example.com", "firstName": "New", "lastName": "User"}})
assert not res.errors
assert get_user_model().objects.filter(username="new@example.com").exists()
assert UserProfile.objects.count() == 1
+63
View File
@@ -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
+93
View File
@@ -0,0 +1,93 @@
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import Client
from strawberry.django.test import GraphQLTestClient
from bbs.management.commands.seed_demo_data import DEMO_PRODUCTS
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
DEMO_PRODUCT_NAMES = [name for name, *_ in DEMO_PRODUCTS]
@pytest.mark.django_db
def test_seed_demo_data_is_repeatable():
call_command("seed_demo_data")
call_command("seed_demo_data")
assert get_user_model().objects.filter(username__startswith="demo-").count() == 9
assert Product.objects.filter(name__in=DEMO_PRODUCT_NAMES).count() == 6
assert Order.objects.count() == 11
assert Transaction.objects.count() == 9
assert InventoryTransaction.objects.count() == 6
assert sum(product.inventory_amount for product in Product.objects.filter(name__in=DEMO_PRODUCT_NAMES)) == 155
assert all(profile.balance >= 0 for profile in UserProfile.objects.all())
assert all("pravatar.cc" in profile.image for profile in UserProfile.objects.all())
@pytest.mark.django_db
def test_demo_data_is_available_when_an_admin_has_no_profile():
user_model = get_user_model()
user_model.objects.create_superuser(username="admin", email="admin@example.test", password="password")
call_command("seed_demo_data")
gql_client = GraphQLTestClient(Client())
result = gql_client.query(
"""
{
allProducts(filters: {active: true}) { name }
allUsers { firstName lastName profile { balance } }
}
"""
)
assert not result.errors
assert result.data
assert len(result.data["allProducts"]) == 6
assert len(result.data["allUsers"]) == 9
ada = user_model.objects.get(username="demo-ada")
detail_result = gql_client.query(
"""
query UserDetail($userId: ID!) {
user(userId: $userId) {
firstName
profile {
balance
lastPurchaseDate
orderSumTotal
topMonths { period sum count }
productOrderStats { productName data { x y } }
}
}
}
""",
variables={"userId": str(ada.pk)},
)
assert not detail_result.errors
assert detail_result.data["user"]["profile"]["topMonths"]
assert isinstance(detail_result.data["user"]["profile"]["topMonths"][0]["sum"], float)
@pytest.mark.django_db
def test_seed_demo_data_random_size_adds_consistent_data():
call_command("seed_demo_data", size="small", seed=1, users=5, orders=30)
random_users = get_user_model().objects.exclude(username__startswith="demo-")
assert random_users.count() == 5
assert Order.objects.count() == 11 + 30
for user in random_users:
deposits = sum(t.amount for t in Transaction.objects.filter(user=user))
purchases = sum(o.order_sum for o in Order.objects.filter(customer=user))
assert user.profile.balance == deposits - purchases
assert user.profile.balance >= 0
assert all(product.inventory_amount >= 0 for product in Product.objects.all())
@pytest.mark.django_db
def test_seed_demo_data_random_size_can_run_twice():
call_command("seed_demo_data", size="small", seed=1, users=5, orders=5)
call_command("seed_demo_data", size="small", seed=1, users=5, orders=5)
assert get_user_model().objects.exclude(username__startswith="demo-").count() == 10
+10
View File
@@ -0,0 +1,10 @@
import pytest
from django.test import Client
@pytest.mark.parametrize("path", ["/inventory", "/inventory/add", "/report"])
def test_inventory_pages_serve_the_spa_entry(client: Client, path: str):
response = client.get(path)
assert response.status_code == 200
assert b'<div id="root"></div>' in b"".join(response.streaming_content)
+53
View File
@@ -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"
+21
View File
@@ -0,0 +1,21 @@
from bbs.utils import format_username
def test_format_username():
assert format_username("Ada", "Lovelace") == "adalovela"
def test_format_username_truncates_names():
assert format_username("Aleksandar", "Vukovic") == "aleksavukovi"
def test_format_username_removes_spaces():
assert format_username("Mary Jane", "van Dijk") == "maryjavandij"
def test_format_username_drops_non_ascii_characters():
assert format_username("Ångström", "Ø") == "ngstr"
def test_format_username_allows_empty_names():
assert format_username("", "") == ""
+50
View File
@@ -0,0 +1,50 @@
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 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
from bbs.views.spa import spa_index
admin.autodiscover()
api_patterns = [
# bbs has no login template of its own; the admin's works for any user
path(
"accounts/login/",
LoginView.as_view(template_name="admin/login.html", extra_context={"site_header": "BBS"}),
name="django.contrib.auth.views.login",
),
path(
"accounts/logout/",
LogoutView.as_view(),
name="django.contrib.auth.views.logout",
),
path("admin/", admin.site.urls),
path("graphql/", csrf_exempt(GraphQLView.as_view(schema=schema)), name="graphql"),
]
urlpatterns = [
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),
]
+5
View File
@@ -0,0 +1,5 @@
def format_username(firstname: str, lastname: str):
first = firstname.lower().strip().replace(" ", "")[:6].encode("ascii", "ignore").decode("utf-8")
last = lastname.lower().strip().replace(" ", "")[:6].encode("ascii", "ignore").decode("utf-8")
return f"{first}{last}"
View File
Whitespace-only changes.
+145
View File
@@ -0,0 +1,145 @@
from datetime import timedelta
from itertools import groupby
from django.contrib import messages
from django.shortcuts import redirect, render
from django.utils import timezone
from bbs.forms import DateRangeForm, InventoryTransactionForm
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
def report(request):
# FIXME: split this up in smaller pieces
datetime_now = timezone.localtime()
start_time = timezone.localtime() - timedelta(days=30)
# start of day, 1 month
start_time = start_time.replace(hour=0, minute=0, second=0, microsecond=0)
# end of day
end_time = timezone.localtime().replace(hour=23, minute=59, second=59, microsecond=999)
if request.GET.get("start_time", False) or request.GET.get("end_time", False):
form = DateRangeForm(request.GET)
if form.is_valid():
start_time = form.cleaned_data["start_time"]
# end of day
end_time = form.cleaned_data["end_time"].replace(hour=23, minute=59, second=59, microsecond=999)
else:
form = DateRangeForm(
initial={
"start_time": start_time.strftime("%Y-%m-%d"),
"end_time": end_time.strftime("%Y-%m-%d"),
}
)
# Inventory
products = (
Product.objects.filter(transactions__created__range=[start_time, end_time])
.values("name", "transactions__unit_price", "transactions__amount")
.order_by("name")
)
# group by name and sum
inventory_products = {}
for k, g in groupby(products, lambda x: x["name"]):
group = []
for el in g:
el["transactions__price"] = el["transactions__amount"] * el["transactions__unit_price"]
group.append(el)
inventory_products.update(
{
k: {
"name": k,
"transactions": group,
"transactions_sum": sum(x["transactions__price"] for x in group),
"transactions_units": sum(x["transactions__amount"] for x in group),
}
}
)
itrans = InventoryTransaction.objects.filter(created__range=[start_time, end_time])
inv_in = sum(x[1].get("transactions_sum", 0) for x in inventory_products.items())
# out
out_products = (
Product.objects.filter(orderlines__order__created__range=[start_time, end_time])
.values("name", "orderlines__unit_price", "orderlines__amount")
.order_by("name")
)
# group by name and sum
for k, g in groupby(out_products, lambda x: x["name"]):
group = []
for el in g:
el["orderlines__price"] = el["orderlines__amount"] * el["orderlines__unit_price"]
group.append(el)
product = {
"name": k,
"orderlines": group,
"orderlines_sum": sum(x["orderlines__price"] for x in group),
"orderlines_units": sum(x["orderlines__amount"] for x in group),
}
if k in inventory_products:
# add diff
inventory_products[k].update(product)
else:
inventory_products.update({k: product})
for p in inventory_products.items():
p[1]["value_diff"] = p[1].get("transactions_sum", 0) - p[1].get("orderlines_sum", 0)
p[1]["units_diff"] = p[1].get("transactions_units", 0) - p[1].get("orderlines_units", 0)
inv_out = sum(x[1].get("orderlines_sum", 0) for x in inventory_products.items())
inv_diff = inv_in - inv_out
# Deposits and sales
saldo_profiles = sum(UserProfile.objects.all().values_list("balance", flat=True))
deposits = sum(Transaction.objects.all().values_list("amount", flat=True))
purchases = sum(Order.objects.all().values_list("order_sum", flat=True))
saldo = deposits - purchases
deposits_start = sum(Transaction.objects.filter(created__lt=start_time).values_list("amount", flat=True))
purchases_start = sum(Order.objects.filter(created__lt=start_time).values_list("order_sum", flat=True))
saldo_start = deposits_start - purchases_start
deposits_end = sum(Transaction.objects.filter(created__lt=end_time).values_list("amount", flat=True))
purchases_end = sum(Order.objects.filter(created__lt=end_time).values_list("order_sum", flat=True))
saldo_end = deposits_end - purchases_end
deposits_diff = deposits_end - deposits_start
purchases_diff = purchases_end - purchases_start
saldo_diff = saldo_end - saldo_start
return render(request, "report.html", locals()) # noqa: DJ003
def inventory(request):
products = Product.objects.all().order_by("-active", "inventory_amount", "name")
transactions = InventoryTransaction.objects.all().order_by("-created")
products_active = products.filter(active=True)
wholesale_value = sum([p.wholesale_value for p in products_active])
num_products = sum(products_active.values_list("inventory_amount", flat=True))
return render(request, "inventory.html", locals()) # noqa: DJ003
def inventory_add(request):
if request.method == "POST":
form = InventoryTransactionForm(data=request.POST)
if form.is_valid():
itrans = form.save(commit=False)
# Add amount and user, then save
itrans.product.inventory_amount += itrans.amount
itrans.user = request.user
itrans.product.save()
itrans.save()
messages.success(request, f"Satt inn {itrans.amount} stk {itrans.product}.")
return redirect("inventory-add")
messages.error(request, "Feil med skjemaet, se under.")
form = InventoryTransactionForm(data=request.POST)
else:
form = InventoryTransactionForm()
return render(request, "inventory_add.html", locals()) # noqa: DJ003
+11
View File
@@ -0,0 +1,11 @@
from pathlib import Path
from django.contrib.staticfiles import finders
from django.http import FileResponse, Http404
def spa_index(_request):
index_path = finders.find("index.html")
if not index_path:
raise Http404("The frontend has not been built.")
return FileResponse(Path(index_path).open("rb"), content_type="text/html")
+3 -2
View File
@@ -1,6 +1,7 @@
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'bbssite.settings')
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bbs.settings")
application = get_wsgi_application()
-114
View File
@@ -1,114 +0,0 @@
# Django settings for bbs project.
import os
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DEBUG = True
ADMINS = (
# ('Your Name', 'your_email@example.com'),
)
MANAGERS = ADMINS
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'db.sqlite3',
},
}
TIME_ZONE = 'Europe/Oslo'
LANGUAGE_CODE = 'nb'
USE_I18N = True
USE_L10N = True
SITE_ID = 1
# Media and uploads
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
# Static files
STATIC_ROOT = ''
STATIC_URL = '/static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'static'),
)
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
)
# Make this unique, and don't share it with anybody.
SECRET_KEY = 'osv$uy-ea$*%lc)+xiwt5@2b7au%u*c!pa6hmh#vl)*+tp%fss'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.debug',
'django.template.context_processors.i18n',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
'django.contrib.messages.context_processors.messages',
]
},
},
]
MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
)
ROOT_URLCONF = 'bbssite.urls'
INSTALLED_APPS = (
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.sites',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.admin',
'widget_tweaks',
'bootstrapform',
'django_extensions',
'rest_framework',
'easy_thumbnails',
)
LOCAL_APPS = (
'main',
)
INSTALLED_APPS += LOCAL_APPS
LOGIN_REDIRECT_URL = '/'
SESSION_COOKIE_AGE = 1209600 # 15 minutes
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
BBS_SALDO_MAX = 600
BBS_LIMIT_DEPOSITS = False
# Thumbnails
THUMBNAIL_ALIASES = {
'': {
'product': {'size': (64, 64), 'crop': True},
},
}
try:
from .local_settings import *
except ImportError:
pass
-25
View File
@@ -1,25 +0,0 @@
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from main import urls as main_urls
admin.autodiscover()
urlpatterns = [
url(r'', include(main_urls)),
url(r'^accounts/login/$', 'django.contrib.auth.views.login', name='login'),
url(r'^accounts/logout/$', 'django.contrib.auth.views.logout', name='logout'),
url(r'^admin/', include(admin.site.urls)),
]
urlpatterns += staticfiles_urlpatterns()
if settings.DEBUG:
urlpatterns += [
url(r'^media/(?P<path>.*)$', 'django.views.static.serve', {
'document_root': settings.MEDIA_ROOT,
})
]
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -eu
cd frontend
npm i
npm run build
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
set -eu
source "$HOME/.local/bin/env"
export VITE_API_URL="https://bbs.neuf.no/api"
PROJECT_PATH=/var/www/neuf.no/bbs
PROCESS_NAME="bbs.neuf.no"
echo "Deploying bbs to ${PROJECT_PATH}"
cd "$PROJECT_PATH" || exit 1
git pull
# Backend
uv sync
umask 022; uv run manage.py collectstatic --noinput # Collect static
uv run manage.py migrate # Run DB migrations
# Frontend
bin/build-frontend
# Reload backend
echo "Reloading supervisor process ${PROCESS_NAME}"
sudo /usr/bin/supervisorctl pid "$PROCESS_NAME" | xargs kill -HUP
+19
View File
@@ -0,0 +1,19 @@
version: '3'
services:
mysql:
image: mysql
environment:
MYSQL_ROOT_PASSWORD: dev
MYSQL_DATABASE: bbs
ports:
- "3306:3306"
volumes:
- mydata:/var/lib/mysql
networks:
default:
driver: bridge
volumes:
mydata:
+12
View File
@@ -0,0 +1,12 @@
{
"name": "bbs-schema",
"schemaPath": "../schema.graphql",
"extensions": {
"endpoints": {
"default": {
"url": "http://localhost:8000/graphql/",
"introspect": false
}
}
}
}
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
v26
+3
View File
@@ -0,0 +1,3 @@
{
"printWidth": 120
}
+30
View File
@@ -0,0 +1,30 @@
schema: ../schema.graphql
documents: ./src/**/*.graphql
generates:
./src/types.ts:
plugins:
- typescript:
typesPrefix: Schema
scalars:
DateTime:
input: string
output: string
Decimal:
input: string
output: string
JSON:
input: unknown
output: "Array<{ name: string; count: number }>"
- typescript-operations:
nonOptionalTypename: true
scalars:
DateTime:
input: string
output: string
Decimal:
input: string
output: string
JSON:
input: unknown
output: "Array<{ name: string; count: number }>"
- typed-document-node
+54
View File
@@ -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",
},
},
]);
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/brick.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+8942
View File
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
{
"name": "bbs",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc -b && vite build",
"check-types": "tsc -b",
"dev": "vite",
"format:js": "eslint --fix src",
"format:styles": "prettier --write \"src/**/*.css\"",
"generate": "node scripts/generate-types.mjs",
"lint:js": "eslint src",
"lint:styles": "prettier --check \"src/**/*.css\"",
"preview": "vite preview",
"start": "vite",
"test": "node --test"
},
"dependencies": {
"@apollo/client": "^4.2.0",
"@nivo/bar": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.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/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",
"@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-x": "^4.16.2",
"eslint-plugin-prettier": "^5.5.7",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"prettier": "^3.6.2",
"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": "26"
},
"overrides": {
"@whatwg-node/promise-helpers": "^1.3.2"
},
"type": "module"
}
+66
View File
@@ -0,0 +1,66 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { codegen } from "@graphql-codegen/core";
import { plugin as typedDocumentNodePlugin } from "@graphql-codegen/typed-document-node";
import { plugin as typescriptOperationsPlugin } from "@graphql-codegen/typescript-operations";
import { plugin as typescriptPlugin } from "@graphql-codegen/typescript";
import { buildASTSchema, parse } from "graphql";
import { parse as parseYaml } from "yaml";
const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const configPath = path.join(frontendRoot, "codegen.yml");
const config = parseYaml(await readFile(configPath, "utf8"));
const schemaDocument = parse(await readFile(path.resolve(frontendRoot, config.schema), "utf8"));
const outputEntries = Object.entries(config.generates ?? {});
if (outputEntries.length !== 1) {
throw new Error(`Expected one GraphQL Codegen output in ${configPath}`);
}
const [outputName, outputConfig] = outputEntries[0];
const outputPath = path.resolve(frontendRoot, outputName);
const sourceRoot = path.join(frontendRoot, "src");
const plugins = outputConfig.plugins.map((plugin) =>
typeof plugin === "string" ? { [plugin]: {} } : plugin,
);
async function findGraphqlFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nestedFiles = await Promise.all(
entries.map((entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) return findGraphqlFiles(entryPath);
return entry.isFile() && entry.name.endsWith(".graphql") ? [entryPath] : [];
}),
);
return nestedFiles.flat();
}
const documents = await Promise.all(
(await findGraphqlFiles(sourceRoot))
.sort()
.map(async (location) => ({
location,
document: parse(await readFile(location, "utf8")),
})),
);
const pluginMap = {
typescript: { plugin: typescriptPlugin },
"typescript-operations": { plugin: typescriptOperationsPlugin },
"typed-document-node": { plugin: typedDocumentNodePlugin },
};
const generated = await codegen({
filename: outputPath,
schema: schemaDocument,
schemaAst: buildASTSchema(schemaDocument),
documents,
config: {},
plugins,
pluginMap,
});
await writeFile(outputPath, generated);
+5
View File
@@ -0,0 +1,5 @@
.app {
flex: 1 0 auto;
width: min(100% - 2rem, 1220px);
margin: 1rem auto;
}
+30
View File
@@ -0,0 +1,30 @@
import { ApolloProvider } from "@apollo/client/react";
import React from "react";
import { BrowserRouter as Router } from "react-router-dom";
import styles from "./App.module.css";
import { Routes } from "./Routes";
import client from "./client";
import { Footer } from "./components/Footer";
import { Header } from "./components/Header";
import { Toast } from "./components/Toast";
import { ToastProvider } from "./components/ToastProvider";
import { Wireframe } from "./components/Wireframe";
const App = (): React.JSX.Element => (
<ApolloProvider client={client}>
<Router>
<ToastProvider>
<Header />
<div className={styles.app}>
<Routes />
<Wireframe />
</div>
<Footer />
<Toast />
</ToastProvider>
</Router>
</ApolloProvider>
);
export default App;
+38
View File
@@ -0,0 +1,38 @@
import React from "react";
import { Route, Routes as ReactRouterRoutes } from "react-router-dom";
const Splash = React.lazy(() => import("./components/Splash"));
const UserCreate = React.lazy(() => import("./features/auth/UserCreate"));
const Deposit = React.lazy(() => import("./features/deposit/Deposit"));
const Inventory = React.lazy(() => import("./features/inventory/Inventory"));
const InventoryAdd = React.lazy(() => import("./features/inventory/InventoryAdd"));
const InventoryReport = React.lazy(() => import("./features/inventory/InventoryReport"));
const Log = React.lazy(() => import("./features/log/Log"));
const Register = React.lazy(() => import("./features/register/Register"));
const OrderStats = React.lazy(() => import("./features/stats/OrderStats"));
const ProductStats = React.lazy(() => import("./features/stats/ProductStats"));
const Stats = React.lazy(() => import("./features/stats/Stats"));
const UserStats = React.lazy(() => import("./features/stats/UserStats"));
const UserDetail = React.lazy(() => import("./features/users/UserDetail"));
const UserList = React.lazy(() => import("./features/users/UserList"));
export const Routes = (): React.JSX.Element => (
<React.Suspense fallback={<div>Loading...</div>}>
<ReactRouterRoutes>
<Route path="/deposit" element={<Deposit />} />
<Route path="/inventory" element={<Inventory />} />
<Route path="/inventory/add" element={<InventoryAdd />} />
<Route path="/report" element={<InventoryReport />} />
<Route path="/log" element={<Log />} />
<Route path="/stats" element={<Stats />} />
<Route path="/stats/orders" element={<OrderStats />} />
<Route path="/stats/products" element={<ProductStats />} />
<Route path="/stats/users" element={<UserStats />} />
<Route path="/users" element={<UserList />} />
<Route path="/users/create" element={<UserCreate />} />
<Route path="/user/:userId" element={<UserDetail />} />
<Route path="/" element={<Register />} />
<Route path="*" element={<Splash brandText="404" noOverlay />} />
</ReactRouterRoutes>
</React.Suspense>
);
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="g5" transform="matrix(1.301558, 0, 0, 1.301558, -16.927717, 11.280891)" style="">
<g id="g7">
<g id="g9">
<rect x="48.799" y="12.333" width="27.089001" height="27.089001" id="rect11" style="fill:#fed600"/>
</g>
<defs id="defs13">
<filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="48.799" y="12.333" width="27.089001" height="27.089001">
<feFlood result="back" id="feFlood16"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend18"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="48.799" y="12.333" width="27.089" height="27.089" id="SVGID_1_">
<g id="g21" style="filter:url(#Adobe_OpacityMaskFilter)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,46.7988,10.3325)" id="image23" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 FX+uRPy5Prky5c/8H//Z"/>
</g>
</mask>
<g mask="url(#SVGID_1_)" id="g25" style="opacity:0.46000001">
<rect x="48.799" y="12.333" width="27.089001" height="27.089001" id="rect27" style="fill:#ffffff"/>
</g>
</g>
<g id="g29">
<g id="g31">
<rect x="48.799" y="41.158001" width="27.089001" height="27.089001" id="rect33" style="fill:#fed600"/>
</g>
<defs id="defs35">
<filter id="Adobe_OpacityMaskFilter_1_" filterUnits="userSpaceOnUse" x="48.799" y="41.158001" width="27.089001" height="27.089001">
<feFlood result="back" id="feFlood38"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend40"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="48.799" y="41.158" width="27.089" height="27.089" id="SVGID_2_">
<g id="g43" style="filter:url(#Adobe_OpacityMaskFilter_1_)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,46.7988,39.1582)" id="image45" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 uztZ9R//2Q=="/>
</g>
</mask>
<g mask="url(#SVGID_2_)" id="g47" style="opacity:0.46000001">
<rect x="48.799" y="41.158001" width="27.089001" height="27.089001" id="rect49" style="fill:#ffffff"/>
</g>
</g>
<g id="g51">
<g id="g53">
<rect x="78.223" y="41.158001" width="27.089001" height="27.089001" id="rect55" style="fill:#fed600"/>
</g>
<defs id="defs57">
<filter id="Adobe_OpacityMaskFilter_2_" filterUnits="userSpaceOnUse" x="78.223" y="41.158001" width="27.089001" height="27.089001">
<feFlood result="back" id="feFlood60"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend62"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="78.223" y="41.158" width="27.089" height="27.089" id="SVGID_3_">
<g id="g65" style="filter:url(#Adobe_OpacityMaskFilter_2_)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,76.2231,39.1582)" id="image67" style="overflow:visible" xlink:href="data:image/jpeg;base64,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"/>
</g>
</mask>
<g mask="url(#SVGID_3_)" id="g69" style="opacity:0.46000001">
<rect x="78.223" y="41.158001" width="27.089001" height="27.089001" id="rect71" style="fill:#ffffff"/>
</g>
</g>
<g id="g73">
<g id="g75">
<rect x="19.657" y="12.333" width="27.091" height="27.089001" id="rect77" style="fill:#fed600"/>
</g>
<defs id="defs79">
<filter id="Adobe_OpacityMaskFilter_3_" filterUnits="userSpaceOnUse" x="19.657" y="12.333" width="27.091" height="27.089001">
<feFlood result="back" id="feFlood82"/>
<feBlend in="SourceGraphic" in2="back" mode="normal" id="feBlend84"/>
</filter>
</defs>
<mask maskUnits="userSpaceOnUse" x="19.657" y="12.333" width="27.091" height="27.089" id="SVGID_4_">
<g id="g87" style="filter:url(#Adobe_OpacityMaskFilter_3_)">
<image overflow="visible" width="130" height="130" transform="matrix(0.24,0,0,0.24,17.6567,10.3325)" id="image89" style="overflow:visible" xlink:href="data:image/jpeg;base64,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 Z9L/AErWzlbXa7CGrtNXV1fznz5/wf/Z"/>
</g>
</mask>
<g mask="url(#SVGID_4_)" id="g91" style="opacity:0.46000001">
<rect x="19.657" y="12.333" width="27.091" height="27.089001" id="rect93" style="fill:#ffffff"/>
</g>
</g>
</g>
<rect x="18.633" y="79.66" fill="none" width="615.332" height="68.885" id="rect95"/>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
const client = new ApolloClient({
link: new HttpLink({ uri: import.meta.env.DEV ? "/graphql/" : `${import.meta.env.VITE_API_URL}/graphql/` }),
cache: new InMemoryCache(),
});
export default client;
+20
View File
@@ -0,0 +1,20 @@
.footer {
flex-shrink: 0;
padding: 0 0 1rem;
height: 20px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.8rem;
text-align: center;
}
.heart {
color: var(--brand);
}
.brick {
position: relative;
top: 7px;
display: inline-block;
width: 20px;
height: 20px;
}
+15
View File
@@ -0,0 +1,15 @@
import React from "react";
import styles from "./Footer.module.css";
import Brick from "../assets/brick.svg?react";
export const Footer = (): React.JSX.Element => {
return (
<footer className={styles.footer}>
Made with <span className={styles.heart}>❤</span> by{" "}
<span title="EDB-gjengen">
<Brick className={styles.brick} />
</span>
</footer>
);
};
@@ -0,0 +1,11 @@
.errorMessage {
width: 100%;
margin-top: 30vh;
color: var(--brand);
font-family:
Rocket Rinder,
monospace;
font-size: 3rem;
font-style: italic;
text-align: center;
}
+23
View File
@@ -0,0 +1,23 @@
import type { ErrorLike } from "@apollo/client";
import { CombinedGraphQLErrors } from "@apollo/client/errors";
import styles from "./GQLError.module.css";
const formatModel = (text: string) => {
const withSpaces = text
.replace(/([A-Z])/g, " $1")
.toLocaleLowerCase()
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
};
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>;
}
}
return <div className={styles.errorMessage}>Error...</div>;
};
+142
View File
@@ -0,0 +1,142 @@
.header {
width: min(100% - 2rem, 1220px);
max-width: 1220px;
margin: 1rem auto;
}
.nav {
display: flex;
min-height: 5rem;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem 1rem;
text-transform: uppercase;
}
.navLink:after {
position: absolute;
right: 12px;
left: 14px;
display: block;
height: 1px;
background-color: rgba(243, 33, 61, 0.4);
box-shadow:
0 0 4px rgba(243, 33, 61, 0.4),
0 0 11px rgba(243, 33, 61, 0.4),
0 0 19px rgba(243, 33, 61, 0.4);
content: "";
transform: rotate(-2.5deg);
}
.navLink:hover:after {
background-color: var(--brand);
box-shadow:
0 0 4px var(--brand),
0 0 11px var(--brand),
0 0 19px var(--brand),
0 0 30px var(--brand);
}
.logoNav {
margin-left: 8px;
}
.navItems {
display: flex;
align-items: center;
flex: 1;
flex-wrap: wrap;
padding: 0;
margin: 0;
list-style: none;
}
.navLink {
position: relative;
padding: 0.4rem 0.8rem;
border: 1px solid transparent;
transition: color 0.1s ease-in-out;
}
.navLink:hover {
color: white;
}
.active.active {
color: white;
}
@media (max-width: 640px) {
.nav {
align-items: flex-start;
}
.logoNav {
margin: 0 auto;
}
.navItems {
justify-content: center;
gap: 0.25rem;
}
.navLink {
display: inline-block;
padding: 0.5rem;
font-size: 0.8rem;
}
.brand {
padding-left: 0;
font-size: 2.5rem;
}
}
/* FIXME: Duplicates Splash styles */
.brand {
padding-left: 1rem;
color: var(--brand);
font-family:
Rocket Rinder,
monospace;
font-size: 3rem;
font-style: italic;
letter-spacing: 2px;
line-height: 1;
text-shadow:
0 0 4px rgba(243, 33, 61, 0.8),
0 0 11px rgba(243, 33, 61, 0.8),
0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
left: -8px;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family:
Rocket Clouds DEMO,
serif;
font-size: 10px;
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow:
0 0 4px rgba(121, 152, 238, 0.8),
0 0 11px rgba(121, 152, 238, 0.8),
0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+65
View File
@@ -0,0 +1,65 @@
import clsx from "clsx";
import React from "react";
import { NavLink } from "react-router-dom";
import styles from "./Header.module.css";
const ADMIN_URL = `${import.meta.env.VITE_API_URL}/admin/`;
const navLinkStyles = ({ isActive }: { isActive: boolean }) => clsx(styles.navLink, { [styles.active]: isActive });
export const Header = (): React.JSX.Element => {
return (
<header className={styles.header}>
<nav className={styles.nav}>
<NavLink to="/" className={styles.logoNav}>
<div className={styles.brand}>BBS</div>
<div className={styles.subtitle}>Biceps Bar System</div>
</NavLink>
<ul className={styles.navItems}>
<li>
<NavLink to="/" className={navLinkStyles} end>
Kjøp drikke
</NavLink>
</li>
<li>
<NavLink to="/deposit" className={navLinkStyles}>
Sett inn penger
</NavLink>
</li>
<li>
<NavLink to="/log" className={navLinkStyles}>
Logg
</NavLink>
</li>
<li>
<NavLink to="/inventory" className={navLinkStyles}>
Varebeholdning
</NavLink>
</li>
<li>
<NavLink to="/report" className={navLinkStyles}>
Rapport
</NavLink>
</li>
<li>
<NavLink to="/stats" className={navLinkStyles}>
Statistikk
</NavLink>
</li>
<li>
<NavLink to="/users" className={navLinkStyles}>
Folk
</NavLink>
</li>
<li>
<a href={ADMIN_URL} className={styles.navLink}>
Admin
</a>
</li>
</ul>
</nav>
</header>
);
};
+82
View File
@@ -0,0 +1,82 @@
.splash {
top: 0;
right: 0;
bottom: 0;
left: 0;
display: grid;
place-items: center;
text-align: center;
}
.overlay {
position: fixed;
}
.wrap {
position: relative;
}
.sun {
width: clamp(100px, 65vw, 600px);
height: clamp(100px, 65vw, 600px);
border-radius: 50% 50%;
margin: 0 auto;
background: linear-gradient(var(--sun) 50%, var(--sun-end) 90%);
box-shadow:
0 0 4px var(--sun),
0 0 11px var(--sun),
0 0 19px var(--sun),
0 0 30px var(--sun);
}
.brand {
position: relative;
left: -0.6rem;
padding-top: 3rem;
color: var(--brand);
font-family:
Rocket Rinder,
monospace;
font-size: clamp(20px, 20vw, 200px);
font-style: italic;
letter-spacing: 1rem;
line-height: 1;
text-shadow:
0 0 4px rgba(243, 33, 61, 0.8),
0 0 11px rgba(243, 33, 61, 0.8),
0 0 19px rgba(243, 33, 61, 0.8);
}
.subtitle {
position: relative;
bottom: 0.5rem;
left: 0rem;
animation: flicker 1.5s infinite alternate;
color: var(--interlace);
font-family:
Rocket Clouds DEMO,
serif;
font-size: clamp(20px, 5vw, 50px);
font-weight: bold;
line-height: 1;
transform: rotate(-6deg);
}
@keyframes flicker {
0%,
18%,
22%,
25%,
53%,
57%,
100% {
text-shadow:
0 0 4px rgba(121, 152, 238, 0.8),
0 0 11px rgba(121, 152, 238, 0.8),
0 0 19px rgba(121, 152, 238, 0.8);
}
20%,
24%,
55% {
text-shadow: none;
}
}
+20
View File
@@ -0,0 +1,20 @@
import clsx from "clsx";
import React from "react";
import styles from "./Splash.module.css";
interface SplashProps {
brandText?: string;
noOverlay?: boolean;
}
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>
<div className={styles.brand}>{brandText}</div>
<div className={styles.subtitle}>Biceps Bar System</div>
</div>
</div>
);
export default Splash;
+34
View File
@@ -0,0 +1,34 @@
.toast {
position: absolute;
bottom: 32px;
left: 32px;
padding: 0.6rem 1.2rem;
border-radius: 4px;
opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease-in-out;
}
.visible {
opacity: 1;
pointer-events: initial;
}
.primary {
background: linear-gradient(var(--sun) 30%, var(--sun-end) 100%);
box-shadow:
0 0 4px var(--sun),
0 0 11px var(--sun),
0 0 19px var(--sun);
}
.danger {
background: var(--brand);
box-shadow:
0 0 4px rgba(243, 33, 61, 0.8),
0 0 11px rgba(243, 33, 61, 0.8),
0 0 19px rgba(243, 33, 61, 0.8);
}
.close {
display: none;
}
+18
View File
@@ -0,0 +1,18 @@
import clsx from "clsx";
import React from "react";
import styles from "./Toast.module.css";
import { useToast } from "../hooks/useToast";
export const Toast = (): React.JSX.Element => {
const { showToast, message, background, visible } = useToast();
const classes = clsx([styles[background], styles.toast], { [styles.visible]: visible });
return (
<div className={classes} role="alert" aria-live="assertive" aria-atomic="true">
<div>{message}</div>
<button type="button" aria-label="Close" onClick={() => showToast(message)} className={styles.close} />
</div>
);
};
+33
View File
@@ -0,0 +1,33 @@
import React, { useState } from "react";
import type { ReactNode } from "react";
import { ToastContext, defaultToastState } from "../contexts/ToastContext";
import type { Background } from "../contexts/ToastContext";
type ToastProviderProps = {
children: ReactNode;
};
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);
setBackground(background);
if (!visible) {
setVisible(true);
setTimeout(() => {
setVisible(false);
}, 3000);
}
};
const hide = () => {
setVisible(true);
setMessage("");
};
return (
<ToastContext.Provider value={{ visible, showToast, message, hide, background }}>{children}</ToastContext.Provider>
);
};
@@ -0,0 +1,52 @@
.decoration {
position: absolute;
z-index: -1;
top: 0;
right: 0;
left: 0;
height: 100vh;
overflow: clip;
pointer-events: none;
}
.wireframe {
position: absolute;
bottom: calc(22% - 512px);
left: calc(50% - 512px);
display: grid;
width: 1024px;
height: 1024px;
animation-duration: 10s;
animation-iteration-count: infinite;
animation-name: isometric-rotate;
animation-timing-function: linear;
grid-template-columns: repeat(8, 128px);
}
.cell {
border-right: 2px solid var(--sun);
border-bottom: 2px solid var(--sun);
}
.cell:nth-child(8n) {
border-right: none;
}
.cell:nth-child(n + 57) {
border-bottom: none;
}
@media (max-width: 640px) {
.decoration {
display: none;
}
}
@keyframes isometric-rotate {
0% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-45deg);
}
100% {
transform: rotateX(70deg) rotateY(0deg) rotateZ(-405deg);
}
}
+11
View File
@@ -0,0 +1,11 @@
import styles from "./Wireframe.module.css";
export const Wireframe = () => (
<div className={styles.decoration}>
<div className={styles.wireframe}>
{[...Array(64)].map((_, idx) => (
<div key={idx} className={styles.cell} />
))}
</div>
</div>
);
+22
View File
@@ -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);
@@ -0,0 +1,10 @@
.form {
display: grid;
max-width: 500px;
gap: 1rem;
}
.form input {
width: 100%;
min-width: 0;
}
+50
View File
@@ -0,0 +1,50 @@
import { useMutation } from "@apollo/client/react";
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./UserCreate.module.css";
import { CreateUserDocument } from "../../types";
type FormValues = {
firstName: string;
lastName: string;
email: string;
};
const UserCreate = () => {
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>();
const [createUser, { loading: creating }] = useMutation(CreateUserDocument);
const onSubmit: SubmitHandler<FormValues> = async (data) => {
try {
await createUser({ variables: data });
} catch (err) {
console.error("woops", err);
return;
}
reset();
};
return (
<div>
<h2>Opprett ny</h2>
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<label>Fornavn</label>
<input {...register("firstName", { required: true })} />
{errors.firstName && <span>This field is required</span>}
<label>Etternavn</label>
<input {...register("lastName", { required: true })} />
{errors.lastName && <span>This field is required</span>}
<label>E-post</label>
<input type="email" {...register("email", { required: true })} />
{errors.email && <span>This field is required</span>}
<input type="submit" disabled={creating} className="btn-primary" />
</form>
</div>
);
};
export default UserCreate;
@@ -0,0 +1,10 @@
mutation CreateUser($firstName: String!, $lastName: String!, $email: String!) {
createUser(input: {firstName: $firstName, lastName: $lastName, email: $email}) {
id
firstName
lastName
profile {
id
}
}
}
@@ -0,0 +1,113 @@
.deposit {
display: grid;
align-items: start;
gap: 1rem 2rem;
grid-template:
"heading amount" auto
"search amount" auto
"users amount" 1fr
/ minmax(0, 1fr) minmax(260px, 320px);
}
.heading {
grid-area: heading;
margin-bottom: 0;
}
.search {
display: flex;
align-items: center;
gap: 0.75rem;
grid-area: search;
}
.search input {
width: min(100%, 420px);
}
.userList {
display: grid;
max-height: min(62vh, 680px);
padding: 0.5rem 0.5rem 0.5rem 0;
gap: 1rem;
grid-area: users;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
overflow-y: auto;
}
.amountSection {
position: sticky;
top: 1rem;
padding: 1.25rem;
border: 1px solid rgba(221, 81, 127, 0.45);
border-radius: 8px;
background: rgba(35, 15, 41, 0.84);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24);
grid-area: amount;
}
.amountSection h2 {
margin-top: 0;
}
.selectedUser {
min-height: 1.5em;
margin: 0 0 1.25rem;
color: rgba(255, 255, 255, 0.78);
text-shadow: none;
}
.selectedUser strong {
color: var(--text);
}
.amountLabel {
display: block;
margin-bottom: 0.4rem;
color: var(--sun);
}
.amountInput {
display: flex;
align-items: center;
gap: 0.75rem;
}
.amountInput input {
width: 100%;
min-width: 0;
font-size: 1.8rem;
}
.amountInput span {
color: rgba(255, 255, 255, 0.8);
text-shadow: none;
}
.checkout {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.5rem;
}
@media (max-width: 760px) {
.deposit {
gap: 0.75rem;
grid-template:
"heading" auto
"search" auto
"amount" auto
"users" auto
/ minmax(0, 1fr);
}
.amountSection {
position: static;
}
.userList {
max-height: 45vh;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
}
+91
View File
@@ -0,0 +1,91 @@
import React from "react";
import styles from "./Deposit.module.css";
import { useDeposit } from "./useDeposit";
import { UserCard } from "../users/UserCard";
const Deposit = (): React.JSX.Element => {
const {
loading,
selectedUser,
toggleSelectedUser,
amount,
setAmount,
userQuery,
setUserQuery,
filteredUsers,
selectedUserDetails,
reset,
onSubmit,
} = useDeposit();
if (loading) {
return <div>Loading...</div>;
}
return (
<div className={styles.deposit}>
<h2 className={styles.heading}>Hvem skal sette inn penger?</h2>
<div className={styles.search}>
<input
name="userQuery"
onChange={(event: React.FormEvent<HTMLInputElement>) => setUserQuery(event.currentTarget.value)}
value={userQuery}
placeholder="Filtrér på navn"
aria-label="Filtrér personer på navn"
/>
{userQuery !== "" && (
<button type="button" onClick={() => setUserQuery("")}>
Vis alle
</button>
)}
</div>
<section className={styles.amountSection} aria-label="Innskudd">
<h2>Hvor mye?</h2>
{selectedUserDetails ? (
<p className={styles.selectedUser}>
Innskudd til{" "}
<strong>
{selectedUserDetails.firstName} {selectedUserDetails.lastName}
</strong>
</p>
) : (
<p className={styles.selectedUser}>Velg en person fra listen</p>
)}
<label className={styles.amountLabel} htmlFor="deposit-amount">
Beløp
</label>
<div className={styles.amountInput}>
<input
id="deposit-amount"
name="amount"
inputMode="decimal"
value={amount}
onChange={(event) => setAmount(event.target.value)}
placeholder="0"
/>
<span>kr</span>
</div>
<div className={styles.checkout}>
<button type="button" onClick={onSubmit} className="primary">
Sett inn
</button>
<button type="button" onClick={reset}>
Start på nytt
</button>
</div>
</section>
<div className={styles.userList}>
{filteredUsers.map((user) => (
<UserCard
key={user.id}
active={selectedUser === user.id}
onUser={() => toggleSelectedUser(user.id)}
user={user}
/>
))}
</div>
</div>
);
};
export default Deposit;
@@ -0,0 +1,33 @@
mutation CreateDeposit($userId: ID!, $amount: Int!) {
createDeposit(userId: $userId, amount:$amount) {
... on CreateDepositSuccess {
transaction {
id
amount
user {
id
firstName
lastName
profile {
id
balance
}
}
}
}
... on AboveMaxSaldo {
error
message
maxSaldo
above
}
... on FormErrors {
error
message
fields {
field
message
}
}
}
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more