Files
HangmanLab.Backend/api/patch.py
hzhang bf4c0dbbbd feat: apikey alias/renewal + markdown/patch authorship
- APIKey.alias (unique, required). Creating with an existing alias
  renews that key: same key string kept, validity reset to 15d,
  reactivated, name/roles updated (response has renewed=true).
- get_actor(): X-API-Key -> key alias, Bearer -> 'admin'.
- markdown & patch create/update record author / created_at /
  updated_at / last_modified_by from the actor.
- Idempotent run_migrations() (information_schema-guarded ALTERs +
  backfill) so existing tables/data gain the new columns on startup;
  create_all still covers fresh DBs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:51:40 +01:00

132 lines
5.0 KiB
Python

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/<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:
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('/<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')
patch.updated_at = datetime.now(UTC)
patch.last_modified_by = get_actor()
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