Enforce plugin contract purity: single import surface via shopdb.api

Plugins were reaching into internal core paths (shopdb.core.models.*,
shopdb.extensions, shopdb.utils.*), coupling them to core's file layout and
violating the ADR-001 contract. Consolidate onto one versioned surface.

- shopdb.api: expand from 2 helpers to the full plugin import surface -
  db, cache; BaseModel, AuditMixin; core models (Asset, AssetType,
  AssetStatus, Vendor, Model, Communication, CommunicationType, Location,
  Setting, AuditLog, Application, AppVersion, OperatingSystem); response +
  pagination helpers; employee_connection. Documented in PLUGIN-HOOKS.md.
- Migrate all 22 plugin source files to import only from shopdb.api (plus
  shopdb.plugins.base for the ABC).
- Drop the printers plugin's legacy MachineType dependency: remove
  _ensure_legacy_machine_types and the seed_supplies machinetypeid lookup
  (Model.machinetypeid is nullable; printers carry type via PrinterType).
- Guard test test_plugins_only_import_contract_surface scans plugin source
  and fails on any core import outside shopdb.api / shopdb.plugins.base.
- Scaffold templates updated so generated plugins are contract-pure.
- Bump __contract_version__ 0.2.0 -> 0.3.0 (additive surface expansion;
  manifests pin <1.0.0 so they still satisfy).

145 tests pass, naming/style green, app factory boots all 6 plugins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 16:45:06 -04:00
parent 37ffb4add5
commit f663cc5bbe
29 changed files with 2152 additions and 2107 deletions

View File

@@ -233,6 +233,34 @@ These run when the plugin's installation state changes. All optional.
| `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches | | `on_enable(app)` | When the plugin is enabled at runtime | Subscribe to events, warm caches |
| `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues | | `on_disable(app)` | When the plugin is disabled at runtime | Unsubscribe, drain queues |
## The import surface (`shopdb.api`)
`shopdb.api` is the ONLY core module a plugin may import from (besides
`shopdb.plugins.base` for `BasePlugin` / `PluginMeta`). Importing internal
paths like `shopdb.core.models.*`, `shopdb.extensions`, or `shopdb.utils.*`
is a contract violation and fails the test
`tests/test_plugin_contract.py::test_plugins_only_import_contract_surface`.
What `shopdb.api` exposes:
- Infrastructure: `db`, `cache`
- Model bases: `BaseModel`, `AuditMixin`
- Core models: `Asset`, `AssetType`, `AssetStatus`, `Vendor`, `Model`,
`Communication`, `CommunicationType`, `Location`, `Setting`, `AuditLog`,
`Application`, `AppVersion`, `OperatingSystem`
- Responses: `success_response`, `error_response`, `paginated_response`,
`ErrorCodes`
- Pagination: `get_pagination_params`, `paginate_query`
- Helpers: `audit_log`, `resolve_asset_position`
- Legacy employee directory: `employee_connection`
```python
from shopdb.api import db, Asset, AssetType, success_response, paginate_query
```
Adding a name to `shopdb.api` is an additive (minor) contract bump; removing
one is breaking (major). See ADR-002.
## Helpers exposed to plugins ## Helpers exposed to plugins
The framework provides helper APIs in `shopdb.api` (the public namespace). The framework provides helper APIs in `shopdb.api` (the public namespace).

View File

@@ -3,18 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.core.models import (
Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog,
Communication, CommunicationType,
)
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp from ..models import Computer, ComputerType, ComputerInstalledApp

View File

