step 2 with basic crud implemented

This commit is contained in:
2026-04-19 12:36:55 +02:00
parent dae7a60eab
commit 57800f2123
16 changed files with 1113 additions and 110 deletions
+28 -2
View File
@@ -1,6 +1,7 @@
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.engine import make_url
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import get_settings
@@ -14,8 +15,31 @@ class Base(DeclarativeBase):
def _build_engine(database_url: str):
_ensure_sqlite_directory(database_url)
connect_args = {"check_same_thread": False} if database_url.startswith("sqlite") else {}
return create_engine(database_url, connect_args=connect_args)
created_engine = create_engine(database_url, connect_args=connect_args)
if database_url.startswith("sqlite"):
@event.listens_for(created_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
return created_engine
def _ensure_sqlite_directory(database_url: str) -> None:
if not database_url.startswith("sqlite"):
return
database_path = make_url(database_url).database
if not database_path or database_path == ":memory:":
return
from pathlib import Path
Path(database_path).parent.mkdir(parents=True, exist_ok=True)
def configure_database(database_url: str | None = None) -> None:
@@ -23,6 +47,8 @@ def configure_database(database_url: str | None = None) -> None:
settings = get_settings()
resolved_database_url = database_url or settings.database_url
if engine is not None:
engine.dispose()
engine = _build_engine(resolved_database_url)
SessionLocal.configure(bind=engine)
+299 -7
View File
@@ -1,36 +1,328 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from app.db import init_db
from app.db import get_db, init_db
from app.models import Box, Item, SubItem
templates = Jinja2Templates(directory="app/templates")
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")
def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="Moving Helper", lifespan=lifespan)
app = FastAPI(title="搬家助手", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
@app.get("/", include_in_schema=False)
def root() -> RedirectResponse:
return RedirectResponse(url="/boxes", status_code=302)
return RedirectResponse(url="/boxes", status_code=status.HTTP_302_FOUND)
@app.get("/boxes")
def boxes_page(request: Request):
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.html",
context={"page_title": "Boxes"},
name="boxes/index.html",
context={"page_title": "箱子", "boxes": boxes},
)
@app.get("/boxes/new")
def new_box_page(request: Request):
return templates.TemplateResponse(
request=request,
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"),
db: Session = Depends(get_db),
) -> RedirectResponse:
box = Box(
name=name.strip(),
note=_clean_text(note),
room=_clean_text(room),
status=_clean_text(status_text),
)
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},
)
@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"),
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)
db.commit()
return RedirectResponse(url=f"/boxes/{box.id}", 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)
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),
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),
)
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},
)
@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),
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()
db.commit()
return RedirectResponse(url=f"/items/{item.id}", 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)
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),
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),
)
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}/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),
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)
db.commit()
return RedirectResponse(
url=f"/items/{subitem.parent_item_id}",
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)
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)
return app
+70 -5
View File
@@ -1,15 +1,80 @@
from datetime import datetime
from datetime import UTC, datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def utcnow() -> datetime:
return datetime.now(UTC)
class Box(Base):
__tablename__ = "boxes"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, default="Sample Box")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
nullable=False,
)
items: Mapped[list["Item"]] = relationship(
back_populates="box",
cascade="all, delete-orphan",
order_by="Item.id",
)
class Item(Base):
__tablename__ = "items"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
box_id: Mapped[int] = mapped_column(ForeignKey("boxes.id", ondelete="CASCADE"), nullable=False)
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)
is_container: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
nullable=False,
)
box: Mapped[Box] = relationship(back_populates="items")
subitems: Mapped[list["SubItem"]] = relationship(
back_populates="parent_item",
cascade="all, delete-orphan",
order_by="SubItem.id",
)
class SubItem(Base):
__tablename__ = "subitems"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
parent_item_id: Mapped[int] = mapped_column(
ForeignKey("items.id", ondelete="CASCADE"),
nullable=False,
)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
nullable=False,
)
parent_item: Mapped[Item] = relationship(back_populates="subitems")
+106 -1
View File
@@ -3,10 +3,11 @@ body {
font-family: Arial, sans-serif;
background: #f4f4f4;
color: #222;
line-height: 1.5;
}
.container {
max-width: 720px;
max-width: 840px;
margin: 48px auto;
padding: 24px;
background: #fff;
@@ -18,3 +19,107 @@ h1 {
margin-top: 0;
}
h2,
h3,
p {
margin-top: 0;
}
a {
color: #0b57d0;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
label {
display: block;
font-weight: 600;
}
input,
textarea,
button {
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 10px 12px;
font: inherit;
}
button,
.button {
display: inline-block;
width: auto;
background: #0b57d0;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
padding: 10px 14px;
}
.button:hover,
button:hover {
opacity: 0.92;
text-decoration: none;
}
.top-nav {
margin-bottom: 24px;
}
.page-header,
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.stack {
display: grid;
gap: 16px;
}
.card {
border: 1px solid #ddd;
border-radius: 10px;
padding: 16px;
background: #fafafa;
}
.meta,
.muted {
color: #666;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox-row input {
width: auto;
margin: 0;
}
.actions form {
margin: 0;
}
.link-button {
background: none;
border: none;
color: #b42318;
padding: 0;
cursor: pointer;
}
.link-button:hover {
text-decoration: underline;
}
+5 -3
View File
@@ -1,15 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title or "Moving Helper" }}</title>
<title>{{ page_title or "搬家助手" }}</title>
<link rel="stylesheet" href="{{ url_for('static', path='/style.css') }}">
</head>
<body>
<main class="container">
<nav class="top-nav">
<a href="/boxes">箱子</a>
</nav>
{% block content %}{% endblock %}
</main>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
{% extends "base.html" %}
{% block content %}
<h1>Boxes page</h1>
<p>This is the minimal starter page for the boxes module.</p>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>{{ page_title }}</h1>
<a href="/boxes">返回箱子列表</a>
</div>
<form method="post" action="{{ form_action }}" class="stack">
<label>
名称
<input type="text" name="name" value="{{ box.name if box else '' }}" required>
</label>
<label>
房间
<input type="text" name="room" value="{{ box.room if box and box.room else '' }}">
</label>
<label>
状态
<input type="text" name="status" value="{{ box.status if box and box.status else '' }}">
</label>
<label>
备注
<textarea name="note" rows="4">{{ box.note if box and box.note else '' }}</textarea>
</label>
<button type="submit">{{ submit_label }}</button>
</form>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1>箱子</h1>
<p class="muted">这里管理顶层搬家容器,例如纸箱、行李箱或大收纳箱。</p>
</div>
<a class="button" href="/boxes/new">新建箱子</a>
</div>
{% if boxes %}
<div class="stack">
{% for box in boxes %}
<section class="card">
<h2><a href="/boxes/{{ box.id }}">{{ box.name }}</a></h2>
<p class="meta">物品数:{{ box.items|length }}</p>
{% if box.room %}<p>房间:{{ box.room }}</p>{% endif %}
{% if box.status %}<p>状态:{{ box.status }}</p>{% endif %}
{% if box.note %}<p>{{ box.note }}</p>{% endif %}
<div class="actions">
<a href="/boxes/{{ box.id }}">查看详情</a>
<a href="/boxes/{{ box.id }}/edit">编辑</a>
</div>
</section>
{% endfor %}
</div>
{% else %}
<section class="card">
<p>还没有箱子。</p>
<a href="/boxes/new">创建第一个箱子</a>
</section>
{% endif %}
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ box.name }}</h1>
<p class="muted">查看这个箱子的基本信息,以及它下面的直接物品。</p>
</div>
<div class="actions">
<a href="/boxes">返回箱子列表</a>
<a class="button" href="/boxes/{{ box.id }}/items/new">添加物品</a>
</div>
</div>
<section class="card">
<p><strong>房间:</strong> {{ box.room or '-' }}</p>
<p><strong>状态:</strong> {{ box.status or '-' }}</p>
<p><strong>备注:</strong> {{ box.note or '-' }}</p>
<div class="actions">
<a href="/boxes/{{ box.id }}/edit">编辑箱子</a>
<form method="post" action="/boxes/{{ box.id }}/delete">
<button type="submit" class="link-button">删除箱子</button>
</form>
</div>
</section>
<section class="stack">
<h2>物品</h2>
{% if box.items %}
{% for item in box.items %}
<article class="card">
<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 %}
{% if item.note %}<p><strong>备注:</strong> {{ item.note }}</p>{% endif %}
<div class="actions">
<a href="/items/{{ item.id }}">查看详情</a>
<a href="/items/{{ item.id }}/edit">编辑</a>
{% if item.is_container %}
<a href="/items/{{ item.id }}">查看内部内容</a>
{% endif %}
<form method="post" action="/items/{{ item.id }}/delete">
<button type="submit" class="link-button">删除</button>
</form>
</div>
</article>
{% endfor %}
{% else %}
<section class="card">
<p>这个箱子里还没有物品。</p>
</section>
{% endif %}
</section>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ page_title }}</h1>
<p class="muted">所属箱子:<a href="/boxes/{{ box.id }}">{{ box.name }}</a></p>
</div>
<a href="/boxes/{{ box.id }}">返回箱子</a>
</div>
<form method="post" action="{{ form_action }}" class="stack">
<label>
名称
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
</label>
<label>
数量
<input type="number" name="quantity" min="0" value="{{ item.quantity if item and item.quantity is not none else '' }}">
</label>
<label class="checkbox-row">
<input type="checkbox" name="is_container" {% if item and item.is_container %}checked{% endif %}>
这个物品本身是一个小容器
</label>
<label>
备注
<textarea name="note" rows="4">{{ item.note if item and item.note else '' }}</textarea>
</label>
<button type="submit">{{ submit_label }}</button>
</form>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ item.name }}</h1>
<p class="muted">位于箱子 <a href="/boxes/{{ item.box.id }}">{{ item.box.name }}</a></p>
</div>
<div class="actions">
<a href="/boxes/{{ item.box.id }}">返回箱子</a>
<a href="/items/{{ item.id }}/edit">编辑物品</a>
</div>
</div>
<section class="card">
<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>
<div class="actions">
<form method="post" action="/items/{{ item.id }}/delete">
<button type="submit" class="link-button">删除物品</button>
</form>
</div>
</section>
{% if item.is_container %}
<section class="stack">
<div class="page-header">
<h2>子物品</h2>
<a class="button" href="/items/{{ item.id }}/subitems/new">添加子物品</a>
</div>
{% if item.subitems %}
{% for subitem in item.subitems %}
<article class="card">
<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 %}
<div class="actions">
<a href="/subitems/{{ subitem.id }}/edit">编辑</a>
<form method="post" action="/subitems/{{ subitem.id }}/delete">
<button type="submit" class="link-button">删除</button>
</form>
</div>
</article>
{% endfor %}
{% else %}
<section class="card">
<p>还没有子物品。</p>
</section>
{% endif %}
</section>
{% else %}
<section class="card">
<p>这个物品不是容器,因此不能包含子物品。</p>
</section>
{% endif %}
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ page_title }}</h1>
<p class="muted">上级物品:<a href="/items/{{ item.id }}">{{ item.name }}</a></p>
</div>
<a href="/items/{{ item.id }}">返回物品</a>
</div>
<form method="post" action="{{ form_action }}" class="stack">
<label>
名称
<input type="text" name="name" value="{{ subitem.name if subitem else '' }}" required>
</label>
<label>
数量
<input type="number" name="quantity" min="0" value="{{ subitem.quantity if subitem and subitem.quantity is not none else '' }}">
</label>
<label>
备注
<textarea name="note" rows="4">{{ subitem.note if subitem and subitem.note else '' }}</textarea>
</label>
<button type="submit">{{ submit_label }}</button>
</form>
{% endblock %}