Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	shopdb/__init__.py
This commit is contained in:
benjamin.baker
2026-08-28 09:30:35 -04:00
112 changed files with 5502 additions and 604 deletions

View File

@@ -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.

View File

@@ -5,10 +5,14 @@ changed - so retention exists for the pathological case, not the normal one: a
value that flaps would otherwise append a revision every collection cycle
forever.
Two independent limits, both off by default at 0:
Two independent limits. Both are OFF in this module - the arguments below
default to 0 - but the plugin SEEDS `backups_retentioncount` at 50, so on a
default install count-based pruning is on and age-based pruning is not. Read
the seeded settings in plugin.py, not these argument defaults, when answering
"is retention on here".
retentioncount keep at most N revisions per chain
retentiondays additionally drop anything older than N days
retentioncount keep at most N revisions per chain (setting default: 50)
retentiondays additionally drop anything older than N days (default: off)
A CHAIN is one asset, one kind, one source hostname. Several PCs can share a
machine number, and each keeps its own revision history against that machine;

View File

@@ -17,7 +17,7 @@ computers_bp = Blueprint('computers', __name__)
# =============================================================================
@computers_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def list_computer_types():
"""List all computer types."""
page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_computer_types():
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def get_computer_type(type_id: int):
"""Get a single computer type."""
t = db.session.get(ComputerType, type_id)
@@ -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."""
@@ -148,7 +145,7 @@ def delete_computer_type(type_id: int):
# =============================================================================
@computers_bp.route('/protocols', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def list_protocols():
"""List access protocols. ?active=false includes disabled ones."""
query = AccessProtocol.query
@@ -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)
@@ -299,7 +293,7 @@ def _sync_access_methods(comp, data):
# =============================================================================
@computers_bp.route('/display-kiosks', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def list_display_kiosks():
"""Reporting display kiosks, for the Dashboard Defaults picker.
@@ -335,7 +329,7 @@ def list_display_kiosks():
@computers_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def list_computers():
"""
List all computers with filtering and pagination.
@@ -443,7 +437,7 @@ def list_computers():
@computers_bp.route('/<int:computer_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def get_computer(computer_id: int):
"""Get a single computer with full details."""
comp = db.session.get(Computer, computer_id)
@@ -467,7 +461,7 @@ def get_computer(computer_id: int):
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def get_computer_by_asset(asset_id: int):
"""Get computer data by asset ID."""
comp = Computer.query.filter_by(assetid=asset_id).first()
@@ -486,7 +480,7 @@ def get_computer_by_asset(asset_id: int):
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def get_computer_by_hostname(hostname: str):
"""Get computer by hostname."""
comp = Computer.query.filter_by(hostname=hostname).first()
@@ -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():
"""
@@ -623,7 +616,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)."""
@@ -736,7 +728,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."""
@@ -766,7 +757,7 @@ def delete_computer(computer_id: int):
# =============================================================================
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def get_installed_apps(computer_id: int):
"""Get all installed applications for a computer."""
comp = db.session.get(Computer, computer_id)
@@ -789,7 +780,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."""
@@ -846,7 +836,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."""
@@ -874,7 +863,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):
"""
@@ -913,7 +901,7 @@ def report_status(computer_id: int):
# =============================================================================
@computers_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
@require_permission('computers.view')
def dashboard_summary():
"""Get computer dashboard summary data."""
# Total active computers
@@ -958,7 +946,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.
@@ -1012,7 +999,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

View File

@@ -1974,7 +1974,9 @@ class ComputersPlugin(BasePlugin):
'name': 'PCs',
'icon': 'desktop',
'route': '/pcs',
'permission': 'computers.view',
'position': 15,
'section': 'assets',
},
]

View File

@@ -29,6 +29,7 @@ from shopdb.api import Setting
from shopdb.api import send_upload
from ..models import DirectoryEmployee
from shopdb.api import require_permission
logger = logging.getLogger(__name__)
@@ -198,6 +199,7 @@ def _with_photo_url(employee):
@employees_bp.route('/resolve/<sso>', methods=['GET'])
@require_permission('employees.view')
@jwt_required()
def resolve_employee(sso):
"""Resolve one SSO to a name and photo, and say HOW.
@@ -275,6 +277,7 @@ def resolve_employee(sso):
@employees_bp.route('/search', methods=['GET'])
@require_permission('employees.view')
def search_employees():
"""
Search employees by name.
@@ -327,6 +330,7 @@ def search_employees():
@employees_bp.route('/lookup/<sso>', methods=['GET'])
@require_permission('employees.view')
def lookup_employee(sso):
"""Look up a single employee by SSO."""
if not sso.isdigit():
@@ -370,6 +374,7 @@ def lookup_employee(sso):
@employees_bp.route('/lookup', methods=['GET'])
@require_permission('employees.view')
def lookup_employees():
"""
Look up multiple employees by SSO list.
@@ -429,7 +434,7 @@ def lookup_employees():
# =============================================================================
@employees_bp.route('/directory', methods=['GET'])
@jwt_required(optional=True)
@require_permission('employees.view')
def list_directory():
"""Full self-hosted directory (for the management page)."""
guard = _require_selfhosted()

View File

@@ -51,6 +51,18 @@ class EmployeesPlugin(BasePlugin):
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
)
def get_permissions(self) -> List:
"""Reading the staff directory.
The directory is names, SSOs, photographs and who reports to whom. It
was reachable with no account at all, which is how a permissions
question ends up answered by nobody. One read permission: the write
side of this plugin is an import, run from the CLI.
"""
return [
('employees.view', 'View the employee directory', 'employees'),
]
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return employees_bp

View File

@@ -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.

View File

@@ -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,10 @@ 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',
},
]

View File

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

378
plugins/guides/cli.py Normal file
View File

@@ -0,0 +1,378 @@
"""CLI: move guides in and out of a site.
flask guides import-html PATH [PATH ...] an HTML article becomes a guide
flask guides export --out DIR guides become portable files
flask guides import PATH [PATH ...] those files become guides here
A site that adopts this app usually arrives with a folder of procedures already
written - styled HTML pages that get mailed around and opened off a share. This
imports them so they live where they are used, become searchable, tickable and
printable, and stop being a file somebody has to find.
Imports land as DRAFTS, always - both kinds. An HTML conversion is faithful but
it is not a render, and a bundle written on another site has not been read by
anybody here. Publishing is a decision somebody makes after reading.
Sites are separate databases and the useful ones are air-gapped from where a
guide gets written, so export/import is how the same procedure reaches all of
them: one JSON file per guide, images carried inside it, moved by whatever
moves files to that site.
"""
import click
from flask.cli import AppGroup, with_appcontext
from shopdb.api import db
from .models import Guide, GuideImage
from .services import authoring, bundle as bundleservice
from .services.blocks import BlockError
from .services.htmlimport import parse
# Mirrors what the upload route accepts. An import must not be able to store an
# image the editor would have refused, or the guide renders here and nowhere
# else.
ALLOWEDIMAGETYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp')
MAXIMAGEBYTES = 8 * 1024 * 1024
def _slugify(title):
"""Same rule as the API's, kept here so the CLI does not import a route."""
import re
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
base = base[:180]
candidate = base
suffix = 2
while Guide.query.filter(Guide.slug == candidate).first() is not None:
candidate = '{}-{}'.format(base, suffix)
suffix += 1
return candidate
def build_cli():
guides_cli = AppGroup('guides')
@guides_cli.command('import-html')
@click.argument('paths', nargs=-1, required=True,
type=click.Path(exists=True, dir_okay=False))
@click.option('--title', default=None,
help='Override the title taken from the article.')
@click.option('--tags', default=None,
help='Comma-separated tags for every guide imported.')
@click.option('--userid', type=int, default=None,
help='Record this user as the author.')
@click.option('--replace', is_flag=True,
help='If a guide already has this slug, edit it in place '
'(a new revision) instead of creating a second one.')
@click.option('--apply/--dry-run', default=False,
help='Write to the database. The default reports only.')
@with_appcontext
def import_html(paths, title, tags, userid, replace, apply):
"""Convert HTML article(s) into draft guides."""
if title and len(paths) > 1:
raise click.UsageError('--title names one guide; pass one file.')
for path in paths:
with open(path, encoding='utf-8', errors='replace') as handle:
parsed = parse(handle.read())
name = title or parsed['title']
counts = {}
for block in parsed['blocks']:
counts[block['type']] = counts.get(block['type'], 0) + 1
click.echo('{}'.format(path))
click.echo(' title {}'.format(name))
click.echo(' blocks {} ({})'.format(
len(parsed['blocks']),
', '.join('{} {}'.format(v, k) for k, v in sorted(counts.items()))))
click.echo(' images {}'.format(len(parsed['images'])))
for note in parsed['skipped']:
click.echo(' skipped {}'.format(note))
rejected = [image for image in parsed['images']
if image['mimetype'] not in ALLOWEDIMAGETYPES
or len(image['data']) > MAXIMAGEBYTES]
for image in rejected:
click.echo(' DROPPED {} ({}, {:.1f} MB) - not an accepted image'
.format(image['token'], image['mimetype'],
len(image['data']) / 1048576))
if not apply:
click.echo(' dry run, nothing written. Pass --apply to import.')
continue
try:
guide = _import(parsed, name, tags, userid, replace, rejected)
except BlockError as error:
db.session.rollback()
click.echo(' FAILED {}'.format(error))
continue
db.session.commit()
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),
help='Directory to write the bundles into. Created if needed.')
@click.option('--slug', 'slugs', multiple=True,
help='Export just this guide. Repeatable. Default: all.')
@click.option('--published-only', is_flag=True,
help='Skip guides that have never been published.')
@with_appcontext
def export_guides(outdir, slugs, published_only):
"""Write guides out as portable .guide.json files."""
import os
query = Guide.query
if slugs:
query = query.filter(Guide.slug.in_(slugs))
if published_only:
query = query.filter(Guide.publishedrevisionid.isnot(None))
guides = query.order_by(Guide.slug).all()
missing = set(slugs) - {guide.slug for guide in guides}
for slug in sorted(missing):
click.echo(' NOT FOUND {}'.format(slug))
if not guides:
click.echo('Nothing to export.')
return
os.makedirs(outdir, exist_ok=True)
for guide in guides:
images = GuideImage.query.filter_by(guideid=guide.guideid).all()
payload = bundleservice.build(guide, images)
path = os.path.join(outdir, '{}.guide.json'.format(guide.slug))
with open(path, 'w', encoding='utf-8') as handle:
handle.write(bundleservice.dumps(payload))
click.echo(' {} {} blocks, {} image(s), {:.0f} KB{}'.format(
os.path.basename(path), len(payload['blocks']),
len(payload['images']), os.path.getsize(path) / 1024,
'' if guide.publishedrevisionid else ' [draft here]'))
click.echo('{} guide(s) -> {}'.format(len(guides), outdir))
@guides_cli.command('import')
@click.argument('paths', nargs=-1, required=True,
type=click.Path(exists=True))
@click.option('--userid', type=int, default=None,
help='Record this user as the author on this site.')
@click.option('--replace', is_flag=True,
help='If a guide already has the bundle\'s slug, edit it in '
'place (a new revision) instead of creating a second '
'one. This is how an updated guide is re-shipped.')
@click.option('--apply/--dry-run', default=False,
help='Write to the database. The default reports only.')
@with_appcontext
def import_guides(paths, userid, replace, apply):
"""Import .guide.json bundle(s) written by `guides export`."""
import os
files = []
for path in paths:
if os.path.isdir(path):
files.extend(sorted(os.path.join(path, name)
for name in os.listdir(path)
if name.endswith('.guide.json')))
else:
files.append(path)
if not files:
click.echo('No .guide.json files found.')
return
for path in files:
click.echo(os.path.basename(path))
try:
with open(path, encoding='utf-8') as handle:
payload = bundleservice.loads(handle.read())
except bundleservice.BundleError as error:
click.echo(' SKIPPED {}'.format(error))
continue
existing = Guide.query.filter(Guide.slug == payload['slug']).first()
click.echo(' title {}'.format(payload.get('title')))
click.echo(' blocks {}, images {}'.format(
len(payload['blocks']), len(payload['images'])))
if existing is not None:
click.echo(' {} already has this slug{}'.format(
'guide {}'.format(existing.guideid),
' - will be updated in place' if replace
else ' - a SECOND guide will be created; pass --replace to '
'update it instead'))
rejected = [image for image in payload['images']
if image['mimetype'] not in ALLOWEDIMAGETYPES
or len(image['data']) > MAXIMAGEBYTES]
for image in rejected:
click.echo(' DROPPED {} ({}) - not an accepted image'
.format(image['token'], image['mimetype']))
if not apply:
click.echo(' dry run, nothing written. Pass --apply to import.')
continue
try:
guide = _importbundle(payload, userid, replace, rejected)
except BlockError as error:
db.session.rollback()
click.echo(' FAILED {}'.format(error))
continue
db.session.commit()
click.echo(' imported as /guides/{} (draft, revision {})'
.format(guide.slug, guide.revision))
return [guides_cli]
def _import(parsed, name, tags, userid, replace, rejected):
"""Create or update the guide and its images. Caller commits."""
slug = None
guide = None
if replace:
# Match on the slug the title WOULD produce, which is how somebody
# re-imports a corrected article over the one they imported yesterday.
import re
wanted = re.sub(r'[^a-z0-9]+', '-', (name or '').lower()).strip('-')
guide = Guide.query.filter(Guide.slug == wanted).first()
if guide is None:
slug = _slugify(name)
guide = Guide(title=name, slug=slug, status='draft', createdby=userid)
db.session.add(guide)
db.session.flush()
# Images first: the block needs the stored id, and an id only exists once
# the row does.
droppedtokens = {image['token'] for image in rejected}
ids = {}
for image in parsed['images']:
if image['token'] in droppedtokens:
continue
row = GuideImage(guideid=guide.guideid, filename=image['filename'],
mimetype=image['mimetype'],
bytesize=len(image['data']), data=image['data'],
uploadedby=userid)
db.session.add(row)
db.session.flush()
ids[image['token']] = row.guideimageid
blocks = []
for block in parsed['blocks']:
block = dict(block)
token = block.pop('imagetoken', None)
if token is not None:
if token not in ids:
continue # its image was dropped; the caption would lie
block['imageid'] = ids[token]
blocks.append(block)
authoring.save(guide, title=name, summary=parsed['summary'] or None,
tags=tags, blocks=blocks,
note='Imported from HTML', userid=userid)
return guide
def _importbundle(payload, userid, replace, rejected):
"""Create or update a guide from a bundle. Caller commits."""
guide = None
if replace:
guide = Guide.query.filter(Guide.slug == payload['slug']).first()
if guide is None:
# The bundle's own slug when it is free, because the slug is the name
# somebody pastes into a work order and the same guide should have the
# same address on every site. A clash only suffixes.
slug = payload['slug']
if Guide.query.filter(Guide.slug == slug).first() is not None:
slug = _slugify(payload.get('title') or slug)
guide = Guide(title=payload.get('title') or slug, slug=slug,
status='draft', createdby=userid)
db.session.add(guide)
db.session.flush()
# Replacing means the incoming bundle is the whole truth for this guide.
# Its blocks will reference the images arriving now, so the ones already
# stored become orphans - and re-shipping a guide monthly would otherwise
# grow a pile of them nothing points at.
if replace:
GuideImage.query.filter_by(guideid=guide.guideid).delete()
db.session.flush()
droppedtokens = {image['token'] for image in rejected}
ids = {}
for image in payload['images']:
if image['token'] in droppedtokens:
continue
row = GuideImage(guideid=guide.guideid, filename=image['filename'],
mimetype=image['mimetype'],
bytesize=len(image['data']), data=image['data'],
uploadedby=userid)
db.session.add(row)
db.session.flush()
ids[image['token']] = row.guideimageid
blocks = []
for block in payload['blocks']:
block = dict(block)
token = block.pop('imagetoken', None)
if token is not None:
if token not in ids:
continue
block['imageid'] = ids[token]
blocks.append(block)
authoring.save(guide, title=payload.get('title'),
summary=payload.get('summary'), tags=payload.get('tags'),
blocks=blocks, note='Imported from a guide bundle',
userid=userid)
return guide