@@ -1,204 +1,203 @@
"""Computer plugin models.""" """Computer plugin models."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class ComputerType(BaseModel):
class ComputerType(BaseModel): """
""" Computer type classification.
Computer type classification.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc. """
""" __tablename__ = 'computertypes'
__tablename__ = 'computertypes'
computertypeid = db.Column(db.Integer, primary_key=True)
computertypeid = db.Column(db.Integer, primary_key=True) computertype = db.Column(db.String(100), unique=True, nullable=False)
computertype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<ComputerType {self.computertype}>"
return f"<ComputerType {self.computertype}>"
class Computer(BaseModel):
class Computer(BaseModel): """
""" Computer-specific extension data.
Computer-specific extension data.
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores computer-specific fields like hostname, OS, logged in user, etc.
Stores computer-specific fields like hostname, OS, logged in user, etc. """
""" __tablename__ = 'computers'
__tablename__ = 'computers'
computerid = db.Column(db.Integer, primary_key=True)
computerid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Computer classification
# Computer classification computertypeid = db.Column(
computertypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('computertypes.computertypeid'),
db.ForeignKey('computertypes.computertypeid'), nullable=True
nullable=True )
)
# Network identity
# Network identity hostname = db.Column(
hostname = db.Column( db.String(100),
db.String(100), index=True,
index=True, comment='Network hostname'
comment='Network hostname' )
)
# Operating system
# Operating system osid = db.Column(
osid = db.Column( db.Integer,
db.Integer, db.ForeignKey('operatingsystems.osid'),
db.ForeignKey('operatingsystems.osid'), nullable=True
nullable=True )
)
# Hardware make/model (PCs carry vendor + model like equipment)
# Hardware make/model (PCs carry vendor + model like equipment) vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
) modelnumberid = db.Column(
modelnumberid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True
nullable=True )
)
# Status tracking
# Status tracking loggedinuser = db.Column(db.String(100), nullable=True)
loggedinuser = db.Column(db.String(100), nullable=True) lastreporteddate = db.Column(db.DateTime, nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True) lastboottime = db.Column(db.DateTime, nullable=True)
lastboottime = db.Column(db.DateTime, nullable=True)
# Remote access features
# Remote access features isvnc = db.Column(
isvnc = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='VNC remote access enabled'
comment='VNC remote access enabled' )
) iswinrm = db.Column(
iswinrm = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='WinRM enabled'
comment='WinRM enabled' )
)
# Classification flags
# Classification flags isshopfloor = db.Column(
isshopfloor = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Shopfloor PC (vs office PC)'
comment='Shopfloor PC (vs office PC)' )
)
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('computer', uselist=False, lazy='joined')
backref=db.backref('computer', uselist=False, lazy='joined') )
) computertype = db.relationship('ComputerType', backref='computers')
computertype = db.relationship('ComputerType', backref='computers') operatingsystem = db.relationship('OperatingSystem', backref='computers')
operatingsystem = db.relationship('OperatingSystem', backref='computers') vendor = db.relationship('Vendor')
vendor = db.relationship('Vendor') model = db.relationship('Model')
model = db.relationship('Model')
# Installed applications (one-to-many)
# Installed applications (one-to-many) installedapps = db.relationship(
installedapps = db.relationship( 'ComputerInstalledApp',
'ComputerInstalledApp', back_populates='computer',
back_populates='computer', cascade='all, delete-orphan',
cascade='all, delete-orphan', lazy='dynamic'
lazy='dynamic' )
)
__table_args__ = (
__table_args__ = ( db.Index('idx_computer_type', 'computertypeid'),
db.Index('idx_computer_type', 'computertypeid'), db.Index('idx_computer_hostname', 'hostname'),
db.Index('idx_computer_hostname', 'hostname'), db.Index('idx_computer_os', 'osid'),
db.Index('idx_computer_os', 'osid'), )
)
def __repr__(self):
def __repr__(self): return f"<Computer {self.hostname or self.assetid}>"
return f"<Computer {self.hostname or self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.computertype:
if self.computertype: result['computertypename'] = self.computertype.computertype
result['computertypename'] = self.computertype.computertype if self.operatingsystem:
if self.operatingsystem: result['osname'] = self.operatingsystem.osname
result['osname'] = self.operatingsystem.osname if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor if self.model:
if self.model: result['modelname'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber
return result
return result
class ComputerInstalledApp(db.Model):
class ComputerInstalledApp(db.Model): """
""" Junction table for applications installed on computers.
Junction table for applications installed on computers.
Tracks which applications are installed on which computers,
Tracks which applications are installed on which computers, including version information.
including version information. """
""" __tablename__ = 'computerinstalledapps'
__tablename__ = 'computerinstalledapps'
id = db.Column(db.Integer, primary_key=True)
id = db.Column(db.Integer, primary_key=True) computerid = db.Column(
computerid = db.Column( db.Integer,
db.Integer, db.ForeignKey('computers.computerid', ondelete='CASCADE'),
db.ForeignKey('computers.computerid', ondelete='CASCADE'), nullable=False
nullable=False )
) appid = db.Column(
appid = db.Column( db.Integer,
db.Integer, db.ForeignKey('applications.appid'),
db.ForeignKey('applications.appid'), nullable=False
nullable=False )
) appversionid = db.Column(
appversionid = db.Column( db.Integer,
db.Integer, db.ForeignKey('appversions.appversionid'),
db.ForeignKey('appversions.appversionid'), nullable=True
nullable=True )
) # Raw version string from automated collection (when no curated AppVersion)
# Raw version string from automated collection (when no curated AppVersion) installedversion = db.Column(db.String(100), nullable=True)
installedversion = db.Column(db.String(100), nullable=True) isactive = db.Column(db.Boolean, default=True, nullable=False)
isactive = db.Column(db.Boolean, default=True, nullable=False) installeddate = db.Column(db.DateTime, default=db.func.now())
installeddate = db.Column(db.DateTime, default=db.func.now())
# Relationships
# Relationships computer = db.relationship('Computer', back_populates='installedapps')
computer = db.relationship('Computer', back_populates='installedapps') application = db.relationship('Application')
application = db.relationship('Application') appversion = db.relationship('AppVersion')
appversion = db.relationship('AppVersion')
__table_args__ = (
__table_args__ = ( db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'), db.Index('idx_compapp_computer', 'computerid'),
db.Index('idx_compapp_computer', 'computerid'), db.Index('idx_compapp_app', 'appid'),
db.Index('idx_compapp_app', 'appid'), )
)
def to_dict(self):
def to_dict(self): """Convert to dictionary."""
"""Convert to dictionary.""" return {
return { 'id': self.id,
'id': self.id, 'computerid': self.computerid,
'computerid': self.computerid, 'appid': self.appid,
'appid': self.appid, 'appversionid': self.appversionid,
'appversionid': self.appversionid, 'isactive': self.isactive,
'isactive': self.isactive, 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, 'application': {
'application': { 'appid': self.application.appid,
'appid': self.application.appid, 'appname': self.application.appname,
'appname': self.application.appname, 'appdescription': self.application.appdescription,
'appdescription': self.application.appdescription, } if self.application else None,
} if self.application else None, 'version': self.appversion.version if self.appversion else None
'version': self.appversion.version if self.appversion else None }
}
def __repr__(self):
def __repr__(self): return f"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"
return f"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"

View File

@@ -1,307 +1,304 @@
"""Computers plugin main class.""" """Computers plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType, AssetStatus
from shopdb.core.models import AssetType, AssetStatus
from .models import Computer, ComputerType, ComputerInstalledApp
from .models import Computer, ComputerType, ComputerInstalledApp from .api import computers_bp
from .api import computers_bp
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class ComputersPlugin(BasePlugin):
class ComputersPlugin(BasePlugin): """
""" Computers plugin - manages PC, server, and workstation assets.
Computers plugin - manages PC, server, and workstation assets.
Computers include shopfloor PCs, engineer workstations, servers, etc.
Computers include shopfloor PCs, engineer workstations, servers, etc. Uses the new Asset architecture with Computer extension table.
Uses the new Asset architecture with Computer extension table. """
"""
def __init__(self):
def __init__(self): self._manifest = self._load_manifest()
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
def _load_manifest(self) -> Dict: """Load plugin manifest from JSON file."""
"""Load plugin manifest from JSON file.""" manifestpath = Path(__file__).parent / 'manifest.json'
manifestpath = Path(__file__).parent / 'manifest.json' if manifestpath.exists():
if manifestpath.exists(): with open(manifestpath, 'r') as f:
with open(manifestpath, 'r') as f: return json.load(f)
return json.load(f) return {}
return {}
@property
@property def meta(self) -> PluginMeta:
def meta(self) -> PluginMeta: """Return plugin metadata."""
"""Return plugin metadata.""" return PluginMeta(
return PluginMeta( name=self._manifest.get('name', 'computers'),
name=self._manifest.get('name', 'computers'), version=self._manifest.get('version', '1.0.0'),
version=self._manifest.get('version', '1.0.0'), description=self._manifest.get(
description=self._manifest.get( 'description',
'description', 'Computer management for PCs, servers, and workstations'
'Computer management for PCs, servers, and workstations' ),
), author=self._manifest.get('author', 'ShopDB Team'),
author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', []),
dependencies=self._manifest.get('dependencies', []), core_version=self._manifest.get('core_version', '>=1.0.0'),
core_version=self._manifest.get('core_version', '>=1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/computers'),
api_prefix=self._manifest.get('api_prefix', '/api/computers'), )
)
def get_blueprint(self) -> Optional[Blueprint]:
def get_blueprint(self) -> Optional[Blueprint]: """Return Flask Blueprint with API routes."""
"""Return Flask Blueprint with API routes.""" return computers_bp
return computers_bp
def get_models(self) -> List[Type]:
def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes."""
"""Return list of SQLAlchemy model classes.""" return [Computer, ComputerType, ComputerInstalledApp]
return [Computer, ComputerType, ComputerInstalledApp]
def init_app(self, app: Flask, db_instance) -> None:
def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app."""
"""Initialize plugin with Flask app.""" logger.info(f"Computers plugin initialized (v{self.meta.version})")
logger.info(f"Computers plugin initialized (v{self.meta.version})")
# -- ADR-006 collector contract -----------------------------------------
# -- ADR-006 collector contract -----------------------------------------
def get_collector_schema(self) -> Optional[Dict]:
def get_collector_schema(self) -> Optional[Dict]: """Schema for the PC collector payload (matched by hostname)."""
"""Schema for the PC collector payload (matched by hostname).""" return {
return { 'identityfield': 'hostname',
'identityfield': 'hostname', 'fields': {
'fields': { 'hostname': {'type': 'string', 'required': True},
'hostname': {'type': 'string', 'required': True}, 'serialnumber': {'type': 'string'},
'serialnumber': {'type': 'string'}, 'currentuser': {'type': 'string'},
'currentuser': {'type': 'string'}, 'lastboottime': {'type': 'string', 'format': 'date-time'},
'lastboottime': {'type': 'string', 'format': 'date-time'}, 'ipaddress': {'type': 'string'},
'ipaddress': {'type': 'string'}, 'installedsoftware': {
'installedsoftware': { 'type': 'array',
'type': 'array', 'items': {'name': 'string', 'version': 'string'},
'items': {'name': 'string', 'version': 'string'}, },
}, },
}, }
}
def apply_collector_payload(self, payload: Dict) -> Dict:
def apply_collector_payload(self, payload: Dict) -> Dict: """Idempotent upsert of a PC from a collector payload (by hostname)."""
"""Idempotent upsert of a PC from a collector payload (by hostname).""" from datetime import datetime
from datetime import datetime from shopdb.api import Asset, AssetType, Application, Communication, CommunicationType
from shopdb.core.models import (
Asset, AssetType, Application, Communication, CommunicationType, warnings = []
) hostname = (payload.get('hostname') or '').strip()
if not hostname:
warnings = [] raise ValueError('hostname is required')
hostname = (payload.get('hostname') or '').strip()
if not hostname: comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
raise ValueError('hostname is required') if not comp:
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid)
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first() .filter(Asset.assetnumber.ilike(hostname)).first())
if not comp:
comp = (Computer.query.join(Asset, Asset.assetid == Computer.assetid) action = 'updated'
.filter(Asset.assetnumber.ilike(hostname)).first()) if not comp:
atype = AssetType.query.filter_by(assettype='computer').first()
action = 'updated' asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid,
if not comp: statusid=1)
atype = AssetType.query.filter_by(assettype='computer').first() db.session.add(asset)
asset = Asset(assetnumber=hostname, assettypeid=atype.assettypeid, db.session.flush()
statusid=1) comp = Computer(assetid=asset.assetid, hostname=hostname)
db.session.add(asset) db.session.add(comp)
db.session.flush() db.session.flush()
comp = Computer(assetid=asset.assetid, hostname=hostname) action = 'created'
db.session.add(comp)
db.session.flush() comp.lastreporteddate = datetime.utcnow()
action = 'created' if payload.get('lastboottime'):
try:
comp.lastreporteddate = datetime.utcnow() comp.lastboottime = datetime.fromisoformat(
if payload.get('lastboottime'): payload['lastboottime'].replace('Z', '+00:00'))
try: except (ValueError, AttributeError):
comp.lastboottime = datetime.fromisoformat( warnings.append('lastboottime not parseable')
payload['lastboottime'].replace('Z', '+00:00')) if payload.get('currentuser'):
except (ValueError, AttributeError): comp.loggedinuser = payload['currentuser']
warnings.append('lastboottime not parseable') if payload.get('serialnumber') and comp.asset:
if payload.get('currentuser'): comp.asset.serialnumber = payload['serialnumber']
comp.loggedinuser = payload['currentuser']
if payload.get('serialnumber') and comp.asset: if payload.get('ipaddress'):
comp.asset.serialnumber = payload['serialnumber'] ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
primary = Communication.query.filter_by(
if payload.get('ipaddress'): assetid=comp.assetid, isprimary=True).first()
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() if primary:
primary = Communication.query.filter_by( primary.ipaddress = payload['ipaddress']
assetid=comp.assetid, isprimary=True).first() elif ip_comtype:
if primary: db.session.add(Communication(
primary.ipaddress = payload['ipaddress'] assetid=comp.assetid, comtypeid=ip_comtype.comtypeid,
elif ip_comtype: ipaddress=payload['ipaddress'], isprimary=True))
db.session.add(Communication(
assetid=comp.assetid, comtypeid=ip_comtype.comtypeid, for app_data in payload.get('installedsoftware', []) or []:
ipaddress=payload['ipaddress'], isprimary=True)) name = app_data.get('name')
if not name:
for app_data in payload.get('installedsoftware', []) or []: continue
name = app_data.get('name') app = Application.query.filter(Application.appname.ilike(name)).first()
if not name: if not app:
continue warnings.append(f'unknown application: {name}')
app = Application.query.filter(Application.appname.ilike(name)).first() continue
if not app: installed = ComputerInstalledApp.query.filter_by(
warnings.append(f'unknown application: {name}') computerid=comp.computerid, appid=app.appid).first()
continue version = app_data.get('version')
installed = ComputerInstalledApp.query.filter_by( if installed:
computerid=comp.computerid, appid=app.appid).first() installed.installedversion = version
version = app_data.get('version') installed.isactive = True
if installed: else:
installed.installedversion = version db.session.add(ComputerInstalledApp(
installed.isactive = True computerid=comp.computerid, appid=app.appid,
else: installedversion=version))
db.session.add(ComputerInstalledApp(
computerid=comp.computerid, appid=app.appid, db.session.commit()
installedversion=version)) return {
'action': action,
db.session.commit() 'assetid': comp.assetid,
return { 'identityvalue': hostname,
'action': action, 'warnings': warnings,
'assetid': comp.assetid, }
'identityvalue': hostname,
'warnings': warnings, def on_install(self, app: Flask) -> None:
} """Called when plugin is installed."""
with app.app_context():
def on_install(self, app: Flask) -> None: self._ensure_asset_type()
"""Called when plugin is installed.""" self._ensure_computer_types()
with app.app_context(): logger.info("Computers plugin installed")
self._ensure_asset_type()
self._ensure_computer_types() def _ensure_asset_type(self) -> None:
logger.info("Computers plugin installed") """Ensure computer asset type exists."""
existing = AssetType.query.filter_by(assettype='computer').first()
def _ensure_asset_type(self) -> None: if not existing:
"""Ensure computer asset type exists.""" at = AssetType(
existing = AssetType.query.filter_by(assettype='computer').first() assettype='computer',
if not existing: pluginname='computers',
at = AssetType( tablename='computers',
assettype='computer', description='PCs, servers, and workstations',
pluginname='computers', icon='desktop'
tablename='computers', )
description='PCs, servers, and workstations', db.session.add(at)
icon='desktop' logger.debug("Created asset type: computer")
) db.session.commit()
db.session.add(at)
logger.debug("Created asset type: computer") def _ensure_computer_types(self) -> None:
db.session.commit() """Ensure basic computer types exist."""
computer_types = [
def _ensure_computer_types(self) -> None: ('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'),
"""Ensure basic computer types exist.""" ('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'),
computer_types = [ ('CMM PC', 'PC dedicated to CMM operation', 'desktop'),
('Shopfloor PC', 'PC located on the shop floor for machine operation', 'desktop'), ('Server', 'Server system', 'server'),
('Engineer Workstation', 'Engineering workstation for CAD/CAM work', 'laptop'), ('Kiosk', 'Kiosk or info display PC', 'tv'),
('CMM PC', 'PC dedicated to CMM operation', 'desktop'), ('Laptop', 'Laptop computer', 'laptop'),
('Server', 'Server system', 'server'), ('Virtual Machine', 'Virtual machine', 'cloud'),
('Kiosk', 'Kiosk or info display PC', 'tv'), ('Other', 'Other computer type', 'desktop'),
('Laptop', 'Laptop computer', 'laptop'), ]
('Virtual Machine', 'Virtual machine', 'cloud'),
('Other', 'Other computer type', 'desktop'), for name, description, icon in computer_types:
] existing = ComputerType.query.filter_by(computertype=name).first()
if not existing:
for name, description, icon in computer_types: ct = ComputerType(
existing = ComputerType.query.filter_by(computertype=name).first() computertype=name,
if not existing: description=description,
ct = ComputerType( icon=icon
computertype=name, )
description=description, db.session.add(ct)
icon=icon logger.debug(f"Created computer type: {name}")
)
db.session.add(ct) db.session.commit()
logger.debug(f"Created computer type: {name}")
def on_uninstall(self, app: Flask) -> None:
db.session.commit() """Called when plugin is uninstalled."""
logger.info("Computers plugin uninstalled")
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled.""" def get_cli_commands(self) -> List:
logger.info("Computers plugin uninstalled") """Return CLI commands for this plugin."""
def get_cli_commands(self) -> List: @click.group('computers')
"""Return CLI commands for this plugin.""" def computerscli():
"""Computers plugin commands."""
@click.group('computers') pass
def computerscli():
"""Computers plugin commands.""" @computerscli.command('list-types')
pass def list_types():
"""List all computer types."""
@computerscli.command('list-types') from flask import current_app
def list_types():
"""List all computer types.""" with current_app.app_context():
from flask import current_app types = ComputerType.query.filter_by(isactive=True).all()
if not types:
with current_app.app_context(): click.echo('No computer types found.')
types = ComputerType.query.filter_by(isactive=True).all() return
if not types:
click.echo('No computer types found.') click.echo('Computer Types:')
return for t in types:
click.echo(f" [{t.computertypeid}] {t.computertype}")
click.echo('Computer Types:')
for t in types: @computerscli.command('stats')
click.echo(f" [{t.computertypeid}] {t.computertype}") def stats():
"""Show computer statistics."""
@computerscli.command('stats') from flask import current_app
def stats(): from shopdb.api import Asset
"""Show computer statistics."""
from flask import current_app with current_app.app_context():
from shopdb.core.models import Asset total = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True
with current_app.app_context(): ).count()
total = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True click.echo(f"Total active computers: {total}")
).count()
# Shopfloor count
click.echo(f"Total active computers: {total}") shopfloor = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True,
# Shopfloor count Computer.isshopfloor == True
shopfloor = db.session.query(Computer).join(Asset).filter( ).count()
Asset.isactive == True,
Computer.isshopfloor == True click.echo(f" Shopfloor PCs: {shopfloor}")
).count() click.echo(f" Other: {total - shopfloor}")
click.echo(f" Shopfloor PCs: {shopfloor}") @computerscli.command('find')
click.echo(f" Other: {total - shopfloor}") @click.argument('hostname')
def find_by_hostname(hostname):
@computerscli.command('find') """Find a computer by hostname."""
@click.argument('hostname') from flask import current_app
def find_by_hostname(hostname):
"""Find a computer by hostname.""" with current_app.app_context():
from flask import current_app comp = Computer.query.filter(
Computer.hostname.ilike(f'%{hostname}%')
with current_app.app_context(): ).first()
comp = Computer.query.filter(
Computer.hostname.ilike(f'%{hostname}%') if not comp:
).first() click.echo(f'No computer found matching hostname: {hostname}')
return
if not comp:
click.echo(f'No computer found matching hostname: {hostname}') click.echo(f'Found: {comp.hostname}')
return click.echo(f' Asset: {comp.asset.assetnumber}')
click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}')
click.echo(f'Found: {comp.hostname}') click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}')
click.echo(f' Asset: {comp.asset.assetnumber}') click.echo(f' Logged in: {comp.loggedinuser or "N/A"}')
click.echo(f' Type: {comp.computertype.computertype if comp.computertype else "N/A"}')
click.echo(f' OS: {comp.operatingsystem.osname if comp.operatingsystem else "N/A"}') return [computerscli]
click.echo(f' Logged in: {comp.loggedinuser or "N/A"}')
def get_dashboard_widgets(self) -> List[Dict]:
return [computerscli] """Return dashboard widget definitions."""
return [
def get_dashboard_widgets(self) -> List[Dict]: {
"""Return dashboard widget definitions.""" 'name': 'Computer Status',
return [ 'component': 'ComputerStatusWidget',
{ 'endpoint': '/api/computers/dashboard/summary',
'name': 'Computer Status', 'size': 'medium',
'component': 'ComputerStatusWidget', 'position': 6,
'endpoint': '/api/computers/dashboard/summary', },
'size': 'medium', ]
'position': 6,
}, def get_navigation_items(self) -> List[Dict]:
] """Return navigation menu items."""
return [
def get_navigation_items(self) -> List[Dict]: {
"""Return navigation menu items.""" 'name': 'PCs',
return [ 'icon': 'desktop',
{ 'route': '/pcs',
'name': 'PCs', 'position': 15,
'icon': 'desktop', },
'route': '/pcs', ]
'position': 15,
},
]

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, Asset, AssetType, Vendor, Model, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.core.models import Asset, AssetType, Vendor, Model, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Equipment, EquipmentType from ..models import Equipment, EquipmentType
@@ -446,7 +438,7 @@ def dashboard_summary():
).all() ).all()
# Count by status # Count by status
from shopdb.core.models import AssetStatus from shopdb.api import AssetStatus
by_status = db.session.query( by_status = db.session.query(
AssetStatus.status, AssetStatus.status,
db.func.count(Equipment.equipmentid) db.func.count(Equipment.equipmentid)

View File

@@ -1,133 +1,132 @@
"""Equipment plugin models.""" """Equipment plugin models."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class EquipmentType(BaseModel):
class EquipmentType(BaseModel): """
""" Equipment type classification.
Equipment type classification.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc.
Examples: CNC, CMM, Lathe, Grinder, EDM, Part Marker, etc. """
""" __tablename__ = 'equipmenttypes'
__tablename__ = 'equipmenttypes'
equipmenttypeid = db.Column(db.Integer, primary_key=True)
equipmenttypeid = db.Column(db.Integer, primary_key=True) equipmenttype = db.Column(db.String(100), unique=True, nullable=False)
equipmenttype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<EquipmentType {self.equipmenttype}>"
return f"<EquipmentType {self.equipmenttype}>"
class Equipment(BaseModel):
class Equipment(BaseModel): """
""" Equipment-specific extension data.
Equipment-specific extension data.
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores equipment-specific fields like type, model, vendor, etc.
Stores equipment-specific fields like type, model, vendor, etc. """
""" __tablename__ = 'equipment'
__tablename__ = 'equipment'
equipmentid = db.Column(db.Integer, primary_key=True)
equipmentid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Equipment classification
# Equipment classification equipmenttypeid = db.Column(
equipmenttypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('equipmenttypes.equipmenttypeid'),
db.ForeignKey('equipmenttypes.equipmenttypeid'), nullable=True
nullable=True )
)
# Vendor and model
# Vendor and model vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
) modelnumberid = db.Column(
modelnumberid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True
nullable=True )
)
# Equipment-specific fields
# Equipment-specific fields requiresmanualconfig = db.Column(
requiresmanualconfig = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Multi-PC machine needs manual configuration'
comment='Multi-PC machine needs manual configuration' )
) islocationonly = db.Column(
islocationonly = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Virtual location marker (not actual equipment)'
comment='Virtual location marker (not actual equipment)' )
)
# Maintenance tracking
# Maintenance tracking lastmaintenancedate = db.Column(db.DateTime, nullable=True)
lastmaintenancedate = db.Column(db.DateTime, nullable=True) nextmaintenancedate = db.Column(db.DateTime, nullable=True)
nextmaintenancedate = db.Column(db.DateTime, nullable=True) maintenanceintervaldays = db.Column(db.Integer, nullable=True)
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines)
# Controller info (for CNC machines) controllervendorid = db.Column(
controllervendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True,
nullable=True, comment='Controller vendor (e.g., FANUC)'
comment='Controller vendor (e.g., FANUC)' )
) controllermodelid = db.Column(
controllermodelid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True,
nullable=True, comment='Controller model (e.g., 31B)'
comment='Controller model (e.g., 31B)' )
)
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('equipment', uselist=False, lazy='joined')
backref=db.backref('equipment', uselist=False, lazy='joined') )
) equipmenttype = db.relationship('EquipmentType', backref='equipment')
equipmenttype = db.relationship('EquipmentType', backref='equipment') vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items')
vendor = db.relationship('Vendor', foreign_keys=[vendorid], backref='equipment_items') model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items')
model = db.relationship('Model', foreign_keys=[modelnumberid], backref='equipment_items') controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers')
controllervendor = db.relationship('Vendor', foreign_keys=[controllervendorid], backref='equipment_controllers') controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
controllermodel = db.relationship('Model', foreign_keys=[controllermodelid], backref='equipment_controller_models')
__table_args__ = (
__table_args__ = ( db.Index('idx_equipment_type', 'equipmenttypeid'),
db.Index('idx_equipment_type', 'equipmenttypeid'), db.Index('idx_equipment_vendor', 'vendorid'),
db.Index('idx_equipment_vendor', 'vendorid'), )
)
def __repr__(self):
def __repr__(self): return f"<Equipment {self.assetid}>"
return f"<Equipment {self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.equipmenttype:
if self.equipmenttype: result['equipmenttypename'] = self.equipmenttype.equipmenttype
result['equipmenttypename'] = self.equipmenttype.equipmenttype if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor if self.model:
if self.model: result['modelname'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber if self.model.imageurl:
if self.model.imageurl: result['imageurl'] = self.model.imageurl
result['imageurl'] = self.model.imageurl
# Add controller info
# Add controller info if self.controllervendor:
if self.controllervendor: result['controllervendorname'] = self.controllervendor.vendor
result['controllervendorname'] = self.controllervendor.vendor if self.controllermodel:
if self.controllermodel: result['controllermodelname'] = self.controllermodel.modelnumber
result['controllermodelname'] = self.controllermodel.modelnumber
return result
return result

View File

@@ -1,220 +1,219 @@
"""Equipment plugin main class.""" """Equipment plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType, AssetStatus
from shopdb.core.models import AssetType, AssetStatus
from .models import Equipment, EquipmentType
from .models import Equipment, EquipmentType from .api import equipment_bp
from .api import equipment_bp
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class EquipmentPlugin(BasePlugin):
class EquipmentPlugin(BasePlugin): """
""" Equipment plugin - manages manufacturing equipment assets.
Equipment plugin - manages manufacturing equipment assets.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc.
Equipment includes CNCs, CMMs, lathes, grinders, EDMs, part markers, etc. Uses the new Asset architecture with Equipment extension table.
Uses the new Asset architecture with Equipment extension table. """
"""
def __init__(self):
def __init__(self): self._manifest = self._load_manifest()
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
def _load_manifest(self) -> Dict: """Load plugin manifest from JSON file."""
"""Load plugin manifest from JSON file.""" manifestpath = Path(__file__).parent / 'manifest.json'
manifestpath = Path(__file__).parent / 'manifest.json' if manifestpath.exists():
if manifestpath.exists(): with open(manifestpath, 'r') as f:
with open(manifestpath, 'r') as f: return json.load(f)
return json.load(f) return {}
return {}
@property
@property def meta(self) -> PluginMeta:
def meta(self) -> PluginMeta: """Return plugin metadata."""
"""Return plugin metadata.""" return PluginMeta(
return PluginMeta( name=self._manifest.get('name', 'equipment'),
name=self._manifest.get('name', 'equipment'), version=self._manifest.get('version', '1.0.0'),
version=self._manifest.get('version', '1.0.0'), description=self._manifest.get(
description=self._manifest.get( 'description',
'description', 'Equipment management for manufacturing assets'
'Equipment management for manufacturing assets' ),
), author=self._manifest.get('author', 'ShopDB Team'),
author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', []),
dependencies=self._manifest.get('dependencies', []), core_version=self._manifest.get('core_version', '>=1.0.0'),
core_version=self._manifest.get('core_version', '>=1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/equipment'),
api_prefix=self._manifest.get('api_prefix', '/api/equipment'), )
)
def get_blueprint(self) -> Optional[Blueprint]:
def get_blueprint(self) -> Optional[Blueprint]: """Return Flask Blueprint with API routes."""
"""Return Flask Blueprint with API routes.""" return equipment_bp
return equipment_bp
def get_models(self) -> List[Type]:
def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes."""
"""Return list of SQLAlchemy model classes.""" return [Equipment, EquipmentType]
return [Equipment, EquipmentType]
def init_app(self, app: Flask, db_instance) -> None:
def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app."""
"""Initialize plugin with Flask app.""" logger.info(f"Equipment plugin initialized (v{self.meta.version})")
logger.info(f"Equipment plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
def on_install(self, app: Flask) -> None: """Called when plugin is installed."""
"""Called when plugin is installed.""" with app.app_context():
with app.app_context(): self._ensure_asset_type()
self._ensure_asset_type() self._ensure_asset_statuses()
self._ensure_asset_statuses() self._ensure_equipment_types()
self._ensure_equipment_types() logger.info("Equipment plugin installed")
logger.info("Equipment plugin installed")
def _ensure_asset_type(self) -> None:
def _ensure_asset_type(self) -> None: """Ensure equipment asset type exists."""
"""Ensure equipment asset type exists.""" existing = AssetType.query.filter_by(assettype='equipment').first()
existing = AssetType.query.filter_by(assettype='equipment').first() if not existing:
if not existing: at = AssetType(
at = AssetType( assettype='equipment',
assettype='equipment', pluginname='equipment',
pluginname='equipment', tablename='equipment',
tablename='equipment', description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)',
description='Manufacturing equipment (CNCs, CMMs, lathes, etc.)', icon='cog'
icon='cog' )
) db.session.add(at)
db.session.add(at) logger.debug("Created asset type: equipment")
logger.debug("Created asset type: equipment") db.session.commit()
db.session.commit()
def _ensure_asset_statuses(self) -> None:
def _ensure_asset_statuses(self) -> None: """Ensure standard asset statuses exist."""
"""Ensure standard asset statuses exist.""" statuses = [
statuses = [ ('In Use', 'Asset is currently in use', '#28a745'),
('In Use', 'Asset is currently in use', '#28a745'), ('Spare', 'Spare/backup asset', '#17a2b8'),
('Spare', 'Spare/backup asset', '#17a2b8'), ('Retired', 'Asset has been retired', '#6c757d'),
('Retired', 'Asset has been retired', '#6c757d'), ('Maintenance', 'Asset is under maintenance', '#ffc107'),
('Maintenance', 'Asset is under maintenance', '#ffc107'), ('Decommissioned', 'Asset has been decommissioned', '#dc3545'),
('Decommissioned', 'Asset has been decommissioned', '#dc3545'), ]
]
for name, description, color in statuses:
for name, description, color in statuses: existing = AssetStatus.query.filter_by(status=name).first()
existing = AssetStatus.query.filter_by(status=name).first() if not existing:
if not existing: s = AssetStatus(
s = AssetStatus( status=name,
status=name, description=description,
description=description, color=color
color=color )
) db.session.add(s)
db.session.add(s) logger.debug(f"Created asset status: {name}")
logger.debug(f"Created asset status: {name}")
db.session.commit()
db.session.commit()
def _ensure_equipment_types(self) -> None:
def _ensure_equipment_types(self) -> None: """Ensure basic equipment types exist."""
"""Ensure basic equipment types exist.""" equipment_types = [
equipment_types = [ ('CNC', 'Computer Numerical Control machine', 'cnc'),
('CNC', 'Computer Numerical Control machine', 'cnc'), ('CMM', 'Coordinate Measuring Machine', 'cmm'),
('CMM', 'Coordinate Measuring Machine', 'cmm'), ('Lathe', 'Lathe machine', 'lathe'),
('Lathe', 'Lathe machine', 'lathe'), ('Grinder', 'Grinding machine', 'grinder'),
('Grinder', 'Grinding machine', 'grinder'), ('EDM', 'Electrical Discharge Machine', 'edm'),
('EDM', 'Electrical Discharge Machine', 'edm'), ('Part Marker', 'Part marking/engraving equipment', 'marker'),
('Part Marker', 'Part marking/engraving equipment', 'marker'), ('Mill', 'Milling machine', 'mill'),
('Mill', 'Milling machine', 'mill'), ('Press', 'Press machine', 'press'),
('Press', 'Press machine', 'press'), ('Robot', 'Industrial robot', 'robot'),
('Robot', 'Industrial robot', 'robot'), ('Other', 'Other equipment type', 'cog'),
('Other', 'Other equipment type', 'cog'), ]
]
for name, description, icon in equipment_types:
for name, description, icon in equipment_types: existing = EquipmentType.query.filter_by(equipmenttype=name).first()
existing = EquipmentType.query.filter_by(equipmenttype=name).first() if not existing:
if not existing: et = EquipmentType(
et = EquipmentType( equipmenttype=name,
equipmenttype=name, description=description,
description=description, icon=icon
icon=icon )
) db.session.add(et)
db.session.add(et) logger.debug(f"Created equipment type: {name}")
logger.debug(f"Created equipment type: {name}")
db.session.commit()
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
def on_uninstall(self, app: Flask) -> None: """Called when plugin is uninstalled."""
"""Called when plugin is uninstalled.""" logger.info("Equipment plugin uninstalled")
logger.info("Equipment plugin uninstalled")
def get_cli_commands(self) -> List:
def get_cli_commands(self) -> List: """Return CLI commands for this plugin."""
"""Return CLI commands for this plugin."""
@click.group('equipment')
@click.group('equipment') def equipmentcli():
def equipmentcli(): """Equipment plugin commands."""
"""Equipment plugin commands.""" pass
pass
@equipmentcli.command('list-types')
@equipmentcli.command('list-types') def list_types():
def list_types(): """List all equipment types."""
"""List all equipment types.""" from flask import current_app
from flask import current_app
with current_app.app_context():
with current_app.app_context(): types = EquipmentType.query.filter_by(isactive=True).all()
types = EquipmentType.query.filter_by(isactive=True).all() if not types:
if not types: click.echo('No equipment types found.')
click.echo('No equipment types found.') return
return
click.echo('Equipment Types:')
click.echo('Equipment Types:') for t in types:
for t in types: click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
click.echo(f" [{t.equipmenttypeid}] {t.equipmenttype}")
@equipmentcli.command('stats')
@equipmentcli.command('stats') def stats():
def stats(): """Show equipment statistics."""
"""Show equipment statistics.""" from flask import current_app
from flask import current_app from shopdb.api import Asset
from shopdb.core.models import Asset
with current_app.app_context():
with current_app.app_context(): total = db.session.query(Equipment).join(Asset).filter(
total = db.session.query(Equipment).join(Asset).filter( Asset.isactive == True
Asset.isactive == True ).count()
).count()
click.echo(f"Total active equipment: {total}")
click.echo(f"Total active equipment: {total}")
# By type
# By type by_type = db.session.query(
by_type = db.session.query( EquipmentType.equipmenttype,
EquipmentType.equipmenttype, db.func.count(Equipment.equipmentid)
db.func.count(Equipment.equipmentid) ).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid
).join(Equipment, Equipment.equipmenttypeid == EquipmentType.equipmenttypeid ).join(Asset, Asset.assetid == Equipment.assetid
).join(Asset, Asset.assetid == Equipment.assetid ).filter(Asset.isactive == True
).filter(Asset.isactive == True ).group_by(EquipmentType.equipmenttype
).group_by(EquipmentType.equipmenttype ).all()
).all()
if by_type:
if by_type: click.echo("\nBy Type:")
click.echo("\nBy Type:") for t, c in by_type:
for t, c in by_type: click.echo(f" {t}: {c}")
click.echo(f" {t}: {c}")
return [equipmentcli]
return [equipmentcli]
def get_dashboard_widgets(self) -> List[Dict]:
def get_dashboard_widgets(self) -> List[Dict]: """Return dashboard widget definitions."""
"""Return dashboard widget definitions.""" return [
return [ {
{ 'name': 'Equipment Status',
'name': 'Equipment Status', 'component': 'EquipmentStatusWidget',
'component': 'EquipmentStatusWidget', 'endpoint': '/api/equipment/dashboard/summary',
'endpoint': '/api/equipment/dashboard/summary', 'size': 'medium',
'size': 'medium', 'position': 5,
'position': 5, },
}, ]
]
def get_navigation_items(self) -> List[Dict]:
def get_navigation_items(self) -> List[Dict]: """Return navigation menu items."""
"""Return navigation menu items.""" return [
return [ {
{ 'name': 'Equipment',
'name': 'Equipment', 'icon': 'cog',
'icon': 'cog', 'route': '/machines',
'route': '/machines', 'position': 10,
'position': 10, },
}, ]
]

View File

@@ -3,15 +3,7 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, Asset, AssetType, Vendor, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.core.models import Asset, AssetType, Vendor, AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN

View File

@@ -1,121 +1,120 @@
"""Network device plugin models.""" """Network device plugin models."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class NetworkDeviceType(BaseModel):
class NetworkDeviceType(BaseModel): """
""" Network device type classification.
Network device type classification.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc. """
""" __tablename__ = 'networkdevicetypes'
__tablename__ = 'networkdevicetypes'
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
networkdevicetypeid = db.Column(db.Integer, primary_key=True) networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<NetworkDeviceType {self.networkdevicetype}>"
return f"<NetworkDeviceType {self.networkdevicetype}>"
class NetworkDevice(BaseModel):
class NetworkDevice(BaseModel): """
""" Network device-specific extension data.
Network device-specific extension data.
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores network device-specific fields like hostname, firmware, ports, etc.
Stores network device-specific fields like hostname, firmware, ports, etc. """
""" __tablename__ = 'networkdevices'
__tablename__ = 'networkdevices'
networkdeviceid = db.Column(db.Integer, primary_key=True)
networkdeviceid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Network device classification
# Network device classification networkdevicetypeid = db.Column(
networkdevicetypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
db.ForeignKey('networkdevicetypes.networkdevicetypeid'), nullable=True
nullable=True )
)
# Vendor
# Vendor vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
)
# Network identity
# Network identity hostname = db.Column(
hostname = db.Column( db.String(100),
db.String(100), index=True,
index=True, comment='Network hostname'
comment='Network hostname' )
)
# Firmware/software version
# Firmware/software version firmwareversion = db.Column(db.String(100), nullable=True)
firmwareversion = db.Column(db.String(100), nullable=True)
# Physical characteristics
# Physical characteristics portcount = db.Column(
portcount = db.Column( db.Integer,
db.Integer, nullable=True,
nullable=True, comment='Number of ports (for switches)'
comment='Number of ports (for switches)' )
)
# Features
# Features ispoe = db.Column(
ispoe = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Power over Ethernet capable'
comment='Power over Ethernet capable' )
) ismanaged = db.Column(
ismanaged = db.Column( db.Boolean,
db.Boolean, default=False,
default=False, comment='Managed device (SNMP, web interface, etc.)'
comment='Managed device (SNMP, web interface, etc.)' )
)
# For IDF/closet locations
# For IDF/closet locations rackunit = db.Column(
rackunit = db.Column( db.String(20),
db.String(20), nullable=True,
nullable=True, comment='Rack unit position (e.g., U1, U5)'
comment='Rack unit position (e.g., U1, U5)' )
)
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('network_device', uselist=False, lazy='joined')
backref=db.backref('network_device', uselist=False, lazy='joined') )
) networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices') vendor = db.relationship('Vendor', backref='network_devices')
vendor = db.relationship('Vendor', backref='network_devices')
__table_args__ = (
__table_args__ = ( db.Index('idx_netdev_type', 'networkdevicetypeid'),
db.Index('idx_netdev_type', 'networkdevicetypeid'), db.Index('idx_netdev_hostname', 'hostname'),
db.Index('idx_netdev_hostname', 'hostname'), db.Index('idx_netdev_vendor', 'vendorid'),
db.Index('idx_netdev_vendor', 'vendorid'), )
)
def __repr__(self):
def __repr__(self): return f"<NetworkDevice {self.hostname or self.assetid}>"
return f"<NetworkDevice {self.hostname or self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.networkdevicetype:
if self.networkdevicetype: result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor
return result
return result

