add image flow

This commit is contained in:
2026-04-19 12:54:25 +02:00
parent 57800f2123
commit 5fdf3f4ab2
14 changed files with 606 additions and 21 deletions
Vendored
BIN
View File
Binary file not shown.
+48 -5
View File
@@ -6,10 +6,11 @@
- Jinja2 服务端渲染 - Jinja2 服务端渲染
- SQLAlchemy - SQLAlchemy
- SQLite - SQLite
- Pillow
- pytest / FastAPI TestClient - pytest / FastAPI TestClient
- Docker / Docker Compose - Docker / Docker Compose
项目目标是小而稳、容易继续扩展。目前已经从“纯脚手架”进入第二阶段,支持固定三层的数据结构基础 CRUD但还没有开始做图片、搜索和复杂界面 项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构基础 CRUD以及单图上传能力,但仍然没有加入搜索、OCR、AI 识别或其他扩展功能
## 当前数据模型 ## 当前数据模型
@@ -27,7 +28,7 @@
- 只有 `is_container = true``Item` 才允许拥有 `SubItem` - 只有 `is_container = true``Item` 才允许拥有 `SubItem`
- `SubItem` 是最后一级,不允许继续向下嵌套 - `SubItem` 是最后一级,不允许继续向下嵌套
换句话说,结构固定为: 结构固定为:
```text ```text
Box Box
@@ -42,6 +43,7 @@ Box
- Box 列表、详情、新建、编辑、删除 - Box 列表、详情、新建、编辑、删除
- Item 新建、详情、编辑、删除 - Item 新建、详情、编辑、删除
- SubItem 新建、编辑、删除 - SubItem 新建、编辑、删除
- Box / Item / SubItem 单张图片上传、替换、删除、展示
- `/` 重定向到 `/boxes` - `/` 重定向到 `/boxes`
- Jinja2 模板渲染 - Jinja2 模板渲染
- 静态文件挂载 - 静态文件挂载
@@ -54,14 +56,52 @@ Box
- 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem` - 删除 `Box` 时,会级联删除其下全部 `Item` 和对应 `SubItem`
- 删除容器型 `Item` 时,会级联删除其下 `SubItem` - 删除容器型 `Item` 时,会级联删除其下 `SubItem`
## 图片能力说明
这一阶段的图片系统保持简单直接:
- `Box` 最多支持 1 张图片
- `Item` 最多支持 1 张图片
- `SubItem` 最多支持 1 张图片
- 支持上传、替换、删除
- 不支持多图
图片的主要用途是帮助识别物品、提高浏览效率、方便手机拍照后直接附加到记录中。
它不是一个原图归档系统。
### 图片处理方式
上传图片后,系统会使用 Pillow 做统一处理:
- 读取上传图片
- 去除 EXIF 元数据
- 转换为 JPEG
- 按最长边缩放到不超过 `1600px`
- 使用约 `80` 质量保存
- 将处理后的 JPEG 二进制直接写入 SQLite `BLOB`
同时还会记录:
- `image_mime_type`
- `image_width`
- `image_height`
图片访问通过普通 HTTP 路由返回 JPEG 数据,例如:
- `/boxes/{id}/image`
- `/items/{id}/image`
- `/subitems/{id}/image`
## 当前未实现 ## 当前未实现
这一阶段仍然没有实现以下内容: 这一阶段仍然没有实现以下内容:
- 图片上传
- 图片字段
- 图片处理
- 搜索 - 搜索
- 多图上传
- OCR
- AI 识别物品
- 图片标签
- 图片分类
- 登录 / 鉴权 - 登录 / 鉴权
- 标签系统 - 标签系统
- 前后端分离 - 前后端分离
@@ -75,6 +115,7 @@ Box
│ ├── __init__.py │ ├── __init__.py
│ ├── config.py │ ├── config.py
│ ├── db.py │ ├── db.py
│ ├── images.py
│ ├── main.py │ ├── main.py
│ ├── models.py │ ├── models.py
│ ├── static │ ├── static
@@ -190,4 +231,6 @@ python -m pytest
- 404 返回 - 404 返回
- 非容器 Item 不能创建 SubItem - 非容器 Item 不能创建 SubItem
- Box / Item 删除后的级联删除 - Box / Item 删除后的级联删除
- 图片上传、转换为 JPEG、缩放、读取、替换、删除
- 无图片访问和非法图片上传等错误路径
- 关键 POST 请求后的重定向行为 - 关键 POST 请求后的重定向行为
+39 -1
View File
@@ -1,6 +1,6 @@
from typing import Generator from typing import Generator
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import make_url from sqlalchemy.engine import make_url
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
@@ -68,3 +68,41 @@ def init_db(database_url: str | None = None) -> None:
configure_database(database_url) configure_database(database_url)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_sync_sqlite_image_columns()
def _sync_sqlite_image_columns() -> None:
if engine is None or engine.dialect.name != "sqlite":
return
image_columns = {
"boxes": {
"image_blob": "BLOB",
"image_mime_type": "VARCHAR(50)",
"image_width": "INTEGER",
"image_height": "INTEGER",
},
"items": {
"image_blob": "BLOB",
"image_mime_type": "VARCHAR(50)",
"image_width": "INTEGER",
"image_height": "INTEGER",
},
"subitems": {
"image_blob": "BLOB",
"image_mime_type": "VARCHAR(50)",
"image_width": "INTEGER",
"image_height": "INTEGER",
},
}
with engine.begin() as connection:
for table_name, columns in image_columns.items():
existing_columns = {
row[1] for row in connection.execute(text(f"PRAGMA table_info({table_name})"))
}
for column_name, column_type in columns.items():
if column_name not in existing_columns:
connection.execute(
text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")
)
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import dataclass
from io import BytesIO
from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
MAX_IMAGE_SIDE = 1600
JPEG_QUALITY = 80
JPEG_MIME_TYPE = "image/jpeg"
@dataclass(slots=True)
class ProcessedImage:
blob: bytes
mime_type: str
width: int
height: int
def process_upload(file: UploadFile | None) -> ProcessedImage | None:
if file is None or not file.filename:
return None
try:
raw_bytes = file.file.read()
if not raw_bytes:
raise HTTPException(status_code=400, detail="上传的图片内容为空")
with Image.open(BytesIO(raw_bytes)) as source_image:
processed_image = _prepare_image(source_image)
except UnidentifiedImageError as exc:
raise HTTPException(status_code=400, detail="上传的文件不是合法图片") from exc
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=400, detail="图片处理失败,请尝试更换图片") from exc
finally:
file.file.close()
return processed_image
def _prepare_image(source_image: Image.Image) -> ProcessedImage:
prepared = _strip_metadata_and_convert(source_image)
prepared.thumbnail((MAX_IMAGE_SIDE, MAX_IMAGE_SIDE))
output = BytesIO()
prepared.save(output, format="JPEG", quality=JPEG_QUALITY, optimize=True)
blob = output.getvalue()
return ProcessedImage(
blob=blob,
mime_type=JPEG_MIME_TYPE,
width=prepared.width,
height=prepared.height,
)
def _strip_metadata_and_convert(source_image: Image.Image) -> Image.Image:
if source_image.mode in ("RGBA", "LA"):
background = Image.new("RGB", source_image.size, (255, 255, 255))
alpha = source_image.getchannel("A")
background.paste(source_image.convert("RGBA"), mask=alpha)
return background
if source_image.mode == "P":
return source_image.convert("RGB")
if source_image.mode != "RGB":
return source_image.convert("RGB")
return source_image.copy()
+72 -2
View File
@@ -1,12 +1,13 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, Response
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 sqlalchemy.orm import Session
from app.db import get_db, init_db from app.db import get_db, init_db
from app.images import process_upload
from app.models import Box, Item, SubItem from app.models import Box, Item, SubItem
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -56,6 +57,30 @@ def _require_container_item(item: Item) -> None:
raise HTTPException(status_code=400, detail="Only container items can have sub-items") raise HTTPException(status_code=400, detail="Only container items can have sub-items")
def _set_image_fields(target, processed_image) -> None:
if processed_image is None:
return
target.image_blob = processed_image.blob
target.image_mime_type = processed_image.mime_type
target.image_width = processed_image.width
target.image_height = processed_image.height
def _clear_image_fields(target) -> None:
target.image_blob = None
target.image_mime_type = None
target.image_width = None
target.image_height = None
def _image_response_or_404(target) -> Response:
if target.image_blob is None or target.image_mime_type is None:
raise HTTPException(status_code=404, detail="Image not found")
return Response(content=target.image_blob, media_type=target.image_mime_type)
def create_app() -> FastAPI: def create_app() -> FastAPI:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
@@ -97,6 +122,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None), note: str | None = Form(default=None),
room: str | None = Form(default=None), room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"), status_text: str | None = Form(default=None, alias="status"),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
box = Box( box = Box(
@@ -105,6 +131,7 @@ def create_app() -> FastAPI:
room=_clean_text(room), room=_clean_text(room),
status=_clean_text(status_text), status=_clean_text(status_text),
) )
_set_image_fields(box, process_upload(image_file))
db.add(box) db.add(box)
db.commit() db.commit()
db.refresh(box) db.refresh(box)
@@ -119,6 +146,10 @@ def create_app() -> FastAPI:
context={"page_title": box.name, "box": box}, context={"page_title": box.name, "box": box},
) )
@app.get("/boxes/{box_id}/image")
def get_box_image(box_id: int, db: Session = Depends(get_db)) -> Response:
return _image_response_or_404(_get_box_or_404(db, box_id))
@app.get("/boxes/{box_id}/edit") @app.get("/boxes/{box_id}/edit")
def edit_box_page(box_id: int, request: Request, db: Session = Depends(get_db)): def edit_box_page(box_id: int, request: Request, db: Session = Depends(get_db)):
box = _get_box_or_404(db, box_id) box = _get_box_or_404(db, box_id)
@@ -140,6 +171,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None), note: str | None = Form(default=None),
room: str | None = Form(default=None), room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"), status_text: str | None = Form(default=None, alias="status"),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
box = _get_box_or_404(db, box_id) box = _get_box_or_404(db, box_id)
@@ -147,9 +179,17 @@ def create_app() -> FastAPI:
box.note = _clean_text(note) box.note = _clean_text(note)
box.room = _clean_text(room) box.room = _clean_text(room)
box.status = _clean_text(status_text) box.status = _clean_text(status_text)
_set_image_fields(box, process_upload(image_file))
db.commit() db.commit()
return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/boxes/{box_id}/image/delete")
def delete_box_image(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
_clear_image_fields(box)
db.commit()
return RedirectResponse(url=f"/boxes/{box.id}/edit", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/boxes/{box_id}/delete") @app.post("/boxes/{box_id}/delete")
def delete_box(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse: def delete_box(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
box = _get_box_or_404(db, box_id) box = _get_box_or_404(db, box_id)
@@ -179,6 +219,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None), note: str | None = Form(default=None),
quantity: str | None = Form(default=None), quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None), is_container: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
box = _get_box_or_404(db, box_id) box = _get_box_or_404(db, box_id)
@@ -189,6 +230,7 @@ def create_app() -> FastAPI:
quantity=_parse_quantity(quantity), quantity=_parse_quantity(quantity),
is_container=_is_checked(is_container), is_container=_is_checked(is_container),
) )
_set_image_fields(item, process_upload(image_file))
db.add(item) db.add(item)
db.commit() db.commit()
db.refresh(item) db.refresh(item)
@@ -203,6 +245,10 @@ def create_app() -> FastAPI:
context={"page_title": item.name, "item": item}, context={"page_title": item.name, "item": item},
) )
@app.get("/items/{item_id}/image")
def get_item_image(item_id: int, db: Session = Depends(get_db)) -> Response:
return _image_response_or_404(_get_item_or_404(db, item_id))
@app.get("/items/{item_id}/edit") @app.get("/items/{item_id}/edit")
def edit_item_page(item_id: int, request: Request, db: Session = Depends(get_db)): def edit_item_page(item_id: int, request: Request, db: Session = Depends(get_db)):
item = _get_item_or_404(db, item_id) item = _get_item_or_404(db, item_id)
@@ -225,6 +271,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None), note: str | None = Form(default=None),
quantity: str | None = Form(default=None), quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None), is_container: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
item = _get_item_or_404(db, item_id) item = _get_item_or_404(db, item_id)
@@ -234,9 +281,17 @@ def create_app() -> FastAPI:
item.is_container = _is_checked(is_container) item.is_container = _is_checked(is_container)
if not item.is_container: if not item.is_container:
item.subitems.clear() item.subitems.clear()
_set_image_fields(item, process_upload(image_file))
db.commit() db.commit()
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/items/{item_id}/image/delete")
def delete_item_image(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
_clear_image_fields(item)
db.commit()
return RedirectResponse(url=f"/items/{item.id}/edit", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/items/{item_id}/delete") @app.post("/items/{item_id}/delete")
def delete_item(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse: def delete_item(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
item = _get_item_or_404(db, item_id) item = _get_item_or_404(db, item_id)
@@ -267,6 +322,7 @@ def create_app() -> FastAPI:
name: str = Form(...), name: str = Form(...),
note: str | None = Form(default=None), note: str | None = Form(default=None),
quantity: str | None = Form(default=None), quantity: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
item = _get_item_or_404(db, item_id) item = _get_item_or_404(db, item_id)
@@ -277,11 +333,16 @@ def create_app() -> FastAPI:
note=_clean_text(note), note=_clean_text(note),
quantity=_parse_quantity(quantity), quantity=_parse_quantity(quantity),
) )
_set_image_fields(subitem, process_upload(image_file))
db.add(subitem) db.add(subitem)
db.commit() db.commit()
db.refresh(subitem) db.refresh(subitem)
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/subitems/{subitem_id}/image")
def get_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> Response:
return _image_response_or_404(_get_subitem_or_404(db, subitem_id))
@app.get("/subitems/{subitem_id}/edit") @app.get("/subitems/{subitem_id}/edit")
def edit_subitem_page(subitem_id: int, request: Request, db: Session = Depends(get_db)): def edit_subitem_page(subitem_id: int, request: Request, db: Session = Depends(get_db)):
subitem = _get_subitem_or_404(db, subitem_id) subitem = _get_subitem_or_404(db, subitem_id)
@@ -303,18 +364,27 @@ def create_app() -> FastAPI:
name: str = Form(...), name: str = Form(...),
note: str | None = Form(default=None), note: str | None = Form(default=None),
quantity: str | None = Form(default=None), quantity: str | None = Form(default=None),
image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> RedirectResponse: ) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id) subitem = _get_subitem_or_404(db, subitem_id)
subitem.name = name.strip() subitem.name = name.strip()
subitem.note = _clean_text(note) subitem.note = _clean_text(note)
subitem.quantity = _parse_quantity(quantity) subitem.quantity = _parse_quantity(quantity)
_set_image_fields(subitem, process_upload(image_file))
db.commit() db.commit()
return RedirectResponse( return RedirectResponse(
url=f"/items/{subitem.parent_item_id}", url=f"/items/{subitem.parent_item_id}",
status_code=status.HTTP_303_SEE_OTHER, status_code=status.HTTP_303_SEE_OTHER,
) )
@app.post("/subitems/{subitem_id}/image/delete")
def delete_subitem_image(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id)
_clear_image_fields(subitem)
db.commit()
return RedirectResponse(url=f"/subitems/{subitem.id}/edit", status_code=status.HTTP_303_SEE_OTHER)
@app.post("/subitems/{subitem_id}/delete") @app.post("/subitems/{subitem_id}/delete")
def delete_subitem(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse: def delete_subitem(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id) subitem = _get_subitem_or_404(db, subitem_id)
+13 -1
View File
@@ -1,6 +1,6 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
@@ -18,6 +18,10 @@ class Box(Base):
note: Mapped[str | None] = mapped_column(Text, nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True)
room: Mapped[str | None] = mapped_column(String(100), nullable=True) room: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str | None] = mapped_column(String(50), nullable=True) status: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
image_mime_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
@@ -42,6 +46,10 @@ class Item(Base):
note: Mapped[str | None] = mapped_column(Text, nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True) quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
is_container: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_container: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
image_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
image_mime_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
@@ -69,6 +77,10 @@ class SubItem(Base):
name: Mapped[str] = mapped_column(String(100), nullable=False) name: Mapped[str] = mapped_column(String(100), nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True) note: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True) quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_blob: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
image_mime_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
image_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
image_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
+19
View File
@@ -92,6 +92,25 @@ button:hover {
background: #fafafa; background: #fafafa;
} }
.detail-image {
display: block;
width: 100%;
max-width: 480px;
height: auto;
border-radius: 8px;
margin-bottom: 16px;
}
.thumb-image {
display: block;
width: 120px;
max-width: 100%;
height: auto;
border-radius: 8px;
margin-bottom: 12px;
border: 1px solid #ddd;
}
.meta, .meta,
.muted { .muted {
color: #666; color: #666;
+19 -1
View File
@@ -6,7 +6,7 @@
<a href="/boxes">返回箱子列表</a> <a href="/boxes">返回箱子列表</a>
</div> </div>
<form method="post" action="{{ form_action }}" class="stack"> <form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label> <label>
名称 名称
<input type="text" name="name" value="{{ box.name if box else '' }}" required> <input type="text" name="name" value="{{ box.name if box else '' }}" required>
@@ -23,6 +23,24 @@
备注 备注
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea> <textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
</label> </label>
<label>
图片
<input type="file" name="image_file" accept="image/*">
</label>
{% if box and box.image_blob %}
<section class="card">
<p><strong>当前图片:</strong></p>
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
<button
type="submit"
class="link-button"
formaction="/boxes/{{ box.id }}/image/delete"
formmethod="post"
>
删除当前图片
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button> <button type="submit">{{ submit_label }}</button>
</form> </form>
{% endblock %} {% endblock %}
+6
View File
@@ -13,6 +13,9 @@
</div> </div>
<section class="card"> <section class="card">
{% if box.image_blob %}
<img src="/boxes/{{ box.id }}/image" alt="{{ box.name }}" class="detail-image">
{% endif %}
<p><strong>房间:</strong> {{ box.room or '-' }}</p> <p><strong>房间:</strong> {{ box.room or '-' }}</p>
<p><strong>状态:</strong> {{ box.status or '-' }}</p> <p><strong>状态:</strong> {{ box.status or '-' }}</p>
<p><strong>备注:</strong> {{ box.note or '-' }}</p> <p><strong>备注:</strong> {{ box.note or '-' }}</p>
@@ -29,6 +32,9 @@
{% if box.items %} {% if box.items %}
{% for item in box.items %} {% for item in box.items %}
<article class="card"> <article class="card">
{% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="thumb-image">
{% endif %}
<h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3> <h3><a href="/items/{{ item.id }}">{{ item.name }}</a></h3>
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p> <p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %} {% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
+19 -1
View File
@@ -9,7 +9,7 @@
<a href="/boxes/{{ box.id }}">返回箱子</a> <a href="/boxes/{{ box.id }}">返回箱子</a>
</div> </div>
<form method="post" action="{{ form_action }}" class="stack"> <form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label> <label>
名称 名称
<input type="text" name="name" value="{{ item.name if item else '' }}" required> <input type="text" name="name" value="{{ item.name if item else '' }}" required>
@@ -26,6 +26,24 @@
备注 备注
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea> <textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
</label> </label>
<label>
图片
<input type="file" name="image_file" accept="image/*">
</label>
{% if item and item.image_blob %}
<section class="card">
<p><strong>当前图片:</strong></p>
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
<button
type="submit"
class="link-button"
formaction="/items/{{ item.id }}/image/delete"
formmethod="post"
>
删除当前图片
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button> <button type="submit">{{ submit_label }}</button>
</form> </form>
{% endblock %} {% endblock %}
+6
View File
@@ -13,6 +13,9 @@
</div> </div>
<section class="card"> <section class="card">
{% if item.image_blob %}
<img src="/items/{{ item.id }}/image" alt="{{ item.name }}" class="detail-image">
{% endif %}
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p> <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.quantity if item.quantity is not none else '-' }}</p>
<p><strong>备注:</strong> {{ item.note or '-' }}</p> <p><strong>备注:</strong> {{ item.note or '-' }}</p>
@@ -32,6 +35,9 @@
{% if item.subitems %} {% if item.subitems %}
{% for subitem in item.subitems %} {% for subitem in item.subitems %}
<article class="card"> <article class="card">
{% if subitem.image_blob %}
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image">
{% endif %}
<h3>{{ subitem.name }}</h3> <h3>{{ subitem.name }}</h3>
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %} {% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %} {% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
+19 -1
View File
@@ -9,7 +9,7 @@
<a href="/items/{{ item.id }}">返回物品</a> <a href="/items/{{ item.id }}">返回物品</a>
</div> </div>
<form method="post" action="{{ form_action }}" class="stack"> <form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
<label> <label>
名称 名称
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required> <input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
@@ -22,6 +22,24 @@
备注 备注
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea> <textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
</label> </label>
<label>
图片
<input type="file" name="image_file" accept="image/*">
</label>
{% if subitem and subitem.image_blob %}
<section class="card">
<p><strong>当前图片:</strong></p>
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="detail-image">
<button
type="submit"
class="link-button"
formaction="/subitems/{{ subitem.id }}/image/delete"
formmethod="post"
>
删除当前图片
</button>
</section>
{% endif %}
<button type="submit">{{ submit_label }}</button> <button type="submit">{{ submit_label }}</button>
</form> </form>
{% endblock %} {% endblock %}
+1
View File
@@ -3,5 +3,6 @@ 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 python-multipart==0.0.20
pillow==11.2.1
pytest==8.4.1 pytest==8.4.1
httpx==0.28.1 httpx==0.28.1
+272 -9
View File
@@ -1,32 +1,62 @@
from io import BytesIO
from pathlib import Path from pathlib import Path
import app.db as db_module import app.db as db_module
from PIL import Image
from app.models import Box, Item, SubItem from app.models import Box, Item, SubItem
def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready"): def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready", image=None):
return client.post( data = {"name": name, "note": note, "room": room, "status": status}
"/boxes", files = {"image_file": image} if image is not None else None
data={"name": name, "note": note, "room": room, "status": status}, return client.post("/boxes", data=data, files=files, follow_redirects=False)
follow_redirects=False,
)
def create_item(client, box_id, name="Item A", note="Note", quantity="2", is_container=False): def create_item(
client,
box_id,
name="Item A",
note="Note",
quantity="2",
is_container=False,
image=None,
):
data = {"name": name, "note": note, "quantity": quantity} data = {"name": name, "note": note, "quantity": quantity}
if is_container: if is_container:
data["is_container"] = "on" data["is_container"] = "on"
return client.post(f"/boxes/{box_id}/items", data=data, follow_redirects=False) files = {"image_file": image} if image is not None else None
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
def create_subitem(client, item_id, name="SubItem A", note="Small", quantity="3"): def create_subitem(client, item_id, name="SubItem A", note="Small", quantity="3", image=None):
files = {"image_file": image} if image is not None else None
return client.post( return client.post(
f"/items/{item_id}/subitems", f"/items/{item_id}/subitems",
data={"name": name, "note": note, "quantity": quantity}, data={"name": name, "note": note, "quantity": quantity},
files=files,
follow_redirects=False, follow_redirects=False,
) )
def make_image_upload(
filename="sample.png",
image_format="PNG",
size=(2000, 1000),
color=(255, 0, 0),
mode="RGB",
):
image = Image.new(mode, size, color)
output = BytesIO()
image.save(output, format=image_format)
return (filename, output.getvalue(), "image/png")
def read_jpeg_size(image_bytes):
with Image.open(BytesIO(image_bytes)) as image:
return image.format, image.size
def test_app_uses_isolated_sqlite_database(client): def test_app_uses_isolated_sqlite_database(client):
assert db_module.engine is not None assert db_module.engine is not None
@@ -299,3 +329,236 @@ def test_post_redirects_are_reasonable(client, db_session):
assert item_response.headers["location"] == f"/items/{item.id}" assert item_response.headers["location"] == f"/items/{item.id}"
assert subitem_response.headers["location"] == f"/items/{item.id}" assert subitem_response.headers["location"] == f"/items/{item.id}"
def test_can_upload_image_for_box_and_process_it(client, db_session):
response = create_box(client, name="Photo Box", image=make_image_upload())
assert response.status_code == 303
box = db_session.query(Box).one()
assert box.image_blob is not None
assert box.image_mime_type == "image/jpeg"
assert box.image_width == 1600
assert box.image_height == 800
image_format, image_size = read_jpeg_size(box.image_blob)
assert image_format == "JPEG"
assert image_size == (1600, 800)
def test_can_upload_image_for_item(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = create_item(client, box.id, name="Book", image=make_image_upload())
assert response.status_code == 303
item = db_session.query(Item).one()
assert item.image_blob is not None
assert item.image_mime_type == "image/jpeg"
def test_can_upload_image_for_subitem(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="Cable", image=make_image_upload())
assert response.status_code == 303
subitem = db_session.query(SubItem).one()
assert subitem.image_blob is not None
assert subitem.image_mime_type == "image/jpeg"
def test_box_image_route_returns_jpeg(client, db_session):
box = Box(name="Photo Box")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
db_session.expire_all()
response = client.get(f"/boxes/{box.id}/image")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_item_image_route_returns_jpeg(client, db_session):
box = Box(name="Main Box")
item = Item(name="Lamp", box=box, is_container=False)
db_session.add_all([box, item])
db_session.commit()
client.post(
f"/items/{item.id}/update",
data={"name": item.name, "note": "", "quantity": "", "is_container": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.get(f"/items/{item.id}/image")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_subitem_image_route_returns_jpeg(client, db_session):
box = Box(name="Main Box")
item = Item(name="Pouch", box=box, is_container=True)
subitem = SubItem(name="Cable", parent_item=item)
db_session.add_all([box, item, subitem])
db_session.commit()
client.post(
f"/subitems/{subitem.id}/update",
data={"name": subitem.name, "note": "", "quantity": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.get(f"/subitems/{subitem.id}/image")
assert response.status_code == 200
assert response.headers["content-type"] == "image/jpeg"
def test_image_routes_return_404_when_no_image(client, db_session):
box = Box(name="Main Box")
item = Item(name="Lamp", box=box, is_container=False)
subitem = SubItem(name="Cable", parent_item=Item(name="Pouch", box=box, is_container=True))
db_session.add_all([box, item, subitem.parent_item, subitem])
db_session.commit()
assert client.get(f"/boxes/{box.id}/image").status_code == 404
assert client.get(f"/items/{item.id}/image").status_code == 404
assert client.get(f"/subitems/{subitem.id}/image").status_code == 404
def test_image_routes_return_404_for_missing_objects(client):
assert client.get("/boxes/9999/image").status_code == 404
assert client.get("/items/9999/image").status_code == 404
assert client.get("/subitems/9999/image").status_code == 404
def test_can_replace_existing_image(client, db_session):
box = Box(name="Photo Box")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload(color=(255, 0, 0))},
follow_redirects=False,
)
db_session.expire_all()
first_blob = db_session.get(Box, box.id).image_blob
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload(color=(0, 255, 0))},
follow_redirects=False,
)
db_session.expire_all()
updated_box = db_session.get(Box, box.id)
assert updated_box.image_blob is not None
assert updated_box.image_blob != first_blob
def test_can_delete_existing_image_and_clear_metadata(client, db_session):
box = Box(name="Photo Box")
db_session.add(box)
db_session.commit()
client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": make_image_upload()},
follow_redirects=False,
)
response = client.post(f"/boxes/{box.id}/image/delete", follow_redirects=False)
assert response.status_code == 303
db_session.expire_all()
updated_box = db_session.get(Box, box.id)
assert updated_box.image_blob is None
assert updated_box.image_mime_type is None
assert updated_box.image_width is None
assert updated_box.image_height is None
assert client.get(f"/boxes/{box.id}/image").status_code == 404
def test_deleting_image_when_none_exists_is_safe(client, db_session):
item = Item(name="No Image Item", box=Box(name="Main Box"), is_container=False)
db_session.add(item)
db_session.commit()
response = client.post(f"/items/{item.id}/image/delete", follow_redirects=False)
assert response.status_code == 303
def test_uploading_non_image_file_returns_400_and_does_not_write_data(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = client.post(
f"/items/{box.id}/subitems",
data={"name": "Should Fail", "note": "", "quantity": ""},
files={"image_file": ("bad.txt", b"not an image", "text/plain")},
follow_redirects=False,
)
assert response.status_code in {400, 404}
item = Item(name="Pouch", box=box, is_container=True)
db_session.add(item)
db_session.commit()
response = create_subitem(
client,
item.id,
name="Should Fail",
image=("bad.txt", b"not an image", "text/plain"),
)
assert response.status_code == 400
assert response.json()["detail"] == "上传的文件不是合法图片"
assert db_session.query(SubItem).count() == 0
def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client, db_session):
box = Box(name="Main Box")
db_session.add(box)
db_session.commit()
response = client.post(
f"/boxes/{box.id}/update",
data={"name": box.name, "note": "", "room": "", "status": ""},
files={"image_file": ("broken.jpg", b"\xff\xd8\xff\xdbbroken", "image/jpeg")},
follow_redirects=False,
)
assert response.status_code == 400
db_session.expire_all()
updated_box = db_session.get(Box, box.id)
assert updated_box.image_blob is None
assert updated_box.image_mime_type is None
assert updated_box.image_width is None
assert updated_box.image_height is None