Printing a dark-mode page lays a full page of toner down for the background and knocks the text out of it in white. On a shopfloor printer that is a cartridge for a handful of guides, and the result is harder to read than the plain version. Somebody would have found this the expensive way. The theme tokens are redefined for print only, in an unscoped block because overriding them has to reach :root. Every guide element already renders through those variables rather than literal colours, so this is a dozen lines instead of a rewrite - the same discipline that made dark mode work without dark-mode code. Backgrounds are dropped rather than printed grey: a callout is identified by its rule and its heading, which cost nothing. Checkboxes print EMPTY even where they are ticked on screen, because a printed guide is a worksheet somebody carries to the bay, and one person's progress is not part of the procedure. The footer gains the print date beside the version, so a stale copy pinned to a noticeboard can be spotted. Also corrects the model and migration found by running this against a real database rather than SQLite: BaseModel contributes createddate, modifieddate and isactive to every table and the migration never created them, so the first read failed on unknown columns; the model had also declared duplicate timestamps on top, now removed with the API field names kept stable. And the slug is 191 characters, not 300 - it is UNIQUE-indexed, MySQL caps an index key at 767 bytes, and utf8mb4 is 4 bytes per character.
134 lines
4.7 KiB
Python
134 lines
4.7 KiB
Python
"""Saving, revisioning and publishing a guide.
|
|
|
|
Kept out of the API layer so the rules are testable without a request, and so
|
|
there is exactly ONE path that writes a guide. A second write path is how a
|
|
system ends up with revisions that skipped the dedup, or a published pointer
|
|
that points at nothing.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
|
|
from shopdb.api import db
|
|
|
|
from ..models import Guide, GuideRevision
|
|
from .blocks import validate, contenthash
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.utcnow()
|
|
|
|
|
|
def save(guide, title=None, summary=None, tags=None, blocks=None, note=None,
|
|
userid=None):
|
|
"""Apply an edit to the working copy, writing a revision if it changed.
|
|
|
|
Returns (guide, revision_or_None). None means the save was a no-op: the
|
|
content hashed identically to the last revision, so nothing was recorded.
|
|
That is the normal outcome of an autosave where the author changed nothing,
|
|
and it is what keeps the history readable.
|
|
"""
|
|
if title is not None:
|
|
guide.title = title.strip()
|
|
if summary is not None:
|
|
guide.summary = summary.strip() or None
|
|
if tags is not None:
|
|
# Normalised once, here: trimmed, empties dropped, order preserved.
|
|
# Tags are compared case-insensitively when hashing, but stored as the
|
|
# author typed them - "PXE" should not become "pxe" on screen.
|
|
guide.tags = ', '.join(t.strip() for t in tags.split(',') if t.strip()) or None
|
|
if blocks is not None:
|
|
guide.blocks = validate(blocks)
|
|
|
|
incoming = contenthash(guide.title, guide.summary, guide.tags, guide.blocks)
|
|
|
|
latest = (GuideRevision.query
|
|
.filter_by(guideid=guide.guideid)
|
|
.order_by(GuideRevision.revision.desc())
|
|
.first())
|
|
if latest is not None and latest.contenthash == incoming:
|
|
# Nothing a reader would notice has changed. Touch nothing else: an
|
|
# updatedat that moves on every autosave makes "last edited" a lie.
|
|
return guide, None
|
|
|
|
guide.revision = (latest.revision + 1) if latest else 1
|
|
guide.updatedby = userid
|
|
|
|
revision = GuideRevision(
|
|
guideid=guide.guideid,
|
|
revision=guide.revision,
|
|
title=guide.title,
|
|
summary=guide.summary,
|
|
tags=guide.tags,
|
|
blocksjson=guide.blocksjson,
|
|
contenthash=incoming,
|
|
note=(note or '').strip() or None,
|
|
createdby=userid,
|
|
)
|
|
db.session.add(revision)
|
|
db.session.flush()
|
|
return guide, revision
|
|
|
|
|
|
def publish(guide, userid=None):
|
|
"""Point readers at the current working copy.
|
|
|
|
Publishing does NOT snapshot: it promotes the latest existing revision. If
|
|
it snapshotted, a publish with unsaved edits would create a version nobody
|
|
reviewed - which is exactly the accident this workflow exists to prevent.
|
|
"""
|
|
latest = (GuideRevision.query
|
|
.filter_by(guideid=guide.guideid)
|
|
.order_by(GuideRevision.revision.desc())
|
|
.first())
|
|
if latest is None:
|
|
raise ValueError('nothing to publish: this guide has no saved content')
|
|
|
|
guide.publishedrevisionid = latest.guiderevisionid
|
|
guide.status = 'published'
|
|
guide.publishedat = _utcnow()
|
|
guide.updatedby = userid
|
|
db.session.flush()
|
|
return latest
|
|
|
|
|
|
def unpublish(guide, userid=None):
|
|
"""Withdraw a guide from readers without deleting anything.
|
|
|
|
For a procedure that turns out to be wrong. The revisions stay, so it can be
|
|
corrected and published again, and so there is still a record of what people
|
|
were being told in the meantime.
|
|
"""
|
|
guide.publishedrevisionid = None
|
|
guide.status = 'draft'
|
|
guide.updatedby = userid
|
|
db.session.flush()
|
|
return guide
|
|
|
|
|
|
def readable(guide):
|
|
"""The revision a READER should be served, or None.
|
|
|
|
Never the working copy: a published guide shows what was published, even
|
|
while somebody is editing the next version. Editors get the working copy
|
|
through the editor, which is a different route on purpose.
|
|
"""
|
|
if not guide.publishedrevisionid:
|
|
return None
|
|
return db.session.get(GuideRevision, guide.publishedrevisionid)
|
|
|
|
|
|
def restore(guide, revision, userid=None):
|
|
"""Copy an old revision back onto the working copy.
|
|
|
|
Deliberately NOT a rollback of history: restoring writes a NEW revision with
|
|
the old content. The record of what was published between then and now stays
|
|
intact, which is the difference between a history and a rewrite.
|
|
"""
|
|
return save(guide,
|
|
title=revision.title,
|
|
summary=revision.summary or '',
|
|
tags=revision.tags or '',
|
|
blocks=revision.blocks,
|
|
note='Restored from version {}'.format(revision.revision),
|
|
userid=userid)
|