Files
shopdb-flask/plugins/guides/services/authoring.py

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)