Compare commits

..

4 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
47 changed files with 1281 additions and 146 deletions

View File

@@ -9,10 +9,12 @@ RMC, construction materials, …) are browsable with search, manufacturer / type
size filters, pagination, and a detail view per item. 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
@@ -31,18 +33,35 @@ 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
``` ```
@@ -58,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
@@ -71,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)
@@ -144,8 +170,18 @@ item's catalog details, quantity, quality (when applicable), owner username and
last update. Private rows last update. Private rows
never appear there and inventory notes are never exposed. never appear there and inventory notes are never exposed.
Clicking an item name in either **My Inventory** or **Available** opens the same The **Orders** tab (backed by `GET /api/v1/orders`) is also public. A logged-in
catalog detail view used by the catalog grid. user clicks any item to open its detail view, then fills in the **Request this
item** form there with a quantity, optional notes, and — for mineable
commodities — an optional quality. Setting up the order publishes the request
on the public board.
Orders show the item, quantity, quality, notes, requesting username, status
(`open`/`fulfilled`) and last update. Notes on orders are public by design;
inventory notes remain private. Only the owner can edit, mark fulfilled/reopen,
or remove their own order.
Clicking an item name in **My Inventory**, **Available** or **Orders** opens the
same catalog detail view used by the catalog grid.
### Security: read before exposing this publicly ### Security: read before exposing this publicly
@@ -153,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
@@ -183,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 # 65 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
``` ```
@@ -229,12 +266,19 @@ PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, qual
DELETE /api/v1/me/inventory/{class_name} DELETE /api/v1/me/inventory/{class_name}
GET /api/v1/available?category=&search=&sort=&page=&page_size= GET /api/v1/available?category=&search=&sort=&page=&page_size=
GET /api/v1/orders?category=&search=&status=&sort=&page=&page_size=
POST /api/v1/orders {class_name, quantity?, notes?, quality?}
PATCH /api/v1/orders/{id} {quantity?, notes?, status?, quality?}
DELETE /api/v1/orders/{id}
``` ```
`/api/v1/me/*` requires a valid session cookie and returns `401` otherwise. `/api/v1/me/*` requires a valid session cookie and returns `401` otherwise.
`/api/v1/available` is public and read-only; it accepts `sort=name|category|quantity|updated` `/api/v1/available` and `GET /api/v1/orders` are public and read-only. Both
(default `updated`). Catalog `category` values are `ships`, `ship-items`, accept `sort=name|category|quantity|updated` (default `updated`); orders also
`fps-items` and `commodities`. 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,126 +1,23 @@
--- ---
# Example play that exercises the trade_hub role against the local inventory.
# To use the role in an existing playbook, copy roles/trade_hub into that
# repository's roles/ directory and add:
#
# roles:
# - role: trade_hub
# tags: [trade_hub]
#
- name: Deploy Star Citizen Inventory (trade-hub) - name: Deploy Star Citizen Inventory (trade-hub)
hosts: webservers hosts: webservers
vars: vars:
backend_image: sc-inventory-backend # The standalone play used to install podman; in a larger playbook the
frontend_image: sc-inventory-frontend # "packages" role normally provides it instead.
postgres_image: docker.io/library/postgres:16-alpine trade_hub_install_podman: true
image_tag: latest trade_hub_postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
network_name: sc-inventory trade_hub_cookie_secure: "false"
frontend_port: "8080:80" trade_hub_force_rebuild: false
postgres_volume: sc-inventory-data
postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}"
database_url: "postgresql+psycopg://sc_inventory:{{ postgres_password }}@db:5432/sc_inventory"
# Set to true once the site is served over TLS.
cookie_secure: "false"
# Set to true to force image rebuilds even when the images already exist
force_rebuild: false
tasks: roles:
- name: Check whether podman is installed - role: trade_hub
ansible.builtin.command: podman --version tags: [trade_hub]
register: podman_check
failed_when: false
changed_when: false
- name: Install podman if missing
become: true
ansible.builtin.package:
name: podman
state: present
when: podman_check.rc != 0
- name: Remove legacy Apache container if present
containers.podman.podman_container:
name: my-apache-server
state: absent
- name: Check if backend image exists
ansible.builtin.command: podman image exists {{ backend_image }}:{{ image_tag }}
register: backend_image_check
failed_when: false
changed_when: false
- name: Check if frontend image exists
ansible.builtin.command: podman image exists {{ frontend_image }}:{{ image_tag }}
register: frontend_image_check
failed_when: false
changed_when: false
- name: Build backend image
ansible.builtin.command: >
podman build -t {{ backend_image }}:{{ image_tag }}
{{ playbook_dir }}/trade-hub/backend
when: backend_image_check.rc != 0 or force_rebuild | bool
- name: Build frontend image
ansible.builtin.command: >
podman build -t {{ frontend_image }}:{{ image_tag }}
{{ playbook_dir }}/trade-hub/frontend
when: frontend_image_check.rc != 0 or force_rebuild | bool
- name: Create podman network
containers.podman.podman_network:
name: "{{ network_name }}"
state: present
- name: Create PostgreSQL data volume
containers.podman.podman_volume:
name: "{{ postgres_volume }}"
state: present
- name: Run PostgreSQL container (internal only)
containers.podman.podman_container:
name: sc-inventory-db
image: "{{ postgres_image }}"
state: started
network: "{{ network_name }}"
network_aliases:
- db
env:
POSTGRES_USER: sc_inventory
POSTGRES_PASSWORD: "{{ postgres_password }}"
POSTGRES_DB: sc_inventory
volumes:
- "{{ postgres_volume }}:/var/lib/postgresql/data:Z"
restart_policy: unless-stopped
- name: Run backend container (internal only)
containers.podman.podman_container:
name: sc-inventory-backend
image: "{{ backend_image }}:{{ image_tag }}"
state: started
# podman does not recreate a container when its image tag resolves to
# a new ID, so without this a rebuilt image would be ignored.
recreate: true
network: "{{ network_name }}"
# Network alias "backend" matches the compose service name, so the
# nginx upstream (http://backend:8000) resolves in both deployments.
network_aliases:
- backend
env:
DATABASE_URL: "{{ database_url }}"
DB_WAIT_SECONDS: "60"
COOKIE_SECURE: "{{ cookie_secure }}"
restart_policy: unless-stopped
- name: Run frontend container (published port)
containers.podman.podman_container:
name: sc-inventory-frontend
image: "{{ frontend_image }}:{{ image_tag }}"
state: started
recreate: true
network: "{{ network_name }}"
ports:
- "{{ frontend_port }}"
restart_policy: unless-stopped
- name: Verify the stack responds
ansible.builtin.uri:
url: http://localhost:8080/healthz
status_code: 200
register: health_result
until: health_result.status == 200
retries: 8
delay: 10

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