View File

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

View File

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

View File

@@ -68,12 +68,71 @@
</ul>
</div>
<!-- Reference picker -->
<div v-if="refOpen" class="ge-refmask" @click.self="refOpen = false">
<div class="ge-ref">
<div class="ge-refhead">
<strong>Link to a guide or article</strong>
<button type="button" @click="refOpen = false"><X :size="16" /></button>
</div>
<input ref="refInput" v-model="refQuery" class="ge-refsearch"
placeholder="Search by title..." @input="searchReferences">
<p v-if="refLoading" class="ge-hint">Searching...</p>
<p v-else-if="!refResults.length" class="ge-hint">
{{ refQuery ? 'Nothing matches that.' : 'Type to search.' }}
</p>
<ul v-else class="ge-reflist">
<li v-for="item in refResults" :key="item.kind + item.id">
<button type="button" @click="insertReference(item)">
<span class="ge-refkind">{{ item.kind }}</span>
<span class="ge-reftitle">{{ item.title }}</span>
<small v-if="item.summary">{{ item.summary }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor">
<div v-for="(block, index) in blocks" :key="block.id"
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
@focusin="focused = block.id">
<!-- Insert point ABOVE each section.
The type palette used to live only at the bottom of the whole
guide, so adding a step in the middle meant adding it at the end
and walking it up one press at a time. The insert bar puts the
palette where the section is actually going. -->
<div v-for="(block, index) in blocks" :key="block.id">
<div class="ge-insert" :class="{ 'ge-insertopen': insertAt === index }">
<button type="button" class="ge-insertbtn"
:title="'Add a section here'"
@click="insertAt = insertAt === index ? null : index">
<Plus :size="14" /> Add section
</button>
</div>
<div v-if="insertAt === index" class="ge-palette">
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type, index)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden
@change="event => onFilePick(event, index)">
</label>
</div>
<div class="ge-block"
:class="{ 'ge-focused': focused === block.id,
'ge-dragging': dragIndex === index,
'ge-dragover': dragOver === index }"
@focusin="focused = block.id"
@dragover.prevent="dragOver = index"
@dragleave="dragOver === index && (dragOver = null)"
@drop.prevent="dropOn(index)">
<div class="ge-blockbar">
<!-- Dragging is on the HANDLE, not the whole block: a draggable
container swallows text selection inside its own textarea. -->
<span class="ge-grip" draggable="true" title="Drag to move"
@dragstart="startDrag(index, $event)" @dragend="endDrag">
<GripVertical :size="14" />
</span>
<span class="ge-type">{{ block.type }}</span>
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
<option value="info">info</option>
@@ -92,16 +151,23 @@
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
<button type="button" title="Code" @click="wrap(block, '`')">&lt;&gt;</button>
<button type="button" title="Link" @click="makeLink(block)">link</button>
<!-- Linking to another guide or a KB article by hand means
knowing its slug or id. This finds it by name and writes the
link, so a procedure can point at the one before it. -->
<button type="button" title="Reference a guide or KB article"
@click="openReference(block)"><Link2 :size="14" /></button>
</div>
<div class="ge-blockbtns">
<button type="button" title="Move up" @click="move(index, -1)"
:disabled="index === 0">^</button>
:disabled="index === 0"><ArrowUp :size="14" /></button>
<button type="button" title="Move down" @click="move(index, 1)"
:disabled="index === blocks.length - 1">v</button>
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
:disabled="index === blocks.length - 1"><ArrowDown :size="14" /></button>
<button type="button" title="Duplicate" @click="duplicate(index)">
<Copy :size="14" />
</button>
<button type="button" class="ge-del" title="Remove"
@click="remove(index)">x</button>
@click="remove(index)"><X :size="14" /></button>
</div>
</div>
@@ -158,14 +224,24 @@
placeholder="One row per line, cells separated by |&#10;Header | Header&#10;Cell | Cell"></textarea>
</div>
</div>
</div>
<div class="ge-add">
<span>Add:</span>
<!-- The end of the guide gets the same control as every gap between
sections, so there is one way to add a section, not two. -->
<div class="ge-insert ge-insertend"
:class="{ 'ge-insertopen': insertAt === blocks.length }">
<button type="button" class="ge-insertbtn"
@click="insertAt = insertAt === blocks.length ? null : blocks.length">
<Plus :size="14" /> Add section
</button>
</div>
<div v-if="insertAt === blocks.length" class="ge-palette">
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type)">{{ type }}</button>
@click="add(type, blocks.length)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden @change="onFilePick">
<input type="file" accept="image/*" hidden
@change="event => onFilePick(event, blocks.length)">
</label>
</div>
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js'
import { proof, applyFix } from '../utils/proof.js'
import { useGuideImages } from '../utils/images.js'
import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next'
import { knowledgebaseApi } from '@/api'
const route = useRoute()
const router = useRouter()
@@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
slug: '', ispublished: false })
const blocks = ref([])
// Which gap the type palette is open in. null = closed; blocks.length = the end.
const insertAt = ref(null)
const dragIndex = ref(null)
const dragOver = ref(null)
const refOpen = ref(false)
const refQuery = ref('')
const refResults = ref([])
const refLoading = ref(false)
const refInput = ref(null)
let refBlock = null
let refTimer = null
const revisions = ref([])
const saving = ref(false)
const message = ref('')
@@ -288,17 +377,111 @@ function insertIndex () {
return index === -1 ? blocks.value.length : index + 1
}
function add (type) {
// `at` is an explicit gap from an insert bar. Without one, fall back to
// "after whatever is focused", which is what the old bottom palette did.
function add (type, at = null) {
const block = { id: newid(), type }
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
if (type === 'callout') block.variant = 'info'
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
const at = insertIndex()
blocks.value.splice(at, 0, block)
const index = at === null ? insertIndex() : at
blocks.value.splice(index, 0, block)
insertAt.value = null
focused.value = block.id
nextTick(() => textareas[block.id]?.focus())
}
// --------------------------------------------------------------- dragging ---
function startDrag (index, event) {
dragIndex.value = index
// Firefox will not start a drag without payload, and 'move' is what the
// cursor should say.
event.dataTransfer.effectAllowed = 'move'
try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ }
}
function endDrag () {
dragIndex.value = null
dragOver.value = null
}
function dropOn (index) {
const from = dragIndex.value
endDrag()
if (from === null || from === index) return
const [moved] = blocks.value.splice(from, 1)
blocks.value.splice(index, 0, moved)
focused.value = moved.id
}
// ------------------------------------------------------------- references ---
function openReference (block) {
refBlock = block
refOpen.value = true
refQuery.value = ''
refResults.value = []
nextTick(() => refInput.value?.focus())
}
function searchReferences () {
clearTimeout(refTimer)
const term = refQuery.value.trim()
if (!term) { refResults.value = []; return }
refTimer = setTimeout(async () => {
refLoading.value = true
try {
const results = []
// Guides first: a procedure most often points at another procedure.
try {
const response = await api.get('/guides', { params: { q: term } })
for (const guide of (response.data?.data || [])) {
if (!guide.ispublished) continue // never link readers at a draft
results.push({ kind: 'guide', id: guide.guideid, title: guide.title,
summary: guide.summary, url: `/guides/${guide.slug}` })
}
} catch (err) { /* guides always exist here, but never break the picker */ }
// The knowledge base is a separate plugin and may not be installed at
// this site (ADR-009). Its absence must cost nothing but its results.
try {
const response = await knowledgebaseApi.list({ search: term, perpage: 20 })
const rows = response.data?.data || []
for (const article of (Array.isArray(rows) ? rows : rows.items || [])) {
results.push({ kind: 'article', id: article.articleid || article.id,
title: article.title,
summary: article.summary || article.description,
url: `/knowledgebase/${article.articleid || article.id}` })
}
} catch (err) { /* KB plugin absent or disabled */ }
refResults.value = results.slice(0, 25)
} finally {
refLoading.value = false
}
}, 250)
}
function insertReference (item) {
// An in-app path, not an absolute URL: the link has to keep working when the
// site is reached by another hostname or mounted under a subpath.
const markdown = `[${item.title}](${item.url})`
const area = refBlock ? textareas[refBlock.id] : null
if (refBlock && area) {
const start = area.selectionStart ?? refBlock.text?.length ?? 0
const end = area.selectionEnd ?? start
const text = refBlock.text || ''
refBlock.text = text.slice(0, start) + markdown + text.slice(end)
nextTick(() => {
area.focus()
const caret = start + markdown.length
area.setSelectionRange(caret, caret)
})
} else if (refBlock) {
refBlock.text = (refBlock.text || '') + markdown
}
refOpen.value = false
}
function duplicate (index) {
const source = blocks.value[index]
const copy = JSON.parse(JSON.stringify(source))
@@ -383,7 +566,7 @@ async function uploadImage (file) {
return response.data?.data || response.data
}
async function insertImage (file) {
async function insertImage (file, at = null) {
error.value = ''
status.value = 'Uploading image...'
try {
@@ -392,7 +575,8 @@ async function insertImage (file) {
// for anything that still treats a block as a plain document.
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
url: image.url, caption: '', alt: '' }
blocks.value.splice(insertIndex(), 0, block)
blocks.value.splice(at === null ? insertIndex() : at, 0, block)
insertAt.value = null
focused.value = block.id
status.value = ''
} catch (err) {
@@ -416,9 +600,9 @@ function onDrop (event) {
if (file) insertImage(file)
}
function onFilePick (event) {
function onFilePick (event, at = null) {
const file = event.target.files?.[0]
if (file) insertImage(file)
if (file) insertImage(file, at)
event.target.value = ''
}
@@ -714,4 +898,77 @@ onMounted(async () => {
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer; }
/* --- insert bars, drag handle, reference picker -------------------------- */
/* A quiet line that becomes a button on hover: present when wanted, not
competing with the guide's own content the rest of the time. */
.ge-insert {
display: flex; align-items: center; justify-content: center;
height: 1.5rem; opacity: 0; transition: opacity 0.12s;
}
.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; }
.ge-insert::before, .ge-insert::after {
content: ''; flex: 1; height: 1px; background: var(--border);
}
.ge-insertbtn {
display: inline-flex; align-items: center; gap: 0.3rem;
margin: 0 0.5rem; padding: 0.15rem 0.6rem;
font-size: 0.75rem; color: var(--text-light);
border: 1px solid var(--border); border-radius: 999px;
background: var(--bg); cursor: pointer; white-space: nowrap;
}
.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); }
.ge-palette {
display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
margin: 0 0 0.6rem; padding: 0.5rem;
border: 1px solid var(--border); border-radius: 5px;
}
.ge-palette button, .ge-palette .ge-upload {
font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border);
background: transparent; color: var(--text); border-radius: 3px; cursor: pointer;
}
.ge-palette button:hover, .ge-palette .ge-upload:hover {
color: var(--primary); border-color: var(--primary);
}
.ge-grip { display: inline-flex; align-items: center; color: var(--text-light);
cursor: grab; padding-right: 0.2rem; }
.ge-grip:active { cursor: grabbing; }
.ge-dragging { opacity: 0.45; }
.ge-dragover { border-top: 2px solid var(--primary); }
.ge-refmask {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 8vh; z-index: 50;
}
.ge-ref {
width: min(34rem, 92vw); max-height: 70vh; overflow: auto;
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; padding: 1rem;
}
.ge-refhead { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.6rem; }
.ge-refhead button { background: transparent; border: none; color: var(--text-light);
cursor: pointer; }
.ge-refsearch {
width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text); margin-bottom: 0.6rem;
}
.ge-reflist { list-style: none; margin: 0; padding: 0; }
.ge-reflist button {
width: 100%; display: flex; flex-direction: column; gap: 0.15rem;
padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text); cursor: pointer;
}
.ge-reflist button:hover { border-color: var(--primary); }
.ge-refkind {
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-light);
}
.ge-reftitle { font-weight: 600; }
.ge-reflist small { color: var(--text-light); }
</style>

