Guides: write a procedure in ShopDB, not beside it
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.
This commit is contained in:
282
plugins/guides/models/guide.py
Normal file
282
plugins/guides/models/guide.py
Normal file
@@ -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 <input> 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,
|
||||
}
|
||||
Reference in New Issue
Block a user