Compare commits

...

14 Commits

Author SHA1 Message Date
boxswapper
364e59343f after migration 2026-11-13 13:01:22 -07:00
boxswapper
6d90e131b2 created new role 2026-11-13 12:26:24 -07:00
boxswapper
632aee934f order button 2026-11-12 19:41:50 -07:00
boxswapper
3be92f55fb order button 2026-11-12 19:41:10 -07:00
c8c5e658e2 updated quality for ore 2026-11-12 13:57:23 -07:00
4fbad900a2 updated commodities 2026-11-12 13:16:57 -07:00
c8ac8e63ee <h1>Free People of the Verse <span>NEXUS</span></h1>
<p class=subtitle>Network for Exchange of Xeno and Utility Supplies</p>
2026-11-12 13:13:16 -07:00
b53e3a8a30 added comodities 2026-11-12 13:04:02 -07:00
120daf8c31 fixed remove issue, made items avaiable for available view 2026-11-12 12:49:19 -07:00
2807ffd160 document inventory sorting and test count
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
51cd47bd47 recreate app containers when images are rebuilt
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
83611e2a2c add inventory sort control to the ui
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
8f3bff2e62 add inventory sorting to the api
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
c17d8a9f8a stop tracking bytecode and local session files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-11-12 12:07:15 -07:00
78 changed files with 4160 additions and 577 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.omo/

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f302d08ffewav8qUiPZ0aI2j",
"updatedAt": "2026-11-12T17:30:43.353Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T17:30:43.353Z"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f304d0bffewgGWmy7An2rOeJ",
"updatedAt": "2026-11-12T17:30:35.146Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T17:30:35.146Z"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f306c90ffe63BtszteHo8OAl",
"updatedAt": "2026-11-12T17:30:27.126Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T17:30:27.126Z"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f306cc1ffemzh3awaA137wZh",
"updatedAt": "2026-11-12T17:30:27.053Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T17:30:27.053Z"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f306cedffevymy11Qu04FoB0",
"updatedAt": "2026-11-12T17:30:27.004Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T17:30:27.004Z"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f393836ffeUi9lBqb1kfxxoz",
"updatedAt": "2026-11-12T18:38:36.743Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T18:38:36.743Z"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"sessionID": "ses_e2f45eaccffeiis1QONAKg7gzm",
"updatedAt": "2026-11-12T17:10:58.656Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-11-12T17:10:58.656Z"
}
}
}

155
README.md
View File

@@ -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)
@@ -112,11 +141,47 @@ from scratch.
- Catalog browsing works without an account; the catalog endpoints remain - Catalog browsing works without an account; the catalog endpoints remain
read-only. read-only.
Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item The inventory view can be filtered by category and searched by name/class
that is already present increments its quantity; quantity and notes can then be name, and sorted by `name` (default), `category`, `quantity` (high to low) or
edited or the row removed. Responses are enriched live from the catalog. If a `updated` (most recently changed first).
catalog item disappears after a dataset refresh, the row is reported with
`available: false` instead of being silently deleted. Inventory rows store `(user_id, class_name, quantity, notes, visibility,
quality)`. Adding an item that is already present increments its quantity;
quantity, notes, visibility and quality can then be edited or the row removed.
Every added item starts `private`; the Visibility toggle switches it to
`available` and back. Responses are enriched live from the catalog.
`quality` is an optional mining/refining value from **1 to 1000**. It is only
accepted for commodities that are actually mineable — ores, minerals and gems
(and their refined/raw/pure variants). Gases, organics, processed goods, crafted
alloys and salvage are excluded. The catalog exposes this as
`quality_applicable`; the UI shows a Quality input only for those commodities.
`null` means the owner has not recorded a quality. On `PATCH`, sending
`"quality": null` clears it, while omitting the field leaves it unchanged. Each
inventory row holds one quality (the schema keeps one row per commodity, so
different quality lots are not separated). If a catalog item disappears after a
dataset refresh, the row is reported with `available: false` instead of being
silently deleted (`available` is about the catalog, `visibility` is the owner's
classification).
The **Available** tab (backed by `GET /api/v1/available`) is public, like the
catalog: it lists every row marked `available` across all accounts with the
item's catalog details, quantity, quality (when applicable), owner username and
last update. Private rows
never appear there and inventory notes are never exposed.
The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in
user clicks any item to open its detail view, then fills in the **Request this
item** form there with a quantity, optional notes, and — for mineable
commodities — an optional quality. Setting up the order publishes the request
on the public board.
Orders show the item, quantity, quality, notes, requesting username, status
(`open`/`fulfilled`) and last update. Notes on orders are public by design;
inventory notes remain private. Only the owner can edit, mark fulfilled/reopen,
or remove their own order.
Clicking an item name in **My Inventory**, **Available** or **Orders** opens the
same catalog detail view used by the catalog grid.
### Security: read before exposing this publicly ### Security: read before exposing this publicly
@@ -124,7 +189,8 @@ The stack publishes plain HTTP on port 8080. Session cookies therefore travel
in cleartext, and `COOKIE_SECURE` defaults to `false` so local development works. 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
@@ -154,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 # 45 tests, no database needed .venv/bin/python -m pytest tests/ -q # 83 tests, no database needed
DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000
``` ```
@@ -167,10 +233,17 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
- `data/samples/` — small real-schema subset for local dev/tests - `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. This does not migrate changed columns — introduce Alembic not crash the backend. Additive columns for existing deployments (currently
when the schema starts evolving in place. `inventory_entries.visibility`) are applied automatically on startup; changing
or dropping a column still requires Alembic.
## API ## API
@@ -186,14 +259,26 @@ POST /api/v1/auth/login {username, password} -> 200 + session c
POST /api/v1/auth/logout -> 204 POST /api/v1/auth/logout -> 204
GET /api/v1/auth/me -> {user} GET /api/v1/auth/me -> {user}
GET /api/v1/me/inventory?category=&search=&page=&page_size= GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
GET /api/v1/me/inventory/summary 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?} 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/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` and `GET /api/v1/orders` are public and read-only. Both
accept `sort=name|category|quantity|updated` (default `updated`); orders also
accept `status=open|fulfilled`. Creating or changing an order requires a
session; only its owner may update or delete it. Catalog `category` values are
`ships`, `ship-items`, `fps-items` and `commodities`.
### Configuration ### Configuration

View File

