Compare commits
8 Commits
120daf8c31
...
ansible-ro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
364e59343f | ||
|
|
6d90e131b2 | ||
|
|
632aee934f | ||
|
|
3be92f55fb | ||
| c8c5e658e2 | |||
| 4fbad900a2 | |||
| c8ac8e63ee | |||
| b53e3a8a30 |
139
README.md
139
README.md
@@ -4,14 +4,17 @@ A Star Citizen item catalog built from the
|
|||||||
[scunpacked](https://github.com/richardthombs/scunpacked) game data (mirrored at
|
[scunpacked](https://github.com/richardthombs/scunpacked) game data (mirrored at
|
||||||
[StarCitizenWiki/scunpacked-data](https://github.com/StarCitizenWiki/scunpacked-data)).
|
[StarCitizenWiki/scunpacked-data](https://github.com/StarCitizenWiki/scunpacked-data)).
|
||||||
|
|
||||||
Ships, ship equipment, and FPS items are browsable with search, manufacturer /
|
Ships, ship equipment, FPS items, and cargo commodities (ores, refined metals,
|
||||||
type / size filters, pagination, and a detail view per item.
|
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
|
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
|
from the catalog are added to their own list with a quantity and notes. Logged-in
|
||||||
catalog itself stays immutable read-only reference data; personal inventories
|
users can also place **orders** for any catalog item; orders go onto a public
|
||||||
live in PostgreSQL and reference catalog items by `class_name` only, so the
|
board that every visitor can see. The catalog itself stays immutable read-only
|
||||||
dataset can be replaced wholesale without touching user data.
|
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
|
## Stack
|
||||||
|
|
||||||
@@ -22,24 +25,43 @@ dataset can be replaced wholesale without touching user data.
|
|||||||
| database | PostgreSQL 16 | Accounts, sessions, and per-user inventory rows |
|
| database | PostgreSQL 16 | Accounts, sessions, and per-user inventory rows |
|
||||||
| deploy | Ansible + Podman (containers.podman) | Builds images and runs the three containers |
|
| 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,
|
The backend image downloads the full scunpacked dataset at build time, then
|
||||||
so the catalog needs no database — only user data does.
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
playbook.yml Ansible deployment (hosts: webservers)
|
playbook.yml Example play using the role (hosts: webservers)
|
||||||
inventory.ini Inventory (localhost + commented remote example)
|
inventory.ini Inventory (localhost + commented remote example)
|
||||||
trade-hub/
|
teardown.yml Removes this project's containers and data
|
||||||
podman-compose.yml Local three-service stack (db + backend + frontend)
|
roles/trade_hub/
|
||||||
backend/ FastAPI app, tests, data fetch script, Dockerfile
|
README.md Role usage and variables
|
||||||
frontend/ nginx config + static inventory UI
|
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
|
## Run locally with Podman Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd trade-hub
|
cd roles/trade_hub/files/trade-hub
|
||||||
podman-compose up -d --build
|
podman-compose up -d --build
|
||||||
# or: 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
|
## 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
|
```bash
|
||||||
POSTGRES_PASSWORD='choose-something-strong' ansible-playbook playbook.yml
|
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
|
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,
|
1. installs podman if missing (only when `trade_hub_install_podman: true`),
|
||||||
2. removes the legacy `my-apache-server` container if present,
|
2. optionally removes the legacy `my-apache-server` container
|
||||||
3. builds `sc-inventory-backend` / `sc-inventory-frontend` images (skipped when
|
(`trade_hub_remove_legacy_apache: true`),
|
||||||
they already exist; set `-e force_rebuild=true` to rebuild),
|
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,
|
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
|
5. runs PostgreSQL, the backend, and the frontend containers (only the frontend
|
||||||
publishes a port: 8080),
|
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)
|
## Reset for testing (remove everything)
|
||||||
|
|
||||||
@@ -116,22 +145,43 @@ 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
|
name, and sorted by `name` (default), `category`, `quantity` (high to low) or
|
||||||
`updated` (most recently changed first).
|
`updated` (most recently changed first).
|
||||||
|
|
||||||
Inventory rows store `(user_id, class_name, quantity, notes, visibility)`.
|
Inventory rows store `(user_id, class_name, quantity, notes, visibility,
|
||||||
Adding an item that is already present increments its quantity; quantity, notes
|
quality)`. Adding an item that is already present increments its quantity;
|
||||||
and visibility can then be edited or the row removed. Every added item starts
|
quantity, notes, visibility and quality can then be edited or the row removed.
|
||||||
`private`; the Visibility toggle switches it to `available` and back. Responses
|
Every added item starts `private`; the Visibility toggle switches it to
|
||||||
are enriched live from the catalog. If a catalog item disappears after a
|
`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
|
dataset refresh, the row is reported with `available: false` instead of being
|
||||||
silently deleted (`available` is about the catalog, `visibility` is the owner's
|
silently deleted (`available` is about the catalog, `visibility` is the owner's
|
||||||
classification).
|
classification).
|
||||||
|
|
||||||
The **Available** tab (backed by `GET /api/v1/available`) is public, like the
|
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
|
catalog: it lists every row marked `available` across all accounts with the
|
||||||
item's catalog details, quantity, owner username and last update. Private rows
|
item's catalog details, quantity, quality (when applicable), owner username and
|
||||||
|
last update. Private rows
|
||||||
never appear there and inventory notes are never exposed.
|
never appear there and inventory notes are never exposed.
|
||||||
|
|
||||||
Clicking an item name in either **My Inventory** or **Available** opens the same
|
The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in
|
||||||
catalog detail view used by the catalog grid.
|
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
|
### Security: read before exposing this publicly
|
||||||
|
|
||||||
@@ -139,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.
|
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
|
Before putting this on the open internet, terminate TLS in front of the
|
||||||
frontend (Caddy, nginx with certificates, or a load balancer) and set
|
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.
|
Also change `POSTGRES_PASSWORD` from its development default.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
@@ -169,10 +220,10 @@ curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory
|
|||||||
## Backend development
|
## Backend development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd trade-hub/backend
|
cd roles/trade_hub/files/trade-hub/backend
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt
|
||||||
.venv/bin/python -m pytest tests/ -q # 59 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
|
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -182,6 +233,12 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
|
|||||||
- `data/samples/` — small real-schema subset for local dev/tests
|
- `data/samples/` — small real-schema subset for local dev/tests
|
||||||
- `scripts/fetch_data.py` — downloads the full dataset (used by the Dockerfile)
|
- `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
|
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
|
retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does
|
||||||
not crash the backend. Additive columns for existing deployments (currently
|
not crash the backend. Additive columns for existing deployments (currently
|
||||||
@@ -204,16 +261,24 @@ GET /api/v1/auth/me -> {user}
|
|||||||
|
|
||||||
GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
|
GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
|
||||||
GET /api/v1/me/inventory/summary
|
GET /api/v1/me/inventory/summary
|
||||||
POST /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?}
|
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, quality?}
|
||||||
DELETE /api/v1/me/inventory/{class_name}
|
DELETE /api/v1/me/inventory/{class_name}
|
||||||
|
|
||||||
GET /api/v1/available?category=&search=&sort=&page=&page_size=
|
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/me/*` requires a valid session cookie and returns `401` otherwise.
|
||||||
`/api/v1/available` is public and read-only; it accepts `sort=name|category|quantity|updated`
|
`/api/v1/available` and `GET /api/v1/orders` are public and read-only. Both
|
||||||
(default `updated`).
|
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
|
### Configuration
|
||||||
|
|
||||||
|
|||||||
137
playbook.yml
137
playbook.yml
@@ -1,126 +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)
|
- name: Deploy Star Citizen Inventory (trade-hub)
|
||||||
hosts: webservers
|
hosts: webservers
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
backend_image: sc-inventory-backend
|
# The standalone play used to install podman; in a larger playbook the
|
||||||
frontend_image: sc-inventory-frontend
|
# "packages" role normally provides it instead.
|
||||||
postgres_image: docker.io/library/postgres:16-alpine
|
trade_hub_install_podman: true
|
||||||
image_tag: latest
|
trade_hub_postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
|
||||||
network_name: sc-inventory
|
trade_hub_cookie_secure: "false"
|
||||||
frontend_port: "8080:80"
|
trade_hub_force_rebuild: false
|
||||||
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
|
|
||||||
|
|
||||||
tasks:
|
roles:
|
||||||
- name: Check whether podman is installed
|
- role: trade_hub
|
||||||
ansible.builtin.command: podman --version
|
tags: [trade_hub]
|
||||||
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
|
|
||||||
|
|
||||||
- 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
|
|
||||||
# podman does not recreate a container when its image tag resolves to
|
|
||||||
# a new ID, so without this a rebuilt image would be ignored.
|
|
||||||
recreate: true
|
|
||||||
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
|
|
||||||
recreate: true
|
|
||||||
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
|
|
||||||
|
|||||||
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Download the full scunpacked dataset (~83 MB) at build time so the image
|
# Download the full scunpacked dataset at build time so the image is
|
||||||
# is self-contained; DATA_DIR points the API at it.
|
# self-contained; fetch_data.py reduces items.json to commodities.json.
|
||||||
RUN python scripts/fetch_data.py --output-dir /app/data/full
|
RUN python scripts/fetch_data.py --output-dir /app/data/full
|
||||||
ENV DATA_DIR=/app/data/full
|
ENV DATA_DIR=/app/data/full
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ from app.available import router as available_router
|
|||||||
from app.catalog import store
|
from app.catalog import store
|
||||||
from app.db import db_healthy, init_db
|
from app.db import db_healthy, init_db
|
||||||
from app.inventory import router as inventory_router
|
from app.inventory import router as inventory_router
|
||||||
|
from app.orders import router as orders_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -47,8 +48,9 @@ if config.CORS_ORIGINS:
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(inventory_router)
|
app.include_router(inventory_router)
|
||||||
app.include_router(available_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):
|
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 ---------------------------------------------------------
|
# --- Input limits ---------------------------------------------------------
|
||||||
MAX_NOTES_LENGTH = 1000
|
MAX_NOTES_LENGTH = 1000
|
||||||
MAX_QUANTITY = 1_000_000
|
MAX_QUANTITY = 1_000_000
|
||||||
|
MAX_QUALITY = 1000
|
||||||
@@ -83,6 +83,29 @@ def _upgrade_schema(engine) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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:
|
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||||
@@ -46,6 +46,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
|
|||||||
"manufacturer": None,
|
"manufacturer": None,
|
||||||
"quantity": entry.quantity,
|
"quantity": entry.quantity,
|
||||||
"notes": entry.notes,
|
"notes": entry.notes,
|
||||||
|
"quality": entry.quality,
|
||||||
|
"quality_applicable": False,
|
||||||
"visibility": entry.visibility,
|
"visibility": entry.visibility,
|
||||||
"available": False,
|
"available": False,
|
||||||
"created_at": entry.created_at,
|
"created_at": entry.created_at,
|
||||||
@@ -61,6 +63,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
|
|||||||
"manufacturer": item["manufacturer"],
|
"manufacturer": item["manufacturer"],
|
||||||
"quantity": entry.quantity,
|
"quantity": entry.quantity,
|
||||||
"notes": entry.notes,
|
"notes": entry.notes,
|
||||||
|
"quality": entry.quality,
|
||||||
|
"quality_applicable": item.get("quality_applicable", False),
|
||||||
"visibility": entry.visibility,
|
"visibility": entry.visibility,
|
||||||
"available": True,
|
"available": True,
|
||||||
"created_at": entry.created_at,
|
"created_at": entry.created_at,
|
||||||
@@ -68,6 +72,14 @@ def enrich_entry(entry: InventoryEntry) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
||||||
return db.execute(
|
return db.execute(
|
||||||
select(InventoryEntry).where(
|
select(InventoryEntry).where(
|
||||||
@@ -178,10 +190,12 @@ def add_to_inventory(
|
|||||||
db: DbSession = Depends(get_db),
|
db: DbSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
class_name = payload.class_name.strip()
|
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(
|
raise HTTPException(
|
||||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||||
)
|
)
|
||||||
|
_validate_quality(item, payload.quality)
|
||||||
|
|
||||||
entry = _find(db, user, class_name)
|
entry = _find(db, user, class_name)
|
||||||
created = entry is None
|
created = entry is None
|
||||||
@@ -192,12 +206,15 @@ def add_to_inventory(
|
|||||||
class_name=class_name,
|
class_name=class_name,
|
||||||
quantity=payload.quantity,
|
quantity=payload.quantity,
|
||||||
notes=payload.notes,
|
notes=payload.notes,
|
||||||
|
quality=payload.quality,
|
||||||
)
|
)
|
||||||
db.add(entry)
|
db.add(entry)
|
||||||
else:
|
else:
|
||||||
entry.quantity += payload.quantity
|
entry.quantity += payload.quantity
|
||||||
if payload.notes:
|
if payload.notes:
|
||||||
entry.notes = payload.notes
|
entry.notes = payload.notes
|
||||||
|
if payload.quality is not None:
|
||||||
|
entry.quality = payload.quality
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -210,6 +227,8 @@ def add_to_inventory(
|
|||||||
entry.quantity += payload.quantity
|
entry.quantity += payload.quantity
|
||||||
if payload.notes:
|
if payload.notes:
|
||||||
entry.notes = payload.notes
|
entry.notes = payload.notes
|
||||||
|
if payload.quality is not None:
|
||||||
|
entry.quality = payload.quality
|
||||||
db.commit()
|
db.commit()
|
||||||
created = False
|
created = False
|
||||||
|
|
||||||
@@ -235,6 +254,17 @@ def update_inventory_entry(
|
|||||||
entry.notes = payload.notes
|
entry.notes = payload.notes
|
||||||
if payload.visibility is not None:
|
if payload.visibility is not None:
|
||||||
entry.visibility = payload.visibility
|
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.commit()
|
||||||
db.refresh(entry)
|
db.refresh(entry)
|
||||||
@@ -17,6 +17,7 @@ Real schemas this loader consumes:
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
|
||||||
@@ -25,6 +26,69 @@ PLACEHOLDER = "<= PLACEHOLDER =>"
|
|||||||
CATEGORY_SHIPS = "ships"
|
CATEGORY_SHIPS = "ships"
|
||||||
CATEGORY_SHIP_ITEMS = "ship-items"
|
CATEGORY_SHIP_ITEMS = "ship-items"
|
||||||
CATEGORY_FPS_ITEMS = "fps-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):
|
def _load_json(data_dir, filename):
|
||||||
@@ -79,6 +143,7 @@ def _normalize_ship(ship):
|
|||||||
"classification": "Ship",
|
"classification": "Ship",
|
||||||
"description": ship.get("Description") or ship.get("DescriptionText") or "",
|
"description": ship.get("Description") or ship.get("DescriptionText") or "",
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
"quality_applicable": False,
|
||||||
}, {
|
}, {
|
||||||
"career": ship.get("Career") or "",
|
"career": ship.get("Career") or "",
|
||||||
"role": ship.get("Role") or "",
|
"role": ship.get("Role") or "",
|
||||||
@@ -110,7 +175,13 @@ def _normalize_item(item, category):
|
|||||||
"classification": item.get("classification") or "",
|
"classification": item.get("classification") or "",
|
||||||
"description": std.get("Description") or std.get("DescriptionText") or "",
|
"description": std.get("Description") or std.get("DescriptionText") or "",
|
||||||
"tags": tags,
|
"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 = {
|
details = {
|
||||||
"ports": std.get("Ports") or [],
|
"ports": std.get("Ports") or [],
|
||||||
"mass": std.get("Mass"),
|
"mass": std.get("Mass"),
|
||||||
@@ -133,6 +204,9 @@ class Store:
|
|||||||
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
|
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_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_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)
|
items.append(summary)
|
||||||
details[summary["class_name"]] = detail
|
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.
|
# Deterministic order so pagination is stable.
|
||||||
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))
|
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(
|
inventory: Mapped[list["InventoryEntry"]] = relationship(
|
||||||
back_populates="user", cascade="all, delete-orphan"
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
orders: Mapped[list["Order"]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserSession(Base):
|
class UserSession(Base):
|
||||||
@@ -78,6 +81,10 @@ class InventoryEntry(Base):
|
|||||||
"visibility IN ('private', 'available')",
|
"visibility IN ('private', 'available')",
|
||||||
name="ck_inventory_visibility",
|
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)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -87,6 +94,10 @@ class InventoryEntry(Base):
|
|||||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
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
|
# "private" (default): only the owner sees the row. "available": the owner
|
||||||
# is offering the item. Unrelated to the response flag `available`, which
|
# is offering the item. Unrelated to the response flag `available`, which
|
||||||
# only reports whether the catalog still contains the referenced item.
|
# only reports whether the catalog still contains the referenced item.
|
||||||
@@ -101,3 +112,46 @@ class InventoryEntry(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
user: Mapped[User] = relationship(back_populates="inventory")
|
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)
|
||||||
@@ -33,12 +33,15 @@ class InventoryAddRequest(BaseModel):
|
|||||||
class_name: str = Field(min_length=1, max_length=255)
|
class_name: str = Field(min_length=1, max_length=255)
|
||||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
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):
|
class InventoryPatchRequest(BaseModel):
|
||||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||||
visibility: Literal["private", "available"] | None = None
|
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 InventoryEntryOut(BaseModel):
|
||||||
@@ -51,6 +54,10 @@ class InventoryEntryOut(BaseModel):
|
|||||||
manufacturer: dict | None
|
manufacturer: dict | None
|
||||||
quantity: int
|
quantity: int
|
||||||
notes: str
|
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.
|
# Owner-chosen classification. Unrelated to `available` below.
|
||||||
visibility: Literal["private", "available"]
|
visibility: Literal["private", "available"]
|
||||||
# False when the catalog no longer contains the referenced item (e.g. after
|
# False when the catalog no longer contains the referenced item (e.g. after
|
||||||
@@ -84,6 +91,8 @@ class AvailableItemOut(BaseModel):
|
|||||||
grade: Any | None = None
|
grade: Any | None = None
|
||||||
manufacturer: dict | None
|
manufacturer: dict | None
|
||||||
quantity: int
|
quantity: int
|
||||||
|
quality: int | None = None
|
||||||
|
quality_applicable: bool = False
|
||||||
# Public listing: only the owner's display name, never notes.
|
# Public listing: only the owner's display name, never notes.
|
||||||
username: str
|
username: str
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
@@ -100,3 +109,48 @@ class AvailablePage(BaseModel):
|
|||||||
class InventoryAddResponse(BaseModel):
|
class InventoryAddResponse(BaseModel):
|
||||||
entry: InventoryEntryOut
|
entry: InventoryEntryOut
|
||||||
created: bool
|
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
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["status"] == "ok"
|
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():
|
def test_stats():
|
||||||
resp = client.get("/api/v1/stats")
|
resp = client.get("/api/v1/stats")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
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
|
assert body["manufacturers"] == 4
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +43,11 @@ def test_items_default_pagination():
|
|||||||
resp = client.get("/api/v1/items")
|
resp = client.get("/api/v1/items")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
assert body["total"] == 5
|
assert body["total"] == 9
|
||||||
assert body["page"] == 1
|
assert body["page"] == 1
|
||||||
assert body["page_size"] == 25
|
assert body["page_size"] == 25
|
||||||
assert body["total_pages"] == 1
|
assert body["total_pages"] == 1
|
||||||
assert len(body["items"]) == 5
|
assert len(body["items"]) == 9
|
||||||
|
|
||||||
|
|
||||||
def test_items_page_size_capped():
|
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()
|
body = client.get("/api/v1/items", params={"page_size": 100}).json()
|
||||||
keys = [(i["category"], i["name"].lower()) for i in body["items"]]
|
keys = [(i["category"], i["name"].lower()) for i in body["items"]]
|
||||||
assert keys == sorted(keys)
|
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
|
||||||
@@ -6,6 +6,9 @@ from app.api import app
|
|||||||
PASSWORD = "correct horse battery staple"
|
PASSWORD = "correct horse battery staple"
|
||||||
CARROCK = "ANVL_Carrack"
|
CARROCK = "ANVL_Carrack"
|
||||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
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:
|
def authed_client(username: str = "alice") -> TestClient:
|
||||||
@@ -17,11 +20,17 @@ def authed_client(username: str = "alice") -> TestClient:
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""):
|
def add(
|
||||||
return client.post(
|
client: TestClient,
|
||||||
"/api/v1/me/inventory",
|
class_name: str,
|
||||||
json={"class_name": class_name, "quantity": quantity, "notes": notes},
|
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():
|
def test_new_account_starts_with_empty_inventory():
|
||||||
@@ -251,3 +260,97 @@ def test_sort_defaults_to_name_and_rejects_unknown_values():
|
|||||||
listing = client.get("/api/v1/me/inventory").json()
|
listing = client.get("/api/v1/me/inventory").json()
|
||||||
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
|
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
|
||||||
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
|
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
|
||||||
@@ -19,10 +19,12 @@ const state = {
|
|||||||
user: null,
|
user: null,
|
||||||
inventory: { search: "", category: "", sort: "name" },
|
inventory: { search: "", category: "", sort: "name" },
|
||||||
available: { search: "", category: "", sort: "updated" },
|
available: { search: "", category: "", sort: "updated" },
|
||||||
|
orders: { search: "", category: "", status: "", sort: "updated" },
|
||||||
};
|
};
|
||||||
|
|
||||||
let authMode = "login";
|
let authMode = "login";
|
||||||
let pendingView = null;
|
let pendingView = null;
|
||||||
|
let currentDetailClass = null;
|
||||||
|
|
||||||
const els = {};
|
const els = {};
|
||||||
|
|
||||||
@@ -38,6 +40,8 @@ function cacheElements() {
|
|||||||
"available-view", "avail-search", "avail-category", "avail-sort",
|
"available-view", "avail-search", "avail-category", "avail-sort",
|
||||||
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
|
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
|
||||||
"tab-available",
|
"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-backdrop", "auth-close", "auth-title", "auth-form",
|
||||||
"auth-username", "auth-password", "auth-error", "auth-submit",
|
"auth-username", "auth-password", "auth-error", "auth-submit",
|
||||||
"auth-toggle", "toast",
|
"auth-toggle", "toast",
|
||||||
@@ -107,7 +111,7 @@ async function initHeader() {
|
|||||||
const c = health.counts || {};
|
const c = health.counts || {};
|
||||||
els["stats-line"].textContent =
|
els["stats-line"].textContent =
|
||||||
`${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` +
|
`${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 {
|
} catch {
|
||||||
els["api-status"].textContent = "API OFFLINE";
|
els["api-status"].textContent = "API OFFLINE";
|
||||||
els["api-status"].className = "status-pill offline";
|
els["api-status"].className = "status-pill offline";
|
||||||
@@ -198,6 +202,11 @@ async function submitAuth(event) {
|
|||||||
pendingView = null;
|
pendingView = null;
|
||||||
switchToMine();
|
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) {
|
} catch (err) {
|
||||||
els["auth-error"].textContent = err.message;
|
els["auth-error"].textContent = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -222,6 +231,8 @@ async function logout() {
|
|||||||
state.category = "";
|
state.category = "";
|
||||||
loadItems();
|
loadItems();
|
||||||
}
|
}
|
||||||
|
// Orders stay public, but the owner-only action buttons must disappear.
|
||||||
|
if (state.view === "orders") loadOrders();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- catalog list ---------- */
|
/* ---------- catalog list ---------- */
|
||||||
@@ -239,7 +250,12 @@ function buildQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function categoryBadge(category) {
|
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>`;
|
return `<span class="badge cat-${category}">${labels[category] || category}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +349,7 @@ function renderPorts(ports) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openModal(class_name) {
|
async function openModal(class_name) {
|
||||||
|
currentDetailClass = class_name;
|
||||||
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
|
els["modal-body"].innerHTML = '<p class="hint">Loading…</p>';
|
||||||
els["modal-backdrop"].classList.remove("hidden");
|
els["modal-backdrop"].classList.remove("hidden");
|
||||||
try {
|
try {
|
||||||
@@ -356,21 +373,70 @@ async function openModal(class_name) {
|
|||||||
<div class="add-row">
|
<div class="add-row">
|
||||||
<label for="add-qty">Qty</label>
|
<label for="add-qty">Qty</label>
|
||||||
<input type="number" id="add-qty" min="1" max="1000000" value="1">
|
<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">
|
<button type="button" id="add-to-inventory" class="btn-primary">
|
||||||
${state.user ? "Add to my inventory" : "Log in to add"}
|
${state.user ? "Add to my inventory" : "Log in to add"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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", () => {
|
document.getElementById("add-to-inventory").addEventListener("click", () => {
|
||||||
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
|
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) {
|
} catch (err) {
|
||||||
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
|
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
currentDetailClass = null;
|
||||||
els["modal-backdrop"].classList.add("hidden");
|
els["modal-backdrop"].classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +472,7 @@ async function switchToMine() {
|
|||||||
state.view = "mine";
|
state.view = "mine";
|
||||||
els["catalog-view"].classList.add("hidden");
|
els["catalog-view"].classList.add("hidden");
|
||||||
els["available-view"].classList.add("hidden");
|
els["available-view"].classList.add("hidden");
|
||||||
|
els["orders-view"].classList.add("hidden");
|
||||||
els["inventory-view"].classList.remove("hidden");
|
els["inventory-view"].classList.remove("hidden");
|
||||||
await loadInventory();
|
await loadInventory();
|
||||||
}
|
}
|
||||||
@@ -414,6 +481,7 @@ function showCatalogView() {
|
|||||||
state.view = "catalog";
|
state.view = "catalog";
|
||||||
els["inventory-view"].classList.add("hidden");
|
els["inventory-view"].classList.add("hidden");
|
||||||
els["available-view"].classList.add("hidden");
|
els["available-view"].classList.add("hidden");
|
||||||
|
els["orders-view"].classList.add("hidden");
|
||||||
els["catalog-view"].classList.remove("hidden");
|
els["catalog-view"].classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +525,12 @@ async function refreshSummary() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderInventorySummary(summary) {
|
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 || {})
|
const parts = Object.entries(summary.categories || {})
|
||||||
.map(([key, qty]) => `${qty} ${labels[key] || key}`)
|
.map(([key, qty]) => `${qty} ${labels[key] || key}`)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -493,6 +566,48 @@ function itemNameCell(entry) {
|
|||||||
return cell;
|
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) {
|
function renderInventory(listing) {
|
||||||
const tbody = els["inv-tbody"];
|
const tbody = els["inv-tbody"];
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
@@ -557,7 +672,15 @@ function renderInventory(listing) {
|
|||||||
visibilityCell.appendChild(visibility);
|
visibilityCell.appendChild(visibility);
|
||||||
applyVisibility(visibility, entry.visibility);
|
applyVisibility(visibility, entry.visibility);
|
||||||
|
|
||||||
row.append(nameCell, categoryCell, qtyCell, notesCell, visibilityCell, actionCell);
|
row.append(
|
||||||
|
nameCell,
|
||||||
|
categoryCell,
|
||||||
|
qtyCell,
|
||||||
|
qualityCell(entry, true),
|
||||||
|
notesCell,
|
||||||
|
visibilityCell,
|
||||||
|
actionCell
|
||||||
|
);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,15 +746,17 @@ async function deleteEntry(class_name, name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addToInventory(class_name, quantity = 1) {
|
async function addToInventory(class_name, quantity = 1, quality = null) {
|
||||||
if (!state.user) {
|
if (!state.user) {
|
||||||
openAuth("login", "Log in to add items to your inventory.");
|
openAuth("login", "Log in to add items to your inventory.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const body = { class_name, quantity };
|
||||||
|
if (quality !== null) body.quality = quality;
|
||||||
try {
|
try {
|
||||||
const data = await apiFetch("/api/v1/me/inventory", {
|
const data = await apiFetch("/api/v1/me/inventory", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { class_name, quantity },
|
body,
|
||||||
});
|
});
|
||||||
showToast(
|
showToast(
|
||||||
data.created
|
data.created
|
||||||
@@ -652,6 +777,7 @@ async function switchToAvailable() {
|
|||||||
state.view = "available";
|
state.view = "available";
|
||||||
els["catalog-view"].classList.add("hidden");
|
els["catalog-view"].classList.add("hidden");
|
||||||
els["inventory-view"].classList.add("hidden");
|
els["inventory-view"].classList.add("hidden");
|
||||||
|
els["orders-view"].classList.add("hidden");
|
||||||
els["available-view"].classList.remove("hidden");
|
els["available-view"].classList.remove("hidden");
|
||||||
await loadAvailable();
|
await loadAvailable();
|
||||||
}
|
}
|
||||||
@@ -708,11 +834,202 @@ function renderAvailable(listing) {
|
|||||||
updatedCell.className = "avail-updated";
|
updatedCell.className = "avail-updated";
|
||||||
updatedCell.textContent = formatDate(entry.updated_at);
|
updatedCell.textContent = formatDate(entry.updated_at);
|
||||||
|
|
||||||
row.append(nameCell, categoryCell, qtyCell, userCell, updatedCell);
|
row.append(
|
||||||
|
nameCell,
|
||||||
|
categoryCell,
|
||||||
|
qtyCell,
|
||||||
|
qualityCell(entry, false),
|
||||||
|
userCell,
|
||||||
|
updatedCell
|
||||||
|
);
|
||||||
tbody.appendChild(row);
|
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 ---------- */
|
/* ---------- events ---------- */
|
||||||
|
|
||||||
function debounce(fn, wait) {
|
function debounce(fn, wait) {
|
||||||
@@ -745,6 +1062,10 @@ function bindEvents() {
|
|||||||
switchToAvailable();
|
switchToAvailable();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (tab.dataset.view === "orders") {
|
||||||
|
switchToOrders();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
showCatalogView();
|
showCatalogView();
|
||||||
state.category = tab.dataset.category;
|
state.category = tab.dataset.category;
|
||||||
@@ -867,6 +1188,28 @@ function bindAvailableEvents() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ---------- */
|
/* ---------- boot ---------- */
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
@@ -875,6 +1218,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
bindAuthEvents();
|
bindAuthEvents();
|
||||||
bindInventoryEvents();
|
bindInventoryEvents();
|
||||||
bindAvailableEvents();
|
bindAvailableEvents();
|
||||||
|
bindOrderEvents();
|
||||||
initHeader();
|
initHeader();
|
||||||
await initAuth();
|
await initAuth();
|
||||||
loadItems();
|
loadItems();
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="header-inner">
|
<div class="header-inner">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
|
<h1>Free People of the Verse <span>NEXUS</span></h1>
|
||||||
<p class="subtitle">Item catalog from scunpacked game data</p>
|
<p class="subtitle">Network for Exchange of Xeno and Utility Supplies</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="header-status">
|
<div class="header-status">
|
||||||
@@ -28,8 +28,10 @@
|
|||||||
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
|
<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="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="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 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-available" data-view="available">Available</button>
|
||||||
|
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="catalog-view">
|
<div id="catalog-view">
|
||||||
@@ -68,6 +70,7 @@
|
|||||||
<option value="ships">Ships</option>
|
<option value="ships">Ships</option>
|
||||||
<option value="ship-items">Ship Equipment</option>
|
<option value="ship-items">Ship Equipment</option>
|
||||||
<option value="fps-items">FPS Items</option>
|
<option value="fps-items">FPS Items</option>
|
||||||
|
<option value="commodities">Commodities</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="inv-sort" aria-label="Sort inventory">
|
<select id="inv-sort" aria-label="Sort inventory">
|
||||||
<option value="name">Sort: Name</option>
|
<option value="name">Sort: Name</option>
|
||||||
@@ -88,6 +91,7 @@
|
|||||||
<th>Item</th>
|
<th>Item</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Qty</th>
|
<th>Qty</th>
|
||||||
|
<th>Quality</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
<th>Visibility</th>
|
<th>Visibility</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -106,6 +110,7 @@
|
|||||||
<option value="ships">Ships</option>
|
<option value="ships">Ships</option>
|
||||||
<option value="ship-items">Ship Equipment</option>
|
<option value="ship-items">Ship Equipment</option>
|
||||||
<option value="fps-items">FPS Items</option>
|
<option value="fps-items">FPS Items</option>
|
||||||
|
<option value="commodities">Commodities</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="avail-sort" aria-label="Sort available items">
|
<select id="avail-sort" aria-label="Sort available items">
|
||||||
<option value="updated">Sort: Recently updated</option>
|
<option value="updated">Sort: Recently updated</option>
|
||||||
@@ -126,6 +131,7 @@
|
|||||||
<th>Item</th>
|
<th>Item</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Qty</th>
|
<th>Qty</th>
|
||||||
|
<th>Quality</th>
|
||||||
<th>Offered by</th>
|
<th>Offered by</th>
|
||||||
<th>Updated</th>
|
<th>Updated</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -135,6 +141,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<footer class="site-footer">
|
||||||
<p>Data source:
|
<p>Data source:
|
||||||
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
--cat-ships: #38bdf8;
|
--cat-ships: #38bdf8;
|
||||||
--cat-ship-items: #a78bfa;
|
--cat-ship-items: #a78bfa;
|
||||||
--cat-fps-items: #f59e0b;
|
--cat-fps-items: #f59e0b;
|
||||||
|
--cat-commodities: #4ade80;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -215,6 +216,7 @@ main {
|
|||||||
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
|
.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-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-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); }
|
.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; }
|
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }
|
||||||
|
|
||||||
@@ -427,6 +429,16 @@ main {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.35rem 0.5rem;
|
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 {
|
.inv-notes {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
@@ -468,6 +480,40 @@ main {
|
|||||||
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
|
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
|
||||||
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
|
.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 */
|
/* Auth modal */
|
||||||
.modal-narrow { max-width: 400px; }
|
.modal-narrow { max-width: 400px; }
|
||||||
.auth-form {
|
.auth-form {
|
||||||
@@ -520,6 +566,11 @@ main {
|
|||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
}
|
}
|
||||||
.add-row input:focus { outline: none; border-color: var(--accent); }
|
.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 */
|
||||||
.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
|
||||||
@@ -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())
|
|
||||||
Reference in New Issue
Block a user