View File

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

View File

@@ -75,18 +75,32 @@ class GuidesPlugin(BasePlugin):
]
def get_navigation_items(self) -> List[Dict]:
# Position 8, beside Slides (7), rather than down in the Information
# section with the reference lists. A guide is something you go and DO
# something with, like a slide deck you put on a screen - not a
# catalogue you look things up in.
# Position 8, directly under Slides (7), rather than down in the
# Information section with the reference lists. A guide is something you
# go and DO something with, like a slide deck you put on a screen - not
# a catalogue you look things up in.
#
# Warranties held 8 as well, and a TIE is broken by plugin load order,
# so Guides landed underneath it and read as "not under Slides at all".
# Warranties moved to 9. Two nav items sharing a position is not a
# preference, it is a coin toss.
return [
{
'name': 'Guides',
'icon': 'book-open',
'route': '/guides',
'position': 8,
# The nav endpoint hides an item whose permission the caller
# does not hold, so a signed-out visitor sees Guides only when
# the guest role has been granted guides.view.
'permission': 'guides.view',
},
]
def get_cli_commands(self) -> List:
"""CLI: import an existing HTML article as a draft guide."""
from .cli import build_cli
return build_cli()
def init_app(self, app: Flask, db_instance) -> None:
logger.info('Guides plugin initialized (v%s)', self.meta.version)

View File

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

View File

@@ -0,0 +1,130 @@
"""A guide as one portable file.
WHY
Sites are separate installations with separate databases, and the useful
ones are air-gapped from where a guide gets written. A procedure that can
only exist on the machine it was typed on is the problem this plugin was
built to end, one layer up: instead of a document stranded on a share, a
guide stranded in one site's database.
So: export to a single JSON file, carry it however files get carried
there - a share, a robocopy, a USB drive - and import it on the other side.
WHAT TRAVELS
Title, summary, tags and the blocks, plus every image the blocks reference,
inline as base64. One file, no companion directory: half a guide arriving
is worse than none, and a bundle that needs its images to be copied
alongside it is a bundle that will eventually arrive without them.
WHAT DOES NOT TRAVEL
Ids, revision history, authorship and publication state. A guide's id and
its revision numbering belong to the database that issued them, and an
imported guide starts as a draft on the far side, at revision 1, owned by
whoever ran the import. The SLUG travels, because the slug is the name
somebody pastes into a work order and the whole point is that the same
guide is the same guide on both sites.
"""
import base64
import json
FORMAT = 'shopdb-guide'
VERSION = 1
# An image block points at a stored image by its id, and an id means nothing on
# the far side. Blocks travel referring to a token instead, and the importer
# swaps the tokens for the ids its own database issued.
IMAGETOKEN = 'imagetoken'
IMAGEID = 'imageid'
class BundleError(ValueError):
"""The file is not a guide bundle, or is one this version cannot read."""
def build(guide, images):
"""One guide plus its images as a plain dict, ready to be written as JSON.
`images` is the GuideImage rows for this guide. Only the ones its blocks
actually reference are carried - an orphaned paste from a draft is not part
of the procedure and does not need to cross the gap.
"""
byid = {image.guideimageid: image for image in images}
used = []
tokens = {}
blocks = []
for block in guide.blocks:
block = dict(block)
imageid = block.pop(IMAGEID, None)
if imageid is not None:
image = byid.get(imageid)
if image is None:
# The image is gone. Carrying a block that points at nothing
# would render as "Image unavailable" on the far side, which
# reads as an import failure rather than as missing data.
continue
if imageid not in tokens:
tokens[imageid] = 'img{:03d}'.format(len(used) + 1)
used.append(image)
block[IMAGETOKEN] = tokens[imageid]
blocks.append(block)
return {
'format': FORMAT,
'version': VERSION,
'slug': guide.slug,
'title': guide.title,
'summary': guide.summary,
'tags': guide.tags,
'blocks': blocks,
'images': [{
'token': tokens[image.guideimageid],
'filename': image.filename,
'mimetype': image.mimetype,
'data': base64.b64encode(image.data).decode('ascii'),
} for image in used],
}
def dumps(bundle):
return json.dumps(bundle, indent=2, sort_keys=False) + '\n'
def loads(text):
"""Parse a bundle, with the image data decoded back to bytes.
Refuses anything that is not this format. A JSON file that happens to be
somewhere near the others must not be half-imported before the failure
shows up.
"""
try:
bundle = json.loads(text)
except ValueError as error:
raise BundleError('not valid JSON: {}'.format(error))
if not isinstance(bundle, dict) or bundle.get('format') != FORMAT:
raise BundleError('not a {} file'.format(FORMAT))
version = bundle.get('version')
if not isinstance(version, int) or version > VERSION:
raise BundleError(
'bundle version {} is newer than this site can read (max {})'
.format(version, VERSION))
if not isinstance(bundle.get('blocks'), list):
raise BundleError('bundle has no blocks')
images = []
for image in bundle.get('images') or []:
try:
data = base64.b64decode(image['data'], validate=True)
except Exception:
raise BundleError('image {!r} is not readable'
.format(image.get('token')))
images.append({'token': image.get('token'),
'filename': image.get('filename'),
'mimetype': (image.get('mimetype') or '').lower(),
'data': data})
bundle['images'] = images
return bundle

View File

