updated commodities
This commit is contained in:
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],
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
130
trade-hub/backend/tests/test_available.py
Normal file
130
trade-hub/backend/tests/test_available.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""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"
|
||||||
|
|
||||||
|
|
||||||
|
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 = ""):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/me/inventory",
|
||||||
|
json={"class_name": class_name, "quantity": quantity, "notes": notes},
|
||||||
|
)
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user