Guides: procedures written and read inside ShopDB
This commit is contained in:
@@ -74,7 +74,12 @@ class Guide(BaseModel):
|
||||
# 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)
|
||||
slug = db.Column(db.String(300), nullable=False, unique=True, index=True)
|
||||
# 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))
|
||||
@@ -93,15 +98,39 @@ class Guide(BaseModel):
|
||||
# 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'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
||||
onupdate=_utcnow)
|
||||
publishedat = db.Column(db.DateTime)
|
||||
|
||||
# Bumped on every save. Cheap, and it gives a printed copy something
|
||||
# honest to put in its footer: "version 7, printed 26 Aug 2026".
|
||||
# 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
|
||||
@@ -138,8 +167,10 @@ class Guide(BaseModel):
|
||||
'tags': self.taglist(),
|
||||
'status': self.status,
|
||||
'revision': self.revision,
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None,
|
||||
# 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),
|
||||
}
|
||||
@@ -148,6 +179,76 @@ class Guide(BaseModel):
|
||||
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.
|
||||
|
||||
@@ -174,7 +275,6 @@ class GuideImage(BaseModel):
|
||||
data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False)
|
||||
|
||||
uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -184,5 +284,5 @@ class GuideImage(BaseModel):
|
||||
'mimetype': self.mimetype,
|
||||
'bytesize': self.bytesize,
|
||||
'url': '/api/guides/images/{}'.format(self.guideimageid),
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user