View File

@@ -1,146 +1,145 @@
"""Subnet and VLAN models for network plugin.""" """Subnet and VLAN models for network plugin."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class VLAN(BaseModel):
class VLAN(BaseModel): """
""" VLAN definition.
VLAN definition.
Represents a virtual LAN for network segmentation.
Represents a virtual LAN for network segmentation. """
""" __tablename__ = 'vlans'
__tablename__ = 'vlans'
vlanid = db.Column(db.Integer, primary_key=True)
vlanid = db.Column(db.Integer, primary_key=True) vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number') name = db.Column(db.String(100), nullable=False, comment='VLAN name')
name = db.Column(db.String(100), nullable=False, comment='VLAN name') description = db.Column(db.Text, nullable=True)
description = db.Column(db.Text, nullable=True)
# Optional classification
# Optional classification vlantype = db.Column(
vlantype = db.Column( db.String(50),
db.String(50), nullable=True,
nullable=True, comment='Type: data, voice, management, guest, etc.'
comment='Type: data, voice, management, guest, etc.' )
)
# Relationships
# Relationships subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
__table_args__ = (
__table_args__ = ( db.Index('idx_vlan_number', 'vlannumber'),
db.Index('idx_vlan_number', 'vlannumber'), )
)
def __repr__(self):
def __repr__(self): return f"<VLAN {self.vlannumber} - {self.name}>"
return f"<VLAN {self.vlannumber} - {self.name}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary."""
"""Convert to dictionary.""" result = super().to_dict()
result = super().to_dict() result['subnetcount'] = self.subnets.count() if self.subnets else 0
result['subnetcount'] = self.subnets.count() if self.subnets else 0 return result
return result
class Subnet(BaseModel):
class Subnet(BaseModel): """
""" Subnet/IP network definition.
Subnet/IP network definition.
Represents an IP subnet with optional VLAN association.
Represents an IP subnet with optional VLAN association. """
""" __tablename__ = 'subnets'
__tablename__ = 'subnets'
subnetid = db.Column(db.Integer, primary_key=True)
subnetid = db.Column(db.Integer, primary_key=True)
# Network definition
# Network definition cidr = db.Column(
cidr = db.Column( db.String(18),
db.String(18), unique=True,
unique=True, nullable=False,
nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'
comment='CIDR notation (e.g., 10.1.1.0/24)' )
) name = db.Column(db.String(100), nullable=False, comment='Subnet name')
name = db.Column(db.String(100), nullable=False, comment='Subnet name') description = db.Column(db.Text, nullable=True)
description = db.Column(db.Text, nullable=True)
# Network details
# Network details gatewayip = db.Column(
gatewayip = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Default gateway IP address'
comment='Default gateway IP address' )
) subnetmask = db.Column(
subnetmask = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'
comment='Subnet mask (e.g., 255.255.255.0)' )
) networkaddress = db.Column(
networkaddress = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Network address (e.g., 10.1.1.0)'
comment='Network address (e.g., 10.1.1.0)' )
) broadcastaddress = db.Column(
broadcastaddress = db.Column( db.String(15),
db.String(15), nullable=True,
nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'
comment='Broadcast address (e.g., 10.1.1.255)' )
)
# VLAN association
# VLAN association vlanid = db.Column(
vlanid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vlans.vlanid'),
db.ForeignKey('vlans.vlanid'), nullable=True
nullable=True )
)
# Classification
# Classification subnettype = db.Column(
subnettype = db.Column( db.String(50),
db.String(50), nullable=True,
nullable=True, comment='Type: production, development, management, dmz, etc.'
comment='Type: production, development, management, dmz, etc.' )
)
# Location association
# Location association locationid = db.Column(
locationid = db.Column( db.Integer,
db.Integer, db.ForeignKey('locations.locationid'),
db.ForeignKey('locations.locationid'), nullable=True
nullable=True )
)
# DHCP settings
# DHCP settings dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet') dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP') dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
# DNS settings
# DNS settings dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server') dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
# Relationships
# Relationships location = db.relationship('Location', backref='subnets')
location = db.relationship('Location', backref='subnets')
__table_args__ = (
__table_args__ = ( db.Index('idx_subnet_cidr', 'cidr'),
db.Index('idx_subnet_cidr', 'cidr'), db.Index('idx_subnet_vlan', 'vlanid'),
db.Index('idx_subnet_vlan', 'vlanid'), db.Index('idx_subnet_location', 'locationid'),
db.Index('idx_subnet_location', 'locationid'), )
)
def __repr__(self):
def __repr__(self): return f"<Subnet {self.cidr} - {self.name}>"
return f"<Subnet {self.cidr} - {self.name}>"
@property
@property def vlan_number(self):
def vlan_number(self): """Get the VLAN number."""
"""Get the VLAN number.""" return self.vlan.vlannumber if self.vlan else None
return self.vlan.vlannumber if self.vlan else None
def to_dict(self):
def to_dict(self): """Convert to dictionary with related data."""
"""Convert to dictionary with related data.""" result = super().to_dict()
result = super().to_dict()
# Add VLAN info
# Add VLAN info if self.vlan:
if self.vlan: result['vlannumber'] = self.vlan.vlannumber
result['vlannumber'] = self.vlan.vlannumber result['vlanname'] = self.vlan.name
result['vlanname'] = self.vlan.name
# Add location info
# Add location info if self.location:
if self.location: result['locationname'] = self.location.locationname
result['locationname'] = self.location.locationname
return result
return result

