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 import logging from counter_time import ( apply_env_counter_settings, default_counter_settings, get_cutoff_metadata, get_business_date, get_period_window, ) from cycle_api import ( extract_k_index, format_cycle_name, get_cycle_settings_for_camera as get_api_cycle_settings_for_camera, is_cycle_api_configured, load_kandang_cycle_context, ) from werkzeug.exceptions import HTTPException app = Flask(__name__) logger = logging.getLogger(__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')) CYCLE_API_CACHE_SECONDS = int(os.environ.get('CYCLE_API_CACHE_SECONDS', '60')) _cycle_context_cache = { 'loaded_at': None, 'context': None, } 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], 'source': row[1], 'date': row[2], 'counter_value': row[3] }) return data def load_dashboard_settings_from_db(): """Load cycle settings from the local SQLite fallback store.""" 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 build_site_dashboard_settings(kandang_cycles): counter_defaults = apply_env_counter_settings(DEFAULT_DASHBOARD_SETTINGS.copy()) if not kandang_cycles: return counter_defaults saldo_awal = sum(cycle['saldo_awal'] for cycle in kandang_cycles) if len(kandang_cycles) == 1: cycle_name = format_cycle_name( kandang_cycles[0].get('cycle_start', ''), kandang_cycles[0].get('cycle_end', ''), ) else: cycle_name = ', '.join( format_cycle_name(cycle.get('cycle_start', ''), cycle.get('cycle_end', '')) for cycle in kandang_cycles ) cycle_starts = [ parse_cycle_date(cycle['cycle_start'], 'Cycle start') for cycle in kandang_cycles if cycle.get('cycle_start') ] cycle_ends = [ parse_cycle_date(cycle['cycle_end'], 'Cycle end') for cycle in kandang_cycles if cycle.get('cycle_end') ] return apply_env_counter_settings({ 'id': '', 'cycle_name': cycle_name, 'cycle_start': min(cycle_starts).strftime(DISPLAY_DATE_FORMAT) if cycle_starts else '', 'cycle_end': max(cycle_ends).strftime(DISPLAY_DATE_FORMAT) if cycle_ends else '', 'saldo_awal': saldo_awal, 'tuang_cutoff_time': counter_defaults['tuang_cutoff_time'], 'masuk_cutoff_time': counter_defaults['masuk_cutoff_time'], 'local_timezone': counter_defaults['local_timezone'], }) def build_legacy_local_cycle_context(*, source='local'): site_settings = load_dashboard_settings_from_db() kandang_cycles = [] if site_settings.get('cycle_start') and site_settings.get('cycle_end'): kandang_cycles.append({ **site_settings, 'kandang_id': None, 'k_index': None, 'status': 'local', 'flock': None, }) return { 'source': source, 'kandangs': [], 'kandang_by_k_index': {}, 'cycle_by_kandang_id': {}, 'kandang_cycles': kandang_cycles, 'all_cycles_by_kandang': {}, 'site_settings': site_settings, } def build_cycle_context_from_db(*, source='db'): kandang_cycles = load_kandang_cycles_from_db() if kandang_cycles: kandang_by_k_index = {} cycle_by_kandang_id = {} kandangs = [] for cycle in kandang_cycles: kandang_name = cycle.get('kandang_name') or ( f"Kandang {cycle['k_index']}" if cycle.get('k_index') is not None else cycle.get('cycle_name', '') ) kandangs.append({ 'id': cycle['kandang_id'], 'kandang_name': kandang_name, }) if cycle.get('k_index') is not None: kandang_by_k_index[cycle['k_index']] = kandangs[-1] cycle_by_kandang_id[cycle['kandang_id']] = cycle return { 'source': source, 'kandangs': kandangs, 'kandang_by_k_index': kandang_by_k_index, 'cycle_by_kandang_id': cycle_by_kandang_id, 'kandang_cycles': kandang_cycles, 'all_cycles_by_kandang': { cycle['kandang_id']: [cycle] for cycle in kandang_cycles if cycle.get('kandang_id') is not None }, 'site_settings': build_site_dashboard_settings(kandang_cycles), } return build_legacy_local_cycle_context(source=source) def load_cycle_context(): now = datetime.now() cached_loaded_at = _cycle_context_cache.get('loaded_at') cached_context = _cycle_context_cache.get('context') if ( cached_loaded_at and cached_context and (now - cached_loaded_at).total_seconds() < CYCLE_API_CACHE_SECONDS ): return cached_context source = 'db' api_all_cycles = {} if is_cycle_api_configured(): try: api_context = load_kandang_cycle_context() sync_api_cycle_context_to_db(api_context) api_all_cycles = api_context.get('all_cycles_by_kandang', {}) source = 'api' except RuntimeError as error: logger.warning('Cycle API unavailable, using local database: %s', error) context = build_cycle_context_from_db(source=source) if api_all_cycles: context['all_cycles_by_kandang'] = api_all_cycles _cycle_context_cache['loaded_at'] = now _cycle_context_cache['context'] = context return context def get_cycle_settings_for_camera(source_name, cycle_context): if cycle_context.get('kandang_cycles') and any( cycle.get('kandang_id') is not None for cycle in cycle_context['kandang_cycles'] ): return get_api_cycle_settings_for_camera(source_name, cycle_context) return cycle_context.get('site_settings') def get_cycle_dates_for_camera(source_name, cycle_context): cycle_settings = get_cycle_settings_for_camera(source_name, cycle_context) if not cycle_settings: return None, None return ( parse_cycle_date(cycle_settings.get('cycle_start', ''), 'Cycle start'), parse_cycle_date(cycle_settings.get('cycle_end', ''), 'Cycle end'), ) def is_camera_within_cycle(source_name, target_date, cycle_context): cycle_start, cycle_end = get_cycle_dates_for_camera(source_name, cycle_context) return is_date_in_active_cycle(target_date, cycle_start, cycle_end) def filter_data_by_kandang_cycles(data, cycle_context): filtered_data = [] for item in data: item_date = parse_data_date(item['date']) if not item_date: continue cycle_start, cycle_end = get_cycle_dates_for_camera(item['source'], cycle_context) if not is_date_in_active_cycle(item_date, cycle_start, cycle_end): continue filtered_data.append(item) return filtered_data 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, api_cycle_id INTEGER, kandang_id INTEGER, k_index INTEGER, cycle_name TEXT NOT NULL, cycle_start TEXT NOT NULL DEFAULT '', cycle_end TEXT NOT NULL DEFAULT '', saldo_awal INTEGER NOT NULL DEFAULT 0, feed_initial_balance_date TEXT NOT NULL DEFAULT '', cycle_status TEXT NOT NULL DEFAULT '', flock TEXT, 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' """ ) if 'api_cycle_id' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN api_cycle_id INTEGER """ ) if 'kandang_id' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN kandang_id INTEGER """ ) if 'k_index' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN k_index INTEGER """ ) if 'cycle_status' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN cycle_status TEXT NOT NULL DEFAULT '' """ ) if 'flock' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN flock TEXT """ ) if 'feed_initial_balance_date' not in columns: conn.execute( """ ALTER TABLE cycle_settings ADD COLUMN feed_initial_balance_date TEXT NOT NULL DEFAULT '' """ ) refreshed_columns = { row[1] for row in conn.execute('PRAGMA table_info(cycle_settings)').fetchall() } if 'api_cycle_id' in refreshed_columns: conn.execute( """ CREATE UNIQUE INDEX IF NOT EXISTS idx_cycle_settings_api_cycle_id ON cycle_settings(api_cycle_id) WHERE api_cycle_id IS NOT NULL """ ) def map_kandang_cycle_row(row): k_index = row[2] return { 'id': row[0], 'kandang_id': row[1], 'k_index': k_index, 'kandang_name': f'Kandang {k_index}' if k_index is not None else '', 'cycle_name': row[3], 'cycle_start': row[4], 'cycle_end': row[5], 'saldo_awal': row[6], 'feed_initial_balance_date': row[7], 'status': row[8], 'flock': row[9], 'tuang_cutoff_time': row[10], 'masuk_cutoff_time': row[11], 'local_timezone': row[12], } def build_feed_initial_balance_items(kandang_cycles, masuk_data=None, counter_settings=None): counter_settings = counter_settings or default_counter_settings() if masuk_data is None: masuk_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']) items = [] for cycle in kandang_cycles: k_index = cycle.get('k_index') if k_index is None: continue items.append(build_feed_initial_balance_verification(cycle, masuk_data, counter_settings)) return items def build_kandang_selector_options(kandang_cycles): options = [] for cycle in kandang_cycles: k_index = cycle.get('k_index') if k_index is None: continue options.append({ 'k_index': k_index, 'kandang_id': cycle.get('kandang_id'), 'label': f"K{k_index} ยท {cycle.get('kandang_name', f'Kandang {k_index}')}", }) return options def serialize_cycles_for_client(all_cycles_by_kandang): serialized = {} for kandang_id, cycles in (all_cycles_by_kandang or {}).items(): serialized[str(kandang_id)] = [ { 'id': cycle.get('id'), 'k_index': cycle.get('k_index'), 'kandang_id': cycle.get('kandang_id'), 'name': cycle.get('cycle_name'), 'cycle_start': cycle.get('cycle_start', ''), 'cycle_end': cycle.get('cycle_end', ''), 'saldo_awal': cycle.get('saldo_awal', 0), 'feed_initial_balance_date': cycle.get('feed_initial_balance_date', ''), 'status': cycle.get('status', ''), } for cycle in cycles ] return serialized def find_cycle_settings(cycle_context, k_index=None, cycle_id=None): if k_index is None: return None kandang_cycle = next( (cycle for cycle in cycle_context.get('kandang_cycles', []) if cycle.get('k_index') == k_index), None, ) if not kandang_cycle: return None kandang_id = kandang_cycle.get('kandang_id') if cycle_id is not None: for cycle in cycle_context.get('all_cycles_by_kandang', {}).get(kandang_id, []): if str(cycle.get('id')) == str(cycle_id): return cycle return kandang_cycle def filter_items_by_k_index(items, k_index): if k_index is None: return items return [ item for item in items if extract_k_index(item.get('source')) == k_index ] def filter_data_by_selected_cycle(data, cycle_settings, k_index): if k_index is None or not cycle_settings: return data cycle_start = parse_cycle_date(cycle_settings.get('cycle_start', ''), 'Cycle start') cycle_end = parse_cycle_date(cycle_settings.get('cycle_end', ''), 'Cycle end') filtered_data = [] for item in data: if extract_k_index(item.get('source')) != k_index: continue item_date = parse_data_date(item['date']) if not item_date: continue if not is_date_in_active_cycle(item_date, cycle_start, cycle_end): continue filtered_data.append(item) return filtered_data def build_dashboard_view_payload(cycle_context, counter_settings, cutoff_metadata, k_index=None, cycle_id=None): selected_cycle = find_cycle_settings(cycle_context, k_index, cycle_id) today_section = build_today_section(cycle_context, counter_settings, cutoff_metadata) tuang_db_data = filter_data_by_selected_cycle( filter_data_by_kandang_cycles( get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']), cycle_context, ), selected_cycle, k_index, ) masuk_db_data = filter_data_by_selected_cycle( filter_data_by_kandang_cycles( get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']), cycle_context, ), selected_cycle, k_index, ) tuang_items = filter_items_by_k_index(today_section['tuang'], k_index) masuk_items = filter_items_by_k_index(today_section['masuk'], k_index) 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_awal = selected_cycle['saldo_awal'] if selected_cycle else cycle_context['site_settings']['saldo_awal'] masuk_history_data = get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']) if k_index is not None and selected_cycle: feed_initial_balances = [ build_feed_initial_balance_verification(selected_cycle, masuk_history_data, counter_settings) ] else: feed_initial_balances = build_feed_initial_balance_items( cycle_context['kandang_cycles'], masuk_history_data, counter_settings, ) return { 'selected_cycle': selected_cycle, 'feed_initial_balances': feed_initial_balances, 'tuang_db_data': [ { **item, 'display_date': format_history_display_date(item['date'], 'tuang', counter_settings), } for item in tuang_db_data ], 'masuk_db_data': [ { **item, 'display_date': format_history_display_date(item['date'], 'masuk', counter_settings), } for item in masuk_db_data ], 'tuang_json_items': tuang_items, 'masuk_json_items': masuk_items, 'tuang_json_total': sum(item['karung'] for item in tuang_items), 'masuk_json_total': sum(item['karung'] for item in masuk_items), 'tuang_db_total': tuang_db_total, 'masuk_db_total': masuk_db_total, 'saldo_awal': saldo_awal, 'saldo_akhir': saldo_awal + masuk_db_total - tuang_db_total, 'current_date': today_section['date_label'], } def parse_optional_k_index(raw_value): if raw_value in (None, ''): return None try: k_index = int(raw_value) except (TypeError, ValueError): abort(400, description='k_index must be a whole number.') if k_index < 1: abort(400, description='k_index must be at least 1.') return k_index def parse_optional_cycle_id(raw_value): if raw_value in (None, ''): return None try: return int(raw_value) except (TypeError, ValueError): abort(400, description='cycle_id must be a whole number.') def sync_api_cycle_context_to_db(api_context): ensure_settings_db() counter_defaults = apply_env_counter_settings(DEFAULT_DASHBOARD_SETTINGS.copy()) synced_at = datetime.now().isoformat(timespec='seconds') synced_kandang_ids = [] with sqlite3.connect(SETTINGS_DB) as conn: for cycle in api_context.get('kandang_cycles', []): kandang_id = cycle.get('kandang_id') if kandang_id is None: continue synced_kandang_ids.append(kandang_id) conn.execute( """ UPDATE cycle_settings SET is_active = 0 WHERE kandang_id = ? AND (api_cycle_id IS NULL OR api_cycle_id != ?) """, (kandang_id, cycle.get('id')), ) existing_row = conn.execute( """ SELECT id FROM cycle_settings WHERE api_cycle_id = ? """, (cycle.get('id'),), ).fetchone() values = ( kandang_id, cycle.get('k_index'), cycle.get('cycle_name', ''), cycle.get('cycle_start', ''), cycle.get('cycle_end', ''), cycle.get('saldo_awal', 0), cycle.get('feed_initial_balance_date', ''), cycle.get('status', ''), str(cycle.get('flock')) if cycle.get('flock') is not None else None, counter_defaults['tuang_cutoff_time'], counter_defaults['masuk_cutoff_time'], counter_defaults['local_timezone'], 1, synced_at, cycle.get('id'), ) if existing_row: conn.execute( """ UPDATE cycle_settings SET kandang_id = ?, k_index = ?, cycle_name = ?, cycle_start = ?, cycle_end = ?, saldo_awal = ?, feed_initial_balance_date = ?, cycle_status = ?, flock = ?, tuang_cutoff_time = ?, masuk_cutoff_time = ?, local_timezone = ?, is_active = ?, updated_at = ? WHERE api_cycle_id = ? """, values, ) else: conn.execute( """ INSERT INTO cycle_settings ( kandang_id, k_index, cycle_name, cycle_start, cycle_end, saldo_awal, feed_initial_balance_date, cycle_status, flock, tuang_cutoff_time, masuk_cutoff_time, local_timezone, is_active, updated_at, api_cycle_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, values, ) if synced_kandang_ids: placeholders = ', '.join('?' for _ in synced_kandang_ids) conn.execute( f""" UPDATE cycle_settings SET is_active = 0 WHERE kandang_id IS NOT NULL AND kandang_id NOT IN ({placeholders}) """, synced_kandang_ids, ) def load_kandang_cycles_from_db(): ensure_settings_db() with sqlite3.connect(SETTINGS_DB) as conn: rows = conn.execute( """ SELECT api_cycle_id, kandang_id, k_index, cycle_name, cycle_start, cycle_end, saldo_awal, feed_initial_balance_date, cycle_status, flock, tuang_cutoff_time, masuk_cutoff_time, local_timezone FROM cycle_settings WHERE kandang_id IS NOT NULL AND is_active = 1 ORDER BY k_index, kandang_id """ ).fetchall() return [map_kandang_cycle_row(row) for row in rows] 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): if not date_text: return None date_text = str(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 format_history_display_date(date_text, counter_type, counter_settings): return format_history_date(date_text) def get_masuk_calendar_dates_for_lookup(date_text, counter_settings): stored_date = parse_data_date(date_text) if not stored_date: return set() return {stored_date} def filter_data_by_cycle(data, cycle_start, cycle_end): if not cycle_start: 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 cycle_end and item_date > cycle_end: continue filtered_data.append(item) return filtered_data def is_masuk_out_item(item): source_name = str(item.get('source', '')).lower().strip() return ( source_name.endswith('_out') or source_name.endswith('-out') or source_name.endswith(' out') ) def is_masuk_in_item(item): source_name = str(item.get('source', '')).lower().strip() return ( source_name.endswith('_in') or source_name.endswith('-in') or source_name.endswith(' in') ) and not is_masuk_out_item(item) def parse_feed_balance_date(balance_date_text): balance_date_text = str(balance_date_text or '').strip() if not balance_date_text: return None for date_format in (DISPLAY_DATE_FORMAT, '%Y-%m-%d'): try: return datetime.strptime(balance_date_text, date_format).date() except ValueError: continue return parse_data_date(balance_date_text) def get_iot_masuk_in_total_for_date(masuk_data, k_index, balance_date_text, counter_settings=None): balance_dates = get_masuk_calendar_dates_for_lookup(balance_date_text, counter_settings or default_counter_settings()) if not balance_dates: return None, [] matched_items = [] for item in masuk_data: if extract_k_index(item.get('source')) != k_index: continue if not is_masuk_in_item(item): continue item_date = parse_data_date(item['date']) if item_date not in balance_dates: continue matched_items.append(item) if not matched_items: return None, [] return sum(item['counter_value'] for item in matched_items), matched_items def build_feed_initial_balance_verification(cycle, masuk_data, counter_settings=None): counter_settings = counter_settings or default_counter_settings() k_index = cycle.get('k_index') feed_value = cycle.get('saldo_awal', 0) or 0 balance_date = cycle.get('feed_initial_balance_date') or '' iot_value, iot_items = get_iot_masuk_in_total_for_date( masuk_data, k_index, balance_date, counter_settings, ) if not balance_date: verification_status = 'no_date' is_match = None elif iot_value is None: verification_status = 'no_iot_data' is_match = None elif iot_value == feed_value: verification_status = 'match' is_match = True else: verification_status = 'mismatch' is_match = False return { 'source': f'K{k_index} - In', 'k_index': k_index, 'kandang_id': cycle.get('kandang_id'), 'feed_initial_balance': feed_value, 'feed_initial_balance_date': balance_date, 'iot_value': iot_value, 'iot_sources': [item['source'] for item in iot_items], 'is_match': is_match, 'verification_status': verification_status, } 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 target_date or not cycle_start: return False if cycle_end: return cycle_start <= target_date <= cycle_end return target_date >= cycle_start 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({ 'source': source_name, 'karung': karung_count, }) return items def format_history_items(data, counter_type='tuang', counter_settings=None): counter_settings = counter_settings or default_counter_settings() return [ { 'id': item['id'], 'source': item['source'], 'date': format_history_display_date(item['date'], counter_type, counter_settings), '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, counter_settings=None): counter_settings = counter_settings or default_counter_settings() 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, 'masuk' if is_masuk else 'tuang', counter_settings, ), '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_context, 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') tuang_items = [] masuk_items = [] for item in get_json_counter_items(get_json_data(KARUNG_TUANG_JSON)): if is_camera_within_cycle(item['source'], tuang_date, cycle_context): tuang_items.append(item) for item in get_json_counter_items(get_json_data(KARUNG_MASUK_JSON)): if is_camera_within_cycle(item['source'], masuk_date, cycle_context): masuk_items.append(item) 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': bool(tuang_items or masuk_items), '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_context, counter_settings): tuang_date = get_business_date('tuang', counter_settings) - timedelta(days=1) masuk_date = get_business_date('masuk', counter_settings) - timedelta(days=1) tuang_data = filter_data_by_date( filter_data_by_kandang_cycles( get_db_data(DB_CONFIGS['tuang']['path'], DB_CONFIGS['tuang']['table']), cycle_context, ), tuang_date, ) masuk_data = filter_data_by_date( filter_data_by_kandang_cycles( get_db_data(DB_CONFIGS['masuk']['path'], DB_CONFIGS['masuk']['table']), cycle_context, ), 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': bool(tuang_data or masuk_data), '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, 'tuang', counter_settings), 'masuk': format_history_items(masuk_data, 'masuk', counter_settings), } @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(): cycle_context = load_cycle_context() k_index = parse_optional_k_index(request.args.get('k_index')) cycle_id = parse_optional_cycle_id(request.args.get('cycle_id')) dashboard_settings = cycle_context['site_settings'] counter_settings = get_counter_settings_from_dashboard(dashboard_settings) cutoff_metadata = get_cutoff_metadata(counter_settings) selected_cycle = find_cycle_settings(cycle_context, k_index, cycle_id) view_payload = build_dashboard_view_payload( cycle_context, counter_settings, cutoff_metadata, k_index=k_index, cycle_id=cycle_id, ) return api_success_response({ 'cycle_source': cycle_context['source'], 'kandang_options': build_kandang_selector_options(cycle_context['kandang_cycles']), 'cycles_by_kandang': serialize_cycles_for_client(cycle_context.get('all_cycles_by_kandang')), 'selected': { 'k_index': k_index, 'cycle_id': selected_cycle.get('id') if selected_cycle else None, }, 'cycles': [ { 'id': cycle['id'], 'kandang_id': cycle.get('kandang_id'), 'k_index': cycle.get('k_index'), 'name': cycle['cycle_name'], 'start_date': parse_cycle_date(cycle['cycle_start'], 'Cycle start').isoformat() if cycle.get('cycle_start') else None, 'end_date': parse_cycle_date(cycle['cycle_end'], 'Cycle end').isoformat() if cycle.get('cycle_end') else None, 'saldo_awal': cycle['saldo_awal'], 'feed_initial_balance_date': cycle.get('feed_initial_balance_date') or None, 'status': cycle.get('status'), } for cycle in cycle_context['kandang_cycles'] ], 'feed_initial_balances': view_payload['feed_initial_balances'], 'cycle': { 'id': selected_cycle.get('id', '') if selected_cycle else dashboard_settings['id'], 'name': selected_cycle['cycle_name'] if selected_cycle else dashboard_settings['cycle_name'], 'start_date': parse_cycle_date(selected_cycle['cycle_start'], 'Cycle start').isoformat() if selected_cycle and selected_cycle.get('cycle_start') else ( parse_cycle_date(dashboard_settings['cycle_start'], 'Cycle start').isoformat() if dashboard_settings.get('cycle_start') else None ), 'end_date': parse_cycle_date(selected_cycle['cycle_end'], 'Cycle end').isoformat() if selected_cycle and selected_cycle.get('cycle_end') else ( parse_cycle_date(dashboard_settings['cycle_end'], 'Cycle end').isoformat() if dashboard_settings.get('cycle_end') else None ), 'saldo_awal': view_payload['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_context, counter_settings, cutoff_metadata), 'tuang': view_payload['tuang_json_items'], 'masuk': view_payload['masuk_json_items'], 'totals': { 'tuang': view_payload['tuang_json_total'], 'masuk': view_payload['masuk_json_total'], }, 'is_within_active_cycle': bool( view_payload['tuang_json_items'] or view_payload['masuk_json_items'] ), }, 'yesterday_history': build_yesterday_history_section(cycle_context, counter_settings), }, 'totals': { 'tuang_db_total': view_payload['tuang_db_total'], 'masuk_db_total': view_payload['masuk_db_total'], 'saldo_awal': view_payload['saldo_awal'], 'saldo_akhir': view_payload['saldo_akhir'], }, }) @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, ) counter_settings = default_counter_settings() tuang_payload = build_combined_site_payload( 'ktc', tuang_today, tuang_history, is_masuk=False, counter_settings=counter_settings ) masuk_payload = build_combined_site_payload( 'kpc', masuk_today, masuk_history, is_masuk=True, counter_settings=counter_settings ) 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(): cycle_context = load_cycle_context() dashboard_settings = cycle_context['site_settings'] counter_settings = get_counter_settings_from_dashboard(dashboard_settings) cutoff_metadata = get_cutoff_metadata(counter_settings) k_index = parse_optional_k_index(request.args.get('k_index')) cycle_id = parse_optional_cycle_id(request.args.get('cycle_id')) if k_index is None and cycle_context['kandang_cycles']: first_cycle = cycle_context['kandang_cycles'][0] k_index = first_cycle.get('k_index') cycle_id = first_cycle.get('id') view_payload = build_dashboard_view_payload( cycle_context, counter_settings, cutoff_metadata, k_index=k_index, cycle_id=cycle_id, ) selected_cycle = view_payload['selected_cycle'] return render_template('index.html', dashboard_settings=dashboard_settings, kandang_cycles=cycle_context['kandang_cycles'], kandang_options=build_kandang_selector_options(cycle_context['kandang_cycles']), cycles_by_kandang=serialize_cycles_for_client(cycle_context.get('all_cycles_by_kandang')), selected_k_index=k_index, selected_cycle_id=selected_cycle.get('id') if selected_cycle else None, selected_cycle=selected_cycle, feed_initial_balances=view_payload['feed_initial_balances'], cycle_source=cycle_context['source'], cutoff_metadata=cutoff_metadata, tuang_db_data=view_payload['tuang_db_data'], masuk_db_data=view_payload['masuk_db_data'], tuang_json_data={ item['source']: {'karung': item['karung']} for item in view_payload['tuang_json_items'] }, masuk_json_data={ item['source']: {'karung': item['karung']} for item in view_payload['masuk_json_items'] }, tuang_json_total=view_payload['tuang_json_total'], masuk_json_total=view_payload['masuk_json_total'], tuang_db_total=view_payload['tuang_db_total'], masuk_db_total=view_payload['masuk_db_total'], saldo_akhir=view_payload['saldo_akhir'], display_saldo_awal=view_payload['saldo_awal'], current_date=view_payload['current_date']) if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=8090)