From 5fdf3f4ab228bb9102e1ac331664b1ea46523ff4 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 12:54:25 +0200 Subject: [PATCH] add image flow --- .DS_Store | Bin 0 -> 6148 bytes README.md | 53 +++++- app/db.py | 40 ++++- app/images.py | 73 ++++++++ app/main.py | 74 +++++++- app/models.py | 14 +- app/static/style.css | 19 +++ app/templates/boxes/form.html | 20 ++- app/templates/boxes/show.html | 6 + app/templates/items/form.html | 20 ++- app/templates/items/show.html | 6 + app/templates/subitems/form.html | 20 ++- requirements.txt | 1 + tests/test_app.py | 281 ++++++++++++++++++++++++++++++- 14 files changed, 606 insertions(+), 21 deletions(-) create mode 100644 .DS_Store create mode 100644 app/images.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6d70afffd0f5515cd23123ffa14bb740f0e2de79 GIT binary patch literal 6148 zcmeHK%}T>S5Z-O0Z7D(y3Oz1(Etpmji<8H&#u)eJQI9d3G1i2J$Wd7%=w2JDnPfzcV}wOE4HFrH{bpi+ z9q`*N7O{ZkEcp8U;WUY}+;QG{t!`~?H$d8rTmL~8ejeoW%nN4MXkAE|gq0qIm+_<+ z+q*-V=0TiJW~v~LCXjM-6{nFbJULIJOx5~2&;l9ToyFqN8FWRzce3n?#h~wW#j$g= zT(+8f`v<4z>;Qk<>A-V=jjcV(F4zJG`w-8Z4$F~HcFz6aAH9`c0>ry~n%FPpl z>vHf5ljj;NHR^K4)yy!CnYntra5X#lg-U1K)kr-tKn$!hP}io7=l>b}GHW0Ct0iO+ z1H`~TV}M&Df8@cU%-Q;Fd3e?eXm`+1Ft0=f1oV|l02sKBbW~8s1?rIJ8Z0&9DCk$^ PfOHX1giuEe`~m|XgU?DW literal 0 HcmV?d00001 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 @@ 返回箱子列表 -
+ + + {% if box and box.image_blob %} +
+

当前图片:

+ {{ box.name }} + +
+ {% endif %}
{% 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 %} + {{ box.name }} + {% 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 %} + {{ item.name }} + {% endif %}

{{ item.name }}

是否容器: {{ "是" 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 @@ 返回箱子 -
+ + + {% if item and item.image_blob %} +
+

当前图片:

+ {{ item.name }} + +
+ {% endif %}
{% 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 %} + {{ item.name }} + {% 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 %} + {{ subitem.name }} + {% 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 @@ 返回物品 -
+ + + {% if subitem and subitem.image_blob %} +
+

当前图片:

+ {{ subitem.name }} + +
+ {% endif %}
{% 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