From 67f1501b4ea28a6d2cad0735d0353713259188b4 Mon Sep 17 00:00:00 2001 From: a2nr Date: Thu, 1 Oct 2026 11:49:27 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20GET=20/content/export=20=E2=80=94=20dow?= =?UTF-8?q?nload=20zip=20content+assets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- routes/content_editor.py | 52 +++++++++++++++++++- services/tests/test_content_editor_routes.py | 36 ++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/routes/content_editor.py b/routes/content_editor.py index a31af23..bddff71 100644 --- a/routes/content_editor.py +++ b/routes/content_editor.py @@ -25,8 +25,9 @@ import io import os import re as _re import shutil +import zipfile -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, send_file from config import ASSETS_DIR, CONTENT_DIR from services import repositories @@ -522,3 +523,52 @@ def upload_asset(): return jsonify({"success": True, "path": rel_path, "url": f"/assets/{rel_path}"}) finally: db.close() + + +# ── Bundle export (import dilakukan client-side per-file) ──────── + +def _add_dir_to_zip(zf: zipfile.ZipFile, dir_abs: str, prefix: str) -> int: + """Tambahkan semua file di dir_abs ke zip dengan prefix folder. + Ditulis per-file (zf.write) — tidak ada batasan ukuran total.""" + count = 0 + for base, _dirs, files in os.walk(dir_abs): + for fname in sorted(files): + abs_path = os.path.join(base, fname) + rel_inside = os.path.relpath(abs_path, dir_abs) + zf.write(abs_path, arcname=f"{prefix}/{rel_inside}") + count += 1 + return count + + +@content_editor_bp.route("/export", methods=["GET"]) +def export_bundle(): + db = SessionLocal() + try: + user, err = _require_teacher(db) + if err: + return err + scope = request.args.get("scope", "all") + if scope not in ("all", "content", "assets"): + return jsonify({"success": False, "message": "scope tidak valid (all|content|assets)"}), 400 + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + file_count = 0 + if scope in ("all", "content"): + file_count += _add_dir_to_zip(zf, _CONTENT_DIR_ABS, "content") + if scope in ("all", "assets"): + file_count += _add_dir_to_zip(zf, _ASSETS_DIR_ABS, "assets") + if file_count == 0: + return jsonify({"success": False, "message": "Tidak ada file untuk diekspor"}), 404 + + from datetime import datetime + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + buf.seek(0) + return send_file( + buf, + mimetype="application/zip", + as_attachment=True, + download_name=f"elemes-bundle-{scope}-{stamp}.zip", + ) + finally: + db.close() diff --git a/services/tests/test_content_editor_routes.py b/services/tests/test_content_editor_routes.py index 5ae10c3..89fec25 100644 --- a/services/tests/test_content_editor_routes.py +++ b/services/tests/test_content_editor_routes.py @@ -10,10 +10,12 @@ Mengunci kontrak: - regression: _render_markdown_string() == render_markdown_content() untuk file yang sama; """ +import io import os import pathlib import shutil import time +import zipfile import pytest @@ -634,3 +636,37 @@ def test_save_draft_stores_correct_author_id(client): assert draft.author_id == teacher.id finally: db.close() + + +# ── Export bundle ────────────────────────────────────────────────── + +def test_export_requires_teacher(client): + _seed_and_setup() + resp = client.get("/content/export") + assert resp.status_code == 401 + + +def test_export_returns_zip_with_content_and_assets(client): + _seed_and_setup() + _login_teacher(client) + (TEST_ASSETS / "diag.png").write_bytes(b"fake-png") + resp = client.get("/content/export") + assert resp.status_code == 200 + assert "application/zip" in resp.headers["Content-Type"] + assert resp.headers["Content-Disposition"].startswith("attachment; filename=") + zf = zipfile.ZipFile(io.BytesIO(resp.data)) + names = set(zf.namelist()) + assert "content/test_editor/hello.md" in names + assert "content/home.md" in names + assert "assets/diag.png" in names + + +def test_export_scope_content_excludes_assets(client): + _seed_and_setup() + _login_teacher(client) + (TEST_ASSETS / "diag.png").write_bytes(b"fake-png") + resp = client.get("/content/export?scope=content") + assert resp.status_code == 200 + names = zipfile.ZipFile(io.BytesIO(resp.data)).namelist() + assert any(n.startswith("content/") for n in names) + assert not any(n.startswith("assets/") for n in names)