Files
shopdb-flask/plugins/guides/api/routes.py
cproudlock cc1f8ff9fd Print a guide light, whatever the screen theme is
Printing a dark-mode page lays a full page of toner down for the background and
knocks the text out of it in white. On a shopfloor printer that is a cartridge
for a handful of guides, and the result is harder to read than the plain
version. Somebody would have found this the expensive way.

The theme tokens are redefined for print only, in an unscoped block because
overriding them has to reach :root. Every guide element already renders through
those variables rather than literal colours, so this is a dozen lines instead of
a rewrite - the same discipline that made dark mode work without dark-mode code.

Backgrounds are dropped rather than printed grey: a callout is identified by its
rule and its heading, which cost nothing. Checkboxes print EMPTY even where they
are ticked on screen, because a printed guide is a worksheet somebody carries to
the bay, and one person's progress is not part of the procedure. The footer
gains the print date beside the version, so a stale copy pinned to a noticeboard
can be spotted.

Also corrects the model and migration found by running this against a real
database rather than SQLite: BaseModel contributes createddate, modifieddate and
isactive to every table and the migration never created them, so the first read
failed on unknown columns; the model had also declared duplicate timestamps on
top, now removed with the API field names kept stable. And the slug is 191
characters, not 300 - it is UNIQUE-indexed, MySQL caps an index key at 767
bytes, and utf8mb4 is 4 bytes per character.
2026-08-26 11:07:06 -04:00

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[: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)),
})