order button

This commit is contained in:
boxswapper
2026-11-12 19:41:50 -07:00
parent 3be92f55fb
commit 632aee934f
2 changed files with 385 additions and 0 deletions

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

@@ -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