#!/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\d{8}_\d{6}_\d{3})_(?Pdetect|zone_\d+)_id(?P\d+)_f(?P\d+)\.jpg$" ) # Also accept legacy left/right snapshot filenames. _SNAP_RE_LEGACY = re.compile( r"^(?P\d{8}_\d{6}_\d{3})_(?Pleft|right)_id(?P\d+)_f(?P\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//") 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/") 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)