add image flow
This commit is contained in:
@@ -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 请求后的重定向行为
|
||||
|
||||
@@ -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}")
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+13
-1
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="/boxes">返回箱子列表</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack">
|
||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
||||
<label>
|
||||
名称
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
</div>
|
||||
|
||||
<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.status or '-' }}</p>
|
||||
<p><strong>备注:</strong> {{ box.note or '-' }}</p>
|
||||
@@ -29,6 +32,9 @@
|
||||
{% if box.items %}
|
||||
{% for item in box.items %}
|
||||
<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>
|
||||
<p><strong>是否容器:</strong> {{ "是" if item.is_container else "否" }}</p>
|
||||
{% if item.quantity is not none %}<p><strong>数量:</strong> {{ item.quantity }}</p>{% endif %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="/boxes/{{ box.id }}">返回箱子</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack">
|
||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
||||
<label>
|
||||
名称
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
</div>
|
||||
|
||||
<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> {{ item.quantity if item.quantity is not none else '-' }}</p>
|
||||
<p><strong>备注:</strong> {{ item.note or '-' }}</p>
|
||||
@@ -32,6 +35,9 @@
|
||||
{% if item.subitems %}
|
||||
{% for subitem in item.subitems %}
|
||||
<article class="card">
|
||||
{% if subitem.image_blob %}
|
||||
<img src="/subitems/{{ subitem.id }}/image" alt="{{ subitem.name }}" class="thumb-image">
|
||||
{% endif %}
|
||||
<h3>{{ subitem.name }}</h3>
|
||||
{% if subitem.quantity is not none %}<p><strong>数量:</strong> {{ subitem.quantity }}</p>{% endif %}
|
||||
{% if subitem.note %}<p><strong>备注:</strong> {{ subitem.note }}</p>{% endif %}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<a href="/items/{{ item.id }}">返回物品</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ form_action }}" class="stack">
|
||||
<form method="post" action="{{ form_action }}" class="stack" enctype="multipart/form-data">
|
||||
<label>
|
||||
名称
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
|
||||
|
||||
+272
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user