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)
|
// Equipment API (plugin)
|
||||||
export const equipmentApi = {
|
export const equipmentApi = {
|
||||||
list(params = {}) {
|
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
|
// Vendors API
|
||||||
export const vendorsApi = {
|
export const vendorsApi = {
|
||||||
list(params = {}) {
|
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
|
// Operating Systems API
|
||||||
export const operatingsystemsApi = {
|
export const operatingsystemsApi = {
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
|
|||||||
@@ -66,10 +66,10 @@
|
|||||||
<option value="">Select type...</option>
|
<option value="">Select type...</option>
|
||||||
<option
|
<option
|
||||||
v-for="pt in pcTypes"
|
v-for="pt in pcTypes"
|
||||||
:key="pt.machinetypeid"
|
:key="pt.computertypeid"
|
||||||
:value="pt.machinetypeid"
|
:value="pt.computertypeid"
|
||||||
>
|
>
|
||||||
{{ pt.machinetype }}
|
{{ pt.computertype }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 ShopFloorMap from '../../components/ShopFloorMap.vue'
|
||||||
import Modal from '../../components/Modal.vue'
|
import Modal from '../../components/Modal.vue'
|
||||||
import { currentTheme } from '../../stores/theme'
|
import { currentTheme } from '../../stores/theme'
|
||||||
@@ -316,17 +316,10 @@ const operatingsystems = ref([])
|
|||||||
|
|
||||||
// Filter models by selected vendor and PC type
|
// Filter models by selected vendor and PC type
|
||||||
const filteredModels = computed(() => {
|
const filteredModels = computed(() => {
|
||||||
return models.value.filter(m => {
|
// filter by vendor only (PC type now maps to computertypeid, a different id
|
||||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
// space than a model's machinetypeid)
|
||||||
return false
|
if (!form.value.vendorid) return models.value
|
||||||
}
|
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||||
// 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
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -334,7 +327,7 @@ onMounted(async () => {
|
|||||||
// Load reference data
|
// Load reference data
|
||||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
|
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
|
||||||
machinetypesApi.list({ category: 'PC', perpage: 100 }),
|
computersApi.types.list({ perpage: 100 }),
|
||||||
assetsApi.statuses.list(),
|
assetsApi.statuses.list(),
|
||||||
vendorsApi.list({ perpage: 100 }),
|
vendorsApi.list({ perpage: 100 }),
|
||||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
@@ -349,28 +342,28 @@ onMounted(async () => {
|
|||||||
locations.value = locRes.data.data || []
|
locations.value = locRes.data.data || []
|
||||||
operatingsystems.value = osRes.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) {
|
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 pc = response.data.data
|
||||||
|
const ext = pc.computer || {}
|
||||||
|
|
||||||
// Get IP from communications
|
|
||||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||||
|
|
||||||
form.value = {
|
form.value = {
|
||||||
machinenumber: pc.machinenumber || '',
|
machinenumber: pc.assetnumber || '',
|
||||||
alias: pc.alias || '',
|
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||||
hostname: pc.hostname || '',
|
hostname: ext.hostname || '',
|
||||||
serialnumber: pc.serialnumber || '',
|
serialnumber: pc.serialnumber || '',
|
||||||
machinetypeid: pc.machinetype?.machinetypeid || '',
|
machinetypeid: ext.computertypeid || '',
|
||||||
statusid: pc.status?.statusid || '',
|
statusid: pc.statusid || '',
|
||||||
vendorid: pc.vendor?.vendorid || '',
|
vendorid: ext.vendorid || '',
|
||||||
modelnumberid: pc.model?.modelnumberid || '',
|
modelnumberid: ext.modelnumberid || '',
|
||||||
locationid: pc.location?.locationid || '',
|
locationid: pc.locationid || '',
|
||||||
osid: pc.operatingsystem?.osid || '',
|
osid: ext.osid || '',
|
||||||
loggedinuser: pc.loggedinuser || '',
|
loggedinuser: ext.loggedinuser || '',
|
||||||
isvnc: pc.isvnc || false,
|
isvnc: ext.isvnc || false,
|
||||||
iswinrm: pc.iswinrm || false,
|
iswinrm: ext.iswinrm || false,
|
||||||
notes: pc.notes || '',
|
notes: pc.notes || '',
|
||||||
mapx: pc.mapx ?? null,
|
mapx: pc.mapx ?? null,
|
||||||
mapy: pc.mapy ?? null,
|
mapy: pc.mapy ?? null,
|
||||||
@@ -408,40 +401,35 @@ async function savePC() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const machineData = {
|
// One payload for the computers plugin (asset core + computer extension +
|
||||||
machinenumber: form.value.machinenumber,
|
// primary IP). "PC Number" is the business identifier (assetnumber).
|
||||||
alias: form.value.alias,
|
const payload = {
|
||||||
hostname: form.value.hostname,
|
assetnumber: form.value.machinenumber,
|
||||||
serialnumber: form.value.serialnumber,
|
hostname: form.value.hostname || null,
|
||||||
machinetypeid: form.value.machinetypeid || null,
|
serialnumber: form.value.serialnumber || null,
|
||||||
|
computertypeid: form.value.machinetypeid || null,
|
||||||
statusid: form.value.statusid || null,
|
statusid: form.value.statusid || null,
|
||||||
vendorid: form.value.vendorid || null,
|
vendorid: form.value.vendorid || null,
|
||||||
modelnumberid: form.value.modelnumberid || null,
|
modelnumberid: form.value.modelnumberid || null,
|
||||||
locationid: form.value.locationid || null,
|
locationid: form.value.locationid || null,
|
||||||
osid: form.value.osid || null,
|
osid: form.value.osid || null,
|
||||||
loggedinuser: form.value.loggedinuser,
|
loggedinuser: form.value.loggedinuser || null,
|
||||||
isvnc: form.value.isvnc,
|
isvnc: form.value.isvnc,
|
||||||
iswinrm: form.value.iswinrm,
|
iswinrm: form.value.iswinrm,
|
||||||
notes: form.value.notes,
|
notes: form.value.notes || null,
|
||||||
|
ipaddress: form.value.ipaddress || null,
|
||||||
mapx: form.value.mapx,
|
mapx: form.value.mapx,
|
||||||
mapy: form.value.mapy
|
mapy: form.value.mapy
|
||||||
}
|
}
|
||||||
|
// only set display name when an alias is given, so we don't clobber it
|
||||||
let machineId
|
if (form.value.alias) {
|
||||||
if (isEdit.value) {
|
payload.name = form.value.alias
|
||||||
await machinesApi.update(route.params.id, machineData)
|
|
||||||
machineId = route.params.id
|
|
||||||
} else {
|
|
||||||
const response = await machinesApi.create(machineData)
|
|
||||||
machineId = response.data.data.machineid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle IP address - update communication record
|
if (isEdit.value) {
|
||||||
if (form.value.ipaddress) {
|
await computersApi.update(route.params.id, payload)
|
||||||
await machinesApi.updateCommunication(machineId, {
|
} else {
|
||||||
ipaddress: form.value.ipaddress,
|
await computersApi.create(payload)
|
||||||
isprimary: true
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/pcs')
|
router.push('/pcs')
|
||||||
|
|||||||
@@ -1,177 +1,126 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>PC Types</h2>
|
<h2>PC Types</h2>
|
||||||
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>PC Type</th>
|
<th>PC Type</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
|
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
|
||||||
<td>{{ pt.pctype }}</td>
|
<td>{{ pt.computertype }}</td>
|
||||||
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
|
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
|
<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>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
<tr v-if="pcTypes.length === 0">
|
||||||
<tr v-if="pcTypes.length === 0">
|
<td colspan="3" style="text-align: center; color: var(--text-light);">
|
||||||
<td colspan="3" style="text-align: center; color: var(--text-light);">
|
No PC types found
|
||||||
No PC types found
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</div>
|
||||||
<!-- Pagination -->
|
|
||||||
<PaginationBar
|
<!-- Add/Edit Modal -->
|
||||||
:page="page"
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||||
:totalPages="totalPages"
|
<div class="modal">
|
||||||
:perPage="perPage"
|
<div class="modal-header">
|
||||||
@update:page="goToPage"
|
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
||||||
@update:perPage="changePerPage"
|
</div>
|
||||||
/>
|
<form @submit.prevent="save">
|
||||||
</template>
|
<div class="modal-body">
|
||||||
</div>
|
<div class="form-group">
|
||||||
|
<label for="computertype">PC Type *</label>
|
||||||
<!-- Add/Edit Modal -->
|
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
|
||||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
</div>
|
||||||
<div class="modal">
|
<div class="form-group">
|
||||||
<div class="modal-header">
|
<label for="description">Description</label>
|
||||||
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
|
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="save">
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<div class="modal-body">
|
</div>
|
||||||
<div class="form-group">
|
<div class="modal-footer">
|
||||||
<label for="pctype">PC Type *</label>
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||||
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required />
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||||
</div>
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
<div class="form-group">
|
</button>
|
||||||
<label for="description">Description</label>
|
</div>
|
||||||
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
</template>
|
||||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<script setup>
|
||||||
{{ saving ? 'Saving...' : 'Save' }}
|
import { ref, onMounted } from 'vue'
|
||||||
</button>
|
import { computersApi } from '../../api'
|
||||||
</div>
|
|
||||||
</form>
|
const pcTypes = ref([])
|
||||||
</div>
|
const loading = ref(true)
|
||||||
</div>
|
|
||||||
|
const showModal = ref(false)
|
||||||
<!-- Delete Modal -->
|
const editing = ref(null)
|
||||||
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
const saving = ref(false)
|
||||||
<div class="modal">
|
const error = ref('')
|
||||||
<div class="modal-header"><h3>Delete PC Type</h3></div>
|
|
||||||
<div class="modal-body">
|
const form = ref({ computertype: '', description: '' })
|
||||||
<p>Are you sure you want to delete <strong>{{ toDelete?.pctype }}</strong>?</p>
|
|
||||||
</div>
|
onMounted(() => loadData())
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
async function loadData() {
|
||||||
<button class="btn btn-danger" @click="deleteItem">Delete</button>
|
loading.value = true
|
||||||
</div>
|
try {
|
||||||
</div>
|
const response = await computersApi.types.list({ perpage: 100 })
|
||||||
</div>
|
pcTypes.value = response.data.data || []
|
||||||
</div>
|
} catch (err) {
|
||||||
</template>
|
console.error('Error loading PC types:', err)
|
||||||
|
} finally {
|
||||||
<script setup>
|
loading.value = false
|
||||||
import { ref, onMounted } from 'vue'
|
}
|
||||||
import { pctypesApi } from '../../api'
|
}
|
||||||
import PaginationBar from '../../components/PaginationBar.vue'
|
|
||||||
|
function openModal(item = null) {
|
||||||
const pcTypes = ref([])
|
editing.value = item
|
||||||
const loading = ref(true)
|
form.value = item
|
||||||
const page = ref(1)
|
? { computertype: item.computertype || '', description: item.description || '' }
|
||||||
const totalPages = ref(1)
|
: { computertype: '', description: '' }
|
||||||
const perPage = ref(20)
|
error.value = ''
|
||||||
|
showModal.value = true
|
||||||
const showModal = ref(false)
|
}
|
||||||
const editing = ref(null)
|
|
||||||
const saving = ref(false)
|
function closeModal() { showModal.value = false; editing.value = null }
|
||||||
const error = ref('')
|
|
||||||
|
async function save() {
|
||||||
const showDeleteModal = ref(false)
|
error.value = ''
|
||||||
const toDelete = ref(null)
|
saving.value = true
|
||||||
|
try {
|
||||||
const form = ref({ pctype: '', description: '' })
|
if (editing.value) {
|
||||||
|
await computersApi.types.update(editing.value.computertypeid, form.value)
|
||||||
onMounted(() => loadData())
|
} else {
|
||||||
|
await computersApi.types.create(form.value)
|
||||||
async function loadData() {
|
}
|
||||||
loading.value = true
|
closeModal()
|
||||||
try {
|
loadData()
|
||||||
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value })
|
} catch (err) {
|
||||||
pcTypes.value = response.data.data || []
|
error.value = err.response?.data?.message || 'Failed to save'
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
} finally {
|
||||||
} catch (err) {
|
saving.value = false
|
||||||
console.error('Error loading PC types:', err)
|
}
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
</script>
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|||||||
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."""
|
"""Printers plugin API."""
|
||||||
|
|
||||||
from .routes import printers_bp # Legacy Machine-based API
|
from .asset_routes import printers_asset_bp # Asset-based API
|
||||||
from .asset_routes import printers_asset_bp # New Asset-based API
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'printers_bp', # Legacy
|
'printers_asset_bp',
|
||||||
'printers_asset_bp', # New
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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."""
|
"""Printers plugin models."""
|
||||||
|
|
||||||
from .printer_extension import PrinterData # Legacy model for Machine-based architecture
|
from .printer import Printer, PrinterType # Asset-based models
|
||||||
from .printer import Printer, PrinterType # New Asset-based models
|
|
||||||
from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||||
ModelSupply,
|
ModelSupply,
|
||||||
SUPPLY_TYPES,
|
SUPPLY_TYPES,
|
||||||
@@ -10,9 +9,8 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'PrinterData', # Legacy
|
'Printer',
|
||||||
'Printer', # New
|
'PrinterType',
|
||||||
'PrinterType', # New
|
|
||||||
'ModelSupply',
|
'ModelSupply',
|
||||||
'SUPPLY_TYPES',
|
'SUPPLY_TYPES',
|
||||||
'SUPPLY_COLORS',
|
'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.machine import MachineType
|
||||||
from shopdb.core.models import AssetType
|
from shopdb.core.models import AssetType
|
||||||
|
|
||||||
from .models import PrinterData, Printer, PrinterType, ModelSupply
|
from .models import Printer, PrinterType, ModelSupply
|
||||||
from .api import printers_bp, printers_asset_bp
|
from .api import printers_asset_bp
|
||||||
from .services import ZabbixService
|
from .services import ZabbixService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -74,9 +74,8 @@ class PrintersPlugin(BasePlugin):
|
|||||||
def get_models(self) -> List[Type]:
|
def get_models(self) -> List[Type]:
|
||||||
"""Return list of SQLAlchemy model classes."""
|
"""Return list of SQLAlchemy model classes."""
|
||||||
return [
|
return [
|
||||||
PrinterData, # Legacy Machine-based
|
Printer, # Asset-based
|
||||||
Printer, # New Asset-based
|
PrinterType, # printer type classification
|
||||||
PrinterType, # New printer type classification
|
|
||||||
ModelSupply, # model -> toner/drum/waste part numbers
|
ModelSupply, # model -> toner/drum/waste part numbers
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -98,9 +97,6 @@ class PrintersPlugin(BasePlugin):
|
|||||||
app.config.setdefault('ZABBIX_URL', '')
|
app.config.setdefault('ZABBIX_URL', '')
|
||||||
app.config.setdefault('ZABBIX_TOKEN', '')
|
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})")
|
logger.info(f"Printers plugin initialized (v{self.meta.version})")
|
||||||
|
|
||||||
def on_install(self, app: Flask) -> None:
|
def on_install(self, app: Flask) -> None:
|
||||||
|
|||||||
@@ -79,10 +79,7 @@ def create_app(config_name: str = None) -> Flask:
|
|||||||
CORE_BLUEPRINT_NAMES = (
|
CORE_BLUEPRINT_NAMES = (
|
||||||
'auth',
|
'auth',
|
||||||
'assets',
|
'assets',
|
||||||
'machines',
|
|
||||||
'machinetypes',
|
'machinetypes',
|
||||||
'pctypes',
|
|
||||||
'statuses',
|
|
||||||
'vendors',
|
'vendors',
|
||||||
'models',
|
'models',
|
||||||
'businessunits',
|
'businessunits',
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def seed_cli():
|
|||||||
def seed_reference_data():
|
def seed_reference_data():
|
||||||
"""Seed reference data (machine types, statuses, etc.)."""
|
"""Seed reference data (machine types, statuses, etc.)."""
|
||||||
from shopdb.extensions import db
|
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
|
from shopdb.core.models.relationship import RelationshipType
|
||||||
|
|
||||||
# Machine types
|
# Machine types
|
||||||
@@ -67,21 +67,6 @@ def seed_reference_data():
|
|||||||
mt = MachineType(**mt_data)
|
mt = MachineType(**mt_data)
|
||||||
db.session.add(mt)
|
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 (canonical set - the asset model is the contract)
|
||||||
asset_statuses = [
|
asset_statuses = [
|
||||||
{'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'},
|
{'status': 'In Use', 'description': 'Currently in use', 'color': '#28a745'},
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
from .auth import auth_bp
|
from .auth import auth_bp
|
||||||
from .assets import assets_bp
|
from .assets import assets_bp
|
||||||
from .machines import machines_bp
|
|
||||||
from .machinetypes import machinetypes_bp
|
from .machinetypes import machinetypes_bp
|
||||||
from .pctypes import pctypes_bp
|
|
||||||
from .statuses import statuses_bp
|
|
||||||
from .vendors import vendors_bp
|
from .vendors import vendors_bp
|
||||||
from .models import models_bp
|
from .models import models_bp
|
||||||
from .businessunits import businessunits_bp
|
from .businessunits import businessunits_bp
|
||||||
@@ -26,10 +23,7 @@ from .users import users_bp
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
'auth_bp',
|
'auth_bp',
|
||||||
'assets_bp',
|
'assets_bp',
|
||||||
'machines_bp',
|
|
||||||
'machinetypes_bp',
|
'machinetypes_bp',
|
||||||
'pctypes_bp',
|
|
||||||
'statuses_bp',
|
|
||||||
'vendors_bp',
|
'vendors_bp',
|
||||||
'models_bp',
|
'models_bp',
|
||||||
'businessunits_bp',
|
'businessunits_bp',
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ def list_applications():
|
|||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
app_dict['supportteam'] = None
|
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)
|
data.append(app_dict)
|
||||||
|
|
||||||
return paginated_response(data, page, per_page, total)
|
return paginated_response(data, page, per_page, total)
|
||||||
@@ -96,7 +97,8 @@ def get_application(app_id: int):
|
|||||||
else:
|
else:
|
||||||
data['supportteam'] = None
|
data['supportteam'] = None
|
||||||
data['versions'] = [v.to_dict() for v in app.versions.filter_by(isactive=True).order_by(AppVersion.version.desc()).all()]
|
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)
|
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
|
http_code=404
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if any machines use this type
|
# Check if any model uses this type
|
||||||
from shopdb.core.models import Machine
|
from shopdb.core.models import Model
|
||||||
if Machine.query.filter_by(machinetypeid=type_id, isactive=True).first():
|
if Model.query.filter_by(machinetypeid=type_id).first():
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.CONFLICT,
|
ErrorCodes.CONFLICT,
|
||||||
'Cannot delete machine type: machines are using it',
|
'Cannot delete machine type: models are using it',
|
||||||
http_code=409
|
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 .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||||
from .asset import Asset, AssetType, AssetStatus
|
from .asset import Asset, AssetType, AssetStatus
|
||||||
from .machine import Machine, MachineType, MachineStatus, PCType
|
from .machine import MachineType
|
||||||
from .vendor import Vendor
|
from .vendor import Vendor
|
||||||
from .model import Model
|
from .model import Model
|
||||||
from .businessunit import BusinessUnit
|
from .businessunit import BusinessUnit
|
||||||
from .location import Location
|
from .location import Location
|
||||||
from .operatingsystem import OperatingSystem
|
from .operatingsystem import OperatingSystem
|
||||||
from .relationship import MachineRelationship, AssetRelationship, RelationshipType
|
from .relationship import AssetRelationship, RelationshipType
|
||||||
from .communication import Communication, CommunicationType
|
from .communication import Communication, CommunicationType
|
||||||
from .user import User, Role, Permission
|
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 .knowledgebase import KnowledgeBase
|
||||||
from .setting import Setting
|
from .setting import Setting
|
||||||
from .auditlog import AuditLog
|
from .auditlog import AuditLog
|
||||||
@@ -25,11 +25,8 @@ __all__ = [
|
|||||||
'Asset',
|
'Asset',
|
||||||
'AssetType',
|
'AssetType',
|
||||||
'AssetStatus',
|
'AssetStatus',
|
||||||
# Machine (legacy)
|
# Legacy machine type lookup (still referenced by models.machinetypeid)
|
||||||
'Machine',
|
|
||||||
'MachineType',
|
'MachineType',
|
||||||
'MachineStatus',
|
|
||||||
'PCType',
|
|
||||||
# Reference
|
# Reference
|
||||||
'Vendor',
|
'Vendor',
|
||||||
'Model',
|
'Model',
|
||||||
@@ -37,7 +34,6 @@ __all__ = [
|
|||||||
'Location',
|
'Location',
|
||||||
'OperatingSystem',
|
'OperatingSystem',
|
||||||
# Relationships
|
# Relationships
|
||||||
'MachineRelationship',
|
|
||||||
'AssetRelationship',
|
'AssetRelationship',
|
||||||
'RelationshipType',
|
'RelationshipType',
|
||||||
# Communication
|
# Communication
|
||||||
@@ -52,7 +48,6 @@ __all__ = [
|
|||||||
'AppVersion',
|
'AppVersion',
|
||||||
'AppOwner',
|
'AppOwner',
|
||||||
'SupportTeam',
|
'SupportTeam',
|
||||||
'InstalledApp',
|
|
||||||
# Knowledge Base
|
# Knowledge Base
|
||||||
'KnowledgeBase',
|
'KnowledgeBase',
|
||||||
# Settings
|
# Settings
|
||||||
|
|||||||
@@ -1,143 +1,99 @@
|
|||||||
"""Application tracking models."""
|
"""Application tracking models."""
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from .base import BaseModel
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class AppOwner(BaseModel):
|
class AppOwner(BaseModel):
|
||||||
"""Application owner/contact."""
|
"""Application owner/contact."""
|
||||||
__tablename__ = 'appowners'
|
__tablename__ = 'appowners'
|
||||||
|
|
||||||
appownerid = db.Column(db.Integer, primary_key=True)
|
appownerid = db.Column(db.Integer, primary_key=True)
|
||||||
appowner = db.Column(db.String(100), nullable=False)
|
appowner = db.Column(db.String(100), nullable=False)
|
||||||
sso = db.Column(db.String(50))
|
sso = db.Column(db.String(50))
|
||||||
email = db.Column(db.String(100))
|
email = db.Column(db.String(100))
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
|
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<AppOwner {self.appowner}>"
|
return f"<AppOwner {self.appowner}>"
|
||||||
|
|
||||||
|
|
||||||
class SupportTeam(BaseModel):
|
class SupportTeam(BaseModel):
|
||||||
"""Application support team."""
|
"""Application support team."""
|
||||||
__tablename__ = 'supportteams'
|
__tablename__ = 'supportteams'
|
||||||
|
|
||||||
supportteamid = db.Column(db.Integer, primary_key=True)
|
supportteamid = db.Column(db.Integer, primary_key=True)
|
||||||
teamname = db.Column(db.String(100), nullable=False)
|
teamname = db.Column(db.String(100), nullable=False)
|
||||||
teamurl = db.Column(db.String(255))
|
teamurl = db.Column(db.String(255))
|
||||||
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
|
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
owner = db.relationship('AppOwner', back_populates='supportteams')
|
owner = db.relationship('AppOwner', back_populates='supportteams')
|
||||||
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
|
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<SupportTeam {self.teamname}>"
|
return f"<SupportTeam {self.teamname}>"
|
||||||
|
|
||||||
|
|
||||||
class Application(BaseModel):
|
class Application(BaseModel):
|
||||||
"""Application catalog."""
|
"""Application catalog."""
|
||||||
__tablename__ = 'applications'
|
__tablename__ = 'applications'
|
||||||
|
|
||||||
appid = db.Column(db.Integer, primary_key=True)
|
appid = db.Column(db.Integer, primary_key=True)
|
||||||
appname = db.Column(db.String(100), unique=True, nullable=False)
|
appname = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
appdescription = db.Column(db.String(255))
|
appdescription = db.Column(db.String(255))
|
||||||
supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid'))
|
supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid'))
|
||||||
isinstallable = db.Column(db.Boolean, default=False)
|
isinstallable = db.Column(db.Boolean, default=False)
|
||||||
applicationnotes = db.Column(db.Text)
|
applicationnotes = db.Column(db.Text)
|
||||||
installpath = db.Column(db.String(255))
|
installpath = db.Column(db.String(255))
|
||||||
applicationlink = db.Column(db.String(512))
|
applicationlink = db.Column(db.String(512))
|
||||||
documentationpath = db.Column(db.String(512))
|
documentationpath = db.Column(db.String(512))
|
||||||
ishidden = db.Column(db.Boolean, default=False)
|
ishidden = db.Column(db.Boolean, default=False)
|
||||||
isprinter = db.Column(db.Boolean, default=False)
|
isprinter = db.Column(db.Boolean, default=False)
|
||||||
islicenced = db.Column(db.Boolean, default=False)
|
islicenced = db.Column(db.Boolean, default=False)
|
||||||
image = db.Column(db.String(255))
|
image = db.Column(db.String(255))
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
supportteam = db.relationship('SupportTeam', back_populates='applications')
|
supportteam = db.relationship('SupportTeam', back_populates='applications')
|
||||||
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
|
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
|
||||||
installed_on = db.relationship('InstalledApp', back_populates='application', lazy='dynamic')
|
|
||||||
|
def __repr__(self):
|
||||||
def __repr__(self):
|
return f"<Application {self.appname}>"
|
||||||
return f"<Application {self.appname}>"
|
|
||||||
|
|
||||||
|
class AppVersion(db.Model):
|
||||||
class AppVersion(db.Model):
|
"""Application version tracking."""
|
||||||
"""Application version tracking."""
|
__tablename__ = 'appversions'
|
||||||
__tablename__ = 'appversions'
|
|
||||||
|
appversionid = db.Column(db.Integer, primary_key=True)
|
||||||
appversionid = db.Column(db.Integer, primary_key=True)
|
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
|
||||||
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
|
version = db.Column(db.String(50), nullable=False)
|
||||||
version = db.Column(db.String(50), nullable=False)
|
releasedate = db.Column(db.Date)
|
||||||
releasedate = db.Column(db.Date)
|
notes = db.Column(db.String(255))
|
||||||
notes = db.Column(db.String(255))
|
dateadded = db.Column(db.DateTime, default=db.func.now())
|
||||||
dateadded = db.Column(db.DateTime, default=db.func.now())
|
isactive = db.Column(db.Boolean, default=True)
|
||||||
isactive = db.Column(db.Boolean, default=True)
|
|
||||||
|
# Relationships
|
||||||
# Relationships
|
application = db.relationship('Application', back_populates='versions')
|
||||||
application = db.relationship('Application', back_populates='versions')
|
|
||||||
installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic')
|
# Unique constraint on app + version
|
||||||
|
__table_args__ = (
|
||||||
# Unique constraint on app + version
|
db.UniqueConstraint('appid', 'version', name='uq_app_version'),
|
||||||
__table_args__ = (
|
)
|
||||||
db.UniqueConstraint('appid', 'version', name='uq_app_version'),
|
|
||||||
)
|
def to_dict(self):
|
||||||
|
"""Convert to dictionary."""
|
||||||
def to_dict(self):
|
return {
|
||||||
"""Convert to dictionary."""
|
'appversionid': self.appversionid,
|
||||||
return {
|
'appid': self.appid,
|
||||||
'appversionid': self.appversionid,
|
'version': self.version,
|
||||||
'appid': self.appid,
|
'releasedate': self.releasedate.isoformat() if self.releasedate else None,
|
||||||
'version': self.version,
|
'notes': self.notes,
|
||||||
'releasedate': self.releasedate.isoformat() if self.releasedate else None,
|
'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None,
|
||||||
'notes': self.notes,
|
'isactive': self.isactive
|
||||||
'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}>"
|
||||||
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)'
|
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(
|
comtypeid = db.Column(
|
||||||
db.Integer,
|
db.Integer,
|
||||||
db.ForeignKey('communicationtypes.comtypeid'),
|
db.ForeignKey('communicationtypes.comtypeid'),
|
||||||
@@ -95,9 +87,8 @@ class Communication(BaseModel):
|
|||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
db.Index('idx_comm_asset', 'assetid'),
|
db.Index('idx_comm_asset', 'assetid'),
|
||||||
db.Index('idx_comm_machine', 'machineid'),
|
|
||||||
db.Index('idx_comm_ip', 'ipaddress'),
|
db.Index('idx_comm_ip', 'ipaddress'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
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 shopdb.extensions import db
|
||||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class MachineType(BaseModel):
|
class MachineType(BaseModel):
|
||||||
@@ -24,229 +29,3 @@ class MachineType(BaseModel):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<MachineType {self.machinetype}>"
|
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):
|
def __repr__(self):
|
||||||
return f"<AssetRelationship {self.sourceassetid} -> {self.targetassetid}>"
|
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'),
|
'equipment': ('equipmenttypes', 'equipment'),
|
||||||
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
||||||
'notifications': ('notificationtypes', 'notifications'),
|
'notifications': ('notificationtypes', 'notifications'),
|
||||||
'printers': ('printertypes', 'printers', 'printerdata', 'modelsupplies'),
|
'printers': ('printertypes', 'printers', 'modelsupplies'),
|
||||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user