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