Compare commits
14 Commits
0a93925e11
...
ansible-ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364e59343f | ||
|
|
6d90e131b2 | ||
|
|
632aee934f | ||
|
|
3be92f55fb | ||
| c8c5e658e2 | |||
| 4fbad900a2 | |||
| c8ac8e63ee | |||
| b53e3a8a30 | |||
| 120daf8c31 | |||
| 2807ffd160 | |||
| 51cd47bd47 | |||
| 83611e2a2c | |||
| 8f3bff2e62 | |||
| c17d8a9f8a |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.omo/
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f302d08ffewav8qUiPZ0aI2j",
|
||||
"updatedAt": "2026-11-12T17:30:43.353Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T17:30:43.353Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f304d0bffewgGWmy7An2rOeJ",
|
||||
"updatedAt": "2026-11-12T17:30:35.146Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T17:30:35.146Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f306c90ffe63BtszteHo8OAl",
|
||||
"updatedAt": "2026-11-12T17:30:27.126Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T17:30:27.126Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f306cc1ffemzh3awaA137wZh",
|
||||
"updatedAt": "2026-11-12T17:30:27.053Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T17:30:27.053Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f306cedffevymy11Qu04FoB0",
|
||||
"updatedAt": "2026-11-12T17:30:27.004Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T17:30:27.004Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f393836ffeUi9lBqb1kfxxoz",
|
||||
"updatedAt": "2026-11-12T18:38:36.743Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T18:38:36.743Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"sessionID": "ses_e2f45eaccffeiis1QONAKg7gzm",
|
||||
"updatedAt": "2026-11-12T17:10:58.656Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-11-12T17:10:58.656Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
147
README.md
147
README.md
@@ -4,14 +4,17 @@ A Star Citizen item catalog built from the
|
||||
[scunpacked](https://github.com/richardthombs/scunpacked) game data (mirrored at
|
||||
[StarCitizenWiki/scunpacked-data](https://github.com/StarCitizenWiki/scunpacked-data)).
|
||||
|
||||
Ships, ship equipment, and FPS items are browsable with search, manufacturer /
|
||||
type / size filters, pagination, and a detail view per item.
|
||||
Ships, ship equipment, FPS items, and cargo commodities (ores, refined metals,
|
||||
RMC, construction materials, …) are browsable with search, manufacturer / type /
|
||||
size filters, pagination, and a detail view per item.
|
||||
|
||||
Users can optionally create an account and build a **personal inventory**: items
|
||||
from the catalog are added to their own list with a quantity and notes. The
|
||||
catalog itself stays immutable read-only reference data; personal inventories
|
||||
live in PostgreSQL and reference catalog items by `class_name` only, so the
|
||||
dataset can be replaced wholesale without touching user data.
|
||||
from the catalog are added to their own list with a quantity and notes. Logged-in
|
||||
users can also place **orders** for any catalog item; orders go onto a public
|
||||
board that every visitor can see. The catalog itself stays immutable read-only
|
||||
reference data; personal inventories and orders live in PostgreSQL and reference
|
||||
catalog items by `class_name` only, so the dataset can be replaced wholesale
|
||||
without touching user data.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -22,24 +25,43 @@ dataset can be replaced wholesale without touching user data.
|
||||
| database | PostgreSQL 16 | Accounts, sessions, and per-user inventory rows |
|
||||
| deploy | Ansible + Podman (containers.podman) | Builds images and runs the three containers |
|
||||
|
||||
The backend image downloads the full scunpacked dataset (~83 MB) at build time,
|
||||
so the catalog needs no database — only user data does.
|
||||
The backend image downloads the full scunpacked dataset at build time, then
|
||||
reduces `items.json` to a small `commodities.json` (one entry per commodity) and
|
||||
discards the ~68 MB raw file, so the catalog needs no database — only user data
|
||||
does.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
playbook.yml Ansible deployment (hosts: webservers)
|
||||
playbook.yml Example play using the role (hosts: webservers)
|
||||
inventory.ini Inventory (localhost + commented remote example)
|
||||
trade-hub/
|
||||
teardown.yml Removes this project's containers and data
|
||||
roles/trade_hub/
|
||||
README.md Role usage and variables
|
||||
defaults/main.yml Configurable defaults
|
||||
tasks/main.yml Stage source, build images, run containers
|
||||
files/trade-hub/
|
||||
podman-compose.yml Local three-service stack (db + backend + frontend)
|
||||
backend/ FastAPI app, tests, data fetch script, Dockerfile
|
||||
frontend/ nginx config + static inventory UI
|
||||
```
|
||||
|
||||
The Ansible deployment is a self-contained role. To deploy it from an existing
|
||||
playbook, copy `roles/trade_hub` into that repository's `roles/` directory and
|
||||
add the role to the play:
|
||||
|
||||
```yaml
|
||||
- role: trade_hub
|
||||
tags: [trade_hub]
|
||||
```
|
||||
|
||||
See `roles/trade_hub/README.md` for the variables. The play-level `become: true`
|
||||
used by a typical server play makes podman run as root.
|
||||
|
||||
## Run locally with Podman Compose
|
||||
|
||||
```bash
|
||||
cd trade-hub
|
||||
cd roles/trade_hub/files/trade-hub
|
||||
podman-compose up -d --build
|
||||
# or: podman compose up -d --build
|
||||
```
|
||||
@@ -55,7 +77,8 @@ POSTGRES_PASSWORD='choose-something-strong' podman-compose up -d --build
|
||||
|
||||
## Deploy with Ansible
|
||||
|
||||
Deploys to every host in the `webservers` group (defaults to localhost):
|
||||
The example play deploys the role to every host in the `webservers` group
|
||||
(defaults to localhost):
|
||||
|
||||
```bash
|
||||
POSTGRES_PASSWORD='choose-something-strong' ansible-playbook playbook.yml
|
||||
@@ -68,16 +91,22 @@ To deploy to a remote server, edit `inventory.ini`:
|
||||
server1 ansible_host=203.0.113.10 ansible_user=deploy
|
||||
```
|
||||
|
||||
Then run the same command from this directory. The playbook:
|
||||
Then run the same command from this directory. The role:
|
||||
|
||||
1. installs podman if missing,
|
||||
2. removes the legacy `my-apache-server` container if present,
|
||||
3. builds `sc-inventory-backend` / `sc-inventory-frontend` images (skipped when
|
||||
they already exist; set `-e force_rebuild=true` to rebuild),
|
||||
1. installs podman if missing (only when `trade_hub_install_podman: true`),
|
||||
2. optionally removes the legacy `my-apache-server` container
|
||||
(`trade_hub_remove_legacy_apache: true`),
|
||||
3. stages the application source in `/opt/trade-hub` and builds the
|
||||
`sc-inventory-backend` / `sc-inventory-frontend` images (skipped when the
|
||||
source is unchanged and the images already exist; set
|
||||
`-e trade_hub_force_rebuild=true` to force it),
|
||||
4. creates the `sc-inventory` podman network and the `sc-inventory-data` volume,
|
||||
5. runs PostgreSQL, the backend, and the frontend containers (only the frontend
|
||||
publishes a port: 8080),
|
||||
6. verifies `http://localhost:8080/healthz` returns 200.
|
||||
6. verifies `http://127.0.0.1:8080/healthz` returns 200.
|
||||
|
||||
The old standalone playbook's variables (`force_rebuild`, `cookie_secure`, …)
|
||||
became `trade_hub_*` variables; see `roles/trade_hub/README.md`.
|
||||
|
||||
## Reset for testing (remove everything)
|
||||
|
||||
@@ -112,11 +141,47 @@ from scratch.
|
||||
- Catalog browsing works without an account; the catalog endpoints remain
|
||||
read-only.
|
||||
|
||||
Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item
|
||||
that is already present increments its quantity; quantity and notes can then be
|
||||
edited or the row removed. Responses are enriched live from the catalog. If a
|
||||
catalog item disappears after a dataset refresh, the row is reported with
|
||||
`available: false` instead of being silently deleted.
|
||||
The inventory view can be filtered by category and searched by name/class
|
||||
name, and sorted by `name` (default), `category`, `quantity` (high to low) or
|
||||
`updated` (most recently changed first).
|
||||
|
||||
Inventory rows store `(user_id, class_name, quantity, notes, visibility,
|
||||
quality)`. Adding an item that is already present increments its quantity;
|
||||
quantity, notes, visibility and quality can then be edited or the row removed.
|
||||
Every added item starts `private`; the Visibility toggle switches it to
|
||||
`available` and back. Responses are enriched live from the catalog.
|
||||
|
||||
`quality` is an optional mining/refining value from **1 to 1000**. It is only
|
||||
accepted for commodities that are actually mineable — ores, minerals and gems
|
||||
(and their refined/raw/pure variants). Gases, organics, processed goods, crafted
|
||||
alloys and salvage are excluded. The catalog exposes this as
|
||||
`quality_applicable`; the UI shows a Quality input only for those commodities.
|
||||
`null` means the owner has not recorded a quality. On `PATCH`, sending
|
||||
`"quality": null` clears it, while omitting the field leaves it unchanged. Each
|
||||
inventory row holds one quality (the schema keeps one row per commodity, so
|
||||
different quality lots are not separated). If a catalog item disappears after a
|
||||
dataset refresh, the row is reported with `available: false` instead of being
|
||||
silently deleted (`available` is about the catalog, `visibility` is the owner's
|
||||
classification).
|
||||
|
||||
The **Available** tab (backed by `GET /api/v1/available`) is public, like the
|
||||
catalog: it lists every row marked `available` across all accounts with the
|
||||
item's catalog details, quantity, quality (when applicable), owner username and
|
||||
last update. Private rows
|
||||
never appear there and inventory notes are never exposed.
|
||||
|
||||
The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in
|
||||
user clicks any item to open its detail view, then fills in the **Request this
|
||||
item** form there with a quantity, optional notes, and — for mineable
|
||||
commodities — an optional quality. Setting up the order publishes the request
|
||||
on the public board.
|
||||
Orders show the item, quantity, quality, notes, requesting username, status
|
||||
(`open`/`fulfilled`) and last update. Notes on orders are public by design;
|
||||
inventory notes remain private. Only the owner can edit, mark fulfilled/reopen,
|
||||
or remove their own order.
|
||||
|
||||
Clicking an item name in **My Inventory**, **Available** or **Orders** opens the
|
||||
same catalog detail view used by the catalog grid.
|
||||
|
||||
### Security: read before exposing this publicly
|
||||
|
||||
@@ -124,7 +189,8 @@ The stack publishes plain HTTP on port 8080. Session cookies therefore travel
|
||||
in cleartext, and `COOKIE_SECURE` defaults to `false` so local development works.
|
||||
Before putting this on the open internet, terminate TLS in front of the
|
||||
frontend (Caddy, nginx with certificates, or a load balancer) and set
|
||||
`COOKIE_SECURE=true` (compose env var) or `-e cookie_secure=true` (Ansible).
|
||||
`COOKIE_SECURE=true` (compose env var) or
|
||||
`-e trade_hub_cookie_secure=true` (Ansible).
|
||||
Also change `POSTGRES_PASSWORD` from its development default.
|
||||
|
||||
## Verify
|
||||
@@ -154,10 +220,10 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
|
||||
## Backend development
|
||||
|
||||
```bash
|
||||
cd trade-hub/backend
|
||||
cd roles/trade_hub/files/trade-hub/backend
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
||||
.venv/bin/python -m pytest tests/ -q # 45 tests, no database needed
|
||||
.venv/bin/python -m pytest tests/ -q # 83 tests, no database needed
|
||||
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000
|
||||
```
|
||||
|
||||
@@ -167,10 +233,17 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
|
||||
- `data/samples/` — small real-schema subset for local dev/tests
|
||||
- `scripts/fetch_data.py` — downloads the full dataset (used by the Dockerfile)
|
||||
|
||||
Commodities are deduplicated to one catalog entry per name (the 1 SCU container
|
||||
variant is canonical); quantity lives on the inventory row, so 1 SCU, 8 SCU and
|
||||
32 SCU crates of the same commodity share a single entry. Refined and unrefined
|
||||
materials stay separate (`Iron` vs `Iron (Ore)`). Quality applies to both the
|
||||
refined and raw/ore entries and is user-recorded per inventory row.
|
||||
|
||||
Tables are created on startup with an idempotent `create_all` and a connection
|
||||
retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does
|
||||
not crash the backend. This does not migrate changed columns — introduce Alembic
|
||||
when the schema starts evolving in place.
|
||||
not crash the backend. Additive columns for existing deployments (currently
|
||||
`inventory_entries.visibility`) are applied automatically on startup; changing
|
||||
or dropping a column still requires Alembic.
|
||||
|
||||
## API
|
||||
|
||||
@@ -186,14 +259,26 @@ POST /api/v1/auth/login {username, password} -> 200 + session c
|
||||
POST /api/v1/auth/logout -> 204
|
||||
GET /api/v1/auth/me -> {user}
|
||||
|
||||
GET /api/v1/me/inventory?category=&search=&page=&page_size=
|
||||
GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
|
||||
GET /api/v1/me/inventory/summary
|
||||
POST /api/v1/me/inventory {class_name, quantity?, notes?}
|
||||
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?}
|
||||
POST /api/v1/me/inventory {class_name, quantity?, notes?, quality?}
|
||||
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, quality?}
|
||||
DELETE /api/v1/me/inventory/{class_name}
|
||||
|
||||
GET /api/v1/available?category=&search=&sort=&page=&page_size=
|
||||
|
||||
GET /api/v1/orders?category=&search=&status=&sort=&page=&page_size=
|
||||
POST /api/v1/orders {class_name, quantity?, notes?, quality?}
|
||||
PATCH /api/v1/orders/{id} {quantity?, notes?, status?, quality?}
|
||||
DELETE /api/v1/orders/{id}
|
||||
```
|
||||
|
||||
`/api/v1/me/*` requires a valid session cookie and returns `401` otherwise.
|
||||
`/api/v1/available` and `GET /api/v1/orders` are public and read-only. Both
|
||||
accept `sort=name|category|quantity|updated` (default `updated`); orders also
|
||||
accept `status=open|fulfilled`. Creating or changing an order requires a
|
||||
session; only its owner may update or delete it. Catalog `category` values are
|
||||
`ships`, `ship-items`, `fps-items` and `commodities`.
|
||||
|
||||
### Configuration
|
||||
|
||||
|
||||
144
playbook.yml
144
playbook.yml
@@ -1,133 +1,23 @@
|
||||
---
|
||||
# Example play that exercises the trade_hub role against the local inventory.
|
||||
# To use the role in an existing playbook, copy roles/trade_hub into that
|
||||
# repository's roles/ directory and add:
|
||||
#
|
||||
# roles:
|
||||
# - role: trade_hub
|
||||
# tags: [trade_hub]
|
||||
#
|
||||
- name: Deploy Star Citizen Inventory (trade-hub)
|
||||
hosts: webservers
|
||||
|
||||
vars:
|
||||
backend_image: sc-inventory-backend
|
||||
frontend_image: sc-inventory-frontend
|
||||
postgres_image: docker.io/library/postgres:16-alpine
|
||||
image_tag: latest
|
||||
network_name: sc-inventory
|
||||
frontend_port: "8080:80"
|
||||
postgres_volume: sc-inventory-data
|
||||
postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
|
||||
database_url: "postgresql+psycopg://sc_inventory:{{ postgres_password }}@db:5432/sc_inventory"
|
||||
# Set to true once the site is served over TLS.
|
||||
cookie_secure: "false"
|
||||
# Set to true to force image rebuilds even when the images already exist
|
||||
force_rebuild: false
|
||||
# The standalone play used to install podman; in a larger playbook the
|
||||
# "packages" role normally provides it instead.
|
||||
trade_hub_install_podman: true
|
||||
trade_hub_postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
|
||||
trade_hub_cookie_secure: "false"
|
||||
trade_hub_force_rebuild: false
|
||||
|
||||
tasks:
|
||||
- name: Check whether podman is installed
|
||||
ansible.builtin.command: podman --version
|
||||
register: podman_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Install podman if missing
|
||||
become: true
|
||||
ansible.builtin.package:
|
||||
name: podman
|
||||
state: present
|
||||
when: podman_check.rc != 0
|
||||
|
||||
- name: Remove legacy Apache container if present
|
||||
containers.podman.podman_container:
|
||||
name: my-apache-server
|
||||
state: absent
|
||||
|
||||
- name: Check if backend image exists
|
||||
ansible.builtin.command: podman image exists {{ backend_image }}:{{ image_tag }}
|
||||
register: backend_image_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Check if frontend image exists
|
||||
ansible.builtin.command: podman image exists {{ frontend_image }}:{{ image_tag }}
|
||||
register: frontend_image_check
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Build backend image
|
||||
ansible.builtin.command: >
|
||||
podman build -t {{ backend_image }}:{{ image_tag }}
|
||||
{{ playbook_dir }}/trade-hub/backend
|
||||
when: backend_image_check.rc != 0 or force_rebuild | bool
|
||||
|
||||
- name: Build frontend image
|
||||
ansible.builtin.command: >
|
||||
podman build -t {{ frontend_image }}:{{ image_tag }}
|
||||
{{ playbook_dir }}/trade-hub/frontend
|
||||
when: frontend_image_check.rc != 0 or force_rebuild | bool
|
||||
|
||||
# podman_container does not recreate a container whose image tag now
|
||||
# resolves to a new ID, so rebuilt images require removing them first.
|
||||
- name: Remove containers so rebuilt images are used
|
||||
containers.podman.podman_container:
|
||||
name: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- sc-inventory-backend
|
||||
- sc-inventory-frontend
|
||||
when: force_rebuild | bool
|
||||
|
||||
- name: Create podman network
|
||||
containers.podman.podman_network:
|
||||
name: "{{ network_name }}"
|
||||
state: present
|
||||
|
||||
- name: Create PostgreSQL data volume
|
||||
containers.podman.podman_volume:
|
||||
name: "{{ postgres_volume }}"
|
||||
state: present
|
||||
|
||||
- name: Run PostgreSQL container (internal only)
|
||||
containers.podman.podman_container:
|
||||
name: sc-inventory-db
|
||||
image: "{{ postgres_image }}"
|
||||
state: started
|
||||
network: "{{ network_name }}"
|
||||
network_aliases:
|
||||
- db
|
||||
env:
|
||||
POSTGRES_USER: sc_inventory
|
||||
POSTGRES_PASSWORD: "{{ postgres_password }}"
|
||||
POSTGRES_DB: sc_inventory
|
||||
volumes:
|
||||
- "{{ postgres_volume }}:/var/lib/postgresql/data:Z"
|
||||
restart_policy: unless-stopped
|
||||
|
||||
- name: Run backend container (internal only)
|
||||
containers.podman.podman_container:
|
||||
name: sc-inventory-backend
|
||||
image: "{{ backend_image }}:{{ image_tag }}"
|
||||
state: started
|
||||
network: "{{ network_name }}"
|
||||
# Network alias "backend" matches the compose service name, so the
|
||||
# nginx upstream (http://backend:8000) resolves in both deployments.
|
||||
network_aliases:
|
||||
- backend
|
||||
env:
|
||||
DATABASE_URL: "{{ database_url }}"
|
||||
DB_WAIT_SECONDS: "60"
|
||||
COOKIE_SECURE: "{{ cookie_secure }}"
|
||||
restart_policy: unless-stopped
|
||||
|
||||
- name: Run frontend container (published port)
|
||||
containers.podman.podman_container:
|
||||
name: sc-inventory-frontend
|
||||
image: "{{ frontend_image }}:{{ image_tag }}"
|
||||
state: started
|
||||
network: "{{ network_name }}"
|
||||
ports:
|
||||
- "{{ frontend_port }}"
|
||||
restart_policy: unless-stopped
|
||||
|
||||
- name: Verify the stack responds
|
||||
ansible.builtin.uri:
|
||||
url: http://localhost:8080/healthz
|
||||
status_code: 200
|
||||
register: health_result
|
||||
until: health_result.status == 200
|
||||
retries: 8
|
||||
delay: 10
|
||||
roles:
|
||||
- role: trade_hub
|
||||
tags: [trade_hub]
|
||||
|
||||
85
roles/trade_hub/README.md
Normal file
85
roles/trade_hub/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# trade_hub
|
||||
|
||||
Deploys the Free People of the Verse trade hub: a FastAPI backend, an nginx
|
||||
frontend and PostgreSQL, all run by podman on the target host. The role stages
|
||||
its own copy of the application source from `files/trade-hub/` and builds the
|
||||
backend and frontend images locally, so it does not need a registry.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the role to an existing play. It is safe to run after a `packages` role that
|
||||
already installs podman:
|
||||
|
||||
```yaml
|
||||
- hosts: internal-apps
|
||||
become: true
|
||||
vars_files:
|
||||
- ./vars.yml
|
||||
roles:
|
||||
- role: packages
|
||||
tags: [packages]
|
||||
# ...
|
||||
- role: trade_hub
|
||||
tags: [trade_hub]
|
||||
```
|
||||
|
||||
The play-level `become: true` makes podman run as root. If you run the role
|
||||
without become, podman runs rootless and the containers belong to the connecting
|
||||
user.
|
||||
|
||||
The playbook's `Pull images` task is unrelated to this role: the role builds its
|
||||
own backend/frontend images from the staged source. The PostgreSQL image is
|
||||
pulled by the role if it is not already present.
|
||||
|
||||
## Required variables
|
||||
|
||||
Set a real database password in `vars.yml` (or the environment of the control
|
||||
node):
|
||||
|
||||
```yaml
|
||||
trade_hub_postgres_password: "choose-something-strong"
|
||||
```
|
||||
|
||||
If it is omitted, the role falls back to the play variable `POSTGRES_PASSWORD`,
|
||||
then to `POSTGRES_PASSWORD` from the control node environment, then to the
|
||||
development default `sc_inventory_dev`. If the password contains characters
|
||||
that are significant in a URL (`@`, `:`, `/`, …), set `trade_hub_database_url`
|
||||
explicitly as well.
|
||||
|
||||
## Common variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `trade_hub_app_dir` | `/opt/trade-hub` | Where the build context is staged |
|
||||
| `trade_hub_install_podman` | `false` | Install podman if it is missing |
|
||||
| `trade_hub_build_images` | `true` | Build backend/frontend images on the target |
|
||||
| `trade_hub_force_rebuild` | `false` | Rebuild images and restart containers |
|
||||
| `trade_hub_image_tag` | `latest` | Tag applied to built images |
|
||||
| `trade_hub_backend_image` | `localhost/sc-inventory-backend` | Backend image name |
|
||||
| `trade_hub_frontend_image` | `localhost/sc-inventory-frontend` | Frontend image name |
|
||||
| `trade_hub_postgres_image` | `docker.io/library/postgres:16-alpine` | Database image |
|
||||
| `trade_hub_postgres_user` | `sc_inventory` | Database user |
|
||||
| `trade_hub_postgres_db` | `sc_inventory` | Database name |
|
||||
| `trade_hub_postgres_password` | play var/env `POSTGRES_PASSWORD` or `sc_inventory_dev` | Database password |
|
||||
| `trade_hub_network` | `sc-inventory` | Podman network name |
|
||||
| `trade_hub_volume` | `sc-inventory-data` | PostgreSQL data volume |
|
||||
| `trade_hub_frontend_bind` | `0.0.0.0` | Address the frontend publishes on |
|
||||
| `trade_hub_frontend_port` | `8080` | Host port for the frontend |
|
||||
| `trade_hub_cookie_secure` | `false` | Mark session cookies `Secure` (enable behind TLS) |
|
||||
| `trade_hub_healthcheck_url` | `http://127.0.0.1:<port>/healthz` | Post-deploy check |
|
||||
| `trade_hub_remove_legacy_apache` | `false` | Remove a container named `my-apache-server` |
|
||||
| `trade_hub_enable_restart_service` | `false` | Enable `podman-restart.service` |
|
||||
|
||||
The container names also default to `sc-inventory-db`, `sc-inventory-backend`
|
||||
and `sc-inventory-frontend`, matching the previous standalone playbook so an
|
||||
existing installation is upgraded in place.
|
||||
|
||||
## Example deployment
|
||||
|
||||
```bash
|
||||
ansible-playbook site.yml --tags trade_hub -e trade_hub_postgres_password='...'
|
||||
```
|
||||
|
||||
The role verifies `http://127.0.0.1:8080/healthz` after starting the containers.
|
||||
Only the frontend publishes a port; the database and backend stay on the
|
||||
internal podman network.
|
||||
51
roles/trade_hub/defaults/main.yml
Normal file
51
roles/trade_hub/defaults/main.yml
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
# Where the application build context is staged on the target host.
|
||||
trade_hub_app_dir: /opt/trade-hub
|
||||
|
||||
# Install podman as part of this role. Leave false when another role (for
|
||||
# example "packages") already provides podman.
|
||||
trade_hub_install_podman: false
|
||||
|
||||
# Images. These are built locally by default; point them at a registry if you
|
||||
# prefer to pull prebuilt images.
|
||||
trade_hub_image_tag: latest
|
||||
trade_hub_backend_image: localhost/sc-inventory-backend
|
||||
trade_hub_frontend_image: localhost/sc-inventory-frontend
|
||||
trade_hub_postgres_image: docker.io/library/postgres:16-alpine
|
||||
trade_hub_build_images: true
|
||||
trade_hub_force_rebuild: false
|
||||
|
||||
# Container names. Kept compatible with the previous standalone playbook so an
|
||||
# existing installation is upgraded in place.
|
||||
trade_hub_db_container: sc-inventory-db
|
||||
trade_hub_backend_container: sc-inventory-backend
|
||||
trade_hub_frontend_container: sc-inventory-frontend
|
||||
|
||||
# Database.
|
||||
trade_hub_postgres_user: sc_inventory
|
||||
trade_hub_postgres_db: sc_inventory
|
||||
trade_hub_postgres_password: "{{ POSTGRES_PASSWORD | default(lookup('env', 'POSTGRES_PASSWORD'), true) | default('sc_inventory_dev', true) }}"
|
||||
trade_hub_database_url: "postgresql+psycopg://{{ trade_hub_postgres_user }}:{{ trade_hub_postgres_password }}@db:5432/{{ trade_hub_postgres_db }}"
|
||||
trade_hub_db_wait_seconds: 60
|
||||
|
||||
# Networking.
|
||||
trade_hub_network: sc-inventory
|
||||
trade_hub_volume: sc-inventory-data
|
||||
trade_hub_frontend_bind: 0.0.0.0
|
||||
trade_hub_frontend_port: 8080
|
||||
# Set to true (or "true") once the site is served over TLS.
|
||||
trade_hub_cookie_secure: "false"
|
||||
|
||||
# Post-deploy health check.
|
||||
trade_hub_healthcheck_url: "http://127.0.0.1:{{ trade_hub_frontend_port }}/healthz"
|
||||
trade_hub_healthcheck_retries: 12
|
||||
trade_hub_healthcheck_delay: 5
|
||||
|
||||
# The original standalone playbook removed an ad-hoc Apache container that
|
||||
# used the same host. Disabled by default so the role never touches unrelated
|
||||
# containers.
|
||||
trade_hub_remove_legacy_apache: false
|
||||
trade_hub_legacy_apache_container: my-apache-server
|
||||
|
||||
# Enable podman-restart.service so the containers come back after a reboot.
|
||||
trade_hub_enable_restart_service: false
|
||||
@@ -11,8 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Download the full scunpacked dataset (~83 MB) at build time so the image
|
||||
# is self-contained; DATA_DIR points the API at it.
|
||||
# Download the full scunpacked dataset at build time so the image is
|
||||
# self-contained; fetch_data.py reduces items.json to commodities.json.
|
||||
RUN python scripts/fetch_data.py --output-dir /app/data/full
|
||||
ENV DATA_DIR=/app/data/full
|
||||
|
||||
@@ -11,9 +11,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import config
|
||||
from app.auth import router as auth_router
|
||||
from app.available import router as available_router
|
||||
from app.catalog import store
|
||||
from app.db import db_healthy, init_db
|
||||
from app.inventory import router as inventory_router
|
||||
from app.orders import router as orders_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -45,8 +47,10 @@ if config.CORS_ORIGINS:
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(available_router)
|
||||
app.include_router(orders_router)
|
||||
|
||||
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
|
||||
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}
|
||||
|
||||
|
||||
def _filter_items(category, search, manufacturer, item_type, size):
|
||||
69
roles/trade_hub/files/trade-hub/backend/app/available.py
Normal file
69
roles/trade_hub/files/trade-hub/backend/app/available.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Public listing of inventory items owners marked as `available`.
|
||||
|
||||
Read-only and unauthenticated, like the catalog. Notes are never included: the
|
||||
listing only reveals the owner's username, quantity and catalog details.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.inventory import SORT_KEYS, enrich_entry
|
||||
from app.models import InventoryEntry, User
|
||||
from app.schemas import AvailablePage
|
||||
|
||||
router = APIRouter(prefix="/api/v1/available", tags=["available"])
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
@router.get("", response_model=AvailablePage)
|
||||
def list_available(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(InventoryEntry, User.username)
|
||||
.join(User, User.id == InventoryEntry.user_id)
|
||||
.where(InventoryEntry.visibility == "available")
|
||||
).all()
|
||||
|
||||
items = []
|
||||
for entry, username in rows:
|
||||
enriched = enrich_entry(entry)
|
||||
if not enriched["available"]:
|
||||
continue
|
||||
enriched["username"] = username
|
||||
items.append(enriched)
|
||||
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower()
|
||||
or needle in i["class_name"].lower()
|
||||
or needle in i["username"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
@@ -55,3 +55,4 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
|
||||
# --- Input limits ---------------------------------------------------------
|
||||
MAX_NOTES_LENGTH = 1000
|
||||
MAX_QUANTITY = 1_000_000
|
||||
MAX_QUALITY = 1000
|
||||
@@ -51,12 +51,69 @@ def get_db():
|
||||
db.close()
|
||||
|
||||
|
||||
def _upgrade_schema(engine) -> None:
|
||||
"""Add columns that `create_all` cannot add to an existing table.
|
||||
|
||||
PostgreSQL only: the SQLite database used by the tests is created fresh
|
||||
from the models on every run, so it already has every column.
|
||||
"""
|
||||
if engine.dialect.name != "postgresql":
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
|
||||
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_inventory_visibility'
|
||||
) THEN
|
||||
ALTER TABLE inventory_entries
|
||||
ADD CONSTRAINT ck_inventory_visibility
|
||||
CHECK (visibility IN ('private', 'available'));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
|
||||
"quality INTEGER"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_inventory_quality_range'
|
||||
) THEN
|
||||
ALTER TABLE inventory_entries
|
||||
ADD CONSTRAINT ck_inventory_quality_range
|
||||
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||
"""Create missing tables, retrying while PostgreSQL finishes booting.
|
||||
|
||||
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
|
||||
It does not migrate changed columns; introduce Alembic when the schema
|
||||
starts evolving in place.
|
||||
Additive columns for existing deployments are applied afterwards; changing
|
||||
or dropping an existing column still requires Alembic.
|
||||
"""
|
||||
deadline = time.monotonic() + max(wait_seconds, 0)
|
||||
while True:
|
||||
@@ -65,6 +122,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
Base.metadata.create_all(bind=engine, checkfirst=True)
|
||||
_upgrade_schema(engine)
|
||||
return
|
||||
except (OperationalError, IntegrityError, ProgrammingError) as exc:
|
||||
# A concurrent startup may have created the tables first.
|
||||
@@ -33,7 +33,7 @@ router = APIRouter(
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _enrich(entry: InventoryEntry) -> dict:
|
||||
def enrich_entry(entry: InventoryEntry) -> dict:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
return {
|
||||
@@ -46,6 +46,9 @@ def _enrich(entry: InventoryEntry) -> dict:
|
||||
"manufacturer": None,
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": False,
|
||||
"visibility": entry.visibility,
|
||||
"available": False,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
@@ -60,12 +63,23 @@ def _enrich(entry: InventoryEntry) -> dict:
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": item.get("quality_applicable", False),
|
||||
"visibility": entry.visibility,
|
||||
"available": True,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _validate_quality(item: dict, quality: int | None) -> None:
|
||||
if quality is not None and not item.get("quality_applicable", False):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{item['name']}'",
|
||||
)
|
||||
|
||||
|
||||
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
||||
return db.execute(
|
||||
select(InventoryEntry).where(
|
||||
@@ -75,10 +89,35 @@ def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _sort_key_name(item):
|
||||
return (item["name"].lower(), item["class_name"])
|
||||
|
||||
|
||||
def _sort_key_category(item):
|
||||
return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"])
|
||||
|
||||
|
||||
def _sort_key_quantity(item):
|
||||
return (-item["quantity"], item["name"].lower(), item["class_name"])
|
||||
|
||||
|
||||
def _sort_key_updated(item):
|
||||
return (-item["updated_at"].timestamp(), item["name"].lower())
|
||||
|
||||
|
||||
SORT_KEYS = {
|
||||
"name": _sort_key_name,
|
||||
"category": _sort_key_category,
|
||||
"quantity": _sort_key_quantity,
|
||||
"updated": _sort_key_updated,
|
||||
}
|
||||
|
||||
|
||||
@router.get("", response_model=InventoryPage)
|
||||
def list_inventory(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
user: User = Depends(get_current_user),
|
||||
@@ -89,7 +128,7 @@ def list_inventory(
|
||||
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
|
||||
).scalars().all()
|
||||
|
||||
items = [_enrich(row) for row in rows]
|
||||
items = [enrich_entry(row) for row in rows]
|
||||
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
@@ -102,7 +141,7 @@ def list_inventory(
|
||||
if needle in i["name"].lower() or needle in i["class_name"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=lambda i: (i["name"].lower(), i["class_name"]))
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
@@ -151,10 +190,12 @@ def add_to_inventory(
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
class_name = payload.class_name.strip()
|
||||
if class_name not in catalog.store.by_class_name:
|
||||
item = catalog.store.by_class_name.get(class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
|
||||
entry = _find(db, user, class_name)
|
||||
created = entry is None
|
||||
@@ -165,12 +206,15 @@ def add_to_inventory(
|
||||
class_name=class_name,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
quality=payload.quality,
|
||||
)
|
||||
db.add(entry)
|
||||
else:
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
@@ -183,12 +227,14 @@ def add_to_inventory(
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
db.commit()
|
||||
created = False
|
||||
|
||||
db.refresh(entry)
|
||||
response.status_code = 201 if created else 200
|
||||
return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created}
|
||||
return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created}
|
||||
|
||||
|
||||
@router.patch("/{class_name}", response_model=InventoryEntryOut)
|
||||
@@ -206,10 +252,23 @@ def update_inventory_entry(
|
||||
entry.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
entry.notes = payload.notes
|
||||
if payload.visibility is not None:
|
||||
entry.visibility = payload.visibility
|
||||
if "quality" in payload.model_fields_set:
|
||||
# Explicit null clears the value; omitted keeps the existing one.
|
||||
if payload.quality is not None:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{entry.class_name}'",
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
entry.quality = payload.quality
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return InventoryEntryOut.model_validate(_enrich(entry))
|
||||
return InventoryEntryOut.model_validate(enrich_entry(entry))
|
||||
|
||||
|
||||
@router.delete("/{class_name}", status_code=204)
|
||||
@@ -17,6 +17,7 @@ Real schemas this loader consumes:
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from app import config
|
||||
|
||||
@@ -25,6 +26,69 @@ PLACEHOLDER = "<= PLACEHOLDER =>"
|
||||
CATEGORY_SHIPS = "ships"
|
||||
CATEGORY_SHIP_ITEMS = "ship-items"
|
||||
CATEGORY_FPS_ITEMS = "fps-items"
|
||||
CATEGORY_COMMODITIES = "commodities"
|
||||
|
||||
# Commodities that can carry a mining/refining quality value (1-1000). These are
|
||||
# the ores, minerals and gems that players actually mine (and their refined
|
||||
# counterparts); crafted alloys, gases, organics, processed goods and junk
|
||||
# by-products are deliberately excluded. Keep in sync with the Ore quality table
|
||||
# at https://starcitizen.tools/Ore_quality.
|
||||
QUALITY_MATERIALS = frozenset(
|
||||
name.lower()
|
||||
for name in (
|
||||
"Agricium",
|
||||
"Aluminum",
|
||||
"Aphorite",
|
||||
"Aslarite",
|
||||
"Beradom",
|
||||
"Beryl",
|
||||
"Bexalite",
|
||||
"Borase",
|
||||
"Carinite",
|
||||
"Copper",
|
||||
"Corundum",
|
||||
"Diamond",
|
||||
"Dolivine",
|
||||
"Feynmaline",
|
||||
"Glacosite",
|
||||
"Gold",
|
||||
"Hadanite",
|
||||
"Hephaestanite",
|
||||
"Iron",
|
||||
"Jaclium",
|
||||
"Janalite",
|
||||
"Laranite",
|
||||
"Lindinium",
|
||||
"Ouratite",
|
||||
"Pressurized Ice",
|
||||
"Quantainium",
|
||||
"Quartz",
|
||||
"Riccite",
|
||||
"Sadaryx",
|
||||
"Saldynium",
|
||||
"Savrilium",
|
||||
"Silicon",
|
||||
"Stileron",
|
||||
"Taranite",
|
||||
"Tin",
|
||||
"Titanium",
|
||||
"Torite",
|
||||
"Tungsten",
|
||||
)
|
||||
)
|
||||
|
||||
# Variant suffixes are presentation labels, not a different material: "Iron
|
||||
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
|
||||
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
|
||||
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _quality_applicable(name, category):
|
||||
if category != CATEGORY_COMMODITIES:
|
||||
return False
|
||||
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
|
||||
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
|
||||
return base_name in QUALITY_MATERIALS
|
||||
|
||||
|
||||
def _load_json(data_dir, filename):
|
||||
@@ -79,6 +143,7 @@ def _normalize_ship(ship):
|
||||
"classification": "Ship",
|
||||
"description": ship.get("Description") or ship.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
"quality_applicable": False,
|
||||
}, {
|
||||
"career": ship.get("Career") or "",
|
||||
"role": ship.get("Role") or "",
|
||||
@@ -110,7 +175,13 @@ def _normalize_item(item, category):
|
||||
"classification": item.get("classification") or "",
|
||||
"description": std.get("Description") or std.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
"quality_applicable": _quality_applicable(
|
||||
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
|
||||
category,
|
||||
),
|
||||
}
|
||||
if category == CATEGORY_COMMODITIES:
|
||||
summary["size"] = None
|
||||
details = {
|
||||
"ports": std.get("Ports") or [],
|
||||
"mass": std.get("Mass"),
|
||||
@@ -133,6 +204,9 @@ class Store:
|
||||
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
|
||||
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
|
||||
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
|
||||
CATEGORY_COMMODITIES: sum(
|
||||
1 for i in items if i["category"] == CATEGORY_COMMODITIES
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +231,11 @@ def load_store(data_dir=None):
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
for entry in _load_json(data_dir, "commodities.json"):
|
||||
summary, detail = _normalize_item(entry, CATEGORY_COMMODITIES)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
# Deterministic order so pagination is stable.
|
||||
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))
|
||||
|
||||
@@ -49,6 +49,9 @@ class User(Base):
|
||||
inventory: Mapped[list["InventoryEntry"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
orders: Mapped[list["Order"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
@@ -74,6 +77,14 @@ class InventoryEntry(Base):
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
|
||||
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
|
||||
CheckConstraint(
|
||||
"visibility IN ('private', 'available')",
|
||||
name="ck_inventory_visibility",
|
||||
),
|
||||
CheckConstraint(
|
||||
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
|
||||
name="ck_inventory_quality_range",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
@@ -83,6 +94,16 @@ class InventoryEntry(Base):
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# Mining/refining quality (1-1000) for commodities where it applies. NULL
|
||||
# means "not specified"; non-quality commodities must keep this NULL. The
|
||||
# applicability itself is catalog data, not stored here.
|
||||
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# "private" (default): only the owner sees the row. "available": the owner
|
||||
# is offering the item. Unrelated to the response flag `available`, which
|
||||
# only reports whether the catalog still contains the referenced item.
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="private", server_default="private"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
@@ -91,3 +112,46 @@ class InventoryEntry(Base):
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="inventory")
|
||||
|
||||
|
||||
class Order(Base):
|
||||
"""A public request for a catalog item placed by a logged-in user.
|
||||
|
||||
Orders are intentionally visible to everyone (see app/orders.py). They
|
||||
reference catalog items by `class_name` only, exactly like inventory rows,
|
||||
so a catalog refresh cannot break them.
|
||||
"""
|
||||
|
||||
__tablename__ = "orders"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"),
|
||||
CheckConstraint(
|
||||
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
|
||||
name="ck_orders_quality_range",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('open', 'fulfilled')",
|
||||
name="ck_orders_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
# Mining/refining quality (1-1000); only valid for quality-applicable ores.
|
||||
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="open", server_default="open"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="orders")
|
||||
209
roles/trade_hub/files/trade-hub/backend/app/orders.py
Normal file
209
roles/trade_hub/files/trade-hub/backend/app/orders.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Public order board.
|
||||
|
||||
Any logged-in user can place an order (a request) for a catalog item. Every
|
||||
order is visible to everyone, with the requester's display name, quantity,
|
||||
notes, and — for quality-applicable ores — the requested quality. Orders are
|
||||
read-only for the public; only the owner can edit or remove their own order.
|
||||
|
||||
Like inventory rows, orders reference catalog items by `class_name` only, so a
|
||||
dataset refresh cannot break them.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import catalog
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.inventory import SORT_KEYS
|
||||
from app.models import Order, User
|
||||
from app.schemas import OrderCreateRequest, OrderOut, OrderPage, OrderPatchRequest
|
||||
|
||||
router = APIRouter(prefix="/api/v1/orders", tags=["orders"])
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def enrich_order(order: Order, username: str) -> dict:
|
||||
item = catalog.store.by_class_name.get(order.class_name)
|
||||
if item is None:
|
||||
item_data = {
|
||||
"name": order.class_name.replace("_", " "),
|
||||
"category": None,
|
||||
"type": None,
|
||||
"size": None,
|
||||
"grade": None,
|
||||
"manufacturer": None,
|
||||
"quality_applicable": False,
|
||||
"available": False,
|
||||
}
|
||||
else:
|
||||
item_data = {
|
||||
"name": item["name"],
|
||||
"category": item["category"],
|
||||
"type": item["type"],
|
||||
"size": item["size"],
|
||||
"grade": item["grade"],
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quality_applicable": item.get("quality_applicable", False),
|
||||
"available": True,
|
||||
}
|
||||
return {
|
||||
"id": order.id,
|
||||
"class_name": order.class_name,
|
||||
**item_data,
|
||||
"quantity": order.quantity,
|
||||
"notes": order.notes,
|
||||
"quality": order.quality,
|
||||
"username": username,
|
||||
"status": order.status,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _validate_quality(item: dict, quality: int | None) -> None:
|
||||
if quality is not None and not item.get("quality_applicable", False):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{item['name']}'",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=OrderPage)
|
||||
def list_orders(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
status: str | None = Query(default=None, pattern="^(open|fulfilled)$"),
|
||||
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(Order, User.username).join(User, User.id == Order.user_id)
|
||||
).all()
|
||||
|
||||
items = [enrich_order(order, username) for order, username in rows]
|
||||
|
||||
if status is not None:
|
||||
items = [i for i in items if i["status"] == status]
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower()
|
||||
or needle in i["class_name"].lower()
|
||||
or needle in i["username"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=OrderOut,
|
||||
status_code=201,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def create_order(
|
||||
payload: OrderCreateRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
class_name = payload.class_name.strip()
|
||||
item = catalog.store.by_class_name.get(class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
|
||||
order = Order(
|
||||
user_id=user.id,
|
||||
class_name=class_name,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
quality=payload.quality,
|
||||
)
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return enrich_order(order, user.username)
|
||||
|
||||
|
||||
def _own_order(db: DbSession, user: User, order_id: int) -> Order:
|
||||
order = db.get(Order, order_id)
|
||||
if order is None or order.user_id != user.id:
|
||||
# Do not reveal whether somebody else's order exists.
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
return order
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{order_id}",
|
||||
response_model=OrderOut,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def update_order(
|
||||
order_id: int,
|
||||
payload: OrderPatchRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
order = _own_order(db, user, order_id)
|
||||
|
||||
if payload.quantity is not None:
|
||||
order.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
order.notes = payload.notes
|
||||
if payload.status is not None:
|
||||
order.status = payload.status
|
||||
if "quality" in payload.model_fields_set:
|
||||
# Explicit null clears the value; omitted keeps the existing one.
|
||||
if payload.quality is not None:
|
||||
item = catalog.store.by_class_name.get(order.class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{order.class_name}'",
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
order.quality = payload.quality
|
||||
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return enrich_order(order, user.username)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{order_id}",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def delete_order(
|
||||
order_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
order = _own_order(db, user, order_id)
|
||||
db.delete(order)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
156
roles/trade_hub/files/trade-hub/backend/app/schemas.py
Normal file
156
roles/trade_hub/files/trade-hub/backend/app/schemas.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Pydantic request/response models for auth and personal inventory."""
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=32)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=32)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
user: UserOut
|
||||
|
||||
|
||||
class InventoryAddRequest(BaseModel):
|
||||
class_name: str = Field(min_length=1, max_length=255)
|
||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class InventoryPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
visibility: Literal["private", "available"] | None = None
|
||||
# Explicit null clears the quality; omitting the field leaves it unchanged.
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class InventoryEntryOut(BaseModel):
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
notes: str
|
||||
# Mining/refining quality (1-1000); only meaningful when
|
||||
# `quality_applicable` is true. None means the owner has not set it.
|
||||
quality: int | None = None
|
||||
quality_applicable: bool = False
|
||||
# Owner-chosen classification. Unrelated to `available` below.
|
||||
visibility: Literal["private", "available"]
|
||||
# False when the catalog no longer contains the referenced item (e.g. after
|
||||
# a dataset refresh). The row is preserved rather than silently deleted.
|
||||
available: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InventoryPage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[InventoryEntryOut]
|
||||
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
items: int
|
||||
quantity: int
|
||||
categories: dict[str, int]
|
||||
unavailable: int
|
||||
|
||||
|
||||
class AvailableItemOut(BaseModel):
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
quality: int | None = None
|
||||
quality_applicable: bool = False
|
||||
# Public listing: only the owner's display name, never notes.
|
||||
username: str
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AvailablePage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[AvailableItemOut]
|
||||
|
||||
|
||||
class InventoryAddResponse(BaseModel):
|
||||
entry: InventoryEntryOut
|
||||
created: bool
|
||||
|
||||
|
||||
class OrderCreateRequest(BaseModel):
|
||||
class_name: str = Field(min_length=1, max_length=255)
|
||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class OrderPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
status: Literal["open", "fulfilled"] | None = None
|
||||
# Explicit null clears the quality; omitting the field leaves it unchanged.
|
||||
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
|
||||
|
||||
|
||||
class OrderOut(BaseModel):
|
||||
id: int
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
notes: str
|
||||
quality: int | None = None
|
||||
quality_applicable: bool = False
|
||||
# Public listing: the requesting user's display name.
|
||||
username: str
|
||||
status: Literal["open", "fulfilled"]
|
||||
# False when the catalog no longer contains the referenced item.
|
||||
available: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrderPage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[OrderOut]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
"""Download the full scunpacked dataset into a target directory.
|
||||
|
||||
Usage:
|
||||
python scripts/fetch_data.py [--output-dir DIR]
|
||||
|
||||
Default output: backend/data/full (used by the Docker image build).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = "https://raw.githubusercontent.com/StarCitizenWiki/scunpacked-data/master"
|
||||
FILES = [
|
||||
"ships.json",
|
||||
"ship-items.json",
|
||||
"fps-items.json",
|
||||
"manufacturers.json",
|
||||
"items.json",
|
||||
]
|
||||
COMMODITY_KEYS = ("className", "name", "type", "subType", "reference")
|
||||
PLACEHOLDER = "<= PLACEHOLDER =>"
|
||||
|
||||
|
||||
def _commodity_rank(entry):
|
||||
class_name = entry.get("className") or ""
|
||||
return (0 if "_1SCU_" in class_name else 1, entry.get("size") or 0, class_name)
|
||||
|
||||
|
||||
def build_commodities(items_path, out_path):
|
||||
with open(items_path, encoding="utf-8") as f:
|
||||
items = json.load(f)
|
||||
|
||||
grouped = {}
|
||||
for entry in items:
|
||||
class_name = (entry.get("className") or "").lower()
|
||||
name = (entry.get("name") or "").strip()
|
||||
if entry.get("type") != "Cargo" or "commodity" not in class_name:
|
||||
continue
|
||||
if not name or name == PLACEHOLDER:
|
||||
continue
|
||||
grouped.setdefault(name, []).append(entry)
|
||||
|
||||
commodities = [
|
||||
{key: best.get(key) for key in COMMODITY_KEYS}
|
||||
for best in (min(grouped[name], key=_commodity_rank) for name in sorted(grouped))
|
||||
]
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
json.dump(commodities, f, ensure_ascii=False, separators=(",", ":"))
|
||||
return len(commodities)
|
||||
|
||||
|
||||
def main():
|
||||
default_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "data", "full")
|
||||
)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", default=default_dir)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
for name in FILES:
|
||||
url = f"{BASE_URL}/{name}"
|
||||
dest = os.path.join(args.output_dir, name)
|
||||
print(f"Downloading {url} -> {dest}", flush=True)
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
size = os.path.getsize(dest)
|
||||
print(f" done ({size / 1_000_000:.1f} MB)", flush=True)
|
||||
|
||||
items_path = os.path.join(args.output_dir, "items.json")
|
||||
count = build_commodities(
|
||||
items_path, os.path.join(args.output_dir, "commodities.json")
|
||||
)
|
||||
os.remove(items_path)
|
||||
print(f"Wrote {count} commodities (items.json discarded)", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
30
roles/trade_hub/files/trade-hub/backend/tests/fixtures/commodities.json
vendored
Normal file
30
roles/trade_hub/files/trade-hub/backend/tests/fixtures/commodities.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials",
|
||||
"name": "Construction Materials",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "5ee15a9b-4ce5-445b-a02d-415c63f43764"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron",
|
||||
"name": "Iron",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "332096b9-1cbf-436d-b8b0-5ed422c23da9"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron",
|
||||
"name": "Iron (Ore)",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "271effd5-3aa8-4538-b9a0-b0f33dc86d68"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_RMC",
|
||||
"name": "Recycled Material Composite",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "2e50dde5-30d4-4faa-af8f-8e5aa8004b96"
|
||||
}
|
||||
]
|
||||
@@ -11,14 +11,24 @@ def test_healthz():
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["counts"] == {"ships": 2, "ship-items": 2, "fps-items": 1}
|
||||
assert body["counts"] == {
|
||||
"ships": 2,
|
||||
"ship-items": 2,
|
||||
"fps-items": 1,
|
||||
"commodities": 4,
|
||||
}
|
||||
|
||||
|
||||
def test_stats():
|
||||
resp = client.get("/api/v1/stats")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["categories"] == {"ships": 2, "ship-items": 2, "fps-items": 1}
|
||||
assert body["categories"] == {
|
||||
"ships": 2,
|
||||
"ship-items": 2,
|
||||
"fps-items": 1,
|
||||
"commodities": 4,
|
||||
}
|
||||
assert body["manufacturers"] == 4
|
||||
|
||||
|
||||
@@ -33,11 +43,11 @@ def test_items_default_pagination():
|
||||
resp = client.get("/api/v1/items")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 5
|
||||
assert body["total"] == 9
|
||||
assert body["page"] == 1
|
||||
assert body["page_size"] == 25
|
||||
assert body["total_pages"] == 1
|
||||
assert len(body["items"]) == 5
|
||||
assert len(body["items"]) == 9
|
||||
|
||||
|
||||
def test_items_page_size_capped():
|
||||
@@ -155,3 +165,29 @@ def test_deterministic_sort_order():
|
||||
body = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
keys = [(i["category"], i["name"].lower()) for i in body["items"]]
|
||||
assert keys == sorted(keys)
|
||||
|
||||
|
||||
def test_items_category_filter_commodities():
|
||||
resp = client.get("/api/v1/items", params={"category": "commodities"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 4
|
||||
assert all(i["category"] == "commodities" for i in body["items"])
|
||||
|
||||
|
||||
def test_items_search_matches_refined_and_ore_iron():
|
||||
resp = client.get("/api/v1/items", params={"search": "iron"})
|
||||
body = resp.json()
|
||||
assert body["total"] == 2
|
||||
assert {i["name"] for i in body["items"]} == {"Iron", "Iron (Ore)"}
|
||||
|
||||
|
||||
def test_item_detail_commodity():
|
||||
resp = client.get("/api/v1/items/Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["name"] == "Iron (Ore)"
|
||||
assert body["category"] == "commodities"
|
||||
assert body["type"] == "Cargo"
|
||||
assert body["size"] is None
|
||||
assert isinstance(body["details"], dict)
|
||||
156
roles/trade_hub/files/trade-hub/backend/tests/test_available.py
Normal file
156
roles/trade_hub/files/trade-hub/backend/tests/test_available.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Public available-items listing: open access, owner attribution, no notes."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str) -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def add(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
resp = client.post("/api/v1/me/inventory", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def offer(client: TestClient, class_name: str, quantity: int = 1):
|
||||
add(client, class_name, quantity)
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{class_name}", json={"visibility": "available"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def public_items(**params):
|
||||
return TestClient(app).get("/api/v1/available", params=params).json()
|
||||
|
||||
|
||||
def test_listing_is_public_and_empty_by_default():
|
||||
body = public_items()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
assert body["page"] == 1
|
||||
|
||||
|
||||
def test_private_items_are_not_listed_until_marked_available():
|
||||
alice = authed_client("alice")
|
||||
add(alice, CARROCK)
|
||||
assert public_items()["total"] == 0
|
||||
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
|
||||
body = public_items()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["class_name"] == CARROCK
|
||||
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"})
|
||||
assert public_items()["total"] == 0
|
||||
|
||||
|
||||
def test_listing_shows_owner_and_catalog_details_but_not_notes():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CARROCK, quantity=3)
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"notes": "hidden stash"})
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["username"] == "alice"
|
||||
assert item["name"] == "Anvil Carrack"
|
||||
assert item["category"] == "ships"
|
||||
assert item["manufacturer"]["code"] == "ANVL"
|
||||
assert item["quantity"] == 3
|
||||
assert "notes" not in item
|
||||
assert "hidden stash" not in str(public_items())
|
||||
|
||||
|
||||
def test_each_owner_appears_as_its_own_offer():
|
||||
offer(authed_client("alice"), CARROCK, quantity=1)
|
||||
offer(authed_client("bob"), CARROCK, quantity=2)
|
||||
|
||||
items = public_items(sort="quantity")["items"]
|
||||
assert [(i["username"], i["quantity"], i["class_name"]) for i in items] == [
|
||||
("bob", 2, CARROCK),
|
||||
("alice", 1, CARROCK),
|
||||
]
|
||||
|
||||
|
||||
def test_removing_an_offer_removes_it_from_the_listing():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CARROCK)
|
||||
assert public_items()["total"] == 1
|
||||
|
||||
assert alice.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
|
||||
assert public_items()["total"] == 0
|
||||
assert public_items()["items"] == []
|
||||
|
||||
|
||||
def test_listing_filters_by_category_and_searches_owner_or_item():
|
||||
offer(authed_client("alice"), CARROCK)
|
||||
offer(authed_client("bob"), RIFLE)
|
||||
|
||||
assert [i["class_name"] for i in public_items(category="ships")["items"]] == [CARROCK]
|
||||
assert [i["username"] for i in public_items(search="bob")["items"]] == ["bob"]
|
||||
assert [i["class_name"] for i in public_items(search="carrack")["items"]] == [CARROCK]
|
||||
|
||||
|
||||
def test_listing_paginates_and_rejects_unknown_sort():
|
||||
for quantity, class_name in ((1, CARROCK), (2, RIFLE)):
|
||||
offer(authed_client(f"pilot{quantity}"), class_name, quantity=quantity)
|
||||
|
||||
body = public_items(page=2, page_size=1)
|
||||
assert body["total"] == 2
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
assert public_items(page_size=500)["page_size"] == 100
|
||||
assert TestClient(app).get("/api/v1/available?sort=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_commodity_offers_are_listed_with_owner():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, IRON_ORE, quantity=12)
|
||||
body = public_items()
|
||||
assert body["total"] == 1
|
||||
item = body["items"][0]
|
||||
assert item["name"] == "Iron (Ore)"
|
||||
assert item["category"] == "commodities"
|
||||
assert item["quantity"] == 12
|
||||
assert item["username"] == "alice"
|
||||
|
||||
|
||||
def test_commodity_quality_is_listed_publicly():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, IRON_ORE, quantity=12)
|
||||
add(alice, IRON_ORE, quality=875)
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["quality"] == 875
|
||||
assert item["quality_applicable"] is True
|
||||
|
||||
|
||||
def test_non_quality_commodity_is_listed_without_quality():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CONSTRUCTION)
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["quality"] is None
|
||||
assert item["quality_applicable"] is False
|
||||
356
roles/trade_hub/files/trade-hub/backend/tests/test_inventory.py
Normal file
356
roles/trade_hub/files/trade-hub/backend/tests/test_inventory.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str = "alice") -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def add(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
return client.post("/api/v1/me/inventory", json=payload)
|
||||
|
||||
|
||||
def test_new_account_starts_with_empty_inventory():
|
||||
client = authed_client("freshpilot")
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert listing["total"] == 0
|
||||
assert listing["items"] == []
|
||||
assert client.get("/api/v1/me/inventory/summary").json() == {
|
||||
"items": 0,
|
||||
"quantity": 0,
|
||||
"categories": {},
|
||||
"unavailable": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_inventory_requires_authentication():
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/v1/me/inventory").status_code == 401
|
||||
assert add(client, CARROCK).status_code == 401
|
||||
assert client.get("/api/v1/me/inventory/summary").status_code == 401
|
||||
|
||||
|
||||
def test_add_unknown_catalog_item_is_not_found():
|
||||
client = authed_client()
|
||||
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
|
||||
|
||||
|
||||
def test_add_creates_enriched_entry():
|
||||
client = authed_client()
|
||||
resp = add(client, CARROCK)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["created"] is True
|
||||
entry = body["entry"]
|
||||
assert entry["class_name"] == CARROCK
|
||||
assert entry["name"] == "Anvil Carrack"
|
||||
assert entry["category"] == "ships"
|
||||
assert entry["quantity"] == 1
|
||||
assert entry["visibility"] == "private"
|
||||
assert entry["available"] is True
|
||||
assert entry["manufacturer"]["code"] == "ANVL"
|
||||
|
||||
|
||||
def test_add_same_item_increments_quantity():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK, quantity=1).status_code == 201
|
||||
resp = add(client, CARROCK, quantity=2, notes="pledged")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["created"] is False
|
||||
assert body["entry"]["quantity"] == 3
|
||||
assert body["entry"]["notes"] == "pledged"
|
||||
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert listing["total"] == 1
|
||||
|
||||
|
||||
def test_inventories_are_isolated_between_users():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
|
||||
assert add(alice, CARROCK).status_code == 201
|
||||
assert add(bob, RIFLE).status_code == 201
|
||||
|
||||
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
|
||||
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
|
||||
|
||||
assert [i["class_name"] for i in alice_items] == [CARROCK]
|
||||
assert [i["class_name"] for i in bob_items] == [RIFLE]
|
||||
|
||||
|
||||
def test_patch_updates_quantity_and_notes():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["quantity"] == 4
|
||||
assert body["notes"] == "hangar 3"
|
||||
|
||||
|
||||
def test_visibility_toggles_between_private_and_available():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["visibility"] == "available"
|
||||
listed = client.get("/api/v1/me/inventory").json()["items"]
|
||||
assert listed[0]["visibility"] == "available"
|
||||
|
||||
back = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"}
|
||||
)
|
||||
assert back.json()["visibility"] == "private"
|
||||
|
||||
|
||||
def test_patch_rejects_unknown_visibility():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "public"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_visibility_is_kept_when_quantity_changes_and_item_is_readded():
|
||||
client = authed_client()
|
||||
add(client, CARROCK)
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
|
||||
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 3})
|
||||
assert add(client, CARROCK, quantity=1).status_code == 200
|
||||
entry = client.get("/api/v1/me/inventory").json()["items"][0]
|
||||
assert entry["quantity"] == 4
|
||||
assert entry["visibility"] == "available"
|
||||
|
||||
|
||||
def test_patch_unknown_entry_is_not_found():
|
||||
client = authed_client()
|
||||
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
|
||||
|
||||
|
||||
def test_patch_rejects_zero_quantity():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_delete_removes_entry():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
|
||||
assert client.get("/api/v1/me/inventory").json()["total"] == 0
|
||||
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
|
||||
|
||||
|
||||
def test_list_filters_by_category_and_search():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
assert add(client, RIFLE).status_code == 201
|
||||
|
||||
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
|
||||
assert ships["total"] == 1
|
||||
assert ships["items"][0]["class_name"] == CARROCK
|
||||
|
||||
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
|
||||
assert found["total"] == 1
|
||||
assert found["items"][0]["class_name"] == CARROCK
|
||||
|
||||
|
||||
def test_list_pagination_envelope_and_cap():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
assert add(client, RIFLE).status_code == 201
|
||||
|
||||
body = client.get(
|
||||
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
|
||||
).json()
|
||||
assert body["total"] == 2
|
||||
assert body["page"] == 2
|
||||
assert body["page_size"] == 1
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
|
||||
assert capped["page_size"] == 100
|
||||
|
||||
|
||||
def test_summary_aggregates_quantity_and_categories():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK, quantity=2).status_code == 201
|
||||
assert add(client, RIFLE, quantity=3).status_code == 201
|
||||
|
||||
summary = client.get("/api/v1/me/inventory/summary").json()
|
||||
assert summary["items"] == 2
|
||||
assert summary["quantity"] == 5
|
||||
assert summary["unavailable"] == 0
|
||||
assert summary["categories"]["ships"] == 2
|
||||
|
||||
|
||||
def test_notes_length_is_bounded():
|
||||
client = authed_client()
|
||||
resp = add(client, CARROCK, notes="x" * 1001)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_catalog_is_not_mutated_by_inventory_operations():
|
||||
client = authed_client()
|
||||
before = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
|
||||
client.delete(f"/api/v1/me/inventory/{CARROCK}")
|
||||
|
||||
after = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
assert after == before
|
||||
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
|
||||
assert "quantity" not in carrack
|
||||
assert "notes" not in carrack
|
||||
|
||||
|
||||
def test_sort_by_category_groups_categories():
|
||||
client = authed_client()
|
||||
add(client, CARROCK)
|
||||
add(client, RIFLE)
|
||||
items = client.get("/api/v1/me/inventory?sort=category").json()["items"]
|
||||
assert [i["class_name"] for i in items] == [RIFLE, CARROCK]
|
||||
|
||||
|
||||
def test_sort_by_quantity_is_descending():
|
||||
client = authed_client()
|
||||
add(client, CARROCK, quantity=5)
|
||||
add(client, RIFLE, quantity=1)
|
||||
items = client.get("/api/v1/me/inventory?sort=quantity").json()["items"]
|
||||
assert [i["class_name"] for i in items] == [CARROCK, RIFLE]
|
||||
|
||||
|
||||
def test_sort_defaults_to_name_and_rejects_unknown_values():
|
||||
client = authed_client()
|
||||
add(client, CARROCK)
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
|
||||
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_add_commodity_uses_catalog_name_and_quantity():
|
||||
client = authed_client("refiner")
|
||||
resp = add(client, IRON_ORE, quantity=12, notes="from Aaron Halo")
|
||||
assert resp.status_code == 201
|
||||
entry = client.get("/api/v1/me/inventory").json()["items"][0]
|
||||
assert entry["name"] == "Iron (Ore)"
|
||||
assert entry["category"] == "commodities"
|
||||
assert entry["quantity"] == 12
|
||||
assert entry["available"] is True
|
||||
assert entry["visibility"] == "private"
|
||||
|
||||
|
||||
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
|
||||
client = TestClient(app)
|
||||
items = client.get(
|
||||
"/api/v1/items", params={"category": "commodities", "page_size": 100}
|
||||
).json()["items"]
|
||||
flags = {item["class_name"]: item["quality_applicable"] for item in items}
|
||||
assert flags[IRON] is True
|
||||
assert flags[IRON_ORE] is True
|
||||
assert flags[CONSTRUCTION] is False
|
||||
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
|
||||
|
||||
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
|
||||
assert all(item["quality_applicable"] is False for item in ships)
|
||||
|
||||
|
||||
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
|
||||
client = authed_client()
|
||||
assert add(client, IRON_ORE).status_code == 201
|
||||
|
||||
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["quality"] == 750
|
||||
assert body["quality_applicable"] is True
|
||||
|
||||
cleared = client.patch(
|
||||
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert cleared.json()["quality"] is None
|
||||
|
||||
|
||||
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
|
||||
client = authed_client()
|
||||
first = add(client, IRON, quality=900)
|
||||
assert first.status_code == 201
|
||||
assert first.json()["entry"]["quality"] == 900
|
||||
|
||||
second = add(client, IRON, quantity=2)
|
||||
assert second.status_code == 200
|
||||
entry = second.json()["entry"]
|
||||
assert entry["quality"] == 900
|
||||
assert entry["quantity"] == 3
|
||||
|
||||
replaced = add(client, IRON, quality=400)
|
||||
assert replaced.json()["entry"]["quality"] == 400
|
||||
|
||||
|
||||
def test_quality_is_rejected_for_non_mineable_commodities():
|
||||
client = authed_client()
|
||||
assert add(client, CONSTRUCTION).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "does not apply" in resp.json()["detail"]
|
||||
|
||||
add_resp = add(client, CONSTRUCTION, quality=500)
|
||||
assert add_resp.status_code == 422
|
||||
|
||||
|
||||
def test_quality_is_rejected_for_ships():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_quality_range_is_enforced():
|
||||
client = authed_client()
|
||||
assert add(client, IRON_ORE, quality=1).status_code == 201
|
||||
assert add(client, IRON, quality=1000).status_code == 201
|
||||
assert add(client, IRON_ORE, quality=0).status_code == 422
|
||||
assert add(client, IRON_ORE, quality=1001).status_code == 422
|
||||
|
||||
assert add(client, IRON, quantity=1).status_code == 200
|
||||
assert client.patch(
|
||||
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
|
||||
).status_code == 422
|
||||
176
roles/trade_hub/files/trade-hub/backend/tests/test_orders.py
Normal file
176
roles/trade_hub/files/trade-hub/backend/tests/test_orders.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Public order board: authenticated creation, public read, owner-only edits."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str) -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def place(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
return client.post("/api/v1/orders", json=payload)
|
||||
|
||||
|
||||
def public_orders(**params):
|
||||
return TestClient(app).get("/api/v1/orders", params=params).json()
|
||||
|
||||
|
||||
def test_order_board_is_public_and_empty_by_default():
|
||||
body = public_orders()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
assert body["page"] == 1
|
||||
|
||||
|
||||
def test_placing_an_order_requires_authentication():
|
||||
assert place(TestClient(app), CARROCK).status_code == 401
|
||||
|
||||
|
||||
def test_placing_an_order_returns_enriched_public_entry():
|
||||
alice = authed_client("alice")
|
||||
resp = place(alice, CARROCK, quantity=2, notes="Need a hauler")
|
||||
assert resp.status_code == 201, resp.text
|
||||
order = resp.json()
|
||||
assert order["class_name"] == CARROCK
|
||||
assert order["name"] == "Anvil Carrack"
|
||||
assert order["category"] == "ships"
|
||||
assert order["manufacturer"]["code"] == "ANVL"
|
||||
assert order["quantity"] == 2
|
||||
assert order["notes"] == "Need a hauler"
|
||||
assert order["username"] == "alice"
|
||||
assert order["status"] == "open"
|
||||
assert order["available"] is True
|
||||
|
||||
public = public_orders()
|
||||
assert public["total"] == 1
|
||||
assert public["items"][0]["id"] == order["id"]
|
||||
assert public["items"][0]["username"] == "alice"
|
||||
|
||||
|
||||
def test_orders_from_every_user_are_visible():
|
||||
place(authed_client("alice"), CARROCK)
|
||||
place(authed_client("bob"), RIFLE)
|
||||
|
||||
items = public_orders(sort="name")["items"]
|
||||
assert [(i["username"], i["class_name"]) for i in items] == [
|
||||
("alice", CARROCK),
|
||||
("bob", RIFLE),
|
||||
]
|
||||
|
||||
|
||||
def test_order_quality_is_stored_and_public_for_ore():
|
||||
alice = authed_client("alice")
|
||||
resp = place(alice, IRON_ORE, quantity=10, quality=875)
|
||||
assert resp.status_code == 201, resp.text
|
||||
assert resp.json()["quality"] == 875
|
||||
assert resp.json()["quality_applicable"] is True
|
||||
|
||||
item = public_orders()["items"][0]
|
||||
assert item["quality"] == 875
|
||||
assert item["quality_applicable"] is True
|
||||
|
||||
|
||||
def test_quality_is_rejected_for_non_mineable_items():
|
||||
alice = authed_client("alice")
|
||||
assert place(alice, CONSTRUCTION, quality=500).status_code == 422
|
||||
assert place(alice, CARROCK, quality=100).status_code == 422
|
||||
assert place(alice, IRON_ORE, quality=1001).status_code == 422
|
||||
|
||||
|
||||
def test_unknown_catalog_item_is_rejected():
|
||||
assert place(authed_client("alice"), "NOPE_Does_Not_Exist").status_code == 404
|
||||
|
||||
|
||||
def test_listing_filters_by_category_search_and_status():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
first = place(alice, CARROCK).json()
|
||||
place(bob, RIFLE)
|
||||
place(alice, IRON_ORE, quality=400)
|
||||
|
||||
assert [i["class_name"] for i in public_orders(category="ships")["items"]] == [
|
||||
CARROCK
|
||||
]
|
||||
assert [i["username"] for i in public_orders(search="bob")["items"]] == ["bob"]
|
||||
assert [
|
||||
i["class_name"] for i in public_orders(search="iron")["items"]
|
||||
] == [IRON_ORE]
|
||||
|
||||
alice.patch(f"/api/v1/orders/{first['id']}", json={"status": "fulfilled"})
|
||||
assert [i["class_name"] for i in public_orders(status="open")["items"]] == [
|
||||
IRON_ORE,
|
||||
RIFLE,
|
||||
]
|
||||
assert [i["class_name"] for i in public_orders(status="fulfilled")["items"]] == [
|
||||
CARROCK
|
||||
]
|
||||
|
||||
|
||||
def test_listing_paginates_and_rejects_unknown_sort():
|
||||
alice = authed_client("alice")
|
||||
place(alice, CARROCK)
|
||||
place(alice, RIFLE)
|
||||
|
||||
body = public_orders(page=2, page_size=1)
|
||||
assert body["total"] == 2
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
assert public_orders(page_size=500)["page_size"] == 100
|
||||
assert TestClient(app).get("/api/v1/orders?sort=bogus").status_code == 422
|
||||
assert TestClient(app).get("/api/v1/orders?status=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_only_the_owner_can_edit_or_remove_an_order():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
order = place(alice, IRON_ORE, quality=500).json()
|
||||
|
||||
assert (
|
||||
bob.patch(
|
||||
f"/api/v1/orders/{order['id']}", json={"quantity": 99}
|
||||
).status_code
|
||||
== 404
|
||||
)
|
||||
assert bob.delete(f"/api/v1/orders/{order['id']}").status_code == 404
|
||||
|
||||
updated = alice.patch(
|
||||
f"/api/v1/orders/{order['id']}",
|
||||
json={"quantity": 3, "notes": "Updated", "status": "fulfilled"},
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
body = updated.json()
|
||||
assert body["quantity"] == 3
|
||||
assert body["notes"] == "Updated"
|
||||
assert body["status"] == "fulfilled"
|
||||
assert body["quality"] == 500
|
||||
|
||||
cleared = alice.patch(
|
||||
f"/api/v1/orders/{order['id']}", json={"quality": None}
|
||||
)
|
||||
assert cleared.status_code == 200, cleared.text
|
||||
assert cleared.json()["quality"] is None
|
||||
|
||||
assert alice.delete(f"/api/v1/orders/{order['id']}").status_code == 204
|
||||
assert public_orders()["total"] == 0
|
||||
@@ -17,11 +17,14 @@ const state = {
|
||||
totalPages: 1,
|
||||
view: "catalog",
|
||||
user: null,
|
||||
inventory: { search: "", category: "" },
|
||||
inventory: { search: "", category: "", sort: "name" },
|
||||
available: { search: "", category: "", sort: "updated" },
|
||||
orders: { search: "", category: "", status: "", sort: "updated" },
|
||||
};
|
||||
|
||||
let authMode = "login";
|
||||
let pendingView = null;
|
||||
let currentDetailClass = null;
|
||||
|
||||
const els = {};
|
||||
|
||||
@@ -32,11 +35,17 @@ function cacheElements() {
|
||||
"error-banner", "loading", "empty-state", "results-grid",
|
||||
"pagination", "prev-page", "next-page", "page-info",
|
||||
"modal-backdrop", "modal-close", "modal-title", "modal-body",
|
||||
"catalog-view", "inventory-view", "inv-search", "inv-category",
|
||||
"catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort",
|
||||
"inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine",
|
||||
"available-view", "avail-search", "avail-category", "avail-sort",
|
||||
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
|
||||
"tab-available",
|
||||
"orders-view", "order-search", "order-category", "order-status", "order-sort",
|
||||
"order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders",
|
||||
"auth-backdrop", "auth-close", "auth-title", "auth-form",
|
||||
"auth-username", "auth-password", "auth-error", "auth-submit",
|
||||
"auth-toggle", "toast",
|
||||
"confirm-backdrop", "confirm-message", "confirm-ok", "confirm-cancel",
|
||||
];
|
||||
for (const id of ids) els[id] = document.getElementById(id);
|
||||
}
|
||||
@@ -102,7 +111,7 @@ async function initHeader() {
|
||||
const c = health.counts || {};
|
||||
els["stats-line"].textContent =
|
||||
`${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` +
|
||||
`${c["fps-items"] ?? 0} FPS items`;
|
||||
`${c["fps-items"] ?? 0} FPS items · ${c.commodities ?? 0} commodities`;
|
||||
} catch {
|
||||
els["api-status"].textContent = "API OFFLINE";
|
||||
els["api-status"].className = "status-pill offline";
|
||||
@@ -193,6 +202,11 @@ async function submitAuth(event) {
|
||||
pendingView = null;
|
||||
switchToMine();
|
||||
}
|
||||
// The detail view was opened while logged out: re-render it so the
|
||||
// order form becomes available in place.
|
||||
if (currentDetailClass && !els["modal-backdrop"].classList.contains("hidden")) {
|
||||
openModal(currentDetailClass);
|
||||
}
|
||||
} catch (err) {
|
||||
els["auth-error"].textContent = err.message;
|
||||
} finally {
|
||||
@@ -217,6 +231,8 @@ async function logout() {
|
||||
state.category = "";
|
||||
loadItems();
|
||||
}
|
||||
// Orders stay public, but the owner-only action buttons must disappear.
|
||||
if (state.view === "orders") loadOrders();
|
||||
}
|
||||
|
||||
/* ---------- catalog list ---------- */
|
||||
@@ -234,7 +250,12 @@ function buildQuery() {
|
||||
}
|
||||
|
||||
function categoryBadge(category) {
|
||||
const labels = { ships: "Ship", "ship-items": "Ship Item", "fps-items": "FPS Item" };
|
||||
const labels = {
|
||||
ships: "Ship",
|
||||
"ship-items": "Ship Item",
|
||||
"fps-items": "FPS Item",
|
||||
commodities: "Commodity",
|
||||
};
|
||||
return `<span class="badge cat-${category}">${labels[category] || category}</span>`;
|
||||
}
|
||||
|
||||
@@ -328,6 +349,7 @@ function renderPorts(ports) {
|
||||
}
|
||||
|
||||
async function openModal(class_name) {
|
||||
currentDetailClass = class_name;
|
||||
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
|
||||
els["modal-backdrop"].classList.remove("hidden");
|
||||
try {
|
||||
@@ -351,24 +373,93 @@ async function openModal(class_name) {
|
||||
<div class="add-row">
|
||||
<label for="add-qty">Qty</label>
|
||||
<input type="number" id="add-qty" min="1" max="1000000" value="1">
|
||||
${item.quality_applicable ? `
|
||||
<label for="add-quality">Quality</label>
|
||||
<input type="number" id="add-quality" min="1" max="1000" placeholder="—">
|
||||
` : ""}
|
||||
<button type="button" id="add-to-inventory" class="btn-primary">
|
||||
${state.user ? "Add to my inventory" : "Log in to add"}
|
||||
</button>
|
||||
</div>
|
||||
<h3>Request this item</h3>
|
||||
${state.user ? `
|
||||
<p class="hint">Set up an order so every player can see what you are looking for.</p>
|
||||
<div class="add-row">
|
||||
<label for="order-qty">Qty</label>
|
||||
<input type="number" id="order-qty" min="1" max="1000000" value="1">
|
||||
${item.quality_applicable ? `
|
||||
<label for="order-quality">Quality</label>
|
||||
<input type="number" id="order-quality" min="1" max="1000" placeholder="—">
|
||||
` : ""}
|
||||
<input type="text" id="order-notes" class="order-notes-input"
|
||||
maxlength="1000" placeholder="Notes for other pilots (optional)">
|
||||
<button type="button" id="create-order" class="btn-primary">Set up order</button>
|
||||
</div>
|
||||
<p id="order-error" class="auth-error"></p>
|
||||
` : `
|
||||
<div class="add-row">
|
||||
<span>Log in to set up an order other players can see.</span>
|
||||
<button type="button" id="order-login" class="btn-primary">Log in to request</button>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
document.getElementById("add-to-inventory").addEventListener("click", () => {
|
||||
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
|
||||
addToInventory(item.class_name, qty);
|
||||
let quality = null;
|
||||
const qualityInput = document.getElementById("add-quality");
|
||||
if (qualityInput && qualityInput.value.trim() !== "") {
|
||||
quality = Number(qualityInput.value);
|
||||
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
|
||||
showToast("Quality must be between 1 and 1000", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
addToInventory(item.class_name, qty, quality);
|
||||
});
|
||||
|
||||
const createOrderButton = document.getElementById("create-order");
|
||||
if (createOrderButton) {
|
||||
createOrderButton.addEventListener("click", () =>
|
||||
submitDetailOrder(item, createOrderButton)
|
||||
);
|
||||
} else {
|
||||
const loginButton = document.getElementById("order-login");
|
||||
if (loginButton) {
|
||||
loginButton.addEventListener("click", () =>
|
||||
openAuth("login", "Log in to set up an order.")
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
currentDetailClass = null;
|
||||
els["modal-backdrop"].classList.add("hidden");
|
||||
}
|
||||
|
||||
let confirmResolve = null;
|
||||
|
||||
function confirmAction(message, label = "Remove") {
|
||||
if (confirmResolve) closeConfirm(false);
|
||||
els["confirm-message"].textContent = message;
|
||||
els["confirm-ok"].textContent = label;
|
||||
els["confirm-backdrop"].classList.remove("hidden");
|
||||
els["confirm-ok"].focus();
|
||||
return new Promise((resolve) => {
|
||||
confirmResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function closeConfirm(result) {
|
||||
els["confirm-backdrop"].classList.add("hidden");
|
||||
const resolve = confirmResolve;
|
||||
confirmResolve = null;
|
||||
if (resolve) resolve(result);
|
||||
}
|
||||
|
||||
/* ---------- personal inventory ---------- */
|
||||
|
||||
async function switchToMine() {
|
||||
@@ -380,6 +471,8 @@ async function switchToMine() {
|
||||
setActiveTab(els["tab-mine"]);
|
||||
state.view = "mine";
|
||||
els["catalog-view"].classList.add("hidden");
|
||||
els["available-view"].classList.add("hidden");
|
||||
els["orders-view"].classList.add("hidden");
|
||||
els["inventory-view"].classList.remove("hidden");
|
||||
await loadInventory();
|
||||
}
|
||||
@@ -387,6 +480,8 @@ async function switchToMine() {
|
||||
function showCatalogView() {
|
||||
state.view = "catalog";
|
||||
els["inventory-view"].classList.add("hidden");
|
||||
els["available-view"].classList.add("hidden");
|
||||
els["orders-view"].classList.add("hidden");
|
||||
els["catalog-view"].classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -394,8 +489,10 @@ function showCatalogView() {
|
||||
function resetInventoryView() {
|
||||
state.inventory.search = "";
|
||||
state.inventory.category = "";
|
||||
state.inventory.sort = "name";
|
||||
els["inv-search"].value = "";
|
||||
els["inv-category"].value = "";
|
||||
els["inv-sort"].value = "name";
|
||||
els["inv-summary"].textContent = "";
|
||||
renderInventory({ total: 0, items: [] });
|
||||
}
|
||||
@@ -404,6 +501,7 @@ async function loadInventory() {
|
||||
const params = new URLSearchParams();
|
||||
if (state.inventory.search) params.set("search", state.inventory.search);
|
||||
if (state.inventory.category) params.set("category", state.inventory.category);
|
||||
params.set("sort", state.inventory.sort);
|
||||
params.set("page", "1");
|
||||
params.set("page_size", String(INVENTORY_PAGE_SIZE));
|
||||
|
||||
@@ -427,7 +525,12 @@ async function refreshSummary() {
|
||||
}
|
||||
|
||||
function renderInventorySummary(summary) {
|
||||
const labels = { ships: "ships", "ship-items": "ship items", "fps-items": "FPS items" };
|
||||
const labels = {
|
||||
ships: "ships",
|
||||
"ship-items": "ship items",
|
||||
"fps-items": "FPS items",
|
||||
commodities: "commodities",
|
||||
};
|
||||
const parts = Object.entries(summary.categories || {})
|
||||
.map(([key, qty]) => `${qty} ${labels[key] || key}`)
|
||||
.join(" · ");
|
||||
@@ -440,6 +543,71 @@ function renderInventorySummary(summary) {
|
||||
(parts ? ` · ${parts}` : "") + unavailable;
|
||||
}
|
||||
|
||||
function itemNameCell(entry) {
|
||||
const cell = document.createElement("td");
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "inv-item-link";
|
||||
button.innerHTML =
|
||||
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
|
||||
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>`;
|
||||
if (entry.available === false) {
|
||||
button.disabled = true;
|
||||
button.classList.add("is-static");
|
||||
button.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
'<div class="unavailable-note">Not in current catalog</div>'
|
||||
);
|
||||
} else {
|
||||
button.title = "View item details";
|
||||
button.addEventListener("click", () => openModal(entry.class_name));
|
||||
}
|
||||
cell.appendChild(button);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function qualityCell(entry, editable) {
|
||||
const cell = document.createElement("td");
|
||||
if (!entry.quality_applicable) {
|
||||
// Keep a recorded quality visible even if the catalog item disappeared.
|
||||
cell.innerHTML =
|
||||
entry.quality == null
|
||||
? '<span class="inv-quality-na">—</span>'
|
||||
: escapeHtml(String(entry.quality));
|
||||
return cell;
|
||||
}
|
||||
if (!editable) {
|
||||
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
|
||||
return cell;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.className = "inv-quality";
|
||||
input.min = "1";
|
||||
input.max = "1000";
|
||||
input.placeholder = "—";
|
||||
input.value = entry.quality == null ? "" : String(entry.quality);
|
||||
input.setAttribute("aria-label", `Quality of ${entry.name}`);
|
||||
input.addEventListener("change", () => {
|
||||
const raw = input.value.trim();
|
||||
if (raw === "") {
|
||||
entry.quality = null;
|
||||
patchEntry(entry.class_name, { quality: null });
|
||||
return;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 1000) {
|
||||
input.value = entry.quality == null ? "" : String(entry.quality);
|
||||
showToast("Quality must be between 1 and 1000", true);
|
||||
return;
|
||||
}
|
||||
entry.quality = value;
|
||||
patchEntry(entry.class_name, { quality: value });
|
||||
});
|
||||
cell.appendChild(input);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function renderInventory(listing) {
|
||||
const tbody = els["inv-tbody"];
|
||||
tbody.innerHTML = "";
|
||||
@@ -449,11 +617,7 @@ function renderInventory(listing) {
|
||||
for (const entry of listing.items) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.innerHTML =
|
||||
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
|
||||
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>` +
|
||||
(entry.available ? "" : '<div class="unavailable-note">Not in current catalog</div>');
|
||||
const nameCell = itemNameCell(entry);
|
||||
|
||||
const categoryCell = document.createElement("td");
|
||||
categoryCell.innerHTML = entry.category
|
||||
@@ -500,11 +664,56 @@ function renderInventory(listing) {
|
||||
remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name));
|
||||
actionCell.appendChild(remove);
|
||||
|
||||
row.append(nameCell, categoryCell, qtyCell, notesCell, actionCell);
|
||||
const visibilityCell = document.createElement("td");
|
||||
const visibility = document.createElement("button");
|
||||
visibility.type = "button";
|
||||
visibility.className = "visibility-toggle";
|
||||
visibility.addEventListener("click", () => toggleVisibility(entry, visibility));
|
||||
visibilityCell.appendChild(visibility);
|
||||
applyVisibility(visibility, entry.visibility);
|
||||
|
||||
row.append(
|
||||
nameCell,
|
||||
categoryCell,
|
||||
qtyCell,
|
||||
qualityCell(entry, true),
|
||||
notesCell,
|
||||
visibilityCell,
|
||||
actionCell
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function applyVisibility(button, value) {
|
||||
const isAvailable = value === "available";
|
||||
button.textContent = isAvailable ? "Available" : "Private";
|
||||
button.classList.toggle("is-available", isAvailable);
|
||||
button.setAttribute("aria-pressed", String(isAvailable));
|
||||
button.title = isAvailable
|
||||
? "Marked available — click to make private"
|
||||
: "Private to you — click to mark available";
|
||||
}
|
||||
|
||||
async function toggleVisibility(entry, button) {
|
||||
const next = entry.visibility === "available" ? "private" : "available";
|
||||
button.disabled = true;
|
||||
try {
|
||||
const updated = await apiFetch(
|
||||
`/api/v1/me/inventory/${encodeURIComponent(entry.class_name)}`,
|
||||
{ method: "PATCH", body: { visibility: next } }
|
||||
);
|
||||
entry.visibility = updated.visibility;
|
||||
applyVisibility(button, updated.visibility);
|
||||
showToast(updated.visibility === "available" ? "Marked available" : "Marked private");
|
||||
} catch (err) {
|
||||
if (!handleUnauthorized(err)) showToast(err.message, true);
|
||||
loadInventory();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function patchEntry(class_name, payload) {
|
||||
try {
|
||||
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
|
||||
@@ -520,27 +729,34 @@ async function patchEntry(class_name, payload) {
|
||||
}
|
||||
|
||||
async function deleteEntry(class_name, name) {
|
||||
if (!window.confirm(`Remove "${name}" from your inventory?`)) return;
|
||||
const confirmed = await confirmAction(
|
||||
`Remove "${name}" from your inventory? If it is marked available it will ` +
|
||||
"disappear from everyone else's view.",
|
||||
"Remove"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
showToast("Removed from your inventory");
|
||||
loadInventory();
|
||||
await loadInventory();
|
||||
} catch (err) {
|
||||
if (!handleUnauthorized(err)) showToast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function addToInventory(class_name, quantity = 1) {
|
||||
async function addToInventory(class_name, quantity = 1, quality = null) {
|
||||
if (!state.user) {
|
||||
openAuth("login", "Log in to add items to your inventory.");
|
||||
return;
|
||||
}
|
||||
const body = { class_name, quantity };
|
||||
if (quality !== null) body.quality = quality;
|
||||
try {
|
||||
const data = await apiFetch("/api/v1/me/inventory", {
|
||||
method: "POST",
|
||||
body: { class_name, quantity },
|
||||
body,
|
||||
});
|
||||
showToast(
|
||||
data.created
|
||||
@@ -554,6 +770,266 @@ async function addToInventory(class_name, quantity = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- available items (public) ---------- */
|
||||
|
||||
async function switchToAvailable() {
|
||||
setActiveTab(els["tab-available"]);
|
||||
state.view = "available";
|
||||
els["catalog-view"].classList.add("hidden");
|
||||
els["inventory-view"].classList.add("hidden");
|
||||
els["orders-view"].classList.add("hidden");
|
||||
els["available-view"].classList.remove("hidden");
|
||||
await loadAvailable();
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
async function loadAvailable() {
|
||||
const params = new URLSearchParams();
|
||||
if (state.available.search) params.set("search", state.available.search);
|
||||
if (state.available.category) params.set("category", state.available.category);
|
||||
params.set("sort", state.available.sort);
|
||||
params.set("page_size", String(INVENTORY_PAGE_SIZE));
|
||||
|
||||
els["avail-summary"].textContent = "Loading…";
|
||||
try {
|
||||
const listing = await apiFetch(`/api/v1/available?${params}`);
|
||||
renderAvailable(listing);
|
||||
} catch (err) {
|
||||
els["avail-summary"].textContent = "";
|
||||
showToast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAvailable(listing) {
|
||||
const tbody = els["avail-tbody"];
|
||||
tbody.innerHTML = "";
|
||||
els["avail-summary"].textContent =
|
||||
`${listing.total} item${listing.total === 1 ? "" : "s"} offered`;
|
||||
els["avail-empty"].classList.toggle("hidden", listing.total !== 0);
|
||||
els["avail-table-wrap"].classList.toggle("hidden", listing.total === 0);
|
||||
|
||||
for (const entry of listing.items) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const nameCell = itemNameCell(entry);
|
||||
|
||||
const categoryCell = document.createElement("td");
|
||||
categoryCell.innerHTML = entry.category
|
||||
? categoryBadge(entry.category)
|
||||
: '<span class="badge size">—</span>';
|
||||
|
||||
const qtyCell = document.createElement("td");
|
||||
qtyCell.textContent = String(entry.quantity);
|
||||
|
||||
const userCell = document.createElement("td");
|
||||
userCell.className = "avail-user";
|
||||
userCell.textContent = entry.username;
|
||||
|
||||
const updatedCell = document.createElement("td");
|
||||
updatedCell.className = "avail-updated";
|
||||
updatedCell.textContent = formatDate(entry.updated_at);
|
||||
|
||||
row.append(
|
||||
nameCell,
|
||||
categoryCell,
|
||||
qtyCell,
|
||||
qualityCell(entry, false),
|
||||
userCell,
|
||||
updatedCell
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- orders (public board) ---------- */
|
||||
|
||||
async function switchToOrders() {
|
||||
setActiveTab(els["tab-orders"]);
|
||||
state.view = "orders";
|
||||
els["catalog-view"].classList.add("hidden");
|
||||
els["inventory-view"].classList.add("hidden");
|
||||
els["available-view"].classList.add("hidden");
|
||||
els["orders-view"].classList.remove("hidden");
|
||||
await loadOrders();
|
||||
}
|
||||
|
||||
async function submitDetailOrder(item, button) {
|
||||
const qtyInput = document.getElementById("order-qty");
|
||||
const qualityInput = document.getElementById("order-quality");
|
||||
const notesInput = document.getElementById("order-notes");
|
||||
const error = document.getElementById("order-error");
|
||||
|
||||
const quantity = Number(qtyInput.value);
|
||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000000) {
|
||||
error.textContent = "Quantity must be between 1 and 1,000,000";
|
||||
return;
|
||||
}
|
||||
|
||||
let quality = null;
|
||||
if (item.quality_applicable && qualityInput) {
|
||||
const raw = qualityInput.value.trim();
|
||||
if (raw !== "") {
|
||||
quality = Number(raw);
|
||||
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
|
||||
error.textContent = "Quality must be between 1 and 1000";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
class_name: item.class_name,
|
||||
quantity,
|
||||
notes: notesInput.value.trim(),
|
||||
};
|
||||
if (quality !== null) body.quality = quality;
|
||||
|
||||
button.disabled = true;
|
||||
error.textContent = "";
|
||||
try {
|
||||
await apiFetch("/api/v1/orders", { method: "POST", body });
|
||||
showToast("Order set up — visible to all pilots");
|
||||
if (state.view === "orders") loadOrders();
|
||||
} catch (err) {
|
||||
if (!handleUnauthorized(err)) error.textContent = err.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
const params = new URLSearchParams();
|
||||
if (state.orders.search) params.set("search", state.orders.search);
|
||||
if (state.orders.category) params.set("category", state.orders.category);
|
||||
if (state.orders.status) params.set("status", state.orders.status);
|
||||
params.set("sort", state.orders.sort);
|
||||
params.set("page_size", String(INVENTORY_PAGE_SIZE));
|
||||
|
||||
els["order-summary"].textContent = "Loading…";
|
||||
try {
|
||||
const listing = await apiFetch(`/api/v1/orders?${params}`);
|
||||
renderOrders(listing);
|
||||
} catch (err) {
|
||||
els["order-summary"].textContent = "";
|
||||
showToast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOrders(listing) {
|
||||
const tbody = els["order-tbody"];
|
||||
tbody.innerHTML = "";
|
||||
els["order-summary"].textContent =
|
||||
`${listing.total} order${listing.total === 1 ? "" : "s"}`;
|
||||
els["order-empty"].classList.toggle("hidden", listing.total !== 0);
|
||||
els["order-table-wrap"].classList.toggle("hidden", listing.total === 0);
|
||||
|
||||
for (const entry of listing.items) {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const nameCell = itemNameCell(entry);
|
||||
|
||||
const categoryCell = document.createElement("td");
|
||||
categoryCell.innerHTML = entry.category
|
||||
? categoryBadge(entry.category)
|
||||
: '<span class="badge size">—</span>';
|
||||
|
||||
const qtyCell = document.createElement("td");
|
||||
qtyCell.textContent = String(entry.quantity);
|
||||
|
||||
const notesCell = document.createElement("td");
|
||||
notesCell.className = "order-notes";
|
||||
notesCell.textContent = entry.notes || "—";
|
||||
|
||||
const userCell = document.createElement("td");
|
||||
userCell.className = "avail-user";
|
||||
userCell.textContent = entry.username;
|
||||
|
||||
const statusCell = document.createElement("td");
|
||||
const status = document.createElement("span");
|
||||
status.className = `order-status ${entry.status}`;
|
||||
status.textContent = entry.status === "fulfilled" ? "Fulfilled" : "Open";
|
||||
statusCell.appendChild(status);
|
||||
|
||||
const updatedCell = document.createElement("td");
|
||||
updatedCell.className = "avail-updated";
|
||||
updatedCell.textContent = formatDate(entry.updated_at);
|
||||
|
||||
const actionCell = document.createElement("td");
|
||||
actionCell.className = "order-actions";
|
||||
if (state.user && entry.username === state.user.username) {
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "visibility-toggle";
|
||||
const next = entry.status === "fulfilled" ? "open" : "fulfilled";
|
||||
toggle.textContent = next === "fulfilled" ? "Fulfil" : "Reopen";
|
||||
toggle.dataset.status = next;
|
||||
toggle.addEventListener("click", () =>
|
||||
patchOrder(entry, { status: next }, toggle)
|
||||
);
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "inv-remove";
|
||||
remove.textContent = "Remove";
|
||||
remove.addEventListener("click", () => deleteOrder(entry));
|
||||
|
||||
actionCell.append(toggle, remove);
|
||||
}
|
||||
|
||||
row.append(
|
||||
nameCell,
|
||||
categoryCell,
|
||||
qtyCell,
|
||||
qualityCell(entry, false),
|
||||
notesCell,
|
||||
userCell,
|
||||
statusCell,
|
||||
updatedCell,
|
||||
actionCell
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function patchOrder(entry, payload, button = null) {
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
const updated = await apiFetch(`/api/v1/orders/${entry.id}`, {
|
||||
method: "PATCH",
|
||||
body: payload,
|
||||
});
|
||||
showToast(
|
||||
updated.status === "fulfilled" ? "Order marked fulfilled" : "Order reopened"
|
||||
);
|
||||
await loadOrders();
|
||||
} catch (err) {
|
||||
if (!handleUnauthorized(err)) showToast(err.message, true);
|
||||
await loadOrders();
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOrder(entry) {
|
||||
const confirmed = await confirmAction(
|
||||
`Remove your order for "${entry.name}"? It will disappear for everyone.`,
|
||||
"Remove"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await apiFetch(`/api/v1/orders/${entry.id}`, { method: "DELETE" });
|
||||
showToast("Order removed");
|
||||
await loadOrders();
|
||||
} catch (err) {
|
||||
if (!handleUnauthorized(err)) showToast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- events ---------- */
|
||||
|
||||
function debounce(fn, wait) {
|
||||
@@ -582,6 +1058,14 @@ function bindEvents() {
|
||||
switchToMine();
|
||||
return;
|
||||
}
|
||||
if (tab.dataset.view === "available") {
|
||||
switchToAvailable();
|
||||
return;
|
||||
}
|
||||
if (tab.dataset.view === "orders") {
|
||||
switchToOrders();
|
||||
return;
|
||||
}
|
||||
setActiveTab(tab);
|
||||
showCatalogView();
|
||||
state.category = tab.dataset.category;
|
||||
@@ -644,8 +1128,14 @@ function bindEvents() {
|
||||
els["modal-backdrop"].addEventListener("click", (event) => {
|
||||
if (event.target === els["modal-backdrop"]) closeModal();
|
||||
});
|
||||
els["confirm-ok"].addEventListener("click", () => closeConfirm(true));
|
||||
els["confirm-cancel"].addEventListener("click", () => closeConfirm(false));
|
||||
els["confirm-backdrop"].addEventListener("click", (event) => {
|
||||
if (event.target === els["confirm-backdrop"]) closeConfirm(false);
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
closeConfirm(false);
|
||||
closeModal();
|
||||
closeAuth();
|
||||
});
|
||||
@@ -674,6 +1164,50 @@ function bindInventoryEvents() {
|
||||
state.inventory.category = els["inv-category"].value;
|
||||
loadInventory();
|
||||
});
|
||||
els["inv-sort"].addEventListener("change", () => {
|
||||
state.inventory.sort = els["inv-sort"].value;
|
||||
loadInventory();
|
||||
});
|
||||
}
|
||||
|
||||
function bindAvailableEvents() {
|
||||
els["avail-search"].addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
state.available.search = els["avail-search"].value.trim();
|
||||
loadAvailable();
|
||||
}, 300)
|
||||
);
|
||||
els["avail-category"].addEventListener("change", () => {
|
||||
state.available.category = els["avail-category"].value;
|
||||
loadAvailable();
|
||||
});
|
||||
els["avail-sort"].addEventListener("change", () => {
|
||||
state.available.sort = els["avail-sort"].value;
|
||||
loadAvailable();
|
||||
});
|
||||
}
|
||||
|
||||
function bindOrderEvents() {
|
||||
els["order-search"].addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
state.orders.search = els["order-search"].value.trim();
|
||||
loadOrders();
|
||||
}, 300)
|
||||
);
|
||||
els["order-category"].addEventListener("change", () => {
|
||||
state.orders.category = els["order-category"].value;
|
||||
loadOrders();
|
||||
});
|
||||
els["order-status"].addEventListener("change", () => {
|
||||
state.orders.status = els["order-status"].value;
|
||||
loadOrders();
|
||||
});
|
||||
els["order-sort"].addEventListener("change", () => {
|
||||
state.orders.sort = els["order-sort"].value;
|
||||
loadOrders();
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
@@ -683,6 +1217,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
bindEvents();
|
||||
bindAuthEvents();
|
||||
bindInventoryEvents();
|
||||
bindAvailableEvents();
|
||||
bindOrderEvents();
|
||||
initHeader();
|
||||
await initAuth();
|
||||
loadItems();
|
||||
@@ -3,15 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Star Citizen Inventory</title>
|
||||
<title>FPOV Logistics Readiness</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
|
||||
<p class="subtitle">Item catalog from scunpacked game data</p>
|
||||
<h1>Free People of the Verse <span>NEXUS</span></h1>
|
||||
<p class="subtitle">Network for Exchange of Xeno and Utility Supplies</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="header-status">
|
||||
@@ -28,7 +28,10 @@
|
||||
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
|
||||
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
|
||||
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
|
||||
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
|
||||
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
|
||||
<button class="tab" id="tab-available" data-view="available">Available</button>
|
||||
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
|
||||
</nav>
|
||||
|
||||
<div id="catalog-view">
|
||||
@@ -67,6 +70,13 @@
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
<option value="commodities">Commodities</option>
|
||||
</select>
|
||||
<select id="inv-sort" aria-label="Sort inventory">
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="category">Sort: Category</option>
|
||||
<option value="quantity">Sort: Quantity</option>
|
||||
<option value="updated">Sort: Recently updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="inv-summary" class="inv-summary"></div>
|
||||
@@ -81,7 +91,9 @@
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Notes</th>
|
||||
<th>Visibility</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -90,6 +102,92 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="available-view" class="hidden">
|
||||
<div class="inventory-controls">
|
||||
<input type="search" id="avail-search" placeholder="Search item or pilot…" autocomplete="off">
|
||||
<select id="avail-category">
|
||||
<option value="">All categories</option>
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
<option value="commodities">Commodities</option>
|
||||
</select>
|
||||
<select id="avail-sort" aria-label="Sort available items">
|
||||
<option value="updated">Sort: Recently updated</option>
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="category">Sort: Category</option>
|
||||
<option value="quantity">Sort: Quantity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="avail-summary" class="inv-summary"></div>
|
||||
<div id="avail-empty" class="empty-state hidden">
|
||||
<p>Nothing is being offered right now.</p>
|
||||
<p class="hint">Mark items as available in My Inventory to list them here.</p>
|
||||
</div>
|
||||
<div id="avail-table-wrap" class="inv-table-wrap hidden">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Offered by</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="avail-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="orders-view" class="hidden">
|
||||
<div class="inventory-controls">
|
||||
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
|
||||
<select id="order-category">
|
||||
<option value="">All categories</option>
|
||||
<option value="ships">Ships</option>
|
||||
<option value="ship-items">Ship Equipment</option>
|
||||
<option value="fps-items">FPS Items</option>
|
||||
<option value="commodities">Commodities</option>
|
||||
</select>
|
||||
<select id="order-status" aria-label="Filter by order status">
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
</select>
|
||||
<select id="order-sort" aria-label="Sort orders">
|
||||
<option value="updated">Sort: Recently updated</option>
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="category">Sort: Category</option>
|
||||
<option value="quantity">Sort: Quantity</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="order-summary" class="inv-summary"></div>
|
||||
<div id="order-empty" class="empty-state hidden">
|
||||
<p>No orders yet.</p>
|
||||
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
|
||||
</div>
|
||||
<div id="order-table-wrap" class="inv-table-wrap hidden">
|
||||
<table class="inv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Notes</th>
|
||||
<th>Requested by</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="order-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>Data source:
|
||||
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
||||
@@ -105,6 +203,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="confirm-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal modal-narrow" role="alertdialog" aria-modal="true"
|
||||
aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||
<h2 id="confirm-title">Remove item</h2>
|
||||
<p id="confirm-message" class="modal-desc"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="confirm-cancel" class="btn-secondary">Cancel</button>
|
||||
<button type="button" id="confirm-ok" class="btn-danger">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="auth-backdrop" class="modal-backdrop hidden">
|
||||
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
|
||||
<button class="modal-close" id="auth-close" aria-label="Close">×</button>
|
||||
@@ -14,6 +14,7 @@
|
||||
--cat-ships: #38bdf8;
|
||||
--cat-ship-items: #a78bfa;
|
||||
--cat-fps-items: #f59e0b;
|
||||
--cat-commodities: #4ade80;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -132,6 +133,23 @@ body {
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
|
||||
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #1b0505;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover { filter: brightness(1.1); }
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
/* Main */
|
||||
main {
|
||||
@@ -198,6 +216,7 @@ main {
|
||||
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
|
||||
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
|
||||
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
|
||||
.badge.cat-commodities { color: var(--cat-commodities); border-color: var(--cat-commodities); }
|
||||
.badge.size { color: var(--text-dim); border-color: var(--border); }
|
||||
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
|
||||
|
||||
@@ -347,9 +366,11 @@ main {
|
||||
}
|
||||
#inv-search { flex: 2 1 280px; }
|
||||
#inv-category { flex: 1 1 180px; }
|
||||
#inv-sort { flex: 1 1 170px; }
|
||||
.inventory-controls input:focus,
|
||||
.inventory-controls select:focus { outline: none; border-color: var(--accent); }
|
||||
#inventory-view {
|
||||
#inventory-view,
|
||||
#available-view {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.25rem 0;
|
||||
@@ -380,6 +401,21 @@ main {
|
||||
}
|
||||
.inv-table tr:last-child td { border-bottom: none; }
|
||||
.inv-item-name { font-weight: 600; }
|
||||
.inv-item-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inv-item-link:hover .inv-item-name { text-decoration: underline; }
|
||||
.inv-item-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.inv-item-link.is-static { cursor: default; }
|
||||
.inv-item-link.is-static:hover .inv-item-name { text-decoration: none; }
|
||||
.inv-item-sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
@@ -393,6 +429,16 @@ main {
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-quality {
|
||||
width: 5rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-quality-na { color: var(--text-dim); }
|
||||
.inv-quality:focus { outline: none; border-color: var(--accent); }
|
||||
.inv-notes {
|
||||
width: 100%;
|
||||
min-width: 12rem;
|
||||
@@ -413,8 +459,61 @@ main {
|
||||
cursor: pointer;
|
||||
}
|
||||
.inv-remove:hover { border-color: var(--danger); }
|
||||
.visibility-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.visibility-toggle:hover { border-color: var(--accent); color: var(--text); }
|
||||
.visibility-toggle.is-available {
|
||||
border-color: var(--success);
|
||||
color: var(--success);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.visibility-toggle:disabled { opacity: 0.6; cursor: progress; }
|
||||
.avail-user { font-weight: 600; }
|
||||
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
|
||||
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
|
||||
|
||||
/* Orders board */
|
||||
.order-notes {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.82rem;
|
||||
min-width: 10rem;
|
||||
max-width: 18rem;
|
||||
white-space: normal;
|
||||
}
|
||||
.order-status {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.order-status.open {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.order-status.fulfilled {
|
||||
color: var(--success);
|
||||
border-color: var(--success);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
.order-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Auth modal */
|
||||
.modal-narrow { max-width: 400px; }
|
||||
.auth-form {
|
||||
@@ -467,6 +566,11 @@ main {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
.add-row input:focus { outline: none; border-color: var(--accent); }
|
||||
.add-row input.order-notes-input {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
.toast {
|
||||
16
roles/trade_hub/meta/main.yml
Normal file
16
roles/trade_hub/meta/main.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: trade_hub
|
||||
author: boxswapper
|
||||
description: Deploy the Free People of the Verse trade hub (FastAPI, nginx, PostgreSQL) with Podman.
|
||||
license: MIT
|
||||
min_ansible_version: "2.15"
|
||||
platforms:
|
||||
- name: GenericLinux
|
||||
versions:
|
||||
- all
|
||||
galaxy_tags:
|
||||
- podman
|
||||
- fastapi
|
||||
- postgresql
|
||||
dependencies: []
|
||||
215
roles/trade_hub/tasks/main.yml
Normal file
215
roles/trade_hub/tasks/main.yml
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
- name: Check whether podman is installed
|
||||
ansible.builtin.command: podman --version
|
||||
register: trade_hub_podman_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Install podman when requested
|
||||
ansible.builtin.package:
|
||||
name: podman
|
||||
state: present
|
||||
become: true
|
||||
when:
|
||||
- trade_hub_podman_check.rc != 0
|
||||
- trade_hub_install_podman | bool
|
||||
|
||||
- name: Re-check podman after installation
|
||||
ansible.builtin.command: podman --version
|
||||
register: trade_hub_podman_recheck
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
when: trade_hub_podman_check.rc != 0
|
||||
|
||||
- name: Require podman to be available
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- trade_hub_podman_check.rc == 0 or (trade_hub_podman_recheck.rc | default(1)) == 0
|
||||
fail_msg: >-
|
||||
podman is not installed on {{ inventory_hostname }}. Install it first
|
||||
(for example with the "packages" role) or run this role with
|
||||
-e trade_hub_install_podman=true.
|
||||
|
||||
- name: Remove the legacy Apache container when requested
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_legacy_apache_container }}"
|
||||
state: absent
|
||||
when: trade_hub_remove_legacy_apache | bool
|
||||
|
||||
- name: Create the application directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ trade_hub_app_dir }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Stage the application build context
|
||||
ansible.builtin.copy:
|
||||
src: "{{ role_path }}/files/trade-hub/{{ item.src }}"
|
||||
dest: "{{ trade_hub_app_dir }}/{{ item.dest }}"
|
||||
mode: preserve
|
||||
loop:
|
||||
# Only the files needed by the two Dockerfiles are staged. Copying the
|
||||
# whole tree would also ship local development artefacts such as .venv.
|
||||
- { src: "backend/Dockerfile", dest: "backend/Dockerfile" }
|
||||
- { src: "backend/requirements.txt", dest: "backend/requirements.txt" }
|
||||
- { src: "backend/main.py", dest: "backend/main.py" }
|
||||
- { src: "backend/app/", dest: "backend/app/" }
|
||||
- { src: "backend/scripts/", dest: "backend/scripts/" }
|
||||
- { src: "frontend/Dockerfile", dest: "frontend/Dockerfile" }
|
||||
- { src: "frontend/nginx.conf", dest: "frontend/nginx.conf" }
|
||||
- { src: "frontend/src/", dest: "frontend/src/" }
|
||||
register: trade_hub_source
|
||||
|
||||
- name: Check whether the backend image exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
|
||||
register: trade_hub_backend_image_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Check whether the frontend image exists
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
|
||||
register: trade_hub_frontend_image_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Build the backend image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- build
|
||||
- --tag
|
||||
- "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
|
||||
- "{{ trade_hub_app_dir }}/backend"
|
||||
when:
|
||||
- trade_hub_build_images | bool
|
||||
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_backend_image_check.rc != 0
|
||||
register: trade_hub_backend_build
|
||||
|
||||
- name: Build the frontend image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- podman
|
||||
- build
|
||||
- --tag
|
||||
- "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
|
||||
- "{{ trade_hub_app_dir }}/frontend"
|
||||
when:
|
||||
- trade_hub_build_images | bool
|
||||
- trade_hub_force_rebuild | bool or trade_hub_source.changed or trade_hub_frontend_image_check.rc != 0
|
||||
register: trade_hub_frontend_build
|
||||
|
||||
- name: Require prebuilt images when image builds are disabled
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- trade_hub_backend_image_check.rc == 0
|
||||
- trade_hub_frontend_image_check.rc == 0
|
||||
fail_msg: >-
|
||||
trade_hub_build_images is false but {{ trade_hub_backend_image }}:{{
|
||||
trade_hub_image_tag }} and/or {{ trade_hub_frontend_image }}:{{
|
||||
trade_hub_image_tag }} are missing on {{ inventory_hostname }}.
|
||||
when: not trade_hub_build_images | bool
|
||||
|
||||
- name: Record whether the application containers need to be recreated
|
||||
ansible.builtin.set_fact:
|
||||
trade_hub_recreate_containers: >-
|
||||
{{ (trade_hub_force_rebuild | bool)
|
||||
or ((trade_hub_build_images | bool) and (trade_hub_source.changed | default(false)))
|
||||
or (trade_hub_backend_build.changed | default(false))
|
||||
or (trade_hub_frontend_build.changed | default(false)) }}
|
||||
|
||||
- name: Ensure the PostgreSQL image is present
|
||||
containers.podman.podman_image:
|
||||
name: "{{ trade_hub_postgres_image }}"
|
||||
state: present
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Create the podman network
|
||||
containers.podman.podman_network:
|
||||
name: "{{ trade_hub_network }}"
|
||||
state: present
|
||||
|
||||
- name: Create the PostgreSQL data volume
|
||||
containers.podman.podman_volume:
|
||||
name: "{{ trade_hub_volume }}"
|
||||
state: present
|
||||
|
||||
- name: Run the PostgreSQL container
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_db_container }}"
|
||||
image: "{{ trade_hub_postgres_image }}"
|
||||
state: started
|
||||
recreate: "{{ trade_hub_force_rebuild | bool }}"
|
||||
network: "{{ trade_hub_network }}"
|
||||
network_aliases:
|
||||
- db
|
||||
env:
|
||||
POSTGRES_USER: "{{ trade_hub_postgres_user }}"
|
||||
POSTGRES_PASSWORD: "{{ trade_hub_postgres_password }}"
|
||||
POSTGRES_DB: "{{ trade_hub_postgres_db }}"
|
||||
volumes:
|
||||
- "{{ trade_hub_volume }}:/var/lib/postgresql/data:Z"
|
||||
restart_policy: unless-stopped
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Run the backend container
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_backend_container }}"
|
||||
image: "{{ trade_hub_backend_image }}:{{ trade_hub_image_tag }}"
|
||||
state: started
|
||||
# podman does not recreate a container when its image tag resolves to a new
|
||||
# ID, so recreate whenever the image was rebuilt or the app changed.
|
||||
recreate: "{{ trade_hub_recreate_containers | bool }}"
|
||||
network: "{{ trade_hub_network }}"
|
||||
# The nginx upstream is http://backend:8000, so this alias must match.
|
||||
network_aliases:
|
||||
- backend
|
||||
env:
|
||||
DATABASE_URL: "{{ trade_hub_database_url }}"
|
||||
DB_WAIT_SECONDS: "{{ trade_hub_db_wait_seconds }}"
|
||||
COOKIE_SECURE: "{{ trade_hub_cookie_secure | bool | lower }}"
|
||||
restart_policy: unless-stopped
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Run the frontend container
|
||||
containers.podman.podman_container:
|
||||
name: "{{ trade_hub_frontend_container }}"
|
||||
image: "{{ trade_hub_frontend_image }}:{{ trade_hub_image_tag }}"
|
||||
state: started
|
||||
recreate: "{{ trade_hub_recreate_containers | bool }}"
|
||||
network: "{{ trade_hub_network }}"
|
||||
ports:
|
||||
- "{{ trade_hub_frontend_bind }}:{{ trade_hub_frontend_port }}:80"
|
||||
restart_policy: unless-stopped
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Enable podman-restart so containers survive a reboot
|
||||
ansible.builtin.systemd:
|
||||
name: podman-restart.service
|
||||
enabled: true
|
||||
state: started
|
||||
when: trade_hub_enable_restart_service | bool
|
||||
|
||||
- name: Wait for the application to answer health checks
|
||||
ansible.builtin.uri:
|
||||
url: "{{ trade_hub_healthcheck_url }}"
|
||||
status_code: 200
|
||||
register: trade_hub_health
|
||||
until: trade_hub_health.status == 200
|
||||
retries: "{{ trade_hub_healthcheck_retries }}"
|
||||
delay: "{{ trade_hub_healthcheck_delay }}"
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,77 +0,0 @@
|
||||
"""Pydantic request/response models for auth and personal inventory."""
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=32)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=32)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
user: UserOut
|
||||
|
||||
|
||||
class InventoryAddRequest(BaseModel):
|
||||
class_name: str = Field(min_length=1, max_length=255)
|
||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
||||
|
||||
|
||||
class InventoryPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
|
||||
|
||||
class InventoryEntryOut(BaseModel):
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
notes: str
|
||||
# False when the catalog no longer contains the referenced item (e.g. after
|
||||
# a dataset refresh). The row is preserved rather than silently deleted.
|
||||
available: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InventoryPage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[InventoryEntryOut]
|
||||
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
items: int
|
||||
quantity: int
|
||||
categories: dict[str, int]
|
||||
unavailable: int
|
||||
|
||||
|
||||
class InventoryAddResponse(BaseModel):
|
||||
entry: InventoryEntryOut
|
||||
created: bool
|
||||
Binary file not shown.
@@ -1,42 +0,0 @@
|
||||
"""Download the full scunpacked dataset into a target directory.
|
||||
|
||||
Usage:
|
||||
python scripts/fetch_data.py [--output-dir DIR]
|
||||
|
||||
Default output: backend/data/full (used by the Docker image build).
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
BASE_URL = "https://raw.githubusercontent.com/StarCitizenWiki/scunpacked-data/master"
|
||||
FILES = [
|
||||
"ships.json",
|
||||
"ship-items.json",
|
||||
"fps-items.json",
|
||||
"manufacturers.json",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
default_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "data", "full")
|
||||
)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", default=default_dir)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
for name in FILES:
|
||||
url = f"{BASE_URL}/{name}"
|
||||
dest = os.path.join(args.output_dir, name)
|
||||
print(f"Downloading {url} -> {dest}", flush=True)
|
||||
urllib.request.urlretrieve(url, dest)
|
||||
size = os.path.getsize(dest)
|
||||
print(f" done ({size / 1_000_000:.1f} MB)", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,191 +0,0 @@
|
||||
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
|
||||
|
||||
def authed_client(username: str = "alice") -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""):
|
||||
return client.post(
|
||||
"/api/v1/me/inventory",
|
||||
json={"class_name": class_name, "quantity": quantity, "notes": notes},
|
||||
)
|
||||
|
||||
|
||||
def test_new_account_starts_with_empty_inventory():
|
||||
client = authed_client("freshpilot")
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert listing["total"] == 0
|
||||
assert listing["items"] == []
|
||||
assert client.get("/api/v1/me/inventory/summary").json() == {
|
||||
"items": 0,
|
||||
"quantity": 0,
|
||||
"categories": {},
|
||||
"unavailable": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_inventory_requires_authentication():
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/v1/me/inventory").status_code == 401
|
||||
assert add(client, CARROCK).status_code == 401
|
||||
assert client.get("/api/v1/me/inventory/summary").status_code == 401
|
||||
|
||||
|
||||
def test_add_unknown_catalog_item_is_not_found():
|
||||
client = authed_client()
|
||||
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
|
||||
|
||||
|
||||
def test_add_creates_enriched_entry():
|
||||
client = authed_client()
|
||||
resp = add(client, CARROCK)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["created"] is True
|
||||
entry = body["entry"]
|
||||
assert entry["class_name"] == CARROCK
|
||||
assert entry["name"] == "Anvil Carrack"
|
||||
assert entry["category"] == "ships"
|
||||
assert entry["quantity"] == 1
|
||||
assert entry["available"] is True
|
||||
assert entry["manufacturer"]["code"] == "ANVL"
|
||||
|
||||
|
||||
def test_add_same_item_increments_quantity():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK, quantity=1).status_code == 201
|
||||
resp = add(client, CARROCK, quantity=2, notes="pledged")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["created"] is False
|
||||
assert body["entry"]["quantity"] == 3
|
||||
assert body["entry"]["notes"] == "pledged"
|
||||
|
||||
listing = client.get("/api/v1/me/inventory").json()
|
||||
assert listing["total"] == 1
|
||||
|
||||
|
||||
def test_inventories_are_isolated_between_users():
|
||||
alice = authed_client("alice")
|
||||
bob = authed_client("bob")
|
||||
|
||||
assert add(alice, CARROCK).status_code == 201
|
||||
assert add(bob, RIFLE).status_code == 201
|
||||
|
||||
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
|
||||
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
|
||||
|
||||
assert [i["class_name"] for i in alice_items] == [CARROCK]
|
||||
assert [i["class_name"] for i in bob_items] == [RIFLE]
|
||||
|
||||
|
||||
def test_patch_updates_quantity_and_notes():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["quantity"] == 4
|
||||
assert body["notes"] == "hangar 3"
|
||||
|
||||
|
||||
def test_patch_unknown_entry_is_not_found():
|
||||
client = authed_client()
|
||||
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
|
||||
|
||||
|
||||
def test_patch_rejects_zero_quantity():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_delete_removes_entry():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
|
||||
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
|
||||
assert client.get("/api/v1/me/inventory").json()["total"] == 0
|
||||
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
|
||||
|
||||
|
||||
def test_list_filters_by_category_and_search():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
assert add(client, RIFLE).status_code == 201
|
||||
|
||||
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
|
||||
assert ships["total"] == 1
|
||||
assert ships["items"][0]["class_name"] == CARROCK
|
||||
|
||||
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
|
||||
assert found["total"] == 1
|
||||
assert found["items"][0]["class_name"] == CARROCK
|
||||
|
||||
|
||||
def test_list_pagination_envelope_and_cap():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
assert add(client, RIFLE).status_code == 201
|
||||
|
||||
body = client.get(
|
||||
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
|
||||
).json()
|
||||
assert body["total"] == 2
|
||||
assert body["page"] == 2
|
||||
assert body["page_size"] == 1
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
|
||||
assert capped["page_size"] == 100
|
||||
|
||||
|
||||
def test_summary_aggregates_quantity_and_categories():
|
||||
client = authed_client()
|
||||
assert add(client, CARROCK, quantity=2).status_code == 201
|
||||
assert add(client, RIFLE, quantity=3).status_code == 201
|
||||
|
||||
summary = client.get("/api/v1/me/inventory/summary").json()
|
||||
assert summary["items"] == 2
|
||||
assert summary["quantity"] == 5
|
||||
assert summary["unavailable"] == 0
|
||||
assert summary["categories"]["ships"] == 2
|
||||
|
||||
|
||||
def test_notes_length_is_bounded():
|
||||
client = authed_client()
|
||||
resp = add(client, CARROCK, notes="x" * 1001)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_catalog_is_not_mutated_by_inventory_operations():
|
||||
client = authed_client()
|
||||
before = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
|
||||
assert add(client, CARROCK).status_code == 201
|
||||
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
|
||||
client.delete(f"/api/v1/me/inventory/{CARROCK}")
|
||||
|
||||
after = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||
assert after == before
|
||||
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
|
||||
assert "quantity" not in carrack
|
||||
assert "notes" not in carrack
|
||||
Reference in New Issue
Block a user