From 632aee934f8c99239be5db2e867d38a3e1498e81 Mon Sep 17 00:00:00 2001 From: boxswapper Date: Thu, 12 Nov 2026 19:41:50 -0700 Subject: [PATCH] order button --- trade-hub/backend/app/orders.py | 209 +++++++++++++++++++++++++ trade-hub/backend/tests/test_orders.py | 176 +++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 trade-hub/backend/app/orders.py create mode 100644 trade-hub/backend/tests/test_orders.py diff --git a/trade-hub/backend/app/orders.py b/trade-hub/backend/app/orders.py new file mode 100644 index 0000000..6a3b20a --- /dev/null +++ b/trade-hub/backend/app/orders.py @@ -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) diff --git a/trade-hub/backend/tests/test_orders.py b/trade-hub/backend/tests/test_orders.py new file mode 100644 index 0000000..b133771 --- /dev/null +++ b/trade-hub/backend/tests/test_orders.py @@ -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