from html import escape from pathlib import Path import sys from urllib.parse import quote, unquote from fastapi import FastAPI, Form, HTTPException, Query 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 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") @app.on_event("startup") def startup(): init_db() @app.get("/") def read_root(): return { "name": "ConfiVoice Admin Web", "status": "ok", "admin_page": "/admin", "database": get_database_label(), } @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(): return _html_page( title="Logout", active="logout", content="""

Logout

Sesi admin ditutup. Saat ini web admin belum memakai session login khusus, jadi halaman ini menjadi pintu keluar visual.

Kembali ke Dashboard
""", ) @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 _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 _html_page(title, content, active="dashboard"): return f""" {escape(title)}
{_sidebar(active)}
{content}
"""