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