951 lines
31 KiB
Python
951 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Edge production counter dashboard.
|
||
Reads counter.db + current_counter.json from the counter stack.
|
||
Tracks daily per-zone sack counts per counting day (no batches).
|
||
Default port 5000.
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import time
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
|
||
|
||
from flask import Flask, render_template, jsonify, request, Response, send_file
|
||
from werkzeug.serving import WSGIRequestHandler
|
||
|
||
from dotenv import load_dotenv
|
||
load_dotenv()
|
||
|
||
from zones_config import load_zones, zone_meta
|
||
|
||
|
||
app = Flask(__name__, template_folder="templates")
|
||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
||
|
||
_DEFAULT_DIR = "/opt/jetson-counter"
|
||
DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db")
|
||
CURRENT_COUNTER_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_COUNTER_PATH", f"{_DEFAULT_DIR}/current_counter.json"))
|
||
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
|
||
|
||
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
||
|
||
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots")
|
||
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
|
||
|
||
CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true"
|
||
CONTROL_FILE = os.getenv("CONTROL_FILE", f"{_DEFAULT_DIR}/control.json")
|
||
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
|
||
|
||
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
||
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
||
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "object")
|
||
|
||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||
|
||
ZONES = load_zones(camera_name=CAMERA_NAME)
|
||
ZONES_META = zone_meta(ZONES)
|
||
ZONE_IDS = [z["id"] for z in ZONES_META]
|
||
|
||
@app.route("/api/live-video")
|
||
def api_live_video():
|
||
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
|
||
return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503
|
||
|
||
def generate():
|
||
consecutive_fails = 0
|
||
MAX_FAILS = 30
|
||
while True:
|
||
try:
|
||
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
|
||
jpeg = f.read()
|
||
if not jpeg or len(jpeg) < 2 or jpeg[:2] != b"\xff\xd8":
|
||
consecutive_fails += 1
|
||
if consecutive_fails >= MAX_FAILS:
|
||
return
|
||
time.sleep(0.05)
|
||
continue
|
||
consecutive_fails = 0
|
||
yield (b"--frame\r\n"
|
||
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
|
||
except FileNotFoundError:
|
||
consecutive_fails += 1
|
||
if consecutive_fails >= MAX_FAILS:
|
||
return
|
||
time.sleep(1.0)
|
||
continue
|
||
except Exception:
|
||
consecutive_fails += 1
|
||
if consecutive_fails >= MAX_FAILS:
|
||
return
|
||
time.sleep(0.5)
|
||
continue
|
||
time.sleep(0.05)
|
||
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
|
||
|
||
|
||
_SNAP_RE = re.compile(
|
||
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>detect|zone_\d+)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
||
)
|
||
# Also accept legacy left/right snapshot filenames.
|
||
_SNAP_RE_LEGACY = re.compile(
|
||
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>left|right)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
||
)
|
||
_LEGACY_KIND_MAP = {"left": "zone_1", "right": "zone_2"}
|
||
|
||
|
||
def _parse_snapshot(path, category):
|
||
m = _SNAP_RE.match(path.name) or _SNAP_RE_LEGACY.match(path.name)
|
||
if not m:
|
||
return None
|
||
try:
|
||
dt = datetime.strptime(m.group("ts"), "%Y%m%d_%H%M%S_%f")
|
||
except ValueError:
|
||
dt = datetime.fromtimestamp(path.stat().st_mtime)
|
||
kind = m.group("kind")
|
||
kind = _LEGACY_KIND_MAP.get(kind, kind)
|
||
return {
|
||
"file": f"{category}/{path.name}",
|
||
"category": category,
|
||
"kind": kind,
|
||
"track_id": int(m.group("tid")),
|
||
"frame": int(m.group("frame")),
|
||
"timestamp": dt.isoformat(),
|
||
"mtime": path.stat().st_mtime,
|
||
}
|
||
|
||
|
||
def _collect_snapshots():
|
||
base = os.path.abspath(CROSS_SNAPSHOT_DIR)
|
||
items = []
|
||
for category in ("cross", "detect"):
|
||
sub = os.path.join(base, category)
|
||
if not os.path.isdir(sub):
|
||
continue
|
||
for name in os.listdir(sub):
|
||
if not name.lower().endswith(".jpg"):
|
||
continue
|
||
info = _parse_snapshot(Path(sub) / name, category)
|
||
if info:
|
||
items.append(info)
|
||
items.sort(key=lambda x: x["mtime"], reverse=True)
|
||
return items
|
||
|
||
|
||
@app.route("/api/snapshots")
|
||
def api_snapshots():
|
||
try:
|
||
kind = request.args.get("kind", "all")
|
||
track_id = request.args.get("track_id", type=int)
|
||
date = request.args.get("date")
|
||
limit = request.args.get("limit", 200, type=int)
|
||
|
||
items = _collect_snapshots()
|
||
if kind and kind != "all":
|
||
if kind == "cross":
|
||
items = [i for i in items if i["category"] == "cross"]
|
||
elif kind == "detect":
|
||
items = [i for i in items if i["category"] == "detect"]
|
||
else:
|
||
# zone_N or legacy left/right (already remapped in parse)
|
||
items = [i for i in items if i["kind"] == kind]
|
||
if track_id is not None:
|
||
items = [i for i in items if i["track_id"] == track_id]
|
||
if date:
|
||
items = [i for i in items if i["timestamp"][:10] == date]
|
||
|
||
total = len(items)
|
||
items = items[:limit]
|
||
for i in items:
|
||
i.pop("mtime", None)
|
||
return jsonify({"success": True, "total": total, "count": len(items), "snapshots": items})
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e), "snapshots": []}), 200
|
||
|
||
|
||
@app.route("/api/snapshot-image/<category>/<path:filename>")
|
||
def api_snapshot_image(category, filename):
|
||
if category not in ("cross", "detect"):
|
||
return jsonify({"success": False, "error": "invalid category"}), 404
|
||
base = os.path.abspath(os.path.join(CROSS_SNAPSHOT_DIR, category))
|
||
requested = os.path.abspath(os.path.join(base, filename))
|
||
if not requested.startswith(base + os.sep) or not os.path.isfile(requested):
|
||
return jsonify({"success": False, "error": "not found"}), 404
|
||
return send_file(requested, mimetype="image/jpeg")
|
||
|
||
|
||
def _ensure_db():
|
||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||
conn = sqlite3.connect(DB_PATH)
|
||
cur = conn.cursor()
|
||
cur.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS daily_counters (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
counting_date TEXT NOT NULL,
|
||
camera_name TEXT NOT NULL,
|
||
object_label TEXT NOT NULL,
|
||
total_count INTEGER NOT NULL DEFAULT 0,
|
||
start_time TEXT,
|
||
end_time TEXT,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE(counting_date, camera_name, object_label)
|
||
)
|
||
"""
|
||
)
|
||
cur.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS daily_zone_counts (
|
||
counting_date TEXT NOT NULL,
|
||
camera_name TEXT NOT NULL,
|
||
object_label TEXT NOT NULL,
|
||
zone_id TEXT NOT NULL,
|
||
count INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (counting_date, camera_name, object_label, zone_id)
|
||
)
|
||
"""
|
||
)
|
||
# Lightweight migrate if legacy columns still exist (counter store does full migrate).
|
||
cur.execute("PRAGMA table_info(daily_counters)")
|
||
cols = {row[1] for row in cur.fetchall()}
|
||
if "total_left" in cols or "total_right" in cols:
|
||
cur.execute(
|
||
"""
|
||
SELECT counting_date, camera_name, object_label,
|
||
COALESCE(total_left, 0), COALESCE(total_right, 0)
|
||
FROM daily_counters
|
||
"""
|
||
)
|
||
for counting_date, camera_name, object_label, left, right in cur.fetchall():
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO daily_zone_counts
|
||
(counting_date, camera_name, object_label, zone_id, count)
|
||
VALUES (?, ?, ?, 'zone_1', ?)
|
||
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||
DO UPDATE SET count = excluded.count
|
||
""",
|
||
(counting_date, camera_name, object_label, left),
|
||
)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO daily_zone_counts
|
||
(counting_date, camera_name, object_label, zone_id, count)
|
||
VALUES (?, ?, ?, 'zone_2', ?)
|
||
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||
DO UPDATE SET count = excluded.count
|
||
""",
|
||
(counting_date, camera_name, object_label, right),
|
||
)
|
||
cur.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS daily_counters_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
counting_date TEXT NOT NULL,
|
||
camera_name TEXT NOT NULL,
|
||
object_label TEXT NOT NULL,
|
||
total_count INTEGER NOT NULL DEFAULT 0,
|
||
start_time TEXT,
|
||
end_time TEXT,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
UNIQUE(counting_date, camera_name, object_label)
|
||
)
|
||
"""
|
||
)
|
||
cur.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO daily_counters_new
|
||
(id, counting_date, camera_name, object_label,
|
||
total_count, start_time, end_time, updated_at)
|
||
SELECT id, counting_date, camera_name, object_label,
|
||
total_count, start_time, end_time, updated_at
|
||
FROM daily_counters
|
||
"""
|
||
)
|
||
cur.execute("DROP TABLE daily_counters")
|
||
cur.execute("ALTER TABLE daily_counters_new RENAME TO daily_counters")
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
_ensure_db()
|
||
|
||
|
||
def get_db():
|
||
conn = sqlite3.connect(DB_PATH)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
|
||
def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||
if dt is None:
|
||
dt = datetime.now()
|
||
cutoff = datetime.strptime(cutoff_str, "%H:%M").time()
|
||
if dt.time() < cutoff:
|
||
return dt.date().isoformat()
|
||
return (dt.date() + timedelta(days=1)).isoformat()
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
return render_template(
|
||
"dashboard.html",
|
||
site_name=SITE_NAME,
|
||
show_detect=SAVE_DETECT_SNAPSHOT,
|
||
control_enabled=CONTROL_ENABLED,
|
||
zones=ZONES_META,
|
||
)
|
||
|
||
|
||
def _read_counting_flag():
|
||
try:
|
||
with open(CONTROL_FILE, "r", encoding="utf-8") as f:
|
||
return bool(json.load(f).get("counting", CONTROL_DEFAULT_COUNTING))
|
||
except FileNotFoundError:
|
||
return CONTROL_DEFAULT_COUNTING
|
||
except Exception:
|
||
return CONTROL_DEFAULT_COUNTING
|
||
|
||
|
||
def _write_counting_flag(counting):
|
||
os.makedirs(os.path.dirname(CONTROL_FILE) or ".", exist_ok=True)
|
||
tmp = f"{CONTROL_FILE}.tmp"
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump({"counting": bool(counting)}, f)
|
||
os.replace(tmp, CONTROL_FILE)
|
||
|
||
|
||
@app.route("/api/control", methods=["GET"])
|
||
def api_control_get():
|
||
return jsonify({
|
||
"success": True,
|
||
"enabled": CONTROL_ENABLED,
|
||
"counting": _read_counting_flag(),
|
||
})
|
||
|
||
|
||
@app.route("/api/control", methods=["POST"])
|
||
def api_control_set():
|
||
if not CONTROL_ENABLED:
|
||
return jsonify({"success": False, "error": "Runtime control is disabled (set CONTROL_ENABLED=true)"}), 403
|
||
data = request.get_json(silent=True) or {}
|
||
if "counting" not in data:
|
||
return jsonify({"success": False, "error": "Missing 'counting' field"}), 400
|
||
try:
|
||
counting = bool(data["counting"])
|
||
_write_counting_flag(counting)
|
||
return jsonify({"success": True, "counting": counting})
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/snapshots")
|
||
def snapshots_page():
|
||
return render_template(
|
||
"snapshots.html",
|
||
site_name=SITE_NAME,
|
||
show_detect=SAVE_DETECT_SNAPSHOT,
|
||
zones=ZONES_META,
|
||
)
|
||
|
||
|
||
def _empty_counts():
|
||
return {zid: 0 for zid in ZONE_IDS}
|
||
|
||
|
||
def _empty_current():
|
||
return {
|
||
"counting_date": get_counting_date(),
|
||
"total_count": 0,
|
||
"counts": _empty_counts(),
|
||
"zones": ZONES_META,
|
||
"start_time": None,
|
||
"last_detection_time": None,
|
||
}
|
||
|
||
|
||
def _normalize_counts(raw):
|
||
counts = _empty_counts()
|
||
if isinstance(raw, dict):
|
||
for zid in ZONE_IDS:
|
||
counts[zid] = int(raw.get(zid, 0) or 0)
|
||
return counts
|
||
return counts
|
||
|
||
|
||
def _load_current_state():
|
||
"""Load live counter state from current_counter.json."""
|
||
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
counts = data.get("counts")
|
||
if not isinstance(counts, dict):
|
||
counts = {
|
||
"zone_1": int(data.get("count_left", 0) or 0),
|
||
"zone_2": int(data.get("count_right", 0) or 0),
|
||
}
|
||
counts = _normalize_counts(counts)
|
||
# Prefer total_count; fall back to legacy "count" then sum of zones.
|
||
if data.get("total_count") is not None:
|
||
total = int(data.get("total_count") or 0)
|
||
elif data.get("count") is not None:
|
||
total = int(data.get("count") or 0)
|
||
else:
|
||
total = sum(counts.values())
|
||
return {
|
||
"counting_date": data.get("counting_date") or get_counting_date(),
|
||
"total_count": total,
|
||
"counts": counts,
|
||
"zones": ZONES_META,
|
||
"start_time": data.get("start_time"),
|
||
"last_detection_time": data.get("last_detection_time"),
|
||
}
|
||
|
||
|
||
def _zone_counts_for_days(conn, dates):
|
||
"""Return {counting_date: {zone_id: count}} for the given dates."""
|
||
if not dates:
|
||
return {}
|
||
cur = conn.cursor()
|
||
placeholders = ",".join("?" for _ in dates)
|
||
cur.execute(
|
||
f"""
|
||
SELECT counting_date, zone_id, COALESCE(count, 0)
|
||
FROM daily_zone_counts
|
||
WHERE counting_date IN ({placeholders})
|
||
""",
|
||
list(dates),
|
||
)
|
||
out = {d: _empty_counts() for d in dates}
|
||
for counting_date, zone_id, count in cur.fetchall():
|
||
if counting_date in out and zone_id in out[counting_date]:
|
||
out[counting_date][zone_id] = int(count)
|
||
return out
|
||
|
||
|
||
def _zero_day_row(date_str, camera_name=None, object_label=None):
|
||
"""Stub row for a counting day with no activity."""
|
||
counts = _empty_counts()
|
||
return {
|
||
"date": date_str,
|
||
"camera_name": camera_name or CAMERA_NAME,
|
||
"object_label": object_label or OBJECT_LABEL,
|
||
"total_count": 0,
|
||
"counts": counts,
|
||
"zones": ZONES_META,
|
||
"diff": 0,
|
||
"start_time": None,
|
||
"end_time": None,
|
||
"updated_at": None,
|
||
}
|
||
|
||
|
||
def _fill_date_gaps(rows, date_from=None, date_to=None, descending=True):
|
||
"""Ensure every date in [date_from, date_to] is present; missing days are 0."""
|
||
by_date = {r["date"]: r for r in rows}
|
||
if date_to is None:
|
||
date_to = get_counting_date()
|
||
if date_from is None:
|
||
if not by_date:
|
||
return rows
|
||
date_from = min(by_date.keys())
|
||
|
||
sample = next(iter(by_date.values()), None)
|
||
cam = sample["camera_name"] if sample else CAMERA_NAME
|
||
lab = sample["object_label"] if sample else OBJECT_LABEL
|
||
|
||
filled = []
|
||
d = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||
end = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||
if d > end:
|
||
return rows
|
||
|
||
while d <= end:
|
||
key = d.isoformat()
|
||
if key in by_date:
|
||
filled.append(by_date[key])
|
||
else:
|
||
filled.append(_zero_day_row(key, cam, lab))
|
||
d += timedelta(days=1)
|
||
|
||
if descending:
|
||
filled.reverse()
|
||
return filled
|
||
|
||
|
||
def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0):
|
||
"""Query daily_counters with optional date range and pagination.
|
||
|
||
Missing dates in the requested range are filled with zero totals so the API
|
||
always returns a continuous series.
|
||
"""
|
||
conn = get_db()
|
||
cur = conn.cursor()
|
||
|
||
clauses = []
|
||
params = []
|
||
|
||
if days is not None and date_from is None and date_to is None:
|
||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||
|
||
if date_from:
|
||
clauses.append("counting_date >= ?")
|
||
params.append(date_from)
|
||
if date_to:
|
||
clauses.append("counting_date <= ?")
|
||
params.append(date_to)
|
||
|
||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||
|
||
sql = f"""
|
||
SELECT counting_date, camera_name, object_label,
|
||
total_count, start_time, end_time, updated_at
|
||
FROM daily_counters
|
||
{where}
|
||
ORDER BY counting_date ASC
|
||
"""
|
||
cur.execute(sql, params)
|
||
day_rows = cur.fetchall()
|
||
dates = [row["counting_date"] for row in day_rows]
|
||
zone_map = _zone_counts_for_days(conn, dates)
|
||
rows = [
|
||
{
|
||
"date": row["counting_date"],
|
||
"camera_name": row["camera_name"],
|
||
"object_label": row["object_label"],
|
||
"total_count": row["total_count"],
|
||
"counts": zone_map.get(row["counting_date"], _empty_counts()),
|
||
"zones": ZONES_META,
|
||
"diff": sum(zone_map.get(row["counting_date"], _empty_counts()).values()),
|
||
"start_time": row["start_time"],
|
||
"end_time": row["end_time"],
|
||
"updated_at": row["updated_at"],
|
||
}
|
||
for row in day_rows
|
||
]
|
||
conn.close()
|
||
|
||
fill_to = date_to or get_counting_date()
|
||
rows = _fill_date_gaps(rows, date_from=date_from, date_to=fill_to, descending=True)
|
||
total = len(rows)
|
||
if limit is not None:
|
||
rows = rows[offset: offset + limit]
|
||
return rows, total
|
||
|
||
|
||
@app.route("/api/current")
|
||
@app.route("/api/current-counter")
|
||
def api_current():
|
||
"""Current counting-day totals from live state file."""
|
||
try:
|
||
state = _load_current_state()
|
||
return jsonify(
|
||
{
|
||
"success": True,
|
||
"active": True,
|
||
"site_name": SITE_NAME,
|
||
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
|
||
**state,
|
||
}
|
||
)
|
||
except FileNotFoundError:
|
||
return jsonify(
|
||
{
|
||
"success": True,
|
||
"site_name": SITE_NAME,
|
||
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
|
||
"active": False,
|
||
"error": "No active counter — state file missing",
|
||
**_empty_current(),
|
||
}
|
||
), 200
|
||
except Exception as e:
|
||
return jsonify(
|
||
{
|
||
"success": False,
|
||
"error": f"Failed to read current counter from {CURRENT_COUNTER_PATH}: {e}",
|
||
"site_name": SITE_NAME,
|
||
**_empty_current(),
|
||
}
|
||
), 500
|
||
|
||
|
||
@app.route("/api/history")
|
||
def api_history():
|
||
"""
|
||
Historical daily counters.
|
||
|
||
Query params:
|
||
days – last N calendar days (default 30; ignored if date_from/date_to set)
|
||
date_from – inclusive YYYY-MM-DD
|
||
date_to – inclusive YYYY-MM-DD
|
||
limit – page size (default: all matching)
|
||
offset – page offset (default 0)
|
||
"""
|
||
try:
|
||
days = request.args.get("days", type=int)
|
||
date_from = request.args.get("date_from")
|
||
date_to = request.args.get("date_to")
|
||
limit = request.args.get("limit", type=int)
|
||
offset = request.args.get("offset", 0, type=int)
|
||
|
||
if date_from:
|
||
try:
|
||
datetime.strptime(date_from, "%Y-%m-%d")
|
||
except ValueError:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "VALIDATION_ERROR",
|
||
"message": f"Invalid date_from '{date_from}'. Use YYYY-MM-DD.",
|
||
},
|
||
}), 400
|
||
if date_to:
|
||
try:
|
||
datetime.strptime(date_to, "%Y-%m-%d")
|
||
except ValueError:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "VALIDATION_ERROR",
|
||
"message": f"Invalid date_to '{date_to}'. Use YYYY-MM-DD.",
|
||
},
|
||
}), 400
|
||
|
||
if days is None and date_from is None and date_to is None:
|
||
days = 30
|
||
if offset < 0:
|
||
offset = 0
|
||
if limit is not None and limit < 1:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "VALIDATION_ERROR",
|
||
"message": "limit must be a positive integer",
|
||
},
|
||
}), 400
|
||
|
||
rows, total = _query_history(
|
||
date_from=date_from,
|
||
date_to=date_to,
|
||
days=days,
|
||
limit=limit,
|
||
offset=offset,
|
||
)
|
||
payload = {
|
||
"success": True,
|
||
"site_name": SITE_NAME,
|
||
"zones": ZONES_META,
|
||
"filters": {
|
||
"days": days,
|
||
"date_from": date_from,
|
||
"date_to": date_to,
|
||
},
|
||
"count": len(rows),
|
||
"data": rows,
|
||
}
|
||
if limit is not None:
|
||
payload["pagination"] = {
|
||
"offset": offset,
|
||
"limit": limit,
|
||
"total": total,
|
||
"has_next": offset + limit < total,
|
||
"has_prev": offset > 0,
|
||
}
|
||
else:
|
||
payload["total"] = total
|
||
return jsonify(payload)
|
||
except sqlite3.OperationalError as e:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "DATABASE_UNAVAILABLE",
|
||
"message": f"Database unavailable at {DB_PATH}: {e}",
|
||
},
|
||
"data": [],
|
||
"count": 0,
|
||
"total": 0,
|
||
}), 200
|
||
except Exception as e:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "INTERNAL_ERROR",
|
||
"message": str(e),
|
||
},
|
||
}), 500
|
||
|
||
|
||
@app.route("/api/history/<counting_date>")
|
||
def api_history_day(counting_date):
|
||
"""Single counting-day record by YYYY-MM-DD."""
|
||
try:
|
||
datetime.strptime(counting_date, "%Y-%m-%d")
|
||
except ValueError:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "VALIDATION_ERROR",
|
||
"message": f"Invalid counting_date '{counting_date}'. Use YYYY-MM-DD.",
|
||
},
|
||
}), 400
|
||
|
||
try:
|
||
rows, _ = _query_history(date_from=counting_date, date_to=counting_date)
|
||
data = rows[0] if rows else _zero_day_row(counting_date)
|
||
return jsonify({
|
||
"success": True,
|
||
"site_name": SITE_NAME,
|
||
"data": data,
|
||
})
|
||
except sqlite3.OperationalError as e:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "DATABASE_UNAVAILABLE",
|
||
"message": f"Database unavailable at {DB_PATH}: {e}",
|
||
},
|
||
}), 503
|
||
except Exception as e:
|
||
return jsonify({
|
||
"success": False,
|
||
"error": {
|
||
"code": "INTERNAL_ERROR",
|
||
"message": str(e),
|
||
},
|
||
}), 500
|
||
|
||
|
||
@app.route("/api/summary")
|
||
def api_summary():
|
||
try:
|
||
conn = get_db()
|
||
cur = conn.cursor()
|
||
today = get_counting_date()
|
||
cur.execute(
|
||
"""
|
||
SELECT COALESCE(total_count, 0) as total_count
|
||
FROM daily_counters
|
||
WHERE counting_date = ?
|
||
""",
|
||
(today,),
|
||
)
|
||
today_row = cur.fetchone()
|
||
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
|
||
cur.execute(
|
||
"""
|
||
SELECT COALESCE(total_count, 0) as total_count
|
||
FROM daily_counters
|
||
WHERE counting_date = ?
|
||
""",
|
||
(yesterday,),
|
||
)
|
||
yesterday_row = cur.fetchone()
|
||
cur.execute(
|
||
"""
|
||
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
||
COUNT(DISTINCT counting_date) as total_days
|
||
FROM daily_counters
|
||
"""
|
||
)
|
||
all_time = cur.fetchone()
|
||
cur.execute(
|
||
"""
|
||
SELECT zone_id, COALESCE(SUM(count), 0)
|
||
FROM daily_zone_counts
|
||
GROUP BY zone_id
|
||
"""
|
||
)
|
||
grand_counts = _empty_counts()
|
||
for zone_id, count in cur.fetchall():
|
||
if zone_id in grand_counts:
|
||
grand_counts[zone_id] = int(count)
|
||
zone_map = _zone_counts_for_days(conn, [today, yesterday])
|
||
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_counters")
|
||
avg = cur.fetchone()
|
||
cur.execute(
|
||
"""
|
||
SELECT counting_date, total_count
|
||
FROM daily_counters
|
||
ORDER BY total_count DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
best = cur.fetchone()
|
||
conn.close()
|
||
return jsonify(
|
||
{
|
||
"zones": ZONES_META,
|
||
"today": {
|
||
"date": today,
|
||
"total_count": today_row["total_count"] if today_row else 0,
|
||
"counts": zone_map.get(today, _empty_counts()),
|
||
},
|
||
"yesterday": {
|
||
"date": yesterday,
|
||
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
||
"counts": zone_map.get(yesterday, _empty_counts()),
|
||
},
|
||
"all_time": {
|
||
"grand_total": all_time["grand_total"],
|
||
"counts": grand_counts,
|
||
"total_days": all_time["total_days"],
|
||
},
|
||
"average_per_day": avg["avg_per_day"] or 0,
|
||
"best_day": {
|
||
"date": best["counting_date"] if best else None,
|
||
"count": best["total_count"] if best else 0,
|
||
},
|
||
}
|
||
)
|
||
except sqlite3.OperationalError as e:
|
||
empty = {
|
||
"zones": ZONES_META,
|
||
"today": {"date": datetime.now().date().isoformat(), "total_count": 0, "counts": _empty_counts()},
|
||
"yesterday": {"date": "", "total_count": 0, "counts": _empty_counts()},
|
||
"all_time": {"grand_total": 0, "counts": _empty_counts(), "total_days": 0},
|
||
"average_per_day": 0,
|
||
"best_day": {"date": None, "count": 0},
|
||
}
|
||
return jsonify({"success": False, "error": f"Database unavailable: {e}", **empty}), 200
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/daily-data")
|
||
def api_daily_data():
|
||
try:
|
||
days = request.args.get("days", 30, type=int)
|
||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||
date_to = get_counting_date()
|
||
rows, _ = _query_history(date_from=date_from, date_to=date_to)
|
||
rows = list(reversed(rows)) # ASC for charts
|
||
daily_data = [
|
||
{
|
||
"date": row["date"],
|
||
"total_count": row["total_count"],
|
||
"counts": row["counts"],
|
||
}
|
||
for row in rows
|
||
]
|
||
return jsonify({"zones": ZONES_META, "data": daily_data})
|
||
except sqlite3.OperationalError:
|
||
return jsonify({"zones": ZONES_META, "data": []}), 200
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
@app.route("/api/available-dates")
|
||
def api_available_dates():
|
||
try:
|
||
rows, _ = _query_history()
|
||
dates = [
|
||
{
|
||
"date": row["date"],
|
||
"total_count": row["total_count"],
|
||
"counts": row["counts"],
|
||
"start_time": row["start_time"],
|
||
"end_time": row["end_time"],
|
||
}
|
||
for row in rows
|
||
]
|
||
return jsonify({"zones": ZONES_META, "data": dates})
|
||
except sqlite3.OperationalError:
|
||
return jsonify({"zones": ZONES_META, "data": []}), 200
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
|
||
def _excel_response(wb, filename):
|
||
output = BytesIO()
|
||
wb.save(output)
|
||
output.seek(0)
|
||
return Response(
|
||
output.getvalue(),
|
||
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||
)
|
||
|
||
|
||
def _style_header(ws, cols):
|
||
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
|
||
thin_border = Border(
|
||
left=Side(style="thin"), right=Side(style="thin"),
|
||
top=Side(style="thin"), bottom=Side(style="thin"),
|
||
)
|
||
for col_idx, (col_letter, text) in enumerate(cols, 1):
|
||
cell = ws.cell(row=1, column=col_idx, value=text)
|
||
cell.font = header_font
|
||
cell.fill = header_fill
|
||
cell.alignment = Alignment(horizontal="center")
|
||
cell.border = thin_border
|
||
ws.freeze_panes = "A2"
|
||
|
||
|
||
def _auto_width(ws):
|
||
for col in ws.columns:
|
||
max_len = 0
|
||
col_letter = col[0].column_letter
|
||
for cell in col:
|
||
if cell.value is not None:
|
||
max_len = max(max_len, len(str(cell.value)))
|
||
ws.column_dimensions[col_letter].width = max_len + 4
|
||
|
||
|
||
@app.route("/api/export-daily-csv")
|
||
def export_daily_xlsx():
|
||
try:
|
||
days = request.args.get("days", 30, type=int)
|
||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||
date_to = get_counting_date()
|
||
rows, _ = _query_history(date_from=date_from, date_to=date_to)
|
||
rows = list(reversed(rows)) # ASC for export
|
||
except sqlite3.OperationalError as e:
|
||
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
||
except Exception as e:
|
||
return jsonify({"success": False, "error": str(e)}), 500
|
||
|
||
wb = Workbook()
|
||
ws = wb.active
|
||
ws.title = "Daily Counters"
|
||
headers = [("A", "Date"), ("B", "Total")]
|
||
# column letters continue past B
|
||
from openpyxl.utils import get_column_letter
|
||
col = 3
|
||
for z in ZONES_META:
|
||
headers.append((get_column_letter(col), z["name"]))
|
||
col += 1
|
||
headers.append((get_column_letter(col), "First Count"))
|
||
headers.append((get_column_letter(col + 1), "Last Count"))
|
||
_style_header(ws, headers)
|
||
|
||
for r_idx, row in enumerate(rows, 2):
|
||
counts = row["counts"]
|
||
ws.cell(row=r_idx, column=1, value=row["date"])
|
||
ws.cell(row=r_idx, column=2, value=row["total_count"])
|
||
c = 3
|
||
for z in ZONES_META:
|
||
ws.cell(row=r_idx, column=c, value=counts.get(z["id"], 0))
|
||
c += 1
|
||
ws.cell(row=r_idx, column=c, value=row["start_time"])
|
||
ws.cell(row=r_idx, column=c + 1, value=row["end_time"])
|
||
|
||
_auto_width(ws)
|
||
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||
return _excel_response(wb, filename)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
WSGIRequestHandler.protocol_version = "HTTP/1.1"
|
||
print(f"ZenAI KTC zone counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
|
||
print(f"DB: {DB_PATH}")
|
||
print(f"State: {CURRENT_COUNTER_PATH}")
|
||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG) |