import base64 import hashlib import hmac import json import os import time from html import escape from pathlib import Path import sys from urllib.parse import quote, unquote from fastapi import FastAPI, Form, HTTPException, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse BASE_DIR = Path(__file__).resolve().parent PROJECT_DIR = BASE_DIR.parent ML_DIR = PROJECT_DIR / "ml" if str(ML_DIR) not in sys.path: sys.path.append(str(ML_DIR)) from database import ( # noqa: E402 authenticate_user, create_user, delete_prediction_result, delete_user, get_database_label, get_prediction_result, get_user_by_id, init_db, list_prediction_results, list_users, save_prediction_result, update_prediction_result, update_user, ) app = FastAPI(title="ConfiVoice Admin Web") ADMIN_SESSION_COOKIE = "confivoice_admin_session" ADMIN_SESSION_MAX_AGE = 60 * 60 * 24 * 7 ADMIN_SESSION_SECRET = os.getenv("CONFIVOICE_ADMIN_SECRET", "confivoice-admin-local") @app.on_event("startup") def startup(): init_db() @app.middleware("http") async def require_admin_session(request: Request, call_next): path = request.url.path.rstrip("/") or "/" public_admin_paths = {"/admin/login", "/admin/register", "/admin/logout"} if path.startswith("/admin") and path not in public_admin_paths: user = _get_admin_user_from_request(request) if user is None: return RedirectResponse(url="/admin/login", status_code=303) request.state.admin_user = user return await call_next(request) @app.get("/") def read_root(): return { "name": "ConfiVoice Admin Web", "status": "ok", "admin_page": "/admin", "database": get_database_label(), } @app.get("/admin/login", response_class=HTMLResponse) def read_admin_login_page(request: Request): if _get_admin_user_from_request(request): return RedirectResponse(url="/admin", status_code=303) return _auth_page(mode="login") @app.post("/admin/login") def login_admin( username: str = Form(...), password: str = Form(...), ): user = authenticate_user(username.strip().lower(), password) if user is None: return _auth_page( mode="login", error_message="Username atau password salah.", username=username, ) response = RedirectResponse(url="/admin", status_code=303) response.set_cookie( ADMIN_SESSION_COOKIE, _create_admin_session_token(user), max_age=ADMIN_SESSION_MAX_AGE, httponly=True, samesite="lax", ) return response @app.get("/admin/register", response_class=HTMLResponse) def read_admin_register_page(request: Request): if _get_admin_user_from_request(request): return RedirectResponse(url="/admin", status_code=303) return _auth_page(mode="register") @app.post("/admin/register", response_class=HTMLResponse) def register_admin( full_name: str = Form(...), username: str = Form(...), password: str = Form(...), confirm_password: str = Form(...), ): full_name = " ".join(full_name.strip().split()) username = username.strip().lower() password = password.strip() confirm_password = confirm_password.strip() if not full_name or not username or not password or not confirm_password: return _auth_page( mode="register", error_message="Lengkapi semua data akun terlebih dahulu.", full_name=full_name, username=username, ) if len(username) < 3: return _auth_page( mode="register", error_message="Username minimal 3 karakter.", full_name=full_name, username=username, ) if len(password) < 6: return _auth_page( mode="register", error_message="Password minimal 6 karakter.", full_name=full_name, username=username, ) if password != confirm_password: return _auth_page( mode="register", error_message="Konfirmasi password belum sama.", full_name=full_name, username=username, ) try: create_user(full_name, username, password) except Exception as error: message = str(error).lower() if "duplicate" in message or "unique" in message: error_text = "Username sudah terdaftar." else: error_text = "Registrasi gagal. Coba lagi." return _auth_page( mode="register", error_message=error_text, full_name=full_name, username=username, ) return _auth_page( mode="login", success_message="Registrasi berhasil. Silakan login.", username=username, ) @app.get("/predictions") def read_predictions(limit: int = 500): return { "database": get_database_label(), "data": list_prediction_results(limit=limit), } @app.post("/admin/predictions") def create_prediction_from_admin( student_name: str = Form(...), student_gender: str = Form(""), predicted_label: str = Form("PD"), description: str = Form(""), confidence_percent: float = Form(0), probability_pd_percent: float = Form(0), probability_tpd_percent: float = Form(0), is_valid_audio: str = Form("1"), error_message: str = Form(""), audio_duration: float = Form(0), ): student_name = student_name.strip() if not student_name: raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") save_prediction_result( student_name, _result_payload( predicted_label=predicted_label, student_gender=student_gender, description=description, confidence_percent=confidence_percent, probability_pd_percent=probability_pd_percent, probability_tpd_percent=probability_tpd_percent, is_valid_audio=is_valid_audio, error_message=error_message, audio_duration=audio_duration, ), ) return _redirect_admin() @app.get("/admin/predictions/new", response_class=HTMLResponse) def read_new_prediction_page(): return _html_page( title="Tambah Data", active="prediction-new", content=f"""

Tambah Data

Tambahkan hasil analisis secara manual jika diperlukan.

{_prediction_form(action="/admin/predictions", submit_label="Tambah Data")}
""", ) @app.get("/admin/predictions/{prediction_id}/edit", response_class=HTMLResponse) def read_edit_prediction_page(prediction_id: int): row = get_prediction_result(prediction_id) if row is None: raise HTTPException(status_code=404, detail="Data tidak ditemukan.") return _html_page( title=f"Edit Data #{prediction_id}", active="dashboard", content=f"""

Edit Data #{prediction_id}

Ubah hasil analisis yang tersimpan di admin.

Kembali
{_prediction_form(row=row, action=f"/admin/predictions/{prediction_id}/edit", submit_label="Simpan Perubahan")}
""", ) @app.post("/admin/predictions/{prediction_id}/edit") def update_prediction_from_admin( prediction_id: int, student_name: str = Form(...), student_gender: str = Form(""), predicted_label: str = Form("PD"), description: str = Form(""), confidence_percent: float = Form(0), probability_pd_percent: float = Form(0), probability_tpd_percent: float = Form(0), is_valid_audio: str = Form("1"), error_message: str = Form(""), audio_duration: float = Form(0), ): student_name = student_name.strip() if not student_name: raise HTTPException(status_code=400, detail="Nama siswa/i wajib diisi.") updated = update_prediction_result( prediction_id, _db_values( student_name=student_name, student_gender=student_gender, predicted_label=predicted_label, description=description, confidence_percent=confidence_percent, probability_pd_percent=probability_pd_percent, probability_tpd_percent=probability_tpd_percent, is_valid_audio=is_valid_audio, error_message=error_message, audio_duration=audio_duration, ), ) if not updated: raise HTTPException(status_code=404, detail="Data tidak ditemukan.") return _redirect_admin() @app.post("/admin/predictions/{prediction_id}/delete") def delete_prediction_from_admin(prediction_id: int): delete_prediction_result(prediction_id) return _redirect_admin() @app.get("/admin/users", response_class=HTMLResponse) def read_users_page(): users = list_users(limit=500) return _html_page( title="Tambah User", active="users", content=f"""

Tambah User

Kelola akun guru atau pengguna aplikasi ConfiVoice.

Tambah User

Username akan disimpan huruf kecil, password disimpan sebagai hash.

{_user_form(action="/admin/users", submit_label="Tambah User", password_required=True)}

CRUD User

Edit atau hapus user yang sudah terdaftar.

{_user_table(users)}
""", ) @app.post("/admin/users") def create_user_from_admin( full_name: str = Form(...), username: str = Form(...), password: str = Form(...), ): full_name = full_name.strip() username = username.strip() password = password.strip() if not full_name or not username or not password: raise HTTPException(status_code=400, detail="Nama, username, dan password wajib diisi.") try: create_user(full_name, username, password) except Exception as error: raise HTTPException( status_code=400, detail="Username sudah dipakai atau data user tidak valid.", ) from error return RedirectResponse(url="/admin/users", status_code=303) @app.get("/admin/users/{user_id}/edit", response_class=HTMLResponse) def read_edit_user_page(user_id: int): user = get_user_by_id(user_id) if user is None: raise HTTPException(status_code=404, detail="User tidak ditemukan.") return _html_page( title=f"Edit User #{user_id}", active="users", content=f"""

Edit User #{user_id}

Kosongkan password jika tidak ingin mengganti password.

Kembali
{_user_form(row=user, action=f"/admin/users/{user_id}/edit", submit_label="Simpan User")}
""", ) @app.post("/admin/users/{user_id}/edit") def update_user_from_admin( user_id: int, full_name: str = Form(...), username: str = Form(...), password: str = Form(""), ): full_name = full_name.strip() username = username.strip() if not full_name or not username: raise HTTPException(status_code=400, detail="Nama dan username wajib diisi.") try: updated = update_user(user_id, full_name, username, password) except Exception as error: raise HTTPException( status_code=400, detail="Username sudah dipakai atau data user tidak valid.", ) from error if not updated: raise HTTPException(status_code=404, detail="User tidak ditemukan.") return RedirectResponse(url="/admin/users", status_code=303) @app.post("/admin/users/{user_id}/delete") def delete_user_from_admin(user_id: int): delete_user(user_id) return RedirectResponse(url="/admin/users", status_code=303) @app.get("/admin/logout", response_class=HTMLResponse) def read_logout_page(): response = RedirectResponse(url="/admin/login", status_code=303) response.delete_cookie(ADMIN_SESSION_COOKIE) return response @app.get("/admin/students/{student_name}", response_class=HTMLResponse) def read_student_predictions_page(student_name: str): decoded_name = unquote(student_name) rows = [ row for row in list_prediction_results(limit=500) if row["student_name"] == decoded_name ] total = len(rows) pd_count = sum(1 for row in rows if row["predicted_label"] == "PD") tpd_count = sum(1 for row in rows if row["predicted_label"] == "TPD") avg_pd = _average_pd_ratio(rows) return _html_page( title=f"Analisis {decoded_name}", active="dashboard", content=f"""

{escape(decoded_name)}

Folder hasil analisis siswa/i.

Kembali
Total Prediksi{total}
Percaya Diri{pd_count}
Tidak Percaya Diri{tpd_count}
Rata-rata PD{avg_pd * 100:.1f}%

Isi Folder Analisis

Edit atau hapus hasil prediksi milik siswa/i ini.

{_prediction_table(rows)}
""", ) @app.get("/admin", response_class=HTMLResponse) def read_admin_page( q: str = Query("", alias="q"), label: str = Query("", alias="label"), ): rows = list_prediction_results(limit=500) clean_query = q.strip() clean_label = label.strip().upper() if clean_label not in {"PD", "TPD"}: clean_label = "" filtered_rows = _filter_prediction_rows(rows, clean_query, clean_label) total = len(filtered_rows) pd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "PD") tpd_count = sum(1 for row in filtered_rows if row["predicted_label"] == "TPD") avg_pd = _average_pd_ratio(filtered_rows) grouped_rows = _group_predictions_by_student(filtered_rows) return _html_page( title="Admin ConfiVoice", active="dashboard", content=f"""

Admin ConfiVoice

Dashboard hasil analisis percaya diri siswa/i dari aplikasi Flutter.

Total Data Tampil{total}
Percaya Diri{pd_count}
Tidak Percaya Diri{tpd_count}
Rata-rata Percaya Diri{avg_pd * 100:.1f}%

Folder Siswa/i

Pilih nama siswa/i untuk melihat semua hasil analisisnya.

{_dashboard_filter_form(clean_query, clean_label)} {_student_folder_grid(grouped_rows)}
""", ) def _redirect_admin(): return RedirectResponse(url="/admin", status_code=303) def _create_admin_session_token(user): payload = { "id": user["id"], "username": user["username"], "exp": int(time.time()) + ADMIN_SESSION_MAX_AGE, } payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") encoded_payload = base64.urlsafe_b64encode(payload_bytes).decode("ascii") signature = hmac.new( ADMIN_SESSION_SECRET.encode("utf-8"), encoded_payload.encode("utf-8"), hashlib.sha256, ).hexdigest() return f"{encoded_payload}.{signature}" def _verify_admin_session_token(token): try: encoded_payload, signature = token.split(".", 1) expected_signature = hmac.new( ADMIN_SESSION_SECRET.encode("utf-8"), encoded_payload.encode("utf-8"), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(signature, expected_signature): return None payload = json.loads(base64.urlsafe_b64decode(encoded_payload.encode("ascii"))) if int(payload.get("exp") or 0) < int(time.time()): return None return payload except Exception: return None def _get_admin_user_from_request(request): token = request.cookies.get(ADMIN_SESSION_COOKIE) if not token: return None payload = _verify_admin_session_token(token) if not payload: return None return get_user_by_id(payload.get("id")) def _password_eye_icon(): return ( '" ) def _password_eye_off_icon(): return ( '" ) def _result_payload( *, predicted_label, student_gender, description, confidence_percent, probability_pd_percent, probability_tpd_percent, is_valid_audio, error_message, audio_duration, ): return { "predicted_label": predicted_label, "student_gender": _clean_student_gender(student_gender), "description": description, "confidence": _percent_to_ratio(confidence_percent), "probability_pd": _percent_to_ratio(probability_pd_percent), "probability_tpd": _percent_to_ratio(probability_tpd_percent), "is_valid_audio": is_valid_audio == "1", "error_message": error_message.strip() or None, "audio_quality": {"duration": audio_duration}, "voice_indicators": {}, } def _db_values( *, student_name, student_gender, predicted_label, description, confidence_percent, probability_pd_percent, probability_tpd_percent, is_valid_audio, error_message, audio_duration, ): return ( student_name, _clean_student_gender(student_gender), predicted_label.strip() or None, description.strip() or None, _percent_to_ratio(confidence_percent), _percent_to_ratio(probability_pd_percent), _percent_to_ratio(probability_tpd_percent), 1 if is_valid_audio == "1" else 0, error_message.strip() or None, float(audio_duration or 0), 0, 0, 0, 0, 0, ) def _percent_to_ratio(value): return max(0, min(float(value or 0), 100)) / 100 def _ratio_to_percent(value): return f"{float(value or 0) * 100:.2f}" def _clean_student_gender(value): gender = (value or "").strip() return gender if gender in {"Laki-laki", "Perempuan"} else None def _student_gender_label(value): return value or "Belum diisi" def _pd_probability(row): return float(row.get("probability_pd") or 0) def _average_pd_ratio(rows): return sum(_pd_probability(row) for row in rows) / len(rows) if rows else 0 def _filter_prediction_rows(rows, query, label): query = query.strip().lower() label = label.strip().upper() filtered_rows = rows if query: filtered_rows = [ row for row in filtered_rows if query in (row["student_name"] or "").lower() ] if label in {"PD", "TPD"}: filtered_rows = [ row for row in filtered_rows if (row["predicted_label"] or "").upper() == label ] return filtered_rows def _dashboard_filter_form(query, label): all_selected = "selected" if not label else "" pd_selected = "selected" if label == "PD" else "" tpd_selected = "selected" if label == "TPD" else "" return f"""
Reset
""" def _group_predictions_by_student(rows): groups = {} for row in rows: student_name = row["student_name"] or "Tanpa Nama" groups.setdefault(student_name, []).append(row) return sorted(groups.items(), key=lambda item: item[0].lower()) def _student_folder_grid(grouped_rows): if not grouped_rows: return '
Belum ada folder siswa/i.
' cards = [] for student_name, rows in grouped_rows: total = len(rows) latest = rows[0] gender = _student_gender_label(latest.get("student_gender")) label = latest.get("predicted_label") or "-" pd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "PD") tpd_count = sum(1 for row in rows if (row.get("predicted_label") or "").upper() == "TPD") pd_sum = sum(_pd_probability(row) for row in rows) labels = " ".join( sorted({(row.get("predicted_label") or "").upper() for row in rows}) ) latest_pd = _pd_probability(latest) * 100 pd_average = _average_pd_ratio(rows) * 100 folder_url = f"/admin/students/{quote(student_name, safe='')}" cards.append( f"""
{escape(student_name[:1].upper() or "?")}
{escape(student_name)} {escape(gender)} | {total} hasil analisis Label terakhir: {escape(label)} | PD terakhir {latest_pd:.1f}% | Rata-rata PD {pd_average:.1f}%
""" ) return ( f'
{"".join(cards)}
' '" ) def _prediction_form(row=None, action="/admin/predictions", submit_label="Simpan"): row = row or {} student_name = escape(str(row.get("student_name") or "")) student_gender = str(row.get("student_gender") or "") predicted_label = str(row.get("predicted_label") or "PD") description = escape(str(row.get("description") or "")) confidence = _ratio_to_percent(row.get("confidence")) probability_pd = _ratio_to_percent(row.get("probability_pd")) probability_tpd = _ratio_to_percent(row.get("probability_tpd")) audio_duration = float(row.get("audio_duration") or 0) error_message = escape(str(row.get("error_message") or "")) valid_audio = str(int(row.get("is_valid_audio", 1))) == "1" pd_selected = "selected" if predicted_label == "PD" else "" tpd_selected = "selected" if predicted_label == "TPD" else "" male_selected = "selected" if student_gender == "Laki-laki" else "" female_selected = "selected" if student_gender == "Perempuan" else "" valid_selected = "selected" if valid_audio else "" invalid_selected = "selected" if not valid_audio else "" return f"""
""" def _prediction_table(rows): table_rows = [] for row in rows: valid_text = "Valid" if row["is_valid_audio"] else "Tidak valid" table_rows.append( "" f"{row['id']}" f"{escape(str(row['created_at']))}" f"{escape(row['student_name'])}" f"{escape(_student_gender_label(row.get('student_gender')))}" f"{escape(row['predicted_label'] or '-')}" f"{escape(row['description'] or '-')}" f"{float(row['confidence'] or 0) * 100:.2f}%" f"{float(row['probability_pd'] or 0) * 100:.2f}%" f"{float(row['probability_tpd'] or 0) * 100:.2f}%" f"{float(row['audio_duration'] or 0):.2f} dtk" f"{valid_text}" "" f"Edit" f"
" "" "
" "" "" ) body = "\n".join(table_rows) or ( 'Belum ada hasil prediksi.' ) return f"""
{body}
ID Waktu Nama siswa/i Jenis kelamin Label Keterangan Confidence PD TPD Durasi Status audio Aksi
""" def _user_form(row=None, action="/admin/users", submit_label="Simpan", password_required=False): row = row or {} full_name = escape(str(row.get("full_name") or "")) username = escape(str(row.get("username") or "")) required = "required" if password_required else "" password_hint = "Wajib diisi untuk user baru." if password_required else "Kosongkan jika tidak diganti." return f"""
""" def _user_table(users): table_rows = [] for user in users: table_rows.append( "" f"{user['id']}" f"{escape(str(user['created_at']))}" f"{escape(user['full_name'])}" f"{escape(user['username'])}" "" f"Edit" f"
" "" "
" "" "" ) body = "\n".join(table_rows) or ( 'Belum ada user terdaftar.' ) return f"""
{body}
ID Dibuat Nama lengkap Username Aksi
""" def _nav_active(active, name): return " active" if active == name else "" def _sidebar(active): update_open = " open" if active in {"prediction-new", "users"} else "" return f""" """ def _auth_page( *, mode, error_message=None, success_message=None, full_name="", username="", ): is_register = mode == "register" title = "Daftar" if is_register else "Masuk" action = "/admin/register" if is_register else "/admin/login" login_active = "" if is_register else " active" register_active = " active" if is_register else "" slider_class = " register" if is_register else "" error_html = ( f'
{escape(error_message)}
' if error_message else "" ) success_html = ( f'
{escape(success_message)}
' if success_message else "" ) eye_icon = _password_eye_icon() eye_off_icon = _password_eye_off_icon() full_name_field = ( f""" """ if is_register else "" ) confirm_password_field = ( """ """ if is_register else "" ) submit_label = "Daftar" if is_register else "Masuk" return f""" {title} ConfiVoice
CV Confivoice

{title}

{'Buat akun admin ConfiVoice.' if is_register else 'Masuk ke pane ConfiVoice.'}

{error_html} {success_html}
{full_name_field} {confirm_password_field}
""" def _html_page(title, content, active="dashboard"): return f""" {escape(title)}
{_sidebar(active)}
{content}
"""