Files
shopdb-flask/plugins/guides/models/guide.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -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 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,
}