Init
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user