@@ -1,133 +1,23 @@
--- ---
# Example play that exercises the trade_hub role against the local inventory.
# To use the role in an existing playbook, copy roles/trade_hub into that
# repository's roles/ directory and add:
#
# roles:
# - role: trade_hub
# tags: [trade_hub]
#
- name: Deploy Star Citizen Inventory (trade-hub) - 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
# podman_container does not recreate a container whose image tag now
# resolves to a new ID, so rebuilt images require removing them first.
- name: Remove containers so rebuilt images are used
containers.podman.podman_container:
name: "{{ item }}"
state: absent
loop:
- sc-inventory-backend
- sc-inventory-frontend
when: force_rebuild | bool
- name: Create podman network
containers.podman.podman_network:
name: "{{ network_name }}"
state: present
- name: Create PostgreSQL data volume
containers.podman.podman_volume:
name: "{{ postgres_volume }}"
state: present
- name: Run PostgreSQL container (internal only)
containers.podman.podman_container:
name: sc-inventory-db
image: "{{ postgres_image }}"
state: started
network: "{{ network_name }}"
network_aliases:
- db
env:
POSTGRES_USER: sc_inventory
POSTGRES_PASSWORD: "{{ postgres_password }}"
POSTGRES_DB: sc_inventory
volumes:
- "{{ postgres_volume }}:/var/lib/postgresql/data:Z"
restart_policy: unless-stopped
- name: Run backend container (internal only)
containers.podman.podman_container:
name: sc-inventory-backend
image: "{{ backend_image }}:{{ image_tag }}"
state: started
network: "{{ network_name }}"
# Network alias "backend" matches the compose service name, so the
# nginx upstream (http://backend:8000) resolves in both deployments.
network_aliases:
- backend
env:
DATABASE_URL: "{{ database_url }}"
DB_WAIT_SECONDS: "60"
COOKIE_SECURE: "{{ cookie_secure }}"
restart_policy: unless-stopped
- name: Run frontend container (published port)
containers.podman.podman_container:
name: sc-inventory-frontend
image: "{{ frontend_image }}:{{ image_tag }}"
state: started
network: "{{ network_name }}"
ports:
- "{{ frontend_port }}"
restart_policy: unless-stopped
- name: Verify the stack responds
ansible.builtin.uri:
url: http://localhost:8080/healthz
status_code: 200
register: health_result
until: health_result.status == 200
retries: 8
delay: 10

85
roles/trade_hub/README.md Normal file
View 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.

View 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

View File

@@ -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

View File

@@ -11,9 +11,11 @@ from fastapi.middleware.cors import CORSMiddleware
from app import config from app import config
from app.auth import router as auth_router from app.auth import router as auth_router
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
@@ -45,8 +47,10 @@ 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(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):

View 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],
}

View File

@@ -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

View File

@@ -51,12 +51,69 @@ def get_db():
db.close() db.close()
def _upgrade_schema(engine) -> None:
"""Add columns that `create_all` cannot add to an existing table.
PostgreSQL only: the SQLite database used by the tests is created fresh
from the models on every run, so it already has every column.
"""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_visibility'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_visibility
CHECK (visibility IN ('private', 'available'));
END IF;
END $$
"""
)
)
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"quality INTEGER"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_quality_range'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_quality_range
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
END IF;
END $$
"""
)
)
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
"""Create missing tables, retrying while PostgreSQL finishes booting. """Create missing tables, retrying while PostgreSQL finishes booting.
`create_all(checkfirst=True)` is idempotent and safe to run on every start. `create_all(checkfirst=True)` is idempotent and safe to run on every start.
It does not migrate changed columns; introduce Alembic when the schema Additive columns for existing deployments are applied afterwards; changing
starts evolving in place. or dropping an existing column still requires Alembic.
""" """
deadline = time.monotonic() + max(wait_seconds, 0) deadline = time.monotonic() + max(wait_seconds, 0)
while True: while True:
@@ -65,6 +122,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("SELECT 1")) conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine, checkfirst=True) Base.metadata.create_all(bind=engine, checkfirst=True)
_upgrade_schema(engine)
return return
except (OperationalError, IntegrityError, ProgrammingError) as exc: except (OperationalError, IntegrityError, ProgrammingError) as exc:
# A concurrent startup may have created the tables first. # A concurrent startup may have created the tables first.

View File

@@ -33,7 +33,7 @@ router = APIRouter(
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
def _enrich(entry: InventoryEntry) -> dict: def enrich_entry(entry: InventoryEntry) -> dict:
item = catalog.store.by_class_name.get(entry.class_name) item = catalog.store.by_class_name.get(entry.class_name)
if item is None: if item is None:
return { return {
@@ -46,6 +46,9 @@ def _enrich(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,
"available": False, "available": False,
"created_at": entry.created_at, "created_at": entry.created_at,
"updated_at": entry.updated_at, "updated_at": entry.updated_at,
@@ -60,12 +63,23 @@ def _enrich(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,
"available": True, "available": True,
"created_at": entry.created_at, "created_at": entry.created_at,
"updated_at": entry.updated_at, "updated_at": entry.updated_at,
} }
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None: def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
return db.execute( return db.execute(
select(InventoryEntry).where( select(InventoryEntry).where(
@@ -75,10 +89,35 @@ def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
).scalar_one_or_none() ).scalar_one_or_none()
def _sort_key_name(item):
return (item["name"].lower(), item["class_name"])
def _sort_key_category(item):
return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"])
def _sort_key_quantity(item):
return (-item["quantity"], item["name"].lower(), item["class_name"])
def _sort_key_updated(item):
return (-item["updated_at"].timestamp(), item["name"].lower())
SORT_KEYS = {
"name": _sort_key_name,
"category": _sort_key_category,
"quantity": _sort_key_quantity,
"updated": _sort_key_updated,
}
@router.get("", response_model=InventoryPage) @router.get("", response_model=InventoryPage)
def list_inventory( def list_inventory(
category: str | None = None, category: str | None = None,
search: str | None = None, search: str | None = None,
sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1), page_size: int = Query(default=25, ge=1),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
@@ -89,7 +128,7 @@ def list_inventory(
select(InventoryEntry).where(InventoryEntry.user_id == user.id) select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all() ).scalars().all()
items = [_enrich(row) for row in rows] items = [enrich_entry(row) for row in rows]
if category is not None and category.strip(): if category is not None and category.strip():
wanted = category.strip() wanted = category.strip()
@@ -102,7 +141,7 @@ def list_inventory(
if needle in i["name"].lower() or needle in i["class_name"].lower() if needle in i["name"].lower() or needle in i["class_name"].lower()
] ]
items.sort(key=lambda i: (i["name"].lower(), i["class_name"])) items.sort(key=SORT_KEYS[sort])
total = len(items) total = len(items)
total_pages = max(1, math.ceil(total / page_size)) total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size start = (page - 1) * page_size
@@ -151,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
@@ -165,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()
@@ -183,12 +227,14 @@ 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
db.refresh(entry) db.refresh(entry)
response.status_code = 201 if created else 200 response.status_code = 201 if created else 200
return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created} return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created}
@router.patch("/{class_name}", response_model=InventoryEntryOut) @router.patch("/{class_name}", response_model=InventoryEntryOut)
@@ -206,10 +252,23 @@ def update_inventory_entry(
entry.quantity = payload.quantity entry.quantity = payload.quantity
if payload.notes is not None: if payload.notes is not None:
entry.notes = payload.notes entry.notes = payload.notes
if payload.visibility is not None:
entry.visibility = payload.visibility
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{entry.class_name}'",
)
_validate_quality(item, payload.quality)
entry.quality = payload.quality
db.commit() db.commit()
db.refresh(entry) db.refresh(entry)
return InventoryEntryOut.model_validate(_enrich(entry)) return InventoryEntryOut.model_validate(enrich_entry(entry))
@router.delete("/{class_name}", status_code=204) @router.delete("/{class_name}", status_code=204)

View File

@@ -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"]))

View File

@@ -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):
@@ -74,6 +77,14 @@ class InventoryEntry(Base):
__table_args__ = ( __table_args__ = (
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"), UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"), CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
CheckConstraint(
"visibility IN ('private', 'available')",
name="ck_inventory_visibility",
),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_inventory_quality_range",
),
) )
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -83,6 +94,16 @@ class InventoryEntry(Base):
class_name: Mapped[str] = mapped_column(String(255), nullable=False) 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
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.
visibility: Mapped[str] = mapped_column(
String(16), nullable=False, default="private", server_default="private"
)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow DateTime(timezone=True), nullable=False, default=utcnow
) )
@@ -91,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")

