fixed remove issue, made items avaiable for available view

This commit is contained in:
2026-11-12 12:49:19 -07:00
parent 2807ffd160
commit 120daf8c31
10 changed files with 445 additions and 27 deletions

View File

@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app import config
from app.auth import router as auth_router
from app.available import router as available_router
from app.catalog import store
from app.db import db_healthy, init_db
from app.inventory import router as inventory_router
@@ -45,6 +46,7 @@ if config.CORS_ORIGINS:
app.include_router(auth_router)
app.include_router(inventory_router)
app.include_router(available_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}

View File

@@ -51,12 +51,46 @@ def get_db():
db.close()
def _upgrade_schema(engine) -> None:
"""Add columns that `create_all` cannot add to an existing table.
PostgreSQL only: the SQLite database used by the tests is created fresh
from the models on every run, so it already has every column.
"""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_visibility'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_visibility
CHECK (visibility IN ('private', 'available'));
END IF;
END $$
"""
)
)
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
"""Create missing tables, retrying while PostgreSQL finishes booting.
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
It does not migrate changed columns; introduce Alembic when the schema
starts evolving in place.
Additive columns for existing deployments are applied afterwards; changing
or dropping an existing column still requires Alembic.
"""
deadline = time.monotonic() + max(wait_seconds, 0)
while True:
@@ -65,6 +99,7 @@ def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine, checkfirst=True)
_upgrade_schema(engine)
return
except (OperationalError, IntegrityError, ProgrammingError) as exc:
# A concurrent startup may have created the tables first.

View File

@@ -33,7 +33,7 @@ router = APIRouter(
MAX_PAGE_SIZE = 100
def _enrich(entry: InventoryEntry) -> dict:
def enrich_entry(entry: InventoryEntry) -> dict:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
return {
@@ -46,6 +46,7 @@ def _enrich(entry: InventoryEntry) -> dict:
"manufacturer": None,
"quantity": entry.quantity,
"notes": entry.notes,
"visibility": entry.visibility,
"available": False,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
@@ -60,6 +61,7 @@ def _enrich(entry: InventoryEntry) -> dict:
"manufacturer": item["manufacturer"],
"quantity": entry.quantity,
"notes": entry.notes,
"visibility": entry.visibility,
"available": True,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
@@ -91,7 +93,7 @@ def _sort_key_updated(item):
return (-item["updated_at"].timestamp(), item["name"].lower())
_SORT_KEYS = {
SORT_KEYS = {
"name": _sort_key_name,
"category": _sort_key_category,
"quantity": _sort_key_quantity,
@@ -114,7 +116,7 @@ def list_inventory(
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
).scalars().all()
items = [_enrich(row) for row in rows]
items = [enrich_entry(row) for row in rows]
if category is not None and category.strip():
wanted = category.strip()
@@ -127,7 +129,7 @@ def list_inventory(
if needle in i["name"].lower() or needle in i["class_name"].lower()
]
items.sort(key=_SORT_KEYS[sort])
items.sort(key=SORT_KEYS[sort])
total = len(items)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
@@ -213,7 +215,7 @@ def add_to_inventory(
db.refresh(entry)
response.status_code = 201 if created else 200
return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created}
return {"entry": InventoryEntryOut.model_validate(enrich_entry(entry)), "created": created}
@router.patch("/{class_name}", response_model=InventoryEntryOut)
@@ -231,10 +233,12 @@ def update_inventory_entry(
entry.quantity = payload.quantity
if payload.notes is not None:
entry.notes = payload.notes
if payload.visibility is not None:
entry.visibility = payload.visibility
db.commit()
db.refresh(entry)
return InventoryEntryOut.model_validate(_enrich(entry))
return InventoryEntryOut.model_validate(enrich_entry(entry))
@router.delete("/{class_name}", status_code=204)

View File

@@ -74,6 +74,10 @@ class InventoryEntry(Base):
__table_args__ = (
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
CheckConstraint(
"visibility IN ('private', 'available')",
name="ck_inventory_visibility",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -83,6 +87,12 @@ class InventoryEntry(Base):
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
# "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.
visibility: Mapped[str] = mapped_column(
String(16), nullable=False, default="private", server_default="private"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=utcnow
)

View File

@@ -1,6 +1,6 @@
"""Pydantic request/response models for auth and personal inventory."""
from datetime import datetime
from typing import Any
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -38,6 +38,7 @@ class InventoryAddRequest(BaseModel):
class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
visibility: Literal["private", "available"] | None = None
class InventoryEntryOut(BaseModel):
@@ -50,6 +51,8 @@ class InventoryEntryOut(BaseModel):
manufacturer: dict | None
quantity: int
notes: str
# Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"]
# False when the catalog no longer contains the referenced item (e.g. after
# a dataset refresh). The row is preserved rather than silently deleted.
available: bool
@@ -72,6 +75,28 @@ class InventorySummary(BaseModel):
unavailable: int
class AvailableItemOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime
class AvailablePage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[AvailableItemOut]
class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut
created: bool