Add Regrade: instant re-run when photos are stored, fallback re-pick otherwise
Stores the original photo(s) for every card graded from now on, so an existing history row can be re-graded in place without re-uploading -- most useful right after a grading-logic fix like the last few commits. Cards graded before this feature (or saved without images) fall back to the same photo picker as a fresh grade, then start getting instant regrades from then on. list_grades/get_grade now select an explicit column list rather than SELECT * so the stored images are never pulled into memory just to be discarded for the browser-facing response.
This commit is contained in:
parent
552f862f4c
commit
e586bc92e7
4 changed files with 302 additions and 39 deletions
114
app.py
114
app.py
|
|
@ -260,6 +260,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
return self._json(_public_settings())
|
return self._json(_public_settings())
|
||||||
if route == "/api/grade":
|
if route == "/api/grade":
|
||||||
return self._grade_card(body)
|
return self._grade_card(body)
|
||||||
|
if route.startswith("/api/history/") and route.endswith("/regrade"):
|
||||||
|
grade_id = int(route.split("/")[3])
|
||||||
|
return self._regrade_card(grade_id, body)
|
||||||
return self._error("Not found", 404)
|
return self._error("Not found", 404)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return self._error("Bad request", 400)
|
return self._error("Bad request", 400)
|
||||||
|
|
@ -296,24 +299,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
# ------------------------------------------------------------- grade
|
# ------------------------------------------------------------- grade
|
||||||
|
|
||||||
def _grade_card(self, body):
|
def _estimate(self, images, body, settings):
|
||||||
"""Estimate the PSA grade for a card, from uploaded photo(s).
|
"""Run vision.grade_card and shape its result into a storable dict.
|
||||||
|
|
||||||
Always returns the estimate. Saves it to history too, unless the
|
Shared by fresh grading and Regrade — the only difference between
|
||||||
caller explicitly opts out with save=false — a one-off sanity check
|
those two call sites is where `images` comes from (a fresh upload vs.
|
||||||
before you've decided a card is worth logging.
|
photos already on file) and what happens to the result afterward
|
||||||
|
(insert a new row vs. overwrite an existing one), not how the
|
||||||
|
estimate itself gets produced. Raises vision.VisionError on failure;
|
||||||
|
callers turn that into an HTTP response themselves, since a 502 from
|
||||||
|
a first grade and a 502 from a regrade warrant slightly different
|
||||||
|
wording.
|
||||||
"""
|
"""
|
||||||
settings = store.get_settings()
|
|
||||||
|
|
||||||
raw_images = body.get("images") or []
|
|
||||||
if not isinstance(raw_images, list) or not raw_images:
|
|
||||||
return self._error("Send at least one image.", 400)
|
|
||||||
if len(raw_images) > MAX_GRADE_IMAGES:
|
|
||||||
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
|
|
||||||
images, error = _decode_uploaded_images(raw_images)
|
|
||||||
if error:
|
|
||||||
return self._error(error, 400)
|
|
||||||
|
|
||||||
model = body.get("model") if body.get("model") in vision.MODELS else settings.get("vision_model")
|
model = body.get("model") if body.get("model") in vision.MODELS else settings.get("vision_model")
|
||||||
|
|
||||||
# A key sent with the request wins over the server's own. That is what
|
# A key sent with the request wins over the server's own. That is what
|
||||||
|
|
@ -325,18 +322,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
print("[grade] calling vision model={} on {} image(s){}…".format(
|
print("[grade] calling vision model={} on {} image(s){}…".format(
|
||||||
model, len(images), " (caller key)" if caller_key else ""), flush=True)
|
model, len(images), " (caller key)" if caller_key else ""), flush=True)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
result = vision.grade_card(
|
||||||
result = vision.grade_card(
|
images,
|
||||||
images,
|
caller_key or settings.get("anthropic_api_key") or None,
|
||||||
caller_key or settings.get("anthropic_api_key") or None,
|
model=model,
|
||||||
model=model,
|
effort=settings.get("vision_effort"),
|
||||||
effort=settings.get("vision_effort"),
|
)
|
||||||
)
|
|
||||||
except vision.VisionError as exc:
|
|
||||||
return self._error(str(exc), 502)
|
|
||||||
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True)
|
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True)
|
||||||
|
|
||||||
grade = {
|
return {
|
||||||
"image_count": len(images),
|
"image_count": len(images),
|
||||||
"closeups": result["closeups"],
|
"closeups": result["closeups"],
|
||||||
"card_type": result["card_type"],
|
"card_type": result["card_type"],
|
||||||
|
|
@ -355,13 +349,79 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
"estimated_cost": vision.estimate_cost(result["usage"]),
|
"estimated_cost": vision.estimate_cost(result["usage"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _grade_card(self, body):
|
||||||
|
"""Estimate the PSA grade for a card, from uploaded photo(s).
|
||||||
|
|
||||||
|
Always returns the estimate. Saves it to history too, unless the
|
||||||
|
caller explicitly opts out with save=false — a one-off sanity check
|
||||||
|
before you've decided a card is worth logging.
|
||||||
|
"""
|
||||||
|
settings = store.get_settings()
|
||||||
|
|
||||||
|
raw_images = body.get("images") or []
|
||||||
|
if not isinstance(raw_images, list) or not raw_images:
|
||||||
|
return self._error("Send at least one image.", 400)
|
||||||
|
if len(raw_images) > MAX_GRADE_IMAGES:
|
||||||
|
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
|
||||||
|
images, error = _decode_uploaded_images(raw_images)
|
||||||
|
if error:
|
||||||
|
return self._error(error, 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
grade = self._estimate(images, body, settings)
|
||||||
|
except vision.VisionError as exc:
|
||||||
|
return self._error(str(exc), 502)
|
||||||
|
|
||||||
grade_id = None
|
grade_id = None
|
||||||
if body.get("save", True):
|
if body.get("save", True):
|
||||||
thumbnail = _make_thumbnail(images[0][0])
|
thumbnail = _make_thumbnail(images[0][0])
|
||||||
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"))
|
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"),
|
||||||
|
source_images=images)
|
||||||
|
|
||||||
return self._json({"grade": grade, "grade_id": grade_id}, 201)
|
return self._json({"grade": grade, "grade_id": grade_id}, 201)
|
||||||
|
|
||||||
|
def _regrade_card(self, grade_id, body):
|
||||||
|
"""Re-run grading on an existing history row, in place.
|
||||||
|
|
||||||
|
Uses the photo(s) already stored for this card unless the caller
|
||||||
|
supplies fresh ones — which happens for a card graded before this
|
||||||
|
feature existed (or saved without images some other way), where
|
||||||
|
there's nothing on file to re-run with. Either way the row is
|
||||||
|
updated rather than duplicated, and freshly-supplied photos get
|
||||||
|
stored too, so the NEXT regrade on this card is instant.
|
||||||
|
"""
|
||||||
|
existing = store.get_grade(grade_id)
|
||||||
|
if not existing:
|
||||||
|
return self._error("No such grade", 404)
|
||||||
|
|
||||||
|
raw_images = body.get("images")
|
||||||
|
newly_supplied = bool(raw_images)
|
||||||
|
if newly_supplied:
|
||||||
|
if len(raw_images) > MAX_GRADE_IMAGES:
|
||||||
|
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
|
||||||
|
images, error = _decode_uploaded_images(raw_images)
|
||||||
|
if error:
|
||||||
|
return self._error(error, 400)
|
||||||
|
else:
|
||||||
|
images = store.get_grade_images(grade_id)
|
||||||
|
if not images:
|
||||||
|
return self._error(
|
||||||
|
"No photos were saved for this card, so it can't be "
|
||||||
|
"regraded automatically — pick the photo(s) again.", 409)
|
||||||
|
|
||||||
|
settings = store.get_settings()
|
||||||
|
try:
|
||||||
|
grade = self._estimate(images, body, settings)
|
||||||
|
except vision.VisionError as exc:
|
||||||
|
return self._error(str(exc), 502)
|
||||||
|
|
||||||
|
thumbnail = _make_thumbnail(images[0][0])
|
||||||
|
grade = store.update_grade_result(
|
||||||
|
grade_id, grade, thumbnail=thumbnail,
|
||||||
|
source_images=images if newly_supplied else None)
|
||||||
|
|
||||||
|
return self._json({"grade": grade}, 200)
|
||||||
|
|
||||||
|
|
||||||
def lan_ip():
|
def lan_ip():
|
||||||
"""Best-effort LAN address, so a phone can reach this."""
|
"""Best-effort LAN address, so a phone can reach this."""
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,11 @@ function renderHistory() {
|
||||||
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
|
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
|
||||||
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
|
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
|
||||||
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
|
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
|
||||||
<td class="num"><button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button></td>
|
<td class="num">
|
||||||
|
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
|
||||||
|
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
|
||||||
|
<button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button>
|
||||||
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,6 +384,8 @@ function renderEntryModal(g) {
|
||||||
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
|
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
|
||||||
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
|
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<button class="btn btn-quiet btn-sm" data-history-regrade="${g.id}"
|
||||||
|
data-history-has-images="${g.has_source_images ? '1' : '0'}">Regrade</button>
|
||||||
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
|
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
$('#entry-modal').hidden = false;
|
$('#entry-modal').hidden = false;
|
||||||
|
|
@ -391,6 +397,64 @@ function closeEntryModal() {
|
||||||
$('#entry-scrim').hidden = true;
|
$('#entry-scrim').hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- regrade */
|
||||||
|
//
|
||||||
|
// Instant when the card's original photo(s) were saved (everything graded
|
||||||
|
// from now on); otherwise falls back to picking the photo(s) again, same as
|
||||||
|
// grading fresh, just updating this row instead of creating a new one.
|
||||||
|
|
||||||
|
let regradeTargetId = null;
|
||||||
|
|
||||||
|
function applyRegradeResult(g) {
|
||||||
|
const idx = state.history.findIndex((row) => row.id === g.id);
|
||||||
|
if (idx === -1) state.history.unshift(g); else state.history[idx] = g;
|
||||||
|
// created_at was bumped server-side to reflect the just-finished regrade,
|
||||||
|
// so re-sort to match — otherwise the card sits wherever it used to be
|
||||||
|
// until the next full reload.
|
||||||
|
state.history.sort((a, b) =>
|
||||||
|
(b.created_at || '').localeCompare(a.created_at || '') || b.id - a.id);
|
||||||
|
renderHistory();
|
||||||
|
const openDelete = $('#entry-modal').querySelector('[data-history-delete]');
|
||||||
|
if (!$('#entry-modal').hidden && openDelete && Number(openDelete.dataset.historyDelete) === g.id) {
|
||||||
|
renderEntryModal(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRegrade(id, body) {
|
||||||
|
banner('Regrading…');
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/history/${id}/regrade`, { method: 'POST', body });
|
||||||
|
applyRegradeResult(data.grade);
|
||||||
|
banner('Regraded.');
|
||||||
|
} catch (err) {
|
||||||
|
banner(`Regrade failed: ${err.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRegrade(id, hasImages) {
|
||||||
|
if (hasImages) {
|
||||||
|
runRegrade(id, {});
|
||||||
|
} else {
|
||||||
|
regradeTargetId = id;
|
||||||
|
$('#regrade-file').click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#regrade-file').addEventListener('change', async (e) => {
|
||||||
|
const id = regradeTargetId;
|
||||||
|
regradeTargetId = null;
|
||||||
|
if (!id) return;
|
||||||
|
let picked;
|
||||||
|
try {
|
||||||
|
picked = await readPickedImages(e.target, 6);
|
||||||
|
} catch (err) {
|
||||||
|
banner(err.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!picked.length) return;
|
||||||
|
await runRegrade(id, { images: pickedToPayload(picked) });
|
||||||
|
});
|
||||||
|
|
||||||
$('#history-body').addEventListener('click', async (e) => {
|
$('#history-body').addEventListener('click', async (e) => {
|
||||||
const del = e.target.closest('[data-history-delete]');
|
const del = e.target.closest('[data-history-delete]');
|
||||||
if (del) {
|
if (del) {
|
||||||
|
|
@ -399,6 +463,12 @@ $('#history-body').addEventListener('click', async (e) => {
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const regrade = e.target.closest('[data-history-regrade]');
|
||||||
|
if (regrade) {
|
||||||
|
e.stopPropagation();
|
||||||
|
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = e.target.closest('[data-history-row]');
|
const row = e.target.closest('[data-history-row]');
|
||||||
if (row) {
|
if (row) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -419,6 +489,11 @@ $('#entry-modal').addEventListener('click', async (e) => {
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const regrade = e.target.closest('[data-history-regrade]');
|
||||||
|
if (regrade) {
|
||||||
|
startRegrade(Number(regrade.dataset.historyRegrade), regrade.dataset.historyHasImages === '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.target.closest('#entry-save-label')) {
|
if (e.target.closest('#entry-save-label')) {
|
||||||
const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete;
|
const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete;
|
||||||
const label = $('#entry-label').value.trim();
|
const label = $('#entry-label').value.trim();
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@
|
||||||
<div id="grade-review"></div>
|
<div id="grade-review"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Fallback photo picker for Regrade on a card saved before this feature
|
||||||
|
existed (or otherwise missing its stored photo) — regradeTargetId in
|
||||||
|
app.js tracks which row this fires for. -->
|
||||||
|
<input id="regrade-file" type="file" accept="image/*" multiple hidden>
|
||||||
|
|
||||||
<!-- ----------------------------------------------------------- history -->
|
<!-- ----------------------------------------------------------- history -->
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
|
|
|
||||||
145
store.py
145
store.py
|
|
@ -5,6 +5,7 @@ does exactly one thing (estimate a PSA grade from photos) and remembers what
|
||||||
it told you, so you can look back at a card without re-running the estimate.
|
it told you, so you can look back at a card without re-running the estimate.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
@ -42,7 +43,10 @@ CREATE TABLE IF NOT EXISTS grades (
|
||||||
limitations_json TEXT,
|
limitations_json TEXT,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
estimated_cost REAL,
|
estimated_cost REAL,
|
||||||
usage_json TEXT
|
usage_json TEXT,
|
||||||
|
source_images_json TEXT -- the original photo(s), for Regrade; NOT sent
|
||||||
|
-- to the browser in list/detail responses —
|
||||||
|
-- see get_grade_images vs get_grade/list_grades
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
|
||||||
|
|
@ -70,10 +74,14 @@ def init():
|
||||||
# so a column added after cards were already graded needs its own
|
# so a column added after cards were already graded needs its own
|
||||||
# migration — guarded because re-running this against a database
|
# migration — guarded because re-running this against a database
|
||||||
# that already has the column would otherwise error every startup.
|
# that already has the column would otherwise error every startup.
|
||||||
try:
|
for statement in (
|
||||||
conn.execute("ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT")
|
"ALTER TABLE grades ADD COLUMN aspect_measurement_json TEXT",
|
||||||
except sqlite3.OperationalError:
|
"ALTER TABLE grades ADD COLUMN source_images_json TEXT",
|
||||||
pass
|
):
|
||||||
|
try:
|
||||||
|
conn.execute(statement)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
for key, value in DEFAULT_SETTINGS.items():
|
for key, value in DEFAULT_SETTINGS.items():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
|
@ -126,8 +134,35 @@ def save_settings(updates):
|
||||||
# ------------------------------------------------------------------ grades
|
# ------------------------------------------------------------------ grades
|
||||||
|
|
||||||
|
|
||||||
def save_grade(grade, thumbnail=None, label=None):
|
def _encode_images(images):
|
||||||
"""Persist one grading result. Returns the new row's id."""
|
"""(bytes, filename) pairs -> the JSON text stored in source_images_json."""
|
||||||
|
if not images:
|
||||||
|
return None
|
||||||
|
return json.dumps([
|
||||||
|
{"filename": name, "image_base64": base64.standard_b64encode(b).decode("ascii")}
|
||||||
|
for b, name in images
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_images(raw):
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
items = json.loads(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return [(base64.standard_b64decode(item["image_base64"]), item.get("filename") or "upload")
|
||||||
|
for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
def save_grade(grade, thumbnail=None, label=None, source_images=None):
|
||||||
|
"""Persist one grading result. Returns the new row's id.
|
||||||
|
|
||||||
|
`source_images` are the original (bytes, filename) pairs that produced
|
||||||
|
this grade, kept so Regrade can re-run without asking for the photo(s)
|
||||||
|
again. Optional — a caller that skips this still gets everything else;
|
||||||
|
Regrade just falls back to prompting for photos on that row.
|
||||||
|
"""
|
||||||
conn = connect()
|
conn = connect()
|
||||||
try:
|
try:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
|
|
@ -137,8 +172,9 @@ def save_grade(grade, thumbnail=None, label=None):
|
||||||
" grade_low, grade_high, confidence, categories_json, "
|
" grade_low, grade_high, confidence, categories_json, "
|
||||||
" edge_measurements_json, centering_measurement_json, "
|
" edge_measurements_json, centering_measurement_json, "
|
||||||
" aspect_measurement_json, "
|
" aspect_measurement_json, "
|
||||||
" limitations_json, note, estimated_cost, usage_json) "
|
" limitations_json, note, estimated_cost, usage_json, "
|
||||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
" source_images_json) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
now(), label, grade.get("card_type"), grade.get("card_note"),
|
now(), label, grade.get("card_type"), grade.get("card_note"),
|
||||||
grade.get("image_count"), thumbnail,
|
grade.get("image_count"), thumbnail,
|
||||||
|
|
@ -152,6 +188,7 @@ def save_grade(grade, thumbnail=None, label=None):
|
||||||
json.dumps(grade.get("limitations")),
|
json.dumps(grade.get("limitations")),
|
||||||
grade.get("note"), grade.get("estimated_cost"),
|
grade.get("note"), grade.get("estimated_cost"),
|
||||||
json.dumps(grade.get("usage")),
|
json.dumps(grade.get("usage")),
|
||||||
|
_encode_images(source_images),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -160,6 +197,69 @@ def save_grade(grade, thumbnail=None, label=None):
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def update_grade_result(grade_id, grade, thumbnail=None, source_images=None):
|
||||||
|
"""Overwrite a grade's result in place (Regrade) — label is untouched.
|
||||||
|
|
||||||
|
created_at is bumped to now, so the card floats back to the top of
|
||||||
|
History as the most recently-active one, same as if it were freshly
|
||||||
|
graded. `source_images` only overwrites the stored photo(s) when the
|
||||||
|
caller actually supplies new ones (the re-pick fallback path); passing
|
||||||
|
None leaves whatever was already stored for this row alone.
|
||||||
|
"""
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
params = [
|
||||||
|
now(), grade.get("card_type"), grade.get("card_note"),
|
||||||
|
grade.get("image_count"),
|
||||||
|
(grade.get("usage") or {}).get("model"),
|
||||||
|
grade.get("estimated_grade"), grade.get("grade_low"),
|
||||||
|
grade.get("grade_high"), grade.get("confidence"),
|
||||||
|
json.dumps(grade.get("categories")),
|
||||||
|
json.dumps(grade.get("edge_measurements")),
|
||||||
|
json.dumps(grade.get("centering_measurement")),
|
||||||
|
json.dumps(grade.get("aspect_measurement")),
|
||||||
|
json.dumps(grade.get("limitations")),
|
||||||
|
grade.get("note"), grade.get("estimated_cost"),
|
||||||
|
json.dumps(grade.get("usage")),
|
||||||
|
]
|
||||||
|
sql = (
|
||||||
|
"UPDATE grades SET created_at=?, card_type=?, card_note=?, "
|
||||||
|
"image_count=?, model=?, estimated_grade=?, grade_low=?, "
|
||||||
|
"grade_high=?, confidence=?, categories_json=?, "
|
||||||
|
"edge_measurements_json=?, centering_measurement_json=?, "
|
||||||
|
"aspect_measurement_json=?, limitations_json=?, note=?, "
|
||||||
|
"estimated_cost=?, usage_json=?"
|
||||||
|
)
|
||||||
|
if thumbnail is not None:
|
||||||
|
sql += ", thumbnail=?"
|
||||||
|
params.append(thumbnail)
|
||||||
|
if source_images is not None:
|
||||||
|
sql += ", source_images_json=?"
|
||||||
|
params.append(_encode_images(source_images))
|
||||||
|
sql += " WHERE id=?"
|
||||||
|
params.append(grade_id)
|
||||||
|
conn.execute(sql, params)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return get_grade(grade_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_grade_images(grade_id):
|
||||||
|
"""The original (bytes, filename) pairs for Regrade, or None if this
|
||||||
|
grade never had them stored (a card graded before this feature existed,
|
||||||
|
or one saved without the images path). Server-side use only — never
|
||||||
|
sent to the browser, unlike everything get_grade/list_grades return."""
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT source_images_json FROM grades WHERE id = ?", (grade_id,)
|
||||||
|
).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return _decode_images(row["source_images_json"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
def _row_to_grade(row):
|
def _row_to_grade(row):
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
for key in ("categories_json", "edge_measurements_json",
|
for key in ("categories_json", "edge_measurements_json",
|
||||||
|
|
@ -171,14 +271,34 @@ def _row_to_grade(row):
|
||||||
d[out_key] = json.loads(raw) if raw else None
|
d[out_key] = json.loads(raw) if raw else None
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
d[out_key] = None
|
d[out_key] = None
|
||||||
|
# The raw photo(s) are only ever for the regrade endpoint to read
|
||||||
|
# server-side (see get_grade_images) — swapping this for a boolean here
|
||||||
|
# keeps every browser-facing response light, the same reason thumbnails
|
||||||
|
# are pre-shrunk rather than sending the original photo for display.
|
||||||
|
d["has_source_images"] = bool(d.pop("source_images_json", None))
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# Every column except source_images_json — that one is only ever read
|
||||||
|
# through get_grade_images, so a plain SELECT * here would pull the full
|
||||||
|
# original photo(s) off disk for every row just to discard them a moment
|
||||||
|
# later in _row_to_grade, silently defeating the whole point of keeping
|
||||||
|
# list/detail responses light.
|
||||||
|
_LIST_COLUMNS = (
|
||||||
|
"id, created_at, label, card_type, card_note, image_count, thumbnail, "
|
||||||
|
"model, estimated_grade, grade_low, grade_high, confidence, "
|
||||||
|
"categories_json, edge_measurements_json, centering_measurement_json, "
|
||||||
|
"aspect_measurement_json, limitations_json, note, estimated_cost, "
|
||||||
|
"usage_json, (source_images_json IS NOT NULL) AS source_images_json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_grades(limit=200):
|
def list_grades(limit=200):
|
||||||
conn = connect()
|
conn = connect()
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM grades ORDER BY created_at DESC, id DESC LIMIT ?",
|
"SELECT {} FROM grades ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||||
|
.format(_LIST_COLUMNS),
|
||||||
(limit,),
|
(limit,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -189,7 +309,10 @@ def list_grades(limit=200):
|
||||||
def get_grade(grade_id):
|
def get_grade(grade_id):
|
||||||
conn = connect()
|
conn = connect()
|
||||||
try:
|
try:
|
||||||
row = conn.execute("SELECT * FROM grades WHERE id = ?", (grade_id,)).fetchone()
|
row = conn.execute(
|
||||||
|
"SELECT {} FROM grades WHERE id = ?".format(_LIST_COLUMNS),
|
||||||
|
(grade_id,),
|
||||||
|
).fetchone()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return _row_to_grade(row) if row else None
|
return _row_to_grade(row) if row else None
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue