from datetime import datetime, UTC from flask import Blueprint, request, jsonify from api import limiter, require_auth, is_user_admin, get_actor from contexts.RequestContext import RequestContext from db import get_db from db.models.Markdown import Markdown from db.models.MarkdownPatch import MarkdownPatch from db.models.MarkdownSetting import MarkdownSetting from db.models.MarkdownPermissionSetting import MarkdownPermissionSetting import api import logging logger = logging.getLogger(__name__) patch_bp = Blueprint('patch', __name__, url_prefix='/api/patch') def _parent_visible_to_caller(session, markdown, is_admin): """Patch cards inherit the parent markdown's visibility. Admins see all; non-admins are denied for private or protected parents (so patches can't be used to leak restricted content).""" if is_admin or markdown.setting_id is None: return True setting = session.query(MarkdownSetting).get(markdown.setting_id) if not setting or not setting.permission_setting_id: return True ps = session.query(MarkdownPermissionSetting).get(setting.permission_setting_id) if ps and ps.permission in ('private', 'protected'): return False return True @patch_bp.route('/by_markdown/', methods=['GET']) @limiter.limit(api.get_rate_limit) def get_patches(markdown_id): """List patch cards for a markdown, ordered, respecting parent visibility.""" is_admin = is_user_admin() with get_db() as session: markdown = session.query(Markdown).get(markdown_id) if markdown is None: return jsonify({"error": "markdown not found"}), 404 if not _parent_visible_to_caller(session, markdown, is_admin): return jsonify({"error": "permission denied"}), 403 patches = ( session.query(MarkdownPatch) .filter(MarkdownPatch.markdown_id == markdown_id) .order_by(MarkdownPatch.order.asc(), MarkdownPatch.id.asc()) .all() ) return jsonify([p.to_dict() for p in patches]), 200 @patch_bp.route('/', methods=['POST']) @require_auth(roles=['admin', 'creator']) @limiter.limit(api.get_rate_limit) def create_patch(): """Create a patch card. Body: markdown_id, content, title?, order?""" data = request.get_json(silent=True) if not data: return jsonify({"error": "invalid or missing JSON body"}), 400 markdown_id = data.get('markdown_id') content = data.get('content') if not markdown_id or content is None or content == "": return jsonify({"error": "missing required fields"}), 400 with get_db() as session: if session.query(Markdown).get(markdown_id) is None: return jsonify({"error": "markdown not found"}), 404 try: actor = get_actor() now = datetime.now(UTC) patch = MarkdownPatch( markdown_id=markdown_id, title=data.get('title'), content=content, order=data.get('order', 0), author=actor, last_modified_by=actor, created_at=now, updated_at=now, ) session.add(patch) session.commit() return jsonify(patch.to_dict()), 201 except Exception as e: logger.error(f"failed to create patch: {e}") errno = RequestContext.get_error_id() session.rollback() return jsonify({"error": f"create failed - {errno}"}), 500 @patch_bp.route('/', methods=['PUT', 'PATCH']) @require_auth(roles=['admin', 'creator']) @limiter.limit(api.get_rate_limit) def update_patch(patch_id): """Update a patch card (title/content/order).""" data = request.get_json(silent=True) if not data: return jsonify({"error": "invalid or missing JSON body"}), 400 with get_db() as session: patch = session.query(MarkdownPatch).get(patch_id) if patch is None: return jsonify({"error": "patch not found"}), 404 if request.method == "PUT": patch.title = data.get('title') patch.content = data.get('content') patch.order = data.get('order', 0) else: if 'title' in data: patch.title = data.get('title') if 'content' in data: patch.content = data.get('content') if 'order' in data: patch.order = data.get('order') patch.updated_at = datetime.now(UTC) patch.last_modified_by = get_actor() session.commit() return jsonify(patch.to_dict()), 200 @patch_bp.route('/', methods=['DELETE']) @require_auth(roles=['admin']) @limiter.limit(api.get_rate_limit) def delete_patch(patch_id): """Delete a patch card.""" with get_db() as session: patch = session.query(MarkdownPatch).get(patch_id) if patch is None: return jsonify({"error": "patch not found"}), 404 session.delete(patch) session.commit() return jsonify({"message": "patch deleted"}), 200