View File

@@ -1,217 +1,216 @@
"""Network plugin main class.""" """Network plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models import AssetType
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN from .api import network_bp
from .api import network_bp
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
class NetworkPlugin(BasePlugin):
class NetworkPlugin(BasePlugin): """
""" Network plugin - manages network device assets.
Network plugin - manages network device assets.
Network devices include switches, routers, access points, cameras, IDFs, etc.
Network devices include switches, routers, access points, cameras, IDFs, etc. Uses the new Asset architecture with NetworkDevice extension table.
Uses the new Asset architecture with NetworkDevice extension table. """
"""
def __init__(self):
def __init__(self): self._manifest = self._load_manifest()
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
def _load_manifest(self) -> Dict: """Load plugin manifest from JSON file."""
"""Load plugin manifest from JSON file.""" manifestpath = Path(__file__).parent / 'manifest.json'
manifestpath = Path(__file__).parent / 'manifest.json' if manifestpath.exists():
if manifestpath.exists(): with open(manifestpath, 'r') as f:
with open(manifestpath, 'r') as f: return json.load(f)
return json.load(f) return {}
return {}
@property
@property def meta(self) -> PluginMeta:
def meta(self) -> PluginMeta: """Return plugin metadata."""
"""Return plugin metadata.""" return PluginMeta(
return PluginMeta( name=self._manifest.get('name', 'network'),
name=self._manifest.get('name', 'network'), version=self._manifest.get('version', '1.0.0'),
version=self._manifest.get('version', '1.0.0'), description=self._manifest.get(
description=self._manifest.get( 'description',
'description', 'Network device management for switches, APs, and cameras'
'Network device management for switches, APs, and cameras' ),
), author=self._manifest.get('author', 'ShopDB Team'),
author=self._manifest.get('author', 'ShopDB Team'), dependencies=self._manifest.get('dependencies', []),
dependencies=self._manifest.get('dependencies', []), core_version=self._manifest.get('core_version', '>=1.0.0'),
core_version=self._manifest.get('core_version', '>=1.0.0'), api_prefix=self._manifest.get('api_prefix', '/api/network'),
api_prefix=self._manifest.get('api_prefix', '/api/network'), )
)
def get_blueprint(self) -> Optional[Blueprint]:
def get_blueprint(self) -> Optional[Blueprint]: """Return Flask Blueprint with API routes."""
"""Return Flask Blueprint with API routes.""" return network_bp
return network_bp
def get_models(self) -> List[Type]:
def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes."""
"""Return list of SQLAlchemy model classes.""" return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
def init_app(self, app: Flask, db_instance) -> None:
def init_app(self, app: Flask, db_instance) -> None: """Initialize plugin with Flask app."""
"""Initialize plugin with Flask app.""" logger.info(f"Network plugin initialized (v{self.meta.version})")
logger.info(f"Network plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
def on_install(self, app: Flask) -> None: """Called when plugin is installed."""
"""Called when plugin is installed.""" with app.app_context():
with app.app_context(): self._ensure_asset_type()
self._ensure_asset_type() self._ensure_network_device_types()
self._ensure_network_device_types() logger.info("Network plugin installed")
logger.info("Network plugin installed")
def _ensure_asset_type(self) -> None:
def _ensure_asset_type(self) -> None: """Ensure network_device asset type exists."""
"""Ensure network_device asset type exists.""" existing = AssetType.query.filter_by(assettype='network_device').first()
existing = AssetType.query.filter_by(assettype='network_device').first() if not existing:
if not existing: at = AssetType(
at = AssetType( assettype='network_device',
assettype='network_device', pluginname='network',
pluginname='network', tablename='networkdevices',
tablename='networkdevices', description='Network infrastructure devices (switches, APs, cameras, etc.)',
description='Network infrastructure devices (switches, APs, cameras, etc.)', icon='network-wired'
icon='network-wired' )
) db.session.add(at)
db.session.add(at) logger.debug("Created asset type: network_device")
logger.debug("Created asset type: network_device") db.session.commit()
db.session.commit()
def _ensure_network_device_types(self) -> None:
def _ensure_network_device_types(self) -> None: """Ensure basic network device types exist."""
"""Ensure basic network device types exist.""" device_types = [
device_types = [ ('Switch', 'Network switch', 'network-wired'),
('Switch', 'Network switch', 'network-wired'), ('Router', 'Network router', 'router'),
('Router', 'Network router', 'router'), ('Access Point', 'Wireless access point', 'wifi'),
('Access Point', 'Wireless access point', 'wifi'), ('Firewall', 'Network firewall', 'shield'),
('Firewall', 'Network firewall', 'shield'), ('Camera', 'IP camera', 'video'),
('Camera', 'IP camera', 'video'), ('IDF', 'Intermediate Distribution Frame/closet', 'box'),
('IDF', 'Intermediate Distribution Frame/closet', 'box'), ('MDF', 'Main Distribution Frame', 'building'),
('MDF', 'Main Distribution Frame', 'building'), ('Patch Panel', 'Patch panel', 'th'),
('Patch Panel', 'Patch panel', 'th'), ('UPS', 'Uninterruptible power supply', 'battery'),
('UPS', 'Uninterruptible power supply', 'battery'), ('Other', 'Other network device', 'network-wired'),
('Other', 'Other network device', 'network-wired'), ]
]
for name, description, icon in device_types:
for name, description, icon in device_types: existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first() if not existing:
if not existing: ndt = NetworkDeviceType(
ndt = NetworkDeviceType( networkdevicetype=name,
networkdevicetype=name, description=description,
description=description, icon=icon
icon=icon )
) db.session.add(ndt)
db.session.add(ndt) logger.debug(f"Created network device type: {name}")
logger.debug(f"Created network device type: {name}")
db.session.commit()
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
def on_uninstall(self, app: Flask) -> None: """Called when plugin is uninstalled."""
"""Called when plugin is uninstalled.""" logger.info("Network plugin uninstalled")
logger.info("Network plugin uninstalled")
def get_cli_commands(self) -> List:
def get_cli_commands(self) -> List: """Return CLI commands for this plugin."""
"""Return CLI commands for this plugin."""
@click.group('network')
@click.group('network') def networkcli():
def networkcli(): """Network plugin commands."""
"""Network plugin commands.""" pass
pass
@networkcli.command('list-types')
@networkcli.command('list-types') def list_types():
def list_types(): """List all network device types."""
"""List all network device types.""" from flask import current_app
from flask import current_app
with current_app.app_context():
with current_app.app_context(): types = NetworkDeviceType.query.filter_by(isactive=True).all()
types = NetworkDeviceType.query.filter_by(isactive=True).all() if not types:
if not types: click.echo('No network device types found.')
click.echo('No network device types found.') return
return
click.echo('Network Device Types:')
click.echo('Network Device Types:') for t in types:
for t in types: click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
@networkcli.command('stats')
@networkcli.command('stats') def stats():
def stats(): """Show network device statistics."""
"""Show network device statistics.""" from flask import current_app
from flask import current_app from shopdb.api import Asset
from shopdb.core.models import Asset
with current_app.app_context():
with current_app.app_context(): total = db.session.query(NetworkDevice).join(Asset).filter(
total = db.session.query(NetworkDevice).join(Asset).filter( Asset.isactive == True
Asset.isactive == True ).count()
).count()
click.echo(f"Total active network devices: {total}")
click.echo(f"Total active network devices: {total}")
# By type
# By type by_type = db.session.query(
by_type = db.session.query( NetworkDeviceType.networkdevicetype,
NetworkDeviceType.networkdevicetype, db.func.count(NetworkDevice.networkdeviceid)
db.func.count(NetworkDevice.networkdeviceid) ).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid ).join(Asset, Asset.assetid == NetworkDevice.assetid
).join(Asset, Asset.assetid == NetworkDevice.assetid ).filter(Asset.isactive == True
).filter(Asset.isactive == True ).group_by(NetworkDeviceType.networkdevicetype
).group_by(NetworkDeviceType.networkdevicetype ).all()
).all()
if by_type:
if by_type: click.echo("\nBy Type:")
click.echo("\nBy Type:") for t, c in by_type:
for t, c in by_type: click.echo(f" {t}: {c}")
click.echo(f" {t}: {c}")
@networkcli.command('find')
@networkcli.command('find') @click.argument('hostname')
@click.argument('hostname') def find_by_hostname(hostname):
def find_by_hostname(hostname): """Find a network device by hostname."""
"""Find a network device by hostname.""" from flask import current_app
from flask import current_app
with current_app.app_context():
with current_app.app_context(): netdev = NetworkDevice.query.filter(
netdev = NetworkDevice.query.filter( NetworkDevice.hostname.ilike(f'%{hostname}%')
NetworkDevice.hostname.ilike(f'%{hostname}%') ).first()
).first()
if not netdev:
if not netdev: click.echo(f'No network device found matching hostname: {hostname}')
click.echo(f'No network device found matching hostname: {hostname}') return
return
click.echo(f'Found: {netdev.hostname}')
click.echo(f'Found: {netdev.hostname}') click.echo(f' Asset: {netdev.asset.assetnumber}')
click.echo(f' Asset: {netdev.asset.assetnumber}') click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}') click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}')
click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}') click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
return [networkcli]
return [networkcli]
def get_dashboard_widgets(self) -> List[Dict]:
def get_dashboard_widgets(self) -> List[Dict]: """Return dashboard widget definitions."""
"""Return dashboard widget definitions.""" return [
return [ {
{ 'name': 'Network Status',
'name': 'Network Status', 'component': 'NetworkStatusWidget',
'component': 'NetworkStatusWidget', 'endpoint': '/api/network/dashboard/summary',
'endpoint': '/api/network/dashboard/summary', 'size': 'medium',
'size': 'medium', 'position': 7,
'position': 7, },
}, ]
]
def get_navigation_items(self) -> List[Dict]:
def get_navigation_items(self) -> List[Dict]: """Return navigation menu items."""
"""Return navigation menu items.""" return [
return [ {
{ 'name': 'Network',
'name': 'Network', 'icon': 'network-wired',
'icon': 'network-wired', 'route': '/network',
'route': '/network', 'position': 18,
'position': 18, },
}, ]
]

