diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000..6d70aff
Binary files /dev/null and b/.DS_Store differ
diff --git a/README.md b/README.md
index 057da78..606a94d 100644
--- a/README.md
+++ b/README.md
@@ -6,10 +6,11 @@
- Jinja2 服务端渲染
- SQLAlchemy
- SQLite
+- Pillow
- pytest / FastAPI TestClient
- Docker / Docker Compose
-项目目标是小而稳、容易继续扩展。目前已经从“纯脚手架”进入第二阶段,支持固定三层的数据结构和基础 CRUD,但还没有开始做图片、搜索和复杂界面。
+项目目标是小而稳、容易继续扩展。目前已经支持固定三层的数据结构、基础 CRUD,以及单图上传能力,但仍然没有加入搜索、OCR、AI 识别或其他扩展功能。
## 当前数据模型
@@ -27,7 +28,7 @@
- 只有 `is_container = true` 的 `Item` 才允许拥有 `SubItem`
- `SubItem` 是最后一级,不允许继续向下嵌套
-换句话说,结构固定为:
+结构固定为:
```text
Box
@@ -42,6 +43,7 @@ Box
- Box 列表、详情、新建、编辑、删除
- Item 新建、详情、编辑、删除
- SubItem 新建、编辑、删除
+- Box / Item / SubItem 单张图片上传、替换、删除、展示
- `/` 重定向到 `/boxes`
- Jinja2 模板渲染
- 静态文件挂载
@@ -54,14 +56,52 @@ Box
- 删除 `Box` 时,会级联删除其下全部 `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
│ ├── config.py
│ ├── db.py
+│ ├── images.py
│ ├── main.py
│ ├── models.py
│ ├── static
@@ -190,4 +231,6 @@ python -m pytest
- 404 返回
- 非容器 Item 不能创建 SubItem
- Box / Item 删除后的级联删除
+- 图片上传、转换为 JPEG、缩放、读取、替换、删除
+- 无图片访问和非法图片上传等错误路径
- 关键 POST 请求后的重定向行为
diff --git a/app/db.py b/app/db.py
index 8b07e46..8d90fbf 100644
--- a/app/db.py
+++ b/app/db.py
@@ -1,6 +1,6 @@
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.orm import DeclarativeBase, Session, sessionmaker
@@ -68,3 +68,41 @@ def init_db(database_url: str | None = None) -> None:
configure_database(database_url)
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}")
+ )
diff --git a/app/images.py b/app/images.py
new file mode 100644
index 0000000..47474cb
--- /dev/null
+++ b/app/images.py
@@ -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()
diff --git a/app/main.py b/app/main.py
index 4ae276d..054940b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,12 +1,13 @@
from contextlib import asynccontextmanager
-from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
-from fastapi.responses import RedirectResponse
+from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
+from fastapi.responses import RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.db import get_db, init_db
+from app.images import process_upload
from app.models import Box, Item, SubItem
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")
+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:
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -97,6 +122,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"),
+ image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = Box(
@@ -105,6 +131,7 @@ def create_app() -> FastAPI:
room=_clean_text(room),
status=_clean_text(status_text),
)
+ _set_image_fields(box, process_upload(image_file))
db.add(box)
db.commit()
db.refresh(box)
@@ -119,6 +146,10 @@ def create_app() -> FastAPI:
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")
def edit_box_page(box_id: int, request: Request, db: Session = Depends(get_db)):
box = _get_box_or_404(db, box_id)
@@ -140,6 +171,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"),
+ image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
@@ -147,9 +179,17 @@ def create_app() -> FastAPI:
box.note = _clean_text(note)
box.room = _clean_text(room)
box.status = _clean_text(status_text)
+ _set_image_fields(box, process_upload(image_file))
db.commit()
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")
def delete_box(box_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
@@ -179,6 +219,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
+ image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
@@ -189,6 +230,7 @@ def create_app() -> FastAPI:
quantity=_parse_quantity(quantity),
is_container=_is_checked(is_container),
)
+ _set_image_fields(item, process_upload(image_file))
db.add(item)
db.commit()
db.refresh(item)
@@ -203,6 +245,10 @@ def create_app() -> FastAPI:
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")
def edit_item_page(item_id: int, request: Request, db: Session = Depends(get_db)):
item = _get_item_or_404(db, item_id)
@@ -225,6 +271,7 @@ def create_app() -> FastAPI:
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
+ image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
@@ -234,9 +281,17 @@ def create_app() -> FastAPI:
item.is_container = _is_checked(is_container)
if not item.is_container:
item.subitems.clear()
+ _set_image_fields(item, process_upload(image_file))
db.commit()
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")
def delete_item(item_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
@@ -267,6 +322,7 @@ def create_app() -> FastAPI:
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
+ image_file: UploadFile | None = File(default=None),
db: Session = Depends(get_db),
) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
@@ -277,11 +333,16 @@ def create_app() -> FastAPI:
note=_clean_text(note),
quantity=_parse_quantity(quantity),
)
+ _set_image_fields(subitem, process_upload(image_file))
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}/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")
def edit_subitem_page(subitem_id: int, request: Request, db: Session = Depends(get_db)):
subitem = _get_subitem_or_404(db, subitem_id)
@@ -303,18 +364,27 @@ def create_app() -> FastAPI:
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
+ image_file: UploadFile | None = File(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)
+ _set_image_fields(subitem, process_upload(image_file))
db.commit()
return RedirectResponse(
url=f"/items/{subitem.parent_item_id}",
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")
def delete_subitem(subitem_id: int, db: Session = Depends(get_db)) -> RedirectResponse:
subitem = _get_subitem_or_404(db, subitem_id)
diff --git a/app/models.py b/app/models.py
index 0e40a25..2e76460 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,6 +1,6 @@
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 app.db import Base
@@ -18,6 +18,10 @@ class Box(Base):
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)
+ 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)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -42,6 +46,10 @@ class Item(Base):
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)
+ 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)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
@@ -69,6 +77,10 @@ class SubItem(Base):
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)
+ 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)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
diff --git a/app/static/style.css b/app/static/style.css
index afbe040..24c44ec 100644
--- a/app/static/style.css
+++ b/app/static/style.css
@@ -92,6 +92,25 @@ button:hover {
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,
.muted {
color: #666;
diff --git a/app/templates/boxes/form.html b/app/templates/boxes/form.html
index d818581..949644e 100644
--- a/app/templates/boxes/form.html
+++ b/app/templates/boxes/form.html
@@ -6,7 +6,7 @@
返回箱子列表
-
{% endblock %}
diff --git a/app/templates/boxes/show.html b/app/templates/boxes/show.html
index ae2f424..e974941 100644
--- a/app/templates/boxes/show.html
+++ b/app/templates/boxes/show.html
@@ -13,6 +13,9 @@
+ {% if box.image_blob %}
+
+ {% endif %}
房间: {{ box.room or '-' }}
状态: {{ box.status or '-' }}
备注: {{ box.note or '-' }}
@@ -29,6 +32,9 @@
{% if box.items %}
{% for item in box.items %}
+ {% if item.image_blob %}
+
+ {% endif %}
是否容器: {{ "是" if item.is_container else "否" }}
{% if item.quantity is not none %}数量: {{ item.quantity }}
{% endif %}
diff --git a/app/templates/items/form.html b/app/templates/items/form.html
index e775102..3f2f32d 100644
--- a/app/templates/items/form.html
+++ b/app/templates/items/form.html
@@ -9,7 +9,7 @@
返回箱子
-
{% endblock %}
diff --git a/app/templates/items/show.html b/app/templates/items/show.html
index fb87786..78db883 100644
--- a/app/templates/items/show.html
+++ b/app/templates/items/show.html
@@ -13,6 +13,9 @@
+ {% if item.image_blob %}
+
+ {% endif %}
是否容器: {{ "是" if item.is_container else "否" }}
数量: {{ item.quantity if item.quantity is not none else '-' }}
备注: {{ item.note or '-' }}
@@ -32,6 +35,9 @@
{% if item.subitems %}
{% for subitem in item.subitems %}
+ {% if subitem.image_blob %}
+
+ {% endif %}
{{ subitem.name }}
{% if subitem.quantity is not none %}数量: {{ subitem.quantity }}
{% endif %}
{% if subitem.note %}备注: {{ subitem.note }}
{% endif %}
diff --git a/app/templates/subitems/form.html b/app/templates/subitems/form.html
index 775d302..4fcc5a0 100644
--- a/app/templates/subitems/form.html
+++ b/app/templates/subitems/form.html
@@ -9,7 +9,7 @@
返回物品
-
{% endblock %}
diff --git a/requirements.txt b/requirements.txt
index c185c1e..c79d4ff 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,5 +3,6 @@ uvicorn[standard]==0.35.0
jinja2==3.1.6
sqlalchemy==2.0.43
python-multipart==0.0.20
+pillow==11.2.1
pytest==8.4.1
httpx==0.28.1
diff --git a/tests/test_app.py b/tests/test_app.py
index 3cbe58d..9ded456 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,32 +1,62 @@
+from io import BytesIO
from pathlib import Path
import app.db as db_module
+from PIL import Image
+
from app.models import Box, Item, SubItem
-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,
- )
+def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready", image=None):
+ data = {"name": name, "note": note, "room": room, "status": status}
+ files = {"image_file": image} if image is not None else None
+ return client.post("/boxes", data=data, files=files, 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}
if is_container:
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(
f"/items/{item_id}/subitems",
data={"name": name, "note": note, "quantity": quantity},
+ files=files,
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):
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 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