Record planting day and show harvest dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07057e6c3d
commit
83a9cb6dcc
11 changed files with 282 additions and 44 deletions
93
app.py
93
app.py
|
|
@ -21,12 +21,25 @@ APP_DIR = Path(__file__).resolve().parent
|
|||
STATIC_DIR = APP_DIR / "static"
|
||||
ITEM_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,79}$")
|
||||
MAX_BODY = 2048
|
||||
SEASONS = ("spring", "summer", "fall", "winter")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def valid_game_date(value: object) -> bool:
|
||||
def whole(number: object, low: int, high: int) -> bool:
|
||||
return isinstance(number, int) and not isinstance(number, bool) and low <= number <= high
|
||||
|
||||
return (
|
||||
isinstance(value, dict)
|
||||
and whole(value.get("year"), 1, 2)
|
||||
and value.get("season") in SEASONS
|
||||
and whole(value.get("day"), 1, 28)
|
||||
)
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = str(path)
|
||||
|
|
@ -53,8 +66,32 @@ class Store:
|
|||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO metadata (key, value) VALUES ('revision', '0');
|
||||
-- A NULL date means the crop was planted before dates were recorded.
|
||||
CREATE TABLE IF NOT EXISTS plantings (
|
||||
item_id TEXT PRIMARY KEY,
|
||||
year INTEGER,
|
||||
season TEXT,
|
||||
day INTEGER,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
# Version 1.4 stored planting as an undated "planted-<crop>" check.
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO plantings (item_id, updated_at, updated_by)
|
||||
SELECT substr(item_id, 9), updated_at, updated_by FROM checks
|
||||
WHERE item_id LIKE 'planted-%' AND checked = 1
|
||||
"""
|
||||
)
|
||||
if db.execute("DELETE FROM checks WHERE item_id LIKE 'planted-%'").rowcount:
|
||||
self._bump_revision(db)
|
||||
|
||||
@staticmethod
|
||||
def _bump_revision(db: sqlite3.Connection) -> int:
|
||||
db.execute("UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'")
|
||||
return int(db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0])
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with closing(self.connect()) as db, db:
|
||||
|
|
@ -67,11 +104,47 @@ class Store:
|
|||
latest_row = db.execute(
|
||||
"SELECT updated_at, updated_by FROM checks ORDER BY updated_at DESC, rowid DESC LIMIT 1"
|
||||
).fetchone()
|
||||
plantings = db.execute(
|
||||
"SELECT item_id, year, season, day FROM plantings ORDER BY item_id"
|
||||
).fetchall()
|
||||
return {
|
||||
"revision": revision,
|
||||
"updatedAt": latest_row["updated_at"] if latest_row else None,
|
||||
"updatedBy": latest_row["updated_by"] if latest_row else None,
|
||||
"checks": {row["item_id"]: bool(row["checked"]) for row in rows},
|
||||
"plantings": {
|
||||
row["item_id"]: {"year": row["year"], "season": row["season"], "day": row["day"]}
|
||||
for row in plantings
|
||||
},
|
||||
}
|
||||
|
||||
def set_planting(self, item_id: str, planted: dict | None, user: str) -> dict:
|
||||
changed_at = utc_now()
|
||||
with closing(self.connect()) as db, db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
if planted is None:
|
||||
db.execute("DELETE FROM plantings WHERE item_id = ?", (item_id,))
|
||||
else:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO plantings (item_id, year, season, day, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item_id) DO UPDATE SET
|
||||
year = excluded.year,
|
||||
season = excluded.season,
|
||||
day = excluded.day,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
""",
|
||||
(item_id, planted["year"], planted["season"], planted["day"], changed_at, user),
|
||||
)
|
||||
revision = self._bump_revision(db)
|
||||
return {
|
||||
"itemId": item_id,
|
||||
"planted": planted,
|
||||
"revision": revision,
|
||||
"updatedAt": changed_at,
|
||||
"updatedBy": user,
|
||||
}
|
||||
|
||||
def set_check(self, item_id: str, checked: bool, user: str) -> dict:
|
||||
|
|
@ -89,12 +162,7 @@ class Store:
|
|||
""",
|
||||
(item_id, int(checked), changed_at, user),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'revision'"
|
||||
)
|
||||
revision = int(
|
||||
db.execute("SELECT value FROM metadata WHERE key = 'revision'").fetchone()[0]
|
||||
)
|
||||
revision = self._bump_revision(db)
|
||||
return {
|
||||
"itemId": item_id,
|
||||
"checked": checked,
|
||||
|
|
@ -153,7 +221,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path != "/api/checks":
|
||||
if path not in ("/api/checks", "/api/plantings"):
|
||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if self.headers.get_content_type() != "application/json":
|
||||
|
|
@ -173,10 +241,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
item_id = payload.get("itemId") if isinstance(payload, dict) else None
|
||||
checked = payload.get("checked") if isinstance(payload, dict) else None
|
||||
if not isinstance(item_id, str) or not ITEM_ID.fullmatch(item_id):
|
||||
self.error_json("Invalid itemId", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if path == "/api/plantings":
|
||||
planted = payload.get("planted")
|
||||
if planted is not None and not valid_game_date(planted):
|
||||
self.error_json("planted must be null or a {year, season, day} date", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if planted is not None:
|
||||
planted = {key: planted[key] for key in ("year", "season", "day")}
|
||||
self.json_response(self.server.store.set_planting(item_id, planted, self.authenticated_user()))
|
||||
return
|
||||
checked = payload.get("checked")
|
||||
if not isinstance(checked, bool):
|
||||
self.error_json("checked must be boolean", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue