guest role; guide section ticking

This commit is contained in:
2026-08-27 13:02:35 -04:00
parent d38a599292
commit 4da6c6aa36
45 changed files with 830 additions and 243 deletions

View File

@@ -14,7 +14,7 @@ from datetime import datetime
import json
import re
from flask import Blueprint, request, Response
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
@@ -42,9 +42,25 @@ ALLOWEDIMAGETYPES = {
}
def _currentuser():
"""The signed-in user, or None.
`current_user` is a LocalProxy, so `current_user is not None` is True even
when nobody is signed in - the proxy exists, the user behind it does not.
Under a required JWT that never showed; the read routes are jwt-optional
now, and the first guest request found it by way of
"'NoneType' object has no attribute 'haspermission'".
"""
try:
return current_user._get_current_object()
except Exception:
return None
def _userid():
if current_user is not None:
return current_user.userid
user = _currentuser()
if user is not None:
return user.userid
try:
return int(get_jwt_identity())
except (TypeError, ValueError):
@@ -59,8 +75,12 @@ def _has(permission):
the decorator makes, including the admin bypass that lives inside
haspermission.
"""
return bool(current_user is not None
and current_user.haspermission(permission))
user = _currentuser()
return bool(user is not None and user.haspermission(permission))
def _signedin():
return _currentuser() is not None
def _mayedit(guide):
@@ -101,7 +121,6 @@ def _slugify(title, guideid=None):
# ---------------------------------------------------------------- reading ---
@guides_bp.route('', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_guides():
"""Published guides, plus the caller's own drafts if they can author.
@@ -109,9 +128,15 @@ def list_guides():
A half-written procedure on a shop floor is worse than no procedure, so
drafts are invisible to readers - but an author must be able to find their
own, or drafting is a trap door.
A guest reaches this only if the guest role has been granted guides.view.
A guest is never an author, so the draft branches below cannot open for one.
"""
caneditall = _has('guides.edit')
mine = _userid()
# A guest has no user id, and `createdby == None` is a real match in SQL
# against every guide imported without an author. Their drafts are not
# theirs; nobody's are.
mine = _userid() if _signedin() else -1
# Match against the PUBLISHED text for anything the caller is only allowed
# to read, and against the working copy only for guides they may edit.
@@ -185,7 +210,6 @@ def list_guides():
@guides_bp.route('/tags', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_tags():
"""Every tag in use, with a count, for the filter chips.
@@ -215,7 +239,6 @@ def list_tags():
@guides_bp.route('/<slug>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_guide(slug):
"""What a reader gets: the published revision.
@@ -257,7 +280,6 @@ def read_guide(slug):
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_working(guideid):
"""The working copy, for the editor."""
@@ -275,7 +297,6 @@ def read_working(guideid):
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def list_revisions(guideid):
guide = db.session.get(Guide, guideid)
@@ -298,7 +319,6 @@ def list_revisions(guideid):
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def read_revision(revisionid):
"""One revision by id.
@@ -327,8 +347,40 @@ def read_revision(revisionid):
# ---------------------------------------------------------------- writing ---
def _attachimages(guide, blocks):
"""Attach the images this guide's blocks reference but nothing owns yet.
An image pasted into a guide that has not been saved is uploaded with no
guideid - there is no id to give it. The upload route says the editor
attaches it on save, and the editor never did: it only sends a guideid when
the guide already has one. So a screenshot pasted while writing a NEW guide
stayed unattached forever.
An unattached image is visible to editors and to whoever uploaded it, and
to nobody else - so the guide looked right to the person who wrote it and
showed "Image unavailable" to every reader. Attaching on save is what makes
the image belong to the guide, and therefore readable by anyone allowed to
read the guide once it is published.
Only images nothing else owns are claimed: an image already attached to
another guide is left alone rather than stolen by whoever references it.
"""
referenced = set()
for block in blocks or []:
if isinstance(block, dict) and block.get('imageid') is not None:
try:
referenced.add(int(block['imageid']))
except (TypeError, ValueError):
continue
if not referenced:
return
(GuideImage.query
.filter(GuideImage.guideimageid.in_(referenced))
.filter(GuideImage.guideid.is_(None))
.update({'guideid': guide.guideid}, synchronize_session=False))
@guides_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('guides.create')
def create_guide():
data = request.get_json(silent=True) or {}
@@ -353,13 +405,15 @@ def create_guide():
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
# The first save is exactly when a pasted image has no owner yet.
_attachimages(guide, guide.blocks)
db.session.commit()
return success_response(guide.to_dict(includeblocks=True),
message='Guide created')
@guides_bp.route('/<int:guideid>', methods=['PUT'])
@jwt_required()
@require_permission('guides.view')
def update_guide(guideid):
guide = db.session.get(Guide, guideid)
@@ -387,6 +441,8 @@ def update_guide(guideid):
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
_attachimages(guide, guide.blocks)
# The slug is set once, at create, and does NOT follow the title.
#
# _slugify's own docstring says the slug is stored so that renaming cannot
@@ -409,7 +465,6 @@ def update_guide(guideid):
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
@jwt_required()
@require_permission('guides.publish')
def publish_guide(guideid):
guide = db.session.get(Guide, guideid)
@@ -425,7 +480,6 @@ def publish_guide(guideid):
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
@jwt_required()
@require_permission('guides.publish')
def unpublish_guide(guideid):
guide = db.session.get(Guide, guideid)
@@ -437,7 +491,6 @@ def unpublish_guide(guideid):
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
@jwt_required()
@require_permission('guides.view')
def restore_revision(guideid, revisionid):
guide = db.session.get(Guide, guideid)
@@ -458,7 +511,6 @@ def restore_revision(guideid, revisionid):
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
@jwt_required()
@require_permission('guides.delete')
def delete_guide(guideid):
guide = db.session.get(Guide, guideid)
@@ -472,7 +524,6 @@ def delete_guide(guideid):
# ----------------------------------------------------------------- images ---
@guides_bp.route('/images', methods=['POST'])
@jwt_required()
@require_permission('guides.create')
def upload_image():
"""Take a pasted or dropped image.
@@ -553,9 +604,16 @@ def _mayseeimage(image):
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
@jwt_required()
@require_permission('guides.view')
def serve_image(imageid):
"""The image itself, under the same rule as the guide that holds it.
A guest who may read a published guide must see its screenshots, or every
image renders as "Image unavailable" and the guide looks broken.
_mayseeimage already answers correctly for somebody holding nothing - it
requires the PUBLISHED revision to reference the image - so a guest needs no
separate branch here.
"""
image = db.session.get(GuideImage, imageid)
if image is None:
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)

View File

@@ -114,6 +114,52 @@ def build_cli():
click.echo(' imported as /guides/{} (draft, revision {})'
.format(guide.slug, guide.revision))
@guides_cli.command('fix-images')
@click.option('--apply/--dry-run', default=False,
help='Write to the database. The default reports only.')
@with_appcontext
def fix_images(apply):
"""Attach images a guide references but nothing owns.
An image pasted into a guide that had not been saved yet was uploaded
with no guideid, and nothing ever attached it. Editors and the uploader
could see it; every other reader got "Image unavailable" on a published
guide. Saving a guide attaches its images now, but a guide nobody edits
again would stay broken, so this repairs what is already stored.
"""
orphans = GuideImage.query.filter(GuideImage.guideid.is_(None)).all()
if not orphans:
click.echo('No unattached images.')
return
wanted = {}
for guide in Guide.query.all():
for block in guide.blocks or []:
if isinstance(block, dict) and block.get('imageid') is not None:
try:
wanted.setdefault(int(block['imageid']), guide)
except (TypeError, ValueError):
continue
fixed = 0
for image in orphans:
guide = wanted.get(image.guideimageid)
if guide is None:
click.echo(' image {} is referenced by no guide - left alone'
.format(image.guideimageid))
continue
click.echo(' image {} -> guide {} ({})'.format(
image.guideimageid, guide.guideid, guide.slug))
if apply:
image.guideid = guide.guideid
fixed += 1
if not apply:
click.echo('{} would be attached. Pass --apply to write.'.format(fixed))
return
db.session.commit()
click.echo('{} image(s) attached.'.format(fixed))
@guides_cli.command('export')
@click.option('--out', 'outdir', required=True,
type=click.Path(file_okay=False),

View File

@@ -2,14 +2,36 @@
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
<template v-for="(block, index) in blocks" :key="block.id || index">
<!--
A heading and a step can be ticked off while reading, not just a
checklist item. On a long procedure the question is not "what have I
done" so much as "where was I" - somebody looks away to do the thing
the guide asked for, and looks back at a wall of text. A struck-through
section is where they were.
Reading only: in the editor a tick would be an edit.
-->
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
v-html="inline(block.text)"></h2>
:class="{ 'gb-sectiondone': isTicked(block.id) }">
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
:checked="isTicked(block.id)"
:aria-label="'Mark this section done'"
@change="toggle(block.id, $event.target.checked)">
<span v-html="inline(block.text)"></span>
</h2>
<p v-else-if="block.type === 'paragraph'" class="gb-para"
v-html="inline(block.text)"></p>
<div v-else-if="block.type === 'step'" class="gb-step">
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
<div v-else-if="block.type === 'step'" class="gb-step"
:class="{ 'gb-sectiondone': isTicked(block.id) }">
<label class="gb-stepnum">
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
:checked="isTicked(block.id)"
:aria-label="'Mark step ' + stepNumber(index) + ' done'"
@change="toggle(block.id, $event.target.checked)">
Step {{ stepNumber(index) }}
</label>
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
<div v-if="block.text" v-html="inline(block.text)"></div>
</div>
@@ -193,6 +215,20 @@ function select (block) {
dark mode work with no dark-mode code. */
.gb-blocks { line-height: 1.65; }
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
/* A ticked section is struck through and faded, not hidden: somebody scanning
back up the page has to be able to read what they already did. */
.gb-sectiondone { text-decoration: line-through; opacity: 0.55; }
.gb-sectiondone .gb-stepnum { text-decoration: none; }
.gb-sectiontick {
/* Touch-sized: a guide is read on a shopfloor PC, often with gloves on. */
width: 1.1rem;
height: 1.1rem;
margin-right: 0.5rem;
vertical-align: middle;
cursor: pointer;
}
.gb-heading .gb-sectiontick { text-decoration: none; }
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
.gb-step {

View File

@@ -6,15 +6,20 @@
* checkboxes are live in exactly one of them.
*
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
* plugin is disabled. Every route requires auth because every guides API route
* is behind JWT plus a guides.* permission.
* plugin is disabled.
*
* READING does not require auth; WRITING does. The read routes are gated on
* guides.view, and a signed-out visitor holds whatever the GUEST role has been
* granted - so the role decides, not the router. Sending a guest to /login when
* the site has granted them guides.view would make the grant look broken;
* letting them through when it has not costs one 401 the view already handles.
*/
export default [
{
path: 'guides',
name: 'guides',
component: () => import('./views/GuidesList.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
meta: { plugin: 'guides' }
},
{
path: 'guides/new',
@@ -26,7 +31,7 @@ export default [
path: 'guides/:slug',
name: 'guide-view',
component: () => import('./views/GuideView.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
meta: { plugin: 'guides' }
},
{
path: 'guides/:guideid/edit',

View File

@@ -122,10 +122,14 @@ function resetProgress () {
saveProgress()
}
// Everything that can be ticked: checklist items, and the sections a reader
// crosses off to keep their place - a step, or a heading on a guide that has
// no steps at all (a converted article often has fifty headings and none).
const totalChecks = computed(() => {
let total = 0
for (const block of guide.value?.blocks || []) {
if (block.type === 'checklist') total += (block.items || []).length
else if (block.type === 'step' || block.type === 'heading') total += 1
}
return total
})

View File

@@ -90,6 +90,10 @@ class GuidesPlugin(BasePlugin):
'icon': 'book-open',
'route': '/guides',
'position': 8,
# The nav endpoint hides an item whose permission the caller
# does not hold, so a signed-out visitor sees Guides only when
# the guest role has been granted guides.view.
'permission': 'guides.view',
},
]

View File

@@ -124,11 +124,21 @@ def plaintext(blocks):
def checklistitemcount(blocks):
"""How many tickable items a guide has, for the list and the progress bar."""
"""How many tickable things a guide has, for the card and the progress bar.
Checklist items, plus the sections a reader crosses off to keep their place:
a step, or a heading. A guide converted from an article often has fifty
headings and no steps at all, and "0 steps to tick" on a card that is
entirely tickable reads as a broken feature.
"""
total = 0
for block in blocks or []:
if isinstance(block, dict) and block.get('type') == 'checklist':
if not isinstance(block, dict):
continue
if block.get('type') == 'checklist':
total += len(block.get('items') or [])
elif block.get('type') in ('step', 'heading'):
total += 1
return total