@@ -0,0 +1,464 @@
"""Turn a hand-written HTML article into guide blocks.
WHY THIS EXISTS
Sites arrive with a folder of KB articles: a procedure written once as a
styled HTML page, mailed around, opened from a share. Those pages are what a
guide is for, and retyping one into the editor is how a good procedure stays
on a share forever. This reads the page and produces blocks.
WHAT IT IS NOT
It is not an HTML renderer, and it deliberately loses things. Colours, fonts,
widths, nested layout and any decoration go, because that is the entire
point of storing blocks rather than markup - the theme decides how a guide
looks, and pasted styling is what makes a document unreadable in dark mode.
A converted guide is a DRAFT: somebody reads it before it is published.
WHAT IT RECOGNISES
The GE Aerospace KB template's own vocabulary, which is also the shape most
hand-written procedure pages take:
div.step + span.step-number -> step (guides do the numbering)
div.alert alert-<variant> -> callout
div.code-block, pre, code -> code
ul.checkbox-list -> checklist (tickable, and remembered)
h1..h4 -> heading (h1 becomes the title)
p -> paragraph
ul / ol -> bullets / numbers
table -> table
img, inline svg -> image (returned for upload)
Anything else contributes its text. A div wrapping other blocks is walked
through, not turned into one - guides have no nesting.
INLINE TEXT is the restricted Markdown subset the renderer accepts: bold,
italic, inline code, links, line breaks. Anything else becomes plain text,
which is a loss the format intends.
"""
import base64
import re
from html import unescape
from html.parser import HTMLParser
from urllib.parse import unquote
# Blocks a guide has no concept of, and page furniture no reader wants twice.
# The nav is a table of contents the guide generates from its own headings.
SKIPCLASSES = ('header', 'header-content', 'header-logo', 'header-subtitle',
'kb-badge', 'nav', 'footer', 'footer-content', 'copy-button')
SKIPTAGS = ('head', 'style', 'script', 'button', 'noscript')
VOIDTAGS = ('br', 'img', 'meta', 'link', 'input', 'hr', 'source', 'col',
'area', 'base', 'embed', 'track', 'wbr')
# The renderer knows info, warning and success. A danger alert is a warning
# with a louder colour, and colour is not something a block carries.
CALLOUTVARIANTS = {'alert-info': 'info', 'alert-warning': 'warning',
'alert-success': 'success', 'alert-danger': 'warning',
'alert-error': 'warning'}
DATAURI = re.compile(r'^data:([\w.+-]+/[\w.+-]+)?(;charset=[\w-]+)?(;base64)?,(.*)$',
re.IGNORECASE | re.DOTALL)
class Node:
"""One element or one run of text. A tree small enough to walk twice."""
__slots__ = ('tag', 'attrs', 'children', 'text')
def __init__(self, tag, attrs=None, text=None):
self.tag = tag
self.attrs = attrs or {}
self.children = []
self.text = text
@property
def classes(self):
return (self.attrs.get('class') or '').split()
class _Tree(HTMLParser):
"""Builds the node tree. Tolerant: real articles have unclosed tags."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.root = Node('#root')
self.stack = [self.root]
# A <p> that is never closed is normal HTML, and the browser closes it the
# moment a block element starts. Without the same rule, one unclosed <p>
# swallows the entire rest of the article into a single paragraph.
CLOSESPARAGRAPH = ('p', 'div', 'section', 'article', 'ul', 'ol', 'li',
'table', 'tr', 'td', 'th', 'pre', 'blockquote', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6')
def _autoclose(self, tag):
openers = {'li': ('li',), 'tr': ('tr', 'td', 'th'),
'td': ('td', 'th'), 'th': ('td', 'th')}.get(tag, ())
if tag in self.CLOSESPARAGRAPH:
openers = openers + ('p',)
while len(self.stack) > 1 and self.stack[-1].tag in openers:
self.stack.pop()
def handle_starttag(self, tag, attrs):
self._autoclose(tag)
node = Node(tag, {k: (v or '') for k, v in attrs})
self.stack[-1].children.append(node)
if tag not in VOIDTAGS:
self.stack.append(node)
def handle_startendtag(self, tag, attrs):
self.stack[-1].children.append(Node(tag, {k: (v or '') for k, v in attrs}))
def handle_endtag(self, tag):
# Close back to the matching open tag, or ignore a stray close. An
# article with one unclosed <div> must still import.
for depth in range(len(self.stack) - 1, 0, -1):
if self.stack[depth].tag == tag:
del self.stack[depth:]
return
def handle_data(self, data):
if data.strip():
self.stack[-1].children.append(Node('#text', text=data))
def _skip(node):
if node.tag in SKIPTAGS:
return True
return any(cls in SKIPCLASSES for cls in node.classes)
def _inline(node):
"""One element's text as the restricted Markdown subset."""
out = []
for child in node.children:
if child.tag == '#text':
out.append(re.sub(r'\s+', ' ', child.text))
elif _skip(child):
continue
elif child.tag == 'br':
out.append('\n')
elif child.tag in ('strong', 'b'):
inner = _inline(child).strip()
out.append('**{}**'.format(inner) if inner else '')
elif child.tag in ('em', 'i'):
inner = _inline(child).strip()
out.append('*{}*'.format(inner) if inner else '')
elif child.tag in ('code', 'kbd', 'samp', 'tt'):
inner = _inline(child).strip()
out.append('`{}`'.format(inner) if inner else '')
elif child.tag == 'a':
inner = _inline(child).strip()
href = child.attrs.get('href', '').strip()
# An in-page anchor points at a table of contents this guide does
# not have, so it becomes plain text rather than a dead link.
if href and not href.startswith('#'):
out.append('[{}]({})'.format(inner, href))
else:
out.append(inner)
else:
# span.command-inline and friends: the KB template marks up inline
# commands with a class rather than <code>, so honour that too.
inner = _inline(child)
if child.tag == 'span' and 'command-inline' in child.classes:
stripped = inner.strip()
out.append('`{}`'.format(stripped) if stripped else '')
else:
out.append(inner)
text = ''.join(out)
text = re.sub(r'[ \t]+', ' ', text)
return text.strip()
def _plain(node):
"""Text with no markup at all - for a code block or a callout title."""
if node.tag == '#text':
return node.text
if _skip(node):
return ''
return ''.join(_plain(child) for child in node.children)
def _imagepayload(node):
"""(mimetype, bytes, filename) for an <img>, or None if it is not embeddable."""
src = unescape(node.attrs.get('src', '').strip())
match = DATAURI.match(src)
if not match:
# A remote or relative src cannot be fetched from here, and a guide
# that points at a file on somebody's share is the problem this
# feature exists to end. Reported, not imported.
return None
mimetype = (match.group(1) or 'image/png').lower()
payload = match.group(4)
try:
data = (base64.b64decode(payload, validate=False) if match.group(3)
else unquote(payload).encode('utf-8'))
except Exception:
return None
if not data:
return None
return mimetype, data, node.attrs.get('alt') or 'image'
class _Builder:
def __init__(self):
self.blocks = []
self.images = []
self.title = None
self.skipped = []
def _add(self, block):
block['id'] = 'b{:03d}'.format(len(self.blocks) + 1)
self.blocks.append(block)
def _items(self, node, tag='li'):
items = []
for child in node.children:
if child.tag != tag or _skip(child):
continue
text = _inline(child)
if text:
items.append({'id': 'i{:03d}'.format(len(items) + 1 + len(self.blocks) * 100),
'text': text})
return items
def walk(self, node):
for child in node.children:
if child.tag == '#text' or _skip(child):
continue
self.visit(child)
def visit(self, node):
tag = node.tag
classes = node.classes
if tag == 'h1':
# The article's own title, not a heading inside it.
if self.title is None:
self.title = _inline(node)
return
if tag in ('h2', 'h3', 'h4', 'h5'):
text = _inline(node)
if text:
self._add({'type': 'heading', 'text': text})
return
if tag == 'p':
text = _inline(node)
if text:
self._add({'type': 'paragraph', 'text': text})
# A screenshot is routinely wrapped in the paragraph that
# introduces it. Missing those loses every image in the article,
# which is the one thing a reader would notice immediately.
for image in _descendants(node, 'img'):
self._image(image)
return
if tag in ('pre',) or 'code-block' in classes:
text = _plain(node).strip('\n')
text = '\n'.join(line.rstrip() for line in text.splitlines())
if text.strip():
self._add({'type': 'code', 'text': text})
return
if 'step' in classes:
self._step(node)
return
if any(cls.startswith('alert') for cls in classes):
self._callout(node, classes)
return
if tag == 'ul':
items = self._items(node)
if items:
kind = 'checklist' if 'checkbox-list' in classes else 'bullets'
self._add({'type': kind, 'items': items})
return
if tag == 'ol':
items = self._items(node)
if items:
self._add({'type': 'numbers', 'items': items})
return
if tag == 'table':
self._table(node)
return
if tag == 'img':
self._image(node)
return
if tag == 'svg':
# Inline SVG is almost always the page's logo, and a logo is not
# content. Named in the report so nothing disappears silently.
self.skipped.append('inline <svg> dropped')
return
if tag in ('div', 'section', 'article', 'main', 'body', 'html',
'figure', 'blockquote', 'li', 'td', 'th', 'tr', 'tbody',
'thead', 'span', 'a', 'strong', 'em', 'small', 'label'):
# A wrapper. Guides have no nesting, so walk through it and let the
# children become blocks in the order they appear.
self.walk(node)
return
self.walk(node)
def _step(self, node):
"""div.step: a numbered step, whose body may hold other blocks."""
title = ''
text = []
rest = []
for child in node.children:
if child.tag == '#text' or _skip(child):
continue
if child.tag == 'span' and 'step-number' in child.classes:
continue # guides number their own steps
if child.tag in ('h3', 'h4', 'h5') and not title:
title = _inline(child)
continue
if child.tag == 'p' and not rest:
paragraph = _inline(child)
if paragraph:
text.append(paragraph)
continue
rest.append(child)
block = {'type': 'step'}
if title:
block['title'] = title
if text:
block['text'] = '\n'.join(text)
self._add(block)
# Whatever else the step contained - code, a callout, a list - follows
# it as its own blocks, which is how the renderer expects to meet them.
for child in rest:
self.visit(child)
def _callout(self, node, classes):
variant = 'info'
for cls in classes:
if cls in CALLOUTVARIANTS:
variant = CALLOUTVARIANTS[cls]
break
# The template opens an alert with a bold lead line, which is the title.
title = ''
body = []
for child in node.children:
if child.tag == '#text':
piece = re.sub(r'\s+', ' ', child.text).strip()
if piece:
body.append(piece)
elif _skip(child):
continue
elif child.tag in ('strong', 'b') and not title and not body:
title = _plain(child).strip()
else:
piece = _inline(child)
if piece:
body.append(piece)
block = {'type': 'callout', 'variant': variant, 'text': ' '.join(body)}
if title:
block['title'] = title
if block['text'] or title:
self._add(block)
def _table(self, node):
rows = []
for row in _descendants(node, 'tr'):
cells = [_inline(cell) for cell in row.children
if cell.tag in ('td', 'th')]
if cells:
rows.append((cells, any(cell.tag == 'th' for cell in row.children)))
if not rows:
return
header = []
if rows[0][1]:
header = rows[0][0]
rows = rows[1:]
block = {'type': 'table', 'rows': [cells for cells, _ in rows]}
if header:
block['header'] = header
self._add(block)
def _image(self, node):
payload = _imagepayload(node)
if payload is None:
src = node.attrs.get('src', '')[:60]
self.skipped.append('image not embedded, skipped: {}'.format(src))
return
mimetype, data, alt = payload
token = 'img{:03d}'.format(len(self.images) + 1)
self.images.append({'token': token, 'mimetype': mimetype, 'data': data,
'filename': '{}{}'.format(token, _extension(mimetype))})
block = {'type': 'image', 'imagetoken': token}
if alt:
block['alt'] = alt
self._add(block)
def _descendants(node, tag):
for child in node.children:
if child.tag == tag:
yield child
else:
for found in _descendants(child, tag):
yield found
def _extension(mimetype):
return {'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
'image/webp': '.webp', 'image/svg+xml': '.svg'}.get(mimetype, '')
def _documenttitle(tree):
"""The <title>, minus the house suffix every KB page carries."""
for node in _descendants(tree, 'title'):
text = _plain(node).strip()
return re.sub(r'\s*[-|]\s*GE Aerospace Knowledge Base\s*$', '', text).strip()
return ''
def parse(html):
"""Parse an article. Returns a dict, raising nothing on malformed markup.
{
'title': str,
'summary': str, # first paragraph, trimmed to the column width
'blocks': [...], # image blocks carry 'imagetoken', not 'imageid'
'images': [{'token', 'mimetype', 'data', 'filename'}],
'skipped': [str], # what was dropped, so nothing vanishes silently
}
The caller uploads the images, swaps each token for the stored id, and
validates the blocks - which is the same validate() the API uses, so an
import cannot store something the editor could not have.
"""
tree = _Tree()
tree.feed(html)
tree.close()
builder = _Builder()
builder.walk(tree.root)
title = builder.title or _documenttitle(tree.root) or 'Untitled'
summary = ''
for block in builder.blocks:
if block['type'] == 'paragraph':
summary = re.sub(r'[*`]', '', block['text'])
break
if len(summary) > 300:
summary = summary[:297].rsplit(' ', 1)[0] + '...'
return {'title': title, 'summary': summary, 'blocks': builder.blocks,
'images': builder.images, 'skipped': builder.skipped}

View File

@@ -77,7 +77,7 @@ def _search_clause(search):
@knowledgebase_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('kb.view')
def list_articles():
"""List all knowledge base articles."""
page, per_page = get_pagination_params(request)
@@ -142,7 +142,7 @@ def list_articles():
@knowledgebase_bp.route('/stats', methods=['GET'])
@jwt_required(optional=True)
@require_permission('kb.view')
def get_stats():
"""Get knowledge base statistics."""
# Counted over the same set the list shows. A total that includes articles
@@ -158,7 +158,7 @@ def get_stats():
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('kb.view')
def get_article(link_id: int):
"""Get a single knowledge base article."""
article = db.session.get(KnowledgeBase, link_id)
@@ -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."""

View File

@@ -68,6 +68,7 @@ class KnowledgeBasePlugin(BasePlugin):
'name': 'Knowledge Base',
'icon': 'book-open',
'route': '/knowledgebase',
'permission': 'kb.view',
'position': 35,
'section': 'information',
},

View File

@@ -17,7 +17,7 @@ machines_bp = Blueprint('machines', __name__)
# =============================================================================
@machines_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('machines.view')
def list_machine_types():
"""List all machine types."""
page, per_page = get_pagination_params(request)
@@ -39,7 +39,7 @@ def list_machine_types():
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('machines.view')
def get_machine_type(type_id: int):
"""Get a single machine type."""
t = db.session.get(MachineType, type_id)
@@ -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."""
@@ -147,7 +144,7 @@ def delete_machine_type(type_id: int):
# =============================================================================
@machines_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('machines.view')
def list_machines():
"""
List all machines with filtering and pagination.
@@ -284,7 +281,7 @@ def _dualpath_partner_for(assetid):
@machines_bp.route('/<int:machine_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('machines.view')
def get_machine(machine_id: int):
"""Get a single machine item with full details."""
mach = db.session.get(Machine, machine_id)
@@ -304,7 +301,7 @@ def get_machine(machine_id: int):
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('machines.view')
def get_machine_by_asset(asset_id: int):
"""Get machine data by asset ID."""
mach = Machine.query.filter_by(assetid=asset_id).first()
@@ -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."""
@@ -531,7 +525,7 @@ def delete_machine(machine_id: int):
# =============================================================================
@machines_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
@require_permission('machines.view')
def dashboard_summary():
"""Get machine dashboard summary data."""
# Dualpath single-machine collapse: hide the secondary bay from counts so a
@@ -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.

View File

@@ -231,7 +231,9 @@ class MachinesPlugin(BasePlugin):
'name': 'Machines',
'icon': 'cog',
'route': '/machines',
'permission': 'machines.view',
'position': 10,
'section': 'assets',
},
]

View File

@@ -49,7 +49,7 @@ def _merged(tool, today=None):
# =============================================================================
@measuringtools_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def list_types():
"""List measuring-tool types."""
page, per_page = get_pagination_params(request)
@@ -64,7 +64,7 @@ def list_types():
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def get_type(type_id: int):
"""Get one measuring-tool type."""
tool_type = db.session.get(MeasuringToolType, type_id)
@@ -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."""
@@ -153,7 +150,7 @@ def delete_type(type_id: int):
# =============================================================================
@measuringtools_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def list_tools():
"""List measuring tools with filters + pagination.
@@ -211,7 +208,7 @@ def list_tools():
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def get_tool(tool_id: int):
"""Get one measuring tool (asset core merged with the extension)."""
tool = db.session.get(MeasuringTool, tool_id)
@@ -222,7 +219,7 @@ def get_tool(tool_id: int):
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def get_tool_by_asset(asset_id: int):
"""Get a measuring tool by its core asset id."""
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
@@ -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)."""
@@ -377,7 +371,7 @@ def delete_tool(tool_id: int):
# =============================================================================
@measuringtools_bp.route('/map-overlay', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def map_overlay():
"""Calibration-status overlay for active measuring tools.
@@ -408,7 +402,7 @@ def map_overlay():
# =============================================================================
@measuringtools_bp.route('/report/calibration', methods=['GET'])
@jwt_required(optional=True)
@require_permission('measuringtools.view')
def calibration_report():
"""Counts + lists bucketed by derived calibration status."""
today = date.today()

View File

@@ -73,7 +73,9 @@ class MeasuringToolsPlugin(BasePlugin):
'name': 'Measuring Tools',
'icon': 'ruler',
'route': '/measuringtools',
'permission': 'measuringtools.view',
'position': 22,
'section': 'assets',
},
]
@@ -85,6 +87,7 @@ class MeasuringToolsPlugin(BasePlugin):
'description': 'Measuring tools bucketed by calibration status',
'category': 'compliance',
'route': '/reports/calibration',
'permission': 'measuringtools.view',
},
]

View File

