TIF_E41253310/Backend/app/routers/questions.py

498 lines
18 KiB
Python

"""
Router untuk Pertanyaan dan Pilihan Jawaban Tracer Study (CRUD Management)
PRD Reference: Pertanyaan-dan-jawaban.md
"""
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from pydantic import BaseModel, ConfigDict
from typing import List, Optional
from app.config.database import get_db
from app.models.models import Question, AnswerOption
from app.middleware.auth_middleware import get_current_active_user
router = APIRouter(prefix="/questions", tags=["Questions & Answer Options"])
# ========== SCHEMAS ==========
class AnswerOptionBase(BaseModel):
kode: str
teks: str
urutan: Optional[int] = 0
is_active: Optional[bool] = True
class AnswerOptionCreate(AnswerOptionBase):
pass
class AnswerOptionUpdate(BaseModel):
kode: Optional[str] = None
teks: Optional[str] = None
urutan: Optional[int] = None
is_active: Optional[bool] = None
class AnswerOptionResponse(AnswerOptionBase):
id: int
question_id: int
model_config = ConfigDict(from_attributes=True)
class QuestionBase(BaseModel):
kode: str
pertanyaan: str
tipe: str = "pilihan" # dropdown, pilihan, skala, teks, date, ya_tidak
section: str = "kuesioner_utama"
nomor_form: Optional[int] = 0
tahun: Optional[str] = "all"
is_active: Optional[bool] = True
class QuestionCreate(QuestionBase):
options: Optional[List[AnswerOptionBase]] = []
class QuestionUpdate(BaseModel):
kode: Optional[str] = None
pertanyaan: Optional[str] = None
tipe: Optional[str] = None
section: Optional[str] = None
nomor_form: Optional[int] = None
tahun: Optional[str] = None
is_active: Optional[bool] = None
class QuestionSchema(BaseModel):
id: int
kode: str
pertanyaan: str
tipe: str
section: str
nomor_form: int
tahun: Optional[str] = "all"
is_active: bool = True
options: List[AnswerOptionResponse] = []
model_config = ConfigDict(from_attributes=True)
class QuestionListResponse(BaseModel):
items: List[QuestionSchema]
total: int
sections: List[str]
class FormSchema(BaseModel):
questions: List[QuestionSchema]
sections: List[str]
# ========== ENDPOINTS ==========
@router.get("/", response_model=QuestionListResponse, summary="Ambil daftar semua pertanyaan dengan filter & search")
def get_all_questions(
search: Optional[str] = Query(None, description="Cari teks pertanyaan atau kode pertanyaan"),
section: Optional[str] = Query(None, description="Filter berdasarkan section"),
is_active: Optional[bool] = Query(None, description="Filter status aktif"),
skip: int = Query(0, ge=0),
limit: int = Query(200, ge=1, le=500),
db: Session = Depends(get_db)
):
"""
Mengambil daftar semua pertanyaan tracer study beserta pilihan jawabannya.
Mendukung pencarian teks/kode dan filter section.
"""
query = db.query(Question)
if search and search.strip():
term = f"%{search.strip()}%"
query = query.filter(or_(Question.kode.ilike(term), Question.pertanyaan.ilike(term)))
if section and section.strip() and section != "all":
query = query.filter(Question.section == section.strip())
if is_active is not None:
query = query.filter(Question.is_active == is_active)
total = query.count()
questions = query.order_by(Question.nomor_form.asc(), Question.id.asc()).offset(skip).limit(limit).all()
# Ambil list unique sections yang ada di DB
all_sections_raw = db.query(Question.section).distinct().all()
sections = [s[0] for s in all_sections_raw if s[0]]
# Batch load all answer options in ONE query instead of N individual queries
q_ids = [q.id for q in questions]
options_by_q = {q.id: [] for q in questions}
if q_ids:
all_opts = db.query(AnswerOption).filter(
AnswerOption.question_id.in_(q_ids)
).order_by(AnswerOption.urutan.asc(), AnswerOption.id.asc()).all()
for opt in all_opts:
options_by_q.setdefault(opt.question_id, []).append(opt)
items = []
for q in questions:
opts = options_by_q.get(q.id, [])
q_dict = {
"id": q.id,
"kode": q.kode,
"pertanyaan": q.pertanyaan,
"tipe": q.tipe or "pilihan",
"section": q.section or "kuesioner_utama",
"nomor_form": q.nomor_form or 0,
"tahun": q.tahun or "all",
"is_active": q.is_active if q.is_active is not None else True,
"options": [AnswerOptionResponse.model_validate(o) for o in opts]
}
items.append(QuestionSchema(**q_dict))
return QuestionListResponse(items=items, total=total, sections=sections)
@router.get("/stats", summary="Ringkasan statistik pertanyaan dan opsi jawaban")
def get_question_stats(db: Session = Depends(get_db)):
"""
Ringkasan jumlah pertanyaan aktif, total opsi jawaban, dan rincian per section.
"""
total_q = db.query(Question).count()
active_q = db.query(Question).filter(Question.is_active == True).count()
total_opts = db.query(AnswerOption).count()
section_breakdown = db.query(
Question.section,
func.count(Question.id)
).group_by(Question.section).all()
return {
"total_questions": total_q,
"active_questions": active_q,
"total_options": total_opts,
"sections": {s[0]: s[1] for s in section_breakdown if s[0]}
}
@router.get("/formulir", response_model=FormSchema, summary="Ambil semua pertanyaan untuk form alumni")
def get_formulir(db: Session = Depends(get_db)):
"""
Ambil semua pertanyaan aktif dan pilihan jawaban untuk formulir tracer study.
"""
questions = db.query(Question).filter(Question.is_active == True).order_by(Question.nomor_form).all()
q_ids = [q.id for q in questions]
options_by_q = {q.id: [] for q in questions}
if q_ids:
all_opts = db.query(AnswerOption).filter(
AnswerOption.question_id.in_(q_ids),
AnswerOption.is_active == True
).order_by(AnswerOption.urutan.asc(), AnswerOption.id.asc()).all()
for opt in all_opts:
options_by_q.setdefault(opt.question_id, []).append(opt)
result_questions = []
sections = []
for q in questions:
options = options_by_q.get(q.id, [])
question_data = QuestionSchema(
id=q.id,
kode=q.kode,
pertanyaan=q.pertanyaan,
tipe=q.tipe or "pilihan",
section=q.section or "kuesioner_utama",
nomor_form=q.nomor_form or 0,
tahun=q.tahun or "all",
is_active=True,
options=[AnswerOptionResponse.model_validate(o) for o in options]
)
result_questions.append(question_data)
if q.section and q.section not in sections:
sections.append(q.section)
return FormSchema(questions=result_questions, sections=sections)
@router.get("/sections", summary="Daftar section unik")
def get_sections(db: Session = Depends(get_db)):
sections = db.query(Question.section).distinct().all()
return {"sections": [s[0] for s in sections if s[0]]}
@router.get("/{question_id}", response_model=QuestionSchema, summary="Ambil detail pertanyaan beserta opsi jawaban")
def get_question_detail(question_id: int, db: Session = Depends(get_db)):
q = db.query(Question).filter(Question.id == question_id).first()
if not q:
raise HTTPException(status_code=404, detail="Pertanyaan tidak ditemukan")
options = db.query(AnswerOption).filter(
AnswerOption.question_id == q.id
).order_by(AnswerOption.urutan).all()
return QuestionSchema(
id=q.id,
kode=q.kode,
pertanyaan=q.pertanyaan,
tipe=q.tipe or "pilihan",
section=q.section or "kuesioner_utama",
nomor_form=q.nomor_form or 0,
tahun=q.tahun or "all",
is_active=q.is_active if q.is_active is not None else True,
options=[AnswerOptionResponse.model_validate(o) for o in options]
)
def resequence_questions(db: Session):
"""
Mengurutkan ulang seluruh nomor_form pertanyaan dari 1 sampai N
secara berkesinambungan tanpa ada celah atau nomor kembar.
"""
questions = db.query(Question).order_by(Question.nomor_form.asc(), Question.id.asc()).all()
for idx, q in enumerate(questions, start=1):
q.nomor_form = idx
db.commit()
@router.post("/reorder", summary="Rapikan ulang seluruh nomor urut pertanyaan agar berurutan 1 sampai N")
def reorder_all_questions(db: Session = Depends(get_db)):
"""
Menata ulang nomor urut pertanyaan (nomor_form) dari 1 sampai N secara berkesinambungan
berdasarkan urutan saat ini.
"""
resequence_questions(db)
total = db.query(Question).count()
return {
"status": "success",
"message": f"Berhasil merapikan nomor urut untuk {total} pertanyaan secara berurutan (1-{total}).",
"total": total
}
@router.post("/", response_model=QuestionSchema, status_code=status.HTTP_201_CREATED, summary="Tambah pertanyaan baru")
def create_question(question_in: QuestionCreate, db: Session = Depends(get_db)):
existing = db.query(Question).filter(Question.kode == question_in.kode.strip()).first()
if existing:
raise HTTPException(status_code=400, detail=f"Pertanyaan dengan kode '{question_in.kode}' sudah ada")
# Tentukan nomor urut berikutnya jika tidak diisi atau jika melompat terlalu jauh
max_no = db.query(func.max(Question.nomor_form)).scalar() or 0
assigned_no = question_in.nomor_form if (question_in.nomor_form and question_in.nomor_form > 0) else (max_no + 1)
new_q = Question(
kode=question_in.kode.strip(),
pertanyaan=question_in.pertanyaan.strip(),
tipe=question_in.tipe,
section=question_in.section,
nomor_form=assigned_no,
tahun=question_in.tahun or "all",
is_active=question_in.is_active if question_in.is_active is not None else True,
)
db.add(new_q)
db.flush()
# Tambahkan options jika disertakan
created_options = []
if question_in.options:
for idx, opt in enumerate(question_in.options, start=1):
new_opt = AnswerOption(
question_id=new_q.id,
kode=opt.kode.strip(),
teks=opt.teks.strip(),
urutan=opt.urutan if opt.urutan else idx,
is_active=opt.is_active if opt.is_active is not None else True
)
db.add(new_opt)
created_options.append(new_opt)
db.commit()
# Pastikan seluruh pertanyaan tersusun rapi berurutan 1..N
resequence_questions(db)
db.refresh(new_q)
return QuestionSchema(
id=new_q.id,
kode=new_q.kode,
pertanyaan=new_q.pertanyaan,
tipe=new_q.tipe,
section=new_q.section,
nomor_form=new_q.nomor_form,
tahun=new_q.tahun,
is_active=new_q.is_active,
options=[AnswerOptionResponse.model_validate(o) for o in created_options]
)
@router.put("/{question_id}", response_model=QuestionSchema, summary="Perbarui pertanyaan")
def update_question(question_id: int, question_in: QuestionUpdate, db: Session = Depends(get_db)):
q = db.query(Question).filter(Question.id == question_id).first()
if not q:
raise HTTPException(status_code=404, detail="Pertanyaan tidak ditemukan")
if question_in.kode and question_in.kode.strip() != q.kode:
existing = db.query(Question).filter(Question.kode == question_in.kode.strip()).first()
if existing and existing.id != question_id:
raise HTTPException(status_code=400, detail=f"Kode '{question_in.kode}' sudah digunakan oleh pertanyaan lain")
q.kode = question_in.kode.strip()
if question_in.pertanyaan is not None:
q.pertanyaan = question_in.pertanyaan.strip()
if question_in.tipe is not None:
q.tipe = question_in.tipe
if question_in.section is not None:
q.section = question_in.section
if question_in.nomor_form is not None:
q.nomor_form = question_in.nomor_form
if question_in.tahun is not None:
q.tahun = question_in.tahun
if question_in.is_active is not None:
q.is_active = question_in.is_active
db.commit()
# Jika nomor_form diubah, rapikan kembali urutan pertanyaan
if question_in.nomor_form is not None:
resequence_questions(db)
db.refresh(q)
options = db.query(AnswerOption).filter(
AnswerOption.question_id == q.id
).order_by(AnswerOption.urutan).all()
return QuestionSchema(
id=q.id,
kode=q.kode,
pertanyaan=q.pertanyaan,
tipe=q.tipe,
section=q.section,
nomor_form=q.nomor_form,
tahun=q.tahun,
is_active=q.is_active,
options=[AnswerOptionResponse.model_validate(o) for o in options]
)
@router.delete("/{question_id}", summary="Hapus pertanyaan beserta opsi jawabannya")
def delete_question(question_id: int, hard: bool = True, db: Session = Depends(get_db)):
q = db.query(Question).filter(Question.id == question_id).first()
if not q:
raise HTTPException(status_code=404, detail="Pertanyaan tidak ditemukan")
kode_q = q.kode
if hard:
# Cascade delete answer_options
db.query(AnswerOption).filter(AnswerOption.question_id == question_id).delete()
db.delete(q)
db.commit()
# Otomatis rapikan urutan sisa pertanyaan sehingga tidak ada nomor yang bolong / melompat
resequence_questions(db)
return {"status": "success", "message": f"Pertanyaan '{kode_q}' berhasil dihapus dan nomor urut seluruh pertanyaan telah dirapikan secara berkesinambungan"}
else:
q.is_active = False
db.commit()
return {"status": "success", "message": f"Pertanyaan '{kode_q}' dinonaktifkan"}
# ========== ANSWER OPTIONS SUB-ENDPOINTS ==========
@router.post("/{question_id}/options", response_model=AnswerOptionResponse, status_code=status.HTTP_201_CREATED, summary="Tambah opsi jawaban baru ke pertanyaan")
def add_answer_option(question_id: int, option_in: AnswerOptionCreate, db: Session = Depends(get_db)):
q = db.query(Question).filter(Question.id == question_id).first()
if not q:
raise HTTPException(status_code=404, detail="Pertanyaan tidak ditemukan")
new_opt = AnswerOption(
question_id=question_id,
kode=option_in.kode.strip(),
teks=option_in.teks.strip(),
urutan=option_in.urutan or 0,
is_active=option_in.is_active if option_in.is_active is not None else True
)
db.add(new_opt)
db.commit()
db.refresh(new_opt)
return AnswerOptionResponse.model_validate(new_opt)
@router.put("/options/{option_id}", response_model=AnswerOptionResponse, summary="Perbarui opsi jawaban")
def update_answer_option(option_id: int, option_in: AnswerOptionUpdate, db: Session = Depends(get_db)):
opt = db.query(AnswerOption).filter(AnswerOption.id == option_id).first()
if not opt:
raise HTTPException(status_code=404, detail="Pilihan jawaban tidak ditemukan")
if option_in.kode is not None:
opt.kode = option_in.kode.strip()
if option_in.teks is not None:
opt.teks = option_in.teks.strip()
if option_in.urutan is not None:
opt.urutan = option_in.urutan
if option_in.is_active is not None:
opt.is_active = option_in.is_active
db.commit()
db.refresh(opt)
return AnswerOptionResponse.model_validate(opt)
@router.delete("/options/{option_id}", summary="Hapus opsi jawaban")
def delete_answer_option(option_id: int, db: Session = Depends(get_db)):
opt = db.query(AnswerOption).filter(AnswerOption.id == option_id).first()
if not opt:
raise HTTPException(status_code=404, detail="Pilihan jawaban tidak ditemukan")
db.delete(opt)
db.commit()
return {"status": "success", "message": "Pilihan jawaban berhasil dihapus"}
@router.post("/sync-jti-prodi-options", summary="Inisialisasi opsi program studi JTI pada pertanyaan kdpstmsnmh")
def sync_jti_prodi_options(db: Session = Depends(get_db)):
"""
Menyinkronkan pilihan jawaban pertanyaan nomor 2 (kdpstmsnmh) agar secara khusus memuat
Program Studi Jurusan Teknologi Informasi (TIF, MIF, TKK).
"""
q = db.query(Question).filter(Question.kode == "kdpstmsnmh").first()
if not q:
# Create question kdpstmsnmh if not exists
q = Question(
kode="kdpstmsnmh",
pertanyaan="Program Studi",
tipe="pilihan",
section="identitas",
nomor_form=2,
tahun="all",
is_active=True
)
db.add(q)
db.flush()
# Clear existing options for this question
db.query(AnswerOption).filter(AnswerOption.question_id == q.id).delete()
jti_prodis = [
{"kode": "55301", "teks": "Teknik Informatika (55301)", "urutan": 1},
{"kode": "57401", "teks": "Manajemen Informatika (57401)", "urutan": 2},
{"kode": "56401", "teks": "Teknik Komputer (56401)", "urutan": 3},
{"kode": "55302", "teks": "Teknik Informatika Kampus Kab. Nganjuk (55302)", "urutan": 4},
{"kode": "55304", "teks": "Teknik Informatika Kampus Kab. Sidoarjo (55304)", "urutan": 5},
]
for p in jti_prodis:
opt = AnswerOption(
question_id=q.id,
kode=p["kode"],
teks=p["teks"],
urutan=p["urutan"],
is_active=True
)
db.add(opt)
db.commit()
return {
"status": "success",
"message": f"Berhasil menginisialisasi {len(jti_prodis)} opsi prodi Teknologi Informasi pada pertanyaan 'kdpstmsnmh'",
"options": jti_prodis
}