Merge remote-tracking branch 'origin/main' into wilm
# Conflicts: # shopdb/__init__.py
This commit is contained in:
@@ -77,7 +77,6 @@ def _summary(revision, islatest=False):
|
||||
|
||||
|
||||
@backups_bp.route('/kinds', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def list_kinds():
|
||||
"""Registered backup kinds, for UI that needs to enumerate them."""
|
||||
@@ -94,7 +93,6 @@ def list_kinds():
|
||||
|
||||
|
||||
@backups_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def asset_revisions(assetid):
|
||||
"""Revision history for one asset, newest first.
|
||||
@@ -130,7 +128,6 @@ def asset_revisions(assetid):
|
||||
|
||||
|
||||
@backups_bp.route('/asset/<int:assetid>/info', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def asset_info(assetid):
|
||||
"""A kind's 'at a glance' card, built from its LATEST revision.
|
||||
@@ -190,7 +187,6 @@ def asset_info(assetid):
|
||||
|
||||
|
||||
@backups_bp.route('/revisions/<int:backuprevisionid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def get_revision(backuprevisionid):
|
||||
"""One revision, including its parsed payload where there is one."""
|
||||
@@ -207,7 +203,6 @@ def get_revision(backuprevisionid):
|
||||
|
||||
|
||||
@backups_bp.route('/revisions/<int:backuprevisionid>/download', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.download')
|
||||
def download_revision(backuprevisionid):
|
||||
"""Render a revision back to its native file format.
|
||||
@@ -282,7 +277,6 @@ def download_revision(backuprevisionid):
|
||||
|
||||
|
||||
@backups_bp.route('/revisions/<int:backuprevisionid>/diff', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def diff_revision(backuprevisionid):
|
||||
"""Diff two revisions of the same asset+kind.
|
||||
@@ -372,7 +366,6 @@ def _diffprojections(old, new):
|
||||
|
||||
|
||||
@backups_bp.route('/dashboard/stale', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('backups.view')
|
||||
def dashboard_stale():
|
||||
"""Chains whose backup has stopped running.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1974,7 +1974,9 @@ class ComputersPlugin(BasePlugin):
|
||||
'name': 'PCs',
|
||||
'icon': 'desktop',
|
||||
'route': '/pcs',
|
||||
'permission': 'computers.view',
|
||||
'position': 15,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
require_any_permission,
|
||||
authorized_service_token, Setting, Application,
|
||||
)
|
||||
|
||||
@@ -338,8 +339,7 @@ def post_report():
|
||||
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_scopes():
|
||||
"""List imaging PC-type scopes with entry + published-version counts."""
|
||||
scopes = ManifestScope.query.order_by(
|
||||
@@ -364,8 +364,7 @@ def list_scopes():
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def preview_scope(scopeid):
|
||||
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -377,8 +376,7 @@ def preview_scope(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/applications', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_applications():
|
||||
"""The core Applications catalog (id + name) for the curated entry->app link
|
||||
picker in the entry editor."""
|
||||
@@ -446,7 +444,6 @@ def _apply_app_link(entry, payload):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_scope():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
@@ -475,8 +472,7 @@ def create_scope():
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
@@ -487,7 +483,6 @@ def get_scope(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -510,7 +505,6 @@ def update_scope(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -533,7 +527,6 @@ def _validate_entry(payload):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_entry(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -558,7 +551,6 @@ def create_entry(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
@@ -587,7 +579,6 @@ def update_entry(entryid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
@@ -599,7 +590,6 @@ def delete_entry(entryid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def reorder_entries(scopeid):
|
||||
"""Set entry order from a list of entryids (the ordering contract)."""
|
||||
@@ -621,8 +611,7 @@ def reorder_entries(scopeid):
|
||||
# -- simulate: "what would this PC get" ---------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def simulate_scope(scopeid):
|
||||
"""Which draft entries apply to a given machine profile, and why the rest
|
||||
are filtered out (reuses the engine-mirror filters)."""
|
||||
@@ -662,8 +651,7 @@ def simulate_scope(scopeid):
|
||||
# -- compliance: "how much of the fleet has this app" -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def scope_compliance(scopeid):
|
||||
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
||||
|
||||
@@ -684,7 +672,6 @@ PAYLOAD_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def upload_entry_payload(entryid):
|
||||
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
||||
@@ -709,8 +696,7 @@ def upload_entry_payload(entryid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def download_entry_payload(entryid):
|
||||
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
@@ -730,7 +716,6 @@ def download_entry_payload(entryid):
|
||||
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def publish_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -759,8 +744,7 @@ def publish_scope_route(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def publish_preflight(scopeid):
|
||||
"""Would publishing this scope outrun the fleet's enforcer lib?
|
||||
|
||||
@@ -781,8 +765,7 @@ def publish_preflight(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_versions(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
@@ -801,8 +784,7 @@ def list_versions(scopeid):
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
||||
methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_version(scopeid, versionnumber):
|
||||
version = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scopeid, versionnumber=versionnumber).first()
|
||||
@@ -830,8 +812,7 @@ def _normalize_cidrs(raw):
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_config():
|
||||
"""Plugin config: the on-share export root + the client IP allowlist."""
|
||||
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
@@ -843,7 +824,6 @@ def get_config():
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def put_config():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
@@ -870,7 +850,6 @@ def put_config():
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def export_share_route(scopeid):
|
||||
"""Write the scope's current published JSON to the configured share root
|
||||
@@ -892,7 +871,6 @@ def export_share_route(scopeid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def rollback_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
@@ -1221,8 +1199,7 @@ def _report_stale_cutoff():
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def list_reports():
|
||||
"""Latest enforcement report per PC (fleet compliance view).
|
||||
|
||||
@@ -1328,8 +1305,7 @@ def list_reports():
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def get_report(reportid):
|
||||
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
||||
report = db.session.get(ManifestEnforcementReport, reportid)
|
||||
@@ -1358,8 +1334,7 @@ def get_report(reportid):
|
||||
|
||||
|
||||
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||
def dashboard_failures():
|
||||
"""Entries that FAILED on their PC's most recent enforcement cycle.
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ class GeEnforcePlugin(BasePlugin):
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
||||
return [
|
||||
('geenforce.view', 'View imaging PC types and manifests',
|
||||
'geenforce'),
|
||||
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
||||
'geenforce'),
|
||||
('geenforce.publish', 'Publish, roll back, and export manifests',
|
||||
@@ -125,6 +127,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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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
378
plugins/guides/cli.py
Normal 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
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, '`')"><></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 | Header | Header 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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
130
plugins/guides/services/bundle.py
Normal file
130
plugins/guides/services/bundle.py
Normal 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
|
||||
464
plugins/guides/services/htmlimport.py
Normal file
464
plugins/guides/services/htmlimport.py
Normal 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}
|
||||
@@ -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."""
|
||||
|
||||
@@ -68,6 +68,7 @@ class KnowledgeBasePlugin(BasePlugin):
|
||||
'name': 'Knowledge Base',
|
||||
'icon': 'book-open',
|
||||
'route': '/knowledgebase',
|
||||
'permission': 'kb.view',
|
||||
'position': 35,
|
||||
'section': 'information',
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -231,7 +231,9 @@ class MachinesPlugin(BasePlugin):
|
||||
'name': 'Machines',
|
||||
'icon': 'cog',
|
||||
'route': '/machines',
|
||||
'permission': 'machines.view',
|
||||
'position': 10,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -207,7 +207,9 @@ class NetworkPlugin(BasePlugin):
|
||||
'name': 'Network',
|
||||
'icon': 'network-wired',
|
||||
'route': '/network',
|
||||
'permission': 'network.view',
|
||||
'position': 18,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -385,7 +385,9 @@ class PrintersPlugin(BasePlugin):
|
||||
'name': 'Printers',
|
||||
'icon': 'printer',
|
||||
'route': '/printers',
|
||||
'permission': 'printers.view',
|
||||
'position': 20,
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import (db, success_response, error_response, ErrorCodes,
|
||||
require_permission)
|
||||
require_permission, require_any_permission)
|
||||
from shopdb.api import send_upload
|
||||
|
||||
from ..models import TvSlide
|
||||
@@ -90,8 +90,7 @@ def serve_image(surface, filename):
|
||||
# =============================================================================
|
||||
|
||||
@slides_bp.route('/<surface>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
@require_any_permission('slides.view', 'slides.manage')
|
||||
def list_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
||||
@@ -108,7 +107,6 @@ def list_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/upload', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def upload_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
@@ -153,7 +151,6 @@ def upload_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/order', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def reorder_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
@@ -169,7 +166,6 @@ def reorder_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/delete', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def delete_slides(surface):
|
||||
if not _valid_surface(surface):
|
||||
@@ -195,7 +191,6 @@ def delete_slides(surface):
|
||||
|
||||
|
||||
@slides_bp.route('/<surface>/<int:slideid>', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
@require_permission('slides.manage')
|
||||
def update_slide(surface, slideid):
|
||||
if not _valid_surface(surface):
|
||||
|
||||
@@ -63,6 +63,7 @@ class SlidesPlugin(BasePlugin):
|
||||
non-admin role so a curator can manage the lobby TV + screensaver
|
||||
without full admin. Admins hold it by default."""
|
||||
return [
|
||||
('slides.view', 'View the slide decks', 'slides'),
|
||||
('slides.manage',
|
||||
'Manage lobby display and screensaver slides', 'slides'),
|
||||
]
|
||||
@@ -76,7 +77,10 @@ class SlidesPlugin(BasePlugin):
|
||||
'icon': 'image',
|
||||
'route': '/settings/slides',
|
||||
'position': 7,
|
||||
'permission': 'slides.manage',
|
||||
# The read permission: a manager holds it implicitly through
|
||||
# the route's require_any_permission, and it is the one a site
|
||||
# can grant to guests.
|
||||
'permission': ['slides.view', 'slides.manage'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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']}),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
29
plugins/usb/frontend/drivefilter.js
Normal file
29
plugins/usb/frontend/drivefilter.js
Normal 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))
|
||||
}
|
||||
62
plugins/usb/frontend/drivefilter.spec.js
Normal file
62
plugins/usb/frontend/drivefilter.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
509
plugins/usb/frontend/views/USBKiosk.vue
Normal file
509
plugins/usb/frontend/views/USBKiosk.vue
Normal 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>
|
||||
113
plugins/usb/frontend/views/USBSettings.vue
Normal file
113
plugins/usb/frontend/views/USBSettings.vue
Normal 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>
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user