View 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)

View File

@@ -0,0 +1,156 @@
"""Pydantic request/response models for auth and personal inventory."""
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from app import config
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=32)
password: str = Field(min_length=1, max_length=128)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
created_at: datetime
class AuthResponse(BaseModel):
user: UserOut
class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
visibility: Literal["private", "available"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryEntryOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
# Mining/refining quality (1-1000); only meaningful when
# `quality_applicable` is true. None means the owner has not set it.
quality: int | None = None
quality_applicable: bool = False
# Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"]
# False when the catalog no longer contains the referenced item (e.g. after
# a dataset refresh). The row is preserved rather than silently deleted.
available: bool
created_at: datetime
updated_at: datetime
class InventoryPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[InventoryEntryOut]
class InventorySummary(BaseModel):
items: int
quantity: int
categories: dict[str, int]
unavailable: int
class AvailableItemOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
quality: int | None = None
quality_applicable: bool = False
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime
class AvailablePage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[AvailableItemOut]
class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut
created: bool
class OrderCreateRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
status: Literal["open", "fulfilled"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderOut(BaseModel):
id: int
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
quality: int | None = None
quality_applicable: bool = False
# Public listing: the requesting user's display name.
username: str
status: Literal["open", "fulfilled"]
# False when the catalog no longer contains the referenced item.
available: bool
created_at: datetime
updated_at: datetime
class OrderPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[OrderOut]

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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())

View 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"
}
]

View File

@@ -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)

View 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

View File

