From 67237014245b874b7735380c2b59f66683a7d055 Mon Sep 17 00:00:00 2001 From: dsutanto Date: Wed, 19 Aug 2026 10:32:35 +0700 Subject: [PATCH] feat: add full cycle_start_date REST API, YAML persistence, and CORS support to dashboard.py --- dashboard.py | 129 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/dashboard.py b/dashboard.py index 89f0bbc..6bcd860 100644 --- a/dashboard.py +++ b/dashboard.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import json import mimetypes +import re import sqlite3 import threading import time @@ -13,7 +14,7 @@ from datetime import datetime, timezone from http.server import HTTPServer, SimpleHTTPRequestHandler from pathlib import Path from socketserver import ThreadingMixIn -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): @@ -28,7 +29,48 @@ _db_conn = None _db_lock = threading.Lock() _db_path = "" _mortality_dirs: list[Path] = [] -_cycle_start_date: str = "2026-05-22" + + +def _load_initial_cycle_start_date() -> str: + """Read cycle_start_date from configs/cycle7_batch_optimized.yaml if available.""" + cfg_path = Path(__file__).resolve().parent / "configs" / "cycle7_batch_optimized.yaml" + if cfg_path.exists(): + content = cfg_path.read_text(encoding="utf-8") + match = re.search(r"^\s*cycle_start_date:\s*['\"]?([^'\"\s#]+)['\"]?", content, re.MULTILINE) + if match: + return match.group(1).strip() + return "2026-05-22" + + +_cycle_start_date: str = _load_initial_cycle_start_date() + + +def _persist_cycle_start_date(new_date: str) -> bool: + """Update in-memory cycle_start_date and save to config YAML files.""" + global _cycle_start_date + _cycle_start_date = new_date + updated_any = False + for cfg_name in ("cycle7_batch_optimized.yaml", "cycle7_batch.yaml"): + cfg_path = Path(__file__).resolve().parent / "configs" / cfg_name + if cfg_path.exists(): + content = cfg_path.read_text(encoding="utf-8") + pattern = r"^([ \t]*cycle_start_date:[ \t]*)(?:['\"]?)([^'\"\r\n#]+)(?:['\"]?)([ \t]*(?:#.*)?)$" + + def replacer(match: re.Match) -> str: + prefix = match.group(1) + comment = match.group(3) or "" + if comment and not comment.startswith(" "): + comment = f" {comment.lstrip()}" + if not comment.startswith(" "): + comment = f" {comment}" + return f'{prefix}"{new_date}"{comment}' + + new_content, count = re.subn(pattern, replacer, content, count=1, flags=re.MULTILINE) + if count > 0: + cfg_path.write_text(new_content, encoding="utf-8") + updated_any = True + print(f"[dashboard] 📅 Updated cycle_start_date to: {new_date} (persisted in configs: {updated_any})") + return updated_any def _calc_cycle_info(target_date_str: str) -> dict: @@ -93,12 +135,31 @@ class DashboardHandler(SimpleHTTPRequestHandler): def log_message(self, format, *args): pass + def do_OPTIONS(self): + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + self.send_header("Content-Length", "0") + self.end_headers() + def do_GET(self): try: self._handle() except (BrokenPipeError, ConnectionResetError): pass + def do_POST(self): + try: + parsed = urlparse(self.path) + path = unquote(parsed.path) + if path == "/api/config/cycle_start_date": + self._handle_set_cycle_start_date() + return + self._send_error(404) + except (BrokenPipeError, ConnectionResetError): + pass + def _handle(self): parsed = urlparse(self.path) path = unquote(parsed.path) @@ -119,6 +180,35 @@ class DashboardHandler(SimpleHTTPRequestHandler): self._send_json({"cameras": _discover_cameras(self.shm_dir)}) return + if path == "/api/config/cycle_start_date": + query = parse_qs(parsed.query) + if "set" in query and query["set"]: + new_date = query["set"][0].strip() + try: + from datetime import date as date_type + date_type.fromisoformat(new_date) + persisted = _persist_cycle_start_date(new_date) + self._send_json({ + "status": "ok", + "message": f"Cycle start date successfully set to {new_date}", + "cycle_start_date": _cycle_start_date, + "persisted": persisted, + }) + return + except ValueError as err: + self._send_json({"error": f"Invalid date format (expected YYYY-MM-DD): {err}"}, status_code=400) + return + self._send_json({"cycle_start_date": _cycle_start_date}) + return + + if path == "/api/config": + self._send_json({ + "cycle_start_date": _cycle_start_date, + "shm_dir": self.shm_dir, + "db_path": _db_path, + }) + return + if path.startswith("/api/db/"): self._handle_db(path) return @@ -133,6 +223,35 @@ class DashboardHandler(SimpleHTTPRequestHandler): self._send_error(404) + def _handle_set_cycle_start_date(self): + content_length = int(self.headers.get("Content-Length", 0)) + if content_length > 0: + body = self.rfile.read(content_length).decode("utf-8") + try: + data = json.loads(body) + new_date = str(data.get("cycle_start_date", "")).strip() + if new_date: + from datetime import date as date_type + date_type.fromisoformat(new_date) + persisted = _persist_cycle_start_date(new_date) + self._send_json({ + "status": "ok", + "message": f"Cycle start date successfully set to {new_date}", + "cycle_start_date": _cycle_start_date, + "persisted": persisted, + }) + return + else: + self._send_json({"error": "Missing 'cycle_start_date' in request body"}, status_code=400) + return + except ValueError as err: + self._send_json({"error": f"Invalid date format (expected YYYY-MM-DD): {err}"}, status_code=400) + return + except Exception as err: + self._send_json({"error": str(err)}, status_code=400) + return + self._send_json({"error": "Empty request body"}, status_code=400) + def _handle_status(self): cams = _discover_cameras(self.shm_dir) now = time.time() @@ -407,11 +526,13 @@ class DashboardHandler(SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(data) - def _send_json(self, obj): + def _send_json(self, obj, status_code=200): data = json.dumps(obj).encode("utf-8") - self.send_response(200) + self.send_response(status_code) self.send_header("Content-Type", "application/json") self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data)