@@ -47,7 +47,7 @@ def _upsert_primary_ip(asset, ip):
# =============================================================================
@network_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def list_network_device_types():
"""List all network device types."""
page, per_page = get_pagination_params(request)
@@ -69,7 +69,7 @@ def list_network_device_types():
@network_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def get_network_device_type(type_id: int):
"""Get a single network device type."""
t = db.session.get(NetworkDeviceType, type_id)
@@ -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."""
@@ -179,7 +176,7 @@ def delete_network_device_type(type_id: int):
# =============================================================================
@network_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def list_network_devices():
"""
List all network devices with filtering and pagination.
@@ -287,7 +284,7 @@ def list_network_devices():
@network_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def get_network_device(device_id: int):
"""Get a single network device with full details."""
netdev = db.session.get(NetworkDevice, device_id)
@@ -307,7 +304,7 @@ def get_network_device(device_id: int):
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def get_network_device_by_asset(asset_id: int):
"""Get network device data by asset ID."""
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
@@ -327,7 +324,7 @@ def get_network_device_by_asset(asset_id: int):
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def get_network_device_by_hostname(hostname: str):
"""Get network device by hostname."""
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
@@ -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."""
@@ -589,7 +583,7 @@ def delete_network_device(device_id: int):
# =============================================================================
@network_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def dashboard_summary():
"""Get network device dashboard summary data."""
# Total active network devices
@@ -637,7 +631,7 @@ def dashboard_summary():
# =============================================================================
@network_bp.route('/vlans', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def list_vlans():
"""List all VLANs with filtering and pagination."""
page, per_page = get_pagination_params(request)
@@ -670,7 +664,7 @@ def list_vlans():
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def get_vlan(vlan_id: int):
"""Get a single VLAN with its subnets."""
vlan = db.session.get(VLAN, vlan_id)
@@ -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."""
@@ -815,7 +806,7 @@ def delete_vlan(vlan_id: int):
# =============================================================================
@network_bp.route('/subnets', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def list_subnets():
"""List all subnets with filtering and pagination."""
page, per_page = get_pagination_params(request)
@@ -856,7 +847,7 @@ def list_subnets():
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('network.view')
def get_subnet(subnet_id: int):
"""Get a single subnet plus the network devices whose primary IP falls in
its CIDR range."""
@@ -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."""

View File

@@ -1,5 +1,25 @@
<template>
<div class="detail-page" v-if="device">
<!--
The same page-header + header-actions block every other asset detail page
uses. This page had no header at all: Edit was tucked inside the hero
title row and there was no Print Label, no Retire and no way back to the
list, so managing a network device meant looking somewhere different from
every other asset type.
-->
<div class="page-header">
<h2>Network Device Details</h2>
<div class="header-actions">
<router-link :to="`/print/asset-label/network_device/${deviceId}`"
class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link v-if="authStore.isAuthenticated" :to="`/network/${deviceId}/edit`"
class="btn btn-primary">Edit</router-link>
<button v-if="authStore.isAuthenticated" @click="confirmRetire"
class="btn btn-danger">Retire</button>
<router-link to="/network" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div class="hero-card">
<div class="hero-image">
<img v-if="device.networkdevice?.imageurl" :src="withBase(device.networkdevice.imageurl)" alt="Model photo" />
@@ -10,13 +30,6 @@
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/network/${deviceId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :style="colorStyle(device.statuscolor)">
@@ -184,18 +197,9 @@
</div>
</div>
<!-- Actions -->
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
Print Label
</router-link>
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
Edit Device
</router-link>
<button @click="confirmDelete" class="btn btn-danger">
Delete Device
</button>
</div>
<!-- The manage actions live in the page header, where every other asset
detail page puts them. They used to sit down here instead, which is
why finding them on a network device meant looking somewhere else. -->
<!-- Audit Footer -->
<div class="audit-footer">
@@ -228,6 +232,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
@@ -293,17 +298,24 @@ function formatDate(dateStr) {
})
}
async function confirmDelete() {
if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
try {
await networkApi.delete(deviceId)
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
toast.error('Failed to delete device')
}
// Soft delete, like every other asset type: the record leaves the lists while
// its history and relationships stay put, and it can be restored by setting
// isactive back. Called Retire rather than Delete for exactly that reason.
async function confirmRetire () {
const label = device.value?.networkdevice?.hostname
|| device.value?.name || device.value?.assetnumber || 'this device'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
+ `History and relationships are kept, and it can be restored.`)) return
try {
await networkApi.delete(deviceId)
toast.success(`Retired ${label}`)
router.push('/network')
} catch (error) {
console.error('Error retiring network device:', error)
toast.error(apiError(error, 'Failed to retire network device'))
}
}
</script>
<style scoped>

View File

@@ -207,7 +207,9 @@ class NetworkPlugin(BasePlugin):
'name': 'Network',
'icon': 'network-wired',
'route': '/network',
'permission': 'network.view',
'position': 18,
'section': 'assets',
},
]

View File

@@ -239,6 +239,7 @@ def _employee_picture(sso):
# =============================================================================
@notifications_bp.route('/types', methods=['GET'])
@require_permission('notifications.view')
def list_notification_types():
"""List all notification types."""
page, per_page = get_pagination_params(request)
@@ -257,7 +258,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 +293,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."""
@@ -335,6 +334,7 @@ def update_notification_type(type_id: int):
# =============================================================================
@notifications_bp.route('', methods=['GET'])
@require_permission('notifications.view')
def list_notifications():
"""
List all notifications with filtering and pagination.
@@ -390,6 +390,7 @@ def list_notifications():
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
@require_permission('notifications.view')
def get_notification(notification_id: int):
"""Get a single notification."""
n = db.session.get(Notification, notification_id)
@@ -405,7 +406,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 +465,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 +531,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."""
@@ -556,6 +554,7 @@ def delete_notification(notification_id: int):
# =============================================================================
@notifications_bp.route('/active', methods=['GET'])
@require_permission('notifications.view')
def get_active_notifications():
"""
Get currently active notifications for display.
@@ -586,6 +585,7 @@ def get_active_notifications():
@notifications_bp.route('/calendar', methods=['GET'])
@require_permission('notifications.view')
def get_calendar_events():
"""
Get notifications in FullCalendar event format.
@@ -624,6 +624,7 @@ def get_calendar_events():
@notifications_bp.route('/dashboard/summary', methods=['GET'])
@require_permission('notifications.view')
def dashboard_summary():
"""Get notifications dashboard summary."""
now = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -874,6 +875,7 @@ def get_shopfloor_notifications():
@notifications_bp.route('/dashboard/active', methods=['GET'])
@require_permission('notifications.view')
@jwt_required()
def dashboard_active():
"""Notifications currently showing on the floor.

View File

@@ -232,12 +232,14 @@ class NotificationsPlugin(BasePlugin):
'name': 'Notifications',
'icon': 'bell',
'route': '/notifications',
'permission': 'notifications.view',
'position': 5,
},
{
'name': 'Calendar',
'icon': 'calendar',
'route': '/calendar',
'permission': 'notifications.view',
'position': 6,
},
]

View File

@@ -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}."""
@@ -552,7 +542,7 @@ def _csv_response(rows, columns, filename):
@printedparts_bp.route('/reports/stock', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printedparts.view')
def report_stock():
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
@@ -586,7 +576,7 @@ def report_stock():
@printedparts_bp.route('/reports/consumption', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printedparts.view')
def report_consumption():
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
@@ -614,7 +604,7 @@ def report_consumption():
@printedparts_bp.route('/reports/by-person', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printedparts.view')
def report_by_person():
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
days = request.args.get('days', 30, type=int)
@@ -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.
@@ -734,7 +722,7 @@ def upload_item_file(item_id: int):
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printedparts.view')
def download_item_file(file_id: int):
"""Download a revision under its original filename."""
record = db.session.get(PrintedItemFile, file_id)
@@ -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."""

View File

@@ -106,7 +106,11 @@ class PrintedpartsPlugin(BasePlugin):
'name': '3D Parts',
'icon': 'box',
'route': '/printedparts',
'position': 46,
'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',
},
]

View File

@@ -30,7 +30,7 @@ printers_asset_bp = Blueprint('printers_asset', __name__)
# =============================================================================
@printers_asset_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def list_printer_types():
"""List all printer types."""
page, per_page = get_pagination_params(request)
@@ -52,7 +52,7 @@ def list_printer_types():
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def get_printer_type(type_id: int):
"""Get a single printer type."""
t = db.session.get(PrinterType, type_id)
@@ -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."""
@@ -150,7 +147,7 @@ def delete_printer_type(type_id: int):
# =============================================================================
@printers_asset_bp.route('/drivers', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def list_drivers():
"""List printer drivers. ?active=false includes inactive ones."""
query = PrinterDriver.query
@@ -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)
@@ -213,7 +207,7 @@ def delete_driver(driver_id):
# =============================================================================
@printers_asset_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def list_printers():
"""
List all printers with filtering and pagination.
@@ -970,7 +964,7 @@ def printers_for_host(hostname: str):
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def get_asset_printer_assignment(asset_id: int):
"""What THIS asset is assigned, without inheritance.
@@ -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.
@@ -1788,7 +1778,7 @@ def seed_assignment_from_observed(asset_id: int):
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def get_printer(printer_id: int):
"""Get a single printer with full details."""
printer = db.session.get(Printer, printer_id)
@@ -1821,7 +1811,7 @@ def get_printer(printer_id: int):
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def get_printer_by_asset(asset_id: int):
"""Get printer data by asset ID."""
printer = Printer.query.filter_by(assetid=asset_id).first()
@@ -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."""
@@ -2056,7 +2043,7 @@ def delete_printer(printer_id: int):
# =============================================================================
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def get_printer_supplies(printer_id: int):
"""Get supply levels from Zabbix (real-time lookup)."""
printer = db.session.get(Printer, printer_id)
@@ -2251,7 +2238,7 @@ def _get_low_supplies_data():
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def low_supplies():
"""Get printers with low or critical supply levels."""
data = _get_low_supplies_data()
@@ -2259,7 +2246,7 @@ def low_supplies():
@printers_asset_bp.route('/lookup', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def printer_lookup():
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
@@ -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.
@@ -2320,7 +2306,7 @@ def refresh_supplies_cache():
# =============================================================================
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def dashboard_summary():
"""Get printer dashboard summary data."""
# Total active printers
@@ -2394,7 +2380,7 @@ def _validate_supply_payload(data):
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def supplies_meta():
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
return success_response({
@@ -2405,7 +2391,7 @@ def supplies_meta():
@printers_asset_bp.route('/models', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def list_supply_models():
"""List models with a supply count, for the supply-management picker.
@@ -2464,7 +2450,7 @@ def list_supply_models():
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def list_model_supplies(modelnumberid: int):
"""List all supplies mapped to a model."""
model = db.session.get(Model, modelnumberid)
@@ -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.
@@ -2685,7 +2667,7 @@ def dashboard_supplies():
# =============================================================================
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
@jwt_required(optional=True)
@require_permission('printers.view')
def supplies_forecast():
"""Days-to-empty and replacement counts per printer.

View File

@@ -4,7 +4,7 @@
<h1>Toner Report</h1>
<div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
<EmailReportButton v-if="!loading && !error && canemail" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
@@ -133,9 +133,16 @@
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import EmailReportButton from '@/components/EmailReportButton.vue'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
// Emailing a report is the reports.export permission the endpoint enforces.
// Offering the button to somebody who will only get a 403 is worse than not
// offering it.
const authStore = useAuthStore()
const canemail = computed(() => authStore.hasPermission('reports.export'))
const emailColumns = [
{ key: 'printer', label: 'Printer' },
{ key: 'hostname', label: 'Hostname' },

View File

@@ -385,7 +385,9 @@ class PrintersPlugin(BasePlugin):
'name': 'Printers',
'icon': 'printer',
'route': '/printers',
'permission': 'printers.view',
'position': 20,
'section': 'assets',
},
]

View File

@@ -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):

View File

@@ -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'],
},
]

View File

@@ -67,9 +67,27 @@ class ToolsPlugin(BasePlugin):
about revoking what a role does or does not hold.
"""
return [
# Reaching the tools page at all. The tools themselves run in the
# browser and touch no data, so this is about whether the entry
# appears - which is exactly the question a site answers when it
# decides whether people without an account may use them.
('tools.view', 'Use the browser tools (codes, conversions)', 'tools'),
('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.
@@ -121,6 +139,10 @@ class ToolsPlugin(BasePlugin):
'icon': 'wrench',
'route': '/tools',
'position': 47,
'section': 'fleet',
# Either right reaches the page: somebody granted only Secure
# Send still needs the entry that leads to it.
'permission': ['tools.view', 'tools.secrets'],
},
]

View File

@@ -22,9 +22,9 @@ cmmc_usb schema used here:
import logging
import re
from datetime import datetime
from datetime import datetime, timedelta
from flask import Blueprint, request
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
from shopdb.api import (
@@ -257,7 +257,7 @@ def _log_to_dict(cur, row):
# =============================================================================
@usb_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('usb.view')
def list_usb_devices():
"""List USB devices with checkout status.
@@ -306,7 +306,7 @@ def list_usb_devices():
@usb_bp.route('/<device_id>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('usb.view')
def get_usb_device(device_id):
"""Get a single device plus its last 20 check-in/out log rows."""
if _usb_selfhosted():
@@ -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."""
@@ -600,7 +595,7 @@ def checkin_device(device_id):
# =============================================================================
@usb_bp.route('/<device_id>/history', methods=['GET'])
@jwt_required(optional=True)
@require_permission('usb.view')
def get_device_history(device_id):
"""Paginated check-in/out log for one device."""
if _usb_selfhosted():
@@ -630,7 +625,7 @@ def get_device_history(device_id):
@usb_bp.route('/checkouts', methods=['GET'])
@jwt_required(optional=True)
@require_permission('usb.view')
def list_all_checkouts():
"""List check-out log rows.
@@ -681,7 +676,7 @@ def list_all_checkouts():
@usb_bp.route('/checkouts/active', methods=['GET'])
@jwt_required(optional=True)
@require_permission('usb.view')
def list_active_checkouts():
"""List the latest check-out log row for every currently checked-out device.
@@ -721,3 +716,471 @@ def list_active_checkouts():
conn.close()
return success_response(data)
# =============================================================================
# Kiosk: UNAUTHENTICATED check-out / check-in
# =============================================================================
# The bar these have to meet, same as the parts kiosk: they can move a device
# between checked-out and checked-in and NOTHING else. No create, no edit, no
# retire, no browsing the fleet, no reading who holds what without presenting
# the device. Identity is the scanned badge, resolved server-side; the client
# never asserts who it is.
#
# Off unless a site turns it on. An unauthenticated write is a decision about
# the door, not about the person walking through it.
def _kiosk_setting(key, default=False):
row = Setting.query.filter_by(key=key).first()
if row is None or row.value is None or row.value == '':
return default
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
def _kiosk_enabled():
return _kiosk_setting('usb_kiosk_enabled', False)
def _kiosk_disabled_response():
# 404, not 403: when the kiosk is off these routes should not read as
# "there is a door here and you may not use it".
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
def _kiosk_badge(data):
"""The scanned badge, or an error response. Never trusts a client name."""
badge = (data.get('badge') or '').strip()
if not badge:
return None, error_response(ErrorCodes.VALIDATION_ERROR,
'Scan your badge')
return badge, None
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
def kiosk_device(device_id):
"""What the kiosk shows after a device is scanned.
Deliberately narrow: the description, whether it is out, and the NAME of
whoever holds it - which the return-for-someone-else confirm has to show, or
the person standing there cannot tell whether they are doing the right
thing. No badge number, no history, no contact details.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
if _usb_selfhosted():
return selfhosted.kiosk_device(device_id)
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code',
http_code=404)
holder = ''
if device.get('status') == _STATUS_CHECKED_OUT:
holder = _kiosk_holder_name(cur, device_id)
finally:
conn.close()
return success_response({
'device_id': device.get('device_id'),
'device_desc': device.get('device_desc'),
'status': device.get('status'),
'locker_location': device.get('locker_location'),
'holdername': holder,
})
def _kiosk_holder_badge(cur, device_id):
"""Badge on the most recent check-out for this device, or ''."""
cur.execute(
'SELECT badge_number FROM checkinoutlog '
"WHERE device_id = %s AND action = 'check-out' "
'ORDER BY timestamp DESC, log_id DESC LIMIT 1',
(device_id,),
)
row = cur.fetchone()
return (row.get('badge_number') or '').strip() if row else ''
def _kiosk_holder_name(cur, device_id):
badge = _kiosk_holder_badge(cur, device_id)
return _resolve_badge_name(cur, badge) if badge else ''
@usb_bp.route('/kiosk/session', methods=['POST'])
def kiosk_session():
"""What one badge may do right now. Body: {badge}.
The drives here carry no barcode, so the kiosk cannot ask somebody to
present one - it has to offer a LIST. That is a real widening: before, you
had to already hold a drive to learn anything. So the list is not anonymous.
It costs a badge that resolves to a real person, which is the same bar
check-out itself has to clear, and it is a POST so the badge does not end up
in a proxy access log the way a query string would.
Returns the caller's own drives (to return), what is free (to take), and a
COUNT of everything else - names for those are a separate, deliberate step,
so a passer-by does not read the whole cabinet's holders off a wall screen.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_session(data)
badge, failure = _kiosk_badge(data)
if failure:
return failure
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
name = _resolve_badge_name(cur, badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT',
http_code=422)
cur.execute(
'SELECT ' + _DEVICE_COLS + ' FROM devices '
'WHERE status IN (%s, %s) ORDER BY device_id',
(_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT),
)
rows = cur.fetchall() or []
yours, available, others = [], [], 0
for row in rows:
entry = {
'device_id': row.get('device_id'),
'device_desc': row.get('device_desc'),
'locker_location': row.get('locker_location'),
}
if row.get('status') == _STATUS_CHECKED_IN:
available.append(entry)
elif _kiosk_holder_badge(cur, row.get('device_id')) == badge:
yours.append(entry)
else:
others += 1
finally:
conn.close()
return success_response({'name': name, 'yours': yours,
'available': available, 'otherscount': others})
@usb_bp.route('/kiosk/others', methods=['POST'])
def kiosk_others():
"""Drives checked out to OTHER people, with holder names. Body: {badge}.
Behind its own tap, and only when returning for somebody else is allowed at
all. Somebody has to be able to put back a drive left on a bench by a person
who is on leave - but the names that makes visible should not be the first
thing on a shopfloor screen.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_others(data)
badge, failure = _kiosk_badge(data)
if failure:
return failure
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
if not _resolve_badge_name(cur, badge):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT',
http_code=422)
cur.execute(
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s '
'ORDER BY device_id',
(_STATUS_CHECKED_OUT,),
)
out = []
for row in cur.fetchall() or []:
holderbadge = _kiosk_holder_badge(cur, row.get('device_id'))
if holderbadge == badge:
continue
out.append({
'device_id': row.get('device_id'),
'device_desc': row.get('device_desc'),
'locker_location': row.get('locker_location'),
'holdername': _resolve_badge_name(cur, holderbadge),
})
finally:
conn.close()
return success_response(out)
@usb_bp.route('/kiosk/checkout', methods=['POST'])
def kiosk_checkout():
"""Check a device out at the kiosk. Body: {device_id, badge}."""
if not _kiosk_enabled():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_checkout(data)
device_id = (data.get('device_id') or '').strip()
badge, failure = _kiosk_badge(data)
if failure:
return failure
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code',
http_code=404)
if device.get('status') == _STATUS_RETIRED:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is retired - see IT')
if device.get('status') == _STATUS_CHECKED_OUT:
holder = _kiosk_holder_name(cur, device_id)
return error_response(
ErrorCodes.CONFLICT,
'That device is already checked out{}'.format(
' to {}'.format(holder) if holder else ''),
http_code=409)
# An unresolvable badge is REFUSED here, unlike the signed-in route
# which creates the user row. A checkout nobody can trace to a
# person is the one thing this tracking exists to prevent, and at a
# kiosk there is no signed-in operator to vouch for it.
name = _resolve_badge_name(cur, badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to check this device out',
http_code=422)
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, locker_location) '
"VALUES (%s, %s, 'check-out', %s, %s)",
(badge, device_id, _now(), device.get('locker_location')),
)
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_OUT, device_id),
)
conn.commit()
finally:
conn.close()
return success_response({'device_id': device_id, 'holdername': name},
message='{} checked out to {}'.format(
device_id, name))
@usb_bp.route('/kiosk/checkin', methods=['POST'])
def kiosk_checkin():
"""Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}.
Holder by default. A mismatch is refused with the holder's name and a flag
the screen uses to offer "returning it for them", because the alternative -
a drive whose owner is on leave that literally cannot be returned at the
kiosk - just moves the problem to somebody with a login.
The badge RECORDED is the one that was scanned: the log says who actually
handed the device back, which is the honest record and needs no change to a
schema this app does not own.
"""
if not _kiosk_enabled():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
return selfhosted.kiosk_checkin(data)
device_id = (data.get('device_id') or '').strip()
badge, failure = _kiosk_badge(data)
if failure:
return failure
forsomeoneelse = bool(data.get('forsomeoneelse'))
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
device = _fetch_device(cur, device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code',
http_code=404)
if device.get('status') != _STATUS_CHECKED_OUT:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is not checked out')
name = _resolve_badge_name(cur, badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to return this device',
http_code=422)
holderbadge = _kiosk_holder_badge(cur, device_id)
if holderbadge and holderbadge != badge:
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return error_response(
ErrorCodes.FORBIDDEN,
'That device is checked out to somebody else - they '
'need to return it, or see IT',
http_code=403)
if not forsomeoneelse:
holdername = _resolve_badge_name(cur, holderbadge)
return error_response(
ErrorCodes.CONFLICT,
'That device is checked out to {}. Confirm you are '
'returning it for them.'.format(holdername or 'somebody else'),
details={'needsconfirm': True,
'holdername': holdername},
http_code=409)
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, locker_location) '
"VALUES (%s, %s, 'check-in', %s, %s)",
(badge, device_id, _now(), device.get('locker_location')),
)
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_IN, device_id),
)
conn.commit()
finally:
conn.close()
return success_response({'device_id': device_id},
message='{} checked in'.format(device_id))
# =============================================================================
# Reports
# =============================================================================
# CMMC removable-media tracking is an audit obligation, and until now the only
# way to answer "who had which drive, when" was to read the check-in/out log
# page by page. This is the same question over a window: a day for "what
# happened this shift", a month or six for a periodic review, a year or all time
# for an audit.
# The windows the UI offers, in days. 0 means everything ever recorded.
AUDITPERIODS = {'day': 1, 'month': 30, 'sixmonths': 182, 'year': 365, 'all': 0}
def _auditdays():
"""Days of history to report. Accepts ?period= or a raw ?days=."""
period = (request.args.get('period') or '').strip().lower()
if period in AUDITPERIODS:
return AUDITPERIODS[period]
days = request.args.get('days', type=int)
return max(0, days) if days is not None else 30
def _auditcsv(rows, columns, filename):
"""CSV download; generate_csv is not on the contract surface."""
import csv
import io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(columns)
for row in rows:
writer.writerow([row.get(column, '') for column in columns])
return Response(output.getvalue(), mimetype='text/csv',
headers={'Content-Disposition':
'attachment; filename={}'.format(filename)})
AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge',
'name', 'locker_location']
@usb_bp.route('/reports/audit', methods=['GET'])
@require_permission('usb.view')
def report_audit():
"""Every check-out and check-in in the window, newest first.
Rows, not just totals: an audit asks who had a specific drive on a specific
day, and a count cannot answer that. Summary figures ride along for the
people who only want the shape of it.
"""
days = _auditdays()
if _usb_selfhosted():
rows = selfhosted.audit_rows(days)
return _auditresponse(rows, days)
cutoff = None
if days > 0:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime(
'%Y-%m-%d %H:%M:%S')
conn = cmmc_usb_connection()
try:
with conn.cursor() as cur:
sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, '
'l.locker_location, d.device_desc, '
'u.first_name, u.last_name '
'FROM checkinoutlog l '
'LEFT JOIN devices d ON d.device_id = l.device_id '
'LEFT JOIN users u ON u.badge_number = l.badge_number ')
params = []
if cutoff:
sql += 'WHERE l.timestamp >= %s '
params.append(cutoff)
# A total order: timestamp alone ties for two events in the same
# second and the same query then returns them in a different order
# each run, which reads as the log being unstable.
sql += 'ORDER BY l.timestamp DESC, l.log_id DESC'
cur.execute(sql, tuple(params))
rows = []
for row in cur.fetchall() or []:
name = '{} {}'.format(row.get('first_name') or '',
row.get('last_name') or '').strip()
stamp = row.get('timestamp')
rows.append({
'timestamp': stamp.isoformat() if hasattr(stamp, 'isoformat')
else (stamp or ''),
'action': row.get('action') or '',
'device_id': row.get('device_id') or '',
'device_desc': row.get('device_desc') or '',
'badge': row.get('badge_number') or '',
'name': name,
'locker_location': row.get('locker_location') or '',
})
finally:
conn.close()
return _auditresponse(rows, days)
def _auditresponse(rows, days):
"""One voice for both directory modes: same CSV, same JSON, same summary."""
if request.args.get('format') == 'csv':
return _auditcsv(rows, AUDITCOLUMNS, 'usb-audit.csv')
return success_response({
'columns': AUDITCOLUMNS,
'rows': rows,
'days': days,
'summary': _auditsummary(rows),
})
def _auditsummary(rows):
checkouts = sum(1 for row in rows if row['action'] == 'check-out')
return {
'events': len(rows),
'checkouts': checkouts,
'checkins': sum(1 for row in rows if row['action'] == 'check-in'),
'devices': len({row['device_id'] for row in rows if row['device_id']}),
'people': len({row['badge'] for row in rows if row['badge']}),
}

View File

@@ -16,7 +16,7 @@ History rows are synthesized from usbcheckouts (each row = a check-out event and
if returned, a check-in event).
"""
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from shopdb.api import (
db, success_response, error_response, ErrorCodes,
@@ -274,3 +274,225 @@ def list_checkouts(request, active_only):
event['device_status'] = _status(checkout.device) if checkout.device else None
data.append(event)
return success_response(data)
# ---------------------------------------------------------------------------
# Kiosk (self-hosted mode). Same contract as the external path in routes.py:
# move a device between checked-out and checked-in, attributed to a badge that
# resolved to a real person, and nothing else.
# ---------------------------------------------------------------------------
def kiosk_device(device_id):
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code', http_code=404)
return success_response({
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'status': _status(device),
'locker_location': device.storagelocation,
# Name only. The kiosk needs it for the return-for-someone-else
# confirm; it does not need their badge.
'holdername': device.currentusername or '' if device.ischeckedout else '',
})
def kiosk_session(data):
"""See routes.kiosk_session for why the list costs a valid badge."""
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
name = _resolve_name(badge)
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT', http_code=422)
yours, available, others = [], [], 0
for device in USBDevice.query.filter_by(isactive=True).order_by(
USBDevice.serialnumber).all():
entry = {
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'locker_location': device.storagelocation,
}
if not device.ischeckedout:
available.append(entry)
elif (device.currentuserid or '').strip() == badge:
yours.append(entry)
else:
others += 1
return success_response({'name': name, 'yours': yours,
'available': available, 'otherscount': others})
def kiosk_others(data):
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
if not _resolve_name(badge):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT', http_code=422)
out = []
for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True
).order_by(USBDevice.serialnumber).all():
if (device.currentuserid or '').strip() == badge:
continue
out.append({
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'locker_location': device.storagelocation,
'holdername': device.currentusername or '',
})
return success_response(out)
def kiosk_checkout(data):
device_id = (data.get('device_id') or '').strip()
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code', http_code=404)
if not device.isactive:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is retired - see IT')
if device.ischeckedout:
return error_response(
ErrorCodes.CONFLICT,
'That device is already checked out{}'.format(
' to {}'.format(device.currentusername)
if device.currentusername else ''),
http_code=409)
# Refused rather than recorded anonymously - see the note on the external
# path. A checkout nobody can trace to a person defeats the tracking.
name = _resolve_name(badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to check this device out',
http_code=422)
now = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
checkoutname=name, checkouttime=now))
device.ischeckedout = True
device.currentuserid = badge
device.currentusername = name
device.currentcheckoutdate = now
db.session.commit()
return success_response({'device_id': device.serialnumber,
'holdername': name},
message='{} checked out to {}'.format(
device.serialnumber, name))
def kiosk_checkin(data):
device_id = (data.get('device_id') or '').strip()
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
forsomeoneelse = bool(data.get('forsomeoneelse'))
device = _find(device_id)
if not device:
return error_response(ErrorCodes.NOT_FOUND,
'No device matches that code', http_code=404)
if not device.ischeckedout:
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is not checked out')
name = _resolve_name(badge)
if not name:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT to return this device',
http_code=422)
holderbadge = (device.currentuserid or '').strip()
if holderbadge and holderbadge != badge:
if not _kiosk_flag('usb_kiosk_allow_return_for_others', True):
return error_response(
ErrorCodes.FORBIDDEN,
'That device is checked out to somebody else - they need to '
'return it, or see IT',
http_code=403)
if not forsomeoneelse:
return error_response(
ErrorCodes.CONFLICT,
'That device is checked out to {}. Confirm you are returning '
'it for them.'.format(device.currentusername or 'somebody else'),
details={'needsconfirm': True,
'holdername': device.currentusername or ''},
http_code=409)
open_checkout = (USBCheckout.query
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.order_by(USBCheckout.checkouttime.desc()).first())
if open_checkout:
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
db.session.commit()
return success_response({'device_id': device.serialnumber},
message='{} checked in'.format(device.serialnumber))
def _kiosk_flag(key, default):
from shopdb.api import Setting
row = Setting.query.filter_by(key=key).first()
if row is None or row.value is None or row.value == '':
return default
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
def audit_rows(days):
"""Check-out / check-in events over a window (self-hosted mode).
One usbcheckouts row holds BOTH ends of a loan, so it expands into two
events - the take and, when it has come back, the return. Reporting the row
once would make a returned drive indistinguishable from one still out.
Rows only: the caller owns the CSV, the summary and the response shape, so
both directory modes answer in exactly one voice.
"""
cutoff = None
if days > 0:
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
query = (db.session.query(USBCheckout, USBDevice)
.join(USBDevice, USBDevice.usbdeviceid == USBCheckout.usbdeviceid))
if cutoff is not None:
# Either end of the loan can fall in the window: a drive taken before it
# and returned inside it is a check-in the window has to show.
query = query.filter(db.or_(USBCheckout.checkouttime >= cutoff,
USBCheckout.checkintime >= cutoff))
rows = []
for checkout, device in query.all():
base = {
'device_id': device.serialnumber,
'device_desc': device.label or device.productname or '',
'badge': checkout.sso or '',
'name': checkout.checkoutname or '',
'locker_location': device.storagelocation or '',
}
if checkout.checkouttime and (cutoff is None or checkout.checkouttime >= cutoff):
rows.append(dict(base, timestamp=checkout.checkouttime.isoformat(),
action='check-out'))
if checkout.checkintime and (cutoff is None or checkout.checkintime >= cutoff):
rows.append(dict(base, timestamp=checkout.checkintime.isoformat(),
action='check-in'))
# Newest first, and a total order so two events in the same second do not
# swap places between identical runs.
rows.sort(key=lambda row: (row['timestamp'], row['device_id']), reverse=True)
return rows

View File

@@ -0,0 +1,29 @@
// Narrowing a cabinet of drives from a kiosk keyboard.
//
// Every free drive is already on the client, so this filters what is on screen
// rather than asking the API again - the list is small, and a round trip per
// keystroke on a wall panel is a worse experience than no filter at all.
// What one drive can be found by. 'locker' is in here on purpose: it is how
// people say where a drive lives, so "locker engineer" has to find the drives
// in the engineering locker rather than nothing at all.
export function drivetext (drive) {
return [drive.device_id, drive.device_desc, 'locker', drive.locker_location]
.filter(Boolean).join(' ').toLowerCase()
}
// Every word typed has to appear somewhere in the drive's text, in any order,
// so "engineer kingston" and "kingston engineer" find the same drive. An empty
// query matches everything - a blank box is not a filter.
export function matchesdrive (drive, query) {
const words = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return true
const text = drivetext(drive)
return words.every(word => text.includes(word))
}
export function filterdrives (drives, query) {
const words = String(query || '').trim()
if (!words) return drives
return drives.filter(drive => matchesdrive(drive, query))
}

View File

@@ -0,0 +1,62 @@
// The kiosk filter. Every case here is something somebody standing at the
// cabinet would actually type, including the phrasing they use out loud -
// "locker engineer" rather than the bare locker name.
import { describe, it, expect } from 'vitest'
import { filterdrives, matchesdrive } from './drivefilter'
const DRIVES = [
{ device_id: 'USB-3', device_desc: 'Kingston 16GB', locker_location: 'DT' },
{ device_id: 'USB-4', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
{ device_id: 'USB-10', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
{ device_id: 'USB-11', device_desc: 'Kingston 64GB', locker_location: 'B11' },
]
describe('filterdrives', () => {
it('an empty box is not a filter', () => {
expect(filterdrives(DRIVES, '')).toHaveLength(4)
expect(filterdrives(DRIVES, ' ')).toHaveLength(4)
})
it('finds a drive by its id', () => {
expect(filterdrives(DRIVES, 'usb-4').map(d => d.device_id)).toEqual(['USB-4'])
})
it('finds drives by what is written on them', () => {
expect(filterdrives(DRIVES, 'kingston').map(d => d.device_id))
.toEqual(['USB-3', 'USB-11'])
})
it('finds a locker by name', () => {
expect(filterdrives(DRIVES, 'engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('finds a locker the way somebody says it out loud', () => {
expect(filterdrives(DRIVES, 'locker engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('takes the words in any order', () => {
expect(filterdrives(DRIVES, 'sandisk engineer').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
expect(filterdrives(DRIVES, 'engineer sandisk').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('ignores case and stray spacing', () => {
expect(filterdrives(DRIVES, ' ENGINEER SanDisk ').map(d => d.device_id))
.toEqual(['USB-4', 'USB-10'])
})
it('matches nothing rather than everything when nothing matches', () => {
expect(filterdrives(DRIVES, 'zzz')).toEqual([])
})
it('does not fall over on a drive missing its description or locker', () => {
const sparse = [{ device_id: 'USB-9' }]
expect(matchesdrive(sparse[0], 'usb-9')).toBe(true)
expect(matchesdrive(sparse[0], 'engineer')).toBe(false)
})
})

View File

@@ -25,10 +25,26 @@ export default [
name: 'usb-edit',
component: () => import('./views/USBForm.vue'),
meta: { requiresAuth: true, plugin: 'usb' }
},
{
path: 'settings/usb',
name: 'settings-usb',
component: () => import('./views/USBSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'usb' }
}
]
export const toplevel = [
{
// Touch kiosk for taking and returning USB drives: scan the drive, scan a
// badge. Full-screen and OUTSIDE AppLayout, and deliberately not behind
// requiresAuth - the whole point is that a guest at the cabinet can use it.
// The API refuses every kiosk route unless usb_kiosk_enabled is on.
path: '/usb-kiosk',
name: 'usb-kiosk',
component: () => import('./views/USBKiosk.vue'),
meta: { plugin: 'usb' }
},
{
path: '/print/usb-labels',
name: 'print-usb-labels',

View File

@@ -0,0 +1,509 @@
<template>
<div class="usb-kiosk" @click="focusEntry">
<header class="kiosk-header">
<h1>USB Drives</h1>
<button v-if="step !== 'badge'" class="btn btn-secondary" @click="reset">
Done
</button>
</header>
<div v-if="disabled" class="kiosk-step">
<p class="kiosk-prompt">This kiosk is turned off.</p>
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
</div>
<template v-else>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<!--
Badge FIRST. The drives carry no barcode, so there is nothing to present
and the screen has to offer a list - which means the list has to cost
something. A badge that resolves to a real person is the same bar taking
a drive already has to clear.
-->
<section v-if="step === 'badge'" class="kiosk-step">
<p class="kiosk-prompt">Enter your SSO to start</p>
<div class="entry-panel">
<input ref="entryInput" v-model="manualBadge" class="entry-input"
inputmode="none" autocomplete="off" placeholder="SSO"
@keydown.enter.prevent="startSession(manualBadge)" />
<TouchKeypad @digit="manualBadge += $event"
@clear="manualBadge = ''"
@backspace="manualBadge = manualBadge.slice(0, -1)" />
<button class="btn btn-primary act-button"
:disabled="!manualBadge || busy"
@click="startSession(manualBadge)">
{{ busy ? 'Checking...' : 'Next' }}
</button>
</div>
</section>
<!-- the picker -->
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
<p class="kiosk-greeting">{{ session.name }}</p>
<div v-if="session.yours.length" class="drive-group drive-group-yours">
<h2>Your drives</h2>
<div class="drive-list">
<button v-for="drive in session.yours" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkin')">
<span class="drive-name">
{{ drive.device_id }}
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
</div>
</div>
<div class="drive-group">
<div class="drive-group-head">
<h2>Available</h2>
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
thirty drives is faster to narrow than to read. -->
<div v-if="session.available.length" class="drive-filter">
<input ref="filterInput" v-model="drivefilter"
class="drive-filter-input"
autocomplete="off" spellcheck="false"
placeholder="Find a drive, a locker..." />
<button v-if="drivefilter" class="drive-filter-clear"
title="Clear" @click="drivefilter = ''">Clear</button>
</div>
</div>
<p v-if="!session.available.length" class="kiosk-hint">
No drives are free right now.
</p>
<p v-else-if="!availabledrives.length" class="kiosk-hint">
No free drive matches "{{ drivefilter }}".
</p>
<div v-else class="drive-list">
<button v-for="drive in availabledrives" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkout')">
<span class="drive-name">
{{ drive.device_id }}
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
</span>
<span class="drive-action take">Take</span>
</button>
</div>
</div>
<!-- Other people's drives are behind a tap, not on the main screen: a
passer-by should not read the whole cabinet's holders off a wall. -->
<button v-if="session.otherscount" class="btn btn-secondary others-button"
:disabled="busy" @click="loadOthers">
Returning a drive for somebody else?
({{ session.otherscount }} out with others)
</button>
</section>
<!-- somebody else's drives -->
<section v-else-if="step === 'others'" class="kiosk-step kiosk-wide">
<h2>Checked out to somebody else</h2>
<p class="kiosk-hint">
Returning one puts it back and records that YOU handed it in.
</p>
<div class="drive-list">
<button v-for="drive in others" :key="drive.device_id"
class="drive-row" :disabled="busy"
@click="act(drive, 'checkin', true)">
<span class="drive-name">
{{ drive.device_id }}
<small class="drive-desc">{{ drive.holdername || 'somebody else' }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
</div>
<button class="btn btn-secondary act-button" @click="step = 'pick'">
Back
</button>
</section>
<!-- done -->
<section v-else-if="step === 'done'" class="kiosk-step">
<p class="kiosk-done">{{ doneMessage }}</p>
<button class="btn btn-primary act-button" @click="reset">Finish</button>
</section>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter'
const step = ref('badge')
const badge = ref('')
const session = ref({ name: '', yours: [], available: [], otherscount: 0 })
const others = ref([])
const error = ref('')
const doneMessage = ref('')
const busy = ref(false)
const disabled = ref(false)
const manualBadge = ref('')
const entryInput = ref(null)
let resetTimer = null
onMounted(async () => {
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
// that says so - a kiosk merely switched off must not look broken.
try {
const response = await settingsApi.get('usb_kiosk_enabled')
const value = String(response.data?.data?.value ?? '').toLowerCase()
disabled.value = !(value === 'true' || value === '1')
} catch (err) {
// An unreachable setting is not proof it is off; let the API decide.
disabled.value = false
}
focusEntry()
})
onBeforeUnmount(() => clearTimeout(resetTimer))
function focusEntry (event) {
const tag = event?.target?.tagName
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|| tag === 'BUTTON' || tag === 'A') return
nextTick(() => entryInput.value?.focus())
}
// Narrowing the free drives. The matcher lives in its own module so it can be
// tested without mounting a kiosk.
const drivefilter = ref('')
const filterInput = ref(null)
// Focused as the picker opens, so somebody can simply start typing. The badge
// step owns the keyboard until then.
function focusFilter () {
nextTick(() => filterInput.value?.focus())
}
const availabledrives = computed(
() => filterdrives(session.value.available, drivefilter.value))
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
// plain text for ids that carry no digits.
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
function byDeviceId (a, b) {
return collator.compare(a.device_id || '', b.device_id || '')
}
function messageFrom (err, fallback) {
return err.response?.data?.data?.error?.message || fallback
}
async function startSession (value) {
const scanned = (value || '').trim()
if (!scanned || busy.value) return
error.value = ''
busy.value = true
try {
const response = await api.post('/usb/kiosk/session', { badge: scanned })
const data = response.data.data
data.yours = (data.yours || []).sort(byDeviceId)
data.available = (data.available || []).sort(byDeviceId)
session.value = data
badge.value = scanned
manualBadge.value = ''
step.value = 'pick'
focusFilter()
} catch (sessionError) {
error.value = messageFrom(sessionError, 'Could not look that badge up')
} finally {
busy.value = false
focusEntry()
}
}
async function loadOthers () {
error.value = ''
busy.value = true
try {
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
others.value = (response.data.data || []).sort(byDeviceId)
step.value = 'others'
} catch (othersError) {
error.value = messageFrom(othersError, 'Could not load those drives')
} finally {
busy.value = false
}
}
async function act (drive, action, forsomeoneelse = false) {
if (busy.value) return
error.value = ''
busy.value = true
try {
const body = { device_id: drive.device_id, badge: badge.value }
if (forsomeoneelse) body.forsomeoneelse = true
const response = await api.post(`/usb/kiosk/${action}`, body)
doneMessage.value = response.data?.message
|| (action === 'checkin' ? 'Drive returned' : 'Drive checked out')
step.value = 'done'
// Clear the screen for the next person rather than leaving a name up.
resetTimer = setTimeout(reset, 8000)
} catch (actError) {
error.value = messageFrom(actError, 'That did not go through')
// The list is now out of date either way - somebody else may have taken it.
await refresh()
} finally {
busy.value = false
}
}
async function refresh () {
try {
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
const data = response.data.data
data.yours = (data.yours || []).sort(byDeviceId)
data.available = (data.available || []).sort(byDeviceId)
session.value = data
step.value = 'pick'
} catch (refreshError) {
reset()
}
}
function reset () {
clearTimeout(resetTimer)
step.value = 'badge'
badge.value = ''
session.value = { name: '', yours: [], available: [], otherscount: 0 }
others.value = []
error.value = ''
doneMessage.value = ''
manualBadge.value = ''
// The next person at the cabinet must not inherit somebody else's filter and
// conclude there are no drives left.
drivefilter.value = ''
focusEntry()
}
</script>
<style scoped>
/* Touch-sized throughout: a wall panel, often operated with gloves on.
Laid out to match the parts kiosk beside it - two kiosks on the same floor
that sit differently on their screens read as two different products. */
.usb-kiosk {
min-height: 100vh;
padding: 2rem;
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
align-items: center;
/* Centered as one block on a wall-mounted display rather than hugging the
top. `safe` so a short screen - or a long drive list - scrolls from the top
instead of clipping the header off the edge, which plain `center` does with
no way to scroll back up. */
justify-content: center;
justify-content: safe center;
gap: 1.5rem;
}
.kiosk-header {
width: 100%;
max-width: 72rem;
display: flex;
justify-content: center;
align-items: center;
/* Title centered over the panel; Done is pulled out of the flow so it keeps
its top-right corner instead of pushing the title off-center on the steps
that show it. */
position: relative;
}
.kiosk-header .btn {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
.kiosk-header h1 { margin: 0; font-size: 2rem; }
.kiosk-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
max-width: 40rem;
width: 100%;
}
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
that hold a single control - a cabinet of thirty drives is three or four
columns of one glance rather than a scroll. The steps that are just a keypad
or a message stay narrow, because stretching one input across a 24 inch panel
reads as a broken page.
It is also height-bounded: the lists absorb whatever space is left over and
scroll inside themselves, so the PAGE never scrolls and the heading saying
whose drives these are stays on screen. A fixed max-height cannot do this -
it either clips with room to spare or overflows once both groups are
present, which is exactly what it did. */
.kiosk-wide {
align-items: stretch;
max-width: 72rem;
max-height: calc(100vh - 9rem);
min-height: 0;
}
.kiosk-wide .drive-group {
display: flex;
flex-direction: column;
min-height: 0;
}
/* Your own drives size to their content - usually one or two - and the
available list absorbs what is left. Sharing the space evenly clipped a
single row of "Your drives" in half while the other list had room. */
.kiosk-wide .drive-group-yours { flex: 0 0 auto; }
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; }
.kiosk-error {
padding: 0.9rem 1.1rem;
border: 1px solid var(--danger, #b3261e);
border-left-width: 5px;
border-radius: 4px;
color: var(--danger, #b3261e);
font-size: 1.1rem;
text-align: center;
}
.kiosk-done { font-size: 1.6rem; text-align: center; margin: 2rem 0; }
.drive-group { width: 100%; }
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
/* Heading and filter on one line: the filter belongs to the list under it, and
a row of its own would cost height the lists need. */
.drive-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
.drive-filter-input {
/* Touch-sized like everything else here, and wide enough for a locker name
rather than just a drive number. */
min-width: 16rem;
padding: 0.6rem 0.8rem;
font-size: 1.1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card-solid);
color: var(--text);
}
.drive-filter-input:focus {
outline: none;
border-color: var(--primary);
}
.drive-filter-clear {
padding: 0.6rem 0.9rem;
font-size: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
}
/* As many columns as the panel has room for - four on a wall display, one on a
small screen - so a cabinet of a dozen drives is one glance rather than a
scroll. The list itself scrolls when it has to; the PAGE must not, or the
person loses the heading telling them whose drives these are. */
.drive-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.6rem;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.drive-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--bg-card);
color: var(--text);
font-size: 1.05rem;
text-align: left;
cursor: pointer;
}
.drive-row:disabled { opacity: 0.5; }
/* The name has to YIELD. A flex item defaults to min-width:auto, so a long
label kept its intrinsic width and shoved Take or Return out through the side
of the card - a drive nobody could take. `anywhere` breaks the labels that
carry no spaces at all, which a serial-style name often does not. */
.drive-name {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
/* Two lines of description is enough to tell two drives apart; more just makes
one card tall and every card in its row tall with it. The locker line below
is never clipped - it is how somebody finds the thing. */
.drive-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.drive-action {
flex: 0 0 auto;
padding: 0.5rem 1.1rem;
border-radius: 4px;
font-weight: 600;
font-size: 1rem;
}
.take { background: var(--primary, #2f6fed); color: #fff; }
.return { background: var(--warning, #b26a00); color: #fff; }
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
.entry-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem 2rem;
}
.entry-input {
width: 16.9rem;
box-sizing: border-box;
font-size: 2.4rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
text-align: center;
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--bg);
color: var(--text);
outline: none;
}
.entry-input:focus { border-color: var(--primary); }
.entry-input::placeholder {
color: var(--text-light);
font-weight: 400;
font-size: 1.4rem;
}
.act-button {
font-size: 1.4rem;
padding: 0.85rem 0;
width: 16.9rem;
border-radius: 0.75rem;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div>
<div class="page-header">
<h2>USB Devices</h2>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_enabled" />
<span>Allow the logged-out USB kiosk</span>
</label>
<p class="field-hint">
Turns on <code>/usb-kiosk</code>, a full-screen touch page where somebody
at the drive cabinet takes and returns a drive without signing in.
It is off by default: this is a write nobody has logged in for, so it is
a decision a site makes deliberately. While it is off, every kiosk
request is refused and the page says so.
</p>
<p class="field-hint">
The kiosk can only move a drive between checked out and checked in. It
cannot add, edit or retire one. Each action is attributed to the SSO
entered on the screen, and an SSO that matches no employee is refused -
a checkout nobody can trace to a person defeats the point of tracking
it.
</p>
<p v-if="values.usb_kiosk_enabled" class="field-hint">
<a :href="withBase('/usb-kiosk')" target="_blank">Open the kiosk</a>
- also in the sidebar under Displays.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
:disabled="!values.usb_kiosk_enabled" />
<span>Let somebody return a drive checked out to another person</span>
</label>
<p class="field-hint">
The kiosk asks first and names the holder, then records the SSO that
actually handed the drive back. Turn this off to refuse a mismatch
outright - but then a drive left behind by somebody on leave cannot be
returned at the kiosk at all, and needs a signed-in user to sort out.
</p>
</div>
<div class="form-actions">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
const values = ref({
usb_kiosk_enabled: false,
usb_kiosk_allow_return_for_others: true,
})
const saving = ref(false)
const message = ref('')
const error = ref('')
function asBool (value) {
return String(value).toLowerCase() === 'true' || value === true || value === 1
}
async function load () {
try {
const response = await settingsApi.list({ category: 'usb' })
for (const row of (response.data.data || [])) {
if (KEYS.includes(row.key)) values.value[row.key] = asBool(row.value)
}
} catch (loadError) {
error.value = 'Could not load USB settings.'
}
}
async function save () {
saving.value = true
message.value = ''
error.value = ''
try {
for (const key of KEYS) {
await settingsApi.update(key, values.value[key] ? 'true' : 'false')
}
message.value = 'Settings saved'
} catch (saveError) {
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.check-row input { width: auto; }
.form-actions { margin-top: 1rem; }
</style>

View File

@@ -70,6 +70,85 @@ class USBPlugin(BasePlugin):
'docs': 'plugins/usb/README.md',
}
def get_reports(self) -> List[Dict]:
"""Removable-media audit.
CMMC tracking is an audit obligation, and the only way to answer "who
had which drive, when" was to page through the check-in/out list by
hand. The window covers a shift, a periodic review, or everything ever
recorded.
"""
return [
{
'id': 'usb-audit',
'name': 'USB Check-in / Check-out Audit',
'description': 'Every take and return over a chosen window - '
'day, month, six months, year or all time - with '
'who, which drive and when',
'category': 'usage',
'endpoint': '/api/usb/reports/audit',
},
]
def get_settings_cards(self) -> List[Dict]:
"""Somewhere to actually turn the kiosk on.
The kiosk page tells an administrator to enable it in Settings under
USB. Without this card that place does not exist, and the instruction
is a dead end - the same trap the backups plugin shipped with, where
settings existed but only a shell could reach them.
"""
return [
{
'group': 'USB Devices',
'to': '/settings/usb',
'icon': 'usb',
'title': 'USB Settings',
'description': 'Logged-out kiosk for taking and returning '
'drives, and whether somebody may return a drive '
'checked out to another person',
'position': 45,
},
]
def get_settings_defaults(self) -> List[Dict]:
"""Kiosk settings.
The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns
it on deliberately. Opening that door is a decision about the door.
"""
return [
{
'key': 'usb_kiosk_enabled',
'value': 'false',
'valuetype': 'boolean',
'category': 'usb',
# Public: the kiosk page runs logged out and has to be able to
# tell "turned off" from "broken" before anyone signs in.
'public': True,
'description': 'Allow check-out and check-in from the logged-out '
'USB kiosk screen (/usb-kiosk). Off by default: the '
'kiosk writes without a login, attributing every '
'action to a scanned badge instead. It can only move '
'a device between checked-out and checked-in - it '
'cannot add, edit, retire or list devices.',
},
{
'key': 'usb_kiosk_allow_return_for_others',
'value': 'true',
'valuetype': 'boolean',
'category': 'usb',
'public': True,
'description': 'At the kiosk, allow somebody to return a device '
'checked out to a DIFFERENT person after confirming. '
'The returning badge is what gets recorded, so the '
'log shows who actually handed it back. Turn this '
'off to refuse any mismatch outright - a device left '
'behind by someone on leave then needs a signed-in '
'user to return it.',
},
]
def get_config_schema(self) -> List[Dict]:
"""CMMC USB check-in/out database connection. Host/name/user are settings
the wizard can edit; the password stays in .env (emitted, not stored)."""
@@ -106,7 +185,9 @@ class USBPlugin(BasePlugin):
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'position': 45,
'permission': 'usb.view',
'position': 25,
'section': 'assets',
},
]

View File

@@ -140,7 +140,7 @@ def _apply_links(warranty, assetids):
# =============================================================================
@warranty_bp.route('', methods=['GET'])
@jwt_required(optional=True)
@require_permission('warranty.view')
def list_warranties():
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
query = Warranty.query
@@ -174,7 +174,7 @@ def list_warranties():
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('warranty.view')
def warranties_for_asset(assetid):
"""Warranties covering one asset (for the asset-detail panel)."""
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
@@ -188,7 +188,7 @@ def warranties_for_asset(assetid):
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
@jwt_required(optional=True)
@require_permission('warranty.view')
def get_warranty(warrantyid):
warranty = db.session.get(Warranty, warrantyid)
if not warranty:
@@ -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
@@ -400,7 +395,7 @@ def sync_dell():
# =============================================================================
@warranty_bp.route('/report', methods=['GET'])
@jwt_required(optional=True)
@require_permission('warranty.view')
def warranty_report():
"""Counts + lists bucketed by derived status."""
today = date.today()
@@ -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."""

View File

@@ -4,7 +4,7 @@
<h1>Warranty Report</h1>
<div class="header-actions">
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading" subject="Warranty Report"
<EmailReportButton v-if="!loading && canemail" subject="Warranty Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
@@ -65,8 +65,15 @@
import { ref, computed, onMounted } from 'vue'
import { warrantyApi } from '@/api'
import EmailReportButton from '@/components/EmailReportButton.vue'
import { useAuthStore } from '@/stores/auth'
import MachineMapChip from '../components/MachineMapChip.vue'
// Emailing a report is the reports.export permission the endpoint enforces.
// Offering the button to somebody who will only get a 403 is worse than not
// offering it.
const authStore = useAuthStore()
const canemail = computed(() => authStore.hasPermission('reports.export'))
const loading = ref(true)
const counts = ref({})
const buckets = ref({})

View File

@@ -57,7 +57,8 @@ class WarrantyPlugin(BasePlugin):
'name': 'Warranties',
'icon': 'shield',
'route': '/warranties',
'position': 8,
'permission': 'warranty.view',
'position': 9,
},
]
@@ -69,6 +70,7 @@ class WarrantyPlugin(BasePlugin):
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
'category': 'warranty',
'route': '/reports/warranty',
'permission': 'warranty.view',
},
]