Files

1543 lines
51 KiB
Python

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)