step 2 with basic crud implemented
This commit is contained in:
@@ -1,15 +1,71 @@
|
||||
# Moving Helper Scaffold
|
||||
# Moving Helper
|
||||
|
||||
这是一个面向可信家庭内网环境的小型工具项目,目前阶段只完成了基础脚手架:
|
||||
这是一个面向可信家庭内网环境的小型搬家记录工具,当前采用轻量技术栈:
|
||||
|
||||
- FastAPI
|
||||
- Jinja2
|
||||
- Jinja2 服务端渲染
|
||||
- SQLAlchemy
|
||||
- SQLite
|
||||
- pytest / FastAPI TestClient
|
||||
- Docker / Docker Compose
|
||||
|
||||
当前还不是完整业务应用,现阶段重点是把运行方式、工程结构、配置和基础测试打稳,后续再继续补 CRUD、图片、搜索等能力。
|
||||
项目目标是小而稳、容易继续扩展。目前已经从“纯脚手架”进入第二阶段,支持固定三层的数据结构和基础 CRUD,但还没有开始做图片、搜索和复杂界面。
|
||||
|
||||
## 当前数据模型
|
||||
|
||||
这个项目不是无限树结构,而是固定最多 3 级:
|
||||
|
||||
- `Box`
|
||||
- `Item`
|
||||
- `SubItem`
|
||||
|
||||
关系如下:
|
||||
|
||||
- 一个 `Box` 包含多个 `Item`
|
||||
- 一个 `Item` 属于一个 `Box`
|
||||
- `Item` 通过 `is_container` 区分是否为“小容器”
|
||||
- 只有 `is_container = true` 的 `Item` 才允许拥有 `SubItem`
|
||||
- `SubItem` 是最后一级,不允许继续向下嵌套
|
||||
|
||||
换句话说,结构固定为:
|
||||
|
||||
```text
|
||||
Box
|
||||
└── Item
|
||||
└── SubItem
|
||||
```
|
||||
|
||||
## 当前已支持
|
||||
|
||||
目前已支持的基础能力:
|
||||
|
||||
- Box 列表、详情、新建、编辑、删除
|
||||
- Item 新建、详情、编辑、删除
|
||||
- SubItem 新建、编辑、删除
|
||||
- `/` 重定向到 `/boxes`
|
||||
- Jinja2 模板渲染
|
||||
- 静态文件挂载
|
||||
- SQLite 持久化
|
||||
- Docker 长期运行
|
||||
- 基础自动化测试
|
||||
|
||||
删除规则:
|
||||
|
||||
- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem`
|
||||
- 删除容器型 `Item` 时,会级联删除其下 `SubItem`
|
||||
|
||||
## 当前未实现
|
||||
|
||||
这一阶段仍然没有实现以下内容:
|
||||
|
||||
- 图片上传
|
||||
- 图片字段
|
||||
- 图片处理
|
||||
- 搜索
|
||||
- 登录 / 鉴权
|
||||
- 标签系统
|
||||
- 前后端分离
|
||||
- 复杂 UI
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -25,32 +81,23 @@
|
||||
│ │ └── style.css
|
||||
│ └── templates
|
||||
│ ├── base.html
|
||||
│ └── boxes.html
|
||||
│ ├── boxes
|
||||
│ ├── items
|
||||
│ └── subitems
|
||||
├── data
|
||||
├── tests
|
||||
│ ├── conftest.py
|
||||
│ └── test_app.py
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── pytest.ini
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
└── .gitignore
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 当前已接好的基础能力
|
||||
|
||||
- FastAPI 应用入口:`app/main.py`
|
||||
- Jinja2 模板渲染
|
||||
- 静态文件挂载:`/static`
|
||||
- `/` 自动重定向到 `/boxes`
|
||||
- SQLite 数据库连接
|
||||
- SQLAlchemy Base / Session
|
||||
- 启动时自动建表
|
||||
- 基础自动化测试
|
||||
|
||||
## 轻量配置
|
||||
|
||||
项目通过环境变量支持下面几个配置项:
|
||||
项目通过环境变量支持以下配置项:
|
||||
|
||||
- `DATABASE_URL`
|
||||
- `HOST`
|
||||
@@ -62,8 +109,6 @@
|
||||
- `HOST=0.0.0.0`
|
||||
- `PORT=10000`
|
||||
|
||||
本地开发和 Docker 运行都可以直接使用这些默认值,也可以按需覆盖。
|
||||
|
||||
## 本地开发模式
|
||||
|
||||
推荐使用本地 Python `venv` 开发和调试。
|
||||
@@ -93,23 +138,17 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
|
||||
http://localhost:10000
|
||||
```
|
||||
|
||||
本地开发默认会使用:
|
||||
本地默认数据库位置:
|
||||
|
||||
```text
|
||||
./data/app.db
|
||||
```
|
||||
|
||||
如果你想临时改端口或数据库路径,也可以这样运行:
|
||||
|
||||
```bash
|
||||
PORT=10000 DATABASE_URL=sqlite:///./data/app.db uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
|
||||
```
|
||||
|
||||
## Docker 部署模式
|
||||
|
||||
Docker / Compose 是这个项目面向长期运行环境的方式。
|
||||
|
||||
### 启动
|
||||
启动:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
@@ -121,65 +160,34 @@ docker compose up --build
|
||||
http://localhost:10000
|
||||
```
|
||||
|
||||
### 运行说明
|
||||
说明:
|
||||
|
||||
- 默认暴露 `10000` 端口
|
||||
- `restart: unless-stopped`,适合长期运行
|
||||
- 容器内应用以用户 `1000:1000` 运行
|
||||
- SQLite 数据文件保存在宿主机 `./data/app.db`
|
||||
- 容器重建不会丢失数据库数据
|
||||
- `restart: unless-stopped`
|
||||
- 容器使用 `1000:1000` 运行
|
||||
- SQLite 文件持久化到宿主机 `./data/app.db`
|
||||
- 容器重建不会丢失数据
|
||||
|
||||
### 数据持久化与备份
|
||||
|
||||
`docker-compose.yml` 会将宿主机目录:
|
||||
|
||||
```text
|
||||
./data
|
||||
```
|
||||
|
||||
挂载到容器内:
|
||||
|
||||
```text
|
||||
/app/data
|
||||
```
|
||||
|
||||
因此数据库文件通常位于:
|
||||
备份时直接复制 SQLite 文件即可:
|
||||
|
||||
```text
|
||||
./data/app.db
|
||||
```
|
||||
|
||||
备份时直接复制这个 SQLite 文件即可。
|
||||
|
||||
## 测试
|
||||
|
||||
这个项目已经接入了最基础的测试设施:
|
||||
|
||||
- `pytest`
|
||||
- `FastAPI TestClient`
|
||||
|
||||
运行测试:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
测试会使用独立的测试数据库文件,不会污染本地开发使用的 `data/app.db`。
|
||||
测试使用独立测试数据库,不会污染真实开发数据。
|
||||
|
||||
当前测试覆盖:
|
||||
当前测试覆盖包括:
|
||||
|
||||
- 应用可正常启动
|
||||
- `/` 会重定向到 `/boxes`
|
||||
- `/boxes` 可正常返回 `200`
|
||||
- 测试数据库与真实开发数据库隔离
|
||||
|
||||
## 当前阶段未实现
|
||||
|
||||
这一轮仍然没有开始做完整业务功能,下面这些内容后续再补:
|
||||
|
||||
- Box / Item 完整 CRUD
|
||||
- 图片上传与处理
|
||||
- 搜索
|
||||
- 登录 / 鉴权
|
||||
- 复杂前端样式
|
||||
- 前后端分离
|
||||
- Box / Item / SubItem 基础 CRUD
|
||||
- 404 返回
|
||||
- 非容器 Item 不能创建 SubItem
|
||||
- Box / Item 删除后的级联删除
|
||||
- 关键 POST 请求后的重定向行为
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -14,8 +15,31 @@ class Base(DeclarativeBase):
|
||||
|
||||
|
||||
def _build_engine(database_url: str):
|
||||
_ensure_sqlite_directory(database_url)
|
||||
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
|
||||
return create_engine(database_url, connect_args=connect_args)
|
||||
created_engine = create_engine(database_url, connect_args=connect_args)
|
||||
|
||||
if database_url.startswith("sqlite"):
|
||||
@event.listens_for(created_engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
return created_engine
|
||||
|
||||
|
||||
def _ensure_sqlite_directory(database_url: str) -> None:
|
||||
if not database_url.startswith("sqlite"):
|
||||
return
|
||||
|
||||
database_path = make_url(database_url).database
|
||||
if not database_path or database_path == ":memory:":
|
||||
return
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
Path(database_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def configure_database(database_url: str | None = None) -> None:
|
||||
@@ -23,6 +47,8 @@ def configure_database(database_url: str | None = None) -> None:
|
||||
|
||||
settings = get_settings()
|
||||
resolved_database_url = database_url or settings.database_url
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
engine = _build_engine(resolved_database_url)
|
||||
SessionLocal.configure(bind=engine)
|
||||
|
||||
|
||||
+299
-7
@@ -1,36 +1,328 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import init_db
|
||||
from app.db import get_db, init_db
|
||||
from app.models import Box, Item, SubItem
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
def _clean_text(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = value.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _parse_quantity(value: str | None) -> int | None:
|
||||
cleaned = _clean_text(value)
|
||||
if cleaned is None:
|
||||
return None
|
||||
return int(cleaned)
|
||||
|
||||
|
||||
def _is_checked(value: str | None) -> bool:
|
||||
return value == "on"
|
||||
|
||||
|
||||
def _get_box_or_404(db: Session, box_id: int) -> Box:
|
||||
box = db.get(Box, box_id)
|
||||
if box is None:
|
||||
raise HTTPException(status_code=404, detail="Box not found")
|
||||
return box
|
||||
|
||||
|
||||
def _get_item_or_404(db: Session, item_id: int) -> Item:
|
||||
item = db.get(Item, item_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return item
|
||||
|
||||
|
||||
def _get_subitem_or_404(db: Session, subitem_id: int) -> SubItem:
|
||||
subitem = db.get(SubItem, subitem_id)
|
||||
if subitem is None:
|
||||
raise HTTPException(status_code=404, detail="Sub-item not found")
|
||||
return subitem
|
||||
|
||||
|
||||
def _require_container_item(item: Item) -> None:
|
||||
if not item.is_container:
|
||||
raise HTTPException(status_code=400, detail="Only container items can have sub-items")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Moving Helper", lifespan=lifespan)
|
||||
app = FastAPI(title="搬家助手", lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
def root() -> RedirectResponse:
|
||||
return RedirectResponse(url="/boxes", status_code=302)
|
||||
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
@app.get("/boxes")
|
||||
def boxes_page(request: Request):
|
||||
def list_boxes(request: Request, db: Session = Depends(get_db)):
|
||||
boxes = db.query(Box).order_by(Box.id.desc()).all()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="boxes.html",
|
||||
context={"page_title": "Boxes"},
|
||||
name="boxes/index.html",
|
||||
context={"page_title": "箱子", "boxes": boxes},
|
||||
)
|
||||
|
||||
@app.get("/boxes/new")
|
||||
def new_box_page(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="boxes/form.html",
|
||||
context={
|
||||
"page_title": "新建箱子",
|
||||
"box": None,
|
||||
"form_action": "/boxes",
|
||||
"submit_label": "创建箱子",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/boxes")
|
||||
def create_box(
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
room: str | None = Form(default=None),
|
||||
status_text: str | None = Form(default=None, alias="status"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
box = Box(
|
||||
name=name.strip(),
|
||||
note=_clean_text(note),
|
||||
room=_clean_text(room),
|
||||
status=_clean_text(status_text),
|
||||
)
|
||||
db.add(box)
|
||||
db.commit()
|
||||
db.refresh(box)
|
||||
return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.get("/boxes/{box_id}")
|
||||
def show_box(box_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
box = _get_box_or_404(db, box_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="boxes/show.html",
|
||||
context={"page_title": box.name, "box": box},
|
||||
)
|
||||
|
||||
@app.get("/boxes/{box_id}/edit")
|
||||
def edit_box_page(box_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
box = _get_box_or_404(db, box_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="boxes/form.html",
|
||||
context={
|
||||
"page_title": f"编辑箱子:{box.name}",
|
||||
"box": box,
|
||||
"form_action": f"/boxes/{box.id}/update",
|
||||
"submit_label": "保存箱子",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/boxes/{box_id}/update")
|
||||
def update_box(
|
||||
box_id: int,
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
room: str | None = Form(default=None),
|
||||
status_text: str | None = Form(default=None, alias="status"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
box = _get_box_or_404(db, box_id)
|
||||
box.name = name.strip()
|
||||
box.note = _clean_text(note)
|
||||
box.room = _clean_text(room)
|
||||
box.status = _clean_text(status_text)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.post("/boxes/{box_id}/delete")
|
||||
def delete_box(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
|
||||
box = _get_box_or_404(db, box_id)
|
||||
db.delete(box)
|
||||
db.commit()
|
||||
return RedirectResponse(url="/boxes", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.get("/boxes/{box_id}/items/new")
|
||||
def new_item_page(box_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
box = _get_box_or_404(db, box_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="items/form.html",
|
||||
context={
|
||||
"page_title": f"为箱子“{box.name}”添加物品",
|
||||
"box": box,
|
||||
"item": None,
|
||||
"form_action": f"/boxes/{box.id}/items",
|
||||
"submit_label": "创建物品",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/boxes/{box_id}/items")
|
||||
def create_item(
|
||||
box_id: int,
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
quantity: str | None = Form(default=None),
|
||||
is_container: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
box = _get_box_or_404(db, box_id)
|
||||
item = Item(
|
||||
box=box,
|
||||
name=name.strip(),
|
||||
note=_clean_text(note),
|
||||
quantity=_parse_quantity(quantity),
|
||||
is_container=_is_checked(is_container),
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.get("/items/{item_id}")
|
||||
def show_item(item_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
item = _get_item_or_404(db, item_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="items/show.html",
|
||||
context={"page_title": item.name, "item": item},
|
||||
)
|
||||
|
||||
@app.get("/items/{item_id}/edit")
|
||||
def edit_item_page(item_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
item = _get_item_or_404(db, item_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="items/form.html",
|
||||
context={
|
||||
"page_title": f"编辑物品:{item.name}",
|
||||
"box": item.box,
|
||||
"item": item,
|
||||
"form_action": f"/items/{item.id}/update",
|
||||
"submit_label": "保存物品",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/items/{item_id}/update")
|
||||
def update_item(
|
||||
item_id: int,
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
quantity: str | None = Form(default=None),
|
||||
is_container: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
item = _get_item_or_404(db, item_id)
|
||||
item.name = name.strip()
|
||||
item.note = _clean_text(note)
|
||||
item.quantity = _parse_quantity(quantity)
|
||||
item.is_container = _is_checked(is_container)
|
||||
if not item.is_container:
|
||||
item.subitems.clear()
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.post("/items/{item_id}/delete")
|
||||
def delete_item(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
|
||||
item = _get_item_or_404(db, item_id)
|
||||
box_id = item.box_id
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/boxes/{box_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.get("/items/{item_id}/subitems/new")
|
||||
def new_subitem_page(item_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
item = _get_item_or_404(db, item_id)
|
||||
_require_container_item(item)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="subitems/form.html",
|
||||
context={
|
||||
"page_title": f"为“{item.name}”添加子物品",
|
||||
"item": item,
|
||||
"subitem": None,
|
||||
"form_action": f"/items/{item.id}/subitems",
|
||||
"submit_label": "创建子物品",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/items/{item_id}/subitems")
|
||||
def create_subitem(
|
||||
item_id: int,
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
quantity: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
item = _get_item_or_404(db, item_id)
|
||||
_require_container_item(item)
|
||||
subitem = SubItem(
|
||||
parent_item=item,
|
||||
name=name.strip(),
|
||||
note=_clean_text(note),
|
||||
quantity=_parse_quantity(quantity),
|
||||
)
|
||||
db.add(subitem)
|
||||
db.commit()
|
||||
db.refresh(subitem)
|
||||
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@app.get("/subitems/{subitem_id}/edit")
|
||||
def edit_subitem_page(subitem_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
subitem = _get_subitem_or_404(db, subitem_id)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="subitems/form.html",
|
||||
context={
|
||||
"page_title": f"编辑子物品:{subitem.name}",
|
||||
"item": subitem.parent_item,
|
||||
"subitem": subitem,
|
||||
"form_action": f"/subitems/{subitem.id}/update",
|
||||
"submit_label": "保存子物品",
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/subitems/{subitem_id}/update")
|
||||
def update_subitem(
|
||||
subitem_id: int,
|
||||
name: str = Form(...),
|
||||
note: str | None = Form(default=None),
|
||||
quantity: str | None = Form(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> RedirectResponse:
|
||||
subitem = _get_subitem_or_404(db, subitem_id)
|
||||
subitem.name = name.strip()
|
||||
subitem.note = _clean_text(note)
|
||||
subitem.quantity = _parse_quantity(quantity)
|
||||
db.commit()
|
||||
return RedirectResponse(
|
||||
url=f"/items/{subitem.parent_item_id}",
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
)
|
||||
|
||||
@app.post("/subitems/{subitem_id}/delete")
|
||||
def delete_subitem(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
|
||||
subitem = _get_subitem_or_404(db, subitem_id)
|
||||
item_id = subitem.parent_item_id
|
||||
db.delete(subitem)
|
||||
db.commit()
|
||||
return RedirectResponse(url=f"/items/{item_id}", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
+70
-5
@@ -1,15 +1,80 @@
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class Box(Base):
|
||||
__tablename__ = "boxes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, default="Sample Box")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
room: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
items: Mapped[list["Item"]] = relationship(
|
||||
back_populates="box",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Item.id",
|
||||
)
|
||||
|
||||
|
||||
class Item(Base):
|
||||
__tablename__ = "items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
box_id: Mapped[int] = mapped_column(ForeignKey("boxes.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
is_container: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
box: Mapped[Box] = relationship(back_populates="items")
|
||||
subitems: Mapped[list["SubItem"]] = relationship(
|
||||
back_populates="parent_item",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="SubItem.id",
|
||||
)
|
||||
|
||||
|
||||
class SubItem(Base):
|
||||
__tablename__ = "subitems"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
parent_item_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("items.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
parent_item: Mapped[Item] = relationship(back_populates="subitems")
|
||||
|
||||
+106
-1
@@ -3,10 +3,11 @@ body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f4f4f4;
|
||||
color: #222;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 720px;
|
||||
max-width: 840px;
|
||||
margin: 48px auto;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
@@ -18,3 +19,107 @@ h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0b57d0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
background: #0b57d0;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
button:hover {
|
||||
opacity: 0.92;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.meta,
|
||||
.muted {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.checkbox-row input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #b42318;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ page_title or "Moving Helper" }}</title>
|
||||
<title>{{ page_title or "搬家助手" }}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<nav class="top-nav">
|
||||
<a href="/boxes">箱子</a>
|
||||
</nav>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Boxes page</h1>
|
||||
<p>This is the minimal starter page for the boxes module.</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ page_title }}</h1>
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack">
|
||||
<label>
|
||||
名称
|
||||
<input type="text" name="name" value="{{ box.name if box else '' }}" required>
|
||||
</label>
|
||||
<label>
|
||||
房间
|
||||
<input type="text" name="room" value="{{ box.room if box and box.room else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
状态
|
||||
<input type="text" name="status" value="{{ box.status if box and box.status else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
备注
|
||||
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
|
||||
</label>
|
||||
<button type="submit">{{ submit_label }}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>箱子</h1>
|
||||
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
|
||||
</div>
|
||||
<a class="button" href="/boxes/new">新建箱子</a>
|
||||
</div>
|
||||
|
||||
{% if boxes %}
|
||||
<div class="stack">
|
||||
{% for box in boxes %}
|
||||
<section class="card">
|
||||
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2>
|
||||
<p class="meta">物品数:{{ box.items|length }}</p>
|
||||
{% if box.room %}<p>房间:{{ box.room }}</p>{% endif %}
|
||||
{% if box.status %}<p>状态:{{ box.status }}</p>{% endif %}
|
||||
{% if box.note %}<p>{{ box.note }}</p>{% endif %}
|
||||
<div class="actions">
|
||||
<a href="/boxes/{{ box.id }}">查看详情</a>
|
||||
<a href="/boxes/{{ box.id }}/edit">编辑</a>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>还没有箱子。</p>
|
||||
<a href="/boxes/new">创建第一个箱子</a>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ box.name }}</h1>
|
||||
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
|
||||
<p><strong>状态:</strong> {{ box.status or '-' }}</p>
|
||||
<p><strong>备注:</strong> {{ box.note or '-' }}</p>
|
||||
<div class="actions">
|
||||
<a href="/boxes/{{ box.id }}/edit">编辑箱子</a>
|
||||
<form method="post" action="/boxes/{{ box.id }}/delete">
|
||||
<button type="submit" class="link-button">删除箱子</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stack">
|
||||
<h2>物品</h2>
|
||||
{% if box.items %}
|
||||
{% for item in box.items %}
|
||||
<article class="card">
|
||||
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
|
||||
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
|
||||
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
|
||||
{% if item.note %}<p><strong>备注:</strong> {{ item.note }}</p>{% endif %}
|
||||
<div class="actions">
|
||||
<a href="/items/{{ item.id }}">查看详情</a>
|
||||
<a href="/items/{{ item.id }}/edit">编辑</a>
|
||||
{% if item.is_container %}
|
||||
<a href="/items/{{ item.id }}">查看内部内容</a>
|
||||
{% endif %}
|
||||
<form method="post" action="/items/{{ item.id }}/delete">
|
||||
<button type="submit" class="link-button">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>这个箱子里还没有物品。</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ page_title }}</h1>
|
||||
<p class="muted">所属箱子:<a href="/boxes/{{ box.id }}">{{ box.name }}</a></p>
|
||||
</div>
|
||||
<a href="/boxes/{{ box.id }}">返回箱子</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack">
|
||||
<label>
|
||||
名称
|
||||
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
|
||||
</label>
|
||||
<label>
|
||||
数量
|
||||
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '' }}">
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
|
||||
这个物品本身是一个小容器
|
||||
</label>
|
||||
<label>
|
||||
备注
|
||||
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
|
||||
</label>
|
||||
<button type="submit">{{ submit_label }}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ item.name }}</h1>
|
||||
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a> 中</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/boxes/{{ item.box.id }}">返回箱子</a>
|
||||
<a href="/items/{{ item.id }}/edit">编辑物品</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
|
||||
<p><strong>数量:</strong> {{ item.quantity if item.quantity is not none else '-' }}</p>
|
||||
<p><strong>备注:</strong> {{ item.note or '-' }}</p>
|
||||
<div class="actions">
|
||||
<form method="post" action="/items/{{ item.id }}/delete">
|
||||
<button type="submit" class="link-button">删除物品</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if item.is_container %}
|
||||
<section class="stack">
|
||||
<div class="page-header">
|
||||
<h2>子物品</h2>
|
||||
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
|
||||
</div>
|
||||
{% if item.subitems %}
|
||||
{% for subitem in item.subitems %}
|
||||
<article class="card">
|
||||
<h3>{{ subitem.name }}</h3>
|
||||
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
|
||||
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
|
||||
<div class="actions">
|
||||
<a href="/subitems/{{ subitem.id }}/edit">编辑</a>
|
||||
<form method="post" action="/subitems/{{ subitem.id }}/delete">
|
||||
<button type="submit" class="link-button">删除</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>还没有子物品。</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="card">
|
||||
<p>这个物品不是容器,因此不能包含子物品。</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ page_title }}</h1>
|
||||
<p class="muted">上级物品:<a href="/items/{{ item.id }}">{{ item.name }}</a></p>
|
||||
</div>
|
||||
<a href="/items/{{ item.id }}">返回物品</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack">
|
||||
<label>
|
||||
名称
|
||||
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
|
||||
</label>
|
||||
<label>
|
||||
数量
|
||||
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '' }}">
|
||||
</label>
|
||||
<label>
|
||||
备注
|
||||
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
|
||||
</label>
|
||||
<button type="submit">{{ submit_label }}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -2,5 +2,6 @@ fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
jinja2==3.1.6
|
||||
sqlalchemy==2.0.43
|
||||
python-multipart==0.0.20
|
||||
pytest==8.4.1
|
||||
httpx==0.28.1
|
||||
|
||||
+11
-1
@@ -2,8 +2,9 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import configure_database
|
||||
from app.db import SessionLocal, configure_database
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
@@ -17,3 +18,12 @@ def client(tmp_path: Path):
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(client) -> Session:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+280
-10
@@ -1,12 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
import app.db as db
|
||||
import app.db as db_module
|
||||
from app.models import Box, Item, SubItem
|
||||
|
||||
|
||||
def test_app_starts(client):
|
||||
response = client.get("/boxes")
|
||||
def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready"):
|
||||
return client.post(
|
||||
"/boxes",
|
||||
data={"name": name, "note": note, "room": room, "status": status},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def create_item(client, box_id, name="Item A", note="Note", quantity="2", is_container=False):
|
||||
data = {"name": name, "note": note, "quantity": quantity}
|
||||
if is_container:
|
||||
data["is_container"] = "on"
|
||||
return client.post(f"/boxes/{box_id}/items", data=data, follow_redirects=False)
|
||||
|
||||
|
||||
def create_subitem(client, item_id, name="SubItem A", note="Small", quantity="3"):
|
||||
return client.post(
|
||||
f"/items/{item_id}/subitems",
|
||||
data={"name": name, "note": note, "quantity": quantity},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
|
||||
def test_app_uses_isolated_sqlite_database(client):
|
||||
assert db_module.engine is not None
|
||||
|
||||
database_name = Path(db_module.engine.url.database)
|
||||
assert database_name.name == "test.db"
|
||||
assert "data/app.db" not in str(database_name)
|
||||
|
||||
|
||||
def test_root_redirects_to_boxes(client):
|
||||
@@ -20,12 +46,256 @@ def test_boxes_page_returns_200(client):
|
||||
response = client.get("/boxes")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Boxes page" in response.text
|
||||
assert "箱子" in response.text
|
||||
|
||||
|
||||
def test_tests_use_isolated_sqlite_database(client):
|
||||
assert db.engine is not None
|
||||
def test_can_create_box(client, db_session):
|
||||
response = create_box(client, name="Kitchen Box")
|
||||
|
||||
database_name = Path(db.engine.url.database)
|
||||
assert database_name.name == "test.db"
|
||||
assert "data/app.db" not in str(database_name)
|
||||
assert response.status_code == 303
|
||||
|
||||
box = db_session.query(Box).one()
|
||||
assert box.name == "Kitchen Box"
|
||||
assert box.room == "Bedroom"
|
||||
|
||||
|
||||
def test_can_edit_box(client, db_session):
|
||||
box = Box(name="Old Box")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
f"/boxes/{box.id}/update",
|
||||
data={"name": "Updated Box", "note": "Updated", "room": "Office", "status": "open"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
db_session.refresh(box)
|
||||
assert box.name == "Updated Box"
|
||||
assert box.room == "Office"
|
||||
assert box.status == "open"
|
||||
|
||||
|
||||
def test_can_delete_box(client, db_session):
|
||||
box = Box(name="Delete Me")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
box_id = box.id
|
||||
|
||||
response = client.post(f"/boxes/{box_id}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
db_session.expire_all()
|
||||
assert db_session.get(Box, box_id) is None
|
||||
|
||||
|
||||
def test_deleting_box_cascades_to_items_and_subitems(client, db_session):
|
||||
box = Box(name="Cascade Box")
|
||||
item = Item(name="Container", is_container=True, box=box)
|
||||
subitem = SubItem(name="Cable", parent_item=item)
|
||||
db_session.add_all([box, item, subitem])
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(f"/boxes/{box.id}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert db_session.query(Box).count() == 0
|
||||
assert db_session.query(Item).count() == 0
|
||||
assert db_session.query(SubItem).count() == 0
|
||||
|
||||
|
||||
def test_missing_box_returns_404(client):
|
||||
response = client.get("/boxes/9999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_box_detail_returns_200_when_box_exists(client, db_session):
|
||||
box = Box(name="Visible Box")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(f"/boxes/{box.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Visible Box" in response.text
|
||||
|
||||
|
||||
def test_can_create_regular_item_under_box(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = create_item(client, box.id, name="Book", is_container=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
item = db_session.query(Item).one()
|
||||
assert item.name == "Book"
|
||||
assert item.is_container is False
|
||||
|
||||
|
||||
def test_can_create_container_item_under_box(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
response = create_item(client, box.id, name="Accessory Pouch", is_container=True)
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
item = db_session.query(Item).one()
|
||||
assert item.is_container is True
|
||||
|
||||
|
||||
def test_can_edit_item(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Old Item", box=box, is_container=False)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
f"/items/{item.id}/update",
|
||||
data={"name": "New Item", "note": "Changed", "quantity": "7", "is_container": "on"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
db_session.refresh(item)
|
||||
assert item.name == "New Item"
|
||||
assert item.quantity == 7
|
||||
assert item.is_container is True
|
||||
|
||||
|
||||
def test_can_delete_item(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Delete Item", box=box, is_container=False)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
item_id = item.id
|
||||
|
||||
response = client.post(f"/items/{item_id}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
db_session.expire_all()
|
||||
assert db_session.get(Item, item_id) is None
|
||||
|
||||
|
||||
def test_deleting_container_item_cascades_to_subitems(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Container", box=box, is_container=True)
|
||||
subitem = SubItem(name="Clip", parent_item=item)
|
||||
db_session.add_all([box, item, subitem])
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(f"/items/{item.id}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert db_session.query(Item).count() == 0
|
||||
assert db_session.query(SubItem).count() == 0
|
||||
|
||||
|
||||
def test_missing_item_returns_404(client):
|
||||
response = client.get("/items/9999")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_item_detail_returns_200_when_item_exists(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Visible Item", box=box, is_container=False)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = client.get(f"/items/{item.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Visible Item" in response.text
|
||||
|
||||
|
||||
def test_can_create_subitem_under_container_item(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Pouch", box=box, is_container=True)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = create_subitem(client, item.id, name="USB Cable")
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
subitem = db_session.query(SubItem).one()
|
||||
assert subitem.name == "USB Cable"
|
||||
|
||||
|
||||
def test_cannot_create_subitem_under_non_container_item(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Book", box=box, is_container=False)
|
||||
db_session.add_all([box, item])
|
||||
db_session.commit()
|
||||
|
||||
response = create_subitem(client, item.id, name="Should Fail")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "Only container items can have sub-items"
|
||||
assert db_session.query(SubItem).count() == 0
|
||||
|
||||
|
||||
def test_can_edit_subitem(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Pouch", box=box, is_container=True)
|
||||
subitem = SubItem(name="Old Sub-item", parent_item=item)
|
||||
db_session.add_all([box, item, subitem])
|
||||
db_session.commit()
|
||||
|
||||
response = client.post(
|
||||
f"/subitems/{subitem.id}/update",
|
||||
data={"name": "New Sub-item", "note": "Updated", "quantity": "9"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
|
||||
db_session.refresh(subitem)
|
||||
assert subitem.name == "New Sub-item"
|
||||
assert subitem.quantity == 9
|
||||
|
||||
|
||||
def test_can_delete_subitem(client, db_session):
|
||||
box = Box(name="Main Box")
|
||||
item = Item(name="Pouch", box=box, is_container=True)
|
||||
subitem = SubItem(name="Delete Sub-item", parent_item=item)
|
||||
db_session.add_all([box, item, subitem])
|
||||
db_session.commit()
|
||||
subitem_id = subitem.id
|
||||
|
||||
response = client.post(f"/subitems/{subitem_id}/delete", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
db_session.expire_all()
|
||||
assert db_session.get(SubItem, subitem_id) is None
|
||||
|
||||
|
||||
def test_missing_subitem_returns_404(client):
|
||||
response = client.get("/subitems/9999/edit")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_post_redirects_are_reasonable(client, db_session):
|
||||
box = Box(name="Redirect Box")
|
||||
db_session.add(box)
|
||||
db_session.commit()
|
||||
|
||||
item_response = create_item(client, box.id, name="Lamp")
|
||||
item_id = int(item_response.headers["location"].split("/")[-1])
|
||||
item = db_session.get(Item, item_id)
|
||||
item.is_container = True
|
||||
db_session.commit()
|
||||
|
||||
subitem_response = create_subitem(client, item.id, name="Bulb")
|
||||
|
||||
assert item_response.headers["location"] == f"/items/{item.id}"
|
||||
assert subitem_response.headers["location"] == f"/items/{item.id}"
|
||||
|
||||
Reference in New Issue
Block a user