diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d570ce9..78ea2d5 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -58,38 +58,6 @@ export const authApi = { } } -// Machines API (legacy - use equipmentApi or computersApi instead) -export const machinesApi = { - list(params = {}) { - return api.get('/machines', { params }) - }, - get(id) { - return api.get(`/machines/${id}`) - }, - create(data) { - return api.post('/machines', data) - }, - update(id, data) { - return api.put(`/machines/${id}`, data) - }, - delete(id) { - return api.delete(`/machines/${id}`) - }, - updateCommunication(id, data) { - return api.put(`/machines/${id}/communication`, data) - }, - // Relationships - getRelationships(id) { - return api.get(`/machines/${id}/relationships`) - }, - createRelationship(id, data) { - return api.post(`/machines/${id}/relationships`, data) - }, - deleteRelationship(relationshipId) { - return api.delete(`/machines/relationships/${relationshipId}`) - } -} - // Equipment API (plugin) export const equipmentApi = { list(params = {}) { @@ -199,25 +167,6 @@ export const machinetypesApi = { } } -// Statuses API -export const statusesApi = { - list(params = {}) { - return api.get('/statuses', { params }) - }, - get(id) { - return api.get(`/statuses/${id}`) - }, - create(data) { - return api.post('/statuses', data) - }, - update(id, data) { - return api.put(`/statuses/${id}`, data) - }, - delete(id) { - return api.delete(`/statuses/${id}`) - } -} - // Vendors API export const vendorsApi = { list(params = {}) { @@ -396,25 +345,6 @@ export const modelsApi = { } } -// PC Types API -export const pctypesApi = { - list(params = {}) { - return api.get('/pctypes', { params }) - }, - get(id) { - return api.get(`/pctypes/${id}`) - }, - create(data) { - return api.post('/pctypes', data) - }, - update(id, data) { - return api.put(`/pctypes/${id}`, data) - }, - delete(id) { - return api.delete(`/pctypes/${id}`) - } -} - // Operating Systems API export const operatingsystemsApi = { list(params = {}) { diff --git a/frontend/src/views/pcs/PCForm.vue b/frontend/src/views/pcs/PCForm.vue index d8292d9..93debf1 100644 --- a/frontend/src/views/pcs/PCForm.vue +++ b/frontend/src/views/pcs/PCForm.vue @@ -66,10 +66,10 @@ Select type... - {{ pt.machinetype }} + {{ pt.computertype }} @@ -268,7 +268,7 @@ + + + + PC Types + + Add PC Type + + + + Loading... + + + + + + + PC Type + Description + Actions + + + + + {{ pt.computertype }} + {{ pt.description || '-' }} + + Edit + + + + + No PC types found + + + + + + + + + + + + + {{ editing ? 'Edit PC Type' : 'Add PC Type' }} + + + + + PC Type * + + + + Description + + + {{ error }} + + + + + + + + + diff --git a/migrations/versions/7c01_drop_legacy_machine.py b/migrations/versions/7c01_drop_legacy_machine.py new file mode 100644 index 0000000..b827638 --- /dev/null +++ b/migrations/versions/7c01_drop_legacy_machine.py @@ -0,0 +1,47 @@ +"""Drop the legacy Machine instance layer + +Retires the Machine model (ADR-001): the asset/computer model is now the +single source of truth. Drops machines + its PC/status lookups + the legacy +relationship and installed-app tables + the legacy printer extension, and +removes the deprecated communications.machineid column. machinetypes is kept +(still referenced by models.machinetypeid). + +Idempotent (IF EXISTS) so it is safe even though the live drop was applied +directly during the cutover. + +Revision ID: 7c01_drop_legacy_machine +Revises: 7b02_gaugelabref +Create Date: 2026-06-26 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7c01_drop_legacy_machine' +down_revision = '7b02_gaugelabref' +branch_labels = None +depends_on = None + +_TABLES = ['printerdata', 'installedapps', 'machinerelationships', + 'machines', 'pctypes', 'machinestatuses'] + + +def upgrade(): + bind = op.get_bind() + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0") + insp = sa.inspect(bind) + if 'machineid' in [c['name'] for c in insp.get_columns('communications')]: + for fk in insp.get_foreign_keys('communications'): + if 'machineid' in fk['constrained_columns'] and fk.get('name'): + bind.exec_driver_sql( + f"ALTER TABLE communications DROP FOREIGN KEY {fk['name']}") + op.drop_column('communications', 'machineid') + for t in _TABLES: + bind.exec_driver_sql(f"DROP TABLE IF EXISTS {t}") + bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1") + + +def downgrade(): + # The Machine layer is retired; recreating it is out of scope. + raise NotImplementedError("Legacy Machine layer cannot be restored") diff --git a/plugins/printers/api/__init__.py b/plugins/printers/api/__init__.py index d4bfa10..5226ee0 100644 --- a/plugins/printers/api/__init__.py +++ b/plugins/printers/api/__init__.py @@ -1,9 +1,7 @@ """Printers plugin API.""" -from .routes import printers_bp # Legacy Machine-based API -from .asset_routes import printers_asset_bp # New Asset-based API +from .asset_routes import printers_asset_bp # Asset-based API __all__ = [ - 'printers_bp', # Legacy - 'printers_asset_bp', # New + 'printers_asset_bp', ] diff --git a/plugins/printers/api/routes.py b/plugins/printers/api/routes.py deleted file mode 100644 index 69c0261..0000000 --- a/plugins/printers/api/routes.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Printers API routes.""" - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.extensions import db -from shopdb.utils.responses import success_response, error_response, paginated_response, ErrorCodes -from shopdb.utils.pagination import get_pagination_params, paginate_query -from shopdb.core.models.machine import Machine, MachineType -from shopdb.core.models.communication import Communication, CommunicationType -from shopdb.core.models import AuditLog - -from ..models import PrinterData -from ..services import ZabbixService - -printers_bp = Blueprint('printers', __name__) - - -@printers_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def list_printers(): - """List all printers.""" - page, per_page = get_pagination_params(request) - - # Get printer machine types - printer_types = MachineType.query.filter_by(category='Printer').all() - printer_type_ids = [pt.machinetypeid for pt in printer_types] - - query = Machine.query.filter( - Machine.machinetypeid.in_(printer_type_ids), - Machine.isactive == True - ) - - # Filters - if location_id := request.args.get('location', type=int): - query = query.filter(Machine.locationid == location_id) - - if search := request.args.get('search'): - query = query.filter( - db.or_( - Machine.machinenumber.ilike(f'%{search}%'), - Machine.hostname.ilike(f'%{search}%'), - Machine.alias.ilike(f'%{search}%') - ) - ) - - query = query.order_by(Machine.machinenumber) - items, total = paginate_query(query, page, per_page) - - printers = [] - for machine in items: - printer_data = { - 'machineid': machine.machineid, - 'machinenumber': machine.machinenumber, - 'hostname': machine.hostname, - 'alias': machine.alias, - 'serialnumber': machine.serialnumber, - 'location': machine.location.locationname if machine.location else None, - 'vendor': machine.vendor.vendor if machine.vendor else None, - 'model': machine.model.modelnumber if machine.model else None, - 'status': machine.status.status if machine.status else None, - } - - # Add printer-specific data - if machine.printerdata: - pd = machine.printerdata - printer_data['printerdata'] = { - 'windowsname': pd.windowsname, - 'sharename': pd.sharename, - 'iscsf': pd.iscsf, - 'pin': pd.pin, - } - - # Get IP from communications - primary_comm = next((c for c in machine.communications if c.isprimary), None) - if not primary_comm and machine.communications: - primary_comm = machine.communications[0] - printer_data['ipaddress'] = primary_comm.ipaddress if primary_comm else None - - printers.append(printer_data) - - return paginated_response(printers, page, per_page, total) - - -@printers_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_printer(machine_id: int): - """Get a single printer with details.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - data = machine.to_dict() - data['machinetype'] = machine.machinetype.to_dict() if machine.machinetype else None - data['vendor'] = machine.vendor.to_dict() if machine.vendor else None - data['model'] = machine.model.to_dict() if machine.model else None - data['location'] = machine.location.to_dict() if machine.location else None - data['status'] = machine.status.to_dict() if machine.status else None - data['communications'] = [c.to_dict() for c in machine.communications] - - # Add printer-specific data - if machine.printerdata: - pd = machine.printerdata - data['printerdata'] = { - 'id': pd.id, - 'windowsname': pd.windowsname, - 'sharename': pd.sharename, - 'iscsf': pd.iscsf, - 'installpath': pd.installpath, - 'pin': pd.pin, - } - - return success_response(data) - - -@printers_bp.route('//printerdata', methods=['PUT']) -@jwt_required() -def update_printer_data(machine_id: int): - """Update printer-specific data.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Get or create printer data - pd = machine.printerdata - if not pd: - pd = PrinterData(machineid=machine_id) - db.session.add(pd) - - # Track changes for audit log - changes = {} - for key in ['windowsname', 'sharename', 'iscsf', 'installpath', 'pin']: - if key in data: - old_val = getattr(pd, key, None) - new_val = data[key] - if old_val != new_val: - changes[key] = {'old': old_val, 'new': new_val} - setattr(pd, key, data[key]) - - # Audit log if there were changes - if changes: - AuditLog.log('updated', 'Printer', entityid=machine_id, - entityname=machine.machinenumber or machine.hostname, changes=changes) - - db.session.commit() - - return success_response({ - 'id': pd.id, - 'windowsname': pd.windowsname, - 'sharename': pd.sharename, - 'iscsf': pd.iscsf, - 'installpath': pd.installpath, - 'pin': pd.pin, - }, message='Printer data updated') - - -@printers_bp.route('//communication', methods=['PUT']) -@jwt_required() -def update_printer_communication(machine_id: int): - """Update printer communication (IP address).""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Get or create IP communication type - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if not ip_comtype: - ip_comtype = CommunicationType(comtype='IP', description='IP Network') - db.session.add(ip_comtype) - db.session.flush() - - # Find existing primary communication or create new one - comm = next((c for c in machine.communications if c.isprimary), None) - if not comm: - comm = next((c for c in machine.communications if c.comtypeid == ip_comtype.comtypeid), None) - if not comm: - comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) - db.session.add(comm) - - # Track changes for audit log - changes = {} - - # Update fields - if 'ipaddress' in data: - if comm.ipaddress != data['ipaddress']: - changes['ipaddress'] = {'old': comm.ipaddress, 'new': data['ipaddress']} - comm.ipaddress = data['ipaddress'] - if 'isprimary' in data: - if comm.isprimary != data['isprimary']: - changes['isprimary'] = {'old': comm.isprimary, 'new': data['isprimary']} - comm.isprimary = data['isprimary'] - if 'macaddress' in data: - if comm.macaddress != data['macaddress']: - changes['macaddress'] = {'old': comm.macaddress, 'new': data['macaddress']} - comm.macaddress = data['macaddress'] - - # Audit log if there were changes - if changes: - AuditLog.log('updated', 'Printer', entityid=machine_id, - entityname=machine.machinenumber or machine.hostname, changes=changes) - - db.session.commit() - - return success_response({ - 'communicationid': comm.communicationid, - 'ipaddress': comm.ipaddress, - 'isprimary': comm.isprimary, - }, message='Communication updated') - - -@printers_bp.route('//supplies', methods=['GET']) -@jwt_required(optional=True) -def get_printer_supplies(machine_id: int): - """Get supply levels from Zabbix (real-time lookup).""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response(ErrorCodes.NOT_FOUND, 'Printer not found', http_code=404) - - # Get IP address - primary_comm = next((c for c in machine.communications if c.isprimary), None) - if not primary_comm and machine.communications: - primary_comm = machine.communications[0] - - if not primary_comm or not primary_comm.ipaddress: - return error_response(ErrorCodes.VALIDATION_ERROR, 'Printer has no IP address') - - service = ZabbixService() - if not service.isconfigured or not service.isreachable: - # Return empty supplies if Zabbix not available (fail gracefully) - return success_response({ - 'ipaddress': primary_comm.ipaddress, - 'supplies': [] - }) - - supplies = service.getsuppliesbyip(primary_comm.ipaddress) - - return success_response({ - 'ipaddress': primary_comm.ipaddress, - 'supplies': supplies or [] - }) - - -@printers_bp.route('/dashboard/summary', methods=['GET']) -@jwt_required(optional=True) -def dashboard_summary(): - """Get printer summary for dashboard.""" - printer_types = MachineType.query.filter_by(category='Printer').all() - printer_type_ids = [pt.machinetypeid for pt in printer_types] - - total = Machine.query.filter( - Machine.machinetypeid.in_(printer_type_ids), - Machine.isactive == True - ).count() - - return success_response({ - 'totalprinters': total, - 'total': total, - 'online': total, # Placeholder - would need Zabbix integration for real status - 'lowsupplies': 0, - 'criticalsupplies': 0 - }) diff --git a/plugins/printers/models/__init__.py b/plugins/printers/models/__init__.py index c21cdc0..0434f25 100644 --- a/plugins/printers/models/__init__.py +++ b/plugins/printers/models/__init__.py @@ -1,7 +1,6 @@ """Printers plugin models.""" -from .printer_extension import PrinterData # Legacy model for Machine-based architecture -from .printer import Printer, PrinterType # New Asset-based models +from .printer import Printer, PrinterType # Asset-based models from .model_supply import ( # data-driven model -> toner/drum/waste mapping ModelSupply, SUPPLY_TYPES, @@ -10,9 +9,8 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping ) __all__ = [ - 'PrinterData', # Legacy - 'Printer', # New - 'PrinterType', # New + 'Printer', + 'PrinterType', 'ModelSupply', 'SUPPLY_TYPES', 'SUPPLY_COLORS', diff --git a/plugins/printers/models/printer_extension.py b/plugins/printers/models/printer_extension.py deleted file mode 100644 index 12144b6..0000000 --- a/plugins/printers/models/printer_extension.py +++ /dev/null @@ -1,58 +0,0 @@ -"""PrinterData model - printer-specific fields linked to machines.""" - -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel - - -class PrinterData(BaseModel): - """ - Printer-specific data linked to Machine table. - - Printers are stored in the machines table (machinetype.category = 'Printer'). - This table only holds printer-specific fields not in machines. - - IP address is stored in the communications table. - Zabbix data is queried in real-time via API (not cached here). - """ - __tablename__ = 'printerdata' - - id = db.Column(db.Integer, primary_key=True) - - # Link to machine - machineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid', ondelete='CASCADE'), - unique=True, - nullable=False, - index=True - ) - - # Windows/Network naming - windowsname = db.Column( - db.String(255), - comment='Windows printer name (e.g., \\\\server\\printer)' - ) - sharename = db.Column( - db.String(100), - comment='CSF/share name' - ) - - # Installation - iscsf = db.Column(db.Boolean, default=False, comment='Is CSF printer') - installpath = db.Column(db.String(255), comment='Driver install path') - - # Printer PIN (for secure print) - pin = db.Column(db.String(20)) - - # Relationship - machine = db.relationship( - 'Machine', - backref=db.backref('printerdata', uselist=False, lazy='joined') - ) - - __table_args__ = ( - db.Index('idx_printerdata_windowsname', 'windowsname'), - ) - - def __repr__(self): - return f"" diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 198b8c3..ac59f96 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -13,8 +13,8 @@ from shopdb.extensions import db from shopdb.core.models.machine import MachineType from shopdb.core.models import AssetType -from .models import PrinterData, Printer, PrinterType, ModelSupply -from .api import printers_bp, printers_asset_bp +from .models import Printer, PrinterType, ModelSupply +from .api import printers_asset_bp from .services import ZabbixService logger = logging.getLogger(__name__) @@ -74,9 +74,8 @@ class PrintersPlugin(BasePlugin): def get_models(self) -> List[Type]: """Return list of SQLAlchemy model classes.""" return [ - PrinterData, # Legacy Machine-based - Printer, # New Asset-based - PrinterType, # New printer type classification + Printer, # Asset-based + PrinterType, # printer type classification ModelSupply, # model -> toner/drum/waste part numbers ] @@ -98,9 +97,6 @@ class PrintersPlugin(BasePlugin): app.config.setdefault('ZABBIX_URL', '') app.config.setdefault('ZABBIX_TOKEN', '') - # Register legacy blueprint for backward compatibility - app.register_blueprint(printers_bp, url_prefix='/api/printers/legacy') - logger.info(f"Printers plugin initialized (v{self.meta.version})") def on_install(self, app: Flask) -> None: diff --git a/shopdb/__init__.py b/shopdb/__init__.py index cc622c3..d11db33 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -79,10 +79,7 @@ def create_app(config_name: str = None) -> Flask: CORE_BLUEPRINT_NAMES = ( 'auth', 'assets', - 'machines', 'machinetypes', - 'pctypes', - 'statuses', 'vendors', 'models', 'businessunits', diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index bb1332b..a2202ea 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -42,7 +42,7 @@ def seed_cli(): def seed_reference_data(): """Seed reference data (machine types, statuses, etc.).""" from shopdb.extensions import db - from shopdb.core.models import MachineType, MachineStatus, OperatingSystem, AssetStatus + from shopdb.core.models import MachineType, OperatingSystem, AssetStatus from shopdb.core.models.relationship import RelationshipType # Machine types @@ -67,21 +67,6 @@ def seed_reference_data(): mt = MachineType(**mt_data) db.session.add(mt) - # Machine statuses - statuses = [ - {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, - {'status': 'Spare', 'description': 'Available as spare', 'color': '#17a2b8'}, - {'status': 'Retired', 'description': 'No longer in use', 'color': '#6c757d'}, - {'status': 'In Repair', 'description': 'Currently being repaired', 'color': '#ffc107'}, - {'status': 'Pending', 'description': 'Pending installation', 'color': '#007bff'}, - ] - - for s_data in statuses: - existing = MachineStatus.query.filter_by(status=s_data['status']).first() - if not existing: - s = MachineStatus(**s_data) - db.session.add(s) - # Asset statuses (canonical set - the asset model is the contract) asset_statuses = [ {'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'}, diff --git a/shopdb/core/api/__init__.py b/shopdb/core/api/__init__.py index b6da533..9f6ccd4 100644 --- a/shopdb/core/api/__init__.py +++ b/shopdb/core/api/__init__.py @@ -2,10 +2,7 @@ from .auth import auth_bp from .assets import assets_bp -from .machines import machines_bp from .machinetypes import machinetypes_bp -from .pctypes import pctypes_bp -from .statuses import statuses_bp from .vendors import vendors_bp from .models import models_bp from .businessunits import businessunits_bp @@ -26,10 +23,7 @@ from .users import users_bp __all__ = [ 'auth_bp', 'assets_bp', - 'machines_bp', 'machinetypes_bp', - 'pctypes_bp', - 'statuses_bp', 'vendors_bp', 'models_bp', 'businessunits_bp', diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py index ea22a3b..cc01155 100644 --- a/shopdb/core/api/applications.py +++ b/shopdb/core/api/applications.py @@ -66,7 +66,8 @@ def list_applications(): } else: app_dict['supportteam'] = None - app_dict['installedcount'] = app.installed_on.filter_by(isactive=True).count() + app_dict['installedcount'] = ComputerInstalledApp.query.filter_by( + appid=app.appid, isactive=True).count() data.append(app_dict) return paginated_response(data, page, per_page, total) @@ -96,7 +97,8 @@ def get_application(app_id: int): else: data['supportteam'] = None data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()] - data['installedcount'] = app.installed_on.filter_by(isactive=True).count() + data['installedcount'] = ComputerInstalledApp.query.filter_by( + appid=app.appid, isactive=True).count() return success_response(data) diff --git a/shopdb/core/api/machines.py b/shopdb/core/api/machines.py deleted file mode 100644 index 4a9ed3d..0000000 --- a/shopdb/core/api/machines.py +++ /dev/null @@ -1,641 +0,0 @@ -""" -Machines API endpoints. - -DEPRECATED: This API is deprecated and will be removed in a future version. -Please migrate to the new asset-based APIs: -- /api/assets - Unified asset queries -- /api/equipment - Equipment CRUD -- /api/computers - Computers CRUD -- /api/network - Network devices CRUD -- /api/printers - Printers CRUD -""" - -import logging -from functools import wraps -from flask import Blueprint, request, g -from flask_jwt_extended import jwt_required, current_user - -from shopdb.extensions import db -from shopdb.core.models import Machine, MachineType, AuditLog -from shopdb.core.models.relationship import MachineRelationship, RelationshipType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -logger = logging.getLogger(__name__) - -machines_bp = Blueprint('machines', __name__) - - -def add_deprecation_headers(f): - """Decorator to add deprecation headers to responses.""" - @wraps(f) - def decorated_function(*args, **kwargs): - response = f(*args, **kwargs) - - # Add deprecation headers - if hasattr(response, 'headers'): - response.headers['X-Deprecated'] = 'true' - response.headers['X-Deprecated-Message'] = ( - 'This endpoint is deprecated. ' - 'Please migrate to /api/assets, /api/equipment, /api/computers, /api/network, or /api/printers.' - ) - response.headers['Sunset'] = '2026-12-31' # Target sunset date - - # Log deprecation warning (once per request) - if not getattr(g, '_deprecation_logged', False): - logger.warning( - f"Deprecated /api/machines endpoint called: {request.method} {request.path}" - ) - g._deprecation_logged = True - - return response - return decorated_function - - -@machines_bp.route('', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def list_machines(): - """ - List all machines with filtering and pagination. - - Query params: - page: int (default 1) - per_page: int (default 20, max 100) - machinetype: int (filter by type ID) - pctype: int (filter by PC type ID) - businessunit: int (filter by business unit ID) - status: int (filter by status ID) - category: str (Equipment, PC, Network) - search: str (search in machinenumber, alias, hostname) - active: bool (default true) - sort: str (field name, prefix with - for desc) - """ - page, per_page = get_pagination_params(request) - - # Build query - query = Machine.query - - # Apply filters - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(Machine.isactive == True) - - if machinetype_id := request.args.get('machinetype', type=int): - query = query.filter(Machine.machinetypeid == machinetype_id) - - if pctype_id := request.args.get('pctype', type=int): - query = query.filter(Machine.pctypeid == pctype_id) - - if businessunit_id := request.args.get('businessunit', type=int): - query = query.filter(Machine.businessunitid == businessunit_id) - - if status_id := request.args.get('status', type=int): - query = query.filter(Machine.statusid == status_id) - - if category := request.args.get('category'): - query = query.join(MachineType).filter(MachineType.category == category) - - if search := request.args.get('search'): - search_term = f'%{search}%' - query = query.filter( - db.or_( - Machine.machinenumber.ilike(search_term), - Machine.alias.ilike(search_term), - Machine.hostname.ilike(search_term), - Machine.serialnumber.ilike(search_term) - ) - ) - - # Filter for machines with map positions - if request.args.get('hasmap', '').lower() == 'true': - query = query.filter( - Machine.mapleft.isnot(None), - Machine.maptop.isnot(None) - ) - - # Apply sorting - sort_field = request.args.get('sort', 'machinenumber') - desc = sort_field.startswith('-') - if desc: - sort_field = sort_field[1:] - - if hasattr(Machine, sort_field): - order = getattr(Machine, sort_field) - query = query.order_by(order.desc() if desc else order) - - # For map view, allow fetching all machines without pagination limit - include_map_extras = request.args.get('hasmap', '').lower() == 'true' - fetch_all = request.args.get('all', '').lower() == 'true' - - if include_map_extras and fetch_all: - # Get all map machines without pagination - items = query.all() - total = len(items) - else: - # Normal pagination - items, total = paginate_query(query, page, per_page) - - # Convert to dicts with relationships - data = [] - for m in items: - d = m.to_dict() - # Get machinetype from model (single source of truth) - mt = m.derived_machinetype - d['machinetype'] = mt.machinetype if mt else None - d['machinetypeid'] = mt.machinetypeid if mt else None - d['category'] = mt.category if mt else None - d['status'] = m.status.status if m.status else None - d['statusid'] = m.statusid - d['businessunit'] = m.businessunit.businessunit if m.businessunit else None - d['businessunitid'] = m.businessunitid - d['vendor'] = m.vendor.vendor if m.vendor else None - d['model'] = m.model.modelnumber if m.model else None - d['pctype'] = m.pctype.pctype if m.pctype else None - d['serialnumber'] = m.serialnumber - d['isvnc'] = m.isvnc - d['iswinrm'] = m.iswinrm - - # Include extra fields for map view - if include_map_extras: - # Get primary IP address from communications - primary_comm = next( - (c for c in m.communications if c.isprimary and c.ipaddress), - None - ) - if not primary_comm: - # Fall back to first communication with IP - primary_comm = next( - (c for c in m.communications if c.ipaddress), - None - ) - d['ipaddress'] = primary_comm.ipaddress if primary_comm else None - - # Get connected PC (parent machine that is a PC) - connected_pc = None - for rel in m.parent_relationships: - if rel.parent_machine and rel.parent_machine.is_pc: - connected_pc = rel.parent_machine.machinenumber - break - d['connected_pc'] = connected_pc - - data.append(d) - - return paginated_response(data, page, per_page, total) - - -@machines_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def get_machine(machine_id: int): - """Get a single machine by ID.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - data = machine.to_dict() - # Add related data - machinetype comes from model (single source of truth) - mt = machine.derived_machinetype - data['machinetype'] = mt.to_dict() if mt else None - data['pctype'] = machine.pctype.to_dict() if machine.pctype else None - data['status'] = machine.status.to_dict() if machine.status else None - data['businessunit'] = machine.businessunit.to_dict() if machine.businessunit else None - data['vendor'] = machine.vendor.to_dict() if machine.vendor else None - data['model'] = machine.model.to_dict() if machine.model else None - data['location'] = machine.location.to_dict() if machine.location else None - data['operatingsystem'] = machine.operatingsystem.to_dict() if machine.operatingsystem else None - - # Add communications - data['communications'] = [c.to_dict() for c in machine.communications.all()] - - return success_response(data) - - -@machines_bp.route('', methods=['POST']) -@jwt_required() -@add_deprecation_headers -def create_machine(): - """Create a new machine.""" - data = request.get_json() - - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if not data.get('machinenumber'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'machinenumber is required') - - if not data.get('modelnumberid'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'modelnumberid is required (determines machine type)') - - # Check for duplicate machinenumber - if Machine.query.filter_by(machinenumber=data['machinenumber']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Machine number '{data['machinenumber']}' already exists", - http_code=409 - ) - - # Create machine - allowed_fields = [ - 'machinenumber', 'alias', 'hostname', 'serialnumber', - 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', - 'vendorid', 'statusid', 'locationid', 'osid', - 'mapleft', 'maptop', 'islocationonly', - 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', - 'requiresmanualconfig', 'notes' - ] - - machine_data = {k: v for k, v in data.items() if k in allowed_fields} - machine = Machine(**machine_data) - machine.createdby = current_user.username - - db.session.add(machine) - db.session.flush() - - # Audit log - AuditLog.log('created', 'Machine', entityid=machine.machineid, - entityname=machine.machinenumber) - - db.session.commit() - - return success_response( - machine.to_dict(), - message='Machine created successfully', - http_code=201 - ) - - -@machines_bp.route('/', methods=['PUT']) -@jwt_required() -@add_deprecation_headers -def update_machine(machine_id: int): - """Update an existing machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Check for duplicate machinenumber if changed - if 'machinenumber' in data and data['machinenumber'] != machine.machinenumber: - existing = Machine.query.filter_by(machinenumber=data['machinenumber']).first() - if existing: - return error_response( - ErrorCodes.CONFLICT, - f"Machine number '{data['machinenumber']}' already exists", - http_code=409 - ) - - # Update allowed fields - allowed_fields = [ - 'machinenumber', 'alias', 'hostname', 'serialnumber', - 'machinetypeid', 'pctypeid', 'businessunitid', 'modelnumberid', - 'vendorid', 'statusid', 'locationid', 'osid', - 'mapleft', 'maptop', 'islocationonly', - 'loggedinuser', 'isvnc', 'iswinrm', 'isshopfloor', - 'requiresmanualconfig', 'notes', 'isactive' - ] - - # Track changes for audit log - changes = {} - for key, value in data.items(): - if key in allowed_fields: - old_val = getattr(machine, key) - if old_val != value: - changes[key] = {'old': old_val, 'new': value} - setattr(machine, key, value) - - machine.modifiedby = current_user.username - - # Audit log if there were changes - if changes: - AuditLog.log('updated', 'Machine', entityid=machine.machineid, - entityname=machine.machinenumber, changes=changes) - - db.session.commit() - - return success_response(machine.to_dict(), message='Machine updated successfully') - - -@machines_bp.route('/', methods=['DELETE']) -@jwt_required() -@add_deprecation_headers -def delete_machine(machine_id: int): - """Soft delete a machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - machine.soft_delete(deleted_by=current_user.username) - - # Audit log - AuditLog.log('deleted', 'Machine', entityid=machine.machineid, - entityname=machine.machinenumber) - - db.session.commit() - - return success_response(message='Machine deleted successfully') - - -@machines_bp.route('//communications', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def get_machine_communications(machine_id: int): - """Get all communications for a machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - comms = [c.to_dict() for c in machine.communications.all()] - return success_response(comms) - - -@machines_bp.route('//communication', methods=['PUT']) -@jwt_required() -@add_deprecation_headers -def update_machine_communication(machine_id: int): - """Update machine communication (IP address).""" - from shopdb.core.models.communication import Communication, CommunicationType - - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - # Get or create IP communication type - ip_comtype = CommunicationType.query.filter_by(comtype='IP').first() - if not ip_comtype: - ip_comtype = CommunicationType(comtype='IP', description='IP Network') - db.session.add(ip_comtype) - db.session.flush() - - # Find existing primary communication or create new one - comms = list(machine.communications.all()) - comm = next((c for c in comms if c.isprimary), None) - if not comm: - comm = next((c for c in comms if c.comtypeid == ip_comtype.comtypeid), None) - if not comm: - comm = Communication(machineid=machine_id, comtypeid=ip_comtype.comtypeid) - db.session.add(comm) - - # Update fields - if 'ipaddress' in data: - comm.ipaddress = data['ipaddress'] - if 'isprimary' in data: - comm.isprimary = data['isprimary'] - if 'macaddress' in data: - comm.macaddress = data['macaddress'] - - db.session.commit() - - return success_response({ - 'communicationid': comm.communicationid, - 'ipaddress': comm.ipaddress, - 'isprimary': comm.isprimary, - }, message='Communication updated') - - -# ==================== Machine Relationships ==================== - -@machines_bp.route('//relationships', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def get_machine_relationships(machine_id: int): - """Get all relationships for a machine (both parent and child).""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - relationships = [] - my_category = machine.machinetype.category if machine.machinetype else None - seen_ids = set() - - # Get all relationships involving this machine - all_rels = list(machine.child_relationships) + list(machine.parent_relationships) - - for rel in all_rels: - if rel.relationshipid in seen_ids: - continue - seen_ids.add(rel.relationshipid) - - # Determine the related machine (the one that isn't us) - if rel.parentmachineid == machine.machineid: - related = rel.child_machine - else: - related = rel.parent_machine - - related_category = related.machinetype.category if related and related.machinetype else None - rel_type = rel.relationship_type.relationshiptype if rel.relationship_type else None - - # Determine direction based on relationship type and categories - if rel_type == 'Controls': - # PC controls Equipment - determine from categories - if my_category == 'PC': - direction = 'controls' - else: - direction = 'controlled_by' - elif rel_type == 'Dualpath': - direction = 'dualpath_partner' - else: - # For other types, use parent/child - if rel.parentmachineid == machine.machineid: - direction = 'controls' - else: - direction = 'controlled_by' - - relationships.append({ - 'relationshipid': rel.relationshipid, - 'direction': direction, - 'relatedmachineid': related.machineid if related else None, - 'relatedmachinenumber': related.machinenumber if related else None, - 'relatedmachinealias': related.alias if related else None, - 'relatedcategory': related_category, - 'relationshiptype': rel_type, - 'relationshiptypeid': rel.relationshiptypeid, - 'notes': rel.notes - }) - - return success_response(relationships) - - -@machines_bp.route('//relationships', methods=['POST']) -@jwt_required() -@add_deprecation_headers -def create_machine_relationship(machine_id: int): - """Create a relationship for a machine.""" - machine = Machine.query.get(machine_id) - - if not machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Machine with ID {machine_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - related_machine_id = data.get('relatedmachineid') - relationship_type_id = data.get('relationshiptypeid') - direction = data.get('direction', 'controlled_by') # 'controls' or 'controlled_by' - - if not related_machine_id: - return error_response(ErrorCodes.VALIDATION_ERROR, 'relatedmachineid is required') - - if not relationship_type_id: - return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptypeid is required') - - related_machine = Machine.query.get(related_machine_id) - if not related_machine: - return error_response( - ErrorCodes.NOT_FOUND, - f'Related machine with ID {related_machine_id} not found', - http_code=404 - ) - - # Determine parent/child based on direction - if direction == 'controls': - parent_id = machine_id - child_id = related_machine_id - else: # controlled_by - parent_id = related_machine_id - child_id = machine_id - - # Check if relationship already exists - existing = MachineRelationship.query.filter_by( - parentmachineid=parent_id, - childmachineid=child_id, - relationshiptypeid=relationship_type_id - ).first() - - if existing: - return error_response( - ErrorCodes.CONFLICT, - 'This relationship already exists', - http_code=409 - ) - - relationship = MachineRelationship( - parentmachineid=parent_id, - childmachineid=child_id, - relationshiptypeid=relationship_type_id, - notes=data.get('notes') - ) - - db.session.add(relationship) - db.session.commit() - - return success_response({ - 'relationshipid': relationship.relationshipid, - 'parentmachineid': relationship.parentmachineid, - 'childmachineid': relationship.childmachineid, - 'relationshiptypeid': relationship.relationshiptypeid - }, message='Relationship created successfully', http_code=201) - - -@machines_bp.route('/relationships/', methods=['DELETE']) -@jwt_required() -@add_deprecation_headers -def delete_machine_relationship(relationship_id: int): - """Delete a machine relationship.""" - relationship = MachineRelationship.query.get(relationship_id) - - if not relationship: - return error_response( - ErrorCodes.NOT_FOUND, - f'Relationship with ID {relationship_id} not found', - http_code=404 - ) - - db.session.delete(relationship) - db.session.commit() - - return success_response(message='Relationship deleted successfully') - - -@machines_bp.route('/relationshiptypes', methods=['GET']) -@jwt_required(optional=True) -@add_deprecation_headers -def list_relationship_types(): - """List all relationship types.""" - types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all() - return success_response([{ - 'relationshiptypeid': t.relationshiptypeid, - 'relationshiptype': t.relationshiptype, - 'description': t.description - } for t in types]) - - -@machines_bp.route('/relationshiptypes', methods=['POST']) -@jwt_required() -@add_deprecation_headers -def create_relationship_type(): - """Create a new relationship type.""" - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if not data.get('relationshiptype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'relationshiptype is required') - - existing = RelationshipType.query.filter_by(relationshiptype=data['relationshiptype']).first() - if existing: - return error_response( - ErrorCodes.CONFLICT, - f"Relationship type '{data['relationshiptype']}' already exists", - http_code=409 - ) - - rel_type = RelationshipType( - relationshiptype=data['relationshiptype'], - description=data.get('description') - ) - - db.session.add(rel_type) - db.session.commit() - - return success_response({ - 'relationshiptypeid': rel_type.relationshiptypeid, - 'relationshiptype': rel_type.relationshiptype, - 'description': rel_type.description - }, message='Relationship type created successfully', http_code=201) diff --git a/shopdb/core/api/machinetypes.py b/shopdb/core/api/machinetypes.py index 5ec6582..9046421 100644 --- a/shopdb/core/api/machinetypes.py +++ b/shopdb/core/api/machinetypes.py @@ -133,12 +133,12 @@ def delete_machinetype(type_id: int): http_code=404 ) - # Check if any machines use this type - from shopdb.core.models import Machine - if Machine.query.filter_by(machinetypeid=type_id, isactive=True).first(): + # Check if any model uses this type + from shopdb.core.models import Model + if Model.query.filter_by(machinetypeid=type_id).first(): return error_response( ErrorCodes.CONFLICT, - 'Cannot delete machine type: machines are using it', + 'Cannot delete machine type: models are using it', http_code=409 ) diff --git a/shopdb/core/api/pctypes.py b/shopdb/core/api/pctypes.py deleted file mode 100644 index 119f785..0000000 --- a/shopdb/core/api/pctypes.py +++ /dev/null @@ -1,141 +0,0 @@ -"""PC Types API endpoints - Full CRUD.""" - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.extensions import db -from shopdb.core.models import PCType -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -pctypes_bp = Blueprint('pctypes', __name__) - - -@pctypes_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_pctypes(): - """List all PC types.""" - page, per_page = get_pagination_params(request) - - query = PCType.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(PCType.isactive == True) - - if search := request.args.get('search'): - query = query.filter(PCType.pctype.ilike(f'%{search}%')) - - query = query.order_by(PCType.pctype) - - items, total = paginate_query(query, page, per_page) - data = [pt.to_dict() for pt in items] - - return paginated_response(data, page, per_page, total) - - -@pctypes_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_pctype(type_id: int): - """Get a single PC type.""" - pt = PCType.query.get(type_id) - - if not pt: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC type with ID {type_id} not found', - http_code=404 - ) - - return success_response(pt.to_dict()) - - -@pctypes_bp.route('', methods=['POST']) -@jwt_required() -def create_pctype(): - """Create a new PC type.""" - data = request.get_json() - - if not data or not data.get('pctype'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'pctype is required') - - if PCType.query.filter_by(pctype=data['pctype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"PC type '{data['pctype']}' already exists", - http_code=409 - ) - - pt = PCType( - pctype=data['pctype'], - description=data.get('description') - ) - - db.session.add(pt) - db.session.commit() - - return success_response(pt.to_dict(), message='PC type created', http_code=201) - - -@pctypes_bp.route('/', methods=['PUT']) -@jwt_required() -def update_pctype(type_id: int): - """Update a PC type.""" - pt = PCType.query.get(type_id) - - if not pt: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC type with ID {type_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if 'pctype' in data and data['pctype'] != pt.pctype: - if PCType.query.filter_by(pctype=data['pctype']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"PC type '{data['pctype']}' already exists", - http_code=409 - ) - - for key in ['pctype', 'description', 'isactive']: - if key in data: - setattr(pt, key, data[key]) - - db.session.commit() - return success_response(pt.to_dict(), message='PC type updated') - - -@pctypes_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_pctype(type_id: int): - """Delete (deactivate) a PC type.""" - pt = PCType.query.get(type_id) - - if not pt: - return error_response( - ErrorCodes.NOT_FOUND, - f'PC type with ID {type_id} not found', - http_code=404 - ) - - from shopdb.core.models import Machine - if Machine.query.filter_by(pctypeid=type_id, isactive=True).first(): - return error_response( - ErrorCodes.CONFLICT, - 'Cannot delete PC type: machines are using it', - http_code=409 - ) - - pt.isactive = False - db.session.commit() - - return success_response(message='PC type deleted') diff --git a/shopdb/core/api/statuses.py b/shopdb/core/api/statuses.py deleted file mode 100644 index bd8e46d..0000000 --- a/shopdb/core/api/statuses.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Machine Statuses API endpoints - Full CRUD.""" - -from flask import Blueprint, request -from flask_jwt_extended import jwt_required - -from shopdb.extensions import db -from shopdb.core.models import MachineStatus -from shopdb.utils.responses import ( - success_response, - error_response, - paginated_response, - ErrorCodes -) -from shopdb.utils.pagination import get_pagination_params, paginate_query - -statuses_bp = Blueprint('statuses', __name__) - - -@statuses_bp.route('', methods=['GET']) -@jwt_required(optional=True) -def list_statuses(): - """List all machine statuses.""" - page, per_page = get_pagination_params(request) - - query = MachineStatus.query - - if request.args.get('active', 'true').lower() != 'false': - query = query.filter(MachineStatus.isactive == True) - - query = query.order_by(MachineStatus.status) - - items, total = paginate_query(query, page, per_page) - data = [s.to_dict() for s in items] - - return paginated_response(data, page, per_page, total) - - -@statuses_bp.route('/', methods=['GET']) -@jwt_required(optional=True) -def get_status(status_id: int): - """Get a single status.""" - s = MachineStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Status with ID {status_id} not found', - http_code=404 - ) - - return success_response(s.to_dict()) - - -@statuses_bp.route('', methods=['POST']) -@jwt_required() -def create_status(): - """Create a new status.""" - data = request.get_json() - - if not data or not data.get('status'): - return error_response(ErrorCodes.VALIDATION_ERROR, 'status is required') - - if MachineStatus.query.filter_by(status=data['status']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Status '{data['status']}' already exists", - http_code=409 - ) - - s = MachineStatus( - status=data['status'], - description=data.get('description'), - color=data.get('color') - ) - - db.session.add(s) - db.session.commit() - - return success_response(s.to_dict(), message='Status created', http_code=201) - - -@statuses_bp.route('/', methods=['PUT']) -@jwt_required() -def update_status(status_id: int): - """Update a status.""" - s = MachineStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Status with ID {status_id} not found', - http_code=404 - ) - - data = request.get_json() - if not data: - return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') - - if 'status' in data and data['status'] != s.status: - if MachineStatus.query.filter_by(status=data['status']).first(): - return error_response( - ErrorCodes.CONFLICT, - f"Status '{data['status']}' already exists", - http_code=409 - ) - - for key in ['status', 'description', 'color', 'isactive']: - if key in data: - setattr(s, key, data[key]) - - db.session.commit() - return success_response(s.to_dict(), message='Status updated') - - -@statuses_bp.route('/', methods=['DELETE']) -@jwt_required() -def delete_status(status_id: int): - """Delete (deactivate) a status.""" - s = MachineStatus.query.get(status_id) - - if not s: - return error_response( - ErrorCodes.NOT_FOUND, - f'Status with ID {status_id} not found', - http_code=404 - ) - - from shopdb.core.models import Machine - if Machine.query.filter_by(statusid=status_id, isactive=True).first(): - return error_response( - ErrorCodes.CONFLICT, - 'Cannot delete status: machines are using it', - http_code=409 - ) - - s.isactive = False - db.session.commit() - - return success_response(message='Status deleted') diff --git a/shopdb/core/models/__init__.py b/shopdb/core/models/__init__.py index 472f867..312660f 100644 --- a/shopdb/core/models/__init__.py +++ b/shopdb/core/models/__init__.py @@ -2,16 +2,16 @@ from .base import BaseModel, SoftDeleteMixin, AuditMixin from .asset import Asset, AssetType, AssetStatus -from .machine import Machine, MachineType, MachineStatus, PCType +from .machine import MachineType from .vendor import Vendor from .model import Model from .businessunit import BusinessUnit from .location import Location from .operatingsystem import OperatingSystem -from .relationship import MachineRelationship, AssetRelationship, RelationshipType +from .relationship import AssetRelationship, RelationshipType from .communication import Communication, CommunicationType from .user import User, Role, Permission -from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp +from .application import Application, AppVersion, AppOwner, SupportTeam from .knowledgebase import KnowledgeBase from .setting import Setting from .auditlog import AuditLog @@ -25,11 +25,8 @@ __all__ = [ 'Asset', 'AssetType', 'AssetStatus', - # Machine (legacy) - 'Machine', + # Legacy machine type lookup (still referenced by models.machinetypeid) 'MachineType', - 'MachineStatus', - 'PCType', # Reference 'Vendor', 'Model', @@ -37,7 +34,6 @@ __all__ = [ 'Location', 'OperatingSystem', # Relationships - 'MachineRelationship', 'AssetRelationship', 'RelationshipType', # Communication @@ -52,7 +48,6 @@ __all__ = [ 'AppVersion', 'AppOwner', 'SupportTeam', - 'InstalledApp', # Knowledge Base 'KnowledgeBase', # Settings diff --git a/shopdb/core/models/application.py b/shopdb/core/models/application.py index e2cc75e..cd30e78 100644 --- a/shopdb/core/models/application.py +++ b/shopdb/core/models/application.py @@ -1,143 +1,99 @@ -"""Application tracking models.""" - -from shopdb.extensions import db -from .base import BaseModel - - -class AppOwner(BaseModel): - """Application owner/contact.""" - __tablename__ = 'appowners' - - appownerid = db.Column(db.Integer, primary_key=True) - appowner = db.Column(db.String(100), nullable=False) - sso = db.Column(db.String(50)) - email = db.Column(db.String(100)) - - # Relationships - supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic') - - def __repr__(self): - return f"" - - -class SupportTeam(BaseModel): - """Application support team.""" - __tablename__ = 'supportteams' - - supportteamid = db.Column(db.Integer, primary_key=True) - teamname = db.Column(db.String(100), nullable=False) - teamurl = db.Column(db.String(255)) - appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid')) - - # Relationships - owner = db.relationship('AppOwner', back_populates='supportteams') - applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic') - - def __repr__(self): - return f"" - - -class Application(BaseModel): - """Application catalog.""" - __tablename__ = 'applications' - - appid = db.Column(db.Integer, primary_key=True) - appname = db.Column(db.String(100), unique=True, nullable=False) - appdescription = db.Column(db.String(255)) - supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid')) - isinstallable = db.Column(db.Boolean, default=False) - applicationnotes = db.Column(db.Text) - installpath = db.Column(db.String(255)) - applicationlink = db.Column(db.String(512)) - documentationpath = db.Column(db.String(512)) - ishidden = db.Column(db.Boolean, default=False) - isprinter = db.Column(db.Boolean, default=False) - islicenced = db.Column(db.Boolean, default=False) - image = db.Column(db.String(255)) - - # Relationships - supportteam = db.relationship('SupportTeam', back_populates='applications') - versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic') - installed_on = db.relationship('InstalledApp', back_populates='application', lazy='dynamic') - - def __repr__(self): - return f"" - - -class AppVersion(db.Model): - """Application version tracking.""" - __tablename__ = 'appversions' - - appversionid = db.Column(db.Integer, primary_key=True) - appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) - version = db.Column(db.String(50), nullable=False) - releasedate = db.Column(db.Date) - notes = db.Column(db.String(255)) - dateadded = db.Column(db.DateTime, default=db.func.now()) - isactive = db.Column(db.Boolean, default=True) - - # Relationships - application = db.relationship('Application', back_populates='versions') - installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic') - - # Unique constraint on app + version - __table_args__ = ( - db.UniqueConstraint('appid', 'version', name='uq_app_version'), - ) - - def to_dict(self): - """Convert to dictionary.""" - return { - 'appversionid': self.appversionid, - 'appid': self.appid, - 'version': self.version, - 'releasedate': self.releasedate.isoformat() if self.releasedate else None, - 'notes': self.notes, - 'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None, - 'isactive': self.isactive - } - - def __repr__(self): - return f"" - - -class InstalledApp(db.Model): - """Junction table for applications installed on machines (PCs).""" - __tablename__ = 'installedapps' - - id = db.Column(db.Integer, primary_key=True) - machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False) - appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) - appversionid = db.Column(db.Integer, db.ForeignKey('appversions.appversionid')) - isactive = db.Column(db.Boolean, default=True, nullable=False) - installeddate = db.Column(db.DateTime, default=db.func.now()) - - # Relationships - machine = db.relationship('Machine', back_populates='installedapps') - application = db.relationship('Application', back_populates='installed_on') - appversion = db.relationship('AppVersion', back_populates='installations') - - # Unique constraint - one app per machine (can have different versions over time) - __table_args__ = ( - db.UniqueConstraint('machineid', 'appid', name='uq_machine_app'), - ) - - def to_dict(self): - """Convert to dictionary.""" - return { - 'id': self.id, - 'machineid': self.machineid, - 'appid': self.appid, - 'appversionid': self.appversionid, - 'isactive': self.isactive, - 'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None, - 'application': { - 'appid': self.application.appid, - 'appname': self.application.appname, - 'appdescription': self.application.appdescription, - } if self.application else None, - 'version': self.appversion.version if self.appversion else None - } - - def __repr__(self): - return f"" +"""Application tracking models.""" + +from shopdb.extensions import db +from .base import BaseModel + + +class AppOwner(BaseModel): + """Application owner/contact.""" + __tablename__ = 'appowners' + + appownerid = db.Column(db.Integer, primary_key=True) + appowner = db.Column(db.String(100), nullable=False) + sso = db.Column(db.String(50)) + email = db.Column(db.String(100)) + + # Relationships + supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic') + + def __repr__(self): + return f"" + + +class SupportTeam(BaseModel): + """Application support team.""" + __tablename__ = 'supportteams' + + supportteamid = db.Column(db.Integer, primary_key=True) + teamname = db.Column(db.String(100), nullable=False) + teamurl = db.Column(db.String(255)) + appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid')) + + # Relationships + owner = db.relationship('AppOwner', back_populates='supportteams') + applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic') + + def __repr__(self): + return f"" + + +class Application(BaseModel): + """Application catalog.""" + __tablename__ = 'applications' + + appid = db.Column(db.Integer, primary_key=True) + appname = db.Column(db.String(100), unique=True, nullable=False) + appdescription = db.Column(db.String(255)) + supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid')) + isinstallable = db.Column(db.Boolean, default=False) + applicationnotes = db.Column(db.Text) + installpath = db.Column(db.String(255)) + applicationlink = db.Column(db.String(512)) + documentationpath = db.Column(db.String(512)) + ishidden = db.Column(db.Boolean, default=False) + isprinter = db.Column(db.Boolean, default=False) + islicenced = db.Column(db.Boolean, default=False) + image = db.Column(db.String(255)) + + # Relationships + supportteam = db.relationship('SupportTeam', back_populates='applications') + versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic') + + def __repr__(self): + return f"" + + +class AppVersion(db.Model): + """Application version tracking.""" + __tablename__ = 'appversions' + + appversionid = db.Column(db.Integer, primary_key=True) + appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False) + version = db.Column(db.String(50), nullable=False) + releasedate = db.Column(db.Date) + notes = db.Column(db.String(255)) + dateadded = db.Column(db.DateTime, default=db.func.now()) + isactive = db.Column(db.Boolean, default=True) + + # Relationships + application = db.relationship('Application', back_populates='versions') + + # Unique constraint on app + version + __table_args__ = ( + db.UniqueConstraint('appid', 'version', name='uq_app_version'), + ) + + def to_dict(self): + """Convert to dictionary.""" + return { + 'appversionid': self.appversionid, + 'appid': self.appid, + 'version': self.version, + 'releasedate': self.releasedate.isoformat() if self.releasedate else None, + 'notes': self.notes, + 'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None, + 'isactive': self.isactive + } + + def __repr__(self): + return f"" diff --git a/shopdb/core/models/communication.py b/shopdb/core/models/communication.py index bf95915..14b3d0c 100644 --- a/shopdb/core/models/communication.py +++ b/shopdb/core/models/communication.py @@ -36,14 +36,6 @@ class Communication(BaseModel): comment='FK to assets table (new architecture)' ) - # Legacy machine FK (for backward compatibility during migration) - machineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid'), - nullable=True, - comment='DEPRECATED: FK to machines table - use assetid instead' - ) - comtypeid = db.Column( db.Integer, db.ForeignKey('communicationtypes.comtypeid'), @@ -95,9 +87,8 @@ class Communication(BaseModel): __table_args__ = ( db.Index('idx_comm_asset', 'assetid'), - db.Index('idx_comm_machine', 'machineid'), db.Index('idx_comm_ip', 'ipaddress'), ) def __repr__(self): - return f"" + return f"" diff --git a/shopdb/core/models/machine.py b/shopdb/core/models/machine.py index f49c306..46a2ce2 100644 --- a/shopdb/core/models/machine.py +++ b/shopdb/core/models/machine.py @@ -1,7 +1,12 @@ -"""Unified Machine model - combines equipment and PCs.""" +"""Legacy machine type lookup. + +The Machine instance model and its PC/status lookups were retired (ADR-001); +assets are the platform contract. MachineType is kept only because the shared +`models` table still references it via models.machinetypeid. +""" from shopdb.extensions import db -from .base import BaseModel, SoftDeleteMixin, AuditMixin +from .base import BaseModel class MachineType(BaseModel): @@ -24,229 +29,3 @@ class MachineType(BaseModel): def __repr__(self): return f"" - - -class MachineStatus(BaseModel): - """Machine status options.""" - __tablename__ = 'machinestatuses' - - statusid = db.Column(db.Integer, primary_key=True) - status = db.Column(db.String(50), unique=True, nullable=False) - description = db.Column(db.Text) - color = db.Column(db.String(20), comment='CSS color for UI') - - def __repr__(self): - return f"" - - -class PCType(BaseModel): - """ - PC type classification for more specific PC categorization. - Examples: Shopfloor PC, Engineer Workstation, CMM PC, etc. - """ - __tablename__ = 'pctypes' - - pctypeid = db.Column(db.Integer, primary_key=True) - pctype = db.Column(db.String(100), unique=True, nullable=False) - description = db.Column(db.Text) - - def __repr__(self): - return f"" - - -class Machine(BaseModel, SoftDeleteMixin, AuditMixin): - """ - Unified machine model for all asset types. - - Machine types can be: - - CNC machines, CMMs, EDMs, etc. (manufacturing equipment) - - PCs (shopfloor PCs, engineer workstations, etc.) - - Network devices (servers, switches, etc.) - if network_devices plugin not used - - The machinetype.category field distinguishes between types. - """ - __tablename__ = 'machines' - - machineid = db.Column(db.Integer, primary_key=True) - - # Identification - machinenumber = db.Column( - db.String(50), - unique=True, - nullable=False, - index=True, - comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)' - ) - alias = db.Column( - db.String(100), - comment='Friendly name' - ) - hostname = db.Column( - db.String(100), - index=True, - comment='Network hostname (for PCs)' - ) - serialnumber = db.Column( - db.String(100), - index=True, - comment='Hardware serial number' - ) - - # Classification - machinetypeid = db.Column( - db.Integer, - db.ForeignKey('machinetypes.machinetypeid'), - nullable=False - ) - pctypeid = db.Column( - db.Integer, - db.ForeignKey('pctypes.pctypeid'), - nullable=True, - comment='Set for PCs, NULL for equipment' - ) - businessunitid = db.Column( - db.Integer, - db.ForeignKey('businessunits.businessunitid'), - nullable=True - ) - modelnumberid = db.Column( - db.Integer, - db.ForeignKey('models.modelnumberid'), - nullable=True - ) - vendorid = db.Column( - db.Integer, - db.ForeignKey('vendors.vendorid'), - nullable=True - ) - - # Status - statusid = db.Column( - db.Integer, - db.ForeignKey('machinestatuses.statusid'), - default=1, - comment='In Use, Spare, Retired, etc.' - ) - - # Location and mapping - locationid = db.Column( - db.Integer, - db.ForeignKey('locations.locationid'), - nullable=True - ) - mapleft = db.Column(db.Integer, comment='X coordinate on floor map') - maptop = db.Column(db.Integer, comment='Y coordinate on floor map') - islocationonly = db.Column( - db.Boolean, - default=False, - comment='Virtual location marker (not actual machine)' - ) - - # PC-specific fields (nullable for non-PC machines) - osid = db.Column( - db.Integer, - db.ForeignKey('operatingsystems.osid'), - nullable=True - ) - loggedinuser = db.Column(db.String(100), nullable=True) - lastreporteddate = db.Column(db.DateTime, nullable=True) - lastboottime = db.Column(db.DateTime, nullable=True) - - # Features/flags - isvnc = db.Column(db.Boolean, default=False, comment='VNC remote access enabled') - iswinrm = db.Column(db.Boolean, default=False, comment='WinRM enabled') - isshopfloor = db.Column(db.Boolean, default=False, comment='Shopfloor PC') - requiresmanualconfig = db.Column( - db.Boolean, - default=False, - comment='Multi-PC machine needs manual configuration' - ) - - # Notes - notes = db.Column(db.Text, nullable=True) - - # Relationships - machinetype = db.relationship('MachineType', backref='machines') - pctype = db.relationship('PCType', backref='machines') - businessunit = db.relationship('BusinessUnit', backref='machines') - model = db.relationship('Model', backref='machines') - vendor = db.relationship('Vendor', backref='machines') - status = db.relationship('MachineStatus', backref='machines') - location = db.relationship('Location', backref='machines') - operatingsystem = db.relationship('OperatingSystem', backref='machines') - - # Communications (one-to-many) - communications = db.relationship( - 'Communication', - backref='machine', - cascade='all, delete-orphan', - lazy='dynamic' - ) - - # Installed applications (for PCs) - installedapps = db.relationship( - 'InstalledApp', - back_populates='machine', - cascade='all, delete-orphan', - lazy='dynamic' - ) - - # Indexes - __table_args__ = ( - db.Index('idx_machine_type_bu', 'machinetypeid', 'businessunitid'), - db.Index('idx_machine_location', 'locationid'), - db.Index('idx_machine_active', 'isactive'), - db.Index('idx_machine_hostname', 'hostname'), - ) - - def __repr__(self): - return f"" - - @property - def display_name(self): - """Get display name (alias if set, otherwise machinenumber).""" - return self.alias or self.machinenumber - - @property - def derived_machinetype(self): - """Get machinetype from model (single source of truth).""" - if self.model and self.model.machinetype: - return self.model.machinetype - return None - - @property - def is_pc(self): - """Check if this machine is a PC type.""" - mt = self.derived_machinetype - return mt.category == 'PC' if mt else False - - @property - def is_equipment(self): - """Check if this machine is equipment.""" - mt = self.derived_machinetype - return mt.category == 'Equipment' if mt else False - - @property - def is_network_device(self): - """Check if this machine is a network device.""" - mt = self.derived_machinetype - return mt.category == 'Network' if mt else False - - @property - def is_printer(self): - """Check if this machine is a printer.""" - mt = self.derived_machinetype - return mt.category == 'Printer' if mt else False - - @property - def primary_ip(self): - """Get primary IP address from communications.""" - comm = self.communications.filter_by( - isprimary=True, - comtypeid=1 # IP type - ).first() - if comm: - return comm.ipaddress - # Fall back to any IP - comm = self.communications.filter_by(comtypeid=1).first() - return comm.ipaddress if comm else None diff --git a/shopdb/core/models/relationship.py b/shopdb/core/models/relationship.py index 0c22c2d..3c199f8 100644 --- a/shopdb/core/models/relationship.py +++ b/shopdb/core/models/relationship.py @@ -115,59 +115,3 @@ class AssetRelationship(BaseModel): def __repr__(self): return f" {self.targetassetid}>" - - -class MachineRelationship(BaseModel): - """ - Relationships between machines. - - Examples: - - PC controls CNC machine - - Two CNCs are dualpath partners - """ - __tablename__ = 'machinerelationships' - - relationshipid = db.Column(db.Integer, primary_key=True) - - parentmachineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid'), - nullable=False - ) - childmachineid = db.Column( - db.Integer, - db.ForeignKey('machines.machineid'), - nullable=False - ) - relationshiptypeid = db.Column( - db.Integer, - db.ForeignKey('relationshiptypes.relationshiptypeid'), - nullable=False - ) - - notes = db.Column(db.Text) - - # Relationships - parent_machine = db.relationship( - 'Machine', - foreign_keys=[parentmachineid], - backref='child_relationships' - ) - child_machine = db.relationship( - 'Machine', - foreign_keys=[childmachineid], - backref='parent_relationships' - ) - relationship_type = db.relationship('RelationshipType', backref='relationships') - - __table_args__ = ( - db.UniqueConstraint( - 'parentmachineid', - 'childmachineid', - 'relationshiptypeid', - name='uq_machine_relationship' - ), - ) - - def __repr__(self): - return f" {self.childmachineid}>" diff --git a/shopdb/plugins/alembic_template.py b/shopdb/plugins/alembic_template.py index 2ff1214..a371736 100644 --- a/shopdb/plugins/alembic_template.py +++ b/shopdb/plugins/alembic_template.py @@ -35,7 +35,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = { 'equipment': ('equipmenttypes', 'equipment'), 'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'), 'notifications': ('notificationtypes', 'notifications'), - 'printers': ('printertypes', 'printers', 'printerdata', 'modelsupplies'), + 'printers': ('printertypes', 'printers', 'modelsupplies'), 'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'), }