View File

@@ -4,15 +4,7 @@ from datetime import datetime
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, employee_connection
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.employee_db import employee_connection
from ..models import Notification, NotificationType from ..models import Notification, NotificationType

View File

@@ -1,157 +1,157 @@
"""Notifications plugin models - adapted to existing database schema.""" """Notifications plugin models - adapted to existing database schema."""
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db
class NotificationType(db.Model): class NotificationType(db.Model):
""" """
Notification type classification. Notification type classification.
Matches existing notificationtypes table. Matches existing notificationtypes table.
""" """
__tablename__ = 'notificationtypes' __tablename__ = 'notificationtypes'
notificationtypeid = db.Column(db.Integer, primary_key=True) notificationtypeid = db.Column(db.Integer, primary_key=True)
typename = db.Column(db.String(50), nullable=False) typename = db.Column(db.String(50), nullable=False)
typedescription = db.Column(db.Text) typedescription = db.Column(db.Text)
typecolor = db.Column(db.String(20), default='#17a2b8') typecolor = db.Column(db.String(20), default='#17a2b8')
isactive = db.Column(db.Boolean, default=True) isactive = db.Column(db.Boolean, default=True)
def __repr__(self): def __repr__(self):
return f"<NotificationType {self.typename}>" return f"<NotificationType {self.typename}>"
def to_dict(self): def to_dict(self):
return { return {
'notificationtypeid': self.notificationtypeid, 'notificationtypeid': self.notificationtypeid,
'typename': self.typename, 'typename': self.typename,
'typedescription': self.typedescription, 'typedescription': self.typedescription,
'typecolor': self.typecolor, 'typecolor': self.typecolor,
'isactive': self.isactive 'isactive': self.isactive
} }
class Notification(db.Model): class Notification(db.Model):
""" """
Notification/announcement model. Notification/announcement model.
Matches existing notifications table schema. Matches existing notifications table schema.
""" """
__tablename__ = 'notifications' __tablename__ = 'notifications'
notificationid = db.Column(db.Integer, primary_key=True) notificationid = db.Column(db.Integer, primary_key=True)
notificationtypeid = db.Column( notificationtypeid = db.Column(
db.Integer, db.Integer,
db.ForeignKey('notificationtypes.notificationtypeid'), db.ForeignKey('notificationtypes.notificationtypeid'),
nullable=True nullable=True
) )
businessunitid = db.Column(db.Integer, nullable=True) businessunitid = db.Column(db.Integer, nullable=True)
appid = db.Column(db.Integer, nullable=True) appid = db.Column(db.Integer, nullable=True)
notification = db.Column(db.Text, nullable=False, comment='The message content') notification = db.Column(db.Text, nullable=False, comment='The message content')
starttime = db.Column(db.DateTime, nullable=True) starttime = db.Column(db.DateTime, nullable=True)
endtime = db.Column(db.DateTime, nullable=True) endtime = db.Column(db.DateTime, nullable=True)
ticketnumber = db.Column(db.String(50), nullable=True) ticketnumber = db.Column(db.String(50), nullable=True)
link = db.Column(db.String(500), nullable=True) link = db.Column(db.String(500), nullable=True)
isactive = db.Column(db.Boolean, default=True) isactive = db.Column(db.Boolean, default=True)
isshopfloor = db.Column(db.Boolean, default=False) isshopfloor = db.Column(db.Boolean, default=False)
employeesso = db.Column(db.String(100), nullable=True) employeesso = db.Column(db.String(100), nullable=True)
employeename = db.Column(db.String(100), nullable=True) employeename = db.Column(db.String(100), nullable=True)
# Relationships # Relationships
notificationtype = db.relationship('NotificationType', backref='notifications') notificationtype = db.relationship('NotificationType', backref='notifications')
def __repr__(self): def __repr__(self):
return f"<Notification {self.notificationid}>" return f"<Notification {self.notificationid}>"
@property @property
def is_current(self): def is_current(self):
"""Check if notification is currently active based on dates.""" """Check if notification is currently active based on dates."""
now = datetime.utcnow() now = datetime.utcnow()
if not self.isactive: if not self.isactive:
return False return False
if self.starttime and now < self.starttime: if self.starttime and now < self.starttime:
return False return False
if self.endtime and now > self.endtime: if self.endtime and now > self.endtime:
return False return False
return True return True
@property @property
def title(self): def title(self):
"""Get title - first line or first 100 chars of notification.""" """Get title - first line or first 100 chars of notification."""
if not self.notification: if not self.notification:
return '' return ''
lines = self.notification.split('\n') lines = self.notification.split('\n')
return lines[0][:100] if lines else self.notification[:100] return lines[0][:100] if lines else self.notification[:100]
def to_dict(self): def to_dict(self):
"""Convert to dictionary with related data.""" """Convert to dictionary with related data."""
result = { result = {
'notificationid': self.notificationid, 'notificationid': self.notificationid,
'notificationtypeid': self.notificationtypeid, 'notificationtypeid': self.notificationtypeid,
'businessunitid': self.businessunitid, 'businessunitid': self.businessunitid,
'appid': self.appid, 'appid': self.appid,
'notification': self.notification, 'notification': self.notification,
'title': self.title, 'title': self.title,
'message': self.notification, 'message': self.notification,
'starttime': self.starttime.isoformat() if self.starttime else None, 'starttime': self.starttime.isoformat() if self.starttime else None,
'endtime': self.endtime.isoformat() if self.endtime else None, 'endtime': self.endtime.isoformat() if self.endtime else None,
'startdate': self.starttime.isoformat() if self.starttime else None, 'startdate': self.starttime.isoformat() if self.starttime else None,
'enddate': self.endtime.isoformat() if self.endtime else None, 'enddate': self.endtime.isoformat() if self.endtime else None,
'ticketnumber': self.ticketnumber, 'ticketnumber': self.ticketnumber,
'link': self.link, 'link': self.link,
'linkurl': self.link, 'linkurl': self.link,
'isactive': bool(self.isactive) if self.isactive is not None else True, 'isactive': bool(self.isactive) if self.isactive is not None else True,
'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False, 'isshopfloor': bool(self.isshopfloor) if self.isshopfloor is not None else False,
'employeesso': self.employeesso, 'employeesso': self.employeesso,
'employeename': self.employeename, 'employeename': self.employeename,
'iscurrent': self.is_current 'iscurrent': self.is_current
} }
# Add type info # Add type info
if self.notificationtype: if self.notificationtype:
result['typename'] = self.notificationtype.typename result['typename'] = self.notificationtype.typename
result['typecolor'] = self.notificationtype.typecolor result['typecolor'] = self.notificationtype.typecolor
return result return result
def to_calendar_event(self): def to_calendar_event(self):
"""Convert to FullCalendar event format.""" """Convert to FullCalendar event format."""
# Map Bootstrap color names to hex colors # Map Bootstrap color names to hex colors
color_map = { color_map = {
'success': '#04b962', 'success': '#04b962',
'warning': '#ff8800', 'warning': '#ff8800',
'danger': '#f5365c', 'danger': '#f5365c',
'info': '#14abef', 'info': '#14abef',
'primary': '#7934f3', 'primary': '#7934f3',
'secondary': '#94614f', 'secondary': '#94614f',
'recognition': '#14abef', # Blue for recognition 'recognition': '#14abef', # Blue for recognition
} }
raw_color = self.notificationtype.typecolor if self.notificationtype else 'info' raw_color = self.notificationtype.typecolor if self.notificationtype else 'info'
# Use mapped color if it's a Bootstrap name, otherwise use as-is (hex) # Use mapped color if it's a Bootstrap name, otherwise use as-is (hex)
color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef') color = color_map.get(raw_color, raw_color if raw_color.startswith('#') else '#14abef')
# For recognition notifications, include employee name (or SSO as fallback) in title # For recognition notifications, include employee name (or SSO as fallback) in title
title = self.title title = self.title
if raw_color == 'recognition': if raw_color == 'recognition':
employee_display = self.employeename or self.employeesso employee_display = self.employeename or self.employeesso
if employee_display: if employee_display:
title = f"{employee_display}: {title}" title = f"{employee_display}: {title}"
return { return {
'id': self.notificationid, 'id': self.notificationid,
'title': title, 'title': title,
'start': self.starttime.isoformat() if self.starttime else None, 'start': self.starttime.isoformat() if self.starttime else None,
'end': self.endtime.isoformat() if self.endtime else None, 'end': self.endtime.isoformat() if self.endtime else None,
'allDay': True, 'allDay': True,
'backgroundColor': color, 'backgroundColor': color,
'borderColor': color, 'borderColor': color,
'extendedProps': { 'extendedProps': {
'notificationid': self.notificationid, 'notificationid': self.notificationid,
'message': self.notification, 'message': self.notification,
'typename': self.notificationtype.typename if self.notificationtype else None, 'typename': self.notificationtype.typename if self.notificationtype else None,
'typecolor': raw_color, 'typecolor': raw_color,
'linkurl': self.link, 'linkurl': self.link,
'ticketnumber': self.ticketnumber, 'ticketnumber': self.ticketnumber,
'employeename': self.employeename, 'employeename': self.employeename,
'employeesso': self.employeesso, 'employeesso': self.employeesso,
} }
} }

