from flask import Flask, abort, jsonify, render_template, request, url_for import sqlite3 import json from datetime import datetime, timedelta, timezone import os from pathlib import Path import uuid from counter_time import ( apply_env_counter_settings, default_counter_settings, get_cutoff_metadata, get_business_date, get_period_window, ) from werkzeug.exceptions import HTTPException app = Flask(__name__) BASE_DIR = Path(__file__).resolve().parent SETTINGS_DB = BASE_DIR / 'dashboard_settings.db' DISPLAY_DATE_FORMAT = '%d-%m-%Y' def load_env_file(env_path): if not env_path.exists(): return with env_path.open() as env_file: for line in env_file: stripped_line = line.strip() if not stripped_line or stripped_line.startswith('#') or '=' not in stripped_line: continue key, value = stripped_line.split('=', 1) os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) def get_configured_db_path(env_name, default_file_name): db_path = Path(os.environ.get(env_name, default_file_name)).expanduser() if not db_path.is_absolute(): db_path = BASE_DIR / db_path return db_path load_env_file(BASE_DIR / '.env') COUNTER_SETTING_DEFAULTS = default_counter_settings() KARUNG_TUANG_DB = get_configured_db_path('KARUNG_TUANG_DB', 'karung_tuang.db') KARUNG_MASUK_DB = get_configured_db_path('KARUNG_MASUK_DB', 'karung_masuk.db') KARUNG_TUANG_JSON = get_configured_db_path('KARUNG_TUANG_JSON', 'karung_tuang.json') KARUNG_MASUK_JSON = get_configured_db_path('KARUNG_MASUK_JSON', 'karung_masuk.json') PULL_STATUS_FILE = BASE_DIR / '.pull_status.json' DEFAULT_COMBINED_HISTORY_DAYS = int(os.environ.get('SOURCE_HISTORY_DAYS', '30')) if KARUNG_TUANG_DB.resolve() == KARUNG_MASUK_DB.resolve(): raise RuntimeError('KARUNG_TUANG_DB and KARUNG_MASUK_DB must point to different database files.') DB_CONFIGS = { 'tuang': { 'path': KARUNG_TUANG_DB, 'table': 'counter_data', }, 'masuk': { 'path': KARUNG_MASUK_DB, 'table': 'karung_counts', }, } DEFAULT_DASHBOARD_SETTINGS = { 'id': '', 'cycle_name': 'Siklus Aktif', 'cycle_start': '', 'cycle_end': '', 'saldo_awal': 0, 'tuang_cutoff_time': COUNTER_SETTING_DEFAULTS['tuang_cutoff_time'], 'masuk_cutoff_time': COUNTER_SETTING_DEFAULTS['masuk_cutoff_time'], 'local_timezone': COUNTER_SETTING_DEFAULTS['local_timezone'], } def get_db_data(db_path, table_name): """Fetch all data from database""" db_path = Path(db_path) if not db_path.exists(): return [] try: conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(f"SELECT id, camera_name, date, counter_value FROM {table_name} ORDER BY date DESC") rows = cursor.fetchall() conn.close() except sqlite3.OperationalError: return [] data = [] for row in rows: data.append({ 'id': row[0], 'name': row[1], 'date': row[2], 'counter_value': row[3] }) return data def load_dashboard_settings(): """Load active cycle settings. Will be replaced by external cycle API.""" ensure_settings_db() with sqlite3.connect(SETTINGS_DB) as conn: row = conn.execute( """ SELECT id, cycle_name, cycle_start, cycle_end, saldo_awal, tuang_cutoff_time, masuk_cutoff_time, local_timezone FROM cycle_settings ORDER BY is_active DESC, id DESC LIMIT 1 """ ).fetchone() if not row: return apply_env_counter_settings(DEFAULT_DASHBOARD_SETTINGS.copy()) return apply_env_counter_settings({ 'id': row[0], 'cycle_name': row[1], 'cycle_start': row[2], 'cycle_end': row[3], 'saldo_awal': row[4], 'tuang_cutoff_time': row[5], 'masuk_cutoff_time': row[6], 'local_timezone': row[7], }) def ensure_settings_db(): with sqlite3.connect(SETTINGS_DB) as conn: existing_schema = conn.execute( """ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'cycle_settings' """ ).fetchone() if existing_schema and 'CHECK (id = 1)' in existing_schema[0]: conn.execute('ALTER TABLE cycle_settings RENAME TO cycle_settings_old') create_cycle_settings_table(conn) conn.execute( """ INSERT INTO cycle_settings ( cycle_name, cycle_start, cycle_end, saldo_awal, tuang_cutoff_time, masuk_cutoff_time, local_timezone, is_active, updated_at ) SELECT cycle_name, cycle_start, cycle_end, saldo_awal, ?, ?, ?, 0, updated_at FROM cycle_settings_old ORDER BY id """ , ( DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time'], DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time'], DEFAULT_DASHBOARD_SETTINGS['local_timezone'], ) ) conn.execute('DROP TABLE cycle_settings_old') else: create_cycle_settings_table(conn) ensure_settings_columns(conn) settings_count = conn.execute('SELECT COUNT(*) FROM cycle_settings').fetchone()[0] if settings_count: ensure_active_cycle(conn) return conn.execute( """ INSERT OR IGNORE INTO cycle_settings ( cycle_name, cycle_start, cycle_end, saldo_awal, tuang_cutoff_time, masuk_cutoff_time, local_timezone, is_active, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?) """, ( DEFAULT_DASHBOARD_SETTINGS['cycle_name'], DEFAULT_DASHBOARD_SETTINGS['cycle_start'], DEFAULT_DASHBOARD_SETTINGS['cycle_end'], DEFAULT_DASHBOARD_SETTINGS['saldo_awal'], DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time'], DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time'], DEFAULT_DASHBOARD_SETTINGS['local_timezone'], datetime.now().isoformat(timespec='seconds') ) ) def create_cycle_settings_table(conn): conn.execute( """ CREATE TABLE IF NOT EXISTS cycle_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, cycle_name TEXT NOT NULL, cycle_start TEXT NOT NULL DEFAULT '', cycle_end TEXT NOT NULL DEFAULT '', saldo_awal INTEGER NOT NULL DEFAULT 0, tuang_cutoff_time TEXT NOT NULL DEFAULT '17:00:00', masuk_cutoff_time TEXT NOT NULL DEFAULT '24:00:00', local_timezone TEXT NOT NULL DEFAULT 'Asia/Jakarta', is_active INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL ) """ ) def ensure_settings_columns(conn): columns = { row[1] for row in conn.execute('PRAGMA table_info(cycle_settings)').fetchall() } if 'is_active' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN is_active INTEGER NOT NULL DEFAULT 0 """ ) if 'tuang_cutoff_time' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN tuang_cutoff_time TEXT NOT NULL DEFAULT '17:00:00' """ ) if 'masuk_cutoff_time' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN masuk_cutoff_time TEXT NOT NULL DEFAULT '24:00:00' """ ) if 'local_timezone' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN local_timezone TEXT NOT NULL DEFAULT 'Asia/Jakarta' """ ) def ensure_active_cycle(conn): active_count = conn.execute( 'SELECT COUNT(*) FROM cycle_settings WHERE is_active = 1' ).fetchone()[0] if active_count: return latest_id = conn.execute( 'SELECT id FROM cycle_settings ORDER BY id DESC LIMIT 1' ).fetchone()[0] conn.execute('UPDATE cycle_settings SET is_active = 1 WHERE id = ?', (latest_id,)) def parse_cycle_date(date_text, field_name): date_text = date_text.strip() if not date_text: return None for date_format in (DISPLAY_DATE_FORMAT, '%Y-%m-%d'): try: return datetime.strptime(date_text, date_format).date() except ValueError: continue abort(400, description=f'{field_name} must use DD-MM-YYYY or YYYY-MM-DD format.') def parse_data_date(date_text): date_text = str(date_text).strip() if not date_text: return None try: return datetime.fromisoformat(date_text.replace('Z', '+00:00')).date() except ValueError: pass for date_format in (DISPLAY_DATE_FORMAT, '%d/%m/%Y'): try: return datetime.strptime(date_text, date_format).date() except ValueError: continue return None def format_history_date(date_text): parsed_date = parse_data_date(date_text) if not parsed_date: return date_text return parsed_date.strftime('%Y-%m-%d') def filter_data_by_cycle(data, cycle_start, cycle_end): if not cycle_start or not cycle_end: return [] filtered_data = [] for item in data: item_date = parse_data_date(item['date']) if not item_date: continue if item_date < cycle_start: continue if item_date > cycle_end: continue filtered_data.append(item) return filtered_data def is_masuk_out_item(item): source_name = str(item.get('name', '')).lower().strip() return ( source_name.endswith('_out') or source_name.endswith('-out') or source_name.endswith(' out') ) def get_counter_settings_from_dashboard(dashboard_settings): return apply_env_counter_settings({ 'local_timezone': dashboard_settings.get( 'local_timezone', DEFAULT_DASHBOARD_SETTINGS['local_timezone'] ), 'tuang_cutoff_time': dashboard_settings.get( 'tuang_cutoff_time', DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time'] ), 'masuk_cutoff_time': dashboard_settings.get( 'masuk_cutoff_time', DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time'] ), }) def get_json_data(json_path): """Fetch current data from JSON file""" try: with open(json_path, 'r') as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError): return {} def get_utc_timestamp(): return datetime.now(timezone.utc).isoformat(timespec='seconds').replace('+00:00', 'Z') def get_request_id(): return request.headers.get('X-Request-ID') or str(uuid.uuid4()) def api_success_response(data, status_code=200): request_id = get_request_id() response = jsonify({ 'data': data, 'meta': { 'timestamp': get_utc_timestamp(), 'path': request.path, 'requestId': request_id, }, }) response.status_code = status_code response.headers['X-Request-ID'] = request_id return response def api_error_response(error, status_code, error_code): request_id = get_request_id() response = jsonify({ 'error': { 'code': error_code, 'message': error, 'details': [], 'timestamp': get_utc_timestamp(), 'path': request.path, 'requestId': request_id, }, }) response.status_code = status_code response.headers['X-Request-ID'] = request_id return response def is_api_request(): return request.path.startswith('/api/') def is_date_in_active_cycle(target_date, cycle_start, cycle_end): if not cycle_start or not cycle_end: return False return cycle_start <= target_date <= cycle_end def filter_data_by_date(data, target_date): filtered_data = [] for item in data: item_date = parse_data_date(item['date']) if item_date == target_date: filtered_data.append(item) return filtered_data def get_json_counter_items(json_data): items = [] for source_name, source_data in json_data.items(): karung_count = 0 if isinstance(source_data, dict): karung_count = source_data.get('karung', 0) items.append({ 'name': source_name, 'karung': karung_count, }) return items def format_history_items(data): return [ { 'id': item['id'], 'name': item['name'], 'date': format_history_date(item['date']), 'counter_value': item['counter_value'], } for item in data ] def load_pull_status(): if not PULL_STATUS_FILE.exists(): return { 'ktc': {'status': 'unknown'}, 'kpc': {'status': 'unknown'}, } try: with PULL_STATUS_FILE.open(encoding='utf-8') as handle: data = json.load(handle) except (OSError, json.JSONDecodeError): return { 'ktc': {'status': 'unknown'}, 'kpc': {'status': 'unknown'}, } if not isinstance(data, dict): return { 'ktc': {'status': 'unknown'}, 'kpc': {'status': 'unknown'}, } return { 'ktc': data.get('ktc') or {'status': 'unknown'}, 'kpc': data.get('kpc') or {'status': 'unknown'}, } def filter_history_by_days(data, days): if days is None or days < 1: return data cutoff_date = datetime.now().date() - timedelta(days=days - 1) filtered = [] for item in data: item_date = parse_data_date(item['date']) if item_date and item_date >= cutoff_date: filtered.append(item) return filtered def build_combined_site_payload(source_name, today_items, history_items, is_masuk=False): today_total = sum(item['karung'] for item in today_items) if is_masuk: history_total = sum( item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value'] for item in history_items ) else: history_total = sum(item['counter_value'] for item in history_items) return { 'source': source_name, 'today': today_items, 'history': format_history_items(history_items), 'totals': { 'today': today_total, 'history': history_total, }, } def get_active_cycle_dates(dashboard_settings): cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start') cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end') return cycle_start, cycle_end def format_display_date_from_iso(iso_date): return datetime.strptime(iso_date, '%Y-%m-%d').strftime(DISPLAY_DATE_FORMAT) def format_business_date_label(tuang_date, masuk_date): if tuang_date == masuk_date: return format_display_date_from_iso(tuang_date) return ( f"Tuang {format_display_date_from_iso(tuang_date)} / " f"Masuk {format_display_date_from_iso(masuk_date)}" ) def get_counter_periods(cutoff_metadata): return cutoff_metadata['counters'] def build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadata=None): cutoff_metadata = cutoff_metadata or get_cutoff_metadata(counter_settings) counter_periods = get_counter_periods(cutoff_metadata) tuang_date = parse_cycle_date(counter_periods['tuang']['business_date'], 'Tuang business date') masuk_date = parse_cycle_date(counter_periods['masuk']['business_date'], 'Masuk business date') is_tuang_within_active_cycle = is_date_in_active_cycle(tuang_date, cycle_start, cycle_end) is_masuk_within_active_cycle = is_date_in_active_cycle(masuk_date, cycle_start, cycle_end) tuang_items = [] masuk_items = [] if is_tuang_within_active_cycle: tuang_items = get_json_counter_items(get_json_data(KARUNG_TUANG_JSON)) if is_masuk_within_active_cycle: masuk_items = get_json_counter_items(get_json_data(KARUNG_MASUK_JSON)) return { 'date': counter_periods['tuang']['business_date'], 'date_label': format_business_date_label( counter_periods['tuang']['business_date'], counter_periods['masuk']['business_date'] ), 'source': 'json', 'is_within_active_cycle': is_tuang_within_active_cycle or is_masuk_within_active_cycle, 'business_dates': { 'tuang': counter_periods['tuang']['business_date'], 'masuk': counter_periods['masuk']['business_date'], }, 'periods': counter_periods, 'totals': { 'tuang': sum(item['karung'] for item in tuang_items), 'masuk': sum(item['karung'] for item in masuk_items), }, 'tuang': tuang_items, 'masuk': masuk_items, } def build_yesterday_history_section(cycle_start, cycle_end, counter_settings): tuang_date = get_business_date('tuang', counter_settings) - timedelta(days=1) masuk_date = get_business_date('masuk', counter_settings) - timedelta(days=1) is_tuang_within_active_cycle = is_date_in_active_cycle(tuang_date, cycle_start, cycle_end) is_masuk_within_active_cycle = is_date_in_active_cycle(masuk_date, cycle_start, cycle_end) tuang_data = [] masuk_data = [] if is_tuang_within_active_cycle: tuang_data = get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']) tuang_data = filter_data_by_cycle(tuang_data, cycle_start, cycle_end) tuang_data = filter_data_by_date(tuang_data, tuang_date) if is_masuk_within_active_cycle: masuk_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']) masuk_data = filter_data_by_cycle(masuk_data, cycle_start, cycle_end) masuk_data = filter_data_by_date(masuk_data, masuk_date) return { 'date': tuang_date.isoformat(), 'date_label': format_business_date_label(tuang_date.isoformat(), masuk_date.isoformat()), 'source': 'database', 'is_within_active_cycle': is_tuang_within_active_cycle or is_masuk_within_active_cycle, 'business_dates': { 'tuang': tuang_date.isoformat(), 'masuk': masuk_date.isoformat(), }, 'totals': { 'tuang': sum(item['counter_value'] for item in tuang_data), 'masuk': sum( item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value'] for item in masuk_data ), }, 'tuang': format_history_items(tuang_data), 'masuk': format_history_items(masuk_data), } @app.errorhandler(HTTPException) def handle_http_exception(error): if not is_api_request(): return error return api_error_response( error.description, error.code, error.name.upper().replace(' ', '_') ) @app.errorhandler(Exception) def handle_unexpected_exception(error): if not is_api_request(): raise error app.logger.exception('Unhandled API error') return api_error_response( 'An unexpected error occurred while processing the request.', 500, 'INTERNAL_SERVER_ERROR' ) @app.route('/api/v1/cycles/active/sections', methods=['GET']) def get_active_cycle_sections(): dashboard_settings = load_dashboard_settings() cycle_start, cycle_end = get_active_cycle_dates(dashboard_settings) counter_settings = get_counter_settings_from_dashboard(dashboard_settings) cutoff_metadata = get_cutoff_metadata(counter_settings) return api_success_response({ 'cycle': { 'id': dashboard_settings['id'], 'name': dashboard_settings['cycle_name'], 'start_date': cycle_start.isoformat() if cycle_start else None, 'end_date': cycle_end.isoformat() if cycle_end else None, 'saldo_awal': dashboard_settings['saldo_awal'], 'local_timezone': dashboard_settings['local_timezone'], 'tuang_cutoff_time': dashboard_settings['tuang_cutoff_time'], 'masuk_cutoff_time': dashboard_settings['masuk_cutoff_time'], }, 'cutoff': cutoff_metadata, 'sections': { 'today': build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadata), 'yesterday_history': build_yesterday_history_section(cycle_start, cycle_end, counter_settings), }, }) @app.route('/api/combined', methods=['GET']) def get_combined(): history_days_raw = request.args.get('history_days', DEFAULT_COMBINED_HISTORY_DAYS) try: history_days = int(history_days_raw) except (TypeError, ValueError): abort(400, description='history_days must be a whole number.') if history_days < 1: abort(400, description='history_days must be at least 1.') tuang_today = get_json_counter_items(get_json_data(KARUNG_TUANG_JSON)) masuk_today = get_json_counter_items(get_json_data(KARUNG_MASUK_JSON)) tuang_history = filter_history_by_days( get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']), history_days, ) masuk_history = filter_history_by_days( get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']), history_days, ) tuang_payload = build_combined_site_payload('ktc', tuang_today, tuang_history, is_masuk=False) masuk_payload = build_combined_site_payload('kpc', masuk_today, masuk_history, is_masuk=True) pull_status = load_pull_status() response = api_success_response({ 'tuang': tuang_payload, 'masuk': masuk_payload, 'totals': { 'tuang': tuang_payload['totals']['today'], 'masuk': sum( item['karung'] if not is_masuk_out_item(item) else -item['karung'] for item in masuk_today ), }, }) payload = response.get_json() payload['meta']['sources'] = { 'ktc': pull_status.get('ktc') or {'status': 'unknown'}, 'kpc': pull_status.get('kpc') or {'status': 'unknown'}, } payload['meta']['history_days'] = history_days response = jsonify(payload) response.status_code = 200 response.headers['X-Request-ID'] = payload['meta']['requestId'] return response @app.route('/') def index(): dashboard_settings = load_dashboard_settings() counter_settings = get_counter_settings_from_dashboard(dashboard_settings) cutoff_metadata = get_cutoff_metadata(counter_settings) cycle_start = parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start') cycle_end = parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end') today_section = build_today_section(cycle_start, cycle_end, counter_settings, cutoff_metadata) # Get historical data from databases tuang_db_data = get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']) masuk_db_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']) tuang_db_data = filter_data_by_cycle(tuang_db_data, cycle_start, cycle_end) masuk_db_data = filter_data_by_cycle(masuk_db_data, cycle_start, cycle_end) for item in tuang_db_data: item['display_date'] = format_history_date(item['date']) tuang_json_data = { item['name']: {'karung': item['karung']} for item in today_section['tuang'] } masuk_json_data = { item['name']: {'karung': item['karung']} for item in today_section['masuk'] } tuang_json_total = today_section['totals']['tuang'] masuk_json_total = today_section['totals']['masuk'] # Calculate totals from database (historical) tuang_db_total = sum(item['counter_value'] for item in tuang_db_data) masuk_db_total = sum( item['counter_value'] if not is_masuk_out_item(item) else -item['counter_value'] for item in masuk_db_data ) saldo_akhir = dashboard_settings['saldo_awal'] + masuk_db_total - tuang_db_total current_date = today_section['date_label'] return render_template('index.html', dashboard_settings=dashboard_settings, cutoff_metadata=cutoff_metadata, tuang_db_data=tuang_db_data, masuk_db_data=masuk_db_data, tuang_json_data=tuang_json_data, masuk_json_data=masuk_json_data, tuang_json_total=tuang_json_total, masuk_json_total=masuk_json_total, tuang_db_total=tuang_db_total, masuk_db_total=masuk_db_total, saldo_akhir=saldo_akhir, current_date=current_date) if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=8090)