anoderb
fix: add password_hash to column discovery candidates
e7ed82f
Raw
History Blame Contribute Delete
18.6 kB
# database.py — MySQL Connection & CRUD Operations for Scraper and Forecast
import pandas as pd
import sqlalchemy
import bcrypt
from sqlalchemy import create_engine, text
from app.config import get_settings
settings = get_settings()
# Global Singleton DB Engine
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600
)
def get_db_engine():
return engine
def get_engine():
"""Compatibility alias for forecast operations."""
return engine
# ── READ OPERATIONS ───────────────────────────────────────────────────────────
def get_all_komoditas():
"""Ambil semua komoditas aktif."""
return pd.read_sql(
"SELECT id, nama, slug, unit, volatile, volatilitas_skor FROM komoditas WHERE is_active=1",
engine
)
def get_harga_harian(komoditas_id: int, days: int = 730):
"""Ambil data harga harian untuk satu komoditas (default 2 tahun terakhir)."""
query = f"""
SELECT hh.tanggal AS date, p.nama AS pasar_nama, hh.harga
FROM harga_harian hh
JOIN pasar p ON p.id = hh.pasar_id
WHERE hh.komoditas_id = {komoditas_id}
AND hh.tanggal >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)
ORDER BY hh.tanggal ASC
"""
df_raw = pd.read_sql(query, engine)
if df_raw.empty:
return pd.DataFrame()
# Pivot → wide format
df = df_raw.pivot_table(
index='date', columns='pasar_nama', values='harga'
).reset_index()
df.columns.name = None
df['date'] = pd.to_datetime(df['date'])
df = df.sort_values('date').reset_index(drop=True)
return df
def get_pasar_list(komoditas_id: int):
"""Ambil daftar pasar yang punya data untuk komoditas ini."""
return pd.read_sql(f"""
SELECT DISTINCT p.id, p.nama
FROM harga_harian hh
JOIN pasar p ON p.id = hh.pasar_id
WHERE hh.komoditas_id = {komoditas_id}
""", engine)
# ── WRITE OPERATIONS ──────────────────────────────────────────────────────────
def save_model_ml(data: dict):
"""Upsert ke tabel model_ml."""
with engine.begin() as conn:
conn.execute(text("""
INSERT INTO model_ml (
komoditas_id, nama_model, versi, deskripsi, file_path,
mape, rmse, mae, r2_score, confidence_level,
stabilitas, status_validasi, catatan_validasi,
tanggal_training, tanggal_evaluasi, is_active,
created_at, updated_at
) VALUES (
:komoditas_id, :nama_model, :versi, :deskripsi, :file_path,
:mape, :rmse, :mae, :r2_score, :confidence_level,
:stabilitas, :status_validasi, :catatan_validasi,
:tanggal_training, :tanggal_evaluasi, 1, NOW(), NOW()
)
ON DUPLICATE KEY UPDATE
nama_model = VALUES(nama_model),
versi = VALUES(versi),
deskripsi = VALUES(deskripsi),
file_path = VALUES(file_path),
mape = VALUES(mape),
rmse = VALUES(rmse),
mae = VALUES(mae),
r2_score = VALUES(r2_score),
confidence_level = VALUES(confidence_level),
stabilitas = VALUES(stabilitas),
status_validasi = VALUES(status_validasi),
catatan_validasi = VALUES(catatan_validasi),
tanggal_evaluasi = VALUES(tanggal_evaluasi),
updated_at = NOW()
"""), data)
with engine.connect() as conn:
return conn.execute(
text("SELECT id FROM model_ml WHERE komoditas_id=:kid ORDER BY updated_at DESC LIMIT 1"),
{'kid': data['komoditas_id']}
).scalar()
def save_hasil_prediksi(predictions: list, komoditas_id: int, pasar_id, model_id: int, meta: dict):
"""Hapus prediksi lama & insert 7 baris baru."""
with engine.begin() as conn:
conn.execute(text("""
DELETE FROM hasil_prediksi
WHERE komoditas_id = :kid AND pasar_id = :pid
AND tanggal_target >= CURDATE()
"""), {'kid': komoditas_id, 'pid': pasar_id})
for p in predictions:
conn.execute(text("""
INSERT INTO hasil_prediksi (
komoditas_id, pasar_id, model_id,
tanggal_prediksi, tanggal_target,
harga_prediksi, confidence_level,
model_name, mape, rmse, created_at
) VALUES (
:kid, :pid, :model_id,
CURDATE(), :tanggal_target,
:harga_prediksi, :confidence_level,
:model_name, :mape, :rmse, NOW()
)
"""), {
'kid' : komoditas_id,
'pid' : pasar_id,
'model_id' : model_id,
'tanggal_target' : p['tanggal'],
'harga_prediksi' : p['harga_prediksi'],
'confidence_level': meta['confidence_level'],
'model_name' : meta['nama_model'],
'mape' : meta['mape'],
'rmse' : meta['rmse'],
})
def save_ringkasan_prediksi(data: dict, komoditas_id: int, model_id: int):
"""Upsert ringkasan prediksi."""
with engine.begin() as conn:
conn.execute(text("""
INSERT INTO ringkasan_prediksi (
komoditas_id, model_id,
harga_min, harga_max, tren, confidence_level,
status_analisis, deskripsi_status,
tanggal_mulai, tanggal_akhir, created_at
) VALUES (
:komoditas_id, :model_id,
:harga_min, :harga_max, :tren, :confidence_level,
:status_analisis, :deskripsi_status,
:tanggal_mulai, :tanggal_akhir, NOW()
)
ON DUPLICATE KEY UPDATE
harga_min = VALUES(harga_min),
harga_max = VALUES(harga_max),
tren = VALUES(tren),
confidence_level = VALUES(confidence_level),
status_analisis = VALUES(status_analisis),
deskripsi_status = VALUES(deskripsi_status),
tanggal_mulai = VALUES(tanggal_mulai),
tanggal_akhir = VALUES(tanggal_akhir)
"""), {**data, 'komoditas_id': komoditas_id, 'model_id': model_id})
def save_insight_prediksi(insights: list, komoditas_id: int, model_id: int):
"""Hapus insight lama & insert baru."""
with engine.begin() as conn:
conn.execute(text(
"DELETE FROM insight_prediksi WHERE komoditas_id = :kid"
), {'kid': komoditas_id})
for ins in insights:
conn.execute(text("""
INSERT INTO insight_prediksi (
komoditas_id, model_id, konten, tipe, ikon,
urutan, is_active, created_at, updated_at
) VALUES (
:kid, :model_id, :konten, :tipe, :ikon,
:urutan, 1, NOW(), NOW()
)
"""), {
'kid' : komoditas_id,
'model_id': model_id,
'konten' : ins['konten'],
'tipe' : ins['tipe'],
'ikon' : ins['ikon'],
'urutan' : ins['urutan'],
})
def update_komoditas_volatilitas(komoditas_id: int, volatile: int, skor: float):
with engine.begin() as conn:
conn.execute(text("""
UPDATE komoditas SET volatile=:v, volatilitas_skor=:s, updated_at=NOW()
WHERE id=:kid
"""), {'v': volatile, 's': skor, 'kid': komoditas_id})
def save_all_model_results(all_results: dict, best_meta: dict, komoditas_id: int, pasar_id: int):
"""Save ALL model results to model_ml (not just best). Best gets is_active=1."""
best_name = best_meta.get('nama_model', '')
with engine.begin() as conn:
# Deactivate old models for this komoditas
conn.execute(text(
"UPDATE model_ml SET is_active=0 WHERE komoditas_id=:kid"
), {'kid': komoditas_id})
# Insert each model result
for model_name, metrics in all_results.items():
is_best = 1 if model_name == best_name or f"{model_name}_Tuned" == best_name else 0
# Hitung stabilitas sederhana untuk kolom ENUM agar tidak error
mape_val = metrics.get('mape', 0)
stabilitas_label = (
'optimal' if mape_val < 2 else
'baik' if mape_val < 5 else
'cukup' if mape_val < 10 else
'perlu_retrain'
)
conn.execute(text("""
INSERT INTO model_ml (
komoditas_id, nama_model, versi, deskripsi, file_path,
mape, rmse, mae, r2_score, confidence_level,
stabilitas, status_validasi, catatan_validasi,
tanggal_training, tanggal_evaluasi, is_active,
created_at, updated_at
) VALUES (
:komoditas_id, :nama_model, '1.0', :deskripsi, '',
:mape, :rmse, :mae, :r2_score, 0,
:stabilitas, 'terverifikasi', '',
CURDATE(), CURDATE(), :is_active, NOW(), NOW()
)
"""), {
'komoditas_id': komoditas_id,
'nama_model': model_name,
'deskripsi': f"Model {model_name} untuk komoditas ID {komoditas_id}",
'mape': round(mape_val, 4),
'rmse': round(metrics.get('rmse', 0), 4),
'mae': round(metrics.get('mae', 0), 4),
'r2_score': round(metrics.get('r2', 0), 4),
'stabilitas': stabilitas_label,
'is_active': is_best,
})
# ── READ OPERATIONS FOR FRONTEND API ──────────────────────────────────────────
def get_prediksi_data(komoditas_id: int):
"""Ambil semua data yang dibutuhkan frontend untuk satu komoditas."""
komoditas = pd.read_sql(f"""
SELECT k.*, kk.nama as kategori_nama
FROM komoditas k
JOIN kategori_komoditas kk ON kk.id = k.kategori_id
WHERE k.id = {komoditas_id}
""", engine).to_dict('records')
komoditas = komoditas[0] if komoditas else None
harga_terkini = pd.read_sql(f"""
SELECT hh.harga, hh.tanggal, p.nama as pasar_nama, p.id as pasar_id
FROM harga_harian hh
JOIN pasar p ON p.id = hh.pasar_id
WHERE hh.komoditas_id = {komoditas_id}
ORDER BY hh.tanggal DESC LIMIT 1
""", engine).to_dict('records')
harga_terkini = harga_terkini[0] if harga_terkini else None
# Best (active) model
model = pd.read_sql(f"""
SELECT * FROM model_ml WHERE komoditas_id={komoditas_id} AND is_active=1
ORDER BY updated_at DESC LIMIT 1
""", engine).to_dict('records')
model = model[0] if model else None
# ALL models for comparison (all runs)
all_models = pd.read_sql(f"""
SELECT nama_model, mape, rmse, mae, r2_score, is_active,
tanggal_training, created_at
FROM model_ml WHERE komoditas_id={komoditas_id}
ORDER BY created_at DESC, rmse ASC
""", engine).to_dict('records')
ringkasan = pd.read_sql(f"""
SELECT * FROM ringkasan_prediksi WHERE komoditas_id={komoditas_id}
ORDER BY created_at DESC LIMIT 1
""", engine).to_dict('records')
ringkasan = ringkasan[0] if ringkasan else None
hasil = pd.read_sql(f"""
SELECT tanggal_target, harga_prediksi, confidence_level
FROM hasil_prediksi
WHERE komoditas_id={komoditas_id} AND tanggal_target >= CURDATE()
ORDER BY tanggal_target ASC
""", engine).to_dict('records')
insights = pd.read_sql(f"""
SELECT * FROM insight_prediksi
WHERE komoditas_id={komoditas_id} AND is_active=1
ORDER BY urutan ASC
""", engine).to_dict('records')
return {
'komoditas' : komoditas,
'harga_terkini' : harga_terkini,
'model' : model,
'all_models' : all_models,
'ringkasan' : ringkasan,
'prediksi_7hari': hasil,
'insights' : insights,
}
def verify_user_mysql(email: str, password_input: str) -> dict:
"""
Verify user credential using MySQL 'users' table.
Checks email, bcrypt password hash (Laravel format), and validates super_admin/admin role.
Auto-discovers actual column names from INFORMATION_SCHEMA to handle varying schemas.
Returns user dict on success, raises Exception otherwise.
"""
with engine.connect() as conn:
# Step 1: Discover actual column names in the 'users' table
try:
col_query = text("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'
""")
columns = [row[0].lower() for row in conn.execute(col_query).fetchall()]
except Exception as e:
raise Exception(f"Gagal membaca skema tabel users: {str(e)}")
if not columns:
raise Exception("Tabel 'users' tidak ditemukan di database")
# Step 2: Map logical fields to actual column names
# Name column: could be 'name', 'nama', 'full_name', 'username'
name_col = None
for candidate in ['name', 'nama', 'full_name', 'username', 'nama_lengkap']:
if candidate in columns:
name_col = candidate
break
if not name_col:
name_col = 'email' # fallback to email as display name
# Password column: could be 'password', 'kata_sandi', 'sandi', 'passwd'
password_col = None
for candidate in ['password', 'password_hash', 'kata_sandi', 'sandi', 'passwd', 'pass']:
if candidate in columns:
password_col = candidate
break
if not password_col:
raise Exception(f"Kolom password tidak ditemukan di tabel users. Kolom yang ada: {', '.join(columns)}")
# Role column: could be 'role', 'roles', 'user_role', 'level', 'tipe'
role_col = None
for candidate in ['role', 'roles', 'user_role', 'level', 'tipe', 'type']:
if candidate in columns:
role_col = candidate
break
if not role_col:
raise Exception(f"Kolom role tidak ditemukan di tabel users. Kolom yang ada: {', '.join(columns)}")
# Step 3: Build and execute the query
try:
sql = f"SELECT id, `{name_col}` as name, email, `{password_col}` as password, `{role_col}` as role FROM users WHERE email = :email LIMIT 1"
res = conn.execute(text(sql), {"email": email}).fetchone()
except Exception as e:
raise Exception(f"Database error querying users table: {str(e)}")
if not res:
raise Exception("Email tidak terdaftar")
user_data = dict(res._mapping)
hashed_password = user_data.get("password")
if not hashed_password:
raise Exception("Password hash tidak ditemukan di database")
# Step 4: Verify password using bcrypt
try:
# Laravel uses $2y$ prefix, python bcrypt expects $2b$ or $2a$
compat_hash = hashed_password
if hashed_password.startswith("$2y$"):
compat_hash = "$2b$" + hashed_password[4:]
is_valid = bcrypt.checkpw(
password_input.encode('utf-8'),
compat_hash.encode('utf-8')
)
except Exception as hash_err:
raise Exception(f"Gagal memverifikasi password hash: {str(hash_err)}")
if not is_valid:
raise Exception("Password salah")
# Step 5: Check role - must be admin or super_admin
role = str(user_data.get("role", "")).lower().strip()
if role not in ["admin", "super_admin", "superadmin", "super-admin"]:
raise Exception(f"Akses ditolak: Role '{role}' tidak memiliki izin administrator")
return {
"id": user_data.get("id"),
"name": user_data.get("name"),
"email": user_data.get("email"),
"role": user_data.get("role")
}
def get_active_models_summary() -> dict:
"""
Get summary of all active models and commodity model list for AI Center page.
"""
with engine.connect() as conn:
try:
query = text("""
SELECT k.id as komoditas_id, k.nama as komoditas_nama, k.unit, k.volatile,
m.nama_model, m.mape, m.rmse, m.mae, m.r2_score, m.status_validasi,
m.tanggal_training, m.is_active
FROM komoditas k
LEFT JOIN model_ml m ON m.komoditas_id = k.id AND m.is_active = 1
WHERE k.is_active = 1
ORDER BY k.nama ASC
""")
rows = conn.execute(query).fetchall()
except Exception as e:
raise Exception(f"Database error querying active models: {str(e)}")
results = []
for r in rows:
d = dict(r._mapping)
# handle date serialization nicely
if d.get("tanggal_training"):
d["tanggal_training"] = str(d["tanggal_training"])
results.append(d)
# Compute averages
active_mapes = [r['mape'] for r in results if r['mape'] is not None]
avg_mape = round(sum(active_mapes) / len(active_mapes), 2) if active_mapes else 0.0
active_count = sum(1 for r in results if r['is_active'] == 1)
latest_date = None
for r in results:
if r['tanggal_training']:
dt_str = str(r['tanggal_training'])
if not latest_date or dt_str > latest_date:
latest_date = dt_str
return {
"total_active_models": active_count,
"average_mape": avg_mape,
"latest_inference": latest_date,
"commodity_models": results
}