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) <noreply@anthropic.com>
This commit is contained in:
122
api/patch.py
Normal file
122
api/patch.py
Normal file
@@ -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/<int:markdown_id>', 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('/<int:patch_id>', 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('/<int:patch_id>', 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
|
||||||
37
db/models/MarkdownPatch.py
Normal file
37
db/models/MarkdownPatch.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user