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:
cproudlock
2026-08-26 10:24:26 -04:00
parent 28af92aaec
commit 38d1e367fc
22 changed files with 2771 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
"""guides plugin baseline: guides, guiderevisions, guideimages.
A new plugin, so this builds its tables rather than stamping an anchor over
core-owned ones (contrast the cutover plugins, whose 0001 is a no-op).
Idempotent: each table is created only if it is not already there, so a database
where an earlier run half-succeeded can be upgraded again without hand-editing
alembic_version_guides.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
revision = 'guides0001baseline'
down_revision = None
branch_labels = None
depends_on = None
def _tables():
return set(inspect(op.get_bind()).get_table_names())
def upgrade():
existing = _tables()
if 'guides' not in existing:
op.create_table(
'guides',
sa.Column('guideid', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('slug', sa.String(length=300), nullable=False),
sa.Column('summary', sa.String(length=1000)),
sa.Column('tags', sa.String(length=500)),
sa.Column('status', sa.String(length=20), nullable=False,
server_default='draft'),
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
nullable=False),
sa.Column('publishedrevisionid', sa.Integer()),
sa.Column('createdby', sa.Integer()),
sa.Column('updatedby', sa.Integer()),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.Column('updatedat', sa.DateTime(), nullable=False),
sa.Column('publishedat', sa.DateTime()),
sa.Column('revision', sa.Integer(), nullable=False, server_default='1'),
sa.PrimaryKeyConstraint('guideid'),
sa.UniqueConstraint('slug', name='uq_guides_slug'),
)
op.create_index('idx_guides_status', 'guides', ['status'])
if 'guiderevisions' not in existing:
op.create_table(
'guiderevisions',
sa.Column('guiderevisionid', sa.Integer(), nullable=False),
sa.Column('guideid', sa.Integer(), nullable=False),
sa.Column('revision', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('summary', sa.String(length=1000)),
sa.Column('tags', sa.String(length=500)),
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
nullable=False),
sa.Column('contenthash', sa.String(length=64), nullable=False),
sa.Column('note', sa.String(length=500)),
sa.Column('createdby', sa.Integer()),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('guiderevisionid'),
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
ondelete='CASCADE'),
)
op.create_index('idx_guiderevisions_guide', 'guiderevisions', ['guideid'])
op.create_index('idx_guiderevisions_hash', 'guiderevisions', ['contenthash'])
if 'guideimages' not in existing:
op.create_table(
'guideimages',
sa.Column('guideimageid', sa.Integer(), nullable=False),
sa.Column('guideid', sa.Integer()),
sa.Column('filename', sa.String(length=255)),
sa.Column('mimetype', sa.String(length=100), nullable=False),
sa.Column('bytesize', sa.Integer(), nullable=False),
sa.Column('data', sa.dialects.mysql.MEDIUMBLOB()
if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(),
nullable=False),
sa.Column('uploadedby', sa.Integer()),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('guideimageid'),
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
ondelete='CASCADE'),
)
op.create_index('idx_guideimages_guide', 'guideimages', ['guideid'])
def downgrade():
for table in ('guideimages', 'guiderevisions', 'guides'):
op.drop_table(table)