diff --git a/app/db.py b/app/db.py index 750abbf..0d40528 100644 --- a/app/db.py +++ b/app/db.py @@ -156,7 +156,7 @@ def _migrasi_kelas_penugasan(conn: sqlite3.Connection) -> None: def init_db() -> None: with get_conn() as conn: - for tabel in ("rps", "portofolio", "nilai"): + for tabel in ("rps", "portofolio", "nilai", "rtm"): conn.execute( f"""CREATE TABLE IF NOT EXISTS {tabel} ( mk_kode TEXT NOT NULL, @@ -259,7 +259,7 @@ def simpan_pengaturan(d: dict) -> None: ) -_TABEL_DOK = ("rps", "portofolio", "nilai") +_TABEL_DOK = ("rps", "portofolio", "nilai", "rtm") def simpan_dok(tabel: str, mk_kode: str, data: dict, status: str, updated_by: str, diff --git a/app/generator/rtm_docx.py b/app/generator/rtm_docx.py new file mode 100644 index 0000000..e5871a4 --- /dev/null +++ b/app/generator/rtm_docx.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +""" +Generator Rancangan Tugas Mahasiswa (RTM) (.docx). +Mengikuti format resmi `FORM RPS & RTM UNTAN (draft).docx`: tabel identitas +(Mata Kuliah/Kode MK/Tugas ke/SKS/Dosen Pengampu/Semester) diikuti tabel +RANCANGAN TUGAS MAHASISWA per tugas (Bentuk, Judul, Sub-CPMK, Deskripsi, +Metode Pengerjaan, Bentuk & Format Luaran, Indikator/Kriteria/Bobot, +Jadwal Pelaksanaan, Lain-lain, Daftar Rujukan). Dokumen ini berdiri sendiri, +tak memuat data nilai/skor mahasiswa sama sekali. +""" +import io +import os + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt, RGBColor + +DARK_BLUE = "1F4E79" +LIGHT_BLUE = "D6E4F0" +WHITE = "FFFFFF" + +FAKULTAS = os.environ.get("FAKULTAS", "FAKULTAS TEKNIK") +PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA") + + +def _shd(cell, hex_color): + tc_pr = cell._tc.get_or_add_tcPr() + el = OxmlElement("w:shd") + el.set(qn("w:val"), "clear") + el.set(qn("w:color"), "auto") + el.set(qn("w:fill"), hex_color) + tc_pr.append(el) + + +def _ct(cell, text, bold=False, italic=False, size=10, color=None, + align=WD_ALIGN_PARAGRAPH.LEFT): + p = cell.paragraphs[0] + p.alignment = align + p.paragraph_format.space_before = Pt(2) + p.paragraph_format.space_after = Pt(2) + run = p.add_run() + for i, line in enumerate(str(text or "").split("\n")): + if i: + run.add_break() + run.add_text(line) + run.bold = bold + run.italic = italic + run.font.name = "Times New Roman" + run.font.size = Pt(size) + if color: + run.font.color.rgb = RGBColor.from_string(color) + + +def _hdr(cell, text, size=10): + _shd(cell, DARK_BLUE) + _ct(cell, text, bold=True, color=WHITE, size=size, align=WD_ALIGN_PARAGRAPH.CENTER) + + +def _borders(table): + tbl_pr = table._tbl.tblPr + el = OxmlElement("w:tblBorders") + for sisi in ("top", "left", "bottom", "right", "insideH", "insideV"): + b = OxmlElement(f"w:{sisi}") + b.set(qn("w:val"), "single") + b.set(qn("w:sz"), "4") + b.set(qn("w:color"), "000000") + el.append(b) + tbl_pr.append(el) + + +def build_rtm(d: dict) -> bytes: + doc = Document() + for sec in doc.sections: + sec.top_margin = sec.bottom_margin = Cm(2.5) + sec.left_margin = sec.right_margin = Cm(2.5) + + def para(text, size=12, bold=False, align=WD_ALIGN_PARAGRAPH.LEFT, before=6, after=3): + p = doc.add_paragraph() + p.alignment = align + p.paragraph_format.space_before = Pt(before) + p.paragraph_format.space_after = Pt(after) + r = p.add_run(text) + r.bold = bold + r.font.name = "Times New Roman" + r.font.size = Pt(size) + return p + + para("RANCANGAN TUGAS MAHASISWA (RTM)", size=16, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER) + para(PRODI, size=12, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER, before=0) + para(f"{FAKULTAS} — UNIVERSITAS TANJUNGPURA", size=11, align=WD_ALIGN_PARAGRAPH.CENTER, before=0) + para(f"Tahun Ajaran {d.get('tahun', '')}", size=11, align=WD_ALIGN_PARAGRAPH.CENTER, + before=0, after=10) + + daftar_tugas = d.get("tugas", []) + if not daftar_tugas: + para("Belum ada Tugas yang diisi untuk RTM ini.", after=6) + + for idx, t in enumerate(daftar_tugas, 1): + if idx > 1: + doc.add_page_break() + + ti = doc.add_table(rows=3, cols=4) + _borders(ti) + baris = [ + ("Mata Kuliah", d.get("nama", ""), "Kode MK", d.get("kode", "")), + ("Tugas ke", str(idx), "SKS", str(d.get("sks", "") or "—")), + ("Dosen Pengampu", d.get("pengampu", "") or "—", "Semester", + str(d.get("semester", "") or "—")), + ] + for ri, (l1, v1, l2, v2) in enumerate(baris): + _shd(ti.cell(ri, 0), LIGHT_BLUE) + _ct(ti.cell(ri, 0), l1, bold=True) + _ct(ti.cell(ri, 1), v1) + _shd(ti.cell(ri, 2), LIGHT_BLUE) + _ct(ti.cell(ri, 2), l2, bold=True) + _ct(ti.cell(ri, 3), v2) + doc.add_paragraph() + + subs_teks = ", ".join(t.get("subs") or []) or "—" + jadwal = "; ".join(x for x in ( + f"Alokasi waktu: {t['alokasi_waktu']}" if t.get("alokasi_waktu") else "", + f"Tanggal: {t['tanggal']}" if t.get("tanggal") else "", + ) if x) or "—" + rows = [ + ("BENTUK TUGAS", t.get("bentuk_tugas", "") or "—"), + ("JUDUL TUGAS", t.get("judul", "") or "—"), + ("SUB-CPMK YANG DICAPAI", subs_teks), + ("DESKRIPSI TUGAS", t.get("deskripsi", "") or "—"), + ("METODE PENGERJAAN TUGAS", t.get("metode_pengerjaan", "") or "—"), + ("BENTUK DAN FORMAT LUARAN", t.get("luaran", "") or "—"), + ("INDIKATOR, KRITERIA DAN BOBOT PENILAIAN", t.get("indikator_bobot", "") or "—"), + ("JADWAL PELAKSANAAN", jadwal), + ("LAIN-LAIN", t.get("lain_lain", "") or "—"), + ("DAFTAR RUJUKAN", t.get("rujukan", "") or "—"), + ] + tt = doc.add_table(rows=1 + len(rows), cols=2) + _borders(tt) + _hdr(tt.cell(0, 0), "RANCANGAN TUGAS MAHASISWA") + tt.cell(0, 0).merge(tt.cell(0, 1)) + for ri, (label, isi) in enumerate(rows, 1): + _shd(tt.cell(ri, 0), LIGHT_BLUE) + _ct(tt.cell(ri, 0), label, bold=True, size=9) + _ct(tt.cell(ri, 1), isi, size=10) + tt.rows[ri].cells[0].width = Cm(5.5) + tt.rows[ri].cells[1].width = Cm(11) + + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() diff --git a/app/main.py b/app/main.py index 530ff89..4644b8e 100644 --- a/app/main.py +++ b/app/main.py @@ -13,6 +13,7 @@ from fastapi.templating import Jinja2Templates from . import auth, db, hitung, nilai_xlsx from .generator.portofolio_docx import build_portofolio from .generator.rps_untan import build_rps +from .generator.rtm_docx import build_rtm BASE = pathlib.Path(__file__).resolve().parent @@ -358,6 +359,43 @@ def form_kosong() -> dict: } +# ── RTM (Rancangan Tugas Mahasiswa) — dokumen sendiri, terpisah dari Nilai ── + +RTM_TUGAS_KOSONG = { + "judul": "", "subs": [], "bentuk_tugas": "", "deskripsi": "", + "metode_pengerjaan": "", "luaran": "", "indikator_bobot": "", + "alokasi_waktu": "", "tanggal": "", "lain_lain": "", "rujukan": "", +} + + +def _ambil_rtm(kode: str) -> tuple[list[dict], dict]: + """Daftar entri Tugas RTM yang sudah tersimpan (bisa kosong — halaman RTM + menambah blok baru secara dinamis lewat JS, bukan slot tetap) — murni + dokumentasi, tak menyentuh data Nilai.""" + tersimpan = db.ambil_dok("rtm", kode) + tugas = [dict(RTM_TUGAS_KOSONG, **t) for t in ((tersimpan or {}).get("data", {}).get("tugas") or [])] + meta = tersimpan or {"status": "", "updated_at": "", "updated_by": ""} + return tugas, meta + + +def _bahan_rtm(kode: str) -> dict | None: + peta = db.peta_mk(kode) + if not peta: + return None + rps = db.ambil_rps(kode) + ident = ((rps or {}).get("data") or {}).get("identitas") or {} + tersimpan = db.ambil_dok("rtm", kode) + tugas = [t for t in ((tersimpan or {}).get("data", {}).get("tugas") or []) + if t.get("judul") or t.get("bentuk_tugas") or t.get("deskripsi")] + return { + "tahun": db.tahun_aktif(), + "nama": peta["nama"], "kode": peta["kode"], + "sks": ident.get("sks", ""), "semester": ident.get("semester", ""), + "pengampu": ident.get("pengampu", ""), + "tugas": tugas, + } + + def _terapkan_override_sub(peta: dict, rps_data: dict | None) -> dict: """Salinan `peta` dengan uraian Sub-CPMK ditimpa override tersimpan di RPS MK ini (bila ada) — dipakai konsisten di form RPS, unduhan RPS, dan @@ -594,6 +632,82 @@ def unduh_rps(kode: str): ) +@app.post("/rps/{kode}/rtm") +async def simpan_rtm(request: Request, kode: str): + """RTM (Rancangan Tugas Mahasiswa) — dokumen sendiri, sekadar dokumentasi + rencana tugas per MK; tak ada kaitan dengan data Nilai/skor sama sekali.""" + peta = db.peta_mk(kode) + if not peta: + return RedirectResponse("/", status_code=303) + if not boleh_rps(request.state.user, kode): + return RedirectResponse( + f"/rps/{kode}/rtm?pesan=Anda+tidak+memiliki+hak+edit+RPS+untuk+MK+ini.", + status_code=303) + form = await request.form() + # blok bisa ditambah/dihapus bebas di JS (lihat form_rtm.html) sehingga + # indeksnya boleh tidak berurutan — cari semua "judul_N" yang ada, bukan + # mengandalkan hitungan 0,1,2,... berurutan tanpa celah. + idxs = sorted({int(m.group(1)) for k in form.keys() + for m in [re.fullmatch(r"judul_(\d+)", k)] if m}) + tugas = [] + for i in idxs: + entri = { + "judul": (form.get(f"judul_{i}") or "").strip(), + "subs": form.getlist(f"subs_{i}"), + "bentuk_tugas": (form.get(f"bentuk_tugas_{i}") or "").strip(), + "deskripsi": (form.get(f"deskripsi_{i}") or "").strip(), + "metode_pengerjaan": (form.get(f"metode_pengerjaan_{i}") or "").strip(), + "luaran": (form.get(f"luaran_{i}") or "").strip(), + "indikator_bobot": (form.get(f"indikator_bobot_{i}") or "").strip(), + "alokasi_waktu": (form.get(f"alokasi_waktu_{i}") or "").strip(), + "tanggal": (form.get(f"tanggal_{i}") or "").strip(), + "lain_lain": (form.get(f"lain_lain_{i}") or "").strip(), + "rujukan": (form.get(f"rujukan_{i}") or "").strip(), + } + if entri["judul"] or entri["bentuk_tugas"] or entri["deskripsi"]: + tugas.append(entri) + db.simpan_dok("rtm", kode, {"tugas": tugas}, "selesai", request.state.user["nama"]) + return RedirectResponse( + f"/rps/{kode}/rtm?pesan=RTM+tersimpan+-+{len(tugas)}+tugas+terisi.", + status_code=303) + + +@app.get("/rps/{kode}/rtm", response_class=HTMLResponse) +def form_rtm(request: Request, kode: str, pesan: str = ""): + peta = db.peta_mk(kode) + if not peta: + return RedirectResponse("/", status_code=303) + rps = db.ambil_rps(kode) + if not rps or rps["status"] != "selesai": + return RedirectResponse( + f"/rps/{kode}?pesan=RTM+hanya+bisa+diisi+setelah+RPS+ditandai+selesai.", + status_code=303) + rtm_tugas, rtm_meta = _ambil_rtm(kode) + return render( + request, "form_rtm.html", + {"peta": peta, "tahun": db.tahun_aktif(), "pesan": pesan, + "boleh_edit": boleh_rps(request.state.user, kode), + "rtm_tugas": rtm_tugas, "rtm_updated_at": rtm_meta.get("updated_at", ""), + "rtm_updated_by": rtm_meta.get("updated_by", "")}, + ) + + +@app.get("/rps/{kode}/rtm/unduh") +def unduh_rtm(kode: str): + bahan = _bahan_rtm(kode) + if not bahan or not bahan["tugas"]: + return RedirectResponse( + f"/rps/{kode}/rtm?pesan=Belum+ada+Tugas+RTM+yang+diisi+untuk+diunduh.", + status_code=303) + isi = build_rtm(bahan) + nama_file = re.sub(r"[^A-Za-z0-9_-]+", "_", f"RTM_{kode}_{bahan['nama']}") + ".docx" + return Response( + content=isi, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": f'attachment; filename="{nama_file}"'}, + ) + + # ═════════════════════════ TAHAP 2: NILAI & PENGUKURAN ═════════════════════════ diff --git a/app/templates/form_nilai.html b/app/templates/form_nilai.html index 009036e..6cb057d 100644 --- a/app/templates/form_nilai.html +++ b/app/templates/form_nilai.html @@ -2,7 +2,7 @@ {% block judul %}Nilai {{ peta.nama }} — Informatika UNTAN{% endblock %} {% block isi %}

Input Nilai — {{ peta.nama }} ({{ peta.kode }})

diff --git a/app/templates/form_nilai_komponen.html b/app/templates/form_nilai_komponen.html index 0dbb5ea..ce939d9 100644 --- a/app/templates/form_nilai_komponen.html +++ b/app/templates/form_nilai_komponen.html @@ -2,7 +2,7 @@ {% block judul %}Nilai {{ komponen.nama }} — {{ peta.nama }} — Informatika UNTAN{% endblock %} {% block isi %}

Isi Nilai — {{ komponen.nama }} ({{ peta.nama }})

diff --git a/app/templates/form_porto.html b/app/templates/form_porto.html index 44e339e..63f2c2a 100644 --- a/app/templates/form_porto.html +++ b/app/templates/form_porto.html @@ -2,7 +2,7 @@ {% block judul %}Portofolio {{ peta.nama }} — Informatika UNTAN{% endblock %} {% block isi %}