Files
shopdb-flask/plugins/machines/plugin.py
cproudlock 5eb84873e8
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
dashboard: convert the last dead widgets, and delete the one that had nothing
Three plugins still declared widgets naming Vue components nobody wrote.
Converting them honestly meant three different answers, not one.

notifications gets a real card: the active notifications themselves, not a
count. "4 active" tells an admin nothing; knowing WHICH message the shop is
looking at is the point, and it is how a stale one gets noticed and taken down.

machines gets machines out of service - anything not In Use, excluding
Inventory, because a spare on a shelf is stock rather than a problem. Someone is
supposed to be chasing each of those and today they are visible only to whoever
thinks to filter the list by status.

network gets NOTHING, and its declaration is deleted rather than converted.
Network devices carry no live status - no polling, no reachability check,
nothing that can be wrong - so the only possible card is a count of how many
exist, which is precisely the always-true number this dashboard exists to get
away from. A comment records that, so the next person does not re-add it. If
reachability is ever collected, that is the card.

Also adds a contract test over every declared card: no component names, a valid
renderer and severity, and - the one that matters - the endpoint must be a REAL
route. A declaration pointing at a route nobody wrote is exactly how the old
widgets rotted unnoticed for months, and now it fails the build instead.
2026-08-11 14:27:29 -04:00

245 lines
8.5 KiB
Python

"""Machines plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType, AssetStatus
from .models import Machine, MachineType
from .api import machines_bp
logger = logging.getLogger(__name__)
class MachinesPlugin(BasePlugin):
"""
Machines plugin - manages manufacturing machine assets.
Machines include CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Uses the new Asset architecture with Machine extension table.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifestpath = Path(__file__).parent / 'manifest.json'
if manifestpath.exists():
with open(manifestpath, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'machines'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Machine management for manufacturing assets'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/machines'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return machines_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [Machine, MachineType]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Machines plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_asset_statuses()
self._ensure_machine_types()
logger.info("Machines plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure machine asset type exists."""
existing = AssetType.query.filter_by(assettype='machine').first()
if not existing:
at = AssetType(
assettype='machine',
pluginname='machines',
tablename='machines',
description='Manufacturing machines (CNCs, CMMs, lathes, etc.)',
icon='cog'
)
db.session.add(at)
logger.debug("Created asset type: machine")
db.session.commit()
def _ensure_asset_statuses(self) -> None:
"""Ensure standard asset statuses exist."""
statuses = [
('In Use', 'Asset is currently in use', '#28a745'),
('Spare', 'Spare/backup asset', '#17a2b8'),
('Retired', 'Asset has been retired', '#6c757d'),
('Maintenance', 'Asset is under maintenance', '#ffc107'),
('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
]
for name, description, color in statuses:
existing = AssetStatus.query.filter_by(status=name).first()
if not existing:
s = AssetStatus(
status=name,
description=description,
color=color
)
db.session.add(s)
logger.debug(f"Created asset status: {name}")
db.session.commit()
def _ensure_machine_types(self) -> None:
"""Ensure basic machine types exist."""
machine_types = [
('CNC', 'Computer Numerical Control machine', 'cnc'),
('CMM', 'Coordinate Measuring Machine', 'cmm'),
('Lathe', 'Lathe machine', 'lathe'),
('Grinder', 'Grinding machine', 'grinder'),
('EDM', 'Electrical Discharge Machine', 'edm'),
('Part Marker', 'Part marking/engraving machine', 'marker'),
('Mill', 'Milling machine', 'mill'),
('Press', 'Press machine', 'press'),
('Robot', 'Industrial robot', 'robot'),
('Other', 'Other machine type', 'cog'),
]
for name, description, icon in machine_types:
existing = MachineType.query.filter_by(machinetype=name).first()
if not existing:
mt = MachineType(
machinetype=name,
description=description,
icon=icon
)
db.session.add(mt)
logger.debug(f"Created machine type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Machines plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('machines')
def machinescli():
"""Machines plugin commands."""
pass
@machinescli.command('list-types')
def list_types():
"""List all machine types."""
from flask import current_app
with current_app.app_context():
types = MachineType.query.filter_by(isactive=True).all()
if not types:
click.echo('No machine types found.')
return
click.echo('Machine Types:')
for t in types:
click.echo(f" [{t.machinetypeid}] {t.machinetype}")
@machinescli.command('stats')
def stats():
"""Show machine statistics."""
from flask import current_app
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(Machine).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active machines: {total}")
# By type
by_type = db.session.query(
MachineType.machinetype,
db.func.count(Machine.machineid)
).join(Machine, Machine.machinetypeid == MachineType.machinetypeid
).join(Asset, Asset.assetid == Machine.assetid
).filter(Asset.isactive == True
).group_by(MachineType.machinetype
).all()
if by_type:
click.echo("\nBy Type:")
for t, c in by_type:
click.echo(f" {t}: {c}")
return [machinescli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard card: machines not in service.
A machine sitting in Repair or marked Lost is a thing someone is
supposed to be chasing, and nothing surfaces it today - it is visible
only to whoever thinks to filter the machines list by status.
Replaces a declaration naming a component nobody wrote.
"""
return [
{
'id': 'machines-outofservice',
'title': 'Machines out of service',
'endpoint': '/api/machines/dashboard/outofservice',
'render': 'exceptions',
'severity': 'warning',
'permission': 'machines.view',
'empty': 'hide',
'position': 45,
'map': {
'title': 'assetnumber',
'detail': 'status',
'meta': [{'key': 'name'}],
'link': '/machines/{assetid}',
},
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Machines',
'icon': 'cog',
'route': '/machines',
'position': 10,
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('machines.view', 'View machines', 'machines'),
('machines.create', 'Create machines', 'machines'),
('machines.edit', 'Edit machines', 'machines'),
('machines.delete', 'Delete machines', 'machines'),
]