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