""" Router untuk Dashboard API - PRD Section 22 """ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from sqlalchemy import func, or_ from typing import Optional, List, Dict, Any from app.config.database import get_db from app.models.alumni import Alumni from app.models.models import Pekerjaan, Kompetensi, ClusteringResult, ModelEvaluation, TracerResponse router = APIRouter(tags=["Dashboard"]) @router.get("/stats") def get_dashboard_stats( kode_prodi: Optional[str] = Query(None), tahun_lulus: Optional[int] = Query(None), db: Session = Depends(get_db) ): """ Dashboard Utama - PRD Section 22 Menampilkan: total alumni, total bekerja, total belum bekerja, distribusi C0-C3 Mendukung filter prodi (khusus role Kaprodi atau Admin) dan filter angkatan/tahun_lulus """ prodi_filter = kode_prodi if (kode_prodi and kode_prodi != "all") else None # Total alumni q_alumni = db.query(Alumni) if prodi_filter: q_alumni = q_alumni.filter(Alumni.kode_prodi == prodi_filter) if tahun_lulus: q_alumni = q_alumni.filter(Alumni.tahun_lulus == int(tahun_lulus)) total_alumni = q_alumni.count() # Total hasil clustering q_cluster = db.query(ClusteringResult).join(Alumni, Alumni.id == ClusteringResult.alumni_id) if prodi_filter: q_cluster = q_cluster.filter(Alumni.kode_prodi == prodi_filter) if tahun_lulus: q_cluster = q_cluster.filter(Alumni.tahun_lulus == int(tahun_lulus)) total_clustered = q_cluster.count() # Distribusi kategori C0-C3 q_kategori = db.query( ClusteringResult.kategori, func.count(ClusteringResult.id).label('count') ).join(Alumni, Alumni.id == ClusteringResult.alumni_id) if prodi_filter: q_kategori = q_kategori.filter(Alumni.kode_prodi == prodi_filter) if tahun_lulus: q_kategori = q_kategori.filter(Alumni.tahun_lulus == int(tahun_lulus)) kategori_dist = q_kategori.group_by(ClusteringResult.kategori).all() kategori_dict = {k: c for k, c in kategori_dist} # Ambil model evaluation terbaru latest_eval = db.query(ModelEvaluation).order_by( ModelEvaluation.created_at.desc() ).first() # Get unique tahun lulus q_tahun = db.query(Alumni.tahun_lulus) if prodi_filter: q_tahun = q_tahun.filter(Alumni.kode_prodi == prodi_filter) tahun_list = q_tahun.distinct().order_by(Alumni.tahun_lulus.desc()).all() tahun_lulus = [t[0] for t in tahun_list if t[0]] return { "total_alumni": total_alumni, "total_clustered": total_clustered, "kategori": { "C0": kategori_dict.get("C0", 0), "C1": kategori_dict.get("C1", 0), "C2": kategori_dict.get("C2", 0), "C3": kategori_dict.get("C3", 0), }, "silhouette_score": latest_eval.silhouette_score if latest_eval else None, "model_version": latest_eval.model_version if latest_eval else None, "tahun_lulus": tahun_lulus # Include tahun for dropdown } @router.get("/by-prodi") def get_stats_by_prodi( db: Session = Depends(get_db) ): """Distribusi alumni dan kategori per prodi""" # Get alumni count by prodi prodi_stats = db.query( Alumni.kode_prodi, func.count(Alumni.id).label('total') ).group_by(Alumni.kode_prodi).all() # Get clustering results by prodi and kategori results = db.query( Alumni.kode_prodi, ClusteringResult.kategori, func.count(ClusteringResult.id).label('count') ).join( ClusteringResult, Alumni.id == ClusteringResult.alumni_id ).group_by( Alumni.kode_prodi, ClusteringResult.kategori ).all() # Format results prodi_data = {} for prodi, total in prodi_stats: prodi_data[prodi] = {"total": total, "C0": 0, "C1": 0, "C2": 0, "C3": 0} for prodi, kategori, count in results: if prodi in prodi_data: prodi_data[prodi][kategori] = count return prodi_data @router.get("/by-year") def get_stats_by_year( kode_prodi: Optional[str] = Query(None), db: Session = Depends(get_db) ): """Distribusi alumni dan tren kategori C0-C3 per tahun kelulusan secara kronologis (Ascending), mendukung filter prodi""" prodi_filter = kode_prodi if (kode_prodi and kode_prodi != "all") else None # 1. Total alumni per tahun lulus urut dari tahun terlama ke terbaru q_year = db.query( Alumni.tahun_lulus, func.count(Alumni.id).label('total') ).filter(Alumni.tahun_lulus.isnot(None)) if prodi_filter: q_year = q_year.filter(Alumni.kode_prodi == prodi_filter) year_totals = q_year.group_by(Alumni.tahun_lulus).order_by(Alumni.tahun_lulus.asc()).all() # 2. Distribusi per kategori C0, C1, C2, C3 per tahun q_cat = db.query( Alumni.tahun_lulus, ClusteringResult.kategori, func.count(ClusteringResult.id).label('count') ).join(ClusteringResult, Alumni.id == ClusteringResult.alumni_id)\ .filter(Alumni.tahun_lulus.isnot(None)) if prodi_filter: q_cat = q_cat.filter(Alumni.kode_prodi == prodi_filter) cat_results = q_cat.group_by(Alumni.tahun_lulus, ClusteringResult.kategori)\ .order_by(Alumni.tahun_lulus.asc()).all() # Susun struktur data kronologis years_list = [str(y[0]) for y in year_totals if y[0]] breakdown_map = {str(y[0]): {"total": y[1], "C0": 0, "C1": 0, "C2": 0, "C3": 0} for y in year_totals if y[0]} for thn, kat, cnt in cat_results: thn_str = str(thn) if thn_str in breakdown_map and kat in breakdown_map[thn_str]: breakdown_map[thn_str][kat] = cnt # Format series untuk area / line chart c0_series = [breakdown_map[y]["C0"] for y in years_list] c1_series = [breakdown_map[y]["C1"] for y in years_list] c2_series = [breakdown_map[y]["C2"] for y in years_list] c3_series = [breakdown_map[y]["C3"] for y in years_list] return { "years": years_list, "breakdown": breakdown_map, "series": [ {"name": "C0 - Sesuai Profil", "data": c0_series, "color": "#059669"}, {"name": "C1 - Bidang Diketahui", "data": c1_series, "color": "#2563eb"}, {"name": "C2 - Kurang Teridentifikasi", "data": c2_series, "color": "#d97706"}, {"name": "C3 - Belum Bekerja", "data": c3_series, "color": "#64748b"}, ], "totals_by_year": {str(year): total for year, total in year_totals} } @router.get("/by-cluster") def get_stats_by_cluster( db: Session = Depends(get_db) ): """Distribusi alumni per cluster""" cluster_stats = db.query( ClusteringResult.cluster, ClusteringResult.kategori, func.count(ClusteringResult.id).label('count') ).group_by( ClusteringResult.cluster, ClusteringResult.kategori ).all() # Format results cluster_data = {} for cluster, kategori, count in cluster_stats: key = f"Cluster_{cluster}" if key not in cluster_data: cluster_data[key] = {"total": 0, "kategori": {}} cluster_data[key]["total"] += count cluster_data[key]["kategori"][kategori] = count return cluster_data @router.get("/competency-profile") def get_competency_profile( db: Session = Depends(get_db) ): """Profil kompetensi rata-rata per kategori""" # Get average competency scores by kategori results = db.query( ClusteringResult.kategori, func.avg(Kompetensi.etika_lulus).label('etika'), func.avg(Kompetensi.keahlian_lulus).label('keahlian'), func.avg(Kompetensi.bahasa_inggris_lulus).label('bahasa_inggris'), func.avg(Kompetensi.teknologi_informasi_lulus).label('teknologi_informasi'), func.avg(Kompetensi.komunikasi_lulus).label('komunikasi'), func.avg(Kompetensi.kerja_sama_lulus).label('kerja_sama'), func.avg(Kompetensi.pengembangan_diri_lulus).label('pengembangan_diri'), func.count(Kompetensi.id).label('count') ).join( Kompetensi, Alumni.id == Kompetensi.alumni_id ).join( ClusteringResult, Alumni.id == ClusteringResult.alumni_id ).group_by( ClusteringResult.kategori ).all() profile = {} for r in results: profile[r.kategori] = { "etika": round(float(r.etika), 2) if r.etika else None, "keahlian": round(float(r.keahlian), 2) if r.keahlian else None, "bahasa_inggris": round(float(r.bahasa_inggris), 2) if r.bahasa_inggris else None, "teknologi_informasi": round(float(r.teknologi_informasi), 2) if r.teknologi_informasi else None, "komunikasi": round(float(r.komunikasi), 2) if r.komunikasi else None, "kerja_sama": round(float(r.kerja_sama), 2) if r.kerja_sama else None, "pengembangan_diri": round(float(r.pengembangan_diri), 2) if r.pengembangan_diri else None, "count": r.count } return profile @router.get("/company-types") def get_top_company_types( limit: int = Query(10, ge=1, le=50), db: Session = Depends(get_db) ): """Top jenis perusahaan berdasarkan hasil clustering""" results = db.query( Pekerjaan.jenis_perusahaan, func.count(Pekerjaan.id).label('count') ).join( Alumni, Pekerjaan.alumni_id == Alumni.id ).join( ClusteringResult, Alumni.id == ClusteringResult.alumni_id ).group_by( Pekerjaan.jenis_perusahaan ).order_by( func.count(Pekerjaan.id).desc() ).limit(limit).all() return { "jenis_perusahaan": [ {"jenis": j, "count": c} for j, c in results ] } @router.get("/top-keywords") def get_top_matched_keywords( limit: int = Query(10, ge=1, le=50), db: Session = Depends(get_db) ): """Top keywords yang match dengan kamus prodi""" # Get all matched keywords results = db.query(ClusteringResult.matched_keyword).filter( ClusteringResult.matched_keyword.isnot(None), ClusteringResult.matched_keyword != "" ).all() # Count keyword frequency keyword_count = {} for r in results: if r.matched_keyword: # Split by comma for multiple keywords keywords = [k.strip() for k in r.matched_keyword.split(",")] for kw in keywords: keyword_count[kw] = keyword_count.get(kw, 0) + 1 # Sort and limit sorted_keywords = sorted(keyword_count.items(), key=lambda x: x[1], reverse=True)[:limit] return { "keywords": [ {"keyword": kw, "count": c} for kw, c in sorted_keywords ] } @router.get("/akreditasi-tables") def get_akreditasi_tables( ts_year: Optional[int] = Query(None, description="Tahun Laporan TS (misal 2024 atau 2025)"), kode_prodi: Optional[str] = Query(None, description="Kode program studi atau 'all'"), mode: Optional[str] = Query("ts_range", description="'ts_range' (TS-2 s/d TS) atau 'all_years'"), db: Session = Depends(get_db) ): """ Tabel Akreditasi LAM INFOKOM (Kriteria 9 / IKU-1): - Tabel 2.B.4: Rata-rata Masa Tunggu Lulusan untuk Bekerja Pertama Kali - Tabel 2.B.5: Kesesuaian Bidang Kerja Lulusan (Infokom vs Non-Infokom & Lingkup Kerja) Mendukung filter TS-2, TS-1, TS dan pemisahan per Program Studi (TIF, MIF, TKK). """ # 1. Dapatkan daftar tahun kelulusan yang valid (< 2026) year_rows = db.query(Alumni.tahun_lulus)\ .filter(Alumni.tahun_lulus.isnot(None), Alumni.tahun_lulus < 2026)\ .distinct().order_by(Alumni.tahun_lulus.desc()).all() available_years = [y[0] for y in year_rows if y[0]] # Tentukan TS Tahun (default 2025 jika tidak disediakan, atau tahun terbaru yang valid) if not ts_year: ts_year = 2025 if 2025 in available_years else (available_years[0] if available_years else 2025) # Tentukan deret tahun yang akan dihitung if mode == "all_years": target_years = sorted(available_years) else: # Standar LAM INFOKOM: 3 Tahun Lulus Terakhir (TS-2, TS-1, TS) target_years = [ts_year - 2, ts_year - 1, ts_year] # Kamus Nama Prodi Resmi JTI Polije PRODI_INFO = { "all": "Semua Program Studi (Jurusan TI)", "55301": "D4 Teknik Informatika (TIF)", "57401": "D3 Manajemen Informatika (MIF)", "56401": "D3 Teknik Komputer (TKK)", "55302": "D4 Teknik Rekayasa Komputer", "55304": "D3 Manajemen Informatika (Kampus Lain)" } prodi_filter = kode_prodi if (kode_prodi and kode_prodi != "all") else None prodi_label = PRODI_INFO.get(kode_prodi, "Semua Program Studi (Jurusan TI)") if kode_prodi else "Semua Program Studi (Jurusan TI)" rows_2b4 = [] rows_2b5 = [] sum_lulusan = 0 sum_terlacak = 0 weighted_waktu_sum = 0.0 weighted_waktu_count = 0 sum_infokom = 0 sum_non_infokom = 0 sum_multinasional = 0 sum_nasional = 0 sum_wirausaha = 0 for year in target_years: # Tentukan label TS (misal TS-2, TS-1, TS) diff = ts_year - year if diff == 0: ts_label = "TS" elif diff > 0: ts_label = f"TS-{diff}" else: ts_label = f"TS+{abs(diff)}" display_label = f"{ts_label} ({year})" if mode == "ts_range" else f"{year} ({ts_label})" # A. Jumlah Lulusan q_lulus = db.query(Alumni).filter(Alumni.tahun_lulus == year) if prodi_filter: q_lulus = q_lulus.filter(Alumni.kode_prodi == prodi_filter) n_lulus = q_lulus.count() # B. Jumlah Lulusan yang Terlacak (isi TracerResponse atau memiliki riwayat kerja) q_terlacak = db.query(Alumni).join(TracerResponse, Alumni.id == TracerResponse.alumni_id)\ .filter(Alumni.tahun_lulus == year) if prodi_filter: q_terlacak = q_terlacak.filter(Alumni.kode_prodi == prodi_filter) n_terlacak = q_terlacak.count() # C. Rata-rata Waktu Tunggu (Bulan) — dihitung dari alumni yang bekerja dengan bulan_dapat_kerja > 0 q_waktu = db.query( func.avg(TracerResponse.bulan_dapat_kerja), func.count(TracerResponse.id) ).join(Alumni, Alumni.id == TracerResponse.alumni_id)\ .filter( Alumni.tahun_lulus == year, TracerResponse.bulan_dapat_kerja.isnot(None), TracerResponse.bulan_dapat_kerja > 0 ) if prodi_filter: q_waktu = q_waktu.filter(Alumni.kode_prodi == prodi_filter) avg_waktu_res, count_waktu = q_waktu.first() avg_waktu = round(float(avg_waktu_res), 2) if avg_waktu_res is not None else 0.0 # D. Profesi Bidang Infokom (C0) q_c0 = db.query(ClusteringResult).join(Alumni, Alumni.id == ClusteringResult.alumni_id)\ .filter(Alumni.tahun_lulus == year, ClusteringResult.kategori == "C0") if prodi_filter: q_c0 = q_c0.filter(Alumni.kode_prodi == prodi_filter) n_infokom = q_c0.count() # E. Profesi Bidang NON Infokom (C1 + C2) q_non_c0 = db.query(ClusteringResult).join(Alumni, Alumni.id == ClusteringResult.alumni_id)\ .filter(Alumni.tahun_lulus == year, ClusteringResult.kategori.in_(["C1", "C2"])) if prodi_filter: q_non_c0 = q_non_c0.filter(Alumni.kode_prodi == prodi_filter) n_non_infokom = q_non_c0.count() # F. Lingkup Tempat Kerja: Multinasional / Internasional q_multi = db.query(TracerResponse).join(Alumni, Alumni.id == TracerResponse.alumni_id)\ .filter( Alumni.tahun_lulus == year, or_( TracerResponse.tingkatan_tempat_kerja.ilike("%multinasional%"), TracerResponse.tingkatan_tempat_kerja.ilike("%internasional%"), TracerResponse.jenis_perusahaan_full.ilike("%multilateral%") ) ) if prodi_filter: q_multi = q_multi.filter(Alumni.kode_prodi == prodi_filter) n_multi = q_multi.count() # G. Lingkup Tempat Kerja: Nasional (BUMN, Instansi Pusat, Swasta Nasional) q_nas = db.query(TracerResponse).join(Alumni, Alumni.id == TracerResponse.alumni_id)\ .filter( Alumni.tahun_lulus == year, TracerResponse.tingkatan_tempat_kerja.ilike("%nasional%") ) if prodi_filter: q_nas = q_nas.filter(Alumni.kode_prodi == prodi_filter) n_nas = q_nas.count() # H. Lingkup Tempat Kerja: Wirausaha q_wira = db.query(Pekerjaan).join(Alumni, Alumni.id == Pekerjaan.alumni_id)\ .filter( Alumni.tahun_lulus == year, or_( Pekerjaan.status_pekerjaan.ilike("%wiraswasta%"), Pekerjaan.jenis_perusahaan.ilike("%wiraswasta%") ) ) if prodi_filter: q_wira = q_wira.filter(Alumni.kode_prodi == prodi_filter) n_wira = q_wira.count() # Masukkan ke baris tabel 2.B.4 rows_2b4.append({ "ts_code": ts_label, "tahun_lulus": year, "tahun_label": display_label, "jumlah_lulusan": n_lulus, "jumlah_terlacak": n_terlacak, "persen_terlacak": round((n_terlacak / n_lulus * 100), 1) if n_lulus > 0 else 0, "rata_waktu_tunggu": avg_waktu }) # Masukkan ke baris tabel 2.B.5 rows_2b5.append({ "ts_code": ts_label, "tahun_lulus": year, "tahun_label": display_label, "jumlah_lulusan": n_lulus, "jumlah_terlacak": n_terlacak, "profesi_infokom": n_infokom, "profesi_non_infokom": n_non_infokom, "persen_infokom": round((n_infokom / n_terlacak * 100), 1) if n_terlacak > 0 else 0, "lingkup_multinasional": n_multi, "lingkup_nasional": n_nas, "lingkup_wirausaha": n_wira }) sum_lulusan += n_lulus sum_terlacak += n_terlacak if avg_waktu > 0 and count_waktu: weighted_waktu_sum += (avg_waktu * count_waktu) weighted_waktu_count += count_waktu sum_infokom += n_infokom sum_non_infokom += n_non_infokom sum_multinasional += n_multi sum_nasional += n_nas sum_wirausaha += n_wira overall_avg_waktu = round(weighted_waktu_sum / weighted_waktu_count, 2) if weighted_waktu_count > 0 else 0.0 # Summary Totals total_2b4 = { "ts_code": "Jumlah", "tahun_label": "Jumlah / Rata-rata", "jumlah_lulusan": sum_lulusan, "jumlah_terlacak": sum_terlacak, "persen_terlacak": round((sum_terlacak / sum_lulusan * 100), 1) if sum_lulusan > 0 else 0, "rata_waktu_tunggu": overall_avg_waktu } total_2b5 = { "ts_code": "Jumlah", "tahun_label": "Jumlah", "jumlah_lulusan": sum_lulusan, "jumlah_terlacak": sum_terlacak, "profesi_infokom": sum_infokom, "profesi_non_infokom": sum_non_infokom, "persen_infokom": round((sum_infokom / sum_terlacak * 100), 1) if sum_terlacak > 0 else 0, "lingkup_multinasional": sum_multinasional, "lingkup_nasional": sum_nasional, "lingkup_wirausaha": sum_wirausaha } available_prodis = [ {"kode": "all", "nama": "Semua Program Studi (Jurusan TI)"}, {"kode": "55301", "nama": "D4 Teknik Informatika (TIF)"}, {"kode": "57401", "nama": "D3 Manajemen Informatika (MIF)"}, {"kode": "56401", "nama": "D3 Teknik Komputer (TKK)"} ] return { "status": "success", "meta": { "ts_year": ts_year, "kode_prodi": kode_prodi or "all", "prodi_label": prodi_label, "mode": mode, "standard": "Instrumen Akreditasi Program Studi (IAPS) LAM INFOKOM Kriteria 9" }, "available_years": available_years, "available_prodis": available_prodis, "tabel_2b4": { "title": "Tabel 2.B.4 Rata-rata Masa Tunggu Lulusan untuk Bekerja Pertama Kali", "rows": rows_2b4, "total": total_2b4 }, "tabel_2b5": { "title": "Tabel 2.B.5. Kesesuaian Bidang Kerja Lulusan", "rows": rows_2b5, "total": total_2b5 } }