Files
shopdb-flask/plugins/guides/plugin.py
cproudlock 38d1e367fc 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.
2026-08-26 10:24:26 -04:00

90 lines
3.1 KiB
Python

"""Guides plugin.
Step-by-step procedures written and read inside ShopDB: images, working
checklists, dark mode and printable output, with drafts and revision history.
Distinct from the knowledgebase plugin, which is a LINK REGISTRY - a
description, a URL and keywords pointing at a document held somewhere else.
This holds the document. They share a tag vocabulary on purpose, so one search
can cover both.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from .models import Guide, GuideRevision, GuideImage
from .api import guides_bp
logger = logging.getLogger(__name__)
class GuidesPlugin(BasePlugin):
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as fh:
return json.load(fh)
return {}
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest.get('name', 'guides'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'Step-by-step guides'),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/guides'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return guides_bp
def get_models(self) -> List[Type]:
return [Guide, GuideRevision, GuideImage]
def get_permissions(self) -> List:
"""Five permissions, and the split between the last two is the point.
publish is separate from edit so a technician can write up a procedure
while somebody else decides it is fit for the floor. Without that split,
anyone who can fix a typo can also put an unreviewed procedure in front
of an operator.
create without edit is a working combination on purpose: an author may
edit their OWN guide while it is unpublished, and needs guides.edit once
other people are following it (see _mayedit in the API).
"""
return [
('guides.view', 'View guides', 'guides'),
('guides.create', 'Create guides and edit your own drafts', 'guides'),
('guides.edit', 'Edit any guide, including published ones', 'guides'),
('guides.publish', 'Publish or withdraw a guide', 'guides'),
('guides.delete', 'Delete guides', 'guides'),
]
def get_navigation_items(self) -> List[Dict]:
return [
{
'name': 'Guides',
'icon': 'book-open',
'route': '/guides',
'position': 36,
'section': 'information',
},
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info('Guides plugin initialized (v%s)', self.meta.version)