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:
@@ -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 = {}) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -19,12 +19,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
|
||||
<td>{{ pt.pctype }}</td>
|
||||
<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>
|
||||
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="pcTypes.length === 0">
|
||||
@@ -35,15 +34,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -56,8 +46,8 @@
|
||||
<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 />
|
||||
<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>
|
||||
@@ -74,52 +64,30 @@
|
||||
</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'
|
||||
import { computersApi } from '../../api'
|
||||
|
||||
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: '' })
|
||||
const form = ref({ computertype: '', description: '' })
|
||||
|
||||
onMounted(() => loadData())
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value })
|
||||
const response = await computersApi.types.list({ perpage: 100 })
|
||||
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 {
|
||||
@@ -127,17 +95,11 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
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: '' }
|
||||
form.value = item
|
||||
? { computertype: item.computertype || '', description: item.description || '' }
|
||||
: { computertype: '', description: '' }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -149,9 +111,9 @@ async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await pctypesApi.update(editing.value.pctypeid, form.value)
|
||||
await computersApi.types.update(editing.value.computertypeid, form.value)
|
||||
} else {
|
||||
await pctypesApi.create(form.value)
|
||||
await computersApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
@@ -161,17 +123,4 @@ async function save() {
|
||||
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>
|
||||
|
||||
47
migrations/versions/7c01_drop_legacy_machine.py
Normal file
47
migrations/versions/7c01_drop_legacy_machine.py
Normal 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")
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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:
|
||||
|
||||
@@ -79,10 +79,7 @@ def create_app(config_name: str = None) -> Flask:
|
||||
CORE_BLUEPRINT_NAMES = (
|
||||
'auth',
|
||||
'assets',
|
||||
'machines',
|
||||
'machinetypes',
|
||||
'pctypes',
|
||||
'statuses',
|
||||
'vendors',
|
||||
'models',
|
||||
'businessunits',
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -58,7 +58,6 @@ class Application(BaseModel):
|
||||
# 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}>"
|
||||
@@ -78,7 +77,6 @@ class AppVersion(db.Model):
|
||||
|
||||
# Relationships
|
||||
application = db.relationship('Application', back_populates='versions')
|
||||
installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic')
|
||||
|
||||
# Unique constraint on app + version
|
||||
__table_args__ = (
|
||||
@@ -99,45 +97,3 @@ class AppVersion(db.Model):
|
||||
|
||||
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}>"
|
||||
|
||||
@@ -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'}>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user