Anyone not signed in holds the guest role
Whether somebody with no account may see something was being answered one feature at a time: a securesend_allow_guests setting, an enable flag per kiosk, and very nearly a third for guides - each with its own settings page, its own vocabulary and its own hand-written branch inside a route. Every one of them answered a permissions question, and this app already administers permissions per role. So a caller with no token is not nobody: they hold the seeded `guest` role. require_permission consults it when there is no identity, which is the entire mechanism. The role is seeded holding NOTHING, so an upgrade opens no door; a site opens one by ticking a box in the same grid it uses for every other role, and closes it the same way. Revocation is read on every request rather than cached, because taking guest access away is what somebody does the moment they realise what they opened, and it has to bite immediately. The redundant @jwt_required() above require_permission is gone from 184 routes. It was belt-and-braces before; now it is actively wrong, because it refuses a request with no token before the permission check can have an opinion, so a grant would appear to do nothing. What a guest may hold is bounded. Any .view or .read permission is grantable. A WRITE has to be blessed by whoever owns it, through the new get_guest_grantable_permissions hook (contract 0.21.0), and Secure Send is the only case that exists: sending a password to a vendor is the one write this product means to offer somebody who never signs in. Everything else is refused by the API and greyed out in the grid rather than warned about - a checkbox that quietly makes deleting an asset anonymous is not a warning anybody reads twice. A guest write records no creator: the trail says when and from where, never who, and that is the reason for the bar. GE-Enforce and Slides gained read permissions so they can be opened to guests without handing out the manage right. Their read routes accept either through require_any_permission, so nothing an existing manager could reach has moved. The navigation endpoint now returns only what the caller can actually reach. Without that a signed-out visitor gets a sidebar of the whole app where every link bounces off the login page, which reads as a broken site rather than as a closed door. A nav item may name several permissions; holding any one is enough, which is what GE-Enforce and Slides need now that a viewer and a manager hold different strings. Two things this found on the way: current_user is a LocalProxy, so `current_user is None` is False for a request with nobody behind it - harmless under a required JWT, an AttributeError the moment verification became optional. And an image pasted into a guide before its first save was never attached to it, so it was visible to editors and invisible to every reader; saving now attaches it, and `flask guides fix-images` repairs what is stored. The guides guest setting written earlier today is gone. Granting guides.view to the guest role replaces it, which is the point.
This commit is contained in:
@@ -77,7 +77,6 @@ def _summary(revision, islatest=False):
|
||||
|
||||
|
||||
@backups_bp.route('/kinds', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def list_kinds():
|
||||
"""Registered backup kinds, for UI that needs to enumerate them."""
|
||||
@@ -94,7 +93,6 @@ def list_kinds():
|
||||
|
||||
|
||||
@backups_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def asset_revisions(assetid):
|
||||
"""Revision history for one asset, newest first.
|
||||
@@ -130,7 +128,6 @@ def asset_revisions(assetid):
|
||||
|
||||
|
||||
@backups_bp.route('/asset/<int:assetid>/info', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def asset_info(assetid):
|
||||
"""A kind's 'at a glance' card, built from its LATEST revision.
|
||||
@@ -190,7 +187,6 @@ def asset_info(assetid):
|
||||
|
||||
|
||||
@backups_bp.route('/revisions/<int:backuprevisionid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def get_revision(backuprevisionid):
|
||||
"""One revision, including its parsed payload where there is one."""
|
||||
@@ -207,7 +203,6 @@ def get_revision(backuprevisionid):
|
||||
|
||||
|
||||
@backups_bp.route('/revisions/<int:backuprevisionid>/download', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.download')
|
||||
def download_revision(backuprevisionid):
|
||||
"""Render a revision back to its native file format.
|
||||
@@ -282,7 +277,6 @@ def download_revision(backuprevisionid):
|
||||
|
||||
|
||||
@backups_bp.route('/revisions/<int:backuprevisionid>/diff', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def diff_revision(backuprevisionid):
|
||||
"""Diff two revisions of the same asset+kind.
|
||||
@@ -372,7 +366,6 @@ def _diffprojections(old, new):
|
||||
|
||||
|
||||
@backups_bp.route('/dashboard/stale', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def dashboard_stale():
|
||||
"""Chains whose backup has stopped running.
|
||||
|
||||
@@ -55,7 +55,6 @@ def get_computer_type(type_id: int):
|
||||
|
||||
|
||||
@computers_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def create_computer_type():
|
||||
"""Create a new computer type."""
|
||||
@@ -93,7 +92,6 @@ def create_computer_type():
|
||||
|
||||
|
||||
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def update_computer_type(type_id: int):
|
||||
"""Update a computer type."""
|
||||
@@ -127,7 +125,6 @@ def update_computer_type(type_id: int):
|
||||
|
||||
|
||||
@computers_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.delete')
|
||||
def delete_computer_type(type_id: int):
|
||||
"""Delete a computer type. Refused if any PC still uses it."""
|
||||
@@ -159,7 +156,6 @@ def list_protocols():
|
||||
|
||||
|
||||
@computers_bp.route('/protocols', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def create_protocol():
|
||||
data = request.get_json() or {}
|
||||
@@ -180,7 +176,6 @@ def create_protocol():
|
||||
|
||||
|
||||
@computers_bp.route('/protocols/<int:protocol_id>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def update_protocol(protocol_id):
|
||||
p = db.session.get(AccessProtocol, protocol_id)
|
||||
@@ -199,7 +194,6 @@ def update_protocol(protocol_id):
|
||||
|
||||
|
||||
@computers_bp.route('/protocols/<int:protocol_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def delete_protocol(protocol_id):
|
||||
p = db.session.get(AccessProtocol, protocol_id)
|
||||
@@ -505,7 +499,6 @@ def get_computer_by_hostname(hostname: str):
|
||||
|
||||
|
||||
@computers_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def create_computer():
|
||||
"""
|
||||
@@ -620,7 +613,6 @@ def create_computer():
|
||||
|
||||
|
||||
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.edit')
|
||||
def update_computer(computer_id: int):
|
||||
"""Update computer (both Asset and Computer records)."""
|
||||
@@ -732,7 +724,6 @@ def update_computer(computer_id: int):
|
||||
|
||||
|
||||
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.delete')
|
||||
def delete_computer(computer_id: int):
|
||||
"""Delete (soft delete) computer."""
|
||||
@@ -785,7 +776,6 @@ def get_installed_apps(computer_id: int):
|
||||
|
||||
|
||||
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def add_installed_app(computer_id: int):
|
||||
"""Add an installed application to a computer."""
|
||||
@@ -842,7 +832,6 @@ def add_installed_app(computer_id: int):
|
||||
|
||||
|
||||
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.delete')
|
||||
def remove_installed_app(computer_id: int, app_id: int):
|
||||
"""Remove an installed application from a computer."""
|
||||
@@ -870,7 +859,6 @@ def remove_installed_app(computer_id: int, app_id: int):
|
||||
# =============================================================================
|
||||
|
||||
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.create')
|
||||
def report_status(computer_id: int):
|
||||
"""
|
||||
@@ -954,7 +942,6 @@ def dashboard_summary():
|
||||
|
||||
|
||||
@computers_bp.route('/dashboard/quiet', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.view')
|
||||
def dashboard_quiet():
|
||||
"""PCs that WERE reporting, have stopped, and are still meant to be in use.
|
||||
@@ -1008,7 +995,6 @@ def dashboard_quiet():
|
||||
|
||||
|
||||
@computers_bp.route('/dashboard/sharedmachines', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('computers.view')
|
||||
def dashboard_sharedmachines():
|
||||
"""Machine numbers claimed by more than one PC, with nothing filed under
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_any_permission,
|
||||
authorized_service_token, Setting, Application,
|
||||
)
|
||||
|
||||
@@ -338,8 +339,7 @@ def post_report():
|
||||
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_scopes():
|
||||
"""List imaging PC-type scopes with entry + published-version counts."""
|
||||
scopes = ManifestScope.query.order_by(
|
||||
@@ -364,8 +364,7 @@ def list_scopes():
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def preview_scope(scopeid):
|
||||
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -377,8 +376,7 @@ def preview_scope(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/applications', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_applications():
|
||||
"""The core Applications catalog (id + name) for the curated entry->app link
|
||||
picker in the entry editor."""
|
||||
@@ -446,7 +444,6 @@ def _apply_app_link(entry, payload):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_scope():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
@@ -475,8 +472,7 @@ def create_scope():
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
@@ -487,7 +483,6 @@ def get_scope(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -510,7 +505,6 @@ def update_scope(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -533,7 +527,6 @@ def _validate_entry(payload):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_entry(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -558,7 +551,6 @@ def create_entry(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
@@ -587,7 +579,6 @@ def update_entry(entryid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
@@ -599,7 +590,6 @@ def delete_entry(entryid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def reorder_entries(scopeid):
|
||||
"""Set entry order from a list of entryids (the ordering contract)."""
|
||||
@@ -621,8 +611,7 @@ def reorder_entries(scopeid):
|
||||
# -- simulate: "what would this PC get" ---------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def simulate_scope(scopeid):
|
||||
"""Which draft entries apply to a given machine profile, and why the rest
|
||||
are filtered out (reuses the engine-mirror filters)."""
|
||||
@@ -662,8 +651,7 @@ def simulate_scope(scopeid):
|
||||
# -- compliance: "how much of the fleet has this app" -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def scope_compliance(scopeid):
|
||||
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
||||
|
||||
@@ -684,7 +672,6 @@ PAYLOAD_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def upload_entry_payload(entryid):
|
||||
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
||||
@@ -709,8 +696,7 @@ def upload_entry_payload(entryid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def download_entry_payload(entryid):
|
||||
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
@@ -730,7 +716,6 @@ def download_entry_payload(entryid):
|
||||
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def publish_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -759,8 +744,7 @@ def publish_scope_route(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def publish_preflight(scopeid):
|
||||
"""Would publishing this scope outrun the fleet's enforcer lib?
|
||||
|
||||
@@ -781,8 +765,7 @@ def publish_preflight(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_versions(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
@@ -801,8 +784,7 @@ def list_versions(scopeid):
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
||||
methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_version(scopeid, versionnumber):
|
||||
version = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scopeid, versionnumber=versionnumber).first()
|
||||
@@ -830,8 +812,7 @@ def _normalize_cidrs(raw):
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_config():
|
||||
"""Plugin config: the on-share export root + the client IP allowlist."""
|
||||
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
@@ -843,7 +824,6 @@ def get_config():
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def put_config():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
@@ -870,7 +850,6 @@ def put_config():
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def export_share_route(scopeid):
|
||||
"""Write the scope's current published JSON to the configured share root
|
||||
@@ -892,7 +871,6 @@ def export_share_route(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def rollback_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -1221,8 +1199,7 @@ def _report_stale_cutoff():
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_reports():
|
||||
"""Latest enforcement report per PC (fleet compliance view).
|
||||
|
||||
@@ -1328,8 +1305,7 @@ def list_reports():
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_report(reportid):
|
||||
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
||||
report = db.session.get(ManifestEnforcementReport, reportid)
|
||||
@@ -1358,8 +1334,7 @@ def get_report(reportid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def dashboard_failures():
|
||||
"""Entries that FAILED on their PC's most recent enforcement cycle.
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ class GeEnforcePlugin(BasePlugin):
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
||||
return [
|
||||
('geenforce.view', 'View imaging PC types and manifests',
|
||||
'geenforce'),
|
||||
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
||||
'geenforce'),
|
||||
('geenforce.publish', 'Publish, roll back, and export manifests',
|
||||
@@ -125,6 +127,9 @@ class GeEnforcePlugin(BasePlugin):
|
||||
'icon': 'shield',
|
||||
'route': '/geenforce/manifests',
|
||||
'position': 46,
|
||||
# The READ permission: managing implies it, and it is the one a
|
||||
# site can hand to guests.
|
||||
'permission': ['geenforce.view', 'geenforce.manage'],
|
||||
'section': 'fleet',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -197,7 +197,6 @@ def track_click(link_id: int):
|
||||
|
||||
|
||||
@knowledgebase_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('kb.create')
|
||||
def create_article():
|
||||
"""Create a new knowledge base article."""
|
||||
@@ -231,7 +230,6 @@ def create_article():
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('kb.edit')
|
||||
def update_article(link_id: int):
|
||||
"""Update a knowledge base article."""
|
||||
@@ -261,7 +259,6 @@ def update_article(link_id: int):
|
||||
|
||||
|
||||
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('kb.delete')
|
||||
def delete_article(link_id: int):
|
||||
"""Delete (deactivate) a knowledge base article."""
|
||||
|
||||
@@ -55,7 +55,6 @@ def get_machine_type(type_id: int):
|
||||
|
||||
|
||||
@machines_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.create')
|
||||
def create_machine_type():
|
||||
"""Create a new machine type."""
|
||||
@@ -92,7 +91,6 @@ def create_machine_type():
|
||||
|
||||
|
||||
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.edit')
|
||||
def update_machine_type(type_id: int):
|
||||
"""Update a machine type."""
|
||||
@@ -126,7 +124,6 @@ def update_machine_type(type_id: int):
|
||||
|
||||
|
||||
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.delete')
|
||||
def delete_machine_type(type_id: int):
|
||||
"""Delete a machine type. Refused if any asset still uses it."""
|
||||
@@ -324,7 +321,6 @@ def get_machine_by_asset(asset_id: int):
|
||||
|
||||
|
||||
@machines_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.create')
|
||||
def create_machine():
|
||||
"""
|
||||
@@ -418,7 +414,6 @@ def create_machine():
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.edit')
|
||||
def update_machine(machine_id: int):
|
||||
"""Update machine (both Asset and Machine records)."""
|
||||
@@ -501,7 +496,6 @@ def update_machine(machine_id: int):
|
||||
|
||||
|
||||
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.delete')
|
||||
def delete_machine(machine_id: int):
|
||||
"""Delete (soft delete) machine."""
|
||||
@@ -578,7 +572,6 @@ def dashboard_summary():
|
||||
|
||||
|
||||
@machines_bp.route('/dashboard/outofservice', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('machines.view')
|
||||
def dashboard_outofservice():
|
||||
"""Machines whose status says they are not running.
|
||||
|
||||
@@ -75,7 +75,6 @@ def get_type(type_id: int):
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.create')
|
||||
def create_type():
|
||||
"""Create a measuring-tool type (reactivates a soft-deleted same-named one)."""
|
||||
@@ -108,7 +107,6 @@ def create_type():
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.edit')
|
||||
def update_type(type_id: int):
|
||||
"""Update a measuring-tool type."""
|
||||
@@ -130,7 +128,6 @@ def update_type(type_id: int):
|
||||
|
||||
|
||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.delete')
|
||||
def delete_type(type_id: int):
|
||||
"""Delete a measuring-tool type. Refused if any tool still uses it."""
|
||||
@@ -239,7 +236,6 @@ def _asset_type_id():
|
||||
|
||||
|
||||
@measuringtools_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.create')
|
||||
def create_tool():
|
||||
"""Create a measuring tool: one Asset core row + one extension row."""
|
||||
@@ -305,7 +301,6 @@ _TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
||||
|
||||
|
||||
@measuringtools_bp.route('/<int:tool_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.edit')
|
||||
def update_tool(tool_id: int):
|
||||
"""Update a measuring tool (asset core fields + extension in one payload)."""
|
||||
@@ -357,7 +352,6 @@ def update_tool(tool_id: int):
|
||||
|
||||
|
||||
@measuringtools_bp.route('/<int:tool_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('measuringtools.delete')
|
||||
def delete_tool(tool_id: int):
|
||||
"""Soft delete a measuring tool (deactivates its asset)."""
|
||||
|
||||
@@ -85,7 +85,6 @@ def get_network_device_type(type_id: int):
|
||||
|
||||
|
||||
@network_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('network.create')
|
||||
def create_network_device_type():
|
||||
"""Create a new network device type."""
|
||||
@@ -123,7 +122,6 @@ def create_network_device_type():
|
||||
|
||||
|
||||
@network_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('network.edit')
|
||||
def update_network_device_type(type_id: int):
|
||||
"""Update a network device type."""
|
||||
@@ -158,7 +156,6 @@ def update_network_device_type(type_id: int):
|
||||
|
||||
|
||||
@network_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('network.delete')
|
||||
def delete_network_device_type(type_id: int):
|
||||
"""Delete a network device type. Refused if any device still uses it."""
|
||||
@@ -347,7 +344,6 @@ def get_network_device_by_hostname(hostname: str):
|
||||
|
||||
|
||||
@network_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('network.create')
|
||||
def create_network_device():
|
||||
"""
|
||||
@@ -464,7 +460,6 @@ def create_network_device():
|
||||
|
||||
|
||||
@network_bp.route('/<int:device_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('network.edit')
|
||||
def update_network_device(device_id: int):
|
||||
"""Update network device (both Asset and NetworkDevice records)."""
|
||||
@@ -559,7 +554,6 @@ def update_network_device(device_id: int):
|
||||
|
||||
|
||||
@network_bp.route('/<int:device_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('network.delete')
|
||||
def delete_network_device(device_id: int):
|
||||
"""Delete (soft delete) network device."""
|
||||
@@ -690,7 +684,6 @@ def get_vlan(vlan_id: int):
|
||||
|
||||
|
||||
@network_bp.route('/vlans', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('network.create')
|
||||
def create_vlan():
|
||||
"""Create a new VLAN."""
|
||||
@@ -732,7 +725,6 @@ def create_vlan():
|
||||
|
||||
|
||||
@network_bp.route('/vlans/<int:vlan_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('network.edit')
|
||||
def update_vlan(vlan_id: int):
|
||||
"""Update a VLAN."""
|
||||
@@ -778,7 +770,6 @@ def update_vlan(vlan_id: int):
|
||||
|
||||
|
||||
@network_bp.route('/vlans/<int:vlan_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('network.delete')
|
||||
def delete_vlan(vlan_id: int):
|
||||
"""Delete (soft delete) a VLAN."""
|
||||
@@ -932,7 +923,6 @@ def _asset_detail_url(assettype, assetid):
|
||||
|
||||
|
||||
@network_bp.route('/subnets', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('network.create')
|
||||
def create_subnet():
|
||||
"""Create a new subnet."""
|
||||
@@ -998,7 +988,6 @@ def create_subnet():
|
||||
|
||||
|
||||
@network_bp.route('/subnets/<int:subnet_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('network.edit')
|
||||
def update_subnet(subnet_id: int):
|
||||
"""Update a subnet."""
|
||||
@@ -1049,7 +1038,6 @@ def update_subnet(subnet_id: int):
|
||||
|
||||
|
||||
@network_bp.route('/subnets/<int:subnet_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('network.delete')
|
||||
def delete_subnet(subnet_id: int):
|
||||
"""Delete (soft delete) a subnet."""
|
||||
|
||||
@@ -257,7 +257,6 @@ def list_notification_types():
|
||||
|
||||
|
||||
@notifications_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification_type():
|
||||
"""Create a new notification type."""
|
||||
@@ -293,7 +292,6 @@ def create_notification_type():
|
||||
|
||||
|
||||
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def update_notification_type(type_id: int):
|
||||
"""Update a notification type, including its auto-expiry rule."""
|
||||
@@ -405,7 +403,6 @@ def get_notification(notification_id: int):
|
||||
|
||||
|
||||
@notifications_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.create')
|
||||
def create_notification():
|
||||
"""Create a new notification."""
|
||||
@@ -465,7 +462,6 @@ def create_notification():
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.edit')
|
||||
def update_notification(notification_id: int):
|
||||
"""Update a notification."""
|
||||
@@ -532,7 +528,6 @@ def update_notification(notification_id: int):
|
||||
|
||||
|
||||
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('notifications.delete')
|
||||
def delete_notification(notification_id: int):
|
||||
"""Delete (soft delete) a notification."""
|
||||
|
||||
@@ -29,7 +29,6 @@ printedparts_bp = Blueprint('printedparts', __name__)
|
||||
|
||||
|
||||
@printedparts_bp.route('/items', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.view')
|
||||
def list_items():
|
||||
"""List printed items, paginated; search + low-stock filter."""
|
||||
@@ -56,7 +55,6 @@ def list_items():
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.view')
|
||||
def get_item(item_id: int):
|
||||
"""Get one printed item with its recent transactions."""
|
||||
@@ -101,7 +99,6 @@ def _mint_itemcode(item):
|
||||
|
||||
|
||||
@printedparts_bp.route('/items', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.create')
|
||||
def create_item():
|
||||
"""Create a printed item.
|
||||
@@ -141,7 +138,6 @@ def create_item():
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.edit')
|
||||
def update_item(item_id: int):
|
||||
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
||||
@@ -174,7 +170,6 @@ def update_item(item_id: int):
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def delete_item(item_id: int):
|
||||
"""Soft-retire an item; its ledger history stays."""
|
||||
@@ -188,7 +183,6 @@ def delete_item(item_id: int):
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def restore_item(item_id: int):
|
||||
"""Bring a retired item back; code, photo, and history are intact."""
|
||||
@@ -204,7 +198,6 @@ def restore_item(item_id: int):
|
||||
# --- item image: the models.py upload/serve/delete trio ---------------------
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.edit')
|
||||
def upload_item_image(item_id: int):
|
||||
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
||||
@@ -240,7 +233,6 @@ def serve_item_image(filename):
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def delete_item_image(item_id: int):
|
||||
"""Clear an item image; delete the file only if this plugin owns it."""
|
||||
@@ -387,7 +379,6 @@ def _alert_team_webhook():
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.restock')
|
||||
def restock_item(item_id: int):
|
||||
"""Add freshly printed stock. Body: {quantity, badge}."""
|
||||
@@ -410,7 +401,6 @@ def restock_item(item_id: int):
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.restock')
|
||||
def adjust_item(item_id: int):
|
||||
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
||||
@@ -668,7 +658,6 @@ def _uploader_name():
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.view')
|
||||
def list_item_files(item_id: int):
|
||||
"""Revision history, newest first."""
|
||||
@@ -678,7 +667,6 @@ def list_item_files(item_id: int):
|
||||
|
||||
|
||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.edit')
|
||||
def upload_item_file(item_id: int):
|
||||
"""Upload the next revision of the item's print file.
|
||||
@@ -747,7 +735,6 @@ def download_item_file(file_id: int):
|
||||
|
||||
|
||||
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printedparts.delete')
|
||||
def delete_item_file(file_id: int):
|
||||
"""Remove a bad revision (wrong file uploaded). History otherwise stays."""
|
||||
|
||||
@@ -107,6 +107,9 @@ class PrintedpartsPlugin(BasePlugin):
|
||||
'icon': 'box',
|
||||
'route': '/printedparts',
|
||||
'position': 27,
|
||||
# Named so the nav can hide it from anybody - a signed-out
|
||||
# visitor included - who does not hold it.
|
||||
'permission': 'printedparts.view',
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -68,7 +68,6 @@ def get_printer_type(type_id: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/types', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.create')
|
||||
def create_printer_type():
|
||||
"""Create a new printer type."""
|
||||
@@ -105,7 +104,6 @@ def create_printer_type():
|
||||
|
||||
|
||||
@printers_asset_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def update_printer_type(type_id: int):
|
||||
"""Update a printer type."""
|
||||
@@ -129,7 +127,6 @@ def update_printer_type(type_id: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.delete')
|
||||
def delete_printer_type(type_id: int):
|
||||
"""Delete a printer type. Refused if any printer still uses it."""
|
||||
@@ -161,7 +158,6 @@ def list_drivers():
|
||||
|
||||
|
||||
@printers_asset_bp.route('/drivers', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.create')
|
||||
def create_driver():
|
||||
data = request.get_json() or {}
|
||||
@@ -180,7 +176,6 @@ def create_driver():
|
||||
|
||||
|
||||
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def update_driver(driver_id):
|
||||
d = db.session.get(PrinterDriver, driver_id)
|
||||
@@ -197,7 +192,6 @@ def update_driver(driver_id):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.delete')
|
||||
def delete_driver(driver_id):
|
||||
d = db.session.get(PrinterDriver, driver_id)
|
||||
@@ -993,7 +987,6 @@ def get_asset_printer_assignment(asset_id: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def set_asset_printer_assignment(asset_id: int):
|
||||
"""Reconcile one asset's whole printer assignment in a single call.
|
||||
@@ -1498,7 +1491,6 @@ def _observed_host_block(hostname, asset, index):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/observed/<hostname>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.view')
|
||||
def observed_for_host(hostname: str):
|
||||
"""What a host last reported, each queue judged against what it is assigned.
|
||||
@@ -1536,7 +1528,6 @@ def observed_for_host(hostname: str):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/observed/for-asset/<int:asset_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.view')
|
||||
def observed_for_asset(asset_id: int):
|
||||
"""The same comparison, reached from an asset page: one block per host.
|
||||
@@ -1649,7 +1640,6 @@ def _seed_source_hostnames(asset):
|
||||
|
||||
@printers_asset_bp.route('/assignments/seed-from-observed/<int:asset_id>',
|
||||
methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def seed_assignment_from_observed(asset_id: int):
|
||||
"""Write what a PC observed as the assignment of the asset in the path.
|
||||
@@ -1840,7 +1830,6 @@ def get_printer_by_asset(asset_id: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.create')
|
||||
def create_printer():
|
||||
"""
|
||||
@@ -1944,7 +1933,6 @@ def create_printer():
|
||||
|
||||
|
||||
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def update_printer(printer_id: int):
|
||||
"""Update printer (both Asset and Printer records)."""
|
||||
@@ -2031,7 +2019,6 @@ def update_printer(printer_id: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.delete')
|
||||
def delete_printer(printer_id: int):
|
||||
"""Delete (soft delete) printer."""
|
||||
@@ -2303,7 +2290,6 @@ def printer_lookup():
|
||||
|
||||
|
||||
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.create')
|
||||
def refresh_supplies_cache():
|
||||
"""Clear cached Zabbix supply data so the next read pulls fresh values.
|
||||
@@ -2485,7 +2471,6 @@ def list_model_supplies(modelnumberid: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.create')
|
||||
def create_model_supply(modelnumberid: int):
|
||||
"""Add a supply to a model."""
|
||||
@@ -2526,7 +2511,6 @@ def create_model_supply(modelnumberid: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def update_model_supply(modelsupplyid: int):
|
||||
"""Update a model supply."""
|
||||
@@ -2571,7 +2555,6 @@ def update_model_supply(modelsupplyid: int):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.delete')
|
||||
def delete_model_supply(modelsupplyid: int):
|
||||
"""Delete a model supply."""
|
||||
@@ -2615,7 +2598,6 @@ def _reordertip(supply):
|
||||
|
||||
|
||||
@printers_asset_bp.route('/dashboard/supplies', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.view')
|
||||
def dashboard_supplies():
|
||||
"""Printers needing a cartridge, flattened to one row per printer.
|
||||
|
||||
@@ -14,7 +14,7 @@ from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import (db, success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
require_permission, require_any_permission)
|
||||
from shopdb.api import send_upload
|
||||
|
||||
from ..models import TvSlide
|
||||
@@ -90,8 +90,7 @@ def serve_image(surface, filename):
|
||||
# =============================================================================
|
||||
|
||||
@slides_bp.route('/<surface>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
@require_any_permission('slides.view', 'slides.manage')
|
||||
def list_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
||||
@@ -108,7 +107,6 @@ def list_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/upload', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def upload_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
@@ -153,7 +151,6 @@ def upload_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/order', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def reorder_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
@@ -169,7 +166,6 @@ def reorder_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/delete', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def delete_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
@@ -195,7 +191,6 @@ def delete_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/<int:slideid>', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def update_slide(surface, slideid):
|
||||
if not _valid_surface(surface):
|
||||
|
||||
@@ -63,6 +63,7 @@ class SlidesPlugin(BasePlugin):
|
||||
non-admin role so a curator can manage the lobby TV + screensaver
|
||||
without full admin. Admins hold it by default."""
|
||||
return [
|
||||
('slides.view', 'View the slide decks', 'slides'),
|
||||
('slides.manage',
|
||||
'Manage lobby display and screensaver slides', 'slides'),
|
||||
]
|
||||
@@ -76,7 +77,10 @@ class SlidesPlugin(BasePlugin):
|
||||
'icon': 'image',
|
||||
'route': '/settings/slides',
|
||||
'position': 7,
|
||||
'permission': 'slides.manage',
|
||||
# The read permission: a manager holds it implicitly through
|
||||
# the route's require_any_permission, and it is the one a site
|
||||
# can grant to guests.
|
||||
'permission': ['slides.view', 'slides.manage'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -70,6 +70,19 @@ class ToolsPlugin(BasePlugin):
|
||||
('tools.secrets', 'Create secure send links', 'tools'),
|
||||
]
|
||||
|
||||
def get_guest_grantable_permissions(self) -> List[str]:
|
||||
"""Creating a secure send link is meant to work without an account.
|
||||
|
||||
Somebody standing at a bench needs to send a password to a vendor. The
|
||||
recipient never had an account, and often neither does the sender - the
|
||||
unguessable token is the authorisation, and the link is one-view.
|
||||
|
||||
A guest-created link records no creator. The audit trail says when and
|
||||
from where, and never who. That is the cost, and it is why this is the
|
||||
only write in the product offered to guests at all.
|
||||
"""
|
||||
return ['tools.secrets']
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""The one switch this plugin owns.
|
||||
|
||||
|
||||
@@ -338,7 +338,6 @@ def get_usb_device(device_id):
|
||||
|
||||
|
||||
@usb_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('usb.create')
|
||||
def create_usb_device():
|
||||
"""Add a device (starts checked-in)."""
|
||||
@@ -385,7 +384,6 @@ def create_usb_device():
|
||||
|
||||
|
||||
@usb_bp.route('/<device_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('usb.edit')
|
||||
def update_usb_device(device_id):
|
||||
"""Edit device_desc / device_owner / locker_location / status."""
|
||||
@@ -428,7 +426,6 @@ def update_usb_device(device_id):
|
||||
|
||||
|
||||
@usb_bp.route('/<device_id>/retire', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('usb.edit')
|
||||
def retire_usb_device(device_id):
|
||||
"""Retire a device (status -> retired)."""
|
||||
@@ -463,7 +460,6 @@ def retire_usb_device(device_id):
|
||||
# =============================================================================
|
||||
|
||||
@usb_bp.route('/<device_id>/checkout', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('usb.create')
|
||||
def checkout_device(device_id):
|
||||
"""Check a device out to a badge."""
|
||||
@@ -528,7 +524,6 @@ def checkout_device(device_id):
|
||||
|
||||
|
||||
@usb_bp.route('/<device_id>/checkin', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('usb.create')
|
||||
def checkin_device(device_id):
|
||||
"""Check a device back in from a badge."""
|
||||
@@ -1111,7 +1106,6 @@ AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge',
|
||||
|
||||
|
||||
@usb_bp.route('/reports/audit', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('usb.view')
|
||||
def report_audit():
|
||||
"""Every check-out and check-in in the window, newest first.
|
||||
|
||||
@@ -197,7 +197,6 @@ def get_warranty(warrantyid):
|
||||
|
||||
|
||||
@warranty_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.create')
|
||||
def create_warranty():
|
||||
data = request.get_json() or {}
|
||||
@@ -220,7 +219,6 @@ def create_warranty():
|
||||
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def update_warranty(warrantyid):
|
||||
warranty = db.session.get(Warranty, warrantyid)
|
||||
@@ -250,7 +248,6 @@ def update_warranty(warrantyid):
|
||||
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.delete')
|
||||
def delete_warranty(warrantyid):
|
||||
warranty = db.session.get(Warranty, warrantyid)
|
||||
@@ -266,7 +263,6 @@ def delete_warranty(warrantyid):
|
||||
# =============================================================================
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>/refresh', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def refresh_warranty(warrantyid):
|
||||
warranty = db.session.get(Warranty, warrantyid)
|
||||
@@ -296,7 +292,6 @@ def refresh_warranty(warrantyid):
|
||||
# =============================================================================
|
||||
|
||||
@warranty_bp.route('/sync/dell', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def sync_dell():
|
||||
"""Look up every asset serial (= Dell service tag) that has no warranty yet
|
||||
@@ -414,7 +409,6 @@ def warranty_report():
|
||||
|
||||
|
||||
@warranty_bp.route('/dashboard/expiring', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.view')
|
||||
def dashboard_expiring():
|
||||
"""Warranties running out, and ones that already have.
|
||||
@@ -498,7 +492,6 @@ def _hostname(asset):
|
||||
# =============================================================================
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>/proof', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def upload_proof(warrantyid):
|
||||
"""Upload (or replace) the proof-of-cover document for a warranty."""
|
||||
@@ -548,7 +541,6 @@ def upload_proof(warrantyid):
|
||||
|
||||
|
||||
@warranty_bp.route('/proof/<path:filename>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.view')
|
||||
def serve_proof(filename):
|
||||
"""Download a proof document.
|
||||
@@ -566,7 +558,6 @@ def serve_proof(filename):
|
||||
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>/proof', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def delete_proof(warrantyid):
|
||||
"""Remove a warranty's proof document."""
|
||||
|
||||
Reference in New Issue
Block a user