The knowledgebase plugin is a link registry - a description, a URL and some keywords pointing at a document held somewhere else. That leaves the documents themselves as HTML files on a share: no search, no history, no way to know whether the copy pinned to a noticeboard is current, and no way for a technician to tick off where they are. A guide is stored as an ordered list of typed blocks rather than a blob of HTML, because three of the four things this feature exists for depend on it. A tickable step needs a stable id to be remembered; dark mode needs semantic markup rather than pasted colours; and print needs to know where a step begins so it does not split one across a page. Free HTML gives none of that and lets pasted styling fight the theme. Inline text is a restricted Markdown subset - bold, italic, inline code, links, line breaks - escaped BEFORE formatting, so nothing an author pasted can reach the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the theme decides how it looks. That is what makes a guide written next year match one written today, and dark mode work without an author thinking about it. DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes something writes an immutable revision; and a separate pointer says which revision READERS get. Without that pointer, editing a live procedure changes it under a technician who is halfway through following it. Publishing promotes an existing revision rather than snapshotting, so a publish with unsaved edits cannot ship a version nobody reviewed. Restoring writes a new revision with old content, so the record of what people were told in between survives. Saves dedup on a content hash covering title, summary, tags and blocks, so an autosave that changed nothing writes no version - otherwise the history fills with identical entries and hides the three edits that mattered. Progress lives in the browser, not the database: shopfloor PCs share a login, so per-user ticks would show one technician's progress to the next person on that bay, which reads as "already done". Keyed by guide and version, because step 4 of version 3 is not step 4 of version 2. Five permissions, and the split between edit and publish is the point: a technician can write a procedure up while somebody else decides it is fit for the floor. create-without-edit works too - an author may edit their own guide until it is published, after which other people are following it.
443 lines
16 KiB
Python
443 lines
16 KiB
Python
"""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[:200]
|
|
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.updatedat.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)),
|
|
})
|