feat: GET /content/export — download zip content+assets

This commit is contained in:
a2nr 2026-10-01 11:49:27 +07:00
parent ba1e7240f3
commit 67f1501b4e
2 changed files with 87 additions and 1 deletions

View File

@ -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()

View File

@ -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)