Guides: procedures written and read inside ShopDB
This commit is contained in:
3
plugins/guides/api/__init__.py
Normal file
3
plugins/guides/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .routes import guides_bp
|
||||
|
||||
__all__ = ['guides_bp']
|
||||
442
plugins/guides/api/routes.py
Normal file
442
plugins/guides/api/routes.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""Guides API.
|
||||
|
||||
TWO WAYS TO READ A GUIDE, on purpose:
|
||||
|
||||
GET /api/guides/<slug> what was PUBLISHED
|
||||
GET /api/guides/<id>/working the working copy, for editors
|
||||
|
||||
A reader following a procedure must not have it change under them because
|
||||
somebody upstairs is editing. The published pointer is what makes that true, and
|
||||
serving the working copy from the reader's route would quietly undo it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import Guide, GuideRevision, GuideImage
|
||||
from ..services import authoring
|
||||
from ..services.blocks import BlockError, checklistitemcount
|
||||
|
||||
guides_bp = Blueprint('guides', __name__)
|
||||
|
||||
|
||||
# Screenshots of a settings dialog, not photographs. Generous for the former and
|
||||
# small enough that a pasted phone picture is refused rather than silently
|
||||
# making a guide take ten seconds to open on a shopfloor PC.
|
||||
MAXIMAGEBYTES = 8 * 1024 * 1024
|
||||
ALLOWEDIMAGETYPES = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
|
||||
|
||||
def _userid():
|
||||
if current_user is not None:
|
||||
return current_user.userid
|
||||
try:
|
||||
return int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _has(permission):
|
||||
"""Whether the caller holds a permission, without raising.
|
||||
|
||||
require_permission decorates a route; this is for the in-route decisions -
|
||||
may this person see drafts, may they edit this particular guide. Same check
|
||||
the decorator makes, including the admin bypass that lives inside
|
||||
haspermission.
|
||||
"""
|
||||
return bool(current_user is not None
|
||||
and current_user.haspermission(permission))
|
||||
|
||||
|
||||
def _mayedit(guide):
|
||||
"""Edit rights for one guide.
|
||||
|
||||
guides.edit covers everything. Otherwise an author may edit their OWN guide
|
||||
while it is unpublished - which is what makes drafting useful without
|
||||
handing everyone rights over published procedures. Once it is published it
|
||||
takes guides.edit, because by then other people are following it.
|
||||
"""
|
||||
if _has('guides.edit'):
|
||||
return True
|
||||
if not _has('guides.create'):
|
||||
return False
|
||||
return guide.createdby == _userid() and not guide.publishedrevisionid
|
||||
|
||||
|
||||
def _slugify(title, guideid=None):
|
||||
"""A readable, stable, unique slug.
|
||||
|
||||
Stored rather than derived on read, so renaming a guide cannot break a link
|
||||
somebody pasted into a work order months ago.
|
||||
"""
|
||||
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
|
||||
base = base[:180] # room for a '-2' style suffix inside the 191 cap
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while True:
|
||||
clash = Guide.query.filter(Guide.slug == candidate)
|
||||
if guideid:
|
||||
clash = clash.filter(Guide.guideid != guideid)
|
||||
if not clash.first():
|
||||
return candidate
|
||||
candidate = '{}-{}'.format(base, suffix)
|
||||
suffix += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reading ---
|
||||
|
||||
@guides_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_guides():
|
||||
"""Published guides, plus the caller's own drafts if they can author.
|
||||
|
||||
A half-written procedure on a shop floor is worse than no procedure, so
|
||||
drafts are invisible to readers - but an author must be able to find their
|
||||
own, or drafting is a trap door.
|
||||
"""
|
||||
query = Guide.query
|
||||
|
||||
search = (request.args.get('q') or '').strip()
|
||||
if search:
|
||||
like = '%{}%'.format(search)
|
||||
query = query.filter(db.or_(
|
||||
Guide.title.ilike(like),
|
||||
Guide.summary.ilike(like),
|
||||
Guide.tags.ilike(like),
|
||||
# The body too: people look for "the one about the drive letter",
|
||||
# not for a title they never read.
|
||||
Guide.blocksjson.ilike(like),
|
||||
))
|
||||
|
||||
tag = (request.args.get('tag') or '').strip()
|
||||
if tag:
|
||||
query = query.filter(Guide.tags.ilike('%{}%'.format(tag)))
|
||||
|
||||
caneditall = _has('guides.edit')
|
||||
if not caneditall:
|
||||
mine = _userid()
|
||||
query = query.filter(db.or_(
|
||||
Guide.publishedrevisionid.isnot(None),
|
||||
Guide.createdby == mine,
|
||||
))
|
||||
|
||||
rows = query.order_by(Guide.modifieddate.desc()).all()
|
||||
|
||||
out = []
|
||||
for guide in rows:
|
||||
data = guide.to_dict()
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['checklistitems'] = checklistitemcount(guide.blocks)
|
||||
data['canedit'] = _mayedit(guide)
|
||||
out.append(data)
|
||||
return success_response(out)
|
||||
|
||||
|
||||
@guides_bp.route('/tags', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_tags():
|
||||
"""Every tag in use, with a count, for the filter chips."""
|
||||
counts = {}
|
||||
for (tags,) in db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all():
|
||||
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return success_response([
|
||||
{'tag': tag, 'count': count}
|
||||
for tag, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0].lower()))
|
||||
])
|
||||
|
||||
|
||||
@guides_bp.route('/<slug>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_guide(slug):
|
||||
"""What a reader gets: the published revision.
|
||||
|
||||
An unpublished guide is 404 to anyone who cannot edit it - not 403, which
|
||||
would confirm it exists.
|
||||
"""
|
||||
guide = Guide.query.filter_by(slug=slug).first()
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
|
||||
revision = authoring.readable(guide)
|
||||
if revision is None:
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found',
|
||||
http_code=404)
|
||||
# An editor previewing their own unpublished draft.
|
||||
data = guide.to_dict(includeblocks=True)
|
||||
data['ispublished'] = False
|
||||
data['isdraftpreview'] = True
|
||||
data['canedit'] = True
|
||||
return success_response(data)
|
||||
|
||||
data = guide.to_dict()
|
||||
data.update({
|
||||
'title': revision.title,
|
||||
'summary': revision.summary,
|
||||
'tags': [t.strip() for t in (revision.tags or '').split(',') if t.strip()],
|
||||
'blocks': revision.blocks,
|
||||
'revision': revision.revision,
|
||||
'ispublished': True,
|
||||
'isdraftpreview': False,
|
||||
'canedit': _mayedit(guide),
|
||||
# So an editor can see there is newer unpublished work without leaving
|
||||
# the page.
|
||||
'hasunpublishedchanges': guide.revision != revision.revision,
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_working(guideid):
|
||||
"""The working copy, for the editor."""
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
data = guide.to_dict(includeblocks=True)
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['canedit'] = True
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_revisions(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
rows = (GuideRevision.query.filter_by(guideid=guideid)
|
||||
.order_by(GuideRevision.revision.desc()).all())
|
||||
published = guide.publishedrevisionid
|
||||
return success_response([
|
||||
dict(row.to_dict(), ispublished=(row.guiderevisionid == published))
|
||||
for row in rows
|
||||
])
|
||||
|
||||
|
||||
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_revision(revisionid):
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if revision is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
return success_response(revision.to_dict(includeblocks=True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- writing ---
|
||||
|
||||
@guides_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def create_guide():
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = (data.get('title') or '').strip()
|
||||
if not title:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
|
||||
|
||||
guide = Guide(title=title, slug=_slugify(title), status='draft',
|
||||
createdby=_userid(), updatedby=_userid())
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
|
||||
try:
|
||||
authoring.save(guide,
|
||||
title=title,
|
||||
summary=data.get('summary', ''),
|
||||
tags=data.get('tags', ''),
|
||||
blocks=data.get('blocks', []),
|
||||
note=data.get('note'),
|
||||
userid=_userid())
|
||||
except BlockError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Guide created')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def update_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'title' in data and not (data.get('title') or '').strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
|
||||
|
||||
try:
|
||||
guide, revision = authoring.save(
|
||||
guide,
|
||||
title=data.get('title'),
|
||||
summary=data.get('summary'),
|
||||
tags=data.get('tags'),
|
||||
blocks=data.get('blocks'),
|
||||
note=data.get('note'),
|
||||
userid=_userid())
|
||||
except BlockError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
if 'title' in data:
|
||||
guide.slug = _slugify(guide.title, guide.guideid)
|
||||
|
||||
db.session.commit()
|
||||
result = guide.to_dict(includeblocks=True)
|
||||
# 'unchanged' is a real outcome, not a failure: an autosave where the author
|
||||
# changed nothing must not manufacture a version.
|
||||
result['saved'] = revision is not None
|
||||
return success_response(result,
|
||||
message='Saved' if revision else 'No changes to save')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def publish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
try:
|
||||
revision = authoring.publish(guide, userid=_userid())
|
||||
except ValueError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(),
|
||||
message='Published version {}'.format(revision.revision))
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def unpublish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
authoring.unpublish(guide, userid=_userid())
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(), message='Withdrawn from readers')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def restore_revision(guideid, revisionid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if guide is None or revision is None or revision.guideid != guideid:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
authoring.restore(guide, revision, userid=_userid())
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Restored version {} as a new draft version'
|
||||
.format(revision.revision))
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.delete')
|
||||
def delete_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
db.session.delete(guide)
|
||||
db.session.commit()
|
||||
return success_response({'guideid': guideid}, message='Guide deleted')
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- images ---
|
||||
|
||||
@guides_bp.route('/images', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def upload_image():
|
||||
"""Take a pasted or dropped image.
|
||||
|
||||
Unattached at first: an image is pasted into a NEW guide before that guide
|
||||
has an id. The editor attaches it on save; anything never attached is
|
||||
orphaned and prunable, which is a tidier problem than refusing to accept a
|
||||
paste until the author has saved.
|
||||
"""
|
||||
upload = request.files.get('file')
|
||||
if upload is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
|
||||
mimetype = (upload.mimetype or '').lower()
|
||||
if mimetype not in ALLOWEDIMAGETYPES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unsupported image type {!r}. Allowed: {}'.format(
|
||||
mimetype, ', '.join(sorted(ALLOWEDIMAGETYPES))))
|
||||
|
||||
data = upload.read()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'That file is empty')
|
||||
if len(data) > MAXIMAGEBYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That image is {:.1f} MB; the limit is {} MB'.format(
|
||||
len(data) / 1048576, MAXIMAGEBYTES // 1048576))
|
||||
|
||||
guideid = request.form.get('guideid', type=int)
|
||||
image = GuideImage(guideid=guideid, filename=upload.filename,
|
||||
mimetype=mimetype, bytesize=len(data), data=data,
|
||||
uploadedby=_userid())
|
||||
db.session.add(image)
|
||||
db.session.commit()
|
||||
return success_response(image.to_dict(), message='Image uploaded')
|
||||
|
||||
|
||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def serve_image(imageid):
|
||||
image = db.session.get(GuideImage, imageid)
|
||||
if image is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
return Response(image.data, mimetype=image.mimetype, headers={
|
||||
# Immutable: an image is never edited in place, only replaced by a new
|
||||
# upload with a new id, so it can be cached hard.
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Disposition': 'inline; filename="{}"'.format(
|
||||
image.filename or 'image{}'.format(imageid)),
|
||||
})
|
||||
Reference in New Issue
Block a user