@@ -0,0 +1,356 @@
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str = "alice") -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def add(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
return client.post("/api/v1/me/inventory", json=payload)
def test_new_account_starts_with_empty_inventory():
client = authed_client("freshpilot")
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 0
assert listing["items"] == []
assert client.get("/api/v1/me/inventory/summary").json() == {
"items": 0,
"quantity": 0,
"categories": {},
"unavailable": 0,
}
def test_inventory_requires_authentication():
client = TestClient(app)
assert client.get("/api/v1/me/inventory").status_code == 401
assert add(client, CARROCK).status_code == 401
assert client.get("/api/v1/me/inventory/summary").status_code == 401
def test_add_unknown_catalog_item_is_not_found():
client = authed_client()
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
def test_add_creates_enriched_entry():
client = authed_client()
resp = add(client, CARROCK)
assert resp.status_code == 201
body = resp.json()
assert body["created"] is True
entry = body["entry"]
assert entry["class_name"] == CARROCK
assert entry["name"] == "Anvil Carrack"
assert entry["category"] == "ships"
assert entry["quantity"] == 1
assert entry["visibility"] == "private"
assert entry["available"] is True
assert entry["manufacturer"]["code"] == "ANVL"
def test_add_same_item_increments_quantity():
client = authed_client()
assert add(client, CARROCK, quantity=1).status_code == 201
resp = add(client, CARROCK, quantity=2, notes="pledged")
assert resp.status_code == 200
body = resp.json()
assert body["created"] is False
assert body["entry"]["quantity"] == 3
assert body["entry"]["notes"] == "pledged"
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 1
def test_inventories_are_isolated_between_users():
alice = authed_client("alice")
bob = authed_client("bob")
assert add(alice, CARROCK).status_code == 201
assert add(bob, RIFLE).status_code == 201
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
assert [i["class_name"] for i in alice_items] == [CARROCK]
assert [i["class_name"] for i in bob_items] == [RIFLE]
def test_patch_updates_quantity_and_notes():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
)
assert resp.status_code == 200
body = resp.json()
assert body["quantity"] == 4
assert body["notes"] == "hangar 3"
def test_visibility_toggles_between_private_and_available():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"}
)
assert resp.status_code == 200
assert resp.json()["visibility"] == "available"
listed = client.get("/api/v1/me/inventory").json()["items"]
assert listed[0]["visibility"] == "available"
back = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"}
)
assert back.json()["visibility"] == "private"
def test_patch_rejects_unknown_visibility():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "public"})
assert resp.status_code == 422
def test_visibility_is_kept_when_quantity_changes_and_item_is_readded():
client = authed_client()
add(client, CARROCK)
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 3})
assert add(client, CARROCK, quantity=1).status_code == 200
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["quantity"] == 4
assert entry["visibility"] == "available"
def test_patch_unknown_entry_is_not_found():
client = authed_client()
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
def test_patch_rejects_zero_quantity():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
assert resp.status_code == 422
def test_delete_removes_entry():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
assert client.get("/api/v1/me/inventory").json()["total"] == 0
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
def test_list_filters_by_category_and_search():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
assert ships["total"] == 1
assert ships["items"][0]["class_name"] == CARROCK
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
assert found["total"] == 1
assert found["items"][0]["class_name"] == CARROCK
def test_list_pagination_envelope_and_cap():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
body = client.get(
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
).json()
assert body["total"] == 2
assert body["page"] == 2
assert body["page_size"] == 1
assert body["total_pages"] == 2
assert len(body["items"]) == 1
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
assert capped["page_size"] == 100
def test_summary_aggregates_quantity_and_categories():
client = authed_client()
assert add(client, CARROCK, quantity=2).status_code == 201
assert add(client, RIFLE, quantity=3).status_code == 201
summary = client.get("/api/v1/me/inventory/summary").json()
assert summary["items"] == 2
assert summary["quantity"] == 5
assert summary["unavailable"] == 0
assert summary["categories"]["ships"] == 2
def test_notes_length_is_bounded():
client = authed_client()
resp = add(client, CARROCK, notes="x" * 1001)
assert resp.status_code == 422
def test_catalog_is_not_mutated_by_inventory_operations():
client = authed_client()
before = client.get("/api/v1/items", params={"page_size": 100}).json()
assert add(client, CARROCK).status_code == 201
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
client.delete(f"/api/v1/me/inventory/{CARROCK}")
after = client.get("/api/v1/items", params={"page_size": 100}).json()
assert after == before
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
assert "quantity" not in carrack
assert "notes" not in carrack
def test_sort_by_category_groups_categories():
client = authed_client()
add(client, CARROCK)
add(client, RIFLE)
items = client.get("/api/v1/me/inventory?sort=category").json()["items"]
assert [i["class_name"] for i in items] == [RIFLE, CARROCK]
def test_sort_by_quantity_is_descending():
client = authed_client()
add(client, CARROCK, quantity=5)
add(client, RIFLE, quantity=1)
items = client.get("/api/v1/me/inventory?sort=quantity").json()["items"]
assert [i["class_name"] for i in items] == [CARROCK, RIFLE]
def test_sort_defaults_to_name_and_rejects_unknown_values():
client = authed_client()
add(client, CARROCK)
listing = client.get("/api/v1/me/inventory").json()
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
def test_add_commodity_uses_catalog_name_and_quantity():
client = authed_client("refiner")
resp = add(client, IRON_ORE, quantity=12, notes="from Aaron Halo")
assert resp.status_code == 201
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["name"] == "Iron (Ore)"
assert entry["category"] == "commodities"
assert entry["quantity"] == 12
assert entry["available"] is True
assert entry["visibility"] == "private"
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
client = TestClient(app)
items = client.get(
"/api/v1/items", params={"category": "commodities", "page_size": 100}
).json()["items"]
flags = {item["class_name"]: item["quality_applicable"] for item in items}
assert flags[IRON] is True
assert flags[IRON_ORE] is True
assert flags[CONSTRUCTION] is False
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
assert all(item["quality_applicable"] is False for item in ships)
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
client = authed_client()
assert add(client, IRON_ORE).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
assert resp.status_code == 200
body = resp.json()
assert body["quality"] == 750
assert body["quality_applicable"] is True
cleared = client.patch(
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
)
assert cleared.status_code == 200
assert cleared.json()["quality"] is None
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
client = authed_client()
first = add(client, IRON, quality=900)
assert first.status_code == 201
assert first.json()["entry"]["quality"] == 900
second = add(client, IRON, quantity=2)
assert second.status_code == 200
entry = second.json()["entry"]
assert entry["quality"] == 900
assert entry["quantity"] == 3
replaced = add(client, IRON, quality=400)
assert replaced.json()["entry"]["quality"] == 400
def test_quality_is_rejected_for_non_mineable_commodities():
client = authed_client()
assert add(client, CONSTRUCTION).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
)
assert resp.status_code == 422
assert "does not apply" in resp.json()["detail"]
add_resp = add(client, CONSTRUCTION, quality=500)
assert add_resp.status_code == 422
def test_quality_is_rejected_for_ships():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
assert resp.status_code == 422
def test_quality_range_is_enforced():
client = authed_client()
assert add(client, IRON_ORE, quality=1).status_code == 201
assert add(client, IRON, quality=1000).status_code == 201
assert add(client, IRON_ORE, quality=0).status_code == 422
assert add(client, IRON_ORE, quality=1001).status_code == 422
assert add(client, IRON, quantity=1).status_code == 200
assert client.patch(
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
).status_code == 422

View 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

View File

