From 155aa897c6eb1984b973e2d33aa988e92b64488f Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 16 May 2026 17:28:04 +0100 Subject: [PATCH] feat: markdown patch cards (model + API) Add MarkdownPatch model (markdown_patch table, auto-created by create_all) and /api/patch blueprint: list patches for a markdown (inherits the parent's private/protected visibility), create/update (admin|creator), delete (admin). Co-Authored-By: Claude Opus 4.7 (1M context) --- api/patch.py | 122 +++++++++++++++++++++++++++++++++++++ db/models/MarkdownPatch.py | 37 +++++++++++ 2 files changed, 159 insertions(+) create mode 100644 api/patch.py create mode 100644 db/models/MarkdownPatch.py diff --git a/api/patch.py b/api/patch.py new file mode 100644 index 0000000..0db3dd5 --- /dev/null +++ b/api/patch.py @@ -0,0 +1,122 @@ +from flask import Blueprint, request, jsonify +from api import limiter, require_auth, is_user_admin +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: + patch = MarkdownPatch( + markdown_id=markdown_id, + title=data.get('title'), + content=content, + order=data.get('order', 0), + ) + 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') + 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 diff --git a/db/models/MarkdownPatch.py b/db/models/MarkdownPatch.py new file mode 100644 index 0000000..1184dd4 --- /dev/null +++ b/db/models/MarkdownPatch.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Text, Integer, String, DateTime, ForeignKey +from db.models import Base +import datetime + + +class MarkdownPatch(Base): + """A markdown 'patch card' attached to a parent markdown document. + + Its content is plain markdown text rendered below the parent's body. + Created/updated by admin|creator, deleted by admin; visibility is + inherited from the parent markdown (enforced in the API layer). + """ + __tablename__ = 'markdown_patch' + id = Column(Integer, primary_key=True) + markdown_id = Column( + Integer, ForeignKey('markdown.id'), nullable=False, index=True + ) + title = Column(String(255), nullable=True) + content = Column(Text, nullable=False) + order = Column(Integer, default=0) + created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC)) + updated_at = Column( + DateTime, + default=lambda: datetime.datetime.now(datetime.UTC), + onupdate=lambda: datetime.datetime.now(datetime.UTC), + ) + + def to_dict(self): + return { + 'id': self.id, + 'markdown_id': self.markdown_id, + 'title': self.title, + 'content': self.content, + 'order': self.order, + 'created_at': self.created_at, + 'updated_at': self.updated_at, + }