From f221fba54076157c7b11cb45dd20dbf615dd8409 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Wed, 26 Aug 2026 12:42:24 -0400 Subject: [PATCH] Guides: procedures written and read inside ShopDB --- frontend/src/composables/systemSettings.js | 1 + frontend/src/views/SearchResults.vue | 5 + plugins/guides/__init__.py | 3 + plugins/guides/api/__init__.py | 3 + plugins/guides/api/routes.py | 442 +++++++++++ .../frontend/components/GuideBlocks.vue | 257 +++++++ plugins/guides/frontend/routes.js | 42 ++ plugins/guides/frontend/utils/inline.js | 82 +++ plugins/guides/frontend/utils/proof.js | 145 ++++ plugins/guides/frontend/utils/templates.js | 74 ++ plugins/guides/frontend/views/GuideEditor.vue | 691 ++++++++++++++++++ plugins/guides/frontend/views/GuideView.vue | 271 +++++++ plugins/guides/frontend/views/GuidesList.vue | 163 +++++ plugins/guides/migrations/env.py | 16 + plugins/guides/migrations/script.py.mako | 24 + .../versions/0001_guides_baseline.py | 114 +++ plugins/guides/models/__init__.py | 4 +- plugins/guides/models/guide.py | 120 ++- plugins/guides/plugin.py | 92 +++ plugins/guides/services/authoring.py | 133 ++++ plugins/guides/services/blocks.py | 21 + shopdb/core/api/search.py | 50 ++ shopdb/core/api/settings.py | 1 + shopdb/plugins/alembic_template.py | 1 + tests/test_core/test_search_coverage.py | 72 ++ tests/test_plugin_migrations.py | 3 + tests/test_plugins/test_guides.py | 334 +++++++++ 27 files changed, 3152 insertions(+), 12 deletions(-) create mode 100644 plugins/guides/__init__.py create mode 100644 plugins/guides/api/__init__.py create mode 100644 plugins/guides/api/routes.py create mode 100644 plugins/guides/frontend/components/GuideBlocks.vue create mode 100644 plugins/guides/frontend/routes.js create mode 100644 plugins/guides/frontend/utils/inline.js create mode 100644 plugins/guides/frontend/utils/proof.js create mode 100644 plugins/guides/frontend/utils/templates.js create mode 100644 plugins/guides/frontend/views/GuideEditor.vue create mode 100644 plugins/guides/frontend/views/GuideView.vue create mode 100644 plugins/guides/frontend/views/GuidesList.vue create mode 100644 plugins/guides/migrations/env.py create mode 100644 plugins/guides/migrations/script.py.mako create mode 100644 plugins/guides/migrations/versions/0001_guides_baseline.py create mode 100644 plugins/guides/plugin.py create mode 100644 plugins/guides/services/authoring.py create mode 100644 tests/test_plugins/test_guides.py diff --git a/frontend/src/composables/systemSettings.js b/frontend/src/composables/systemSettings.js index 4f9fa842..69b090a9 100644 --- a/frontend/src/composables/systemSettings.js +++ b/frontend/src/composables/systemSettings.js @@ -45,6 +45,7 @@ export const assetTypeCols = [ export const searchDomains = [ { key: 'application', label: 'Applications' }, { key: 'knowledgebase', label: 'Knowledge Base' }, + { key: 'guide', label: 'Guides' }, { key: 'employee', label: 'Employees' }, { key: 'machine', label: 'Machines' }, { key: 'computer', label: 'PCs' }, diff --git a/frontend/src/views/SearchResults.vue b/frontend/src/views/SearchResults.vue index 80d262f8..1eb53316 100644 --- a/frontend/src/views/SearchResults.vue +++ b/frontend/src/views/SearchResults.vue @@ -117,6 +117,7 @@ const typeLabels = { computer: 'PC', application: 'App', knowledgebase: 'KB', + guide: 'Guide', printer: 'Printer', network_device: 'Network', measuring_tool: 'Measuring Tool', @@ -136,6 +137,7 @@ const filterTypeMap = { measuringtools: ['measuring_tool'], applications: ['application'], knowledgebase: ['knowledgebase'], + guides: ['guide'], notifications: ['notification'], employees: ['employee'], usb: ['usb_device'], @@ -151,6 +153,7 @@ const filterList = [ { key: 'measuringtools', label: 'Measuring Tools' }, { key: 'applications', label: 'Apps' }, { key: 'knowledgebase', label: 'KB' }, + { key: 'guides', label: 'Guides' }, { key: 'notifications', label: 'Notices' }, { key: 'employees', label: 'Employees' }, { key: 'usb', label: 'USB' }, @@ -424,6 +427,7 @@ watch(results, () => { --rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32; --rt-application-bg: #fff3e0; --rt-application-fg: #e65100; --rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2; + --rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8; --rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b; --rt-network-bg: #fff8e1; --rt-network-fg: #f57f17; --rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f; @@ -437,6 +441,7 @@ watch(results, () => { .result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); } .result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); } .result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); } +.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); } .result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); } .result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); } .result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); } 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..ac59426c --- /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[: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('/', 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..ebc24551 --- /dev/null +++ b/plugins/guides/frontend/components/GuideBlocks.vue @@ -0,0 +1,257 @@ + + + + + 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/utils/proof.js b/plugins/guides/frontend/utils/proof.js new file mode 100644 index 00000000..5212ea3f --- /dev/null +++ b/plugins/guides/frontend/utils/proof.js @@ -0,0 +1,145 @@ +/** + * Proofing: the mistakes that matter in a PROCEDURE, not a document. + * + * A spellchecker catches "teh". None of these are spelling, and every one has + * been seen for real: + * + * - a smart quote in a command, so the command does not run + * - a checklist item with no text, so a step is silently missing + * - a heading with nothing under it, left when a draft was reorganised + * - an image with no alt text, invisible to anyone who cannot see it + * - a TODO that was never finished, published anyway + * + * Warnings, not blocks. A person publishing a guide at 14:40 with one empty + * bullet should be told, not stopped - the tool does not know that the bullet is + * deliberate, and a procedure that exists imperfectly beats one nobody wrote. + */ + +// Characters an editor or a word processor substitutes silently. In prose they +// are harmless; in a command they are the difference between working and a +// syntax error nobody can explain. +const SMARTCHARS = [ + // Written as escapes, not literals: the repo's style gate forbids non-ASCII in + // source, and a file whose job is detecting smart quotes cannot contain any. + // The escapes are also unambiguous in a diff, where a curly quote and a + // straight one look identical. + ['\u2018', "'"], ['\u2019', "'"], // single quotes + ['\u201C', '"'], ['\u201D', '"'], // double quotes + ['\u2013', '-'], ['\u2014', '-'], // en dash, em dash + ['\u00A0', ' '], // non-breaking space +] + +const UNFINISHED = /\b(TODO|TBD|FIXME|XXX|\?\?\?)\b/i + +function issue (level, blockid, message, fix) { + return { level, blockid, message, fix: fix || null } +} + +export function proof (guide, blocks) { + const issues = [] + + if (!guide.title || !guide.title.trim()) { + issues.push(issue('error', null, 'This guide has no title')) + } + if (!guide.summary || !guide.summary.trim()) { + issues.push(issue('warn', null, + 'No summary. It is what people read in the list and in search results.')) + } + if (!guide.tags || !guide.tags.trim()) { + issues.push(issue('warn', null, 'No tags, so this will be hard to find')) + } + if (!blocks.length) { + issues.push(issue('error', null, 'This guide is empty')) + return issues + } + + blocks.forEach((block, index) => { + const where = 'block ' + (index + 1) + ' (' + block.type + ')' + + if (block.type === 'code') { + // The important one. An autocorrected quote in a command looks right and + // does not run. + // + // Reported ONCE per block, however many offending characters it has: a + // command with an opening and a closing smart quote is one problem with + // one fix, and listing it twice makes the panel look like it is nagging. + const found = SMARTCHARS.filter(([bad]) => (block.text || '').includes(bad)) + if (found.length) { + issues.push(issue('error', block.id, + where + ': contains ' + found.map(([, good]) => 'a curly or long ' + + (good === "'" ? 'quote' : good === '"' ? 'double quote' : + good === '-' ? 'dash' : 'space')).filter( + (v, i, a) => a.indexOf(v) === i).join(' and ') + + ' where a command needs the plain character. Autocorrect does this, ' + + 'and the command will not run.', + { type: 'replaceAll', pairs: found })) + } + if (!(block.text || '').trim()) { + issues.push(issue('error', block.id, where + ' is empty')) + } + } else if (['heading', 'paragraph', 'callout'].includes(block.type)) { + if (!(block.text || '').trim() && !(block.title || '').trim()) { + issues.push(issue('warn', block.id, where + ' is empty')) + } + } + + if (block.type === 'step' && !(block.title || '').trim() + && !(block.text || '').trim()) { + issues.push(issue('warn', block.id, where + ' has no title and no text')) + } + + if (['checklist', 'bullets', 'numbers'].includes(block.type)) { + const empties = (block.items || []).filter( + item => !String(typeof item === 'string' ? item : item.text || '').trim()) + if (empties.length) { + issues.push(issue('warn', block.id, + where + ': ' + empties.length + ' empty item' + + (empties.length > 1 ? 's' : '') + + '. An empty step reads as a step somebody forgot to write.')) + } + } + + if (block.type === 'image' && !(block.alt || '').trim()) { + issues.push(issue('warn', block.id, + where + ': no alt text. Anyone who cannot see the image gets nothing.')) + } + + if (block.type === 'table' && !(block.rows || []).length) { + issues.push(issue('warn', block.id, where + ' has a header but no rows')) + } + + const text = [block.text, block.title].filter(Boolean).join(' ') + if (UNFINISHED.test(text)) { + issues.push(issue('warn', block.id, + where + ': still contains ' + text.match(UNFINISHED)[0])) + } + }) + + // A heading immediately followed by another heading usually means the section + // under the first one was moved or never written. + blocks.forEach((block, index) => { + if (block.type !== 'heading') return + const next = blocks[index + 1] + if (!next || next.type === 'heading') { + issues.push(issue('warn', block.id, + 'Heading "' + (block.text || '').slice(0, 40) + '" has nothing under it')) + } + }) + + return issues +} + +/** Apply a suggested fix in place, for the ones where the answer is obvious. */ +export function applyFix (block, fix) { + if (!fix) return false + const before = block.text || '' + let after = before + if (fix.type === 'replaceAll') { + for (const [bad, good] of fix.pairs) after = after.split(bad).join(good) + } else if (fix.type === 'replace') { + after = after.split(fix.from).join(fix.to) + } + if (after === before) return false + block.text = after + return true +} diff --git a/plugins/guides/frontend/utils/templates.js b/plugins/guides/frontend/utils/templates.js new file mode 100644 index 00000000..1a166f81 --- /dev/null +++ b/plugins/guides/frontend/utils/templates.js @@ -0,0 +1,74 @@ +/** + * Starting points for a new guide. + * + * The blank page is the real barrier to somebody writing a procedure down. A + * skeleton with the right SHAPE - what you need first, the steps, how you know + * it worked, what to do when it does not - gets a half-decent guide out of + * somebody who would otherwise have written nothing. + * + * The shape is lifted from the KB articles the team already writes, so guides + * written here read like the ones written before them. + */ + +function id (prefix, n) { + return prefix + n +} + +export const templates = [ + { + key: 'blank', + name: 'Blank', + description: 'Start with nothing', + blocks: () => [], + }, + { + key: 'procedure', + name: 'Procedure', + description: 'Before you start, numbered steps, how to check it worked', + blocks: () => [ + { id: id('t', 1), type: 'paragraph', + text: 'What this covers, and when somebody would need it.' }, + { id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start', + text: 'Anything that has to be true first, or that will bite halfway through.' }, + { id: id('t', 3), type: 'heading', text: 'Steps' }, + { id: id('t', 4), type: 'step', title: 'First step', text: '' }, + { id: id('t', 5), type: 'step', title: 'Second step', text: '' }, + { id: id('t', 6), type: 'heading', text: 'Check it worked' }, + { id: id('t', 7), type: 'checklist', items: [ + { id: id('ti', 1), text: '' }, + { id: id('ti', 2), text: '' }, + ] }, + ], + }, + { + key: 'troubleshooting', + name: 'Troubleshooting', + description: 'Symptom, likely causes, what to check first', + blocks: () => [ + { id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' }, + { id: id('t', 2), type: 'heading', text: 'Check these first' }, + { id: id('t', 3), type: 'checklist', items: [ + { id: id('ti', 1), text: '' }, + { id: id('ti', 2), text: '' }, + ] }, + { id: id('t', 4), type: 'heading', text: 'Likely causes' }, + { id: id('t', 5), type: 'table', + header: ['What you see', 'What it usually means'], rows: [['', '']] }, + { id: id('t', 6), type: 'callout', variant: 'info', + text: 'The thing that looks like the cause and is not.' }, + ], + }, + { + key: 'checklist', + name: 'Checklist', + description: 'A list to work through, nothing else', + blocks: () => [ + { id: id('t', 1), type: 'paragraph', text: 'When to use this list.' }, + { id: id('t', 2), type: 'checklist', items: [ + { id: id('ti', 1), text: '' }, + { id: id('ti', 2), text: '' }, + { id: id('ti', 3), text: '' }, + ] }, + ], + }, +] diff --git a/plugins/guides/frontend/views/GuideEditor.vue b/plugins/guides/frontend/views/GuideEditor.vue new file mode 100644 index 00000000..7105497a --- /dev/null +++ b/plugins/guides/frontend/views/GuideEditor.vue @@ -0,0 +1,691 @@ + + + + + diff --git a/plugins/guides/frontend/views/GuideView.vue b/plugins/guides/frontend/views/GuideView.vue new file mode 100644 index 00000000..e0ff3686 --- /dev/null +++ b/plugins/guides/frontend/views/GuideView.vue @@ -0,0 +1,271 @@ + + + + + + + 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/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..8964c3a7 --- /dev/null +++ b/plugins/guides/migrations/versions/0001_guides_baseline.py @@ -0,0 +1,114 @@ +"""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), + # 191 because this is UNIQUE-indexed and MySQL caps an index key at + # 767 bytes; utf8mb4 is 4 bytes per character. + sa.Column('slug', sa.String(length=191), 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('publishedat', sa.DateTime()), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), nullable=False), + 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()), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), 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()), + # BaseModel contributes these to every model in the app; a plugin + # migration has to spell them out or the table is missing columns + # the ORM selects on every query. + sa.Column('createddate', sa.DateTime(), nullable=False), + sa.Column('modifieddate', sa.DateTime(), nullable=False), + sa.Column('isactive', sa.Boolean(), 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 index c32b970e..02a44397 100644 --- a/plugins/guides/models/__init__.py +++ b/plugins/guides/models/__init__.py @@ -1,3 +1,3 @@ -from .guide import Guide, GuideImage, BLOCKTYPES +from .guide import Guide, GuideRevision, GuideImage, BLOCKTYPES -__all__ = ['Guide', 'GuideImage', 'BLOCKTYPES'] +__all__ = ['Guide', 'GuideRevision', 'GuideImage', 'BLOCKTYPES'] diff --git a/plugins/guides/models/guide.py b/plugins/guides/models/guide.py index 5df9ef4b..a3587798 100644 --- a/plugins/guides/models/guide.py +++ b/plugins/guides/models/guide.py @@ -74,7 +74,12 @@ class Guide(BaseModel): # 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) + # 191, not 300: this column is UNIQUE-indexed, and MySQL caps an index key + # at 767 bytes. utf8mb4 stores up to 4 bytes per character, so 191 * 4 = + # 764 is the longest indexable string - 300 produces + # "Specified key was too long" the moment the migration runs on a real + # database, having passed every SQLite test. + slug = db.Column(db.String(191), nullable=False, unique=True, index=True) # One line, shown in the list and in search results. summary = db.Column(db.String(1000)) @@ -93,15 +98,39 @@ class Guide(BaseModel): # 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')) + + # createddate / modifieddate / isactive come from BaseModel - no second set + # of timestamps on top of them, or the next person has to work out which + # pair is authoritative. publishedat is ours because "when did readers start + # seeing this" is a different fact from "when was the row last touched". 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) - # Bumped on every save. Cheap, and it gives a printed copy something - # honest to put in its footer: "version 7, printed 26 Aug 2026". + # 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 @@ -138,8 +167,10 @@ class Guide(BaseModel): '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, + # API names stay createdat/updatedat: the column names are a + # storage detail, and renaming a payload field breaks callers. + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, + 'updatedat': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None, 'publishedat': (self.publishedat.isoformat() + 'Z' if self.publishedat else None), } @@ -148,6 +179,76 @@ class Guide(BaseModel): 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')) + + @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.createddate.isoformat() + 'Z' if self.createddate 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. @@ -174,7 +275,6 @@ class GuideImage(BaseModel): 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 { @@ -184,5 +284,5 @@ class GuideImage(BaseModel): 'mimetype': self.mimetype, 'bytesize': self.bytesize, 'url': '/api/guides/images/{}'.format(self.guideimageid), - 'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None, + 'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None, } diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py new file mode 100644 index 00000000..c8b0e387 --- /dev/null +++ b/plugins/guides/plugin.py @@ -0,0 +1,92 @@ +"""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]: + # Position 8, beside Slides (7), rather than down in the Information + # section with the reference lists. A guide is something you go and DO + # something with, like a slide deck you put on a screen - not a + # catalogue you look things up in. + return [ + { + 'name': 'Guides', + 'icon': 'book-open', + 'route': '/guides', + 'position': 8, + }, + ] + + 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/authoring.py b/plugins/guides/services/authoring.py new file mode 100644 index 00000000..1afd4389 --- /dev/null +++ b/plugins/guides/services/authoring.py @@ -0,0 +1,133 @@ +"""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 + + 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 index ef07608b..08f06c0c 100644 --- a/plugins/guides/services/blocks.py +++ b/plugins/guides/services/blocks.py @@ -17,6 +17,7 @@ Storing text verbatim keeps what the author typed recoverable even if the rendering rules change later. """ +import hashlib import json from ..models import BLOCKTYPES @@ -129,3 +130,23 @@ def checklistitemcount(blocks): 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/core/api/search.py b/shopdb/core/api/search.py index d023304c..70da6bc1 100644 --- a/shopdb/core/api/search.py +++ b/shopdb/core/api/search.py @@ -255,6 +255,55 @@ def _search_applications(query, search_term): return results +def _search_guides(query, search_term): + """Search guides by title, summary, tags and BODY. + + The body matters more here than in most domains. People look for a procedure + by something written halfway down it - "the one about the drive letter" - + not by a title they never read. The body is a JSON blob of blocks, so this + is a LIKE against it: crude, but it finds the guide, and a guide corpus is + tens of documents rather than millions of rows. + + PUBLISHED ONLY. A draft is deliberately invisible to readers, and search is + the one place that rule would otherwise leak - somebody would find a + half-written procedure through the search box and follow it. + """ + results = [] + try: + _require_enabled('guides') + from plugins.guides.models import Guide + + rows = Guide.query.filter( + Guide.isactive == True, + Guide.publishedrevisionid.isnot(None), + _word_match(query, Guide.title, Guide.summary, Guide.tags, + Guide.blocksjson) + ).order_by(Guide.modifieddate.desc()).limit(20).all() + + for guide in rows: + # A title match outranks a body match: someone searching "marker" + # wants the guide called that, not every guide mentioning one. + relevance = 10 + if query.lower() in (guide.title or '').lower(): + relevance += 20 + if guide.tags and query.lower() in guide.tags.lower(): + relevance += 15 + + results.append({ + 'type': 'guide', + 'id': guide.guideid, + 'title': guide.title, + 'subtitle': guide.summary, + 'url': f"/guides/{guide.slug}", + 'relevance': relevance + }) + except ImportError: + pass # guides plugin absent or disabled + except Exception as e: + logger.error(f"Guides search failed: {e}") + return results + + def _search_knowledgebase(query, search_term): """Search Knowledge Base by description and keywords. @@ -1148,6 +1197,7 @@ def global_search(): # Run all search domains results.extend(_search_applications(query, search_term)) results.extend(_search_knowledgebase(query, search_term)) + results.extend(_search_guides(query, search_term)) results.extend(_search_employees(query, search_term)) results.extend(_search_assets(query, search_term)) results.extend(_search_measuringtools(query, search_term)) diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py index 6a17a338..c38d7bdb 100644 --- a/shopdb/core/api/settings.py +++ b/shopdb/core/api/settings.py @@ -101,6 +101,7 @@ IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device', SEARCH_DOMAINS = { 'application': 'Applications', 'knowledgebase': 'Knowledge Base', + 'guide': 'Guides', 'employee': 'Employees', 'machine': 'Machines', 'computer': 'PCs', 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_core/test_search_coverage.py b/tests/test_core/test_search_coverage.py index 57c53115..d80fd3cb 100644 --- a/tests/test_core/test_search_coverage.py +++ b/tests/test_core/test_search_coverage.py @@ -266,3 +266,75 @@ def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow): assert len(rows) == 1 assert rows[0]['id'] == subnetrow assert rows[0]['url'] == f'/networks/{subnetrow}' + + +# --------------------------------------------------------------------------- +# Guides. A guide is not an asset either, so the generic asset search cannot see +# one - and the draft rule makes the omission worse than invisibility: a +# half-written procedure reachable through the search box is one somebody +# follows. +# --------------------------------------------------------------------------- + +@pytest.fixture +def guides(db): + """One published guide and one draft, with distinctive text. + + The published one carries its distinguishing words in the BODY, not the + title, because that is how people look for a procedure - by something + written halfway down it. + """ + from plugins.guides.models import Guide + from plugins.guides.services import authoring + + published = Guide(title='Zzmarker controller swap', slug='zzmarker-swap', + status='draft') + draft = Guide(title='Zzdraft unfinished procedure', slug='zzdraft-unfinished', + status='draft') + db.session.add_all([published, draft]) + db.session.flush() + + authoring.save(published, title=published.title, + summary='Restoring after a swap', tags='zztelesis, zzbackups', + blocks=[{'id': 'g1', 'type': 'paragraph', + 'text': 'Check the zzmappeddrive before starting.'}]) + authoring.publish(published) + authoring.save(draft, title=draft.title, summary='', tags='zztelesis', + blocks=[{'id': 'g2', 'type': 'paragraph', + 'text': 'Half written zzmappeddrive notes.'}]) + db.session.commit() + return {'published': published.guideid, 'draft': draft.guideid} + + +def test_a_published_guide_is_found_by_title(client, guides): + rows = [r for r in search(client, 'Zzmarker') if r['type'] == 'guide'] + assert len(rows) == 1 + assert rows[0]['id'] == guides['published'] + assert rows[0]['url'] == '/guides/zzmarker-swap' + + +def test_a_guide_is_found_by_tag(client, guides): + """Tags are how a technician narrows to a family of procedures.""" + assert 'Zzmarker controller swap' in titles(client, 'zzbackups', 'guide') + + +def test_a_guide_is_found_by_words_in_its_body(client, guides): + """People search for what they remember reading, not for the title. + + The body is stored as a JSON blob of blocks, so this is the check that the + blob is actually reachable rather than merely present. + """ + assert 'Zzmarker controller swap' in titles(client, 'zzmappeddrive', 'guide') + + +def test_a_draft_guide_is_never_returned(client, guides): + """The draft rule has to hold on EVERY path that reaches the row. + + A draft is deliberately invisible to readers, and search is where that rule + would otherwise leak: somebody finds a half-written procedure through the + search box and follows it. The draft here shares a tag and a body word with + the published guide, so it would ride along on both queries if the filter + were missing. + """ + assert 'Zzdraft unfinished procedure' not in titles(client, 'zzmappeddrive', 'guide') + assert 'Zzdraft unfinished procedure' not in titles(client, 'zztelesis', 'guide') + assert titles(client, 'Zzdraft', 'guide') == [] 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 == []