"""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 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, }