@@ -17,11 +17,14 @@ const state = {
totalPages: 1, totalPages: 1,
view: "catalog", view: "catalog",
user: null, user: null,
inventory: { search: "", category: "" }, inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" },
orders: { search: "", category: "", status: "", sort: "updated" },
}; };
let authMode = "login"; let authMode = "login";
let pendingView = null; let pendingView = null;
let currentDetailClass = null;
const els = {}; const els = {};
@@ -32,11 +35,17 @@ function cacheElements() {
"error-banner", "loading", "empty-state", "results-grid", "error-banner", "loading", "empty-state", "results-grid",
"pagination", "prev-page", "next-page", "page-info", "pagination", "prev-page", "next-page", "page-info",
"modal-backdrop", "modal-close", "modal-title", "modal-body", "modal-backdrop", "modal-close", "modal-title", "modal-body",
"catalog-view", "inventory-view", "inv-search", "inv-category", "catalog-view", "inventory-view", "inv-search", "inv-category", "inv-sort",
"inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine", "inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine",
"available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available",
"orders-view", "order-search", "order-category", "order-status", "order-sort",
"order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders",
"auth-backdrop", "auth-close", "auth-title", "auth-form", "auth-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",
"confirm-backdrop", "confirm-message", "confirm-ok", "confirm-cancel",
]; ];
for (const id of ids) els[id] = document.getElementById(id); for (const id of ids) els[id] = document.getElementById(id);
} }
@@ -102,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";
@@ -193,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 {
@@ -217,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 ---------- */
@@ -234,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>`;
} }
@@ -328,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 {
@@ -351,24 +373,93 @@ 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");
} }
let confirmResolve = null;
function confirmAction(message, label = "Remove") {
if (confirmResolve) closeConfirm(false);
els["confirm-message"].textContent = message;
els["confirm-ok"].textContent = label;
els["confirm-backdrop"].classList.remove("hidden");
els["confirm-ok"].focus();
return new Promise((resolve) => {
confirmResolve = resolve;
});
}
function closeConfirm(result) {
els["confirm-backdrop"].classList.add("hidden");
const resolve = confirmResolve;
confirmResolve = null;
if (resolve) resolve(result);
}
/* ---------- personal inventory ---------- */ /* ---------- personal inventory ---------- */
async function switchToMine() { async function switchToMine() {
@@ -380,6 +471,8 @@ async function switchToMine() {
setActiveTab(els["tab-mine"]); setActiveTab(els["tab-mine"]);
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["orders-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden"); els["inventory-view"].classList.remove("hidden");
await loadInventory(); await loadInventory();
} }
@@ -387,6 +480,8 @@ async function switchToMine() {
function showCatalogView() { 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["orders-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden"); els["catalog-view"].classList.remove("hidden");
} }
@@ -394,8 +489,10 @@ function showCatalogView() {
function resetInventoryView() { function resetInventoryView() {
state.inventory.search = ""; state.inventory.search = "";
state.inventory.category = ""; state.inventory.category = "";
state.inventory.sort = "name";
els["inv-search"].value = ""; els["inv-search"].value = "";
els["inv-category"].value = ""; els["inv-category"].value = "";
els["inv-sort"].value = "name";
els["inv-summary"].textContent = ""; els["inv-summary"].textContent = "";
renderInventory({ total: 0, items: [] }); renderInventory({ total: 0, items: [] });
} }
@@ -404,6 +501,7 @@ async function loadInventory() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (state.inventory.search) params.set("search", state.inventory.search); if (state.inventory.search) params.set("search", state.inventory.search);
if (state.inventory.category) params.set("category", state.inventory.category); if (state.inventory.category) params.set("category", state.inventory.category);
params.set("sort", state.inventory.sort);
params.set("page", "1"); params.set("page", "1");
params.set("page_size", String(INVENTORY_PAGE_SIZE)); params.set("page_size", String(INVENTORY_PAGE_SIZE));
@@ -427,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(" · ");
@@ -440,6 +543,71 @@ function renderInventorySummary(summary) {
(parts ? ` · ${parts}` : "") + unavailable; (parts ? ` · ${parts}` : "") + unavailable;
} }
function itemNameCell(entry) {
const cell = document.createElement("td");
const button = document.createElement("button");
button.type = "button";
button.className = "inv-item-link";
button.innerHTML =
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>`;
if (entry.available === false) {
button.disabled = true;
button.classList.add("is-static");
button.insertAdjacentHTML(
"beforeend",
'<div class="unavailable-note">Not in current catalog</div>'
);
} else {
button.title = "View item details";
button.addEventListener("click", () => openModal(entry.class_name));
}
cell.appendChild(button);
return cell;
}
function qualityCell(entry, editable) {
const cell = document.createElement("td");
if (!entry.quality_applicable) {
// Keep a recorded quality visible even if the catalog item disappeared.
cell.innerHTML =
entry.quality == null
? '<span class="inv-quality-na">—</span>'
: escapeHtml(String(entry.quality));
return cell;
}
if (!editable) {
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
return cell;
}
const input = document.createElement("input");
input.type = "number";
input.className = "inv-quality";
input.min = "1";
input.max = "1000";
input.placeholder = "—";
input.value = entry.quality == null ? "" : String(entry.quality);
input.setAttribute("aria-label", `Quality of ${entry.name}`);
input.addEventListener("change", () => {
const raw = input.value.trim();
if (raw === "") {
entry.quality = null;
patchEntry(entry.class_name, { quality: null });
return;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 1000) {
input.value = entry.quality == null ? "" : String(entry.quality);
showToast("Quality must be between 1 and 1000", true);
return;
}
entry.quality = value;
patchEntry(entry.class_name, { quality: value });
});
cell.appendChild(input);
return cell;
}
function renderInventory(listing) { function renderInventory(listing) {
const tbody = els["inv-tbody"]; const tbody = els["inv-tbody"];
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -449,11 +617,7 @@ function renderInventory(listing) {
for (const entry of listing.items) { for (const entry of listing.items) {
const row = document.createElement("tr"); const row = document.createElement("tr");
const nameCell = document.createElement("td"); const nameCell = itemNameCell(entry);
nameCell.innerHTML =
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>` +
(entry.available ? "" : '<div class="unavailable-note">Not in current catalog</div>');
const categoryCell = document.createElement("td"); const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category categoryCell.innerHTML = entry.category
@@ -500,11 +664,56 @@ function renderInventory(listing) {
remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name)); remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name));
actionCell.appendChild(remove); actionCell.appendChild(remove);
row.append(nameCell, categoryCell, qtyCell, notesCell, actionCell); const visibilityCell = document.createElement("td");
const visibility = document.createElement("button");
visibility.type = "button";
visibility.className = "visibility-toggle";
visibility.addEventListener("click", () => toggleVisibility(entry, visibility));
visibilityCell.appendChild(visibility);
applyVisibility(visibility, entry.visibility);
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, true),
notesCell,
visibilityCell,
actionCell
);
tbody.appendChild(row); tbody.appendChild(row);
} }
} }
function applyVisibility(button, value) {
const isAvailable = value === "available";
button.textContent = isAvailable ? "Available" : "Private";
button.classList.toggle("is-available", isAvailable);
button.setAttribute("aria-pressed", String(isAvailable));
button.title = isAvailable
? "Marked available — click to make private"
: "Private to you — click to mark available";
}
async function toggleVisibility(entry, button) {
const next = entry.visibility === "available" ? "private" : "available";
button.disabled = true;
try {
const updated = await apiFetch(
`/api/v1/me/inventory/${encodeURIComponent(entry.class_name)}`,
{ method: "PATCH", body: { visibility: next } }
);
entry.visibility = updated.visibility;
applyVisibility(button, updated.visibility);
showToast(updated.visibility === "available" ? "Marked available" : "Marked private");
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
loadInventory();
} finally {
button.disabled = false;
}
}
async function patchEntry(class_name, payload) { async function patchEntry(class_name, payload) {
try { try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
@@ -520,27 +729,34 @@ async function patchEntry(class_name, payload) {
} }
async function deleteEntry(class_name, name) { async function deleteEntry(class_name, name) {
if (!window.confirm(`Remove "${name}" from your inventory?`)) return; const confirmed = await confirmAction(
`Remove "${name}" from your inventory? If it is marked available it will ` +
"disappear from everyone else's view.",
"Remove"
);
if (!confirmed) return;
try { try {
await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, {
method: "DELETE", method: "DELETE",
}); });
showToast("Removed from your inventory"); showToast("Removed from your inventory");
loadInventory(); await loadInventory();
} catch (err) { } catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true); if (!handleUnauthorized(err)) showToast(err.message, true);
} }
} }
async function addToInventory(class_name, quantity = 1) { async function addToInventory(class_name, quantity = 1, quality = null) {
if (!state.user) { 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
@@ -554,6 +770,266 @@ async function addToInventory(class_name, quantity = 1) {
} }
} }
/* ---------- available items (public) ---------- */
async function switchToAvailable() {
setActiveTab(els["tab-available"]);
state.view = "available";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden");
await loadAvailable();
}
function formatDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
async function loadAvailable() {
const params = new URLSearchParams();
if (state.available.search) params.set("search", state.available.search);
if (state.available.category) params.set("category", state.available.category);
params.set("sort", state.available.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["avail-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/available?${params}`);
renderAvailable(listing);
} catch (err) {
els["avail-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderAvailable(listing) {
const tbody = els["avail-tbody"];
tbody.innerHTML = "";
els["avail-summary"].textContent =
`${listing.total} item${listing.total === 1 ? "" : "s"} offered`;
els["avail-empty"].classList.toggle("hidden", listing.total !== 0);
els["avail-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '<span class="badge size">—</span>';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
userCell,
updatedCell
);
tbody.appendChild(row);
}
}
/* ---------- orders (public board) ---------- */
async function switchToOrders() {
setActiveTab(els["tab-orders"]);
state.view = "orders";
els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden");
els["orders-view"].classList.remove("hidden");
await loadOrders();
}
async function submitDetailOrder(item, button) {
const qtyInput = document.getElementById("order-qty");
const qualityInput = document.getElementById("order-quality");
const notesInput = document.getElementById("order-notes");
const error = document.getElementById("order-error");
const quantity = Number(qtyInput.value);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 1000000) {
error.textContent = "Quantity must be between 1 and 1,000,000";
return;
}
let quality = null;
if (item.quality_applicable && qualityInput) {
const raw = qualityInput.value.trim();
if (raw !== "") {
quality = Number(raw);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
error.textContent = "Quality must be between 1 and 1000";
return;
}
}
}
const body = {
class_name: item.class_name,
quantity,
notes: notesInput.value.trim(),
};
if (quality !== null) body.quality = quality;
button.disabled = true;
error.textContent = "";
try {
await apiFetch("/api/v1/orders", { method: "POST", body });
showToast("Order set up — visible to all pilots");
if (state.view === "orders") loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) error.textContent = err.message;
} finally {
button.disabled = false;
}
}
async function loadOrders() {
const params = new URLSearchParams();
if (state.orders.search) params.set("search", state.orders.search);
if (state.orders.category) params.set("category", state.orders.category);
if (state.orders.status) params.set("status", state.orders.status);
params.set("sort", state.orders.sort);
params.set("page_size", String(INVENTORY_PAGE_SIZE));
els["order-summary"].textContent = "Loading…";
try {
const listing = await apiFetch(`/api/v1/orders?${params}`);
renderOrders(listing);
} catch (err) {
els["order-summary"].textContent = "";
showToast(err.message, true);
}
}
function renderOrders(listing) {
const tbody = els["order-tbody"];
tbody.innerHTML = "";
els["order-summary"].textContent =
`${listing.total} order${listing.total === 1 ? "" : "s"}`;
els["order-empty"].classList.toggle("hidden", listing.total !== 0);
els["order-table-wrap"].classList.toggle("hidden", listing.total === 0);
for (const entry of listing.items) {
const row = document.createElement("tr");
const nameCell = itemNameCell(entry);
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '<span class="badge size">—</span>';
const qtyCell = document.createElement("td");
qtyCell.textContent = String(entry.quantity);
const notesCell = document.createElement("td");
notesCell.className = "order-notes";
notesCell.textContent = entry.notes || "—";
const userCell = document.createElement("td");
userCell.className = "avail-user";
userCell.textContent = entry.username;
const statusCell = document.createElement("td");
const status = document.createElement("span");
status.className = `order-status ${entry.status}`;
status.textContent = entry.status === "fulfilled" ? "Fulfilled" : "Open";
statusCell.appendChild(status);
const updatedCell = document.createElement("td");
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
const actionCell = document.createElement("td");
actionCell.className = "order-actions";
if (state.user && entry.username === state.user.username) {
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "visibility-toggle";
const next = entry.status === "fulfilled" ? "open" : "fulfilled";
toggle.textContent = next === "fulfilled" ? "Fulfil" : "Reopen";
toggle.dataset.status = next;
toggle.addEventListener("click", () =>
patchOrder(entry, { status: next }, toggle)
);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "inv-remove";
remove.textContent = "Remove";
remove.addEventListener("click", () => deleteOrder(entry));
actionCell.append(toggle, remove);
}
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
notesCell,
userCell,
statusCell,
updatedCell,
actionCell
);
tbody.appendChild(row);
}
}
async function patchOrder(entry, payload, button = null) {
if (button) button.disabled = true;
try {
const updated = await apiFetch(`/api/v1/orders/${entry.id}`, {
method: "PATCH",
body: payload,
});
showToast(
updated.status === "fulfilled" ? "Order marked fulfilled" : "Order reopened"
);
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
await loadOrders();
} finally {
if (button) button.disabled = false;
}
}
async function deleteOrder(entry) {
const confirmed = await confirmAction(
`Remove your order for "${entry.name}"? It will disappear for everyone.`,
"Remove"
);
if (!confirmed) return;
try {
await apiFetch(`/api/v1/orders/${entry.id}`, { method: "DELETE" });
showToast("Order removed");
await loadOrders();
} catch (err) {
if (!handleUnauthorized(err)) showToast(err.message, true);
}
}
/* ---------- events ---------- */ /* ---------- events ---------- */
function debounce(fn, wait) { function debounce(fn, wait) {
@@ -582,6 +1058,14 @@ function bindEvents() {
switchToMine(); switchToMine();
return; return;
} }
if (tab.dataset.view === "available") {
switchToAvailable();
return;
}
if (tab.dataset.view === "orders") {
switchToOrders();
return;
}
setActiveTab(tab); setActiveTab(tab);
showCatalogView(); showCatalogView();
state.category = tab.dataset.category; state.category = tab.dataset.category;
@@ -644,8 +1128,14 @@ function bindEvents() {
els["modal-backdrop"].addEventListener("click", (event) => { els["modal-backdrop"].addEventListener("click", (event) => {
if (event.target === els["modal-backdrop"]) closeModal(); if (event.target === els["modal-backdrop"]) closeModal();
}); });
els["confirm-ok"].addEventListener("click", () => closeConfirm(true));
els["confirm-cancel"].addEventListener("click", () => closeConfirm(false));
els["confirm-backdrop"].addEventListener("click", (event) => {
if (event.target === els["confirm-backdrop"]) closeConfirm(false);
});
document.addEventListener("keydown", (event) => { document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
closeConfirm(false);
closeModal(); closeModal();
closeAuth(); closeAuth();
}); });
@@ -674,6 +1164,50 @@ function bindInventoryEvents() {
state.inventory.category = els["inv-category"].value; state.inventory.category = els["inv-category"].value;
loadInventory(); loadInventory();
}); });
els["inv-sort"].addEventListener("change", () => {
state.inventory.sort = els["inv-sort"].value;
loadInventory();
});
}
function bindAvailableEvents() {
els["avail-search"].addEventListener(
"input",
debounce(() => {
state.available.search = els["avail-search"].value.trim();
loadAvailable();
}, 300)
);
els["avail-category"].addEventListener("change", () => {
state.available.category = els["avail-category"].value;
loadAvailable();
});
els["avail-sort"].addEventListener("change", () => {
state.available.sort = els["avail-sort"].value;
loadAvailable();
});
}
function bindOrderEvents() {
els["order-search"].addEventListener(
"input",
debounce(() => {
state.orders.search = els["order-search"].value.trim();
loadOrders();
}, 300)
);
els["order-category"].addEventListener("change", () => {
state.orders.category = els["order-category"].value;
loadOrders();
});
els["order-status"].addEventListener("change", () => {
state.orders.status = els["order-status"].value;
loadOrders();
});
els["order-sort"].addEventListener("change", () => {
state.orders.sort = els["order-sort"].value;
loadOrders();
});
} }
/* ---------- boot ---------- */ /* ---------- boot ---------- */
@@ -683,6 +1217,8 @@ document.addEventListener("DOMContentLoaded", async () => {
bindEvents(); bindEvents();
bindAuthEvents(); bindAuthEvents();
bindInventoryEvents(); bindInventoryEvents();
bindAvailableEvents();
bindOrderEvents();
initHeader(); initHeader();
await initAuth(); await initAuth();
loadItems(); loadItems();

View File

@@ -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,7 +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-orders" data-view="orders">Orders</button>
</nav> </nav>
<div id="catalog-view"> <div id="catalog-view">
@@ -67,6 +70,13 @@
<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 id="inv-sort" aria-label="Sort inventory">
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
<option value="updated">Sort: Recently updated</option>
</select> </select>
</div> </div>
<div id="inv-summary" class="inv-summary"></div> <div id="inv-summary" class="inv-summary"></div>
@@ -81,7 +91,9 @@
<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></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -90,6 +102,92 @@
</div> </div>
</section> </section>
<section id="available-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="avail-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="avail-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="avail-sort" aria-label="Sort available items">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="avail-summary" class="inv-summary"></div>
<div id="avail-empty" class="empty-state hidden">
<p>Nothing is being offered right now.</p>
<p class="hint">Mark items as available in My Inventory to list them here.</p>
</div>
<div id="avail-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Offered by</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="avail-tbody"></tbody>
</table>
</div>
</section>
<section id="orders-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="order-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="order-status" aria-label="Filter by order status">
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="fulfilled">Fulfilled</option>
</select>
<select id="order-sort" aria-label="Sort orders">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="order-summary" class="inv-summary"></div>
<div id="order-empty" class="empty-state hidden">
<p>No orders yet.</p>
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
</div>
<div id="order-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Requested by</th>
<th>Status</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="order-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer"> <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>
@@ -105,6 +203,18 @@
</div> </div>
</div> </div>
<div id="confirm-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="alertdialog" aria-modal="true"
aria-labelledby="confirm-title" aria-describedby="confirm-message">
<h2 id="confirm-title">Remove item</h2>
<p id="confirm-message" class="modal-desc"></p>
<div class="modal-actions">
<button type="button" id="confirm-cancel" class="btn-secondary">Cancel</button>
<button type="button" id="confirm-ok" class="btn-danger">Remove</button>
</div>
</div>
</div>
<div id="auth-backdrop" class="modal-backdrop hidden"> <div id="auth-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title"> <div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<button class="modal-close" id="auth-close" aria-label="Close">&times;</button> <button class="modal-close" id="auth-close" aria-label="Close">&times;</button>

View File

@@ -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; }
@@ -132,6 +133,23 @@ body {
} }
.btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); } .btn-secondary:hover:not(:disabled) { color: var(--text); border-color: var(--accent); }
.btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; } .btn-secondary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger {
background: var(--danger);
color: #1b0505;
border: 1px solid var(--danger);
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
.btn-danger:hover { filter: brightness(1.1); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1.2rem;
}
/* Main */ /* Main */
main { main {
@@ -198,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; }
@@ -347,9 +366,11 @@ main {
} }
#inv-search { flex: 2 1 280px; } #inv-search { flex: 2 1 280px; }
#inv-category { flex: 1 1 180px; } #inv-category { flex: 1 1 180px; }
#inv-sort { flex: 1 1 170px; }
.inventory-controls input:focus, .inventory-controls input:focus,
.inventory-controls select:focus { outline: none; border-color: var(--accent); } .inventory-controls select:focus { outline: none; border-color: var(--accent); }
#inventory-view { #inventory-view,
#available-view {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem 1.25rem 0; padding: 1rem 1.25rem 0;
@@ -380,6 +401,21 @@ main {
} }
.inv-table tr:last-child td { border-bottom: none; } .inv-table tr:last-child td { border-bottom: none; }
.inv-item-name { font-weight: 600; } .inv-item-name { font-weight: 600; }
.inv-item-link {
display: block;
width: 100%;
background: none;
border: none;
padding: 0;
text-align: left;
color: inherit;
font: inherit;
cursor: pointer;
}
.inv-item-link:hover .inv-item-name { text-decoration: underline; }
.inv-item-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.inv-item-link.is-static { cursor: default; }
.inv-item-link.is-static:hover .inv-item-name { text-decoration: none; }
.inv-item-sub { .inv-item-sub {
color: var(--text-dim); color: var(--text-dim);
font-size: 0.75rem; font-size: 0.75rem;
@@ -393,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;
@@ -413,8 +459,61 @@ main {
cursor: pointer; cursor: pointer;
} }
.inv-remove:hover { border-color: var(--danger); } .inv-remove:hover { border-color: var(--danger); }
.visibility-toggle {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
border-radius: 999px;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
cursor: pointer;
white-space: nowrap;
}
.visibility-toggle:hover { border-color: var(--accent); color: var(--text); }
.visibility-toggle.is-available {
border-color: var(--success);
color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.visibility-toggle:disabled { opacity: 0.6; cursor: progress; }
.avail-user { font-weight: 600; }
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; } .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 {
@@ -467,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 {

View 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: []

View 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

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,77 +0,0 @@
"""Pydantic request/response models for auth and personal inventory."""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app import config
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=32)
password: str = Field(min_length=1, max_length=128)
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
created_at: datetime
class AuthResponse(BaseModel):
user: UserOut
class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
class InventoryEntryOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
# False when the catalog no longer contains the referenced item (e.g. after
# a dataset refresh). The row is preserved rather than silently deleted.
available: bool
created_at: datetime
updated_at: datetime
class InventoryPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[InventoryEntryOut]
class InventorySummary(BaseModel):
items: int
quantity: int
categories: dict[str, int]
unavailable: int
class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut
created: bool

View File

@@ -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())

View File

@@ -1,191 +0,0 @@
"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
def authed_client(username: str = "alice") -> TestClient:
client = TestClient(app)
resp = client.post(
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
)
assert resp.status_code == 201, resp.text
return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""):
return client.post(
"/api/v1/me/inventory",
json={"class_name": class_name, "quantity": quantity, "notes": notes},
)
def test_new_account_starts_with_empty_inventory():
client = authed_client("freshpilot")
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 0
assert listing["items"] == []
assert client.get("/api/v1/me/inventory/summary").json() == {
"items": 0,
"quantity": 0,
"categories": {},
"unavailable": 0,
}
def test_inventory_requires_authentication():
client = TestClient(app)
assert client.get("/api/v1/me/inventory").status_code == 401
assert add(client, CARROCK).status_code == 401
assert client.get("/api/v1/me/inventory/summary").status_code == 401
def test_add_unknown_catalog_item_is_not_found():
client = authed_client()
assert add(client, "NOPE_Does_Not_Exist").status_code == 404
def test_add_creates_enriched_entry():
client = authed_client()
resp = add(client, CARROCK)
assert resp.status_code == 201
body = resp.json()
assert body["created"] is True
entry = body["entry"]
assert entry["class_name"] == CARROCK
assert entry["name"] == "Anvil Carrack"
assert entry["category"] == "ships"
assert entry["quantity"] == 1
assert entry["available"] is True
assert entry["manufacturer"]["code"] == "ANVL"
def test_add_same_item_increments_quantity():
client = authed_client()
assert add(client, CARROCK, quantity=1).status_code == 201
resp = add(client, CARROCK, quantity=2, notes="pledged")
assert resp.status_code == 200
body = resp.json()
assert body["created"] is False
assert body["entry"]["quantity"] == 3
assert body["entry"]["notes"] == "pledged"
listing = client.get("/api/v1/me/inventory").json()
assert listing["total"] == 1
def test_inventories_are_isolated_between_users():
alice = authed_client("alice")
bob = authed_client("bob")
assert add(alice, CARROCK).status_code == 201
assert add(bob, RIFLE).status_code == 201
alice_items = alice.get("/api/v1/me/inventory").json()["items"]
bob_items = bob.get("/api/v1/me/inventory").json()["items"]
assert [i["class_name"] for i in alice_items] == [CARROCK]
assert [i["class_name"] for i in bob_items] == [RIFLE]
def test_patch_updates_quantity_and_notes():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"}
)
assert resp.status_code == 200
body = resp.json()
assert body["quantity"] == 4
assert body["notes"] == "hangar 3"
def test_patch_unknown_entry_is_not_found():
client = authed_client()
assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404
def test_patch_rejects_zero_quantity():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0})
assert resp.status_code == 422
def test_delete_removes_entry():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
assert client.get("/api/v1/me/inventory").json()["total"] == 0
assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404
def test_list_filters_by_category_and_search():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json()
assert ships["total"] == 1
assert ships["items"][0]["class_name"] == CARROCK
found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json()
assert found["total"] == 1
assert found["items"][0]["class_name"] == CARROCK
def test_list_pagination_envelope_and_cap():
client = authed_client()
assert add(client, CARROCK).status_code == 201
assert add(client, RIFLE).status_code == 201
body = client.get(
"/api/v1/me/inventory", params={"page_size": 1, "page": 2}
).json()
assert body["total"] == 2
assert body["page"] == 2
assert body["page_size"] == 1
assert body["total_pages"] == 2
assert len(body["items"]) == 1
capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json()
assert capped["page_size"] == 100
def test_summary_aggregates_quantity_and_categories():
client = authed_client()
assert add(client, CARROCK, quantity=2).status_code == 201
assert add(client, RIFLE, quantity=3).status_code == 201
summary = client.get("/api/v1/me/inventory/summary").json()
assert summary["items"] == 2
assert summary["quantity"] == 5
assert summary["unavailable"] == 0
assert summary["categories"]["ships"] == 2
def test_notes_length_is_bounded():
client = authed_client()
resp = add(client, CARROCK, notes="x" * 1001)
assert resp.status_code == 422
def test_catalog_is_not_mutated_by_inventory_operations():
client = authed_client()
before = client.get("/api/v1/items", params={"page_size": 100}).json()
assert add(client, CARROCK).status_code == 201
client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9})
client.delete(f"/api/v1/me/inventory/{CARROCK}")
after = client.get("/api/v1/items", params={"page_size": 100}).json()
assert after == before
carrack = client.get(f"/api/v1/items/{CARROCK}").json()
assert "quantity" not in carrack
assert "notes" not in carrack