guest role; guide section ticking
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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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