@@ -15,6 +15,7 @@ from app.available import router as available_router
from app.catalog import store from app.catalog import store
from app.db import db_healthy, init_db from app.db import db_healthy, init_db
from app.inventory import router as inventory_router from app.inventory import router as inventory_router
from app.orders import router as orders_router
@asynccontextmanager @asynccontextmanager
@@ -47,6 +48,7 @@ if config.CORS_ORIGINS:
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(inventory_router) app.include_router(inventory_router)
app.include_router(available_router) app.include_router(available_router)
app.include_router(orders_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"} VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}

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):
@@ -109,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

@@ -109,3 +109,48 @@ class AvailablePage(BaseModel):
class InventoryAddResponse(BaseModel): class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut entry: InventoryEntryOut
created: bool created: bool
class OrderCreateRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
status: Literal["open", "fulfilled"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderOut(BaseModel):
id: int
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
notes: str
quality: int | None = None
quality_applicable: bool = False
# Public listing: the requesting user's display name.
username: str
status: Literal["open", "fulfilled"]
# False when the catalog no longer contains the referenced item.
available: bool
created_at: datetime
updated_at: datetime
class OrderPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[OrderOut]

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

@@ -19,10 +19,12 @@ const state = {
user: null, user: null,
inventory: { search: "", category: "", sort: "name" }, inventory: { search: "", category: "", sort: "name" },
available: { search: "", category: "", sort: "updated" }, available: { search: "", category: "", sort: "updated" },
orders: { search: "", category: "", status: "", sort: "updated" },
}; };
let authMode = "login"; let authMode = "login";
let pendingView = null; let pendingView = null;
let currentDetailClass = null;
const els = {}; const els = {};
@@ -38,6 +40,8 @@ function cacheElements() {
"available-view", "avail-search", "avail-category", "avail-sort", "available-view", "avail-search", "avail-category", "avail-sort",
"avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody", "avail-summary", "avail-empty", "avail-table-wrap", "avail-tbody",
"tab-available", "tab-available",
"orders-view", "order-search", "order-category", "order-status", "order-sort",
"order-summary", "order-empty", "order-table-wrap", "order-tbody", "tab-orders",
"auth-backdrop", "auth-close", "auth-title", "auth-form", "auth-backdrop", "auth-close", "auth-title", "auth-form",
"auth-username", "auth-password", "auth-error", "auth-submit", "auth-username", "auth-password", "auth-error", "auth-submit",
"auth-toggle", "toast", "auth-toggle", "toast",
@@ -198,6 +202,11 @@ async function submitAuth(event) {
pendingView = null; pendingView = null;
switchToMine(); switchToMine();
} }
// The detail view was opened while logged out: re-render it so the
// order form becomes available in place.
if (currentDetailClass && !els["modal-backdrop"].classList.contains("hidden")) {
openModal(currentDetailClass);
}
} catch (err) { } catch (err) {
els["auth-error"].textContent = err.message; els["auth-error"].textContent = err.message;
} finally { } finally {
@@ -222,6 +231,8 @@ async function logout() {
state.category = ""; state.category = "";
loadItems(); loadItems();
} }
// Orders stay public, but the owner-only action buttons must disappear.
if (state.view === "orders") loadOrders();
} }
/* ---------- catalog list ---------- */ /* ---------- catalog list ---------- */
@@ -338,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 {
@@ -369,6 +381,27 @@ async function openModal(class_name) {
${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);
@@ -383,12 +416,27 @@ async function openModal(class_name) {
} }
addToInventory(item.class_name, qty, quality); 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");
} }
@@ -424,6 +472,7 @@ async function switchToMine() {
state.view = "mine"; state.view = "mine";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["available-view"].classList.add("hidden"); els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["inventory-view"].classList.remove("hidden"); els["inventory-view"].classList.remove("hidden");
await loadInventory(); await loadInventory();
} }
@@ -432,6 +481,7 @@ function showCatalogView() {
state.view = "catalog"; state.view = "catalog";
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["available-view"].classList.add("hidden"); els["available-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["catalog-view"].classList.remove("hidden"); els["catalog-view"].classList.remove("hidden");
} }
@@ -727,6 +777,7 @@ async function switchToAvailable() {
state.view = "available"; state.view = "available";
els["catalog-view"].classList.add("hidden"); els["catalog-view"].classList.add("hidden");
els["inventory-view"].classList.add("hidden"); els["inventory-view"].classList.add("hidden");
els["orders-view"].classList.add("hidden");
els["available-view"].classList.remove("hidden"); els["available-view"].classList.remove("hidden");
await loadAvailable(); await loadAvailable();
} }
@@ -795,6 +846,190 @@ function renderAvailable(listing) {
} }
} }
/* ---------- 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) {
@@ -827,6 +1062,10 @@ function bindEvents() {
switchToAvailable(); switchToAvailable();
return; return;
} }
if (tab.dataset.view === "orders") {
switchToOrders();
return;
}
setActiveTab(tab); setActiveTab(tab);
showCatalogView(); showCatalogView();
state.category = tab.dataset.category; state.category = tab.dataset.category;
@@ -949,6 +1188,28 @@ function bindAvailableEvents() {
}); });
} }
function bindOrderEvents() {
els["order-search"].addEventListener(
"input",
debounce(() => {
state.orders.search = els["order-search"].value.trim();
loadOrders();
}, 300)
);
els["order-category"].addEventListener("change", () => {
state.orders.category = els["order-category"].value;
loadOrders();
});
els["order-status"].addEventListener("change", () => {
state.orders.status = els["order-status"].value;
loadOrders();
});
els["order-sort"].addEventListener("change", () => {
state.orders.sort = els["order-sort"].value;
loadOrders();
});
}
/* ---------- boot ---------- */ /* ---------- boot ---------- */
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
@@ -957,6 +1218,7 @@ document.addEventListener("DOMContentLoaded", async () => {
bindAuthEvents(); bindAuthEvents();
bindInventoryEvents(); bindInventoryEvents();
bindAvailableEvents(); bindAvailableEvents();
bindOrderEvents();
initHeader(); initHeader();
await initAuth(); await initAuth();
loadItems(); loadItems();

View File

@@ -31,6 +31,7 @@
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button> <button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button> <button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button> <button class="tab" id="tab-available" data-view="available">Available</button>
<button class="tab" id="tab-orders" data-view="orders">Orders</button>
</nav> </nav>
<div id="catalog-view"> <div id="catalog-view">
@@ -140,6 +141,53 @@
</div> </div>
</section> </section>
<section id="orders-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="order-search" placeholder="Search item or pilot…" autocomplete="off">
<select id="order-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="order-status" aria-label="Filter by order status">
<option value="">All statuses</option>
<option value="open">Open</option>
<option value="fulfilled">Fulfilled</option>
</select>
<select id="order-sort" aria-label="Sort orders">
<option value="updated">Sort: Recently updated</option>
<option value="name">Sort: Name</option>
<option value="category">Sort: Category</option>
<option value="quantity">Sort: Quantity</option>
</select>
</div>
<div id="order-summary" class="inv-summary"></div>
<div id="order-empty" class="empty-state hidden">
<p>No orders yet.</p>
<p class="hint">Log in, open any catalog item and use “Request this item” to set it up here.</p>
</div>
<div id="order-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Requested by</th>
<th>Status</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody id="order-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer"> <footer class="site-footer">
<p>Data source: <p>Data source:
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a> <a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>

View File

@@ -480,6 +480,40 @@ main {
.avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; } .avail-updated { color: var(--text-dim); font-size: 0.8rem; white-space: nowrap; }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; } .unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Orders board */
.order-notes {
color: var(--text-dim);
font-size: 0.82rem;
min-width: 10rem;
max-width: 18rem;
white-space: normal;
}
.order-status {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--text-dim);
white-space: nowrap;
}
.order-status.open {
color: var(--accent);
border-color: var(--accent);
}
.order-status.fulfilled {
color: var(--success);
border-color: var(--success);
background: rgba(34, 197, 94, 0.12);
}
.order-actions {
display: flex;
gap: 0.35rem;
white-space: nowrap;
}
/* Auth modal */ /* Auth modal */
.modal-narrow { max-width: 400px; } .modal-narrow { max-width: 400px; }
.auth-form { .auth-form {
@@ -532,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