Compare commits
2 Commits
c8ac8e63ee
...
c8c5e658e2
| Author | SHA1 | Date | |
|---|---|---|---|
| c8c5e658e2 | |||
| 4fbad900a2 |
30
README.md
30
README.md
@@ -119,18 +119,29 @@ The inventory view can be filtered by category and searched by name/class
|
||||
name, and sorted by `name` (default), `category`, `quantity` (high to low) or
|
||||
`updated` (most recently changed first).
|
||||
|
||||
Inventory rows store `(user_id, class_name, quantity, notes, visibility)`.
|
||||
Adding an item that is already present increments its quantity; quantity, notes
|
||||
and visibility 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. If a catalog item disappears after a
|
||||
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, owner username and last update. Private rows
|
||||
item's catalog details, quantity, quality (when applicable), owner username and
|
||||
last update. Private rows
|
||||
never appear there and inventory notes are never exposed.
|
||||
|
||||
Clicking an item name in either **My Inventory** or **Available** opens the same
|
||||
@@ -188,7 +199,8 @@ so no PostgreSQL server is required for the suite. Production uses PostgreSQL.
|
||||
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)`).
|
||||
materials stay separate (`Iron` vs `Iron (Ore)`). Quality applies to both the
|
||||
refined and raw/ore entries and is user-recorded per inventory row.
|
||||
|
||||
Tables are created on startup with an idempotent `create_all` and a connection
|
||||
retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does
|
||||
@@ -212,8 +224,8 @@ GET /api/v1/auth/me -> {user}
|
||||
|
||||
GET /api/v1/me/inventory?category=&search=&sort=&page=&page_size=
|
||||
GET /api/v1/me/inventory/summary
|
||||
POST /api/v1/me/inventory {class_name, quantity?, notes?}
|
||||
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?}
|
||||
POST /api/v1/me/inventory {class_name, quantity?, notes?, quality?}
|
||||
PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?, visibility?, quality?}
|
||||
DELETE /api/v1/me/inventory/{class_name}
|
||||
|
||||
GET /api/v1/available?category=&search=&sort=&page=&page_size=
|
||||
|
||||
69
trade-hub/backend/app/available.py
Normal file
69
trade-hub/backend/app/available.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Public listing of inventory items owners marked as `available`.
|
||||
|
||||
Read-only and unauthenticated, like the catalog. Notes are never included: the
|
||||
listing only reveals the owner's username, quantity and catalog details.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.db import get_db
|
||||
from app.inventory import SORT_KEYS, enrich_entry
|
||||
from app.models import InventoryEntry, User
|
||||
from app.schemas import AvailablePage
|
||||
|
||||
router = APIRouter(prefix="/api/v1/available", tags=["available"])
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
@router.get("", response_model=AvailablePage)
|
||||
def list_available(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(InventoryEntry, User.username)
|
||||
.join(User, User.id == InventoryEntry.user_id)
|
||||
.where(InventoryEntry.visibility == "available")
|
||||
).all()
|
||||
|
||||
items = []
|
||||
for entry, username in rows:
|
||||
enriched = enrich_entry(entry)
|
||||
if not enriched["available"]:
|
||||
continue
|
||||
enriched["username"] = username
|
||||
items.append(enriched)
|
||||
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower()
|
||||
or needle in i["class_name"].lower()
|
||||
or needle in i["username"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=SORT_KEYS[sort])
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
@@ -55,3 +55,4 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
|
||||
# --- Input limits ---------------------------------------------------------
|
||||
MAX_NOTES_LENGTH = 1000
|
||||
MAX_QUANTITY = 1_000_000
|
||||
MAX_QUALITY = 1000
|
||||
|
||||
@@ -83,6 +83,29 @@ def _upgrade_schema(engine) -> None:
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
|
||||
"quality INTEGER"
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'ck_inventory_quality_range'
|
||||
) THEN
|
||||
ALTER TABLE inventory_entries
|
||||
ADD CONSTRAINT ck_inventory_quality_range
|
||||
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
|
||||
END IF;
|
||||
END $$
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||
|
||||
@@ -46,6 +46,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
|
||||
"manufacturer": None,
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": False,
|
||||
"visibility": entry.visibility,
|
||||
"available": False,
|
||||
"created_at": entry.created_at,
|
||||
@@ -61,6 +63,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"quality": entry.quality,
|
||||
"quality_applicable": item.get("quality_applicable", False),
|
||||
"visibility": entry.visibility,
|
||||
"available": True,
|
||||
"created_at": entry.created_at,
|
||||
@@ -68,6 +72,14 @@ def enrich_entry(entry: InventoryEntry) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _validate_quality(item: dict, quality: int | None) -> None:
|
||||
if quality is not None and not item.get("quality_applicable", False):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{item['name']}'",
|
||||
)
|
||||
|
||||
|
||||
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
||||
return db.execute(
|
||||
select(InventoryEntry).where(
|
||||
@@ -178,10 +190,12 @@ def add_to_inventory(
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
class_name = payload.class_name.strip()
|
||||
if class_name not in catalog.store.by_class_name:
|
||||
item = catalog.store.by_class_name.get(class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
|
||||
entry = _find(db, user, class_name)
|
||||
created = entry is None
|
||||
@@ -192,12 +206,15 @@ def add_to_inventory(
|
||||
class_name=class_name,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
quality=payload.quality,
|
||||
)
|
||||
db.add(entry)
|
||||
else:
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
@@ -210,6 +227,8 @@ def add_to_inventory(
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
if payload.quality is not None:
|
||||
entry.quality = payload.quality
|
||||
db.commit()
|
||||
created = False
|
||||
|
||||
@@ -235,6 +254,17 @@ def update_inventory_entry(
|
||||
entry.notes = payload.notes
|
||||
if payload.visibility is not None:
|
||||
entry.visibility = payload.visibility
|
||||
if "quality" in payload.model_fields_set:
|
||||
# Explicit null clears the value; omitted keeps the existing one.
|
||||
if payload.quality is not None:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Quality does not apply to '{entry.class_name}'",
|
||||
)
|
||||
_validate_quality(item, payload.quality)
|
||||
entry.quality = payload.quality
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
@@ -17,6 +17,7 @@ Real schemas this loader consumes:
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from app import config
|
||||
|
||||
@@ -27,6 +28,68 @@ CATEGORY_SHIP_ITEMS = "ship-items"
|
||||
CATEGORY_FPS_ITEMS = "fps-items"
|
||||
CATEGORY_COMMODITIES = "commodities"
|
||||
|
||||
# Commodities that can carry a mining/refining quality value (1-1000). These are
|
||||
# the ores, minerals and gems that players actually mine (and their refined
|
||||
# counterparts); crafted alloys, gases, organics, processed goods and junk
|
||||
# by-products are deliberately excluded. Keep in sync with the Ore quality table
|
||||
# at https://starcitizen.tools/Ore_quality.
|
||||
QUALITY_MATERIALS = frozenset(
|
||||
name.lower()
|
||||
for name in (
|
||||
"Agricium",
|
||||
"Aluminum",
|
||||
"Aphorite",
|
||||
"Aslarite",
|
||||
"Beradom",
|
||||
"Beryl",
|
||||
"Bexalite",
|
||||
"Borase",
|
||||
"Carinite",
|
||||
"Copper",
|
||||
"Corundum",
|
||||
"Diamond",
|
||||
"Dolivine",
|
||||
"Feynmaline",
|
||||
"Glacosite",
|
||||
"Gold",
|
||||
"Hadanite",
|
||||
"Hephaestanite",
|
||||
"Iron",
|
||||
"Jaclium",
|
||||
"Janalite",
|
||||
"Laranite",
|
||||
"Lindinium",
|
||||
"Ouratite",
|
||||
"Pressurized Ice",
|
||||
"Quantainium",
|
||||
"Quartz",
|
||||
"Riccite",
|
||||
"Sadaryx",
|
||||
"Saldynium",
|
||||
"Savrilium",
|
||||
"Silicon",
|
||||
"Stileron",
|
||||
"Taranite",
|
||||
"Tin",
|
||||
"Titanium",
|
||||
"Torite",
|
||||
"Tungsten",
|
||||
)
|
||||
)
|
||||
|
||||
# Variant suffixes are presentation labels, not a different material: "Iron
|
||||
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
|
||||
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
|
||||
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
|
||||
|
||||
|
||||
def _quality_applicable(name, category):
|
||||
if category != CATEGORY_COMMODITIES:
|
||||
return False
|
||||
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
|
||||
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
|
||||
return base_name in QUALITY_MATERIALS
|
||||
|
||||
|
||||
def _load_json(data_dir, filename):
|
||||
path = os.path.join(data_dir, filename)
|
||||
@@ -80,6 +143,7 @@ def _normalize_ship(ship):
|
||||
"classification": "Ship",
|
||||
"description": ship.get("Description") or ship.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
"quality_applicable": False,
|
||||
}, {
|
||||
"career": ship.get("Career") or "",
|
||||
"role": ship.get("Role") or "",
|
||||
@@ -111,6 +175,10 @@ def _normalize_item(item, category):
|
||||
"classification": item.get("classification") or "",
|
||||
"description": std.get("Description") or std.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
"quality_applicable": _quality_applicable(
|
||||
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
|
||||
category,
|
||||
),
|
||||
}
|
||||
if category == CATEGORY_COMMODITIES:
|
||||
summary["size"] = None
|
||||
|
||||
@@ -78,6 +78,10 @@ class InventoryEntry(Base):
|
||||
"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)
|
||||
@@ -87,6 +91,10 @@ class InventoryEntry(Base):
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
# Mining/refining quality (1-1000) for commodities where it applies. NULL
|
||||
# means "not specified"; non-quality commodities must keep this NULL. The
|
||||
# applicability itself is catalog data, not stored here.
|
||||
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# "private" (default): only the owner sees the row. "available": the owner
|
||||
# is offering the item. Unrelated to the response flag `available`, which
|
||||
# only reports whether the catalog still contains the referenced item.
|
||||
|
||||
@@ -33,12 +33,15 @@ 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):
|
||||
@@ -51,6 +54,10 @@ class InventoryEntryOut(BaseModel):
|
||||
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
|
||||
@@ -84,6 +91,8 @@ class AvailableItemOut(BaseModel):
|
||||
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
|
||||
|
||||
1332
trade-hub/backend/data/samples/commodities.json
Normal file
1332
trade-hub/backend/data/samples/commodities.json
Normal file
File diff suppressed because it is too large
Load Diff
30
trade-hub/backend/tests/fixtures/commodities.json
vendored
Normal file
30
trade-hub/backend/tests/fixtures/commodities.json
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials",
|
||||
"name": "Construction Materials",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "5ee15a9b-4ce5-445b-a02d-415c63f43764"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron",
|
||||
"name": "Iron",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "332096b9-1cbf-436d-b8b0-5ed422c23da9"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron",
|
||||
"name": "Iron (Ore)",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "271effd5-3aa8-4538-b9a0-b0f33dc86d68"
|
||||
},
|
||||
{
|
||||
"className": "Carryable_TBO_FL_1SCU_Commodity_Metal_RMC",
|
||||
"name": "Recycled Material Composite",
|
||||
"type": "Cargo",
|
||||
"subType": "Cargo",
|
||||
"reference": "2e50dde5-30d4-4faa-af8f-8e5aa8004b96"
|
||||
}
|
||||
]
|
||||
156
trade-hub/backend/tests/test_available.py
Normal file
156
trade-hub/backend/tests/test_available.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Public available-items listing: open access, owner attribution, no notes."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import app
|
||||
|
||||
PASSWORD = "correct horse battery staple"
|
||||
CARROCK = "ANVL_Carrack"
|
||||
RIFLE = "BEHR_Rifle_Ballistic_01"
|
||||
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
|
||||
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
|
||||
|
||||
|
||||
def authed_client(username: str) -> TestClient:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register", json={"username": username, "password": PASSWORD}
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return client
|
||||
|
||||
|
||||
def add(
|
||||
client: TestClient,
|
||||
class_name: str,
|
||||
quantity: int = 1,
|
||||
notes: str = "",
|
||||
quality: int | None = None,
|
||||
):
|
||||
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
|
||||
if quality is not None:
|
||||
payload["quality"] = quality
|
||||
resp = client.post("/api/v1/me/inventory", json=payload)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
return resp
|
||||
|
||||
|
||||
def offer(client: TestClient, class_name: str, quantity: int = 1):
|
||||
add(client, class_name, quantity)
|
||||
resp = client.patch(
|
||||
f"/api/v1/me/inventory/{class_name}", json={"visibility": "available"}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def public_items(**params):
|
||||
return TestClient(app).get("/api/v1/available", params=params).json()
|
||||
|
||||
|
||||
def test_listing_is_public_and_empty_by_default():
|
||||
body = public_items()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
assert body["page"] == 1
|
||||
|
||||
|
||||
def test_private_items_are_not_listed_until_marked_available():
|
||||
alice = authed_client("alice")
|
||||
add(alice, CARROCK)
|
||||
assert public_items()["total"] == 0
|
||||
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "available"})
|
||||
body = public_items()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["class_name"] == CARROCK
|
||||
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"visibility": "private"})
|
||||
assert public_items()["total"] == 0
|
||||
|
||||
|
||||
def test_listing_shows_owner_and_catalog_details_but_not_notes():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CARROCK, quantity=3)
|
||||
alice.patch(f"/api/v1/me/inventory/{CARROCK}", json={"notes": "hidden stash"})
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["username"] == "alice"
|
||||
assert item["name"] == "Anvil Carrack"
|
||||
assert item["category"] == "ships"
|
||||
assert item["manufacturer"]["code"] == "ANVL"
|
||||
assert item["quantity"] == 3
|
||||
assert "notes" not in item
|
||||
assert "hidden stash" not in str(public_items())
|
||||
|
||||
|
||||
def test_each_owner_appears_as_its_own_offer():
|
||||
offer(authed_client("alice"), CARROCK, quantity=1)
|
||||
offer(authed_client("bob"), CARROCK, quantity=2)
|
||||
|
||||
items = public_items(sort="quantity")["items"]
|
||||
assert [(i["username"], i["quantity"], i["class_name"]) for i in items] == [
|
||||
("bob", 2, CARROCK),
|
||||
("alice", 1, CARROCK),
|
||||
]
|
||||
|
||||
|
||||
def test_removing_an_offer_removes_it_from_the_listing():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CARROCK)
|
||||
assert public_items()["total"] == 1
|
||||
|
||||
assert alice.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204
|
||||
assert public_items()["total"] == 0
|
||||
assert public_items()["items"] == []
|
||||
|
||||
|
||||
def test_listing_filters_by_category_and_searches_owner_or_item():
|
||||
offer(authed_client("alice"), CARROCK)
|
||||
offer(authed_client("bob"), RIFLE)
|
||||
|
||||
assert [i["class_name"] for i in public_items(category="ships")["items"]] == [CARROCK]
|
||||
assert [i["username"] for i in public_items(search="bob")["items"]] == ["bob"]
|
||||
assert [i["class_name"] for i in public_items(search="carrack")["items"]] == [CARROCK]
|
||||
|
||||
|
||||
def test_listing_paginates_and_rejects_unknown_sort():
|
||||
for quantity, class_name in ((1, CARROCK), (2, RIFLE)):
|
||||
offer(authed_client(f"pilot{quantity}"), class_name, quantity=quantity)
|
||||
|
||||
body = public_items(page=2, page_size=1)
|
||||
assert body["total"] == 2
|
||||
assert body["total_pages"] == 2
|
||||
assert len(body["items"]) == 1
|
||||
|
||||
assert public_items(page_size=500)["page_size"] == 100
|
||||
assert TestClient(app).get("/api/v1/available?sort=bogus").status_code == 422
|
||||
|
||||
|
||||
def test_commodity_offers_are_listed_with_owner():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, IRON_ORE, quantity=12)
|
||||
body = public_items()
|
||||
assert body["total"] == 1
|
||||
item = body["items"][0]
|
||||
assert item["name"] == "Iron (Ore)"
|
||||
assert item["category"] == "commodities"
|
||||
assert item["quantity"] == 12
|
||||
assert item["username"] == "alice"
|
||||
|
||||
|
||||
def test_commodity_quality_is_listed_publicly():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, IRON_ORE, quantity=12)
|
||||
add(alice, IRON_ORE, quality=875)
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["quality"] == 875
|
||||
assert item["quality_applicable"] is True
|
||||
|
||||
|
||||
def test_non_quality_commodity_is_listed_without_quality():
|
||||
alice = authed_client("alice")
|
||||
offer(alice, CONSTRUCTION)
|
||||
|
||||
item = public_items()["items"][0]
|
||||
assert item["quality"] is None
|
||||
assert item["quality_applicable"] is False
|
||||
@@ -7,6 +7,8 @@ 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:
|
||||
@@ -18,11 +20,17 @@ def authed_client(username: str = "alice") -> TestClient:
|
||||
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 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():
|
||||
@@ -264,3 +272,85 @@ def test_add_commodity_uses_catalog_name_and_quantity():
|
||||
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
|
||||
|
||||
@@ -361,6 +361,10 @@ async function openModal(class_name) {
|
||||
<div class="add-row">
|
||||
<label for="add-qty">Qty</label>
|
||||
<input type="number" id="add-qty" min="1" max="1000000" value="1">
|
||||
${item.quality_applicable ? `
|
||||
<label for="add-quality">Quality</label>
|
||||
<input type="number" id="add-quality" min="1" max="1000" placeholder="—">
|
||||
` : ""}
|
||||
<button type="button" id="add-to-inventory" class="btn-primary">
|
||||
${state.user ? "Add to my inventory" : "Log in to add"}
|
||||
</button>
|
||||
@@ -368,7 +372,16 @@ async function openModal(class_name) {
|
||||
`;
|
||||
document.getElementById("add-to-inventory").addEventListener("click", () => {
|
||||
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
|
||||
addToInventory(item.class_name, qty);
|
||||
let quality = null;
|
||||
const qualityInput = document.getElementById("add-quality");
|
||||
if (qualityInput && qualityInput.value.trim() !== "") {
|
||||
quality = Number(qualityInput.value);
|
||||
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
|
||||
showToast("Quality must be between 1 and 1000", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
addToInventory(item.class_name, qty, quality);
|
||||
});
|
||||
} catch (err) {
|
||||
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
|
||||
@@ -503,6 +516,48 @@ function itemNameCell(entry) {
|
||||
return cell;
|
||||
}
|
||||
|
||||
function qualityCell(entry, editable) {
|
||||
const cell = document.createElement("td");
|
||||
if (!entry.quality_applicable) {
|
||||
// Keep a recorded quality visible even if the catalog item disappeared.
|
||||
cell.innerHTML =
|
||||
entry.quality == null
|
||||
? '<span class="inv-quality-na">—</span>'
|
||||
: escapeHtml(String(entry.quality));
|
||||
return cell;
|
||||
}
|
||||
if (!editable) {
|
||||
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
|
||||
return cell;
|
||||
}
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.className = "inv-quality";
|
||||
input.min = "1";
|
||||
input.max = "1000";
|
||||
input.placeholder = "—";
|
||||
input.value = entry.quality == null ? "" : String(entry.quality);
|
||||
input.setAttribute("aria-label", `Quality of ${entry.name}`);
|
||||
input.addEventListener("change", () => {
|
||||
const raw = input.value.trim();
|
||||
if (raw === "") {
|
||||
entry.quality = null;
|
||||
patchEntry(entry.class_name, { quality: null });
|
||||
return;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < 1 || value > 1000) {
|
||||
input.value = entry.quality == null ? "" : String(entry.quality);
|
||||
showToast("Quality must be between 1 and 1000", true);
|
||||
return;
|
||||
}
|
||||
entry.quality = value;
|
||||
patchEntry(entry.class_name, { quality: value });
|
||||
});
|
||||
cell.appendChild(input);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function renderInventory(listing) {
|
||||
const tbody = els["inv-tbody"];
|
||||
tbody.innerHTML = "";
|
||||
@@ -567,7 +622,15 @@ function renderInventory(listing) {
|
||||
visibilityCell.appendChild(visibility);
|
||||
applyVisibility(visibility, entry.visibility);
|
||||
|
||||
row.append(nameCell, categoryCell, qtyCell, notesCell, visibilityCell, actionCell);
|
||||
row.append(
|
||||
nameCell,
|
||||
categoryCell,
|
||||
qtyCell,
|
||||
qualityCell(entry, true),
|
||||
notesCell,
|
||||
visibilityCell,
|
||||
actionCell
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
@@ -633,15 +696,17 @@ async function deleteEntry(class_name, name) {
|
||||
}
|
||||
}
|
||||
|
||||
async function addToInventory(class_name, quantity = 1) {
|
||||
async function addToInventory(class_name, quantity = 1, quality = null) {
|
||||
if (!state.user) {
|
||||
openAuth("login", "Log in to add items to your inventory.");
|
||||
return;
|
||||
}
|
||||
const body = { class_name, quantity };
|
||||
if (quality !== null) body.quality = quality;
|
||||
try {
|
||||
const data = await apiFetch("/api/v1/me/inventory", {
|
||||
method: "POST",
|
||||
body: { class_name, quantity },
|
||||
body,
|
||||
});
|
||||
showToast(
|
||||
data.created
|
||||
@@ -718,7 +783,14 @@ function renderAvailable(listing) {
|
||||
updatedCell.className = "avail-updated";
|
||||
updatedCell.textContent = formatDate(entry.updated_at);
|
||||
|
||||
row.append(nameCell, categoryCell, qtyCell, userCell, updatedCell);
|
||||
row.append(
|
||||
nameCell,
|
||||
categoryCell,
|
||||
qtyCell,
|
||||
qualityCell(entry, false),
|
||||
userCell,
|
||||
updatedCell
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Notes</th>
|
||||
<th>Visibility</th>
|
||||
<th></th>
|
||||
@@ -129,6 +130,7 @@
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Qty</th>
|
||||
<th>Quality</th>
|
||||
<th>Offered by</th>
|
||||
<th>Updated</th>
|
||||
</tr>
|
||||
|
||||
@@ -429,6 +429,16 @@ main {
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-quality {
|
||||
width: 5rem;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
.inv-quality-na { color: var(--text-dim); }
|
||||
.inv-quality:focus { outline: none; border-color: var(--accent); }
|
||||
.inv-notes {
|
||||
width: 100%;
|
||||
min-width: 12rem;
|
||||
|
||||
Reference in New Issue
Block a user