forked from ariska/karung-web-admin
981 lines
33 KiB
Python
981 lines
33 KiB
Python
from flask import Flask, abort, jsonify, redirect, 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 (
|
|
INDONESIA_TIMEZONES,
|
|
apply_env_counter_settings,
|
|
default_counter_settings,
|
|
get_cutoff_metadata,
|
|
get_default_settings_file,
|
|
get_business_date,
|
|
get_env_locked_counter_settings,
|
|
get_period_window,
|
|
normalize_cutoff_time,
|
|
normalize_timezone,
|
|
save_counter_settings,
|
|
)
|
|
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_SETTINGS_FILE = get_default_settings_file(BASE_DIR)
|
|
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'],
|
|
}
|
|
|
|
DEFAULT_CUTOFF_TIME_OPTIONS = [
|
|
*(f'{hour:02d}:00:00' for hour in range(24)),
|
|
'24:00:00',
|
|
]
|
|
|
|
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],
|
|
'camera_name': row[1],
|
|
'date': row[2],
|
|
'counter_value': row[3]
|
|
})
|
|
return data
|
|
|
|
def load_dashboard_settings():
|
|
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 save_dashboard_settings(settings):
|
|
ensure_settings_db()
|
|
|
|
with sqlite3.connect(SETTINGS_DB) as conn:
|
|
conn.execute('UPDATE cycle_settings SET is_active = 0')
|
|
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
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
|
""",
|
|
(
|
|
settings['cycle_name'],
|
|
settings['cycle_start'],
|
|
settings['cycle_end'],
|
|
settings['saldo_awal'],
|
|
settings['tuang_cutoff_time'],
|
|
settings['masuk_cutoff_time'],
|
|
settings['local_timezone'],
|
|
datetime.now().isoformat(timespec='seconds')
|
|
)
|
|
)
|
|
|
|
export_counter_settings(settings)
|
|
|
|
def get_cycle_options():
|
|
ensure_settings_db()
|
|
|
|
with sqlite3.connect(SETTINGS_DB) as conn:
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT latest_cycle.id,
|
|
latest_cycle.cycle_name,
|
|
latest_cycle.cycle_start,
|
|
latest_cycle.cycle_end,
|
|
latest_cycle.saldo_awal,
|
|
latest_cycle.tuang_cutoff_time,
|
|
latest_cycle.masuk_cutoff_time,
|
|
latest_cycle.local_timezone
|
|
FROM cycle_settings AS latest_cycle
|
|
INNER JOIN (
|
|
SELECT cycle_name, MAX(id) AS latest_id
|
|
FROM cycle_settings
|
|
GROUP BY cycle_name
|
|
) AS grouped_cycle
|
|
ON latest_cycle.id = grouped_cycle.latest_id
|
|
ORDER BY latest_cycle.cycle_name COLLATE NOCASE
|
|
"""
|
|
).fetchall()
|
|
|
|
return [
|
|
{
|
|
'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],
|
|
}
|
|
for row in rows
|
|
]
|
|
|
|
def select_cycle_by_name(cycle_name):
|
|
ensure_settings_db()
|
|
|
|
with sqlite3.connect(SETTINGS_DB) as conn:
|
|
row = conn.execute(
|
|
"""
|
|
SELECT id
|
|
FROM cycle_settings
|
|
WHERE cycle_name = ?
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
""",
|
|
(cycle_name,)
|
|
).fetchone()
|
|
|
|
if not row:
|
|
abort(404, description='Cycle name was not found.')
|
|
|
|
conn.execute('UPDATE cycle_settings SET is_active = 0')
|
|
conn.execute(
|
|
"""
|
|
UPDATE cycle_settings
|
|
SET is_active = 1,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
""",
|
|
(datetime.now().isoformat(timespec='seconds'), row[0])
|
|
)
|
|
|
|
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 format_date_picker_value(date_text):
|
|
parsed_date = parse_cycle_date(date_text, 'Cycle date')
|
|
if not parsed_date:
|
|
return ''
|
|
|
|
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):
|
|
camera_name = str(item['camera_name']).lower().strip()
|
|
return (
|
|
camera_name.endswith('_out')
|
|
or camera_name.endswith('-out')
|
|
or camera_name.endswith(' out')
|
|
)
|
|
|
|
def get_settings_from_form():
|
|
cycle_name = request.form.get('cycle_name', '').strip()
|
|
cycle_start_raw = request.form.get('cycle_start', '').strip()
|
|
cycle_end_raw = request.form.get('cycle_end', '').strip()
|
|
saldo_awal_raw = request.form.get('saldo_awal', '0').strip()
|
|
tuang_cutoff_raw = request.form.get(
|
|
'tuang_cutoff_time',
|
|
DEFAULT_DASHBOARD_SETTINGS['tuang_cutoff_time']
|
|
).strip()
|
|
masuk_cutoff_raw = request.form.get(
|
|
'masuk_cutoff_time',
|
|
DEFAULT_DASHBOARD_SETTINGS['masuk_cutoff_time']
|
|
).strip()
|
|
local_timezone_raw = request.form.get(
|
|
'local_timezone',
|
|
DEFAULT_DASHBOARD_SETTINGS['local_timezone']
|
|
).strip()
|
|
|
|
if not cycle_name:
|
|
abort(400, description='Cycle name is required.')
|
|
if not cycle_start_raw or not cycle_end_raw:
|
|
abort(400, description='Cycle start and cycle end are required to filter historical data.')
|
|
|
|
cycle_start = parse_cycle_date(cycle_start_raw, 'Cycle start')
|
|
cycle_end = parse_cycle_date(cycle_end_raw, 'Cycle end')
|
|
|
|
if cycle_start and cycle_end and cycle_start > cycle_end:
|
|
abort(400, description='Cycle start must be before or equal to cycle end.')
|
|
|
|
try:
|
|
saldo_awal = int(saldo_awal_raw or 0)
|
|
except ValueError:
|
|
abort(400, description='Saldo awal must be a whole number.')
|
|
|
|
if saldo_awal < 0:
|
|
abort(400, description='Saldo awal cannot be negative.')
|
|
|
|
try:
|
|
tuang_cutoff_time = normalize_cutoff_time(tuang_cutoff_raw)
|
|
except ValueError as error:
|
|
abort(400, description=f'Karung Tuang cutoff time is invalid: {error}')
|
|
|
|
try:
|
|
masuk_cutoff_time = normalize_cutoff_time(masuk_cutoff_raw)
|
|
except ValueError as error:
|
|
abort(400, description=f'Karung Masuk cutoff time is invalid: {error}')
|
|
|
|
try:
|
|
local_timezone = normalize_timezone(local_timezone_raw)
|
|
except ValueError as error:
|
|
abort(400, description=f'Local timezone is invalid: {error}')
|
|
|
|
return apply_env_counter_settings({
|
|
'cycle_name': cycle_name,
|
|
'cycle_start': cycle_start.strftime(DISPLAY_DATE_FORMAT) if cycle_start else '',
|
|
'cycle_end': cycle_end.strftime(DISPLAY_DATE_FORMAT) if cycle_end else '',
|
|
'saldo_awal': saldo_awal,
|
|
'tuang_cutoff_time': tuang_cutoff_time,
|
|
'masuk_cutoff_time': masuk_cutoff_time,
|
|
'local_timezone': local_timezone,
|
|
})
|
|
|
|
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 export_counter_settings(settings):
|
|
try:
|
|
save_counter_settings(get_counter_settings_from_dashboard(settings), COUNTER_SETTINGS_FILE)
|
|
except OSError as error:
|
|
abort(500, description=f'Cannot save cutoff settings to {COUNTER_SETTINGS_FILE}: {error}')
|
|
|
|
def get_cutoff_time_options(*selected_cutoff_times):
|
|
option_values = list(DEFAULT_CUTOFF_TIME_OPTIONS)
|
|
for cutoff_time in selected_cutoff_times:
|
|
normalized_cutoff_time = normalize_cutoff_time(cutoff_time)
|
|
if normalized_cutoff_time not in option_values:
|
|
option_values.append(normalized_cutoff_time)
|
|
|
|
return sorted(option_values)
|
|
|
|
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 camera_name, camera_data in json_data.items():
|
|
karung_count = 0
|
|
if isinstance(camera_data, dict):
|
|
karung_count = camera_data.get('karung', 0)
|
|
|
|
items.append({
|
|
'camera_name': camera_name,
|
|
'karung': karung_count,
|
|
})
|
|
|
|
return items
|
|
|
|
def format_history_items(data):
|
|
return [
|
|
{
|
|
'id': item['id'],
|
|
'camera_name': item['camera_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/k1/combined', methods=['GET'])
|
|
def get_k1_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({'camera_name': item['camera_name']}) 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('/settings', methods=['POST'])
|
|
def update_dashboard_settings():
|
|
settings = get_settings_from_form()
|
|
save_dashboard_settings(settings)
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/settings/select', methods=['POST'])
|
|
def select_dashboard_cycle():
|
|
cycle_name = request.form.get('cycle_name', '').strip()
|
|
if not cycle_name:
|
|
abort(400, description='Cycle name is required to select a cycle.')
|
|
|
|
select_cycle_by_name(cycle_name)
|
|
export_counter_settings(load_dashboard_settings())
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/')
|
|
def index():
|
|
dashboard_settings = load_dashboard_settings()
|
|
cycle_options = get_cycle_options()
|
|
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')
|
|
cycle_start_picker_value = format_date_picker_value(dashboard_settings['cycle_start'])
|
|
cycle_end_picker_value = format_date_picker_value(dashboard_settings['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['camera_name']: {'karung': item['karung']}
|
|
for item in today_section['tuang']
|
|
}
|
|
masuk_json_data = {
|
|
item['camera_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,
|
|
timezone_options=INDONESIA_TIMEZONES,
|
|
cutoff_time_options=get_cutoff_time_options(
|
|
dashboard_settings['tuang_cutoff_time'],
|
|
dashboard_settings['masuk_cutoff_time']
|
|
),
|
|
cutoff_metadata=cutoff_metadata,
|
|
env_locked_counter_settings=get_env_locked_counter_settings(),
|
|
cycle_options=cycle_options,
|
|
cycle_start_picker_value=cycle_start_picker_value,
|
|
cycle_end_picker_value=cycle_end_picker_value,
|
|
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)
|