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.
288 lines
12 KiB
Python
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,
|
|
}
|