Files
2026-moving-helper/app/main.py
T

400 lines
14 KiB
Python
Raw Normal View History

2026-04-19 12:13:07 +02:00
from contextlib import asynccontextmanager
2026-04-19 12:54:25 +02:00
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import RedirectResponse, Response
2026-04-19 12:13:07 +02:00
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
2026-04-19 12:36:55 +02:00
from sqlalchemy.orm import Session
2026-04-19 12:13:07 +02:00
2026-04-19 12:36:55 +02:00
from app.db import get_db, init_db
2026-04-19 12:54:25 +02:00
from app.images import process_upload
2026-04-19 12:36:55 +02:00
from app.models import Box, Item, SubItem
2026-04-19 12:13:07 +02:00
templates = Jinja2Templates(directory="app/templates")
2026-04-19 12:36:55 +02:00
def _clean_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = value.strip()
return cleaned or None
def _parse_quantity(value: str | None) -> int | None:
cleaned = _clean_text(value)
if cleaned is None:
return None
return int(cleaned)
def _is_checked(value: str | None) -> bool:
return value == "on"
def _get_box_or_404(db: Session, box_id: int) -> Box:
box = db.get(Box, box_id)
if box is None:
raise HTTPException(status_code=404, detail="Box not found")
return box
def _get_item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status_code=404, detail="Item not found")
return item
def _get_subitem_or_404(db: Session, subitem_id: int) -> SubItem:
subitem = db.get(SubItem, subitem_id)
if subitem is None:
raise HTTPException(status_code=404, detail="Sub-item not found")
return subitem
def _require_container_item(item: Item) -> None:
if not item.is_container:
raise HTTPException(status_code=400, detail="Only container items can have sub-items")
2026-04-19 12:54:25 +02:00
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)
2026-04-19 12:13:07 +02:00
def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
2026-04-19 12:36:55 +02:00
app = FastAPI(title="搬家助手", lifespan=lifespan)
2026-04-19 12:13:07 +02:00
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@app.get("/", include_in_schema=False)
def root() -> RedirectResponse:
2026-04-19 12:36:55 +02:00
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
2026-04-19 12:13:07 +02:00
@app.get("/boxes")
2026-04-19 12:36:55 +02:00
def list_boxes(request: Request, db: Session = Depends(get_db)):
boxes = db.query(Box).order_by(Box.id.desc()).all()
return templates.TemplateResponse(
request=request,
name="boxes/index.html",
context={"page_title": "箱子", "boxes": boxes},
)
@app.get("/boxes/new")
def new_box_page(request: Request):
2026-04-19 12:13:07 +02:00
return templates.TemplateResponse(
request=request,
2026-04-19 12:36:55 +02:00
name="boxes/form.html",
context={
"page_title": "新建箱子",
"box": None,
"form_action": "/boxes",
"submit_label": "创建箱子",
},
)
@app.post("/boxes")
def create_box(
name: str = Form(...),
note: str | None = Form(default=None),
room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"),
2026-04-19 12:54:25 +02:00
image_file: UploadFile | None = File(default=None),
2026-04-19 12:36:55 +02:00
db: Session = Depends(get_db),
) -> RedirectResponse:
box = Box(
name=name.strip(),
note=_clean_text(note),
room=_clean_text(room),
status=_clean_text(status_text),
2026-04-19 12:13:07 +02:00
)
2026-04-19 12:54:25 +02:00
_set_image_fields(box, process_upload(image_file))
2026-04-19 12:36:55 +02:00
db.add(box)
db.commit()
db.refresh(box)
return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/boxes/{box_id}")
def show_box(box_id: int, request: Request, db: Session = Depends(get_db)):
box = _get_box_or_404(db, box_id)
return templates.TemplateResponse(
request=request,
name="boxes/show.html",
context={"page_title": box.name, "box": box},
)
2026-04-19 12:54:25 +02:00
@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))
2026-04-19 12:36:55 +02:00
@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)
return templates.TemplateResponse(
request=request,
name="boxes/form.html",
context={
"page_title": f"编辑箱子:{box.name}",
"box": box,
"form_action": f"/boxes/{box.id}/update",
"submit_label": "保存箱子",
},
)
@app.post("/boxes/{box_id}/update")
def update_box(
box_id: int,
name: str = Form(...),
note: str | None = Form(default=None),
room: str | None = Form(default=None),
status_text: str | None = Form(default=None, alias="status"),
2026-04-19 12:54:25 +02:00
image_file: UploadFile | None = File(default=None),
2026-04-19 12:36:55 +02:00
db: Session = Depends(get_db),
) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
box.name = name.strip()
box.note = _clean_text(note)
box.room = _clean_text(room)
box.status = _clean_text(status_text)
2026-04-19 12:54:25 +02:00
_set_image_fields(box, process_upload(image_file))
2026-04-19 12:36:55 +02:00
db.commit()
return RedirectResponse(url=f"/boxes/{box.id}", status_code=status.HTTP_303_SEE_OTHER)
2026-04-19 12:54:25 +02:00
@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)
2026-04-19 12:36:55 +02:00
@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)
db.delete(box)
db.commit()
return RedirectResponse(url="/boxes", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/boxes/{box_id}/items/new")
def new_item_page(box_id: int, request: Request, db: Session = Depends(get_db)):
box = _get_box_or_404(db, box_id)
return templates.TemplateResponse(
request=request,
name="items/form.html",
context={
"page_title": f"为箱子“{box.name}”添加物品",
"box": box,
"item": None,
"form_action": f"/boxes/{box.id}/items",
"submit_label": "创建物品",
},
)
@app.post("/boxes/{box_id}/items")
def create_item(
box_id: int,
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
2026-04-19 12:54:25 +02:00
image_file: UploadFile | None = File(default=None),
2026-04-19 12:36:55 +02:00
db: Session = Depends(get_db),
) -> RedirectResponse:
box = _get_box_or_404(db, box_id)
item = Item(
box=box,
name=name.strip(),
note=_clean_text(note),
quantity=_parse_quantity(quantity),
is_container=_is_checked(is_container),
)
2026-04-19 12:54:25 +02:00
_set_image_fields(item, process_upload(image_file))
2026-04-19 12:36:55 +02:00
db.add(item)
db.commit()
db.refresh(item)
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/items/{item_id}")
def show_item(item_id: int, request: Request, db: Session = Depends(get_db)):
item = _get_item_or_404(db, item_id)
return templates.TemplateResponse(
request=request,
name="items/show.html",
context={"page_title": item.name, "item": item},
)
2026-04-19 12:54:25 +02:00
@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))
2026-04-19 12:36:55 +02:00
@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)
return templates.TemplateResponse(
request=request,
name="items/form.html",
context={
"page_title": f"编辑物品:{item.name}",
"box": item.box,
"item": item,
"form_action": f"/items/{item.id}/update",
"submit_label": "保存物品",
},
)
@app.post("/items/{item_id}/update")
def update_item(
item_id: int,
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
is_container: str | None = Form(default=None),
2026-04-19 12:54:25 +02:00
image_file: UploadFile | None = File(default=None),
2026-04-19 12:36:55 +02:00
db: Session = Depends(get_db),
) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
item.name = name.strip()
item.note = _clean_text(note)
item.quantity = _parse_quantity(quantity)
item.is_container = _is_checked(is_container)
if not item.is_container:
item.subitems.clear()
2026-04-19 12:54:25 +02:00
_set_image_fields(item, process_upload(image_file))
2026-04-19 12:36:55 +02:00
db.commit()
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
2026-04-19 12:54:25 +02:00
@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)
2026-04-19 12:36:55 +02:00
@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)
box_id = item.box_id
db.delete(item)
db.commit()
return RedirectResponse(url=f"/boxes/{box_id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/items/{item_id}/subitems/new")
def new_subitem_page(item_id: int, request: Request, db: Session = Depends(get_db)):
item = _get_item_or_404(db, item_id)
_require_container_item(item)
return templates.TemplateResponse(
request=request,
name="subitems/form.html",
context={
"page_title": f"为“{item.name}”添加子物品",
"item": item,
"subitem": None,
"form_action": f"/items/{item.id}/subitems",
"submit_label": "创建子物品",
},
)
@app.post("/items/{item_id}/subitems")
def create_subitem(
item_id: int,
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
2026-04-19 12:54:25 +02:00
image_file: UploadFile | None = File(default=None),
2026-04-19 12:36:55 +02:00
db: Session = Depends(get_db),
) -> RedirectResponse:
item = _get_item_or_404(db, item_id)
_require_container_item(item)
subitem = SubItem(
parent_item=item,
name=name.strip(),
note=_clean_text(note),
quantity=_parse_quantity(quantity),
)
2026-04-19 12:54:25 +02:00
_set_image_fields(subitem, process_upload(image_file))
2026-04-19 12:36:55 +02:00
db.add(subitem)
db.commit()
db.refresh(subitem)
return RedirectResponse(url=f"/items/{item.id}", status_code=status.HTTP_303_SEE_OTHER)
2026-04-19 12:54:25 +02:00
@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))
2026-04-19 12:36:55 +02:00
@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)
return templates.TemplateResponse(
request=request,
name="subitems/form.html",
context={
"page_title": f"编辑子物品:{subitem.name}",
"item": subitem.parent_item,
"subitem": subitem,
"form_action": f"/subitems/{subitem.id}/update",
"submit_label": "保存子物品",
},
)
@app.post("/subitems/{subitem_id}/update")
def update_subitem(
subitem_id: int,
name: str = Form(...),
note: str | None = Form(default=None),
quantity: str | None = Form(default=None),
2026-04-19 12:54:25 +02:00
image_file: UploadFile | None = File(default=None),
2026-04-19 12:36:55 +02:00
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)
2026-04-19 12:54:25 +02:00
_set_image_fields(subitem, process_upload(image_file))
2026-04-19 12:36:55 +02:00
db.commit()
return RedirectResponse(
url=f"/items/{subitem.parent_item_id}",
status_code=status.HTTP_303_SEE_OTHER,
)
2026-04-19 12:54:25 +02:00
@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)
2026-04-19 12:36:55 +02:00
@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)
item_id = subitem.parent_item_id
db.delete(subitem)
db.commit()
return RedirectResponse(url=f"/items/{item_id}", status_code=status.HTTP_303_SEE_OTHER)
2026-04-19 12:13:07 +02:00
return app
app = create_app()