""" 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//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)