elemes/services/lesson_registry.py

178 lines
6.5 KiB
Python

"""
Lesson registry — sinkron metadata lesson dari Markdown (home.md) ke tabel `lessons`.
Konten tetap dari Markdown (lesson_service); DB hanya menyimpan metadata
(slug, title, urutan, status aktif). Lesson yang hilang dari home.md
ditandai is_active=False, BUKAN dihapus — history progress tetap utuh.
"""
import logging
from sqlalchemy.orm import Session
from services import lesson_service
from services.repositories import deactivate_missing_lessons, list_lessons, upsert_lesson
logger = logging.getLogger(__name__)
def lesson_specs() -> list[tuple[str, str, int]]:
"""Daftar (slug, title, order_index) dari **seluruh** lesson aktif —
root home.md ditambah setiap sub-home.md (bab/<folder>/sub-home.md).
Urutan: root home.md dulu (indeks lokal), lalu sub-bab (indeks lokal).
Slug didedupe (root selalu menang atas sub-bab bila collision) sehingga
satu lesson ≡ satu baris di tabel `lessons`, tak tergantung dari halaman
mana siswa/guru mengakses materi tersebut.
Tidak pakai cache — fresh setiap panggilan agar perubahan home.md/sub-home.md
yang belum trigger restart tetap terpantau pada sync berikutnya.
"""
lesson_service.get_lessons.cache_clear()
lesson_service.find_lesson_file.cache_clear()
specs: dict[str, tuple[str, str, int]] = {}
# 1) Root home.md — prioritas utama, urutan index-nya menjadi acuan.
root_lessons = lesson_service.get_lessons()
for idx, lesson in enumerate(root_lessons):
slug = lesson["filename"]
if slug.endswith(".md"):
slug = slug[:-3]
specs[slug] = (slug, lesson["title"], idx)
# 2) Tiap folder level-1 yang punya sub-home.md.
for folder in lesson_service.find_all_sub_home_folders():
path = lesson_service.get_sub_home_path(folder)
if not path:
continue
sub_lessons = lesson_service.get_lessons(source_path=path)
for idx, lesson in enumerate(sub_lessons):
slug = lesson["filename"]
if slug.endswith(".md"):
slug = slug[:-3]
# root selamat: setdefault agar urutan root tidak tertimpa.
specs.setdefault(slug, (slug, lesson["title"], idx))
return list(specs.values())
def sync_lesson_registry(db: Session) -> dict:
"""Upsert semua lesson aktif dari Markdown; nonaktifkan yang hilang.
Idempotent — aman dipanggil berulang (startup, command, atau cron).
"""
specs = lesson_specs()
active_slugs = {slug for slug, _, _ in specs}
upserted = 0
for slug, title, order_index in specs:
upsert_lesson(db, slug=slug, title=title, order_index=order_index)
upserted += 1
deactivated = deactivate_missing_lessons(db, active_slugs)
db.commit()
all_lessons = list_lessons(db)
return {
"specs": len(specs),
"upserted": upserted,
"deactivated": deactivated,
"total": len(all_lessons),
"active": sum(1 for lesson in all_lessons if lesson.is_active),
}
def maybe_sync_on_startup():
"""Auto-sync saat app init (storage PostgreSQL). Error DB hanya di-log —
aplikasi tetap jalan; sync berjalan lagi saat startup berikutnya
(command manual `synclessons` sudah dihapus)."""
from services.database import SessionLocal # lazy: tanpa DB tidak dipanggil
if SessionLocal is None:
return
try:
db = SessionLocal()
try:
result = sync_lesson_registry(db)
logger.info("Lesson registry disinkronkan: %s", result)
finally:
db.close()
except Exception as exc: # noqa: BLE001 — startup tidak boleh crash karena DB
logger.warning("Sync lesson registry gagal (akan dicoba lagi nanti): %s", exc)
def recompute_all_lessons(db: Session) -> dict:
"""Hitung ulang composite & state SEMUA progress dengan komponen FRESH.
Dipakai sebagai safety-net startup dan sinkronisasi manual (tombol di
halaman progress guru): menangkap perubahan konten yang terjadi di luar
webapp (git pull, edit manual) sehingga tidak perlu restart server.
Return ringkasan: {lessons, recomputed, affected_lessons}.
"""
from config import LESSON_DONE_MIN_PERCENT, LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT
from services.repositories import list_lessons, recompute_lesson_progress
lessons = list_lessons(db)
recomputed = 0
affected_lessons = 0
for lesson in lessons:
try:
has_exercise, has_quiz = lesson_service.get_lesson_components(lesson.slug)
except Exception as exc: # noqa: BLE001 — satu lesson rusak jangan blokir sisanya
logger.warning("Recompute lesson %s dilewati: %s", lesson.slug, exc)
continue
count = recompute_lesson_progress(
db,
lesson_id=lesson.id,
has_exercise=has_exercise,
has_quiz=has_quiz,
exercise_weight=LESSON_EXERCISE_WEIGHT,
quiz_weight=LESSON_QUIZ_WEIGHT,
done_min_percent=LESSON_DONE_MIN_PERCENT,
)
if count:
affected_lessons += 1
recomputed += count
db.commit()
return {
"lessons": len(lessons),
"recomputed": recomputed,
"affected_lessons": affected_lessons,
}
def backfill_legacy_progress(db: Session) -> int:
"""Rekonsiliasi progress lama saat startup — kini alias `recompute_all_lessons`.
Versi lama hanya menghitung ulang record `in_progress` yang sudah punya
data kelulusan. Setelah publish ikut merekonsiliasi progress, startup
melakukan pass penuh (semua baris dengan data) supaya perubahan konten di
luar webapp tetap konsisten. Dipertahankan demi kompatibilitas pemanggil.
"""
return recompute_all_lessons(db)["recomputed"]
def maybe_backfill_on_startup():
"""Auto-rekonsiliasi progress saat app init (safety net).
Idempotent — bila tidak ada perubahan konten, hasil composite/state tidak
berubah. Error DB hanya di-log agar aplikasi tetap jalan.
"""
from services.database import SessionLocal
if SessionLocal is None:
return
try:
db = SessionLocal()
try:
result = recompute_all_lessons(db)
if result["recomputed"] > 0:
logger.info(
"Rekonsiliasi progress startup: %d baris di %d lesson",
result["recomputed"], result["affected_lessons"],
)
finally:
db.close()
except Exception as exc: # noqa: BLE001
logger.warning("Rekonsiliasi progress startup gagal (akan dicoba lagi nanti): %s", exc)