Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, Query
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import os
|
| 5 |
+
from typing import Dict, Any
|
| 6 |
+
|
| 7 |
+
app = FastAPI(title="Dashboard Akademik API")
|
| 8 |
+
|
| 9 |
+
# Path ke CSV: bisa override lewat env var CSV_PATH
|
| 10 |
+
CSV_PATH = os.getenv("CSV_PATH", "generated_dummy_data.csv")
|
| 11 |
+
|
| 12 |
+
MAP_NILAI = {
|
| 13 |
+
"A": 4.0,
|
| 14 |
+
"AB": 3.5,
|
| 15 |
+
"B": 3.0,
|
| 16 |
+
"BC": 2.5,
|
| 17 |
+
"C": 2.0,
|
| 18 |
+
"D": 1.0,
|
| 19 |
+
"E": 0.0
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
def _ensure_columns(df: pd.DataFrame):
|
| 23 |
+
required = {
|
| 24 |
+
"kode_mhs", "nama_prodi", "id_smt", "kode_mk", "nama_mk",
|
| 25 |
+
"RMK", "sks", "nilai_akhir", "nilai_huruf", "Tahun angkatan",
|
| 26 |
+
"Semester_sekarang", "Deskripsi Matkul"
|
| 27 |
+
}
|
| 28 |
+
missing = required - set(df.columns)
|
| 29 |
+
if missing:
|
| 30 |
+
raise ValueError(f"CSV missing required columns: {missing}")
|
| 31 |
+
|
| 32 |
+
@lru_cache(maxsize=1)
|
| 33 |
+
def load_data_cached() -> pd.DataFrame:
|
| 34 |
+
"""
|
| 35 |
+
Load CSV into DataFrame and cache it for reuse.
|
| 36 |
+
Jika ingin reload, panggil /reload_data endpoint (opsional).
|
| 37 |
+
"""
|
| 38 |
+
if not os.path.exists(CSV_PATH):
|
| 39 |
+
raise FileNotFoundError(f"CSV not found at path: {CSV_PATH}")
|
| 40 |
+
df = pd.read_csv(CSV_PATH)
|
| 41 |
+
# Normalisasi kolom whitespace
|
| 42 |
+
df.columns = [c.strip() for c in df.columns]
|
| 43 |
+
_ensure_columns(df)
|
| 44 |
+
# Pastikan tipe
|
| 45 |
+
df["sks"] = pd.to_numeric(df["sks"], errors="coerce").fillna(0).astype(int)
|
| 46 |
+
df["id_smt"] = pd.to_numeric(df["id_smt"], errors="coerce").fillna(0).astype(int)
|
| 47 |
+
# map nilai huruf -> numerik, jika kosong set NaN -> 0.0
|
| 48 |
+
df["nilai_huruf"] = df["nilai_huruf"].astype(str).str.strip()
|
| 49 |
+
df["nilai_numerik"] = df["nilai_huruf"].map(MAP_NILAI)
|
| 50 |
+
# bila nilai_huruf tidak ada / invalid -> gunakan nilai_akhir jika ada (0-100 scale)
|
| 51 |
+
def fallback_numeric(row):
|
| 52 |
+
if pd.notna(row["nilai_numerik"]):
|
| 53 |
+
return row["nilai_numerik"]
|
| 54 |
+
try:
|
| 55 |
+
v = float(row.get("nilai_akhir", None))
|
| 56 |
+
# convert angka 0-100 ke skala 0-4 berdasarkan mapping ranges
|
| 57 |
+
if v >= 86:
|
| 58 |
+
return 4.0
|
| 59 |
+
if v >= 76:
|
| 60 |
+
return 3.5
|
| 61 |
+
if v >= 66:
|
| 62 |
+
return 3.0
|
| 63 |
+
if v >= 61:
|
| 64 |
+
return 2.5
|
| 65 |
+
if v >= 56:
|
| 66 |
+
return 2.0
|
| 67 |
+
if v >= 41:
|
| 68 |
+
return 1.0
|
| 69 |
+
return 0.0
|
| 70 |
+
except Exception:
|
| 71 |
+
return 0.0
|
| 72 |
+
df["nilai_numerik"] = df.apply(fallback_numeric, axis=1)
|
| 73 |
+
return df
|
| 74 |
+
|
| 75 |
+
def get_final_records(df: pd.DataFrame) -> pd.DataFrame:
|
| 76 |
+
"""
|
| 77 |
+
Ambil record terakhir tiap (kode_mhs, kode_mk) berdasarkan id_smt.
|
| 78 |
+
Ini memenuhi aturan: nilai terakhir berlaku, SKS dihitung sekali.
|
| 79 |
+
"""
|
| 80 |
+
# sort by id_smt lalu ambil tail(1) per group
|
| 81 |
+
df_sorted = df.sort_values(["kode_mhs", "kode_mk", "id_smt"])
|
| 82 |
+
final = df_sorted.groupby(["kode_mhs", "kode_mk"], as_index=False).last()
|
| 83 |
+
return final
|
| 84 |
+
|
| 85 |
+
@app.get("/jumlah_mahasiswa")
|
| 86 |
+
def jumlah_mahasiswa(reload: bool = Query(False, description="set true to reload CSV from disk")):
|
| 87 |
+
"""
|
| 88 |
+
Mengembalikan total mahasiswa unik (kode_mhs).
|
| 89 |
+
Tambah ?reload=true untuk load ulang CSV.
|
| 90 |
+
"""
|
| 91 |
+
if reload:
|
| 92 |
+
load_data_cached.cache_clear()
|
| 93 |
+
try:
|
| 94 |
+
df = load_data_cached()
|
| 95 |
+
except Exception as e:
|
| 96 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 97 |
+
total = int(df["kode_mhs"].nunique())
|
| 98 |
+
return {"total_mahasiswa": total}
|
| 99 |
+
|
| 100 |
+
@app.get("/jumlah_per_angkatan")
|
| 101 |
+
def jumlah_per_angkatan(reload: bool = Query(False, description="set true to reload CSV from disk")):
|
| 102 |
+
if reload:
|
| 103 |
+
load_data_cached.cache_clear()
|
| 104 |
+
try:
|
| 105 |
+
df = load_data_cached()
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 108 |
+
per_ang = df.groupby("Tahun angkatan")["kode_mhs"].nunique().sort_index().to_dict()
|
| 109 |
+
# convert keys to str (JSON-friendly)
|
| 110 |
+
per_ang = {str(k): int(v) for k, v in per_ang.items()}
|
| 111 |
+
return {"mahasiswa_per_angkatan": per_ang}
|
| 112 |
+
|
| 113 |
+
@app.get("/eligible_ta")
|
| 114 |
+
def eligible_ta(reload: bool = Query(False, description="set true to reload CSV from disk"),
|
| 115 |
+
min_sks: int = Query(110, description="threshold SKS untuk eligible (default:110)")):
|
| 116 |
+
"""
|
| 117 |
+
Mengembalikan daftar mahasiswa yang total SKS (menggunakan nilai terakhir tiap matkul) > min_sks.
|
| 118 |
+
"""
|
| 119 |
+
if reload:
|
| 120 |
+
load_data_cached.cache_clear()
|
| 121 |
+
try:
|
| 122 |
+
df = load_data_cached()
|
| 123 |
+
except Exception as e:
|
| 124 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 125 |
+
final = get_final_records(df)
|
| 126 |
+
sks_per_mhs = final.groupby("kode_mhs")["sks"].sum()
|
| 127 |
+
eligible_series = sks_per_mhs[sks_per_mhs > min_sks]
|
| 128 |
+
eligible_list = eligible_series.sort_values(ascending=False).index.to_list()
|
| 129 |
+
# optional: juga sertakan total SKS per mahasiswa pada hasil
|
| 130 |
+
eligible_info = [{"kode_mhs": m, "total_sks": int(sks_per_mhs.loc[m])} for m in eligible_list]
|
| 131 |
+
return {"jumlah_eligible": len(eligible_list), "daftar": eligible_info}
|
| 132 |
+
|
| 133 |
+
@app.get("/ipk_rata_rata")
|
| 134 |
+
def ipk_rata_rata(reload: bool = Query(False, description="set true to reload CSV from disk")):
|
| 135 |
+
"""
|
| 136 |
+
Menghitung IPK rata-rata seluruh mahasiswa:
|
| 137 |
+
- IPS per semester = (Σ sks * nilai_numerik) / Σ sks (menggunakan nilai terakhir utk matkul yang diulang)
|
| 138 |
+
- IPK mahasiswa = rata-rata IPS mahasiswa
|
| 139 |
+
- IPK rata-rata = rata-rata IPK semua mahasiswa
|
| 140 |
+
"""
|
| 141 |
+
if reload:
|
| 142 |
+
load_data_cached.cache_clear()
|
| 143 |
+
try:
|
| 144 |
+
df = load_data_cached()
|
| 145 |
+
except Exception as e:
|
| 146 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 147 |
+
final = get_final_records(df)
|
| 148 |
+
# bobot per record
|
| 149 |
+
final["total_bobot"] = final["sks"] * final["nilai_numerik"]
|
| 150 |
+
# IPS per mahasiswa per semester
|
| 151 |
+
grp = final.groupby(["kode_mhs", "id_smt"]).agg(
|
| 152 |
+
total_bobot=pd.NamedAgg(column="total_bobot", aggfunc="sum"),
|
| 153 |
+
total_sks=pd.NamedAgg(column="sks", aggfunc="sum")
|
| 154 |
+
).reset_index()
|
| 155 |
+
# Hindari pembagian 0
|
| 156 |
+
grp["ips"] = grp.apply(lambda r: (r["total_bobot"] / r["total_sks"]) if r["total_sks"] > 0 else 0.0, axis=1)
|
| 157 |
+
# IPK = rata-rata ips tiap mahasiswa
|
| 158 |
+
ipk_series = grp.groupby("kode_mhs")["ips"].mean()
|
| 159 |
+
mean_ipk = float(round(ipk_series.mean(), 3)) if not ipk_series.empty else 0.0
|
| 160 |
+
# optional: distribusi ipk (quartiles)
|
| 161 |
+
q = ipk_series.quantile([0.25, 0.5, 0.75]).to_dict() if not ipk_series.empty else {}
|
| 162 |
+
q = {str(k): float(v) for k, v in q.items()}
|
| 163 |
+
return {"ipk_rata_rata": mean_ipk, "ipk_quartiles": q}
|
| 164 |
+
|
| 165 |
+
@app.get("/dashboard_summary")
|
| 166 |
+
def dashboard_summary(reload: bool = Query(False, description="set true to reload CSV from disk")):
|
| 167 |
+
if reload:
|
| 168 |
+
load_data_cached.cache_clear()
|
| 169 |
+
try:
|
| 170 |
+
df = load_data_cached()
|
| 171 |
+
except Exception as e:
|
| 172 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 173 |
+
|
| 174 |
+
final = get_final_records(df)
|
| 175 |
+
total_mhs = int(df["kode_mhs"].nunique())
|
| 176 |
+
per_ang = df.groupby("Tahun angkatan")["kode_mhs"].nunique().sort_index().to_dict()
|
| 177 |
+
per_ang = {str(k): int(v) for k, v in per_ang.items()}
|
| 178 |
+
|
| 179 |
+
sks_per_mhs = final.groupby("kode_mhs")["sks"].sum()
|
| 180 |
+
eligible_series = sks_per_mhs[sks_per_mhs > 110]
|
| 181 |
+
eligible_list = [{"kode_mhs": m, "total_sks": int(sks_per_mhs.loc[m])} for m in eligible_series.sort_values(ascending=False).index]
|
| 182 |
+
|
| 183 |
+
# IPK
|
| 184 |
+
final["total_bobot"] = final["sks"] * final["nilai_numerik"]
|
| 185 |
+
grp = final.groupby(["kode_mhs", "id_smt"]).agg(
|
| 186 |
+
total_bobot=pd.NamedAgg(column="total_bobot", aggfunc="sum"),
|
| 187 |
+
total_sks=pd.NamedAgg(column="sks", aggfunc="sum")
|
| 188 |
+
).reset_index()
|
| 189 |
+
grp["ips"] = grp.apply(lambda r: (r["total_bobot"] / r["total_sks"]) if r["total_sks"] > 0 else 0.0, axis=1)
|
| 190 |
+
ipk_series = grp.groupby("kode_mhs")["ips"].mean()
|
| 191 |
+
mean_ipk = float(round(ipk_series.mean(), 3)) if not ipk_series.empty else 0.0
|
| 192 |
+
|
| 193 |
+
summary: Dict[str, Any] = {
|
| 194 |
+
"total_mahasiswa": total_mhs,
|
| 195 |
+
"mahasiswa_per_angkatan": per_ang,
|
| 196 |
+
"eligible_ta": {
|
| 197 |
+
"jumlah": int(eligible_series.shape[0]),
|
| 198 |
+
"daftar": eligible_list
|
| 199 |
+
},
|
| 200 |
+
"ipk": {
|
| 201 |
+
"rata_rata_ipk": mean_ipk
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
return summary
|
| 205 |
+
|
| 206 |
+
@app.post("/reload_data")
|
| 207 |
+
def reload_data():
|
| 208 |
+
"""
|
| 209 |
+
Endpoint untuk menghapus cache dan reload CSV.
|
| 210 |
+
(tidak perlu query param, panggil endpoint ini setelah CSV diganti)
|
| 211 |
+
"""
|
| 212 |
+
load_data_cached.cache_clear()
|
| 213 |
+
try:
|
| 214 |
+
# force load to check file validity
|
| 215 |
+
_ = load_data_cached()
|
| 216 |
+
except Exception as e:
|
| 217 |
+
raise HTTPException(status_code=500, detail=f"Reload failed: {e}")
|
| 218 |
+
return {"status": "reloaded"}
|