View File

@@ -1,204 +1,204 @@
"""Notifications plugin main class.""" """Notifications plugin main class."""
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Dict, Optional, Type from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db
from .models import Notification, NotificationType from .models import Notification, NotificationType
from .api import notifications_bp from .api import notifications_bp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class NotificationsPlugin(BasePlugin): class NotificationsPlugin(BasePlugin):
""" """
Notifications plugin - manages announcements and notifications. Notifications plugin - manages announcements and notifications.
Provides functionality for: Provides functionality for:
- Creating and managing notifications/announcements - Creating and managing notifications/announcements
- Displaying banner notifications - Displaying banner notifications
- Calendar view of notifications - Calendar view of notifications
""" """
def __init__(self): def __init__(self):
self._manifest = self._load_manifest() self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict: def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file.""" """Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json' manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists(): if manifest_path.exists():
with open(manifest_path, 'r') as f: with open(manifest_path, 'r') as f:
return json.load(f) return json.load(f)
return {} return {}
@property @property
def meta(self) -> PluginMeta: def meta(self) -> PluginMeta:
"""Return plugin metadata.""" """Return plugin metadata."""
return PluginMeta( return PluginMeta(
name=self._manifest.get('name', 'notifications'), name=self._manifest.get('name', 'notifications'),
version=self._manifest.get('version', '1.0.0'), version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get( description=self._manifest.get(
'description', 'description',
'Notifications and announcements management' 'Notifications and announcements management'
), ),
author=self._manifest.get('author', 'ShopDB Team'), author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []), dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'), core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/notifications'), api_prefix=self._manifest.get('api_prefix', '/api/notifications'),
) )
def get_blueprint(self) -> Optional[Blueprint]: def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes.""" """Return Flask Blueprint with API routes."""
return notifications_bp return notifications_bp
def get_models(self) -> List[Type]: def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes.""" """Return list of SQLAlchemy model classes."""
return [Notification, NotificationType] return [Notification, NotificationType]
def init_app(self, app: Flask, db_instance) -> None: def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app.""" """Initialize plugin with Flask app."""
logger.info(f"Notifications plugin initialized (v{self.meta.version})") logger.info(f"Notifications plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None: def on_install(self, app: Flask) -> None:
"""Called when plugin is installed.""" """Called when plugin is installed."""
with app.app_context(): with app.app_context():
self._ensure_notification_types() self._ensure_notification_types()
logger.info("Notifications plugin installed") logger.info("Notifications plugin installed")
def _ensure_notification_types(self) -> None: def _ensure_notification_types(self) -> None:
"""Ensure default notification types exist.""" """Ensure default notification types exist."""
default_types = [ default_types = [
('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'), ('Awareness', 'General awareness notification', '#17a2b8', 'info-circle'),
('Change', 'Planned change notification', '#ffc107', 'exchange-alt'), ('Change', 'Planned change notification', '#ffc107', 'exchange-alt'),
('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'), ('Incident', 'Incident or outage notification', '#dc3545', 'exclamation-triangle'),
('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'), ('Maintenance', 'Scheduled maintenance notification', '#6c757d', 'wrench'),
('General', 'General announcement', '#28a745', 'bullhorn'), ('General', 'General announcement', '#28a745', 'bullhorn'),
] ]
for typename, description, color, icon in default_types: for typename, description, color, icon in default_types:
existing = NotificationType.query.filter_by(typename=typename).first() existing = NotificationType.query.filter_by(typename=typename).first()
if not existing: if not existing:
t = NotificationType( t = NotificationType(
typename=typename, typename=typename,
description=description, description=description,
color=color, color=color,
icon=icon icon=icon
) )
db.session.add(t) db.session.add(t)
logger.debug(f"Created notification type: {typename}") logger.debug(f"Created notification type: {typename}")
db.session.commit() db.session.commit()
def on_uninstall(self, app: Flask) -> None: def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled.""" """Called when plugin is uninstalled."""
logger.info("Notifications plugin uninstalled") logger.info("Notifications plugin uninstalled")
def get_cli_commands(self) -> List: def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin.""" """Return CLI commands for this plugin."""
@click.group('notifications') @click.group('notifications')
def notifications_cli(): def notifications_cli():
"""Notifications plugin commands.""" """Notifications plugin commands."""
pass pass
@notifications_cli.command('list-types') @notifications_cli.command('list-types')
def list_types(): def list_types():
"""List all notification types.""" """List all notification types."""
from flask import current_app from flask import current_app
with current_app.app_context(): with current_app.app_context():
types = NotificationType.query.filter_by(isactive=True).all() types = NotificationType.query.filter_by(isactive=True).all()
if not types: if not types:
click.echo('No notification types found.') click.echo('No notification types found.')
return return
click.echo('Notification Types:') click.echo('Notification Types:')
for t in types: for t in types:
click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})") click.echo(f" [{t.notificationtypeid}] {t.typename} ({t.color})")
@notifications_cli.command('stats') @notifications_cli.command('stats')
def stats(): def stats():
"""Show notification statistics.""" """Show notification statistics."""
from flask import current_app from flask import current_app
from datetime import datetime from datetime import datetime
with current_app.app_context(): with current_app.app_context():
now = datetime.utcnow() now = datetime.utcnow()
total = Notification.query.filter( total = Notification.query.filter(
Notification.isactive == True Notification.isactive == True
).count() ).count()
active = Notification.query.filter( active = Notification.query.filter(
Notification.isactive == True, Notification.isactive == True,
Notification.startdate <= now, Notification.startdate <= now,
db.or_( db.or_(
Notification.enddate.is_(None), Notification.enddate.is_(None),
Notification.enddate >= now Notification.enddate >= now
) )
).count() ).count()
click.echo(f"Total notifications: {total}") click.echo(f"Total notifications: {total}")
click.echo(f"Currently active: {active}") click.echo(f"Currently active: {active}")
@notifications_cli.command('create') @notifications_cli.command('create')
@click.option('--title', required=True, help='Notification title') @click.option('--title', required=True, help='Notification title')
@click.option('--message', required=True, help='Notification message') @click.option('--message', required=True, help='Notification message')
@click.option('--type', 'type_name', default='General', help='Notification type') @click.option('--type', 'type_name', default='General', help='Notification type')
def create_notification(title, message, type_name): def create_notification(title, message, type_name):
"""Create a new notification.""" """Create a new notification."""
from flask import current_app from flask import current_app
with current_app.app_context(): with current_app.app_context():
ntype = NotificationType.query.filter_by(typename=type_name).first() ntype = NotificationType.query.filter_by(typename=type_name).first()
if not ntype: if not ntype:
click.echo(f"Error: Notification type '{type_name}' not found.") click.echo(f"Error: Notification type '{type_name}' not found.")
return return
n = Notification( n = Notification(
title=title, title=title,
message=message, message=message,
notificationtypeid=ntype.notificationtypeid notificationtypeid=ntype.notificationtypeid
) )
db.session.add(n) db.session.add(n)
db.session.commit() db.session.commit()
click.echo(f"Created notification #{n.notificationid}: {title}") click.echo(f"Created notification #{n.notificationid}: {title}")
return [notifications_cli] return [notifications_cli]
def get_dashboard_widgets(self) -> List[Dict]: def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions.""" """Return dashboard widget definitions."""
return [ return [
{ {
'name': 'Active Notifications', 'name': 'Active Notifications',
'component': 'NotificationsWidget', 'component': 'NotificationsWidget',
'endpoint': '/api/notifications/dashboard/summary', 'endpoint': '/api/notifications/dashboard/summary',
'size': 'small', 'size': 'small',
'position': 1, 'position': 1,
}, },
] ]
def get_navigation_items(self) -> List[Dict]: def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items.""" """Return navigation menu items."""
return [ return [
{ {
'name': 'Notifications', 'name': 'Notifications',
'icon': 'bell', 'icon': 'bell',
'route': '/notifications', 'route': '/notifications',
'position': 5, 'position': 5,
}, },
{ {
'name': 'Calendar', 'name': 'Calendar',
'icon': 'calendar', 'icon': 'calendar',
'route': '/calendar', 'route': '/calendar',
'position': 6, 'position': 6,
}, },
] ]

View File

@@ -5,15 +5,7 @@ import logging
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db, cache from shopdb.api import db, cache, Asset, AssetType, Vendor, Model, Communication, CommunicationType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.core.models import Asset, AssetType, Vendor, Model, Communication, CommunicationType
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import Printer, PrinterType, ModelSupply from ..models import Printer, PrinterType, ModelSupply
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
@@ -565,7 +557,7 @@ def _get_low_supplies_data():
# location name for the report row # location name for the report row
location_name = None location_name = None
if asset.locationid: if asset.locationid:
from shopdb.core.models import Location from shopdb.api import Location
loc = Location.query.get(asset.locationid) loc = Location.query.get(asset.locationid)
if loc: if loc:
location_name = loc.locationname location_name = loc.locationname

View File

@@ -6,8 +6,7 @@ drum/waste/maintenance item). Lets new models and their toners be added
through the API/UI without a code change. through the API/UI without a code change.
""" """
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
# allowed values, surfaced to the UI via the /supplies/meta endpoint # allowed values, surfaced to the UI via the /supplies/meta endpoint

View File

@@ -1,122 +1,121 @@
"""Printer plugin models - new Asset-based architecture.""" """Printer plugin models - new Asset-based architecture."""
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class PrinterType(BaseModel):
class PrinterType(BaseModel): """
""" Printer type classification.
Printer type classification.
Examples: Laser, Inkjet, Label, MFP, Plotter, etc.
Examples: Laser, Inkjet, Label, MFP, Plotter, etc. """
""" __tablename__ = 'printertypes'
__tablename__ = 'printertypes'
printertypeid = db.Column(db.Integer, primary_key=True)
printertypeid = db.Column(db.Integer, primary_key=True) printertype = db.Column(db.String(100), unique=True, nullable=False)
printertype = db.Column(db.String(100), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), comment='Icon name for UI')
icon = db.Column(db.String(50), comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<PrinterType {self.printertype}>"
return f"<PrinterType {self.printertype}>"
class Printer(BaseModel):
class Printer(BaseModel): """
""" Printer-specific extension data (new Asset architecture).
Printer-specific extension data (new Asset architecture).
Links to core Asset table via assetid.
Links to core Asset table via assetid. Stores printer-specific fields like type, Windows name, share name, etc.
Stores printer-specific fields like type, Windows name, share name, etc. """
""" __tablename__ = 'printers'
__tablename__ = 'printers'
printerid = db.Column(db.Integer, primary_key=True)
printerid = db.Column(db.Integer, primary_key=True)
# Link to core asset
# Link to core asset assetid = db.Column(
assetid = db.Column( db.Integer,
db.Integer, db.ForeignKey('assets.assetid', ondelete='CASCADE'),
db.ForeignKey('assets.assetid', ondelete='CASCADE'), unique=True,
unique=True, nullable=False,
nullable=False, index=True
index=True )
)
# Printer classification
# Printer classification printertypeid = db.Column(
printertypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('printertypes.printertypeid'),
db.ForeignKey('printertypes.printertypeid'), nullable=True
nullable=True )
)
# Vendor
# Vendor vendorid = db.Column(
vendorid = db.Column( db.Integer,
db.Integer, db.ForeignKey('vendors.vendorid'),
db.ForeignKey('vendors.vendorid'), nullable=True
nullable=True )
) modelnumberid = db.Column(
modelnumberid = db.Column( db.Integer,
db.Integer, db.ForeignKey('models.modelnumberid'),
db.ForeignKey('models.modelnumberid'), nullable=True
nullable=True )
)
# Network identity
# Network identity hostname = db.Column(
hostname = db.Column( db.String(100),
db.String(100), index=True,
index=True, comment='Network hostname'
comment='Network hostname' )
)
# Windows/Network naming
# Windows/Network naming windowsname = db.Column(
windowsname = db.Column( db.String(255),
db.String(255), comment='Windows printer name (e.g., \\\\server\\printer)'
comment='Windows printer name (e.g., \\\\server\\printer)' )
) sharename = db.Column(
sharename = db.Column( db.String(100),
db.String(100), comment='CSF/share name'
comment='CSF/share name' )
)
# Installation
# Installation iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer')
iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') installpath = db.Column(db.String(255), comment='Driver install path')
installpath = db.Column(db.String(255), comment='Driver install path')
# Printer PIN (for secure print)
# Printer PIN (for secure print) pin = db.Column(db.String(20))
pin = db.Column(db.String(20))
# Features
# Features iscolor = db.Column(db.Boolean, default=False, comment='Color capable')
iscolor = db.Column(db.Boolean, default=False, comment='Color capable') isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable')
isduplex = db.Column(db.Boolean, default=False, comment='Duplex capable') isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
isnetwork = db.Column(db.Boolean, default=True, comment='Network connected')
# Relationships
# Relationships asset = db.relationship(
asset = db.relationship( 'Asset',
'Asset', backref=db.backref('printer', uselist=False, lazy='joined')
backref=db.backref('printer', uselist=False, lazy='joined') )
) printertype = db.relationship('PrinterType', backref='printers')
printertype = db.relationship('PrinterType', backref='printers') vendor = db.relationship('Vendor', backref='printer_items')
vendor = db.relationship('Vendor', backref='printer_items') model = db.relationship('Model', backref='printer_items')
model = db.relationship('Model', backref='printer_items')
__table_args__ = (
__table_args__ = ( db.Index('idx_printer_type', 'printertypeid'),
db.Index('idx_printer_type', 'printertypeid'), db.Index('idx_printer_hostname', 'hostname'),
db.Index('idx_printer_hostname', 'hostname'), db.Index('idx_printer_windowsname', 'windowsname'),
db.Index('idx_printer_windowsname', 'windowsname'), )
)
def __repr__(self):
def __repr__(self): return f"<Printer {self.hostname or self.assetid}>"
return f"<Printer {self.hostname or self.assetid}>"
def to_dict(self):
def to_dict(self): """Convert to dictionary with related names."""
"""Convert to dictionary with related names.""" result = super().to_dict()
result = super().to_dict()
# Add related object names
# Add related object names if self.printertype:
if self.printertype: result['printertypename'] = self.printertype.printertype
result['printertypename'] = self.printertype.printertype if self.vendor:
if self.vendor: result['vendorname'] = self.vendor.vendor
result['vendorname'] = self.vendor.vendor if self.model:
if self.model: result['modelname'] = self.model.modelnumber
result['modelname'] = self.model.modelnumber
return result
return result

View File

@@ -9,9 +9,7 @@ from flask import Flask, Blueprint
import click import click
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db, AssetType
from shopdb.core.models.machine import MachineType
from shopdb.core.models import AssetType
from .models import Printer, PrinterType, ModelSupply from .models import Printer, PrinterType, ModelSupply
from .api import printers_asset_bp from .api import printers_asset_bp
@@ -104,7 +102,6 @@ class PrintersPlugin(BasePlugin):
with app.app_context(): with app.app_context():
self._ensure_asset_type() self._ensure_asset_type()
self._ensure_printer_types() self._ensure_printer_types()
self._ensure_legacy_machine_types()
logger.info("Printers plugin installed") logger.info("Printers plugin installed")
def _ensure_asset_type(self) -> None: def _ensure_asset_type(self) -> None:
@@ -149,30 +146,6 @@ class PrintersPlugin(BasePlugin):
db.session.commit() db.session.commit()
def _ensure_legacy_machine_types(self) -> None:
"""Ensure basic printer machine types exist (legacy architecture)."""
printertypes = [
('Laser Printer', 'Printer', 'Standard laser printer'),
('Inkjet Printer', 'Printer', 'Inkjet printer'),
('Label Printer', 'Printer', 'Label/barcode printer'),
('Multifunction Printer', 'Printer', 'MFP with scan/copy/fax'),
('Plotter', 'Printer', 'Large format plotter'),
]
for name, category, description in printertypes:
existing = MachineType.query.filter_by(machinetype=name).first()
if not existing:
mt = MachineType(
machinetype=name,
category=category,
description=description,
icon='printer'
)
db.session.add(mt)
logger.debug(f"Created machine type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None: def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled.""" """Called when plugin is uninstalled."""
logger.info("Printers plugin uninstalled") logger.info("Printers plugin uninstalled")

