Retire the legacy Machine model (ADR-001 cutover)

The asset/computer model is now the single source of truth. Remove the Machine
instance layer end to end:

- Delete models Machine, MachineStatus, PCType, MachineRelationship,
  InstalledApp, PrinterData; keep MachineType (models.machinetypeid still
  references it).
- Delete the /api/machines, /api/statuses, /api/pctypes blueprints and the
  legacy /api/printers/legacy (PrinterData) blueprint.
- Drop the deprecated communications.machineid column and its FK.
- Migration 7c01 drops tables machines, machinestatuses, pctypes,
  machinerelationships, installedapps, printerdata (idempotent).
- Fix remaining readers (applications install counts) to ComputerInstalledApp.
- Frontend: remove dead machinesApi/statusesApi/pctypesApi wrappers; repoint
  the PC Types settings page at computer types.

143 tests pass; all asset/computer/dashboard/report/collector endpoints 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 09:59:17 -04:00
parent cb47b9087e
commit b0a0670fcd
23 changed files with 344 additions and 2047 deletions

View File

@@ -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 = {}) {

View File

@@ -66,10 +66,10 @@
<option value="">Select type...</option>
<option
v-for="pt in pcTypes"
:key="pt.machinetypeid"
:value="pt.machinetypeid"
:key="pt.computertypeid"
:value="pt.computertypeid"
>
{{ pt.machinetype }}
{{ pt.computertype }}
</option>
</select>
</div>
@@ -268,7 +268,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
@@ -316,17 +316,10 @@ const operatingsystems = ref([])
// Filter models by selected vendor and PC type
const filteredModels = computed(() => {
return models.value.filter(m => {
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// only exclude models that have a type set and it differs; most models
// have no machinetypeid, so a strict check hides the PC's own model
if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false
}
return true
})
// filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid)
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
onMounted(async () => {
@@ -334,7 +327,7 @@ onMounted(async () => {
// Load reference data
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
machinetypesApi.list({ category: 'PC', perpage: 100 }),
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
@@ -349,28 +342,28 @@ onMounted(async () => {
locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || []
// Load PC if editing
// Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) {
const response = await machinesApi.get(route.params.id)
const response = await computersApi.get(route.params.id)
const pc = response.data.data
const ext = pc.computer || {}
// Get IP from communications
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
form.value = {
machinenumber: pc.machinenumber || '',
alias: pc.alias || '',
hostname: pc.hostname || '',
machinenumber: pc.assetnumber || '',
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
hostname: ext.hostname || '',
serialnumber: pc.serialnumber || '',
machinetypeid: pc.machinetype?.machinetypeid || '',
statusid: pc.status?.statusid || '',
vendorid: pc.vendor?.vendorid || '',
modelnumberid: pc.model?.modelnumberid || '',
locationid: pc.location?.locationid || '',
osid: pc.operatingsystem?.osid || '',
loggedinuser: pc.loggedinuser || '',
isvnc: pc.isvnc || false,
iswinrm: pc.iswinrm || false,
machinetypeid: ext.computertypeid || '',
statusid: pc.statusid || '',
vendorid: ext.vendorid || '',
modelnumberid: ext.modelnumberid || '',
locationid: pc.locationid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
isvnc: ext.isvnc || false,
iswinrm: ext.iswinrm || false,
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
@@ -408,40 +401,35 @@ async function savePC() {
saving.value = true
try {
const machineData = {
machinenumber: form.value.machinenumber,
alias: form.value.alias,
hostname: form.value.hostname,
serialnumber: form.value.serialnumber,
machinetypeid: form.value.machinetypeid || null,
// One payload for the computers plugin (asset core + computer extension +
// primary IP). "PC Number" is the business identifier (assetnumber).
const payload = {
assetnumber: form.value.machinenumber,
hostname: form.value.hostname || null,
serialnumber: form.value.serialnumber || null,
computertypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser,
loggedinuser: form.value.loggedinuser || null,
isvnc: form.value.isvnc,
iswinrm: form.value.iswinrm,
notes: form.value.notes,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
mapy: form.value.mapy
}
let machineId
if (isEdit.value) {
await machinesApi.update(route.params.id, machineData)
machineId = route.params.id
} else {
const response = await machinesApi.create(machineData)
machineId = response.data.data.machineid
// only set display name when an alias is given, so we don't clobber it
if (form.value.alias) {
payload.name = form.value.alias
}
// Handle IP address - update communication record
if (form.value.ipaddress) {
await machinesApi.updateCommunication(machineId, {
ipaddress: form.value.ipaddress,
isprimary: true
})
if (isEdit.value) {
await computersApi.update(route.params.id, payload)
} else {
await computersApi.create(payload)
}
router.push('/pcs')

View File

@@ -1,177 +1,126 @@
<template>
<div>
<div class="page-header">
<h2>PC Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
<td>{{ pt.pctype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="pctype">PC Type *</label>
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete PC Type</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.pctype }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { pctypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const pcTypes = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ pctype: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value })
pcTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading PC types:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await pctypesApi.update(editing.value.pctypeid, form.value)
} else {
await pctypesApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await pctypesApi.delete(toDelete.value.pctypeid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>
<template>
<div>
<div class="page-header">
<h2>PC Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
<td>{{ pt.computertype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="computertype">PC Type *</label>
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
const pcTypes = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ computertype: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 100 })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { computertype: item.computertype || '', description: item.description || '' }
: { computertype: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await computersApi.types.update(editing.value.computertypeid, form.value)
} else {
await computersApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
</script>

View File

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

View File

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

View File

@@ -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('/<int:machine_id>', 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('/<int:machine_id>/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('/<int:machine_id>/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('/<int:machine_id>/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
})

View File

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

View File

@@ -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"<PrinterData machineid={self.machineid}>"

View File

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

View File

@@ -79,10 +79,7 @@ def create_app(config_name: str = None) -> Flask:
CORE_BLUEPRINT_NAMES = (
'auth',
'assets',
'machines',
'machinetypes',
'pctypes',
'statuses',
'vendors',
'models',
'businessunits',

View File

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

View File

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

View File

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

View File

@@ -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('/<int:machine_id>', 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('/<int:machine_id>', 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('/<int:machine_id>', 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('/<int:machine_id>/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('/<int:machine_id>/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('/<int:machine_id>/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('/<int:machine_id>/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/<int:relationship_id>', 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)

View File

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

View File

@@ -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('/<int:type_id>', 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('/<int:type_id>', 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('/<int:type_id>', 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')

View File

@@ -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('/<int:status_id>', 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('/<int:status_id>', 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('/<int:status_id>', 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')

View File

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

View File

@@ -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"<AppOwner {self.appowner}>"
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"<SupportTeam {self.teamname}>"
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"<Application {self.appname}>"
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"<AppVersion {self.application.appname if self.application else self.appid} v{self.version}>"
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"<InstalledApp machine={self.machineid} app={self.appid}>"
"""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"<AppOwner {self.appowner}>"
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"<SupportTeam {self.teamname}>"
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"<Application {self.appname}>"
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"<AppVersion {self.application.appname if self.application else self.appid} v{self.version}>"

View File

@@ -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"<Communication {self.machineid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"
return f"<Communication {self.assetid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"

View File

@@ -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"<MachineType {self.machinetype}>"
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"<MachineStatus {self.status}>"
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"<PCType {self.pctype}>"
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"<Machine {self.machinenumber}>"
@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

View File

@@ -115,59 +115,3 @@ class AssetRelationship(BaseModel):
def __repr__(self):
return f"<AssetRelationship {self.sourceassetid} -> {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"<MachineRelationship {self.parentmachineid} -> {self.childmachineid}>"

View File

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