diff --git a/plugins/guides/__init__.py b/plugins/guides/__init__.py new file mode 100644 index 00000000..e14bbc43 --- /dev/null +++ b/plugins/guides/__init__.py @@ -0,0 +1,3 @@ +from .plugin import GuidesPlugin + +__all__ = ['GuidesPlugin'] diff --git a/plugins/guides/api/__init__.py b/plugins/guides/api/__init__.py new file mode 100644 index 00000000..422e0a08 --- /dev/null +++ b/plugins/guides/api/__init__.py @@ -0,0 +1,3 @@ +from .routes import guides_bp + +__all__ = ['guides_bp'] diff --git a/plugins/guides/api/routes.py b/plugins/guides/api/routes.py new file mode 100644 index 00000000..293af01b --- /dev/null +++ b/plugins/guides/api/routes.py @@ -0,0 +1,442 @@ +"""Guides API. + +TWO WAYS TO READ A GUIDE, on purpose: + + GET /api/guides/ what was PUBLISHED + GET /api/guides//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('/', 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('//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('//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/', 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('/', 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('//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('//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('//restore/', 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('/', 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/', 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)), + }) diff --git a/plugins/guides/frontend/components/GuideBlocks.vue b/plugins/guides/frontend/components/GuideBlocks.vue new file mode 100644 index 00000000..941a1457 --- /dev/null +++ b/plugins/guides/frontend/components/GuideBlocks.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/plugins/guides/frontend/routes.js b/plugins/guides/frontend/routes.js new file mode 100644 index 00000000..5958a0e3 --- /dev/null +++ b/plugins/guides/frontend/routes.js @@ -0,0 +1,42 @@ +/** + * Guides plugin routes. + * + * Three views, and the split between the last two is deliberate: reading a + * guide and editing one are different jobs with different rights, and the + * checkboxes are live in exactly one of them. + * + * meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend + * plugin is disabled. Every route requires auth because every guides API route + * is behind JWT plus a guides.* permission. + */ +export default [ + { + path: 'guides', + name: 'guides', + component: () => import('./views/GuidesList.vue'), + meta: { requiresAuth: true, plugin: 'guides' } + }, + { + path: 'guides/new', + name: 'guide-new', + component: () => import('./views/GuideEditor.vue'), + meta: { requiresAuth: true, plugin: 'guides' } + }, + { + path: 'guides/:slug', + name: 'guide-view', + component: () => import('./views/GuideView.vue'), + meta: { requiresAuth: true, plugin: 'guides' } + }, + { + path: 'guides/:guideid/edit', + name: 'guide-edit', + component: () => import('./views/GuideEditor.vue'), + meta: { requiresAuth: true, plugin: 'guides' } + } +] + +// No full-screen routes: every view renders inside AppLayout. Declared so +// routes.gen.js can read `.toplevel` off the module without Rollup warning +// about a missing export. +export const toplevel = [] diff --git a/plugins/guides/frontend/utils/inline.js b/plugins/guides/frontend/utils/inline.js new file mode 100644 index 00000000..b2ce2356 --- /dev/null +++ b/plugins/guides/frontend/utils/inline.js @@ -0,0 +1,82 @@ +/** + * Inline text rendering for guide blocks. + * + * A guide stores its text as a RESTRICTED Markdown subset - bold, italic, + * inline code, links, line breaks - and nothing else. This turns that into + * HTML. + * + * ESCAPE FIRST, THEN FORMAT. The order is the whole security model: every + * angle bracket and ampersand becomes an entity before any pattern is applied, + * so no markup an author pasted can survive into the DOM. There is no path here + * that emits a tag the code below did not write itself. + * + * The restriction is not only about safety. Pasted rich text carries fonts and + * hardcoded colours, which then fight the theme - black text on a dark + * background, and nobody can say why. Emphasis is semantic; the stylesheet + * decides how it looks. That is what makes dark mode work without an author + * thinking about it, and why a guide written next year still matches one + * written today. + */ + +function escapeHtml (text) { + return String(text == null ? '' : text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +// Only http(s) and mailto. A javascript: or data: URL in a link is the one way +// an author could otherwise smuggle script past the escaping above. +function safeHref (url) { + const trimmed = String(url || '').trim() + if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed + if (trimmed.startsWith('/')) return trimmed // in-app link + return null +} + +export function renderInline (text) { + let html = escapeHtml(text) + + // Inline code first, so formatting characters inside `code` stay literal - + // a path like C:\**temp** should not turn half of itself bold. + const codeSpans = [] + html = html.replace(/`([^`]+)`/g, (match, code) => { + codeSpans.push(code) + return '\u0000CODE' + (codeSpans.length - 1) + '\u0000' + }) + + // [label](url). The URL pattern allows ONE level of balanced parentheses so + // real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case, + // and a naive [^)]+ silently truncates it into a broken link. + html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => { + const href = safeHref(url) + if (!href) return label + const external = /^https?:/i.test(href) + return '' + label + '' + }) + + // **bold** then *italic*, longest marker first so **x** is not read as + // *(*x*)*. + html = html.replace(/\*\*([^*]+)\*\*/g, '$1') + html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1$2') + + html = html.replace(/\n/g, '
') + + html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) => + '' + codeSpans[Number(index)] + '') + + return html +} + +/** Plain text, for titles and anywhere markup would be noise. */ +export function stripInline (text) { + return String(text == null ? '' : text) + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') +} diff --git a/plugins/guides/frontend/views/GuideEditor.vue b/plugins/guides/frontend/views/GuideEditor.vue new file mode 100644 index 00000000..220c6a58 --- /dev/null +++ b/plugins/guides/frontend/views/GuideEditor.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/plugins/guides/frontend/views/GuideView.vue b/plugins/guides/frontend/views/GuideView.vue new file mode 100644 index 00000000..fb2e61c0 --- /dev/null +++ b/plugins/guides/frontend/views/GuideView.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/plugins/guides/frontend/views/GuidesList.vue b/plugins/guides/frontend/views/GuidesList.vue new file mode 100644 index 00000000..052d9eea --- /dev/null +++ b/plugins/guides/frontend/views/GuidesList.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/plugins/guides/manifest.json b/plugins/guides/manifest.json new file mode 100644 index 00000000..d9234e98 --- /dev/null +++ b/plugins/guides/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "guides", + "display_name": "Guides", + "version": "1.0.0", + "description": "Step-by-step guides written and read inside ShopDB, with images, working checklists and printable output", + "author": "ShopDB Team", + "dependencies": [], + "core_version": ">=0.1.0,<1.0.0", + "api_prefix": "/api/guides", + "provides": { + "features": ["guides"] + } +} diff --git a/plugins/guides/migrations/env.py b/plugins/guides/migrations/env.py new file mode 100644 index 00000000..f3de904d --- /dev/null +++ b/plugins/guides/migrations/env.py @@ -0,0 +1,16 @@ +"""Alembic environment for the guides plugin migration chain. + +Delegates to the shared runner in shopdb.plugins.alembic_template, which filters +the metadata to this plugin's tables and drives Alembic against the per-plugin +version table alembic_version_guides. See ADR-008 for the ownership model. + +Like the backups plugin and unlike the cutover ones, guides is NEW: its 0001 +baseline really CREATES its tables, because the core chain never built them. +""" +import os + +os.environ['PLUGIN_NAME'] = 'guides' + +from shopdb.plugins.alembic_template import run_migrations # noqa: E402 + +run_migrations() diff --git a/plugins/guides/migrations/script.py.mako b/plugins/guides/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/plugins/guides/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/plugins/guides/migrations/versions/0001_guides_baseline.py b/plugins/guides/migrations/versions/0001_guides_baseline.py new file mode 100644 index 00000000..2ebd51f1 --- /dev/null +++ b/plugins/guides/migrations/versions/0001_guides_baseline.py @@ -0,0 +1,98 @@ +"""guides plugin baseline: guides, guiderevisions, guideimages. + +A new plugin, so this builds its tables rather than stamping an anchor over +core-owned ones (contrast the cutover plugins, whose 0001 is a no-op). + +Idempotent: each table is created only if it is not already there, so a database +where an earlier run half-succeeded can be upgraded again without hand-editing +alembic_version_guides. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +revision = 'guides0001baseline' +down_revision = None +branch_labels = None +depends_on = None + + +def _tables(): + return set(inspect(op.get_bind()).get_table_names()) + + +def upgrade(): + existing = _tables() + + if 'guides' not in existing: + op.create_table( + 'guides', + sa.Column('guideid', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('slug', sa.String(length=300), nullable=False), + sa.Column('summary', sa.String(length=1000)), + sa.Column('tags', sa.String(length=500)), + sa.Column('status', sa.String(length=20), nullable=False, + server_default='draft'), + sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT() + if op.get_bind().dialect.name == 'mysql' else sa.Text(), + nullable=False), + sa.Column('publishedrevisionid', sa.Integer()), + sa.Column('createdby', sa.Integer()), + sa.Column('updatedby', sa.Integer()), + sa.Column('createdat', sa.DateTime(), nullable=False), + sa.Column('updatedat', sa.DateTime(), nullable=False), + sa.Column('publishedat', sa.DateTime()), + sa.Column('revision', sa.Integer(), nullable=False, server_default='1'), + sa.PrimaryKeyConstraint('guideid'), + sa.UniqueConstraint('slug', name='uq_guides_slug'), + ) + op.create_index('idx_guides_status', 'guides', ['status']) + + if 'guiderevisions' not in existing: + op.create_table( + 'guiderevisions', + sa.Column('guiderevisionid', sa.Integer(), nullable=False), + sa.Column('guideid', sa.Integer(), nullable=False), + sa.Column('revision', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=300), nullable=False), + sa.Column('summary', sa.String(length=1000)), + sa.Column('tags', sa.String(length=500)), + sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT() + if op.get_bind().dialect.name == 'mysql' else sa.Text(), + nullable=False), + sa.Column('contenthash', sa.String(length=64), nullable=False), + sa.Column('note', sa.String(length=500)), + sa.Column('createdby', sa.Integer()), + sa.Column('createdat', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('guiderevisionid'), + sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'], + ondelete='CASCADE'), + ) + op.create_index('idx_guiderevisions_guide', 'guiderevisions', ['guideid']) + op.create_index('idx_guiderevisions_hash', 'guiderevisions', ['contenthash']) + + if 'guideimages' not in existing: + op.create_table( + 'guideimages', + sa.Column('guideimageid', sa.Integer(), nullable=False), + sa.Column('guideid', sa.Integer()), + sa.Column('filename', sa.String(length=255)), + sa.Column('mimetype', sa.String(length=100), nullable=False), + sa.Column('bytesize', sa.Integer(), nullable=False), + sa.Column('data', sa.dialects.mysql.MEDIUMBLOB() + if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(), + nullable=False), + sa.Column('uploadedby', sa.Integer()), + sa.Column('createdat', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('guideimageid'), + sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'], + ondelete='CASCADE'), + ) + op.create_index('idx_guideimages_guide', 'guideimages', ['guideid']) + + +def downgrade(): + for table in ('guideimages', 'guiderevisions', 'guides'): + op.drop_table(table) diff --git a/plugins/guides/models/__init__.py b/plugins/guides/models/__init__.py new file mode 100644 index 00000000..02a44397 --- /dev/null +++ b/plugins/guides/models/__init__.py @@ -0,0 +1,3 @@ +from .guide import Guide, GuideRevision, GuideImage, BLOCKTYPES + +__all__ = ['Guide', 'GuideRevision', 'GuideImage', 'BLOCKTYPES'] diff --git a/plugins/guides/models/guide.py b/plugins/guides/models/guide.py new file mode 100644 index 00000000..6cbda923 --- /dev/null +++ b/plugins/guides/models/guide.py @@ -0,0 +1,282 @@ +"""Guides: step-by-step procedures written and read inside ShopDB. + +WHY THIS IS NOT THE KNOWLEDGE BASE PLUGIN + + The knowledgebase plugin is a LINK REGISTRY - a short description, a URL and + some keywords, pointing at a document that lives somewhere else. That is a + different job. This holds the document itself. + +WHY BLOCKS RATHER THAN A BLOB OF HTML + + A guide is stored as an ordered list of typed blocks, each with a stable id, + serialized as JSON. Free-form HTML would be easier to write and would defeat + three of the four things this feature exists for: + + checklists a tickable step has to be identifiable to be remembered, and + an buried in pasted markup has no identity + dark mode pasted content carries hardcoded colours, which then fight the + theme - black text on a dark background, and nobody can say + why + printing print has to know where a step begins and ends to avoid + splitting one across a page + + Blocks also mean a new block type later is additive: old guides keep + rendering, because the renderer skips what it does not know. + +INLINE TEXT IS A RESTRICTED MARKDOWN SUBSET - bold, italic, inline code, links, +line breaks, and nothing else. No fonts, sizes or colours: emphasis is semantic +and the theme decides how it looks. That single rule is what keeps a guide +written next year looking like one written today, and what makes dark mode work +without an author thinking about it. +""" + +import json +from datetime import datetime + +from shopdb.api import db +from shopdb.core.models.base import BaseModel + + +def _utcnow(): + return datetime.utcnow() + + +# Block types the renderer knows. Stored as data, not as markup, so the same +# guide can render to screen, to dark mode and to paper differently. +BLOCKTYPES = ( + 'heading', + 'paragraph', + 'step', # a numbered step, with its own body + 'checklist', # tickable items - the reason progress is a feature + 'bullets', + 'numbers', + 'callout', # info / warning / success, per the KB house style + 'code', + 'image', + 'table', +) + + +class Guide(BaseModel): + """One guide. Blocks live in a JSON column rather than a child table. + + A guide is edited and saved WHOLE - nobody reorders one paragraph in + isolation - so a child table would buy ordering machinery, a second + migration and N+1 reads, in exchange for nothing this feature needs. The + same reasoning the geenforce manifests and backup payloads already follow. + """ + + __tablename__ = 'guides' + + guideid = db.Column(db.Integer, primary_key=True) + + # Human title, and the slug the URL uses. The slug is stored rather than + # derived on read so an edited title cannot silently break a link somebody + # pasted into a work order. + title = db.Column(db.String(300), nullable=False) + slug = db.Column(db.String(300), nullable=False, unique=True, index=True) + + # One line, shown in the list and in search results. + summary = db.Column(db.String(1000)) + + # Comma-separated, matching how knowledgebase.keywords already works, so one + # search can cover both and there is one tag vocabulary rather than two. + tags = db.Column(db.String(500)) + + # 'draft' or 'published'. A draft is visible to people who can edit, and + # invisible to everyone else: a half-written procedure on a shop floor is + # worse than no procedure. + status = db.Column(db.String(20), nullable=False, default='draft', index=True) + + # MEDIUMTEXT: serialized blocks. Text rather than a native JSON column so the + # bytes come back exactly as written and a diff between versions is stable - + # the same reason the backups plugin stores its projection as text. + blocksjson = db.Column(db.Text(length=16777215), nullable=False, default='[]') + + # What READERS see. Points at an immutable snapshot in guiderevisions, not + # at the working copy above. + # + # This is the whole point of separating the two: without it, editing a live + # procedure changes it under a technician who is halfway through following + # it. With it, they keep reading the published version until somebody + # deliberately publishes a new one. NULL means never published - a draft + # nobody outside the authors can see. + # + # use_alter, because this and guiderevisions.guideid form a cycle: a guide + # points at its published revision, and every revision points back at its + # guide. Without it SQLAlchemy cannot order CREATE/DROP and warns that it + # cannot sort the tables. The database-level constraint is deliberately not + # created by the migration either - the cycle buys nothing there, and the + # pointer is maintained in one place (services/authoring.py). + publishedrevisionid = db.Column( + db.Integer, db.ForeignKey('guiderevisions.guiderevisionid', + ondelete='SET NULL', + use_alter=True, + name='fk_guides_publishedrevision')) + + createdby = db.Column(db.Integer, db.ForeignKey('users.userid')) + updatedby = db.Column(db.Integer, db.ForeignKey('users.userid')) + createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) + updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow, + onupdate=_utcnow) + publishedat = db.Column(db.DateTime) + + # The working copy's version number. Bumped only when a save actually + # changes something, so it counts real edits rather than keystrokes, and a + # printed copy has something honest for its footer: "version 7 (published), + # printed 26 Aug 2026". + revision = db.Column(db.Integer, nullable=False, default=1) + + @property + def blocks(self): + """The decoded block list, or [] on a corrupt row. + + Deliberately forgiving, unlike the backups payload getter: a guide that + will not render is a page a person cannot read, and an empty guide with + its title and tags intact is a better failure than an error page. The + corruption is still visible - the editor shows no blocks. + """ + if not self.blocksjson: + return [] + try: + data = json.loads(self.blocksjson) + except (ValueError, TypeError): + return [] + return data if isinstance(data, list) else [] + + @blocks.setter + def blocks(self, value): + self.blocksjson = json.dumps(value or [], separators=(',', ':')) + + def taglist(self): + """Tags as a clean list, empties dropped.""" + return [t.strip() for t in (self.tags or '').split(',') if t.strip()] + + def to_dict(self, includeblocks=False): + data = { + 'guideid': self.guideid, + 'title': self.title, + 'slug': self.slug, + 'summary': self.summary, + 'tags': self.taglist(), + 'status': self.status, + 'revision': self.revision, + 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None, + 'publishedat': (self.publishedat.isoformat() + 'Z' + if self.publishedat else None), + } + if includeblocks: + data['blocks'] = self.blocks + return data + + +class GuideRevision(BaseModel): + """An immutable snapshot of a guide, written when a save changes something. + + Full snapshots rather than diffs. A guide is small, and a snapshot answers + the question people actually ask - "what did this say in March, when the + job last ran" - without replaying a chain of deltas to find out. The same + reasoning the part-marker backups follow, for the same reason: the version + you need is the one nobody thought to keep. + + DEDUP: a save whose content hashes identically to the latest revision writes + nothing. Without that, autosave produces a hundred identical versions a day + and the history becomes noise that hides the three edits that mattered. + + Never updated after insert. A revision that can be edited is not a record of + anything. + """ + + __tablename__ = 'guiderevisions' + + guiderevisionid = db.Column(db.Integer, primary_key=True) + guideid = db.Column(db.Integer, + db.ForeignKey('guides.guideid', ondelete='CASCADE'), + nullable=False, index=True) + + # Monotonic per guide, and shown to people: "version 7". + revision = db.Column(db.Integer, nullable=False) + + # The whole guide as it stood, not just the blocks: a title or tag change is + # a change worth being able to see and roll back. + title = db.Column(db.String(300), nullable=False) + summary = db.Column(db.String(1000)) + tags = db.Column(db.String(500)) + blocksjson = db.Column(db.Text(length=16777215), nullable=False) + + # sha256 of the canonical content, and the dedup key. + contenthash = db.Column(db.String(64), nullable=False, index=True) + + # Optional "what changed and why", the way a commit message earns its keep. + note = db.Column(db.String(500)) + + createdby = db.Column(db.Integer, db.ForeignKey('users.userid')) + createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) + + @property + def blocks(self): + if not self.blocksjson: + return [] + try: + data = json.loads(self.blocksjson) + except (ValueError, TypeError): + return [] + return data if isinstance(data, list) else [] + + def to_dict(self, includeblocks=False): + data = { + 'guiderevisionid': self.guiderevisionid, + 'guideid': self.guideid, + 'revision': self.revision, + 'title': self.title, + 'summary': self.summary, + 'tags': [t.strip() for t in (self.tags or '').split(',') if t.strip()], + 'note': self.note, + 'createdby': self.createdby, + 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'shorthash': (self.contenthash or '')[:12], + } + if includeblocks: + data['blocks'] = self.blocks + return data + + +class GuideImage(BaseModel): + """An image pasted or dropped into a guide. + + Bytes live in the database, not on disk. Guides are small, the images are + screenshots rather than photographs, and a row travels with a database + backup - whereas a file under instance/ has been lost to a container rebuild + on this very deployment before. If a site ever puts thousands of these in, + the storage decision is one column and one route to revisit. + """ + + __tablename__ = 'guideimages' + + guideimageid = db.Column(db.Integer, primary_key=True) + + # Nullable: an image is uploaded while a NEW guide is still unsaved, so it + # exists before the guide it belongs to does. The editor attaches it on + # save; anything still unattached is orphaned and prunable. + guideid = db.Column(db.Integer, db.ForeignKey('guides.guideid', + ondelete='CASCADE'), index=True) + + filename = db.Column(db.String(255)) + mimetype = db.Column(db.String(100), nullable=False) + bytesize = db.Column(db.Integer, nullable=False) + data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False) + + uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid')) + createdat = db.Column(db.DateTime, nullable=False, default=_utcnow) + + def to_dict(self): + return { + 'guideimageid': self.guideimageid, + 'guideid': self.guideid, + 'filename': self.filename, + 'mimetype': self.mimetype, + 'bytesize': self.bytesize, + 'url': '/api/guides/images/{}'.format(self.guideimageid), + 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + } diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py new file mode 100644 index 00000000..1a3175ea --- /dev/null +++ b/plugins/guides/plugin.py @@ -0,0 +1,89 @@ +"""Guides plugin. + +Step-by-step procedures written and read inside ShopDB: images, working +checklists, dark mode and printable output, with drafts and revision history. + +Distinct from the knowledgebase plugin, which is a LINK REGISTRY - a +description, a URL and keywords pointing at a document held somewhere else. +This holds the document. They share a tag vocabulary on purpose, so one search +can cover both. +""" + +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional, Type + +from flask import Flask, Blueprint + +from shopdb.plugins.base import BasePlugin, PluginMeta + +from .models import Guide, GuideRevision, GuideImage +from .api import guides_bp + +logger = logging.getLogger(__name__) + + +class GuidesPlugin(BasePlugin): + + def __init__(self): + self._manifest = self._load_manifest() + + def _load_manifest(self) -> Dict: + manifest_path = Path(__file__).parent / 'manifest.json' + if manifest_path.exists(): + with open(manifest_path, 'r') as fh: + return json.load(fh) + return {} + + @property + def meta(self) -> PluginMeta: + return PluginMeta( + name=self._manifest.get('name', 'guides'), + version=self._manifest.get('version', '1.0.0'), + description=self._manifest.get('description', 'Step-by-step guides'), + author=self._manifest.get('author', 'ShopDB Team'), + dependencies=self._manifest.get('dependencies', []), + core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'), + api_prefix=self._manifest.get('api_prefix', '/api/guides'), + ) + + def get_blueprint(self) -> Optional[Blueprint]: + return guides_bp + + def get_models(self) -> List[Type]: + return [Guide, GuideRevision, GuideImage] + + def get_permissions(self) -> List: + """Five permissions, and the split between the last two is the point. + + publish is separate from edit so a technician can write up a procedure + while somebody else decides it is fit for the floor. Without that split, + anyone who can fix a typo can also put an unreviewed procedure in front + of an operator. + + create without edit is a working combination on purpose: an author may + edit their OWN guide while it is unpublished, and needs guides.edit once + other people are following it (see _mayedit in the API). + """ + return [ + ('guides.view', 'View guides', 'guides'), + ('guides.create', 'Create guides and edit your own drafts', 'guides'), + ('guides.edit', 'Edit any guide, including published ones', 'guides'), + ('guides.publish', 'Publish or withdraw a guide', 'guides'), + ('guides.delete', 'Delete guides', 'guides'), + ] + + def get_navigation_items(self) -> List[Dict]: + return [ + { + 'name': 'Guides', + 'icon': 'book-open', + 'route': '/guides', + 'position': 36, + 'section': 'information', + }, + ] + + def init_app(self, app: Flask, db_instance) -> None: + logger.info('Guides plugin initialized (v%s)', self.meta.version) diff --git a/plugins/guides/services/__init__.py b/plugins/guides/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/plugins/guides/services/authoring.py b/plugins/guides/services/authoring.py new file mode 100644 index 00000000..d645d2f0 --- /dev/null +++ b/plugins/guides/services/authoring.py @@ -0,0 +1,134 @@ +"""Saving, revisioning and publishing a guide. + +Kept out of the API layer so the rules are testable without a request, and so +there is exactly ONE path that writes a guide. A second write path is how a +system ends up with revisions that skipped the dedup, or a published pointer +that points at nothing. +""" + +from datetime import datetime + +from shopdb.api import db + +from ..models import Guide, GuideRevision +from .blocks import validate, contenthash + + +def _utcnow(): + return datetime.utcnow() + + +def save(guide, title=None, summary=None, tags=None, blocks=None, note=None, + userid=None): + """Apply an edit to the working copy, writing a revision if it changed. + + Returns (guide, revision_or_None). None means the save was a no-op: the + content hashed identically to the last revision, so nothing was recorded. + That is the normal outcome of an autosave where the author changed nothing, + and it is what keeps the history readable. + """ + if title is not None: + guide.title = title.strip() + if summary is not None: + guide.summary = summary.strip() or None + if tags is not None: + # Normalised once, here: trimmed, empties dropped, order preserved. + # Tags are compared case-insensitively when hashing, but stored as the + # author typed them - "PXE" should not become "pxe" on screen. + guide.tags = ', '.join(t.strip() for t in tags.split(',') if t.strip()) or None + if blocks is not None: + guide.blocks = validate(blocks) + + incoming = contenthash(guide.title, guide.summary, guide.tags, guide.blocks) + + latest = (GuideRevision.query + .filter_by(guideid=guide.guideid) + .order_by(GuideRevision.revision.desc()) + .first()) + if latest is not None and latest.contenthash == incoming: + # Nothing a reader would notice has changed. Touch nothing else: an + # updatedat that moves on every autosave makes "last edited" a lie. + return guide, None + + guide.revision = (latest.revision + 1) if latest else 1 + guide.updatedby = userid + guide.updatedat = _utcnow() + + revision = GuideRevision( + guideid=guide.guideid, + revision=guide.revision, + title=guide.title, + summary=guide.summary, + tags=guide.tags, + blocksjson=guide.blocksjson, + contenthash=incoming, + note=(note or '').strip() or None, + createdby=userid, + ) + db.session.add(revision) + db.session.flush() + return guide, revision + + +def publish(guide, userid=None): + """Point readers at the current working copy. + + Publishing does NOT snapshot: it promotes the latest existing revision. If + it snapshotted, a publish with unsaved edits would create a version nobody + reviewed - which is exactly the accident this workflow exists to prevent. + """ + latest = (GuideRevision.query + .filter_by(guideid=guide.guideid) + .order_by(GuideRevision.revision.desc()) + .first()) + if latest is None: + raise ValueError('nothing to publish: this guide has no saved content') + + guide.publishedrevisionid = latest.guiderevisionid + guide.status = 'published' + guide.publishedat = _utcnow() + guide.updatedby = userid + db.session.flush() + return latest + + +def unpublish(guide, userid=None): + """Withdraw a guide from readers without deleting anything. + + For a procedure that turns out to be wrong. The revisions stay, so it can be + corrected and published again, and so there is still a record of what people + were being told in the meantime. + """ + guide.publishedrevisionid = None + guide.status = 'draft' + guide.updatedby = userid + db.session.flush() + return guide + + +def readable(guide): + """The revision a READER should be served, or None. + + Never the working copy: a published guide shows what was published, even + while somebody is editing the next version. Editors get the working copy + through the editor, which is a different route on purpose. + """ + if not guide.publishedrevisionid: + return None + return db.session.get(GuideRevision, guide.publishedrevisionid) + + +def restore(guide, revision, userid=None): + """Copy an old revision back onto the working copy. + + Deliberately NOT a rollback of history: restoring writes a NEW revision with + the old content. The record of what was published between then and now stays + intact, which is the difference between a history and a rewrite. + """ + return save(guide, + title=revision.title, + summary=revision.summary or '', + tags=revision.tags or '', + blocks=revision.blocks, + note='Restored from version {}'.format(revision.revision), + userid=userid) diff --git a/plugins/guides/services/blocks.py b/plugins/guides/services/blocks.py new file mode 100644 index 00000000..08f06c0c --- /dev/null +++ b/plugins/guides/services/blocks.py @@ -0,0 +1,152 @@ +"""Block validation. + +The editor is the only thing that writes blocks today, so this is not defending +against a hostile author - it is defending against a BAD SAVE. A block list that +is wrong in a way nobody notices at save time becomes a guide that will not +render weeks later, when the person who wrote it has moved on and the only +evidence is a blank page. + +So: validate at the door, name the block that is wrong, and refuse the save. +The alternative - storing whatever arrives and coping at render time - was tried +in the backups plugin with a manifest and produced exactly the failure this +avoids: a client bug turning into a broken page that outlived it. + +WHAT IS NOT VALIDATED HERE: the inline text. It is a restricted Markdown subset +rendered through a sanitiser in the browser, which is where a renderer belongs. +Storing text verbatim keeps what the author typed recoverable even if the +rendering rules change later. +""" + +import hashlib +import json + +from ..models import BLOCKTYPES + +# A guide is a procedure, not a document repository. This cap is generous for +# anything a person will read on a shop floor and small enough that a runaway +# paste cannot fill a MEDIUMTEXT column. +MAXBLOCKSBYTES = 2 * 1024 * 1024 +MAXBLOCKS = 500 +MAXTEXT = 20000 + + +class BlockError(ValueError): + """Raised with a message naming the offending block, for the API to relay.""" + + +def _fail(index, message): + raise BlockError('block {}: {}'.format(index + 1, message)) + + +def validate(blocks): + """Check a block list, returning it unchanged, or raise BlockError. + + Kept pure - no database, no request - so the rules are testable directly and + the same function can be reused by an importer later. + """ + if not isinstance(blocks, list): + raise BlockError('blocks must be a list') + if len(blocks) > MAXBLOCKS: + raise BlockError('a guide is limited to {} blocks'.format(MAXBLOCKS)) + + seen = set() + for index, block in enumerate(blocks): + if not isinstance(block, dict): + _fail(index, 'must be an object') + + blockid = block.get('id') + if not isinstance(blockid, str) or not blockid.strip(): + # Ids are what make a checklist item rememberable between visits. + # A block without one silently loses its tick the next time the + # guide is opened, which reads as "the checkboxes do not work". + _fail(index, 'needs a stable id') + if blockid in seen: + _fail(index, 'duplicate id {!r} - ticks would follow the wrong item' + .format(blockid)) + seen.add(blockid) + + blocktype = block.get('type') + if blocktype not in BLOCKTYPES: + _fail(index, 'unknown type {!r}. Known types: {}'.format( + blocktype, ', '.join(BLOCKTYPES))) + + text = block.get('text') + if text is not None: + if not isinstance(text, str): + _fail(index, 'text must be a string') + if len(text) > MAXTEXT: + _fail(index, 'text is longer than {} characters'.format(MAXTEXT)) + + items = block.get('items') + if items is not None: + if not isinstance(items, list): + _fail(index, 'items must be a list') + for item in items: + # Checklist items carry their own id for the same reason blocks + # do: that id is the thing a tick is stored against. + if isinstance(item, dict): + if not isinstance(item.get('id'), str) or not item['id'].strip(): + _fail(index, 'every item needs an id') + if not isinstance(item.get('text', ''), str): + _fail(index, 'item text must be a string') + elif not isinstance(item, str): + _fail(index, 'items must be strings or objects') + + encoded = json.dumps(blocks, separators=(',', ':')) + if len(encoded.encode('utf-8')) > MAXBLOCKSBYTES: + raise BlockError('this guide is larger than {} MB'.format( + MAXBLOCKSBYTES // (1024 * 1024))) + return blocks + + +def plaintext(blocks): + """Flatten a guide to searchable text. + + Search should find a guide by something written halfway down it, not only by + its title - that is how people actually look for a procedure ("the one about + the drive letter"). Cheap because it runs on save, not on every query. + """ + parts = [] + for block in blocks or []: + if not isinstance(block, dict): + continue + if isinstance(block.get('text'), str): + parts.append(block['text']) + for item in block.get('items') or []: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and isinstance(item.get('text'), str): + parts.append(item['text']) + for row in block.get('rows') or []: + if isinstance(row, list): + parts.extend(cell for cell in row if isinstance(cell, str)) + return '\n'.join(parts) + + +def checklistitemcount(blocks): + """How many tickable items a guide has, for the list and the progress bar.""" + total = 0 + for block in blocks or []: + if isinstance(block, dict) and block.get('type') == 'checklist': + total += len(block.get('items') or []) + return total + + +def contenthash(title, summary, tags, blocks): + """sha256 over everything a reader would notice changing. + + Title, summary and tags are included, not just the blocks: renaming a guide + or retagging it is a change worth a revision, and hashing only the body + would silently drop it from the history. + + Canonical by construction - the blocks are re-serialized with sorted keys + and no whitespace, so the same content saved twice hashes the same however + the editor happened to order its JSON that day. + """ + canonical = json.dumps({ + 'title': (title or '').strip(), + 'summary': (summary or '').strip(), + 'tags': sorted(t.strip().lower() for t in (tags or '').split(',') if t.strip()), + 'blocks': blocks or [], + }, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 70e272fe..07de998b 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -58,6 +58,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'manifestblobs', 'manifestenforcementreports', 'manifestenforcementresults', 'pctypealiases'), + 'guides': ('guides', 'guiderevisions', 'guideimages'), 'knowledgebase': ('knowledgebase',), 'machines': ('machinetypes', 'machines'), 'measuringtools': ('measuringtooltypes', 'measuringtools'), diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index e4ef3976..5c58e74c 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -50,6 +50,9 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' # 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup # otherwise throws away. EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen' +# guides is a NEW plugin, so its 0001 builds tables rather than stamping an +# anchor over core-owned ones. +EXPECTED_HEAD_REVISION['guides'] = 'guides0001baseline' # geenforce adds the content-addressed blob store (manifestblobs) on top of its # baseline. EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib' diff --git a/tests/test_plugins/test_guides.py b/tests/test_plugins/test_guides.py new file mode 100644 index 00000000..5c78bf92 --- /dev/null +++ b/tests/test_plugins/test_guides.py @@ -0,0 +1,334 @@ +"""Guides: block validation, revisioning, and the draft/publish split. + +The behaviour worth protecting hardest is the last one. A technician following a +procedure on a shop floor must not have it change under them because somebody is +editing it upstairs, and the only thing standing between them and that is the +published pointer. +""" + +import pytest + +from shopdb import create_app +from shopdb.extensions import db as _db +from shopdb.plugins import plugin_manager + + +# --------------------------------------------------------------------------- +# Block validation - pure, no app context needed. These rules exist to stop a +# BAD SAVE becoming a guide that will not render weeks later, when the author +# has moved on and the only evidence is a blank page. +# --------------------------------------------------------------------------- + +def _block(blockid='b1', blocktype='paragraph', **extra): + return dict({'id': blockid, 'type': blocktype}, **extra) + + +def test_a_well_formed_block_list_passes(): + from plugins.guides.services.blocks import validate + blocks = [ + _block('b1', 'heading', text='Before you start'), + _block('b2', 'paragraph', text='Check the **cable** first.'), + _block('b3', 'checklist', items=[{'id': 'i1', 'text': 'Power on'}]), + ] + assert validate(blocks) == blocks + + +def test_a_block_without_an_id_is_refused(): + """Ids are what make a tick rememberable. + + Without one the checkbox silently loses its state on the next visit, which + reads to a user as "the checkboxes do not work" - a bug report with no + stack trace and no obvious cause. + """ + from plugins.guides.services.blocks import validate, BlockError + with pytest.raises(BlockError) as exc: + validate([{'type': 'paragraph', 'text': 'no id'}]) + assert 'stable id' in str(exc.value) + + +def test_duplicate_ids_are_refused(): + from plugins.guides.services.blocks import validate, BlockError + with pytest.raises(BlockError) as exc: + validate([_block('same'), _block('same')]) + assert 'duplicate' in str(exc.value) + + +def test_an_unknown_block_type_is_refused_and_lists_the_known_ones(): + from plugins.guides.services.blocks import validate, BlockError + with pytest.raises(BlockError) as exc: + validate([_block('b1', 'carousel')]) + message = str(exc.value) + assert 'carousel' in message and 'checklist' in message + + +def test_checklist_items_need_their_own_ids(): + """Same reason as blocks: the item id is what a tick is stored against.""" + from plugins.guides.services.blocks import validate, BlockError + with pytest.raises(BlockError) as exc: + validate([_block('b1', 'checklist', items=[{'text': 'no id'}])]) + assert 'item' in str(exc.value) + + +def test_the_error_names_which_block_is_wrong(): + """An author fixing a rejected save should not have to count blocks.""" + from plugins.guides.services.blocks import validate, BlockError + with pytest.raises(BlockError) as exc: + validate([_block('b1'), _block('b2'), {'type': 'paragraph'}]) + assert str(exc.value).startswith('block 3:') + + +def test_content_hash_covers_title_and_tags_not_just_blocks(): + """Renaming or retagging a guide is a change a reader notices. + + Hashing only the body would drop those edits out of the history entirely. + """ + from plugins.guides.services.blocks import contenthash + blocks = [_block('b1', 'paragraph', text='same body')] + base = contenthash('Title', 'Summary', 'pxe, share', blocks) + assert contenthash('Different', 'Summary', 'pxe, share', blocks) != base + assert contenthash('Title', 'Summary', 'pxe', blocks) != base + assert contenthash('Title', 'Different', 'pxe, share', blocks) != base + + +def test_content_hash_ignores_tag_order_case_and_spacing(): + """Otherwise reordering tags in the editor would manufacture a version.""" + from plugins.guides.services.blocks import contenthash + blocks = [_block()] + assert (contenthash('T', '', 'PXE, Share', blocks) + == contenthash('T', '', 'share ,pxe', blocks)) + + +def test_checklist_item_count_counts_only_checklists(): + from plugins.guides.services.blocks import checklistitemcount + blocks = [ + _block('b1', 'checklist', items=[{'id': 'i1'}, {'id': 'i2'}]), + _block('b2', 'bullets', items=['not tickable']), + _block('b3', 'checklist', items=[{'id': 'i3'}]), + ] + assert checklistitemcount(blocks) == 3 + + +def test_plaintext_flattens_a_guide_for_search(): + """People search for something written halfway down, not for the title.""" + from plugins.guides.services.blocks import plaintext + blocks = [ + _block('b1', 'heading', text='Mapped drive'), + _block('b2', 'checklist', items=[{'id': 'i1', 'text': 'Run the sync'}]), + _block('b3', 'table', rows=[['left', 'right']]), + ] + flat = plaintext(blocks) + assert 'Mapped drive' in flat and 'Run the sync' in flat and 'right' in flat + + +# --------------------------------------------------------------------------- +# Authoring: revisions, dedup and publishing. Needs an app context. +# --------------------------------------------------------------------------- + +@pytest.fixture(scope='module') +def gd_app(): + saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) + application = create_app('testing') + + from plugins.guides.plugin import GuidesPlugin + plugin = GuidesPlugin() + pm = application.extensions['plugin_manager'] + if plugin.meta.api_prefix not in pm._registered_prefixes: + pm._register_plugin_components(plugin) + + with application.app_context(): + _db.create_all() + yield application + _db.session.remove() + _db.drop_all() + + (plugin_manager._app, plugin_manager._db, plugin_manager.registry, + plugin_manager.loader, plugin_manager.migration_manager, + plugin_manager._registered_prefixes) = saved + + +@pytest.fixture +def gd_clean(gd_app): + from plugins.guides.models import Guide, GuideRevision + with gd_app.app_context(): + _db.session.query(GuideRevision).delete() + _db.session.query(Guide).delete() + _db.session.commit() + yield + + +def _newguide(title='Replacing a marker controller'): + from plugins.guides.models import Guide + guide = Guide(title=title, slug=title.lower().replace(' ', '-'), + status='draft') + _db.session.add(guide) + _db.session.flush() + return guide + + +def test_the_first_save_creates_version_one(gd_app, gd_clean): + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + guide, revision = authoring.save( + guide, title='Replacing a marker controller', + blocks=[_block('b1', 'paragraph', text='Power it off first.')]) + _db.session.commit() + assert revision is not None + assert revision.revision == 1 + assert guide.revision == 1 + + +def test_saving_identical_content_writes_no_revision(gd_app, gd_clean): + """Autosave would otherwise produce a hundred versions a day. + + A history full of identical entries hides the three edits that mattered, + which is the same failure the backups plugin dedups away. + """ + from plugins.guides.models import GuideRevision + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + blocks = [_block('b1', 'paragraph', text='Unchanged.')] + authoring.save(guide, title='T', blocks=blocks) + _db.session.commit() + + guide, second = authoring.save(guide, title='T', blocks=blocks) + _db.session.commit() + + assert second is None, 'an unchanged save must not manufacture a version' + assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1 + assert guide.revision == 1 + + +def test_a_real_edit_creates_the_next_version(gd_app, gd_clean): + from plugins.guides.models import GuideRevision + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + authoring.save(guide, title='T', + blocks=[_block('b1', 'paragraph', text='One.')]) + _db.session.commit() + guide, second = authoring.save( + guide, blocks=[_block('b1', 'paragraph', text='Two.')]) + _db.session.commit() + + assert second.revision == 2 + assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 2 + + +def test_readers_see_nothing_until_it_is_published(gd_app, gd_clean): + """A half-written procedure on a shop floor is worse than no procedure.""" + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + authoring.save(guide, title='T', blocks=[_block()]) + _db.session.commit() + assert authoring.readable(guide) is None + + +def test_editing_a_published_guide_does_not_change_what_readers_see(gd_app, gd_clean): + """THE point of the draft/publish split. + + A technician halfway through a procedure keeps reading the version that was + published, even while the next one is being written. + """ + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + authoring.save(guide, title='T', + blocks=[_block('b1', 'paragraph', text='Published text.')]) + authoring.publish(guide) + _db.session.commit() + + authoring.save(guide, + blocks=[_block('b1', 'paragraph', text='Work in progress.')]) + _db.session.commit() + + served = authoring.readable(guide) + assert served.blocks[0]['text'] == 'Published text.' + assert guide.revision == 2, 'the working copy moved on' + assert served.revision == 1, 'what readers get did not' + + +def test_publishing_promotes_the_latest_saved_revision(gd_app, gd_clean): + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='v1')]) + authoring.save(guide, blocks=[_block('b1', 'paragraph', text='v2')]) + authoring.publish(guide) + _db.session.commit() + assert authoring.readable(guide).blocks[0]['text'] == 'v2' + assert guide.status == 'published' + + +def test_publishing_an_empty_guide_is_refused(gd_app, gd_clean): + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + _db.session.commit() + with pytest.raises(ValueError): + authoring.publish(guide) + + +def test_unpublishing_withdraws_without_destroying_the_history(gd_app, gd_clean): + """For a procedure that turns out to be wrong. + + The versions stay, so it can be corrected and published again, and so there + is still a record of what people were being told in the meantime. + """ + from plugins.guides.models import GuideRevision + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + authoring.save(guide, title='T', blocks=[_block()]) + authoring.publish(guide) + _db.session.commit() + + authoring.unpublish(guide) + _db.session.commit() + + assert authoring.readable(guide) is None + assert guide.status == 'draft' + assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1 + + +def test_restoring_writes_a_new_version_rather_than_rewriting_history(gd_app, gd_clean): + """A history you can edit is not a record of anything. + + Restoring brings old content forward as a NEW version, so what was + published in between stays visible. + """ + from plugins.guides.models import GuideRevision + from plugins.guides.services import authoring + with gd_app.app_context(): + guide = _newguide() + _, first = authoring.save(guide, title='T', + blocks=[_block('b1', 'paragraph', text='original')]) + authoring.save(guide, blocks=[_block('b1', 'paragraph', text='mistake')]) + _db.session.commit() + + authoring.restore(guide, first) + _db.session.commit() + + rows = (GuideRevision.query.filter_by(guideid=guide.guideid) + .order_by(GuideRevision.revision).all()) + assert [r.revision for r in rows] == [1, 2, 3] + assert rows[2].blocks[0]['text'] == 'original' + assert 'Restored from version 1' in (rows[2].note or '') + assert rows[1].blocks[0]['text'] == 'mistake', 'the mistake stays on record' + + +def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, gd_clean): + """A guide that will not render is a page a person cannot read. + + An empty guide with its title and tags intact is a better failure than an + error page, and the corruption is still visible - the editor shows nothing. + """ + with gd_app.app_context(): + guide = _newguide() + guide.blocksjson = 'not json at all' + _db.session.commit() + assert guide.blocks == []