View File

@@ -19,9 +19,7 @@ Key facts encoded here:
import logging import logging
from shopdb.extensions import db from shopdb.api import db, Vendor, Model
from shopdb.core.models import Vendor, Model
from shopdb.core.models.machine import MachineType
from ..models import ModelSupply from ..models import ModelSupply
@@ -311,9 +309,6 @@ def seedsupplies():
model that matches a family's keys; if a family matches no existing model, model that matches a family's keys; if a family matches no existing model,
creates a canonical model row so its toners are still available. creates a canonical model row so its toners are still available.
""" """
printertype = MachineType.query.filter_by(category='Printer').first()
printertypeid = printertype.machinetypeid if printertype else None
models_touched = 0 models_touched = 0
supplies_added = 0 supplies_added = 0
@@ -322,10 +317,11 @@ def seedsupplies():
targets = _matching_models(family['matchkeys'], vendor.vendorid) targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets: if not targets:
# machinetypeid is a legacy Model column (nullable); printers are
# asset-based now and carry their type via PrinterType, not here.
model = Model( model = Model(
modelnumber=family['canonical'], modelnumber=family['canonical'],
vendorid=vendor.vendorid, vendorid=vendor.vendorid,
machinetypeid=printertypeid,
) )
db.session.add(model) db.session.add(model)
db.session.flush() db.session.flush()

View File

@@ -24,7 +24,7 @@ from typing import Dict, List, Optional
import requests import requests
from flask import current_app from flask import current_app
from shopdb.extensions import cache from shopdb.api import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ class ZabbixService:
@property @property
def isenabled(self) -> bool: def isenabled(self) -> bool:
"""Whether the integration is switched on.""" """Whether the integration is switched on."""
from shopdb.core.models import Setting from shopdb.api import Setting
db_enabled = Setting.get('zabbix_enabled') db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None: if db_enabled is not None:
return bool(db_enabled) return bool(db_enabled)
@@ -66,7 +66,7 @@ class ZabbixService:
"""Enabled, and a URL plus token are present.""" """Enabled, and a URL plus token are present."""
if not self.isenabled: if not self.isenabled:
return False return False
from shopdb.core.models import Setting from shopdb.api import Setting
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL') self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN') self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._token) return bool(self._url and self._token)

View File

@@ -4,15 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.core.models import AuditLog
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import USBDevice, USBDeviceType, USBCheckout from ..models import USBDevice, USBDeviceType, USBCheckout

View File

