131 lines
5.4 KiB
Python
131 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Export immutable ready preparation deliveries for Android."""
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from trainlog_sqlite import connect_database
|
|
|
|
MAX_DELIVERIES = 128
|
|
MAX_ENTRIES = 64
|
|
MAX_WITHDRAWALS = 128
|
|
|
|
|
|
def build_export(connection: sqlite3.Connection) -> dict:
|
|
if connection.execute("PRAGMA user_version").fetchone()[0] != 24:
|
|
raise ValueError("desktop schema v24 required")
|
|
deliveries = []
|
|
rows = connection.execute(
|
|
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"
|
|
"r.title,r.session_type,r.planned_for,r.notes,p.source_proposal_id,p.source_payload_sha256 "
|
|
"FROM session_preparation_deliveries d "
|
|
"JOIN session_preparations p ON p.preparation_id=d.preparation_id "
|
|
"JOIN session_preparation_revisions r ON r.revision_id=d.revision_id "
|
|
"WHERE p.withdrawn_at IS NULL AND d.state IN('pending','remote_unknown') "
|
|
"ORDER BY d.created_at,d.delivery_id LIMIT ?",
|
|
(MAX_DELIVERIES + 1,),
|
|
).fetchall()
|
|
if len(rows) > MAX_DELIVERIES:
|
|
raise ValueError("preparation delivery capacity exceeded")
|
|
for row in rows:
|
|
entries = connection.execute(
|
|
"SELECT entry_id,position,exercise_id,equipment_id,recording_mode,tracking_mode,"
|
|
"data_fields,load_mode,rest_seconds,target_sets,target_reps,target_duration_seconds,"
|
|
"target_weight_kg,notes FROM session_preparation_entries "
|
|
"WHERE revision_id=? ORDER BY position,entry_id LIMIT ?",
|
|
(row[2], MAX_ENTRIES + 1),
|
|
).fetchall()
|
|
if not entries or len(entries) > MAX_ENTRIES:
|
|
raise ValueError("invalid preparation occurrence count")
|
|
deliveries.append({
|
|
"delivery_id": row[0], "preparation_id": row[1], "revision_id": row[2],
|
|
"execution_session_id": row[3], "state": row[4], "title": row[5],
|
|
"session_type": row[6], "planned_for": row[7], "notes": row[8],
|
|
"source_proposal_id": row[9], "source_payload_sha256": row[10],
|
|
"occurrences": [{
|
|
"entry_id": entry[0], "position": entry[1], "exercise_id": entry[2],
|
|
"equipment_id": entry[3], "recording_mode": entry[4],
|
|
"tracking_mode": entry[5], "data_fields": entry[6],
|
|
"load_mode": entry[7], "rest_seconds": entry[8],
|
|
"target_sets": entry[9], "target_reps": entry[10],
|
|
"target_duration_seconds": entry[11], "target_weight_kg": entry[12],
|
|
"notes": entry[13],
|
|
} for entry in entries],
|
|
})
|
|
withdrawals = []
|
|
withdrawal_rows = connection.execute(
|
|
"SELECT withdrawal_id,preparation_id,revision_id,requested_at "
|
|
"FROM session_preparation_withdrawals WHERE acknowledged_at IS NULL "
|
|
"ORDER BY requested_at,withdrawal_id LIMIT ?",
|
|
(MAX_WITHDRAWALS + 1,),
|
|
).fetchall()
|
|
if len(withdrawal_rows) > MAX_WITHDRAWALS:
|
|
raise ValueError("preparation withdrawal capacity exceeded")
|
|
for row in withdrawal_rows:
|
|
related = connection.execute(
|
|
"SELECT delivery_id,revision_id,execution_session_id "
|
|
"FROM session_preparation_deliveries WHERE preparation_id=? "
|
|
"ORDER BY created_at,delivery_id LIMIT ?",
|
|
(row[1], MAX_DELIVERIES + 1),
|
|
).fetchall()
|
|
if len(related) > MAX_DELIVERIES:
|
|
raise ValueError("preparation withdrawal delivery capacity exceeded")
|
|
withdrawals.append({
|
|
"withdrawal_id": row[0],
|
|
"preparation_id": row[1],
|
|
"revision_id": row[2],
|
|
"requested_at": row[3],
|
|
"deliveries": [{
|
|
"delivery_id": delivery[0],
|
|
"revision_id": delivery[1],
|
|
"execution_session_id": delivery[2],
|
|
} for delivery in related],
|
|
})
|
|
return {
|
|
"format": "trainlog-session-preparations", "version": 2,
|
|
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
"deliveries": deliveries,
|
|
"withdrawals": withdrawals,
|
|
}
|
|
|
|
|
|
def write_atomic(path: Path, payload: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temporary, path)
|
|
except Exception:
|
|
try:
|
|
os.unlink(temporary)
|
|
except FileNotFoundError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("output", type=Path)
|
|
parser.add_argument("--database", required=True, type=Path)
|
|
arguments = parser.parse_args()
|
|
try:
|
|
with connect_database(arguments.database) as connection:
|
|
payload = build_export(connection)
|
|
write_atomic(arguments.output, payload)
|
|
print(f"SESSION_PREPARATION_EXPORT=PASS deliveries={len(payload['deliveries'])}")
|
|
return 0
|
|
except (OSError, sqlite3.Error, ValueError) as error:
|
|
print(f"SESSION_PREPARATION_EXPORT=FAIL {error}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|