Inverting the grantable rule from the .view suffix to explicit declaration dropped every permission that had qualified by how it was spelled. Two of them were shipped guest features with grants already in the field. guides.view: the nav item and list_guides both reason in comments about a signed-out reader holding it, and the route is written around one - drafts are filtered out, search matches the published revision so draft wording cannot leak through a hit, and the author branches cannot open for a caller with no user id. tools.view: without it a guest holding tools.secrets has permission to use Secure Send and no way to reach the page, because tools.view is what puts the entry in the nav. The tools themselves run in the browser and touch no data. The failure was worse than a missing checkbox. update_role refuses the WHOLE request when any submitted permission is undeclared, and the role grid resubmits what the role already holds, so a site that granted either of these under the old rule could no longer save the guest role at all - including to tick assets.view, which is the grant the shop floor needs to look up an asset number without signing in. The test asserts both stay grantable, so narrowing the set again means looking at what is already granted in the field rather than only at what the catalogue declares. Scope: product
126 lines
5.1 KiB
Python
126 lines
5.1 KiB
Python
"""Guides plugin.
|
|
|
|
Step-by-step procedures written and read inside ShopDB: images, working
|
|
checklists, dark mode and printable output, with drafts and revision history.
|
|
|
|
Distinct from the knowledgebase plugin, which is a LINK REGISTRY - a
|
|
description, a URL and keywords pointing at a document held somewhere else.
|
|
This holds the document. They share a tag vocabulary on purpose, so one search
|
|
can cover both.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Type
|
|
|
|
from flask import Flask, Blueprint
|
|
|
|
from shopdb.plugins.base import BasePlugin, PluginMeta
|
|
|
|
from .models import Guide, GuideRevision, GuideImage
|
|
from .api import guides_bp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class GuidesPlugin(BasePlugin):
|
|
|
|
def __init__(self):
|
|
self._manifest = self._load_manifest()
|
|
|
|
def _load_manifest(self) -> Dict:
|
|
manifest_path = Path(__file__).parent / 'manifest.json'
|
|
if manifest_path.exists():
|
|
with open(manifest_path, 'r') as fh:
|
|
return json.load(fh)
|
|
return {}
|
|
|
|
@property
|
|
def meta(self) -> PluginMeta:
|
|
return PluginMeta(
|
|
name=self._manifest.get('name', 'guides'),
|
|
version=self._manifest.get('version', '1.0.0'),
|
|
description=self._manifest.get('description', 'Step-by-step guides'),
|
|
author=self._manifest.get('author', 'ShopDB Team'),
|
|
dependencies=self._manifest.get('dependencies', []),
|
|
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
|
|
api_prefix=self._manifest.get('api_prefix', '/api/guides'),
|
|
)
|
|
|
|
def get_blueprint(self) -> Optional[Blueprint]:
|
|
return guides_bp
|
|
|
|
def get_models(self) -> List[Type]:
|
|
return [Guide, GuideRevision, GuideImage]
|
|
|
|
def get_permissions(self) -> List:
|
|
"""Five permissions, and the split between the last two is the point.
|
|
|
|
publish is separate from edit so a technician can write up a procedure
|
|
while somebody else decides it is fit for the floor. Without that split,
|
|
anyone who can fix a typo can also put an unreviewed procedure in front
|
|
of an operator.
|
|
|
|
create without edit is a working combination on purpose: an author may
|
|
edit their OWN guide while it is unpublished, and needs guides.edit once
|
|
other people are following it (see _mayedit in the API).
|
|
"""
|
|
return [
|
|
('guides.view', 'View guides', 'guides'),
|
|
('guides.create', 'Create guides and edit your own drafts', 'guides'),
|
|
('guides.edit', 'Edit any guide, including published ones', 'guides'),
|
|
('guides.publish', 'Publish or withdraw a guide', 'guides'),
|
|
('guides.delete', 'Delete guides', 'guides'),
|
|
]
|
|
|
|
def get_guest_grantable_permissions(self) -> List[str]:
|
|
"""A procedure is for whoever is standing at the machine.
|
|
|
|
Guides are written to be followed on the floor, by people who often
|
|
have no account and no reason to get one. The nav item and the API
|
|
route both already reason about a signed-out reader holding this, so
|
|
the grant was intended from the start; it qualified under the old
|
|
.view suffix rule and is written down here now that a grant has to be
|
|
declared.
|
|
|
|
What it reaches is bounded by the routes, not by this: list_guides
|
|
returns published guides only, matching search against the PUBLISHED
|
|
revision so draft wording cannot leak through a hit, and a guest can
|
|
never take the author branches because a guest has no user id. Guide
|
|
history stays with the editors. Publishing, editing and deleting are
|
|
separate permissions and none of them is grantable.
|
|
"""
|
|
return ['guides.view']
|
|
|
|
def get_navigation_items(self) -> List[Dict]:
|
|
# Position 8, directly under Slides (7), rather than down in the
|
|
# Information section with the reference lists. A guide is something you
|
|
# go and DO something with, like a slide deck you put on a screen - not
|
|
# a catalogue you look things up in.
|
|
#
|
|
# Warranties held 8 as well, and a TIE is broken by plugin load order,
|
|
# so Guides landed underneath it and read as "not under Slides at all".
|
|
# Warranties moved to 9. Two nav items sharing a position is not a
|
|
# preference, it is a coin toss.
|
|
return [
|
|
{
|
|
'name': 'Guides',
|
|
'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',
|
|
},
|
|
]
|
|
|
|
def get_cli_commands(self) -> List:
|
|
"""CLI: import an existing HTML article as a draft guide."""
|
|
from .cli import build_cli
|
|
return build_cli()
|
|
|
|
def init_app(self, app: Flask, db_instance) -> None:
|
|
logger.info('Guides plugin initialized (v%s)', self.meta.version)
|