@@ -1,167 +1,166 @@
"""USB device plugin models.""" """USB device plugin models."""
from datetime import datetime from datetime import datetime
from shopdb.extensions import db from shopdb.api import db, BaseModel, AuditMixin
from shopdb.core.models.base import BaseModel, AuditMixin
class USBDeviceType(BaseModel):
class USBDeviceType(BaseModel): """
""" USB device type classification.
USB device type classification.
Examples: Flash Drive, External HDD, External SSD, Card Reader
Examples: Flash Drive, External HDD, External SSD, Card Reader """
""" __tablename__ = 'usbdevicetypes'
__tablename__ = 'usbdevicetypes'
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
usbdevicetypeid = db.Column(db.Integer, primary_key=True) typename = db.Column(db.String(50), unique=True, nullable=False)
typename = db.Column(db.String(50), unique=True, nullable=False) description = db.Column(db.Text)
description = db.Column(db.Text) icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
def __repr__(self):
def __repr__(self): return f"<USBDeviceType {self.typename}>"
return f"<USBDeviceType {self.typename}>"
class USBDevice(BaseModel, AuditMixin):
class USBDevice(BaseModel, AuditMixin): """
""" USB device model.
USB device model.
Tracks USB storage devices that can be checked out by users.
Tracks USB storage devices that can be checked out by users. """
""" __tablename__ = 'usbdevices'
__tablename__ = 'usbdevices'
usbdeviceid = db.Column(db.Integer, primary_key=True)
usbdeviceid = db.Column(db.Integer, primary_key=True)
# Identification
# Identification serialnumber = db.Column(db.String(100), unique=True, nullable=False)
serialnumber = db.Column(db.String(100), unique=True, nullable=False) label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
label = db.Column(db.String(100), nullable=True, comment='Human-readable label') assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
# Classification
# Classification usbdevicetypeid = db.Column(
usbdevicetypeid = db.Column( db.Integer,
db.Integer, db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
db.ForeignKey('usbdevicetypes.usbdevicetypeid'), nullable=True
nullable=True )
)
# Specifications
# Specifications capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB') vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)') productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)')
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)') manufacturer = db.Column(db.String(100), nullable=True)
manufacturer = db.Column(db.String(100), nullable=True) productname = db.Column(db.String(100), nullable=True)
productname = db.Column(db.String(100), nullable=True)
# Current status
# Current status ischeckedout = db.Column(db.Boolean, default=False)
ischeckedout = db.Column(db.Boolean, default=False) currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user') currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user')
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user') currentcheckoutdate = db.Column(db.DateTime, nullable=True)
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
# Location
# Location storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
# Security
# Security pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
pin = db.Column(db.String(50), nullable=True, comment='PIN for encrypted devices')
# Notes
# Notes notes = db.Column(db.Text, nullable=True)
notes = db.Column(db.Text, nullable=True)
# Relationships
# Relationships devicetype = db.relationship('USBDeviceType', backref='devices')
devicetype = db.relationship('USBDeviceType', backref='devices')
# Indexes
# Indexes __table_args__ = (
__table_args__ = ( db.Index('idx_usb_serial', 'serialnumber'),
db.Index('idx_usb_serial', 'serialnumber'), db.Index('idx_usb_checkedout', 'ischeckedout'),
db.Index('idx_usb_checkedout', 'ischeckedout'), db.Index('idx_usb_type', 'usbdevicetypeid'),
db.Index('idx_usb_type', 'usbdevicetypeid'), db.Index('idx_usb_currentuser', 'currentuserid'),
db.Index('idx_usb_currentuser', 'currentuserid'), )
)
def __repr__(self):
def __repr__(self): return f"<USBDevice {self.label or self.serialnumber}>"
return f"<USBDevice {self.label or self.serialnumber}>"
@property
@property def display_name(self):
def display_name(self): """Get display name (label if set, otherwise serial number)."""
"""Get display name (label if set, otherwise serial number).""" return self.label or self.serialnumber
return self.label or self.serialnumber
def to_dict(self):
def to_dict(self): """Convert to dictionary with related data."""
"""Convert to dictionary with related data.""" result = super().to_dict()
result = super().to_dict()
# Add type info
# Add type info if self.devicetype:
if self.devicetype: result['typename'] = self.devicetype.typename
result['typename'] = self.devicetype.typename result['typeicon'] = self.devicetype.icon
result['typeicon'] = self.devicetype.icon
# Add computed property
# Add computed property result['displayname'] = self.display_name
result['displayname'] = self.display_name
return result
return result
class USBCheckout(BaseModel):
class USBCheckout(BaseModel): """
""" USB device checkout history.
USB device checkout history.
Tracks when devices are checked out and returned.
Tracks when devices are checked out and returned. Maps to existing usbcheckouts table from classic ShopDB.
Maps to existing usbcheckouts table from classic ShopDB. """
""" __tablename__ = 'usbcheckouts'
__tablename__ = 'usbcheckouts'
checkoutid = db.Column(db.Integer, primary_key=True)
checkoutid = db.Column(db.Integer, primary_key=True)
# Device reference (new column linking to usbdevices table)
# Device reference (new column linking to usbdevices table) usbdeviceid = db.Column(
usbdeviceid = db.Column( db.Integer,
db.Integer, db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'), nullable=True
nullable=True )
)
# Legacy reference to machines table (kept for backward compatibility)
# Legacy reference to machines table (kept for backward compatibility) machineid = db.Column(db.Integer, nullable=False)
machineid = db.Column(db.Integer, nullable=False)
# User info
# User info sso = db.Column(db.String(20), nullable=False, comment='SSO of user')
sso = db.Column(db.String(20), nullable=False, comment='SSO of user') checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
checkoutname = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
# Checkout details checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkouttime = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) checkintime = db.Column(db.DateTime, nullable=True)
checkintime = db.Column(db.DateTime, nullable=True)
# Metadata
# Metadata checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout') checkinnotes = db.Column(db.Text, nullable=True)
checkinnotes = db.Column(db.Text, nullable=True) waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
# Relationships
# Relationships device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
def __repr__(self):
def __repr__(self): return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
return f"<USBCheckout device={self.usbdeviceid} user={self.sso}>"
@property
@property def is_active(self):
def is_active(self): """Check if this checkout is currently active (not returned)."""
"""Check if this checkout is currently active (not returned).""" return self.checkintime is None
return self.checkintime is None
@property
@property def duration_days(self):
def duration_days(self): """Get duration of checkout in days."""
"""Get duration of checkout in days.""" end = self.checkintime or datetime.utcnow()
end = self.checkintime or datetime.utcnow() delta = end - self.checkouttime
delta = end - self.checkouttime return delta.days
return delta.days
def to_dict(self):
def to_dict(self): """Convert to dictionary with computed fields."""
"""Convert to dictionary with computed fields.""" result = super().to_dict()
result = super().to_dict()
result['isactivecheckout'] = self.is_active
result['isactivecheckout'] = self.is_active result['durationdays'] = self.duration_days
result['durationdays'] = self.duration_days
# Add device info if loaded
# Add device info if loaded if self.device:
if self.device: result['devicelabel'] = self.device.label
result['devicelabel'] = self.device.label result['deviceserialnumber'] = self.device.serialnumber
result['deviceserialnumber'] = self.device.serialnumber
return result
return result

View File

@@ -8,7 +8,7 @@ from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db from shopdb.api import db
from .models import USBDevice, USBDeviceType, USBCheckout from .models import USBDevice, USBDeviceType, USBCheckout
from .api import usb_bp from .api import usb_bp

View File

@@ -12,7 +12,10 @@ from .plugins import plugin_manager
# ADR-002 for the bump rules. Plugins declare a compatible range in # ADR-002 for the bump rules. Plugins declare a compatible range in
# their manifest.json `core_version` field. Pre-1.0 (0.x) means the # their manifest.json `core_version` field. Pre-1.0 (0.x) means the
# contract is still settling; sister sites should pin tight ranges. # contract is still settling; sister sites should pin tight ranges.
__contract_version__ = '0.2.0' # 0.3.0: shopdb.api expanded to the full plugin import surface (db, cache,
# model bases, core models, response + pagination helpers, employee_connection)
# so plugins no longer import internal core paths. Additive, hence minor bump.
__contract_version__ = '0.3.0'
def create_app(config_name: str = None) -> Flask: def create_app(config_name: str = None) -> Flask:

View File

@@ -14,7 +14,47 @@ Setting helpers are exposed via BasePlugin instance methods
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from shopdb.core.models import AuditLog # -- Plugin contract surface (ADR-001, versioned per ADR-002) ----------------
# Everything a plugin is allowed to import from the core lives here. Plugins
# import these from `shopdb.api`, never from internal paths like
# `shopdb.core.models.*` or `shopdb.extensions`. The contract test
# (tests/test_plugin_contract.py) enforces this. Adding a name here is an
# additive (minor) contract change; removing one is breaking (major).
# Infrastructure
from shopdb.extensions import db, cache
# Model base classes for declaring plugin tables
from shopdb.core.models.base import BaseModel, AuditMixin
# Core domain models plugins legitimately reference (the asset contract)
from shopdb.core.models import (
Asset,
AssetType,
AssetStatus,
Vendor,
Model,
Communication,
CommunicationType,
Location,
Setting,
AuditLog,
Application,
AppVersion,
OperatingSystem,
)
# Response + pagination helpers for plugin API blueprints
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes,
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
# Legacy employee directory lookup (read-only) used by notifications
from shopdb.utils.employee_db import employee_connection
def audit_log( def audit_log(
@@ -155,4 +195,37 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
return None return None
__all__ = ['audit_log', 'resolve_asset_position'] __all__ = [
# Helpers
'audit_log',
'resolve_asset_position',
# Infrastructure
'db',
'cache',
# Model bases
'BaseModel',
'AuditMixin',
# Core models
'Asset',
'AssetType',
'AssetStatus',
'Vendor',
'Model',
'Communication',
'CommunicationType',
'Location',
'Setting',
'AuditLog',
'Application',
'AppVersion',
'OperatingSystem',
# Response + pagination helpers
'success_response',
'error_response',
'paginated_response',
'ErrorCodes',
'get_pagination_params',
'paginate_query',
# Legacy employee directory
'employee_connection',
]

View File

@@ -3,13 +3,14 @@
from flask import Blueprint, request from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.utils.responses import ( from shopdb.api import (
success_response, success_response,
error_response, error_response,
paginated_response, paginated_response,
ErrorCodes, ErrorCodes,
get_pagination_params,
paginate_query,
) )
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import $Name from ..models import $Name

View File

@@ -6,8 +6,7 @@ this table holds the $name-specific fields. Replace the example fields
below with your domain model. below with your domain model.
""" """
from shopdb.extensions import db from shopdb.api import db, BaseModel
from shopdb.core.models.base import BaseModel
class $Name(BaseModel): class $Name(BaseModel):

View File

@@ -11,8 +11,7 @@ from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.core.models import AssetType from shopdb.api import db, AssetType
from shopdb.extensions import db
from .models import $Name from .models import $Name
from .api import ${name}_bp from .api import ${name}_bp

View File

@@ -6,6 +6,7 @@ plugin's plugin.py / manifest.json.
""" """
import json import json
import re
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -143,3 +144,38 @@ def test_baseplugin_does_not_have_event_handlers_hook():
def test_baseplugin_has_collector_schema_hook(): def test_baseplugin_has_collector_schema_hook():
"""The collector schema hook is on the contract surface.""" """The collector schema hook is on the contract surface."""
assert hasattr(BasePlugin, 'get_collector_schema') assert hasattr(BasePlugin, 'get_collector_schema')
# Imports a plugin may make from the core. shopdb.api is the contract surface;
# shopdb.plugins.base is the plugin ABC. Anything else (shopdb.core.*,
# shopdb.extensions, shopdb.utils.*) is a contract violation per ADR-001.
ALLOWED_CORE_IMPORTS = ('shopdb.api', 'shopdb.plugins.base')
_PLUGIN_IMPORT_RE = re.compile(
r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE
)
def _plugin_source_files():
root = Path(__file__).resolve().parent.parent / 'plugins'
return [p for p in root.rglob('*.py') if '__pycache__' not in p.parts]
def test_plugins_only_import_contract_surface():
"""Plugins must import core code only via shopdb.api / shopdb.plugins.base."""
violations = []
for path in _plugin_source_files():
text = path.read_text()
for match in _PLUGIN_IMPORT_RE.finditer(text):
module = match.group(1) or match.group(2)
if not module.startswith('shopdb'):
continue
if any(module == a or module.startswith(a + '.')
for a in ALLOWED_CORE_IMPORTS):
continue
line = text[:match.start()].count('\n') + 1
violations.append(f'{path.name}:{line} imports {module}')
assert not violations, (
'Plugins must import core only via shopdb.api or shopdb.plugins.base. '
'Violations:\n' + '\n'.join(violations)
)