584 lines
19 KiB
Python
584 lines
19 KiB
Python
from flask import Flask, abort, redirect, render_template, request, url_for
|
|
import sqlite3
|
|
import json
|
|
from datetime import datetime
|
|
import os
|
|
from pathlib import Path
|
|
|
|
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')
|
|
|
|
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')
|
|
|
|
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,
|
|
}
|
|
|
|
def get_db_data(db_path, table_name):
|
|
"""Fetch all data from database"""
|
|
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()
|
|
|
|
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
|
|
FROM cycle_settings
|
|
ORDER BY is_active DESC, id DESC
|
|
LIMIT 1
|
|
"""
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return DEFAULT_DASHBOARD_SETTINGS.copy()
|
|
|
|
return {
|
|
'id': row[0],
|
|
'cycle_name': row[1],
|
|
'cycle_start': row[2],
|
|
'cycle_end': row[3],
|
|
'saldo_awal': row[4],
|
|
}
|
|
|
|
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,
|
|
is_active,
|
|
updated_at
|
|
)
|
|
VALUES (?, ?, ?, ?, 1, ?)
|
|
""",
|
|
(
|
|
settings['cycle_name'],
|
|
settings['cycle_start'],
|
|
settings['cycle_end'],
|
|
settings['saldo_awal'],
|
|
datetime.now().isoformat(timespec='seconds')
|
|
)
|
|
)
|
|
|
|
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
|
|
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],
|
|
}
|
|
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,
|
|
is_active,
|
|
updated_at
|
|
)
|
|
SELECT cycle_name,
|
|
cycle_start,
|
|
cycle_end,
|
|
saldo_awal,
|
|
0,
|
|
updated_at
|
|
FROM cycle_settings_old
|
|
ORDER BY id
|
|
"""
|
|
)
|
|
conn.execute('DROP TABLE cycle_settings_old')
|
|
else:
|
|
create_cycle_settings_table(conn)
|
|
ensure_settings_active_column(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,
|
|
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'],
|
|
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,
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
|
|
def ensure_settings_active_column(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
|
|
"""
|
|
)
|
|
|
|
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):
|
|
return str(item['camera_name']).lower().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()
|
|
|
|
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.')
|
|
|
|
return {
|
|
'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,
|
|
}
|
|
|
|
def ensure_db_writable(db_path):
|
|
db_path = Path(db_path)
|
|
if not db_path.exists():
|
|
abort(404, description=f'Database file not found: {db_path}')
|
|
if not os.access(db_path, os.W_OK):
|
|
abort(403, description=f'Cannot save jumlah karung because database is not writable: {db_path}')
|
|
if not os.access(db_path.parent, os.W_OK):
|
|
abort(403, description=f'Cannot save jumlah karung because database directory is not writable: {db_path.parent}')
|
|
|
|
def update_db_data(db_path, table_name, row_id, counter_value):
|
|
"""Update one database row by primary key."""
|
|
ensure_db_writable(db_path)
|
|
|
|
try:
|
|
with sqlite3.connect(db_path) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
f"""
|
|
UPDATE {table_name}
|
|
SET counter_value = ?
|
|
WHERE id = ?
|
|
""",
|
|
(counter_value, row_id)
|
|
)
|
|
updated_count = cursor.rowcount
|
|
except sqlite3.OperationalError as error:
|
|
abort(403, description=f'Cannot save jumlah karung to database {db_path}: {error}')
|
|
|
|
if updated_count == 0:
|
|
abort(404, description='No database row was found for the requested edit.')
|
|
|
|
def update_db_rows(db_path, table_name, row_values):
|
|
ensure_db_writable(db_path)
|
|
|
|
try:
|
|
with sqlite3.connect(db_path) as conn:
|
|
cursor = conn.cursor()
|
|
for row_id, counter_value in row_values.items():
|
|
cursor.execute(
|
|
f"""
|
|
UPDATE {table_name}
|
|
SET counter_value = ?
|
|
WHERE id = ?
|
|
""",
|
|
(counter_value, row_id)
|
|
)
|
|
|
|
if cursor.rowcount == 0:
|
|
abort(404, description='No database row was found for one of the requested edits.')
|
|
except sqlite3.OperationalError as error:
|
|
abort(403, description=f'Cannot save jumlah karung to database {db_path}: {error}')
|
|
|
|
def get_counter_value_from_form(counter_value_raw):
|
|
if not counter_value_raw:
|
|
abort(400, description='Jumlah karung is required to edit database data.')
|
|
|
|
try:
|
|
counter_value = int(counter_value_raw)
|
|
except ValueError:
|
|
abort(400, description='Jumlah karung must be a whole number.')
|
|
|
|
if counter_value < 0:
|
|
abort(400, description='Jumlah karung cannot be negative.')
|
|
|
|
return counter_value
|
|
|
|
def get_bulk_counter_values_from_form():
|
|
row_values = {}
|
|
|
|
for field_name, field_value in request.form.items():
|
|
if not field_name.startswith('counter_value_'):
|
|
continue
|
|
|
|
row_id_raw = field_name.removeprefix('counter_value_')
|
|
try:
|
|
row_id = int(row_id_raw)
|
|
except ValueError:
|
|
abort(400, description='Invalid database row id in edit form.')
|
|
|
|
row_values[row_id] = get_counter_value_from_form(field_value.strip())
|
|
|
|
if not row_values:
|
|
abort(400, description='No jumlah karung values were submitted.')
|
|
|
|
return row_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 {}
|
|
|
|
@app.route('/database/<db_name>/<int:row_id>/edit', methods=['POST'])
|
|
def edit_db_row(db_name, row_id):
|
|
db_config = DB_CONFIGS.get(db_name)
|
|
if not db_config:
|
|
abort(404, description='Unknown database. Use tuang or masuk.')
|
|
|
|
counter_value = get_counter_value_from_form(request.form.get('counter_value', '').strip())
|
|
|
|
update_db_data(
|
|
db_config['path'],
|
|
db_config['table'],
|
|
row_id,
|
|
counter_value
|
|
)
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/database/<db_name>/edit', methods=['POST'])
|
|
def edit_db_rows(db_name):
|
|
db_config = DB_CONFIGS.get(db_name)
|
|
if not db_config:
|
|
abort(404, description='Unknown database. Use tuang or masuk.')
|
|
|
|
update_db_rows(
|
|
db_config['path'],
|
|
db_config['table'],
|
|
get_bulk_counter_values_from_form()
|
|
)
|
|
return redirect(url_for('index'))
|
|
|
|
@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)
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/')
|
|
def index():
|
|
dashboard_settings = load_dashboard_settings()
|
|
cycle_options = get_cycle_options()
|
|
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'])
|
|
|
|
# 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'])
|
|
|
|
# Get current date data from JSON files
|
|
tuang_json_data = get_json_data('/etc/frigate-counter/karung-tuang-2/karung_tuang.json')
|
|
masuk_json_data = get_json_data('/etc/frigate-counter/karung-masuk/karung_masuk.json')
|
|
|
|
# Calculate totals from JSON (current date)
|
|
tuang_json_total = sum(item.get('karung', 0) for item in tuang_json_data.values())
|
|
masuk_json_total = sum(item.get('karung', 0) for item in masuk_json_data.values())
|
|
|
|
# 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 = datetime.now().strftime(DISPLAY_DATE_FORMAT)
|
|
|
|
return render_template('index.html',
|
|
dashboard_settings=dashboard_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=8080)
|