step 2 with basic crud implemented

This commit is contained in:
2026-04-19 12:36:55 +02:00
parent dae7a60eab
commit 57800f2123
16 changed files with 1113 additions and 110 deletions
+82 -74
View File
@@ -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 请求后的重定向行为
+28 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+5 -3
View File
@@ -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>
-7
View File
@@ -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 %}
+28
View File
@@ -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 %}
+34
View File
@@ -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 %}
+54
View File
@@ -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 %}
+31
View File
@@ -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 %}
+57
View File
@@ -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 %}
+27
View File
@@ -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 %}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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}"