Files
cproudlock e2a226e8b0 Let Guides reach a site, and reach core the way every plugin must
Two faults the guides plugin shipped with, both caught by gates that were
already there.

deploy/site-profile-universal.json never listed `guides`. That file is the
profile the released Windows installer is built from, so the wizard would not
have offered Guides at all - and worse, `flask plugin prune-schema` drops the
tables of any plugin absent from the profile at provisioning (ADR-014). A site
installing the next release would have had its guides table dropped.

plugins/guides/models/guide.py imported shopdb.core.models.base directly.
Plugins reach core only through shopdb.api or shopdb.plugins.base (ADR-001),
and shopdb.api already re-exports BaseModel, so this is the same symbol by the
supported path.

The suite was red on main because of these two; it passes again.
2026-08-26 13:13:16 -04:00

288 lines
12 KiB
Python

"""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, 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)
# 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))
# 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'))
# 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'))
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,
# 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),
}
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'))
@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.
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'))
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.createddate.isoformat() + 'Z' if self.createddate else None,
}