498 lines
18 KiB
Python
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
|
|
}
|