order button
This commit is contained in:
209
trade-hub/backend/app/orders.py
Normal file
209
trade-hub/backend/app/orders.py
Normal 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)
|
||||||
176
trade-hub/backend/tests/test_orders.py
Normal file
176
trade-hub/backend/tests/test_orders.py
Normal 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
|
||||||
Reference in New Issue
Block a user