elemes/services/repositories.py

860 lines
30 KiB
Python

"""
Repository layer — seluruh akses data PostgreSQL lewat SQLAlchemy session.
Dipanggil oleh service/facade; session & commit dikelola pemanggil
(mis. get_db() per request). Kontrak perilaku dijamin test integrasi
terhadap PostgreSQL nyata (test_repositories.py) dan suite kontrak
(test_token_service_contract.py) yang sama untuk kedua backend.
"""
from datetime import datetime, timezone
from uuid import uuid4
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from config import LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT, LESSON_DONE_MIN_PERCENT
from services.models import AccessToken, ContentDraft, Lesson, QuizAttempt, StudentProgress, User
from services.evaluation import compute_composite
from services.lesson_service import get_lesson_components
from services.progress_status import ParsedProgress
from services.student_roundtrip import (
RoundTripImportError,
StudentRoundTripRow,
is_canonical_uuid,
serialize_export_csv,
)
from services.token_hashing import hash_token
def _uuid() -> str:
return str(uuid4())
# ── users & access_tokens ─────────────────────────────────────────
def get_user_by_id(db: Session, user_id: str) -> User | None:
return db.get(User, user_id)
def get_user_by_token(db: Session, raw_token: str) -> User | None:
"""Lookup user dari token mentah. Update last_used_at (tanpa commit)."""
digest = hash_token(raw_token)
token = db.scalar(
select(AccessToken).where(
AccessToken.token_hash == digest,
AccessToken.revoked_at.is_(None),
)
)
if token is None:
return None
user = db.get(User, token.user_id)
if user is None or not user.is_active:
return None
token.last_used_at = datetime.now(timezone.utc)
return user
def find_user_by_raw_token(db: Session, raw_token: str) -> User | None:
"""Lookup user dari token mentah TANPA side-effect (untuk importer/verifikasi)."""
digest = hash_token(raw_token)
token = db.scalar(select(AccessToken).where(AccessToken.token_hash == digest))
if token is None:
return None
return db.get(User, token.user_id)
def create_user(
db: Session,
*,
display_name: str,
role: str,
is_active: bool = True,
user_id: str | None = None,
) -> User:
"""Buat user baru; `user_id` opsional (create-with-ID untuk import round-trip).
Caller existing tidak berubah — parameter baru bersifat opsional.
"""
kwargs: dict = {"display_name": display_name, "role": role, "is_active": is_active}
if user_id is not None:
kwargs["id"] = user_id
user = User(**kwargs)
db.add(user)
db.flush()
return user
def create_access_token(
db: Session, *, user_id: str, raw_token: str
) -> AccessToken:
token = AccessToken(id=_uuid(), user_id=user_id, token_hash=hash_token(raw_token))
db.add(token)
db.flush()
return token
def revoke_token(db: Session, *, user_id: str, raw_token: str) -> bool:
digest = hash_token(raw_token)
token = db.scalar(
select(AccessToken).where(
AccessToken.token_hash == digest,
AccessToken.user_id == user_id,
)
)
if token is None:
return False
token.revoked_at = datetime.now(timezone.utc)
return True
def revoke_all_tokens(db: Session, *, user_id: str) -> int:
tokens = list(
db.scalars(
select(AccessToken).where(
AccessToken.user_id == user_id,
AccessToken.revoked_at.is_(None),
)
)
)
for token in tokens:
token.revoked_at = datetime.now(timezone.utc)
return len(tokens)
# ── lessons ────────────────────────────────────────────────────────
def list_lessons(db: Session) -> list[Lesson]:
return list(db.scalars(select(Lesson).order_by(Lesson.order_index, Lesson.slug)))
def list_active_lessons(db: Session) -> list[Lesson]:
return list(
db.scalars(select(Lesson).where(Lesson.is_active.is_(True)).order_by(Lesson.order_index, Lesson.slug))
)
def get_lesson_by_slug(db: Session, slug: str) -> Lesson | None:
return db.scalar(select(Lesson).where(Lesson.slug == slug))
def upsert_lesson(db: Session, *, slug: str, title: str, order_index: int) -> Lesson:
lesson = get_lesson_by_slug(db, slug)
if lesson is None:
lesson = Lesson(slug=slug, title=title, order_index=order_index)
db.add(lesson)
else:
lesson.title = title
lesson.order_index = order_index
lesson.is_active = True
db.flush()
return lesson
def deactivate_missing_lessons(db: Session, active_slugs: set[str]) -> int:
"""Lesson yang tidak ada lagi di Markdown → is_active=False (bukan dihapus)."""
lessons = list(db.scalars(select(Lesson).where(Lesson.is_active.is_(True))))
changed = 0
for lesson in lessons:
if lesson.slug not in active_slugs:
lesson.is_active = False
changed += 1
return changed
# ── student_progress ───────────────────────────────────────────────
def get_progress(db: Session, *, user_id: str, lesson_id: str) -> StudentProgress | None:
return db.scalar(
select(StudentProgress).where(
StudentProgress.user_id == user_id,
StudentProgress.lesson_id == lesson_id,
)
)
def list_progress_for_user(db: Session, *, user_id: str) -> list[StudentProgress]:
return list(
db.scalars(select(StudentProgress).where(StudentProgress.user_id == user_id))
)
def set_progress(
db: Session,
*,
user_id: str,
lesson_id: str,
state: str,
score_earned: int | None = None,
score_total: int | None = None,
) -> StudentProgress | None:
"""Upsert progress (sparse model).
- state='not_started' → HAPUS row bila ada; tidak ada row = not_started.
- state='scored' wajib membawa score_earned & score_total.
"""
if state == "not_started":
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
if progress is not None:
db.delete(progress)
db.flush()
return None
if state == "scored":
if score_earned is None or score_total is None:
raise ValueError("state='scored' memerlukan score_earned & score_total")
else:
score_earned = None
score_total = None
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
if progress is None:
progress = StudentProgress(
id=_uuid(),
user_id=user_id,
lesson_id=lesson_id,
state=state,
score_earned=score_earned,
score_total=score_total,
)
db.add(progress)
else:
progress.state = state
progress.score_earned = score_earned
progress.score_total = score_total
db.flush()
return progress
def set_exercise_passed(
db: Session, *, user_id: str, lesson_id: str, passed: bool = True
) -> StudentProgress | None:
"""Tandai lulus/tidak latihan untuk (user, lesson).
Buat row progress bila belum ada (state='in_progress'). Tidak commit —
pemanggil mengelola transaksi. Tidak menghitung composite (panggil
`recompute_progress` terpisah).
"""
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
if progress is None:
progress = StudentProgress(
id=_uuid(),
user_id=user_id,
lesson_id=lesson_id,
state="in_progress",
)
db.add(progress)
progress.exercise_passed = passed
db.flush()
return progress
def set_quiz_score(
db: Session, *, user_id: str, lesson_id: str, quiz_earned: int, quiz_total: int
) -> StudentProgress | None:
"""Simpan ringkasan skor kuis untuk (user, lesson).
Buat row progress bila belum ada (state='in_progress'). Tidak commit —
pemanggil mengelola transaksi. Tidak menghitung composite (panggil
`recompute_progress` terpisah).
"""
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
if progress is None:
progress = StudentProgress(
id=_uuid(),
user_id=user_id,
lesson_id=lesson_id,
state="in_progress",
)
db.add(progress)
progress.quiz_score_earned = quiz_earned
progress.quiz_score_total = quiz_total
db.flush()
return progress
def recompute_progress(
db: Session,
*,
user_id: str,
lesson_id: str,
has_exercise: bool,
has_quiz: bool,
exercise_weight: float,
quiz_weight: float,
done_min_percent: float,
) -> StudentProgress | None:
"""Hitung ulang composite_percent & state via compute_composite.
Tidak commit — pemanggil mengelola transaksi.
"""
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
if progress is None:
return None
result = compute_composite(
exercise_passed=progress.exercise_passed,
quiz_earned=progress.quiz_score_earned,
quiz_total=progress.quiz_score_total,
has_exercise=has_exercise,
has_quiz=has_quiz,
exercise_weight=exercise_weight,
quiz_weight=quiz_weight,
done_min_percent=done_min_percent,
)
progress.composite_percent = result.composite_percent
progress.state = "done" if result.is_done else "in_progress"
db.flush()
return progress
def count_completed_lessons(db: Session, *, user_id: str) -> int:
return (
db.scalar(
select(func.count())
.select_from(StudentProgress)
.where(
StudentProgress.user_id == user_id,
StudentProgress.state.in_(("completed", "scored", "done")),
)
)
or 0
)
# ── quiz_attempts (anti-cheat / focus-loss) ───────────────────────
def get_quiz_attempt_by_id(db: Session, attempt_id: str) -> QuizAttempt | None:
return db.get(QuizAttempt, attempt_id)
def list_quiz_attempts_for_user(db: Session, *, user_id: str) -> list[QuizAttempt]:
return list(
db.scalars(select(QuizAttempt).where(QuizAttempt.user_id == user_id))
)
def get_quiz_attempt_for_user_lesson(db: Session, *, user_id: str, lesson_id: str) -> QuizAttempt | None:
"""Satu attempt kuis per (user, lesson) — one-attempt policy (unique constraint)."""
return db.scalars(
select(QuizAttempt)
.where(
QuizAttempt.user_id == user_id,
QuizAttempt.lesson_id == lesson_id,
)
.order_by(QuizAttempt.finished_at.desc())
.limit(1)
).first()
def delete_quiz_attempts(db: Session, *, user_id: str, lesson_id: str) -> int:
"""Hapus attempt (one-attempt) — dipakai saat guru me-reset progress."""
attempts = list(
db.scalars(
select(QuizAttempt).where(
QuizAttempt.user_id == user_id,
QuizAttempt.lesson_id == lesson_id,
)
)
)
for attempt in attempts:
db.delete(attempt)
return len(attempts)
def finalize_quiz_attempt(
db: Session,
*,
attempt_id: str,
user_id: str,
lesson_id: str,
status: str,
termination_reason: str | None,
score_earned: int | None,
score_total: int | None,
started_at: datetime,
finished_at: datetime,
visibility_event_count: int,
user_agent: str | None = None,
answers_json: str | None = None,
) -> tuple[QuizAttempt, bool]:
"""Insert/confirm SATU attempt kuis — idempotent per `attempt_id`.
Return `(attempt, created)`:
- row baru → (attempt, True)
- `attempt_id` sudah ada (retry beacon/fetch ganda) → row PERTAMA, False;
record pertama tidak pernah ditimpa oleh retry.
- `attempt_id` sudah dipakai attempt dengan user/lesson BERBEDA
→ raise ValueError (konflik payload).
- `attempt_id` baru tetapi (user, lesson) sudah punya attempt (one-attempt
policy) → IntegrityError dari unique constraint; pemanggil menangani
sebagai 409. Race dua request attempt_id sama juga dilindungi di sini.
Tidak commit — transaksi dikelola pemanggil (route).
"""
existing = get_quiz_attempt_by_id(db, attempt_id)
if existing is not None:
if existing.user_id != user_id or existing.lesson_id != lesson_id:
raise ValueError("attempt_id sudah dipakai attempt lain")
return existing, False
attempt = QuizAttempt(
id=attempt_id,
user_id=user_id,
lesson_id=lesson_id,
status=status,
termination_reason=termination_reason,
score_earned=score_earned,
score_total=score_total,
started_at=started_at,
finished_at=finished_at,
visibility_event_count=visibility_event_count,
user_agent=user_agent,
answers_json=answers_json,
)
db.add(attempt)
try:
db.flush()
except IntegrityError:
# Race: request ganda dengan attempt_id sama, atau one-attempt
# (user, lesson) sudah terisi. Rollback membatalkan transaksi saat ini;
# baca pemenang — bila ada row dengan attempt_id sama → idempotent.
db.rollback()
winner = get_quiz_attempt_by_id(db, attempt_id)
if winner is None:
raise
return winner, False
return attempt, True
# ── export / import round-trip ─────────────────────────────────────
def _validate_id_list(student_ids: list[str], *, field: str) -> set[str]:
"""Validasi array UUID: tipe list, 1-1000 item, canonical, tanpa duplikat.
Raise ValueError dengan pesan aman (tanpa data sensitif).
"""
if not isinstance(student_ids, list) or not student_ids:
raise ValueError(f"{field} wajib berisi 1-1000 UUID")
if len(student_ids) > 1000:
raise ValueError(f"Maksimum 1000 UUID pada {field}")
seen: set[str] = set()
for sid in student_ids:
if not is_canonical_uuid(sid):
raise ValueError(f"UUID tidak valid pada {field}: {sid!r}")
if sid in seen:
raise ValueError(f"UUID duplikat pada {field}: {sid!r}")
seen.add(sid)
return seen
def list_students_for_export(
db: Session,
student_ids: list[str] | None = None,
) -> list[User]:
"""Siswa untuk export — hanya role student, urutan deterministik.
- `student_ids=None` atau array kosong → seluruh siswa.
- Array non-kosong → divalidasi ketat: duplicate/malformed/unknown/teacher
ID membuat seluruh request gagal (bukan diam-diam dilewati).
"""
if student_ids:
wanted = _validate_id_list(student_ids, field="student_ids")
users = list(
db.scalars(
select(User)
.where(User.id.in_(wanted), User.role == "student")
.order_by(User.created_at, User.id)
)
)
found = {u.id for u in users}
missing = [sid for sid in student_ids if sid not in found]
if missing:
raise ValueError("student_id tidak dikenal atau bukan siswa")
return users
return list(
db.scalars(
select(User).where(User.role == "student").order_by(User.created_at, User.id)
)
)
def export_students_csv(
db: Session,
student_ids: list[str] | None = None,
) -> bytes:
"""Export siswa (selection atau seluruh) + progress → CSV round-trip bytes.
Kolom token selalu kosong; teacher tidak pernah ikut; export tidak mengubah DB.
"""
users = list_students_for_export(db, student_ids)
lessons = list_active_lessons(db)
lesson_slugs = [lesson.slug for lesson in lessons]
rows: list[StudentRoundTripRow] = []
for user in users:
progress_rows = {
p.lesson_id: p for p in list_progress_for_user(db, user_id=user.id)
}
progress: dict[str, ParsedProgress] = {}
for lesson in lessons:
p = progress_rows.get(lesson.id)
if p is None:
continue
if p.state == "scored":
progress[lesson.slug] = ParsedProgress(
"scored", p.score_earned, p.score_total
)
elif p.state == "completed":
progress[lesson.slug] = ParsedProgress("completed")
elif p.state == "done":
progress[lesson.slug] = ParsedProgress(
"done",
exercise_passed=p.exercise_passed,
quiz_earned=p.quiz_score_earned,
quiz_total=p.quiz_score_total,
)
rows.append(
StudentRoundTripRow(
line=0,
student_id=user.id,
raw_token="",
display_name=user.display_name,
progress=progress,
)
)
return serialize_export_csv(rows, lesson_slugs)
def find_student_import_conflicts(
db: Session, rows: list[StudentRoundTripRow]
) -> list[str]:
"""Conflict terhadap database saat ini (tanpa write).
Aturan identitas baris (kontrak round-trip):
- student_id terisi → user WAJIB sudah ada dan ber-role `student`; siswa
existing TIDAK boleh menerima token baru (import hanya restore/update
nama & progress; token lama dipertahankan).
- student_id kosong (siswa baru) → token wajib non-kosong (dijamin
parser); HMAC digest token tidak boleh sudah ada di `access_tokens`
(termasuk token revoked).
Error tidak pernah menyebut token mentah maupun digest.
"""
conflicts: list[str] = []
# Query semua student_id terisi SEKALI, lalu validasi keberadaan & role.
existing_by_id: dict[str, User] = {}
ids = [r.student_id for r in rows if r.student_id]
if ids:
for user in db.scalars(select(User).where(User.id.in_(ids))):
existing_by_id[user.id] = user
new_rows: list[StudentRoundTripRow] = []
for row in rows:
if row.student_id:
user = existing_by_id.get(row.student_id)
if user is None:
conflicts.append(
f"Baris {row.line}: student_id tidak dikenal atau bukan siswa"
)
elif user.role != "student":
conflicts.append(
f"Baris {row.line}: student_id milik teacher — import tidak dapat mengubah teacher"
)
elif row.raw_token:
conflicts.append(
f"Baris {row.line}: siswa existing tidak boleh menerima token baru "
"(hapus siswa terlebih dahulu untuk membuat ulang)"
)
else:
new_rows.append(row)
# Hanya siswa baru yang ikut pemeriksaan digest token.
if new_rows:
digests = {hash_token(r.raw_token) for r in new_rows}
existing_digests = set(
db.scalars(
select(AccessToken.token_hash).where(
AccessToken.token_hash.in_(digests)
)
)
)
for row in new_rows:
if hash_token(row.raw_token) in existing_digests:
conflicts.append(f"Baris {row.line}: token sudah terdaftar di database")
return conflicts
def preview_student_import(
db: Session, rows: list[StudentRoundTripRow]
) -> dict:
"""Ringkasan import TANPA menulis apa pun.
Baris dengan student_id terisi dilaporkan sebagai update/restore
(bukan create); baris tanpa student_id sebagai siswa baru. Conflict
dihitung terhadap state DB saat ini; apply akan memvalidasi ulang.
"""
conflicts = find_student_import_conflicts(db, rows)
to_create = [r for r in rows if not r.student_id]
to_update = [r for r in rows if r.student_id]
progress_to_create = sum(
1 for r in to_create for parsed in r.progress.values() if parsed.state != "reset"
)
progress_to_restore = sum(
1 for r in to_update for parsed in r.progress.values() if parsed.state != "reset"
)
progress_to_reset = sum(
1 for r in rows for parsed in r.progress.values() if parsed.state == "reset"
)
return {
"rows": len(rows),
"students_to_create": len(to_create),
"students_to_update": len(to_update),
"progress_to_create": progress_to_create,
"progress_to_restore": progress_to_restore,
"progress_to_reset": progress_to_reset,
"conflicts": conflicts,
}
def run_student_import(
db: Session, rows: list[StudentRoundTripRow]
) -> dict:
"""Import all-or-nothing: siswa baru dibuat, siswa existing di-restore.
- Memvalidasi ulang conflict terhadap state DB TERBARU (mengalahkan race
preview→apply); bila ada conflict baru, seluruh import gagal tanpa write.
- Siswa baru (student_id kosong): buat user role student + access token
(digest) + progress non-not_started, seperti sebelumnya.
- Siswa existing (student_id terisi, token kosong): pertahankan user &
token lama; update display_name sesuai CSV; apply progress
non-not_started. TIDAK ada token baru yang dibuat/ditimpa.
- Sparse progress dipertahankan: status kosong/not_started tidak pernah
menjadi row database, dan progress lama yang tidak ada di CSV TIDAK
dihapus (merge, bukan snapshot penuh).
- Status 'done' di-apply via set_exercise_passed / set_quiz_score lalu
recompute_progress.
- Status 'reset' menghapus row progress untuk lesson tsb (set ke not_started).
Catatan transaksi: fungsi ini sengaja melakukan COMMIT sekali (kontrak
all-or-nothing round-trip) — pemanggil (route) TIDAK commit lagi. Ini
menyimpang dari pola umum repository yang menyerahkan commit ke pemanggil;
pengecualian ini diperlukan agar seluruh file masuk dalam satu transaksi.
"""
conflicts = find_student_import_conflicts(db, rows)
if conflicts:
raise RoundTripImportError(conflicts)
user_ids: list[str] = []
students_created = 0
students_updated = 0
progress_created = 0
progress_restored = 0
progress_reset = 0
try:
for row in rows:
if row.student_id:
user = db.get(User, row.student_id)
if user is None or user.role != "student":
# Tidak mungkin terjadi pasca-validasi conflict; jaga safety.
raise ValueError(
f"Baris {row.line}: student_id tidak dikenal atau bukan siswa"
)
user.display_name = row.display_name
students_updated += 1
else:
user = create_user(
db,
display_name=row.display_name,
role="student",
is_active=True,
user_id=row.student_id,
)
create_access_token(db, user_id=user.id, raw_token=row.raw_token)
students_created += 1
for slug, parsed in row.progress.items():
lesson = get_lesson_by_slug(db, slug)
if lesson is None:
raise ValueError(f"Baris {row.line}: lesson tidak dikenal: {slug!r}")
if parsed.state == "done":
if parsed.exercise_passed is not None:
set_exercise_passed(
db, user_id=user.id, lesson_id=lesson.id, passed=parsed.exercise_passed
)
if parsed.quiz_earned is not None and parsed.quiz_total is not None:
set_quiz_score(
db,
user_id=user.id,
lesson_id=lesson.id,
quiz_earned=parsed.quiz_earned,
quiz_total=parsed.quiz_total,
)
has_exercise, has_quiz = get_lesson_components(slug)
recompute_progress(
db,
user_id=user.id,
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,
)
elif parsed.state == "reset":
set_progress(db, user_id=user.id, lesson_id=lesson.id, state="not_started")
progress_reset += 1
continue
else:
set_progress(
db,
user_id=user.id,
lesson_id=lesson.id,
state=parsed.state,
score_earned=parsed.score_earned,
score_total=parsed.score_total,
)
if row.student_id:
progress_restored += 1
else:
progress_created += 1
user_ids.append(user.id)
db.commit()
except IntegrityError:
# Race: token/UUID bentrok dengan transaksi lain antara validasi & commit
db.rollback()
raise RoundTripImportError(
["Terjadi konflik data saat menyimpan (perubahan bersamaan) — seluruh import dibatalkan"]
) from None
except Exception:
db.rollback()
raise
return {
"students_created": students_created,
"students_updated": students_updated,
"progress_created": progress_created,
"progress_restored": progress_restored,
"progress_reset": progress_reset,
"user_ids": user_ids,
}
# ── bulk delete ────────────────────────────────────────────────────
def delete_students(db: Session, student_ids: list[str]) -> list[str]:
"""Hapus permanen siswa terpilih beserta token & progress (cascade).
- Seluruh ID divalidasi SEBELUM delete pertama: duplicate/malformed/unknown/
teacher ID membatalkan seluruh request (zero delete).
- Query wajib `User.role == 'student'`; teacher terlindungi.
- Tidak commit — transaksi dikelola pemanggil (service/route).
- Setelah commit, UUID & token target boleh dipakai ulang oleh importer.
"""
wanted = _validate_id_list(student_ids, field="student_ids")
users = list(
db.scalars(select(User).where(User.id.in_(wanted), User.role == "student"))
)
found = {u.id for u in users}
missing = [sid for sid in student_ids if sid not in found]
if missing:
raise ValueError("student_id tidak dikenal atau bukan siswa")
for user in users:
db.delete(user)
db.flush()
return [u.id for u in users]
# ── content_drafts (editor konten guru) ──────────────────────────
def get_active_draft(db: Session, *, target_path: str) -> ContentDraft | None:
return (
db.query(ContentDraft)
.filter(ContentDraft.target_path == target_path, ContentDraft.status == "draft")
.first()
)
def upsert_draft(
db: Session, *, author_id: str, target_path: str, body: str, base_mtime: float | None
) -> ContentDraft:
draft = get_active_draft(db, target_path=target_path)
if draft:
draft.body = body
draft.author_id = author_id
# base_mtime TIDAK ditimpa di sini — hanya di-set ulang lewat "reload
# from disk" eksplisit, supaya deteksi konflik publish tetap mengacu
# ke mtime saat draft pertama dibuat.
else:
draft = ContentDraft(
author_id=author_id, target_path=target_path, body=body, base_mtime=base_mtime
)
db.add(draft)
db.commit()
db.refresh(draft)
return draft
def mark_draft_published(db: Session, *, draft: ContentDraft) -> None:
draft.status = "published"
draft.published_at = datetime.now(timezone.utc)
db.commit()
def discard_draft(db: Session, *, draft_id: str, author_id: str) -> bool:
draft = db.query(ContentDraft).filter(
ContentDraft.id == draft_id, ContentDraft.author_id == author_id
).first()
if not draft:
return False
db.delete(draft)
db.commit()
return True
def retarget_drafts(db: Session, *, old_prefix: str, new_prefix: str) -> int:
"""Dipakai saat rename file/folder — pindahkan draft yang menunjuk ke path lama."""
from sqlalchemy import or_
# Escape % dan _ di prefix agar LIKE tidak men-interpret wildcard dalam
# nama folder/file (mis. folder "50%"). Lihat P2-3 di audit.
escaped = old_prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
rows = db.query(ContentDraft).filter(
ContentDraft.status == "draft",
or_(
ContentDraft.target_path == old_prefix,
ContentDraft.target_path.like(f"{escaped}/%", escape="\\"),
),
).all()
count = 0
for row in rows:
row.target_path = new_prefix + row.target_path[len(old_prefix):]
count += 1
db.commit()
return count
def drop_drafts_under(db: Session, *, path: str) -> int:
"""Dipakai saat delete file/folder — buang draft yang jadi yatim."""
from sqlalchemy import or_
# Escape % dan _ — lihat komentar di retarget_drafts (P2-3).
escaped = path.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
n = db.query(ContentDraft).filter(
ContentDraft.status == "draft",
or_(
ContentDraft.target_path == path,
ContentDraft.target_path.like(f"{escaped}/%", escape="\\"),
),
).delete(synchronize_session=False)
db.commit()
return n