card-grader/store.py
Barely Removable c7bd71a3e1 Initial commit: Card Grader deployed to hippofam.com/cards
PWA card-grading app, deployed behind Nginx Proxy Manager on Unraid with
basic auth. Includes CARD_GRADER_BASE_PATH support for running under a
sub-path, and Docker/compose config for the Unraid deployment.
2026-08-22 09:22:41 -07:00

202 lines
6 KiB
Python

"""SQLite: settings and a log of past grade estimates.
One database, two tables. There is no inventory or pricing here — this app
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.
"""
import json
import os
import sqlite3
import time
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Overridable so a container can point this at a mounted volume (e.g.
# /data/grades.db) instead of the app's own directory, which is what makes
# the data survive a container recreate/image update.
DB_PATH = os.environ.get("CARD_GRADER_DB_PATH") or os.path.join(BASE_DIR, "grades.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS grades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
label TEXT, -- your own name for the card, optional
card_type TEXT, -- pokemon | sports | other_tcg | other
card_note TEXT, -- what the model read off the card
image_count INTEGER,
thumbnail TEXT, -- small JPEG, base64 — first photo only
model TEXT,
estimated_grade INTEGER,
grade_low INTEGER,
grade_high INTEGER,
confidence TEXT,
categories_json TEXT,
edge_measurements_json TEXT,
centering_measurement_json TEXT,
limitations_json TEXT,
note TEXT,
estimated_cost REAL,
usage_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
"""
DEFAULT_SETTINGS = {
"anthropic_api_key": "",
"vision_model": "claude-sonnet-5",
"vision_effort": "low",
}
def connect():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init():
conn = connect()
try:
conn.executescript(SCHEMA)
for key, value in DEFAULT_SETTINGS.items():
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
(key, json.dumps(value)),
)
conn.commit()
finally:
conn.close()
def now():
return time.strftime("%Y-%m-%dT%H:%M:%S")
# --------------------------------------------------------------- settings
def get_settings():
conn = connect()
try:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
finally:
conn.close()
out = dict(DEFAULT_SETTINGS)
for row in rows:
try:
out[row["key"]] = json.loads(row["value"])
except (ValueError, TypeError):
out[row["key"]] = row["value"]
return out
def save_settings(updates):
conn = connect()
try:
for key, value in updates.items():
if key not in DEFAULT_SETTINGS:
continue
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, json.dumps(value)),
)
conn.commit()
finally:
conn.close()
return get_settings()
# ------------------------------------------------------------------ grades
def save_grade(grade, thumbnail=None, label=None):
"""Persist one grading result. Returns the new row's id."""
conn = connect()
try:
cur = conn.execute(
"INSERT INTO grades "
"(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, "
" limitations_json, note, estimated_cost, usage_json) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail,
(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("limitations")),
grade.get("note"), grade.get("estimated_cost"),
json.dumps(grade.get("usage")),
),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def _row_to_grade(row):
d = dict(row)
for key in ("categories_json", "edge_measurements_json",
"centering_measurement_json", "limitations_json", "usage_json"):
out_key = key[:-len("_json")]
raw = d.pop(key, None)
try:
d[out_key] = json.loads(raw) if raw else None
except (ValueError, TypeError):
d[out_key] = None
return d
def list_grades(limit=200):
conn = connect()
try:
rows = conn.execute(
"SELECT * FROM grades ORDER BY created_at DESC, id DESC LIMIT ?",
(limit,),
).fetchall()
finally:
conn.close()
return [_row_to_grade(r) for r in rows]
def get_grade(grade_id):
conn = connect()
try:
row = conn.execute("SELECT * FROM grades WHERE id = ?", (grade_id,)).fetchone()
finally:
conn.close()
return _row_to_grade(row) if row else None
def update_grade_label(grade_id, label):
conn = connect()
try:
conn.execute("UPDATE grades SET label = ? WHERE id = ?", (label, grade_id))
conn.commit()
finally:
conn.close()
return get_grade(grade_id)
def delete_grade(grade_id):
conn = connect()
try:
conn.execute("DELETE FROM grades WHERE id = ?", (grade_id,))
conn.commit()
finally:
conn.close()