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 - FastAPI
- Jinja2 - Jinja2 服务端渲染
- SQLAlchemy - SQLAlchemy
- SQLite - SQLite
- pytest / FastAPI TestClient - pytest / FastAPI TestClient
- Docker / Docker Compose - 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 │ │ └── style.css
│ └── templates │ └── templates
│ ├── base.html │ ├── base.html
── boxes.html ── boxes
│ ├── items
│ └── subitems
├── data ├── data
├── tests ├── tests
│ ├── conftest.py │ ├── conftest.py
│ └── test_app.py │ └── test_app.py
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
├── pytest.ini
├── README.md ├── README.md
── requirements.txt ── requirements.txt
└── .gitignore
``` ```
## 当前已接好的基础能力
- FastAPI 应用入口:`app/main.py`
- Jinja2 模板渲染
- 静态文件挂载:`/static`
- `/` 自动重定向到 `/boxes`
- SQLite 数据库连接
- SQLAlchemy Base / Session
- 启动时自动建表
- 基础自动化测试
## 轻量配置 ## 轻量配置
项目通过环境变量支持下面几个配置项: 项目通过环境变量支持下配置项:
- `DATABASE_URL` - `DATABASE_URL`
- `HOST` - `HOST`
@@ -62,8 +109,6 @@
- `HOST=0.0.0.0` - `HOST=0.0.0.0`
- `PORT=10000` - `PORT=10000`
本地开发和 Docker 运行都可以直接使用这些默认值,也可以按需覆盖。
## 本地开发模式 ## 本地开发模式
推荐使用本地 Python `venv` 开发和调试。 推荐使用本地 Python `venv` 开发和调试。
@@ -93,23 +138,17 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
http://localhost:10000 http://localhost:10000
``` ```
本地开发默认会使用 本地默认数据库位置
```text ```text
./data/app.db ./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 部署模式
Docker / Compose 是这个项目面向长期运行环境的方式。 Docker / Compose 是这个项目面向长期运行环境的方式。
### 启动 启动:
```bash ```bash
docker compose up --build docker compose up --build
@@ -121,65 +160,34 @@ docker compose up --build
http://localhost:10000 http://localhost:10000
``` ```
### 运行说明 说明:
- 默认暴露 `10000` 端口 - 默认暴露 `10000` 端口
- `restart: unless-stopped`,适合长期运行 - `restart: unless-stopped`
- 容器内应用以用户 `1000:1000` 运行 - 容器使用 `1000:1000` 运行
- SQLite 数据文件保存在宿主机 `./data/app.db` - SQLite 文件持久化到宿主机 `./data/app.db`
- 容器重建不会丢失数据库数据 - 容器重建不会丢失数据
### 数据持久化与备份 备份时直接复制 SQLite 文件即可:
`docker-compose.yml` 会将宿主机目录:
```text
./data
```
挂载到容器内:
```text
/app/data
```
因此数据库文件通常位于:
```text ```text
./data/app.db ./data/app.db
``` ```
备份时直接复制这个 SQLite 文件即可。
## 测试 ## 测试
这个项目已经接入了最基础的测试设施:
- `pytest`
- `FastAPI TestClient`
运行测试: 运行测试:
```bash ```bash
pytest python -m pytest
``` ```
测试使用独立测试数据库文件,不会污染本地开发使用的 `data/app.db` 测试使用独立测试数据库,不会污染真实开发数据
当前测试覆盖: 当前测试覆盖包括
- 应用可正常启动 - Box / Item / SubItem 基础 CRUD
- `/` 会重定向到 `/boxes` - 404 返回
- `/boxes` 可正常返回 `200` - 非容器 Item 不能创建 SubItem
- 测试数据库与真实开发数据库隔离 - Box / Item 删除后的级联删除
- 关键 POST 请求后的重定向行为
## 当前阶段未实现
这一轮仍然没有开始做完整业务功能,下面这些内容后续再补:
- Box / Item 完整 CRUD
- 图片上传与处理
- 搜索
- 登录 / 鉴权
- 复杂前端样式
- 前后端分离
+28 -2
View File
@@ -1,6 +1,7 @@
from typing import Generator 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 sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings from app.config import get_settings
@@ -14,8 +15,31 @@ class Base(DeclarativeBase):
def _build_engine(database_url: str): def _build_engine(database_url: str):
_ensure_sqlite_directory(database_url)
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {} 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: 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() settings = get_settings()
resolved_database_url = database_url or settings.database_url resolved_database_url = database_url or settings.database_url
if engine is not None:
engine.dispose()
engine = _build_engine(resolved_database_url) engine = _build_engine(resolved_database_url)
SessionLocal.configure(bind=engine) SessionLocal.configure(bind=engine)
+299 -7
View File
@@ -1,36 +1,328 @@
from contextlib import asynccontextmanager 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.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates 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") 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: def create_app() -> FastAPI:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() init_db()
yield yield
app = FastAPI(title="Moving Helper", lifespan=lifespan) app = FastAPI(title="搬家助手", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
@app.get("/", include_in_schema=False) @app.get("/", include_in_schema=False)
def root() -> RedirectResponse: def root() -> RedirectResponse:
return RedirectResponse(url="/boxes", status_code=302) return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
@app.get("/boxes") @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( return templates.TemplateResponse(
request=request, request=request,
name="boxes.html", name="boxes/index.html",
context={"page_title": "Boxes"}, 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 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 import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
def utcnow() -> datetime:
return datetime.now(UTC)
class Box(Base): class Box(Base):
__tablename__ = "boxes" __tablename__ = "boxes"
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, default="Sample Box") name: Mapped[str] = mapped_column(String(100), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, 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; font-family: Arial, sans-serif;
background: #f4f4f4; background: #f4f4f4;
color: #222; color: #222;
line-height: 1.5;
} }
.container { .container {
max-width: 720px; max-width: 840px;
margin: 48px auto; margin: 48px auto;
padding: 24px; padding: 24px;
background: #fff; background: #fff;
@@ -18,3 +19,107 @@ h1 {
margin-top: 0; 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> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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') }}"> <link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<nav class="top-nav">
<a href="/boxes">箱子</a>
</nav>
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>
</html> </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 uvicorn[standard]==0.35.0
jinja2==3.1.6 jinja2==3.1.6
sqlalchemy==2.0.43 sqlalchemy==2.0.43
python-multipart==0.0.20
pytest==8.4.1 pytest==8.4.1
httpx==0.28.1 httpx==0.28.1
+11 -1
View File
@@ -2,8 +2,9 @@ from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient 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 from app.main import create_app
@@ -17,3 +18,12 @@ def client(tmp_path: Path):
with TestClient(app) as test_client: with TestClient(app) as test_client:
yield 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 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): def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready"):
response = client.get("/boxes") 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): def test_root_redirects_to_boxes(client):
@@ -20,12 +46,256 @@ def test_boxes_page_returns_200(client):
response = client.get("/boxes") response = client.get("/boxes")
assert response.status_code == 200 assert response.status_code == 200
assert "Boxes page" in response.text assert "箱子" in response.text
def test_tests_use_isolated_sqlite_database(client): def test_can_create_box(client, db_session):
assert db.engine is not None response = create_box(client, name="Kitchen Box")
database_name = Path(db.engine.url.database) assert response.status_code == 303
assert database_name.name == "test.db"
assert "data/app.db" not in str(database_name) 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}"