Tech Tools section with a code generator, toner report that names the part, toner forecasting, warranty proof of cover, application uploads
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"printedparts",
|
"printedparts",
|
||||||
"printers",
|
"printers",
|
||||||
"slides",
|
"slides",
|
||||||
|
"tools",
|
||||||
"usb",
|
"usb",
|
||||||
"warranty"
|
"warranty"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -306,6 +306,9 @@ export const printersApi = {
|
|||||||
getDrivers(id) {
|
getDrivers(id) {
|
||||||
return api.get(`/printers/${id}/drivers`)
|
return api.get(`/printers/${id}/drivers`)
|
||||||
},
|
},
|
||||||
|
supplyForecast(days = 90) {
|
||||||
|
return api.get('/printers/supplies/forecast', { params: { days } })
|
||||||
|
},
|
||||||
lowSupplies() {
|
lowSupplies() {
|
||||||
return api.get('/printers/lowsupplies')
|
return api.get('/printers/lowsupplies')
|
||||||
},
|
},
|
||||||
@@ -477,6 +480,33 @@ export const applicationsApi = {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return api.delete(`/applications/${id}`)
|
return api.delete(`/applications/${id}`)
|
||||||
},
|
},
|
||||||
|
// multipart image upload; backend sets image to the served URL
|
||||||
|
uploadImage(id, file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
},
|
||||||
|
removeImage(id) {
|
||||||
|
return api.delete(`/applications/${id}/image`)
|
||||||
|
},
|
||||||
|
// installer upload; backend sets installpath to the download URL.
|
||||||
|
// onProgress gets 0-100 - an installer is big enough that a silent wait
|
||||||
|
// reads as a hang.
|
||||||
|
uploadPackage(id, file, onProgress) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post(`/applications/${id}/package`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
onUploadProgress: event => {
|
||||||
|
if (onProgress && event.total) {
|
||||||
|
onProgress(Math.round((event.loaded * 100) / event.total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
removePackage(id) {
|
||||||
|
return api.delete(`/applications/${id}/package`)
|
||||||
|
},
|
||||||
// Versions
|
// Versions
|
||||||
getVersions(appId) {
|
getVersions(appId) {
|
||||||
return api.get(`/applications/${appId}/versions`)
|
return api.get(`/applications/${appId}/versions`)
|
||||||
@@ -1088,6 +1118,15 @@ export const warrantyApi = {
|
|||||||
},
|
},
|
||||||
report() {
|
report() {
|
||||||
return api.get('/warranty/report')
|
return api.get('/warranty/report')
|
||||||
|
},
|
||||||
|
// proof of cover: invoice, certificate, whatever the vendor sent
|
||||||
|
uploadProof(id, file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
|
},
|
||||||
|
removeProof(id) {
|
||||||
|
return api.delete(`/warranty/${id}/proof`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,16 +120,27 @@ defineExpose({ load })
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.dc-grid {
|
.dc-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* Four across on a wide screen, not five. A card holds a hostname, a machine
|
/* Four across on a wide screen. A card holds a hostname, a machine number
|
||||||
number and a state on one line; at five columns those lines truncate on
|
and a state on one line, so the track cannot go much below this without
|
||||||
the very rows that matter most. auto-fit rather than auto-fill so two
|
truncating the rows that matter most - but 28rem only fits three once the
|
||||||
cards fill the width instead of leaving three empty tracks. */
|
sidebar takes its share at 1920, which is the common case here. auto-fit
|
||||||
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
|
rather than auto-fill so two cards fill the width instead of leaving
|
||||||
|
empty tracks. */
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||||
max-width: 120rem;
|
max-width: 120rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.75rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
||||||
|
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
||||||
|
the cards get narrower and the rows they hold start truncating again. */
|
||||||
|
@media (min-width: 96rem) {
|
||||||
|
.dc-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.dc-card {
|
.dc-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
@@ -216,8 +216,10 @@ watch(currentTheme, () => {
|
|||||||
|
|
||||||
.map-preview {
|
.map-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 500px;
|
/* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
|
||||||
height: 385px;
|
covered the row it was launched from. Aspect ratio kept. */
|
||||||
|
width: 390px;
|
||||||
|
height: 300px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg, #f5f5f5);
|
background: var(--bg, #f5f5f5);
|
||||||
}
|
}
|
||||||
|
|||||||
48
frontend/src/utils/fiscalWeek.js
Normal file
48
frontend/src/utils/fiscalWeek.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Fiscal week, as the classic ASP site computed it.
|
||||||
|
*
|
||||||
|
* That site called it "Fiscal Week" and calculated an ISO 8601 week number
|
||||||
|
* (includes/leftsidebar.asp): week 1 is the week containing 4 January, weeks
|
||||||
|
* start on Monday, and the year is decided by that week's Thursday. This is a
|
||||||
|
* faithful port - the label and the arithmetic both come from there.
|
||||||
|
*
|
||||||
|
* Worth knowing if the numbers are ever questioned: a true GE fiscal calendar
|
||||||
|
* need not line up with ISO weeks. Nobody has asked for a different rule, and
|
||||||
|
* changing it here would silently disagree with the classic site people still
|
||||||
|
* compare against, so it stays ISO until someone says otherwise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 8601 week number for a date (1-53).
|
||||||
|
*
|
||||||
|
* Works in local time deliberately: the number people quote is the one on the
|
||||||
|
* wall where they are, and a UTC-based week rolls over hours early in the
|
||||||
|
* evening at a US site.
|
||||||
|
*/
|
||||||
|
export function fiscalWeek(date = new Date()) {
|
||||||
|
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
|
||||||
|
// The Thursday of this week decides which year the week belongs to, which is
|
||||||
|
// what makes late December and early January land in the right year.
|
||||||
|
const dayOfWeek = (day.getDay() + 6) % 7 // 0 = Monday
|
||||||
|
const thursday = new Date(day)
|
||||||
|
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||||
|
|
||||||
|
// Week 1 is the week containing 4 January; find its Monday.
|
||||||
|
const jan4 = new Date(thursday.getFullYear(), 0, 4)
|
||||||
|
const jan4DayOfWeek = (jan4.getDay() + 6) % 7
|
||||||
|
const week1Monday = new Date(jan4)
|
||||||
|
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek)
|
||||||
|
|
||||||
|
const days = Math.round((thursday - week1Monday) / 86400000)
|
||||||
|
return Math.floor(days / 7) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The year the week belongs to, which is not always the calendar year. */
|
||||||
|
export function fiscalWeekYear(date = new Date()) {
|
||||||
|
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
const dayOfWeek = (day.getDay() + 6) % 7
|
||||||
|
const thursday = new Date(day)
|
||||||
|
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||||
|
return thursday.getFullYear()
|
||||||
|
}
|
||||||
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { fiscalWeek, fiscalWeekYear } from './fiscalWeek'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number has to match what the classic ASP site shows, because people will
|
||||||
|
* have both open. The awkward cases are all at the year boundary, which is
|
||||||
|
* exactly where a naive "day of year / 7" gets it wrong.
|
||||||
|
*/
|
||||||
|
describe('fiscalWeek', () => {
|
||||||
|
it('counts the week containing 4 January as week 1', () => {
|
||||||
|
expect(fiscalWeek(new Date(2026, 0, 4))).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts a late-December date in week 1 of the NEXT year', () => {
|
||||||
|
// 2025-12-29 is a Monday; its Thursday falls in 2026, so it is 2026 week 1.
|
||||||
|
expect(fiscalWeek(new Date(2025, 11, 29))).toBe(1)
|
||||||
|
expect(fiscalWeekYear(new Date(2025, 11, 29))).toBe(2026)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts an early-January date in the LAST week of the previous year', () => {
|
||||||
|
// 2027-01-01 is a Friday; its Thursday is 2026-12-31, so it is 2026 week 53.
|
||||||
|
expect(fiscalWeek(new Date(2027, 0, 1))).toBe(53)
|
||||||
|
expect(fiscalWeekYear(new Date(2027, 0, 1))).toBe(2026)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('holds the same number all week, Monday through Sunday', () => {
|
||||||
|
const monday = new Date(2026, 7, 10)
|
||||||
|
const week = fiscalWeek(monday)
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const day = new Date(2026, 7, 10 + i)
|
||||||
|
expect(fiscalWeek(day)).toBe(week)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('increments the following Monday', () => {
|
||||||
|
expect(fiscalWeek(new Date(2026, 7, 17))).toBe(fiscalWeek(new Date(2026, 7, 10)) + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives a 53-week year its 53rd week', () => {
|
||||||
|
// 2026 starts on a Thursday, so it runs to 53 weeks.
|
||||||
|
expect(fiscalWeek(new Date(2026, 11, 31))).toBe(53)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is not fooled by a time of day', () => {
|
||||||
|
const morning = new Date(2026, 7, 12, 6, 0, 0)
|
||||||
|
const nearMidnight = new Date(2026, 7, 12, 23, 59, 0)
|
||||||
|
expect(fiscalWeek(morning)).toBe(fiscalWeek(nearMidnight))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -104,7 +104,7 @@ import ToastHost from '../components/ToastHost.vue'
|
|||||||
import {
|
import {
|
||||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||||
Box, KeyRound, LogOut
|
Box, KeyRound, LogOut, Wrench
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||||
@@ -166,6 +166,7 @@ const iconMap = {
|
|||||||
'shield': ShieldCheck,
|
'shield': ShieldCheck,
|
||||||
'ruler': Ruler,
|
'ruler': Ruler,
|
||||||
'box': Box,
|
'box': Box,
|
||||||
|
'wrench': Wrench,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default navigation (used as fallback if API fails)
|
// Default navigation (used as fallback if API fails)
|
||||||
|
|||||||
@@ -25,3 +25,8 @@ export default [
|
|||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="hero-card">
|
<div class="hero-card">
|
||||||
<div class="hero-image" v-if="app.image">
|
<div class="hero-image" v-if="app.image">
|
||||||
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
<img :src="imageSrc" :alt="app.appname" @error="handleImageError" />
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-image placeholder" v-else>
|
<div class="hero-image placeholder" v-else>
|
||||||
<span class="placeholder-icon">📦</span>
|
<span class="placeholder-icon">📦</span>
|
||||||
@@ -173,6 +173,17 @@ const route = useRoute()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const app = ref(null)
|
const app = ref(null)
|
||||||
|
|
||||||
|
// An uploaded image is stored as a served URL; entries from the classic site
|
||||||
|
// are a bare filename that still lives under the frontend's own directory.
|
||||||
|
// Both have to render, so the shape of the value decides the source.
|
||||||
|
const imageSrc = computed(() => {
|
||||||
|
const value = app.value?.image
|
||||||
|
if (!value) return ''
|
||||||
|
return value.startsWith('/api/') || value.startsWith('http')
|
||||||
|
? value
|
||||||
|
: `/images/applications/${value}`
|
||||||
|
})
|
||||||
const versions = ref([])
|
const versions = ref([])
|
||||||
const installedOn = ref([])
|
const installedOn = ref([])
|
||||||
const contactEmailDomain = ref('')
|
const contactEmailDomain = ref('')
|
||||||
|
|||||||
@@ -111,18 +111,59 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Network path or URL to install files"
|
placeholder="Network path or URL to install files"
|
||||||
/>
|
/>
|
||||||
|
<small class="form-hint">
|
||||||
|
A share path or URL, or upload the installer below and this fills
|
||||||
|
itself in.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="image">Image Filename</label>
|
<label for="packagefile">Installer</label>
|
||||||
|
<div v-if="uploadedPackage" class="upload-current">
|
||||||
|
<span class="upload-name">{{ uploadedPackage }}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" @click="removePackage"
|
||||||
|
:disabled="busyPackage">Remove installer</button>
|
||||||
|
</div>
|
||||||
|
<input id="packagefile" type="file" class="form-control"
|
||||||
|
:accept="PACKAGE_ACCEPT" @change="onPackagePicked" />
|
||||||
|
<div v-if="packageProgress !== null" class="upload-progress">
|
||||||
|
<div class="upload-bar"><span :style="{ width: packageProgress + '%' }"></span></div>
|
||||||
|
<span>{{ packageProgress }}%</span>
|
||||||
|
</div>
|
||||||
|
<small class="form-hint">
|
||||||
|
Up to 500MB. Anything larger belongs on the share - put its path in
|
||||||
|
Install Path instead. Downloads require a login.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="imagefile">Image</label>
|
||||||
|
<div v-if="imagePreview" class="upload-current">
|
||||||
|
<img :src="imagePreview" alt="" class="upload-thumb" />
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" @click="removeImage"
|
||||||
|
:disabled="busyImage">Remove image</button>
|
||||||
|
</div>
|
||||||
|
<input id="imagefile" type="file" class="form-control"
|
||||||
|
accept="image/*" @change="onImagePicked" />
|
||||||
|
<small class="form-hint">
|
||||||
|
PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="image">Image path</label>
|
||||||
<input
|
<input
|
||||||
id="image"
|
id="image"
|
||||||
v-model="form.image"
|
v-model="form.image"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="e.g., myapp.png"
|
placeholder="filled in by the upload above"
|
||||||
/>
|
/>
|
||||||
<small class="form-hint">Image should be placed in /images/applications/</small>
|
<small class="form-hint">
|
||||||
|
Set by the upload. Older entries hold a bare filename served from
|
||||||
|
/images/applications/, and those still work - leave them alone
|
||||||
|
unless you are replacing the image.
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
||||||
@@ -184,6 +225,76 @@ const form = ref({
|
|||||||
|
|
||||||
const supportTeams = ref([])
|
const supportTeams = ref([])
|
||||||
|
|
||||||
|
// Files are chosen before the application exists, so they are held here and
|
||||||
|
// uploaded once there is an id to attach them to (see saveApplication).
|
||||||
|
const pendingImage = ref(null)
|
||||||
|
const pendingPackage = ref(null)
|
||||||
|
const imagePreview = ref('')
|
||||||
|
const uploadedPackage = ref('')
|
||||||
|
const packageProgress = ref(null)
|
||||||
|
const busyImage = ref(false)
|
||||||
|
const busyPackage = ref(false)
|
||||||
|
|
||||||
|
const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf'
|
||||||
|
const MAX_PACKAGE_BYTES = 500 * 1024 * 1024
|
||||||
|
|
||||||
|
function onImagePicked(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
pendingImage.value = file
|
||||||
|
// Show the chosen file immediately rather than after a round trip.
|
||||||
|
imagePreview.value = URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPackagePicked(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (file.size > MAX_PACKAGE_BYTES) {
|
||||||
|
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 500MB. `
|
||||||
|
+ 'Put it on the share and use Install Path instead.'
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
pendingPackage.value = file
|
||||||
|
uploadedPackage.value = file.name
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage() {
|
||||||
|
pendingImage.value = null
|
||||||
|
imagePreview.value = ''
|
||||||
|
form.value.image = ''
|
||||||
|
if (isEdit.value) {
|
||||||
|
busyImage.value = true
|
||||||
|
try { await applicationsApi.removeImage(route.params.id) }
|
||||||
|
catch (err) { error.value = apiError(err, 'Failed to remove the image') }
|
||||||
|
finally { busyImage.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePackage() {
|
||||||
|
pendingPackage.value = null
|
||||||
|
uploadedPackage.value = ''
|
||||||
|
if (isEdit.value) {
|
||||||
|
busyPackage.value = true
|
||||||
|
try {
|
||||||
|
const response = await applicationsApi.removePackage(route.params.id)
|
||||||
|
form.value.installpath = response.data.data.installpath || ''
|
||||||
|
} catch (err) {
|
||||||
|
error.value = apiError(err, 'Failed to remove the installer')
|
||||||
|
} finally { busyPackage.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The image field holds either a served URL (new) or a bare filename from the
|
||||||
|
// classic site, which is still rendered out of /images/applications/.
|
||||||
|
function imageSrcFor(value) {
|
||||||
|
if (!value) return ''
|
||||||
|
return value.startsWith('/api/') || value.startsWith('http')
|
||||||
|
? value
|
||||||
|
: `/images/applications/${value}`
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Load support teams
|
// Load support teams
|
||||||
@@ -210,6 +321,10 @@ onMounted(async () => {
|
|||||||
image: app.image || '',
|
image: app.image || '',
|
||||||
applicationnotes: app.applicationnotes || ''
|
applicationnotes: app.applicationnotes || ''
|
||||||
}
|
}
|
||||||
|
imagePreview.value = imageSrcFor(app.image)
|
||||||
|
if ((app.installpath || '').startsWith('/api/applications/package/')) {
|
||||||
|
uploadedPackage.value = app.installpath.split('/').pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading data:', err)
|
console.error('Error loading data:', err)
|
||||||
@@ -240,16 +355,34 @@ async function saveApplication() {
|
|||||||
applicationnotes: form.value.applicationnotes || null
|
applicationnotes: form.value.applicationnotes || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let appid = route.params.id
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await applicationsApi.update(route.params.id, appData)
|
await applicationsApi.update(appid, appData)
|
||||||
} else {
|
} else {
|
||||||
await applicationsApi.create(appData)
|
const created = await applicationsApi.create(appData)
|
||||||
|
appid = created.data.data.appid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads come after the save: a new application has no id until it exists,
|
||||||
|
// and both endpoints key on it. A failed upload must not lose the record
|
||||||
|
// that saved fine, so it reports and stays on the form.
|
||||||
|
if (pendingImage.value) {
|
||||||
|
await applicationsApi.uploadImage(appid, pendingImage.value)
|
||||||
|
pendingImage.value = null
|
||||||
|
}
|
||||||
|
if (pendingPackage.value) {
|
||||||
|
packageProgress.value = 0
|
||||||
|
await applicationsApi.uploadPackage(appid, pendingPackage.value,
|
||||||
|
percent => { packageProgress.value = percent })
|
||||||
|
pendingPackage.value = null
|
||||||
|
packageProgress.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/applications')
|
router.push('/applications')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving application:', err)
|
console.error('Error saving application:', err)
|
||||||
error.value = apiError(err, 'Failed to save application')
|
error.value = apiError(err, 'Failed to save application')
|
||||||
|
packageProgress.value = null
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -257,6 +390,46 @@ async function saveApplication() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.upload-current {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.upload-thumb {
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.upload-name {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.upload-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.upload-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.upload-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-group {
|
.checkbox-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
|||||||
@@ -19,3 +19,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, plugin: 'backups' }
|
meta: { requiresAuth: true, plugin: 'backups' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -58,3 +58,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -16,3 +16,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -36,3 +36,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -27,3 +27,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -33,3 +33,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, plugin: 'machines' }
|
meta: { requiresAuth: true, plugin: 'machines' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -52,3 +52,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -57,3 +57,8 @@ export default [
|
|||||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -39,3 +39,8 @@ export default [
|
|||||||
meta: { plugin: 'notifications' }
|
meta: { plugin: 'notifications' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -1482,3 +1482,102 @@ def dashboard_supplies():
|
|||||||
|
|
||||||
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
||||||
return success_response(rows)
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Supply forecast
|
||||||
|
#
|
||||||
|
# The toner report answers "what is empty now". This answers "what will be, and
|
||||||
|
# what have we been getting through" - a purchasing question, on a different
|
||||||
|
# cadence, off data Zabbix has been keeping all along.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def supplies_forecast():
|
||||||
|
"""Days-to-empty and replacement counts per printer.
|
||||||
|
|
||||||
|
?days=90 how far back to read (Zabbix retention is the real ceiling)
|
||||||
|
|
||||||
|
Printers sort by their soonest supply. Anything without an honest estimate
|
||||||
|
is returned separately with the reason, rather than sorted as though it
|
||||||
|
were fine or dropped as though it did not exist.
|
||||||
|
"""
|
||||||
|
from ..services.supply_history import analyse, soonest
|
||||||
|
|
||||||
|
try:
|
||||||
|
days = max(1, min(365, int(request.args.get('days', 90))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
days = 90
|
||||||
|
|
||||||
|
service = ZabbixService()
|
||||||
|
if not service.isconfigured or not service.isreachable:
|
||||||
|
return success_response({
|
||||||
|
'printers': [], 'unestimated': [], 'days': days,
|
||||||
|
'available': False,
|
||||||
|
'reason': 'Zabbix is not configured or not reachable',
|
||||||
|
})
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.session.query(Printer, Asset, Communication, Vendor)
|
||||||
|
.join(Asset, Asset.assetid == Printer.assetid)
|
||||||
|
.join(Communication, Communication.assetid == Asset.assetid)
|
||||||
|
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
|
||||||
|
.filter(Asset.isactive == True,
|
||||||
|
Communication.ipaddress.isnot(None),
|
||||||
|
Communication.ipaddress != '')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
estimated, unestimated = [], []
|
||||||
|
|
||||||
|
for printer, asset, comm, vendor in rows:
|
||||||
|
if printer.printerid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(printer.printerid)
|
||||||
|
|
||||||
|
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
|
||||||
|
if not supplies:
|
||||||
|
continue
|
||||||
|
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
|
||||||
|
history = service.gethistory(itemids, days=days)
|
||||||
|
|
||||||
|
analysed = []
|
||||||
|
for supply in supplies:
|
||||||
|
points = history.get(str(supply.get('itemid')), [])
|
||||||
|
detail = analyse(points)
|
||||||
|
detail['name'] = supply.get('name')
|
||||||
|
detail['color'] = supply.get('color')
|
||||||
|
# Trust the live read for the level; history can lag a poll behind.
|
||||||
|
detail['currentlevel'] = supply.get('level', detail['currentlevel'])
|
||||||
|
analysed.append(detail)
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
'printerid': printer.printerid,
|
||||||
|
'printername': asset.name or printer.hostname or '',
|
||||||
|
'assetnumber': asset.assetnumber or '',
|
||||||
|
'ipaddress': comm.ipaddress,
|
||||||
|
'vendor': vendor.vendor if vendor else None,
|
||||||
|
'supplies': analysed,
|
||||||
|
'daysleft': soonest(analysed),
|
||||||
|
'replacements': sum(s['replacements'] for s in analysed),
|
||||||
|
}
|
||||||
|
(estimated if entry['daysleft'] is not None else unestimated).append(entry)
|
||||||
|
|
||||||
|
# Soonest first: the point of the report is what to order next.
|
||||||
|
estimated.sort(key=lambda p: p['daysleft'])
|
||||||
|
unestimated.sort(key=lambda p: p['printername'])
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'printers': estimated,
|
||||||
|
'unestimated': unestimated,
|
||||||
|
'days': days,
|
||||||
|
'available': True,
|
||||||
|
'summary': {
|
||||||
|
'estimated': len(estimated),
|
||||||
|
'unestimated': len(unestimated),
|
||||||
|
'replacements': sum(p['replacements'] for p in estimated + unestimated),
|
||||||
|
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export default [
|
|||||||
component: () => import('./views/TonerReport.vue'),
|
component: () => import('./views/TonerReport.vue'),
|
||||||
meta: { plugin: 'printers' }
|
meta: { plugin: 'printers' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reports/toner-forecast',
|
||||||
|
name: 'toner-forecast',
|
||||||
|
component: () => import('./views/TonerForecast.vue'),
|
||||||
|
meta: { plugin: 'printers' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/printertypes',
|
path: 'settings/printertypes',
|
||||||
name: 'printer-types',
|
name: 'printer-types',
|
||||||
|
|||||||
182
plugins/printers/frontend/views/TonerForecast.vue
Normal file
182
plugins/printers/frontend/views/TonerForecast.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Toner Forecast</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<select v-model.number="days" class="form-control" @change="load">
|
||||||
|
<option :value="30">Last 30 days</option>
|
||||||
|
<option :value="90">Last 90 days</option>
|
||||||
|
<option :value="180">Last 180 days</option>
|
||||||
|
</select>
|
||||||
|
<router-link to="/reports/toner" class="btn btn-secondary">Toner Report</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading">Reading history...</div>
|
||||||
|
|
||||||
|
<!-- Zabbix off is not the same as nothing running out. Say which. -->
|
||||||
|
<div v-else-if="!available" class="card empty-state">
|
||||||
|
<p>{{ reason || 'Supply history is unavailable.' }}</p>
|
||||||
|
<p class="muted">
|
||||||
|
Levels and history both come from Zabbix. With it unreachable there is
|
||||||
|
nothing to forecast from - this is not a report of "nothing is due".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="summary-row">
|
||||||
|
<div class="summary-card">
|
||||||
|
<span class="summary-number">{{ summary.duewithin30 }}</span>
|
||||||
|
<span class="summary-label">due within 30 days</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<span class="summary-number">{{ summary.estimated }}</span>
|
||||||
|
<span class="summary-label">printers with an estimate</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<span class="summary-number">{{ summary.replacements }}</span>
|
||||||
|
<span class="summary-label">cartridges changed in {{ days }} days</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Printer</th>
|
||||||
|
<th>Runs out in</th>
|
||||||
|
<th>Supply</th>
|
||||||
|
<th>Level</th>
|
||||||
|
<th>Burn rate</th>
|
||||||
|
<th>Changed</th>
|
||||||
|
<th>Based on</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Sorted by days left, not by level: a cartridge at 60% falling
|
||||||
|
fast is ordered before one sitting at 8% that never moves. -->
|
||||||
|
<template v-for="p in printers" :key="p.printerid">
|
||||||
|
<tr v-for="(s, index) in p.supplies" :key="p.printerid + s.name"
|
||||||
|
:class="{ 'row-group-start': index === 0 }">
|
||||||
|
<td v-if="index === 0" :rowspan="p.supplies.length">
|
||||||
|
<router-link :to="`/printers/${p.printerid}`">
|
||||||
|
{{ p.printername || p.assetnumber }}
|
||||||
|
</router-link>
|
||||||
|
<div class="muted small">{{ p.ipaddress }}</div>
|
||||||
|
</td>
|
||||||
|
<td v-if="index === 0" :rowspan="p.supplies.length">
|
||||||
|
<span class="days" :class="urgency(p.daysleft)">
|
||||||
|
{{ p.daysleft }} days
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
|
||||||
|
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td>
|
||||||
|
<td>{{ s.replacements || 0 }}</td>
|
||||||
|
<td class="muted small">
|
||||||
|
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-if="!printers.length">
|
||||||
|
<td colspan="7" class="muted" style="text-align:center;">
|
||||||
|
Nothing can be estimated yet from {{ days }} days of history.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no
|
||||||
|
estimate is neither urgent nor safe, and the reason is the useful part. -->
|
||||||
|
<div v-if="unestimated.length" class="card">
|
||||||
|
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3>
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Printer</th><th>Supply</th><th>Level</th><th>Why</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="p in unestimated" :key="p.printerid">
|
||||||
|
<tr v-for="s in p.supplies" :key="p.printerid + s.name">
|
||||||
|
<td>
|
||||||
|
<router-link :to="`/printers/${p.printerid}`">
|
||||||
|
{{ p.printername || p.assetnumber }}
|
||||||
|
</router-link>
|
||||||
|
</td>
|
||||||
|
<td>{{ s.name }}</td>
|
||||||
|
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
|
||||||
|
<td class="muted">{{ s.reason || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { printersApi } from '@/api'
|
||||||
|
import { apiError } from '@/utils/apiError'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const available = ref(false)
|
||||||
|
const reason = ref('')
|
||||||
|
const days = ref(90)
|
||||||
|
const printers = ref([])
|
||||||
|
const unestimated = ref([])
|
||||||
|
const summary = ref({ duewithin30: 0, estimated: 0, replacements: 0 })
|
||||||
|
|
||||||
|
function urgency(daysleft) {
|
||||||
|
if (daysleft <= 7) return 'critical'
|
||||||
|
if (daysleft <= 30) return 'low'
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await printersApi.supplyForecast(days.value)
|
||||||
|
const data = response.data.data
|
||||||
|
available.value = data.available
|
||||||
|
reason.value = data.reason || ''
|
||||||
|
printers.value = data.printers || []
|
||||||
|
unestimated.value = data.unestimated || []
|
||||||
|
summary.value = data.summary || summary.value
|
||||||
|
} catch (err) {
|
||||||
|
available.value = false
|
||||||
|
reason.value = apiError(err, 'Failed to read supply history')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.actions .form-control { width: auto; }
|
||||||
|
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
||||||
|
.summary-card {
|
||||||
|
background: var(--bg-card-solid);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
min-width: 12rem;
|
||||||
|
}
|
||||||
|
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; }
|
||||||
|
.summary-label { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
.days { font-weight: 700; }
|
||||||
|
.days.critical { color: var(--danger); }
|
||||||
|
.days.low { color: var(--warning); }
|
||||||
|
.small { font-size: 0.8rem; }
|
||||||
|
.row-group-start td { border-top: 2px solid var(--border); }
|
||||||
|
.empty-state { padding: 2rem; text-align: center; }
|
||||||
|
.section-title { padding: 0.75rem 1rem 0; }
|
||||||
|
</style>
|
||||||
@@ -53,22 +53,42 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Printer Name</th>
|
<th>Printer Name</th>
|
||||||
<th>Asset #</th>
|
<th>Hostname</th>
|
||||||
<th>Location</th>
|
|
||||||
<th>IP Address</th>
|
|
||||||
<th>Supplies</th>
|
<th>Supplies</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/printers/${printer.printerid}`">
|
<!-- Coordinates give the name the same floor-plan preview the
|
||||||
|
printer's own page uses; it replaces the Location column. -->
|
||||||
|
<LocationMapTooltip
|
||||||
|
v-if="printer.mapx != null && printer.mapy != null"
|
||||||
|
:left="printer.mapx"
|
||||||
|
:top="printer.mapy"
|
||||||
|
:machineName="printer.printername || printer.assetnumber"
|
||||||
|
>
|
||||||
|
<router-link :to="`/printers/${printer.printerid}`">
|
||||||
|
{{ printer.printername || 'Unknown' }}
|
||||||
|
</router-link>
|
||||||
|
</LocationMapTooltip>
|
||||||
|
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||||
{{ printer.printername || 'Unknown' }}
|
{{ printer.printername || 'Unknown' }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ printer.assetnumber }}</td>
|
<td>
|
||||||
<td>{{ printer.location || '-' }}</td>
|
<!-- Opens the printer's own web page. New tab: the report is a
|
||||||
<td>{{ printer.ipaddress }}</td>
|
worklist, and losing your place in it to visit one printer
|
||||||
|
means finding your row again. -->
|
||||||
|
<a
|
||||||
|
v-if="fqdnFor(printer)"
|
||||||
|
:href="`http://${fqdnFor(printer)}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="printer.ipaddress"
|
||||||
|
>{{ fqdnFor(printer) }}</a>
|
||||||
|
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||||
|
</td>
|
||||||
<td class="supplies-cell">
|
<td class="supplies-cell">
|
||||||
<div
|
<div
|
||||||
v-for="(supply, idx) in printer.supplies"
|
v-for="(supply, idx) in printer.supplies"
|
||||||
@@ -86,6 +106,16 @@
|
|||||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||||
{{ supply.level }}%
|
{{ supply.level }}%
|
||||||
</span>
|
</span>
|
||||||
|
<span class="supply-parts">
|
||||||
|
<span
|
||||||
|
v-for="part in supply.partnumbers"
|
||||||
|
:key="part.partnumber"
|
||||||
|
class="part-chip"
|
||||||
|
:title="partTooltip(part)"
|
||||||
|
>{{ part.partnumber }}</span>
|
||||||
|
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||||
|
class="part-none" title="No part number on file for this model and color">-</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -102,13 +132,14 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||||
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
const emailColumns = [
|
const emailColumns = [
|
||||||
{ key: 'printer', label: 'Printer' },
|
{ key: 'printer', label: 'Printer' },
|
||||||
{ key: 'assetnumber', label: 'Asset #' },
|
{ key: 'hostname', label: 'Hostname' },
|
||||||
{ key: 'location', label: 'Location' },
|
|
||||||
{ key: 'ipaddress', label: 'IP Address' },
|
|
||||||
{ key: 'supply', label: 'Supply' },
|
{ key: 'supply', label: 'Supply' },
|
||||||
|
{ key: 'partnumber', label: 'Part Number' },
|
||||||
{ key: 'level', label: 'Level' },
|
{ key: 'level', label: 'Level' },
|
||||||
{ key: 'status', label: 'Status' },
|
{ key: 'status', label: 'Status' },
|
||||||
]
|
]
|
||||||
@@ -125,6 +156,47 @@ const filterOptions = [
|
|||||||
{ label: 'Low', value: 'low' }
|
{ label: 'Low', value: 'low' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Printers have no stored FQDN; the site builds one from the IP via the
|
||||||
|
// printer_hostname_template setting, the same way PrinterForm does.
|
||||||
|
const hostnameTemplate = ref('')
|
||||||
|
|
||||||
|
function fqdnFor(printer) {
|
||||||
|
if (!printer.ipaddress || !hostnameTemplate.value) return ''
|
||||||
|
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A model can list several capacity tiers for one color, so the chip shows the
|
||||||
|
// part number and the tooltip says which one it is.
|
||||||
|
function partTooltip(part) {
|
||||||
|
const bits = [part.marketingname, part.capacitytier]
|
||||||
|
if (part.pageyield) bits.push(part.pageyield + ' pages')
|
||||||
|
return bits.filter(Boolean).join(' - ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// One flat row per part number so an ordering list is copy-pasteable; supplies
|
||||||
|
// with no part on file still get a row.
|
||||||
|
function reportRows() {
|
||||||
|
const rows = []
|
||||||
|
for (const printer of filteredPrinters.value) {
|
||||||
|
for (const supply of printer.supplies || []) {
|
||||||
|
const parts = supply.partnumbers && supply.partnumbers.length
|
||||||
|
? supply.partnumbers.map(p => p.partnumber)
|
||||||
|
: ['']
|
||||||
|
for (const partnumber of parts) {
|
||||||
|
rows.push({
|
||||||
|
printer: printer.printername || '',
|
||||||
|
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||||
|
supply: supply.name || '',
|
||||||
|
partnumber: partnumber,
|
||||||
|
level: supply.level,
|
||||||
|
status: supply.status || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
const filteredPrinters = computed(() => {
|
const filteredPrinters = computed(() => {
|
||||||
if (filter.value === 'all') return printers.value
|
if (filter.value === 'all') return printers.value
|
||||||
return printers.value.filter(p =>
|
return printers.value.filter(p =>
|
||||||
@@ -132,36 +204,17 @@ const filteredPrinters = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// One row per supply, honoring the active filter, for the emailed table.
|
// Emailed table, honoring the active filter.
|
||||||
const emailRows = computed(() => {
|
const emailRows = computed(() =>
|
||||||
const rows = []
|
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||||
for (const printer of filteredPrinters.value) {
|
)
|
||||||
for (const supply of printer.supplies || []) {
|
|
||||||
rows.push({
|
|
||||||
printer: printer.printername || '',
|
|
||||||
assetnumber: printer.assetnumber || '',
|
|
||||||
location: printer.location || '',
|
|
||||||
ipaddress: printer.ipaddress || '',
|
|
||||||
supply: supply.name || '',
|
|
||||||
level: supply.level + '%',
|
|
||||||
status: supply.status || '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
})
|
|
||||||
|
|
||||||
function exportCSV() {
|
function exportCSV() {
|
||||||
// one row per supply, honoring the active filter
|
|
||||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||||
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
|
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||||
for (const printer of filteredPrinters.value) {
|
const rows = [header]
|
||||||
for (const supply of printer.supplies || []) {
|
for (const row of reportRows()) {
|
||||||
rows.push([
|
rows.push(header.map(key => row[key]))
|
||||||
printer.printername || '', printer.assetnumber || '', printer.location || '',
|
|
||||||
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
@@ -173,6 +226,7 @@ function exportCSV() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
const response = await printersApi.lowSupplies()
|
const response = await printersApi.lowSupplies()
|
||||||
const data = response.data.data
|
const data = response.data.data
|
||||||
printers.value = data.printers || []
|
printers.value = data.printers || []
|
||||||
@@ -218,32 +272,31 @@ onMounted(async () => {
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One grid for the whole cell, with each supply row contributing its cells
|
||||||
|
directly (display: contents). Cartridge names were being ellipsised inside a
|
||||||
|
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||||
|
column sizes to the longest name instead, and the columns still line up
|
||||||
|
across the rows of one printer. */
|
||||||
.supplies-cell {
|
.supplies-cell {
|
||||||
min-width: 280px;
|
min-width: 460px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||||
|
column-gap: 0.5rem;
|
||||||
|
row-gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.supply-row {
|
.supply-row {
|
||||||
display: flex;
|
display: contents;
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.supply-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.supply-name {
|
.supply-name {
|
||||||
flex: 0 0 120px;
|
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.supply-bar-track {
|
.supply-bar-track {
|
||||||
flex: 1;
|
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -269,12 +322,33 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.supply-level {
|
.supply-level {
|
||||||
flex: 0 0 40px;
|
min-width: 40px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.supply-parts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-chip {
|
||||||
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-none {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
.supply-text-ok {
|
.supply-text-ok {
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,6 +358,17 @@ class PrintersPlugin(BasePlugin):
|
|||||||
'category': 'printers',
|
'category': 'printers',
|
||||||
'route': '/reports/toner',
|
'route': '/reports/toner',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
# Separate from the toner report on purpose: that one is an
|
||||||
|
# exceptions list a tech acts on today, this is an ordering
|
||||||
|
# view read monthly, and it runs a heavier history query.
|
||||||
|
'id': 'tonerforecast',
|
||||||
|
'name': 'Toner Forecast',
|
||||||
|
'description': 'Estimated days until each printer runs out, '
|
||||||
|
'and how many cartridges it has been through',
|
||||||
|
'category': 'printers',
|
||||||
|
'route': '/reports/toner-forecast',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_permissions(self) -> List:
|
def get_permissions(self) -> List:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .supply_parts import (
|
|||||||
alerttier,
|
alerttier,
|
||||||
)
|
)
|
||||||
from .supply_alerts import check_supplies
|
from .supply_alerts import check_supplies
|
||||||
|
from .supply_history import analyse, soonest
|
||||||
from .seed_supplies import seedsupplies
|
from .seed_supplies import seedsupplies
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -19,5 +20,7 @@ __all__ = [
|
|||||||
'lookupsupplies',
|
'lookupsupplies',
|
||||||
'alerttier',
|
'alerttier',
|
||||||
'check_supplies',
|
'check_supplies',
|
||||||
|
'analyse',
|
||||||
|
'soonest',
|
||||||
'seedsupplies',
|
'seedsupplies',
|
||||||
]
|
]
|
||||||
|
|||||||
162
plugins/printers/services/supply_history.py
Normal file
162
plugins/printers/services/supply_history.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""Read a supply's level history and say what it means.
|
||||||
|
|
||||||
|
Two questions come out of the same series of readings:
|
||||||
|
|
||||||
|
* how fast is it draining, and when does it hit empty
|
||||||
|
* how many times has it been replaced
|
||||||
|
|
||||||
|
Both hang on one observation: a cartridge only ever goes DOWN while it is in
|
||||||
|
use, so a rise is a replacement. Everything here is built on locating those
|
||||||
|
rises and treating each stretch between them as one cartridge's life.
|
||||||
|
|
||||||
|
The maths is deliberately kept away from Zabbix so it can be tested against a
|
||||||
|
list of numbers. The service layer fetches; this decides.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# A reading can wobble by a point without anything happening - SNMP rounding,
|
||||||
|
# a gauge settling after a power cycle. A rise has to clear this to count as a
|
||||||
|
# new cartridge rather than noise.
|
||||||
|
REPLACEMENT_RISE = 10
|
||||||
|
|
||||||
|
# Below this many readings a slope is arithmetic, not evidence. Two points
|
||||||
|
# through a coarse gauge can "prove" any rate at all.
|
||||||
|
MIN_POINTS_FOR_ESTIMATE = 4
|
||||||
|
|
||||||
|
# Many printers report in 10% steps, so a fortnight can pass on one plateau.
|
||||||
|
# Without a minimum observed drop the slope reads as zero and the forecast
|
||||||
|
# says "never", which is worse than saying nothing.
|
||||||
|
MIN_DROP_FOR_ESTIMATE = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _asfloat(value):
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalise(points):
|
||||||
|
"""[(clock, value)] -> sorted [(datetime, float)], junk dropped.
|
||||||
|
|
||||||
|
Zabbix returns clock as a unix string and value as a string; a history
|
||||||
|
table can also carry the odd unparseable row.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for clock, value in points:
|
||||||
|
seconds = _asfloat(clock)
|
||||||
|
level = _asfloat(value)
|
||||||
|
if seconds is None or level is None:
|
||||||
|
continue
|
||||||
|
out.append((datetime.fromtimestamp(seconds, tz=timezone.utc), level))
|
||||||
|
out.sort(key=lambda p: p[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_replacements(points, rise=REPLACEMENT_RISE):
|
||||||
|
"""Timestamps where the level jumped up - one per cartridge change.
|
||||||
|
|
||||||
|
Returns [] for a series that only falls. A rise smaller than `rise` is
|
||||||
|
treated as noise, not a replacement.
|
||||||
|
"""
|
||||||
|
replacements = []
|
||||||
|
for (_, previous), (when, current) in zip(points, points[1:]):
|
||||||
|
if current - previous >= rise:
|
||||||
|
replacements.append(when)
|
||||||
|
return replacements
|
||||||
|
|
||||||
|
|
||||||
|
def current_run(points, rise=REPLACEMENT_RISE):
|
||||||
|
"""The readings since the last replacement.
|
||||||
|
|
||||||
|
Fitting across a replacement averages a spent cartridge with a fresh one
|
||||||
|
and produces a slope that describes neither.
|
||||||
|
"""
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
start = 0
|
||||||
|
for index in range(1, len(points)):
|
||||||
|
if points[index][1] - points[index - 1][1] >= rise:
|
||||||
|
start = index
|
||||||
|
return points[start:]
|
||||||
|
|
||||||
|
|
||||||
|
def burn_rate(points):
|
||||||
|
"""Percent consumed per day over these readings, or None.
|
||||||
|
|
||||||
|
None means "no honest estimate": too few readings, no elapsed time, or a
|
||||||
|
drop too small to distinguish from a gauge that has not moved yet.
|
||||||
|
"""
|
||||||
|
if len(points) < MIN_POINTS_FOR_ESTIMATE:
|
||||||
|
return None
|
||||||
|
first_when, first_level = points[0]
|
||||||
|
last_when, last_level = points[-1]
|
||||||
|
days = (last_when - first_when).total_seconds() / 86400
|
||||||
|
if days <= 0:
|
||||||
|
return None
|
||||||
|
drop = first_level - last_level
|
||||||
|
if drop < MIN_DROP_FOR_ESTIMATE:
|
||||||
|
return None
|
||||||
|
return drop / days
|
||||||
|
|
||||||
|
|
||||||
|
def analyse(points, rise=REPLACEMENT_RISE):
|
||||||
|
"""Everything the report needs about one supply.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
currentlevel latest reading, or None
|
||||||
|
daysleft at the current rate, or None when there is no estimate
|
||||||
|
burnrateperday percent per day, or None
|
||||||
|
reason why there is no estimate - shown rather than hidden, so
|
||||||
|
a missing forecast is explained instead of looking broken
|
||||||
|
replacements how many times it has been changed in this window
|
||||||
|
lastreplaced when, or None
|
||||||
|
basisdays span of readings the estimate rests on
|
||||||
|
points the run since the last replacement, for the chart
|
||||||
|
"""
|
||||||
|
points = normalise(points)
|
||||||
|
result = {
|
||||||
|
'currentlevel': None, 'daysleft': None, 'burnrateperday': None,
|
||||||
|
'reason': None, 'replacements': 0, 'lastreplaced': None,
|
||||||
|
'basisdays': 0, 'points': [],
|
||||||
|
}
|
||||||
|
if not points:
|
||||||
|
result['reason'] = 'no history'
|
||||||
|
return result
|
||||||
|
|
||||||
|
replacements = find_replacements(points, rise=rise)
|
||||||
|
result['replacements'] = len(replacements)
|
||||||
|
result['lastreplaced'] = replacements[-1].isoformat() if replacements else None
|
||||||
|
|
||||||
|
run = current_run(points, rise=rise)
|
||||||
|
result['currentlevel'] = run[-1][1]
|
||||||
|
result['points'] = [(when.isoformat(), level) for when, level in run]
|
||||||
|
if len(run) >= 2:
|
||||||
|
result['basisdays'] = round(
|
||||||
|
(run[-1][0] - run[0][0]).total_seconds() / 86400, 1)
|
||||||
|
|
||||||
|
rate = burn_rate(run)
|
||||||
|
if rate is None:
|
||||||
|
# Say which of the three it is; "no estimate" alone invites a bug report.
|
||||||
|
if len(run) < MIN_POINTS_FOR_ESTIMATE:
|
||||||
|
result['reason'] = ('replaced recently' if replacements
|
||||||
|
else 'not enough history yet')
|
||||||
|
else:
|
||||||
|
result['reason'] = 'level has not moved enough to estimate'
|
||||||
|
return result
|
||||||
|
|
||||||
|
result['burnrateperday'] = round(rate, 2)
|
||||||
|
result['daysleft'] = max(0, int(run[-1][1] / rate))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def soonest(supplies):
|
||||||
|
"""Days-left of the supply that runs out first, or None if none estimate.
|
||||||
|
|
||||||
|
The report sorts printers by this: a printer is as urgent as its most
|
||||||
|
pressing cartridge, and listing it once per supply would scatter it down
|
||||||
|
the page.
|
||||||
|
"""
|
||||||
|
days = [s['daysleft'] for s in supplies if s.get('daysleft') is not None]
|
||||||
|
return min(days) if days else None
|
||||||
@@ -19,6 +19,7 @@ Configuration (database Setting overrides env var):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -219,6 +220,39 @@ class ZabbixService:
|
|||||||
})
|
})
|
||||||
return supplies
|
return supplies
|
||||||
|
|
||||||
|
def gethistory(self, itemids, days=90, limit=5000):
|
||||||
|
"""Raw level history for supply items: {itemid: [(clock, value), ...]}.
|
||||||
|
|
||||||
|
Zabbix keeps this already - we simply never asked for it. history=3 is
|
||||||
|
the unsigned-integer table, which is where a percent-remaining item
|
||||||
|
lands; a site that types the item as float would need history=0, so a
|
||||||
|
miss falls back rather than erroring.
|
||||||
|
|
||||||
|
Returns {} when Zabbix is off or unreachable, so callers degrade to
|
||||||
|
"no estimate" instead of failing.
|
||||||
|
"""
|
||||||
|
if not itemids:
|
||||||
|
return {}
|
||||||
|
timefrom = int(time.time()) - days * 86400
|
||||||
|
collected = {}
|
||||||
|
for historytype in (3, 0):
|
||||||
|
rows = self._apicall("history.get", {
|
||||||
|
"output": "extend",
|
||||||
|
"history": historytype,
|
||||||
|
"itemids": list(itemids),
|
||||||
|
"time_from": timefrom,
|
||||||
|
"sortfield": "clock",
|
||||||
|
"sortorder": "ASC",
|
||||||
|
"limit": limit,
|
||||||
|
}) or []
|
||||||
|
for row in rows:
|
||||||
|
collected.setdefault(str(row.get("itemid")), []).append(
|
||||||
|
(row.get("clock"), row.get("value")))
|
||||||
|
# Items live in one table or the other; stop once something answered.
|
||||||
|
if collected:
|
||||||
|
break
|
||||||
|
return collected
|
||||||
|
|
||||||
def getpingstatus(self, ip: str) -> str:
|
def getpingstatus(self, ip: str) -> str:
|
||||||
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||||
hostid = self.gethostidbyip(ip)
|
hostid = self.gethostidbyip(ip)
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though
|
|||||||
the common case is one warranty per asset.
|
the common case is one warranty per asset.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, current_app, send_from_directory
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, Asset,
|
db, Asset,
|
||||||
@@ -22,6 +25,21 @@ from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
|
|||||||
|
|
||||||
warranty_bp = Blueprint('warranty', __name__)
|
warranty_bp = Blueprint('warranty', __name__)
|
||||||
|
|
||||||
|
# What a proof of cover actually arrives as: a vendor PDF, a scan, or a
|
||||||
|
# screenshot of a portal page. Office formats are allowed because purchase
|
||||||
|
# records often arrive that way.
|
||||||
|
PROOF_EXTENSIONS = {'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.tif',
|
||||||
|
'.tiff', '.msg', '.eml', '.doc', '.docx', '.xls', '.xlsx'}
|
||||||
|
PROOF_URL_PREFIX = '/api/warranty/proof/'
|
||||||
|
|
||||||
|
# A certificate is a document, not a disk image. Anything past this is somebody
|
||||||
|
# attaching the wrong thing.
|
||||||
|
MAX_PROOF_BYTES = 25 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _proof_dir():
|
||||||
|
return os.path.join(current_app.instance_path, 'warrantyproofs')
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value):
|
def _parse_date(value):
|
||||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
||||||
@@ -466,3 +484,100 @@ def _hostname(asset):
|
|||||||
return None
|
return None
|
||||||
computer = Computer.query.filter_by(assetid=asset.assetid).first()
|
computer = Computer.query.filter_by(assetid=asset.assetid).first()
|
||||||
return computer.hostname if computer else None
|
return computer.hostname if computer else None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Proof of cover
|
||||||
|
#
|
||||||
|
# Authenticated on the way in AND on the way out: an invoice carries prices and
|
||||||
|
# a service tag, so it is not something to serve openly the way an asset photo
|
||||||
|
# is. Stored as warranty-<id><ext>, one per warranty, with the vendor's own
|
||||||
|
# filename kept alongside so a person recognises it later.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@warranty_bp.route('/<int:warrantyid>/proof', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('warranty.edit')
|
||||||
|
def upload_proof(warrantyid):
|
||||||
|
"""Upload (or replace) the proof-of-cover document for a warranty."""
|
||||||
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
|
if not warranty:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
upload = request.files.get('file')
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||||
|
|
||||||
|
ext = os.path.splitext(upload.filename)[1].lower()
|
||||||
|
if ext not in PROOF_EXTENSIONS:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Unsupported document type {}. Allowed: {}'.format(
|
||||||
|
ext, ', '.join(sorted(PROOF_EXTENSIONS))))
|
||||||
|
|
||||||
|
# Seek rather than trust Content-Length: a chunked upload has no length
|
||||||
|
# header, and a client can understate the one it sends.
|
||||||
|
upload.stream.seek(0, os.SEEK_END)
|
||||||
|
size = upload.stream.tell()
|
||||||
|
upload.stream.seek(0)
|
||||||
|
if size > MAX_PROOF_BYTES:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Document is {:.0f}MB; the limit is {:.0f}MB.'.format(
|
||||||
|
size / 1048576, MAX_PROOF_BYTES / 1048576))
|
||||||
|
|
||||||
|
proofdir = _proof_dir()
|
||||||
|
os.makedirs(proofdir, exist_ok=True)
|
||||||
|
# One proof per warranty: clear any prior extension so a re-upload as .pdf
|
||||||
|
# does not leave the old .png behind claiming to be current.
|
||||||
|
for old in glob.glob(os.path.join(proofdir,
|
||||||
|
secure_filename(f'warranty-{warrantyid}') + '.*')):
|
||||||
|
os.remove(old)
|
||||||
|
|
||||||
|
filename = secure_filename(f'warranty-{warrantyid}{ext}')
|
||||||
|
upload.save(os.path.join(proofdir, filename))
|
||||||
|
|
||||||
|
warranty.proofurl = f'{PROOF_URL_PREFIX}{filename}'
|
||||||
|
warranty.prooffilename = upload.filename
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return success_response(warranty.to_dict(), message='Proof uploaded')
|
||||||
|
|
||||||
|
|
||||||
|
@warranty_bp.route('/proof/<path:filename>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('warranty.view')
|
||||||
|
def serve_proof(filename):
|
||||||
|
"""Download a proof document.
|
||||||
|
|
||||||
|
Authenticated: an invoice carries pricing and a service tag. Sent as an
|
||||||
|
attachment under the vendor's original filename where we still have it, so
|
||||||
|
a download is recognisable rather than 'warranty-12.pdf'.
|
||||||
|
"""
|
||||||
|
warranty = Warranty.query.filter(
|
||||||
|
Warranty.proofurl == f'{PROOF_URL_PREFIX}{filename}').first()
|
||||||
|
downloadname = (warranty.prooffilename if warranty and warranty.prooffilename
|
||||||
|
else filename)
|
||||||
|
return send_from_directory(_proof_dir(), filename, as_attachment=True,
|
||||||
|
download_name=downloadname)
|
||||||
|
|
||||||
|
|
||||||
|
@warranty_bp.route('/<int:warrantyid>/proof', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('warranty.edit')
|
||||||
|
def delete_proof(warrantyid):
|
||||||
|
"""Remove a warranty's proof document."""
|
||||||
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
|
if not warranty:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
for old in glob.glob(os.path.join(_proof_dir(),
|
||||||
|
secure_filename(f'warranty-{warrantyid}') + '.*')):
|
||||||
|
os.remove(old)
|
||||||
|
warranty.proofurl = None
|
||||||
|
warranty.prooffilename = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return success_response(warranty.to_dict(), message='Proof removed')
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ export default [
|
|||||||
meta: { plugin: 'warranty' }
|
meta: { plugin: 'warranty' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// No full-screen routes: every view in this plugin renders inside
|
||||||
|
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||||
|
// without Rollup warning about a missing export.
|
||||||
|
export const toplevel = []
|
||||||
|
|||||||
@@ -151,6 +151,21 @@
|
|||||||
<label>Notes</label>
|
<label>Notes</label>
|
||||||
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
|
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Proof of cover</label>
|
||||||
|
<div v-if="proofName" class="proof-current">
|
||||||
|
<a v-if="proofUrl" :href="proofUrl" @click.prevent="downloadProof">{{ proofName }}</a>
|
||||||
|
<span v-else>{{ proofName }}</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" @click="removeProof"
|
||||||
|
:disabled="busyProof">Remove</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" class="form-control" :accept="PROOF_ACCEPT" @change="onProofPicked" />
|
||||||
|
<small class="muted">
|
||||||
|
Invoice, certificate or a saved email - up to 25MB. Downloading
|
||||||
|
needs a login, since it carries pricing and a service tag.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -167,7 +182,7 @@
|
|||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||||
import { useToast } from '@/composables/toast'
|
import { useToast } from '@/composables/toast'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
import MachineMapChip from '../components/MachineMapChip.vue'
|
import MachineMapChip from '../components/MachineMapChip.vue'
|
||||||
@@ -315,7 +330,10 @@ function openModal(item = null) {
|
|||||||
} else {
|
} else {
|
||||||
form.value = blankForm()
|
form.value = blankForm()
|
||||||
selectedAssets.value = []
|
selectedAssets.value = []
|
||||||
|
proofName.value = ''
|
||||||
|
proofUrl.value = ''
|
||||||
}
|
}
|
||||||
|
pendingProof.value = null
|
||||||
assetQuery.value = ''
|
assetQuery.value = ''
|
||||||
assetResults.value = []
|
assetResults.value = []
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -323,15 +341,75 @@ function openModal(item = null) {
|
|||||||
}
|
}
|
||||||
function closeModal() { showModal.value = false; editing.value = null }
|
function closeModal() { showModal.value = false; editing.value = null }
|
||||||
|
|
||||||
|
// Chosen before a new warranty exists, so it is held here and uploaded once
|
||||||
|
// there is an id to attach it to.
|
||||||
|
const pendingProof = ref(null)
|
||||||
|
const proofName = ref('')
|
||||||
|
const proofUrl = ref('')
|
||||||
|
const busyProof = ref(false)
|
||||||
|
const PROOF_ACCEPT = '.pdf,.png,.jpg,.jpeg,.gif,.webp,.tif,.tiff,.msg,.eml,.doc,.docx,.xls,.xlsx'
|
||||||
|
const MAX_PROOF_BYTES = 25 * 1024 * 1024
|
||||||
|
|
||||||
|
function onProofPicked(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (file.size > MAX_PROOF_BYTES) {
|
||||||
|
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 25MB.`
|
||||||
|
event.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = ''
|
||||||
|
pendingProof.value = file
|
||||||
|
proofName.value = file.name
|
||||||
|
proofUrl.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeProof() {
|
||||||
|
pendingProof.value = null
|
||||||
|
proofName.value = ''
|
||||||
|
proofUrl.value = ''
|
||||||
|
if (editing.value) {
|
||||||
|
busyProof.value = true
|
||||||
|
try { await warrantyApi.removeProof(editing.value.warrantyid) }
|
||||||
|
catch (err) { error.value = apiError(err, 'Failed to remove the document') }
|
||||||
|
finally { busyProof.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The route is authenticated, so a plain href would 401. Fetch it through the
|
||||||
|
// client, which carries the token, then hand the blob to the browser.
|
||||||
|
async function downloadProof() {
|
||||||
|
try {
|
||||||
|
const response = await api.get(proofUrl.value, { responseType: 'blob' })
|
||||||
|
const url = URL.createObjectURL(response.data)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = proofName.value || 'proof'
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = apiError(err, 'Failed to download the document')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
|
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
|
||||||
|
let warrantyid
|
||||||
if (editing.value) {
|
if (editing.value) {
|
||||||
await warrantyApi.update(editing.value.warrantyid, payload)
|
warrantyid = editing.value.warrantyid
|
||||||
|
await warrantyApi.update(warrantyid, payload)
|
||||||
} else {
|
} else {
|
||||||
await warrantyApi.create(payload)
|
const created = await warrantyApi.create(payload)
|
||||||
|
warrantyid = created.data.data.warrantyid
|
||||||
|
}
|
||||||
|
// After the save: a new warranty has no id until it exists, and the upload
|
||||||
|
// keys on it. A failed upload leaves the saved record alone and reports.
|
||||||
|
if (pendingProof.value) {
|
||||||
|
await warrantyApi.uploadProof(warrantyid, pendingProof.value)
|
||||||
|
pendingProof.value = null
|
||||||
}
|
}
|
||||||
closeModal()
|
closeModal()
|
||||||
loadData()
|
loadData()
|
||||||
@@ -390,6 +468,8 @@ async function refresh(w) {
|
|||||||
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||||
.filters .form-control { max-width: 320px; }
|
.filters .form-control { max-width: 320px; }
|
||||||
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
.proof-current { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||||
|
.proof-current a { word-break: break-all; }
|
||||||
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
|
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
|
||||||
.page-info { color: var(--text-light); font-size: 0.85rem; }
|
.page-info { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
|||||||
50
plugins/warranty/migrations/versions/0002_proof_document.py
Normal file
50
plugins/warranty/migrations/versions/0002_proof_document.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Add warranties.proofurl / prooffilename (proof-of-cover document).
|
||||||
|
|
||||||
|
A provider lookup answers whether a unit is covered. It does not produce the
|
||||||
|
invoice or the extended-warranty certificate, and a manually entered warranty
|
||||||
|
had nowhere to keep one - so the proof lived in somebody's mailbox until they
|
||||||
|
left. Two columns: the served URL of the stored file, and the name the vendor
|
||||||
|
sent it under, which is what a person recognises months later.
|
||||||
|
|
||||||
|
Idempotent; downgrade drops both.
|
||||||
|
|
||||||
|
Revision ID: warranty0002proof
|
||||||
|
Revises: warranty0001anchor
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'warranty0002proof'
|
||||||
|
down_revision = 'warranty0001anchor'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLE = 'warranties'
|
||||||
|
_COLUMNS = (('proofurl', sa.String(500)), ('prooffilename', sa.String(255)))
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(insp, table):
|
||||||
|
return {c['name'] for c in insp.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if _TABLE not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
existing = _column_names(insp, _TABLE)
|
||||||
|
for name, coltype in _COLUMNS:
|
||||||
|
if name not in existing:
|
||||||
|
op.add_column(_TABLE, sa.Column(name, coltype, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
if _TABLE not in insp.get_table_names():
|
||||||
|
return
|
||||||
|
existing = _column_names(insp, _TABLE)
|
||||||
|
for name, _ in _COLUMNS:
|
||||||
|
if name in existing:
|
||||||
|
op.drop_column(_TABLE, name)
|
||||||
@@ -48,6 +48,14 @@ class Warranty(db.Model):
|
|||||||
# When a provider lookup last refreshed this record.
|
# When a provider lookup last refreshed this record.
|
||||||
lastcheckeddate = db.Column(db.DateTime)
|
lastcheckeddate = db.Column(db.DateTime)
|
||||||
notes = db.Column(db.Text)
|
notes = db.Column(db.Text)
|
||||||
|
|
||||||
|
# Proof of cover: the purchase invoice, the extended-warranty certificate,
|
||||||
|
# whatever the vendor sent. A provider lookup answers "is it covered"; this
|
||||||
|
# answers "prove it" months later, when the email it arrived in is gone.
|
||||||
|
# Stores the served URL, not the original filename - see the upload route.
|
||||||
|
proofurl = db.Column(db.String(500))
|
||||||
|
prooffilename = db.Column(db.String(255))
|
||||||
|
|
||||||
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
|
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
|
||||||
|
|
||||||
links = db.relationship('WarrantyAsset', back_populates='warranty',
|
links = db.relationship('WarrantyAsset', back_populates='warranty',
|
||||||
@@ -68,6 +76,8 @@ class Warranty(db.Model):
|
|||||||
'enddate': self.enddate.isoformat() if self.enddate else None,
|
'enddate': self.enddate.isoformat() if self.enddate else None,
|
||||||
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
|
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
|
||||||
'notes': self.notes,
|
'notes': self.notes,
|
||||||
|
'proofurl': self.proofurl,
|
||||||
|
'prooffilename': self.prooffilename,
|
||||||
'isactive': bool(self.isactive),
|
'isactive': bool(self.isactive),
|
||||||
'status': status,
|
'status': status,
|
||||||
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
"""Applications API endpoints."""
|
"""Applications API endpoints."""
|
||||||
|
|
||||||
from flask import Blueprint, request
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, request, current_app, send_from_directory
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import (
|
from shopdb.core.models import (
|
||||||
@@ -17,6 +21,46 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
|
||||||
|
|
||||||
|
# Uploads live in the instance dir, like model images and part photos, so a
|
||||||
|
# site's files are not mixed into the code tree and survive a redeploy.
|
||||||
|
APP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico'}
|
||||||
|
APP_IMAGE_URL_PREFIX = '/api/applications/image/'
|
||||||
|
|
||||||
|
# Installers are whatever a vendor ships. The list is a guard against a browser
|
||||||
|
# handing us something unintended, not a security boundary - the download route
|
||||||
|
# always sends as an attachment and never executes anything.
|
||||||
|
APP_PACKAGE_EXTENSIONS = {'.exe', '.msi', '.msp', '.zip', '.7z', '.cab', '.iso',
|
||||||
|
'.appx', '.msix', '.ps1', '.bat', '.txt', '.pdf'}
|
||||||
|
APP_PACKAGE_URL_PREFIX = '/api/applications/package/'
|
||||||
|
|
||||||
|
# A real installer runs to hundreds of megabytes; an ISO runs to gigabytes and
|
||||||
|
# does not belong in an instance directory. 500MB is the line: enough for the
|
||||||
|
# msi/exe this is for, small enough that the disk cannot vanish behind it.
|
||||||
|
MAX_PACKAGE_BYTES = 500 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _appimage_dir():
|
||||||
|
return os.path.join(current_app.instance_path, 'applicationimages')
|
||||||
|
|
||||||
|
|
||||||
|
def _apppackage_dir():
|
||||||
|
return os.path.join(current_app.instance_path, 'applicationpackages')
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_upload(directory, stem, ext, upload):
|
||||||
|
"""Save one file per application, replacing any prior extension.
|
||||||
|
|
||||||
|
Returns the stored filename. Without the glob a re-upload as .png would
|
||||||
|
orphan the old .jpg and the two would fight over which is current.
|
||||||
|
"""
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
for old in glob.glob(os.path.join(directory, secure_filename(stem) + '.*')):
|
||||||
|
os.remove(old)
|
||||||
|
filename = secure_filename(f'{stem}{ext}')
|
||||||
|
upload.save(os.path.join(directory, filename))
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
def _computer_models():
|
def _computer_models():
|
||||||
"""Lazily import the computers plugin models, or None if unavailable.
|
"""Lazily import the computers plugin models, or None if unavailable.
|
||||||
|
|
||||||
@@ -460,3 +504,157 @@ def update_installed_app(machine_id: int, app_id: int):
|
|||||||
|
|
||||||
# Support teams + contacts now live in the supportteams blueprint
|
# Support teams + contacts now live in the supportteams blueprint
|
||||||
# (/api/supportteams), replacing the legacy appowners pair.
|
# (/api/supportteams), replacing the legacy appowners pair.
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Image and installer uploads
|
||||||
|
#
|
||||||
|
# Both follow the model-image trio (upload / serve / delete). The image is
|
||||||
|
# public because application pages render it before anything else loads; the
|
||||||
|
# installer is not, because it is a binary a site pays for.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('applications.edit')
|
||||||
|
def upload_application_image(app_id: int):
|
||||||
|
"""Upload (or replace) an application's image.
|
||||||
|
|
||||||
|
multipart/form-data: file=<image>. Stored as application-<id><ext>, one per
|
||||||
|
application, and application.image is pointed at the served URL.
|
||||||
|
"""
|
||||||
|
app = db.session.get(Application, app_id)
|
||||||
|
if not app:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Application with ID {app_id} not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
upload = request.files.get('file')
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||||
|
ext = os.path.splitext(upload.filename)[1].lower()
|
||||||
|
if ext not in APP_IMAGE_EXTENSIONS:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
f'Unsupported image type {ext}')
|
||||||
|
|
||||||
|
filename = _replace_upload(_appimage_dir(), f'application-{app_id}', ext, upload)
|
||||||
|
app.image = f'{APP_IMAGE_URL_PREFIX}{filename}'
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Application', entityid=app_id,
|
||||||
|
entityname=app.appname, changes={'image': {'new': app.image}})
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(app.to_dict(), message='Application image uploaded')
|
||||||
|
|
||||||
|
|
||||||
|
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
|
def serve_application_image(filename):
|
||||||
|
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||||
|
return send_from_directory(_appimage_dir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('applications.edit')
|
||||||
|
def delete_application_image(app_id: int):
|
||||||
|
"""Remove an application's image and clear the field."""
|
||||||
|
app = db.session.get(Application, app_id)
|
||||||
|
if not app:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Application with ID {app_id} not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
for old in glob.glob(os.path.join(_appimage_dir(),
|
||||||
|
secure_filename(f'application-{app_id}') + '.*')):
|
||||||
|
os.remove(old)
|
||||||
|
app.image = None
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(app.to_dict(), message='Application image removed')
|
||||||
|
|
||||||
|
|
||||||
|
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('applications.edit')
|
||||||
|
def upload_application_package(app_id: int):
|
||||||
|
"""Upload (or replace) the installer for an application.
|
||||||
|
|
||||||
|
multipart/form-data: file=<installer>. The original filename is kept in
|
||||||
|
installpath so the download arrives named the way the vendor shipped it,
|
||||||
|
and the stored copy is application-<id><ext>.
|
||||||
|
"""
|
||||||
|
app = db.session.get(Application, app_id)
|
||||||
|
if not app:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Application with ID {app_id} not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
upload = request.files.get('file')
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||||
|
ext = os.path.splitext(upload.filename)[1].lower()
|
||||||
|
if ext not in APP_PACKAGE_EXTENSIONS:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Unsupported installer type {}. Allowed: {}'.format(
|
||||||
|
ext, ', '.join(sorted(APP_PACKAGE_EXTENSIONS))))
|
||||||
|
|
||||||
|
# Measure by seeking the stream, not Content-Length: a chunked upload has
|
||||||
|
# no length header, and trusting the header lets a client understate size.
|
||||||
|
upload.stream.seek(0, os.SEEK_END)
|
||||||
|
size = upload.stream.tell()
|
||||||
|
upload.stream.seek(0)
|
||||||
|
if size > MAX_PACKAGE_BYTES:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Installer is {:.0f}MB; the limit is {:.0f}MB. Put larger payloads '
|
||||||
|
'on the share and link to them with Install Path.'.format(
|
||||||
|
size / 1048576, MAX_PACKAGE_BYTES / 1048576))
|
||||||
|
|
||||||
|
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
|
||||||
|
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Application', entityid=app_id,
|
||||||
|
entityname=app.appname,
|
||||||
|
changes={'installpath': {'new': app.installpath},
|
||||||
|
'uploadedfilename': {'new': upload.filename},
|
||||||
|
'bytes': {'new': size}})
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(
|
||||||
|
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
|
||||||
|
message='Installer uploaded')
|
||||||
|
|
||||||
|
|
||||||
|
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('applications.view')
|
||||||
|
def serve_application_package(filename):
|
||||||
|
"""Download an uploaded installer.
|
||||||
|
|
||||||
|
Authenticated, unlike the image: this is licensed vendor software, and an
|
||||||
|
open URL would publish it to anything that can reach the site. Always sent
|
||||||
|
as an attachment so a browser saves it rather than trying to render it.
|
||||||
|
"""
|
||||||
|
return send_from_directory(_apppackage_dir(), filename, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('applications.edit')
|
||||||
|
def delete_application_package(app_id: int):
|
||||||
|
"""Remove an uploaded installer and clear the path it set."""
|
||||||
|
app = db.session.get(Application, app_id)
|
||||||
|
if not app:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'Application with ID {app_id} not found',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
for old in glob.glob(os.path.join(_apppackage_dir(),
|
||||||
|
secure_filename(f'application-{app_id}') + '.*')):
|
||||||
|
os.remove(old)
|
||||||
|
# Only clear the path if it pointed at the upload; a site's own share path
|
||||||
|
# was typed by a person and is not ours to wipe.
|
||||||
|
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
|
||||||
|
app.installpath = None
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(app.to_dict(), message='Installer removed')
|
||||||
|
|||||||
165
tests/test_core/test_application_uploads.py
Normal file
165
tests/test_core/test_application_uploads.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Tests for application image and installer uploads.
|
||||||
|
|
||||||
|
The form used to take an image FILENAME and expect someone to drop the file into
|
||||||
|
the frontend's own directory by hand, and an install path typed from memory.
|
||||||
|
Both are uploads now, following the model-image trio.
|
||||||
|
|
||||||
|
The two differ deliberately and that is most of what is pinned here: the image
|
||||||
|
is public because application tiles render before anything is authenticated, the
|
||||||
|
installer is not, because it is licensed vendor software.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from shopdb.core.models import Application
|
||||||
|
|
||||||
|
|
||||||
|
def _app(db, name='Test App'):
|
||||||
|
app = Application(appname=name)
|
||||||
|
db.session.add(app)
|
||||||
|
db.session.commit()
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _png():
|
||||||
|
# 1x1 PNG; enough to exercise the path without a fixture file.
|
||||||
|
return io.BytesIO(
|
||||||
|
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||||
|
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
|
||||||
|
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82')
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_upload_sets_the_served_url(client, db, auth_headers):
|
||||||
|
app = _app(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/applications/{app.appid}/image',
|
||||||
|
data={'file': (_png(), 'logo.png')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
assert response.get_json()['data']['image'] == \
|
||||||
|
f'/api/applications/image/application-{app.appid}.png'
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_is_readable_without_a_token(client, db, auth_headers):
|
||||||
|
"""Tiles and lists render the image before anything is authenticated."""
|
||||||
|
app = _app(db)
|
||||||
|
client.post(f'/api/applications/{app.appid}/image',
|
||||||
|
data={'file': (_png(), 'logo.png')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
|
anon = client.get(f'/api/applications/image/application-{app.appid}.png')
|
||||||
|
|
||||||
|
assert anon.status_code == 200
|
||||||
|
assert anon.mimetype == 'image/png'
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_rejects_a_non_image(client, db, auth_headers):
|
||||||
|
app = _app(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/applications/{app.appid}/image',
|
||||||
|
data={'file': (io.BytesIO(b'MZ'), 'payload.exe')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Unsupported image type' in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_upload_sets_the_install_path(client, db, auth_headers):
|
||||||
|
app = _app(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/applications/{app.appid}/package',
|
||||||
|
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
body = response.get_json()['data']
|
||||||
|
assert body['installpath'] == \
|
||||||
|
f'/api/applications/package/application-{app.appid}.msi'
|
||||||
|
# The name the vendor shipped is reported back, since the stored copy is
|
||||||
|
# renamed and the uploader needs to recognise what they sent.
|
||||||
|
assert body['uploadedfilename'] == 'setup.msi'
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_download_needs_a_token(client, db, auth_headers):
|
||||||
|
"""Licensed software: an open URL would publish it to the whole network."""
|
||||||
|
app = _app(db)
|
||||||
|
client.post(f'/api/applications/{app.appid}/package',
|
||||||
|
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
path = f'/api/applications/package/application-{app.appid}.msi'
|
||||||
|
|
||||||
|
assert client.get(path).status_code == 401
|
||||||
|
|
||||||
|
authed = client.get(path, headers=auth_headers)
|
||||||
|
assert authed.status_code == 200
|
||||||
|
assert authed.data == b'fake installer'
|
||||||
|
# Sent as an attachment so a browser saves it rather than rendering it.
|
||||||
|
assert 'attachment' in authed.headers.get('Content-Disposition', '')
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_rejects_an_unlisted_type(client, db, auth_headers):
|
||||||
|
app = _app(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/applications/{app.appid}/package',
|
||||||
|
data={'file': (io.BytesIO(b'x'), 'driver.dll')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Unsupported installer type' in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_installer_rejects_an_oversize_file(client, db, auth_headers, monkeypatch):
|
||||||
|
"""The cap keeps an ISO out of the instance directory."""
|
||||||
|
from shopdb.core.api import applications as applications_api
|
||||||
|
monkeypatch.setattr(applications_api, 'MAX_PACKAGE_BYTES', 1024)
|
||||||
|
app = _app(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/applications/{app.appid}/package',
|
||||||
|
data={'file': (io.BytesIO(b'x' * 4096), 'big.msi')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'the limit is' in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_an_installer_clears_only_an_uploaded_path(client, db, auth_headers):
|
||||||
|
"""A share path was typed by a person and is not ours to wipe."""
|
||||||
|
app = _app(db)
|
||||||
|
client.post(f'/api/applications/{app.appid}/package',
|
||||||
|
data={'file': (io.BytesIO(b'installer'), 'setup.msi')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
|
cleared = client.delete(f'/api/applications/{app.appid}/package',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert cleared.get_json()['data']['installpath'] is None
|
||||||
|
|
||||||
|
app.installpath = r'\\share\installers\vendor.msi'
|
||||||
|
db.session.commit()
|
||||||
|
again = client.delete(f'/api/applications/{app.appid}/package',
|
||||||
|
headers=auth_headers)
|
||||||
|
assert again.get_json()['data']['installpath'] == r'\\share\installers\vendor.msi'
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacing_an_image_does_not_orphan_the_old_extension(client, db, auth_headers):
|
||||||
|
"""One image per application, whatever the extension arrives as."""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
from flask import current_app
|
||||||
|
app = _app(db)
|
||||||
|
|
||||||
|
client.post(f'/api/applications/{app.appid}/image',
|
||||||
|
data={'file': (_png(), 'logo.png')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
client.post(f'/api/applications/{app.appid}/image',
|
||||||
|
data={'file': (io.BytesIO(b'<svg/>'), 'logo.svg')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
|
imagedir = os.path.join(current_app.instance_path, 'applicationimages')
|
||||||
|
files = glob.glob(os.path.join(imagedir, f'application-{app.appid}.*'))
|
||||||
|
assert len(files) == 1 and files[0].endswith('.svg')
|
||||||
@@ -71,6 +71,8 @@ EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
|||||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||||
# the shared board category.
|
# the shared board category.
|
||||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||||
|
# warranty adds the proof-of-cover document columns on top of its anchor.
|
||||||
|
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||||
|
|
||||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
@@ -239,9 +241,21 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
|||||||
first = app.extensions['plugin_manager'].upgrade_all_plugins()
|
first = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||||
second = app.extensions['plugin_manager'].upgrade_all_plugins()
|
second = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||||
|
|
||||||
assert set(first) == set(PLUGIN_TABLE_OWNERS)
|
# A plugin that owns no tables (Tech Tools: every tool is
|
||||||
assert all(status == 'ok' for status in first.values()), first
|
# client-side) carries no chain, so upgrade-all reports it
|
||||||
assert all(status == 'ok' for status in second.values()), second
|
# 'no-migrations'. Assert that explicitly rather than letting it
|
||||||
|
# widen the 'ok' check, which would also swallow a table-owning
|
||||||
|
# plugin whose chain silently went missing.
|
||||||
|
schemaless = set(first) - set(PLUGIN_TABLE_OWNERS)
|
||||||
|
for name in schemaless:
|
||||||
|
assert first[name] == 'no-migrations', (name, first[name])
|
||||||
|
assert second[name] == 'no-migrations', (name, second[name])
|
||||||
|
|
||||||
|
migrated = {name: status for name, status in first.items()
|
||||||
|
if name in PLUGIN_TABLE_OWNERS}
|
||||||
|
assert set(migrated) == set(PLUGIN_TABLE_OWNERS)
|
||||||
|
assert all(status == 'ok' for status in migrated.values()), migrated
|
||||||
|
assert all(second[name] == 'ok' for name in PLUGIN_TABLE_OWNERS), second
|
||||||
|
|
||||||
insp = inspect(db.engine)
|
insp = inspect(db.engine)
|
||||||
for plugin in PLUGIN_TABLE_OWNERS:
|
for plugin in PLUGIN_TABLE_OWNERS:
|
||||||
|
|||||||
130
tests/test_plugins/test_supply_history.py
Normal file
130
tests/test_plugins/test_supply_history.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""Tests for toner burn-rate and replacement counting.
|
||||||
|
|
||||||
|
The analysis is pure arithmetic over a list of readings, so it is tested that
|
||||||
|
way - no Zabbix, no fixtures. What matters is that it refuses to guess: the
|
||||||
|
cases where an estimate would be dishonest are the ones most likely to reach a
|
||||||
|
purchasing decision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from plugins.printers.services.supply_history import (
|
||||||
|
analyse, burn_rate, current_run, find_replacements, normalise, soonest,
|
||||||
|
)
|
||||||
|
|
||||||
|
START = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def series(levels, hours=24):
|
||||||
|
"""[(clock, value)] one reading per `hours`, as Zabbix returns them."""
|
||||||
|
return [(str(int((START + timedelta(hours=i * hours)).timestamp())), str(v))
|
||||||
|
for i, v in enumerate(levels)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_counts_one_replacement_per_upward_step():
|
||||||
|
points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70]))
|
||||||
|
|
||||||
|
assert len(find_replacements(points)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_ignores_a_wobble_that_is_not_a_replacement():
|
||||||
|
"""SNMP rounding and a gauge settling both nudge a reading upward."""
|
||||||
|
points = normalise(series([60, 58, 61, 57, 55]))
|
||||||
|
|
||||||
|
assert find_replacements(points) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimate_uses_only_the_current_cartridge():
|
||||||
|
"""Fitting across a swap averages a spent cartridge with a fresh one."""
|
||||||
|
points = normalise(series([90, 60, 30, 100, 90, 80, 70]))
|
||||||
|
|
||||||
|
run = current_run(points)
|
||||||
|
|
||||||
|
assert [level for _, level in run] == [100, 90, 80, 70]
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_left_from_a_steady_drain():
|
||||||
|
# 10 points a day apart, 5% a day, ending at 55%
|
||||||
|
result = analyse(series([100, 95, 90, 85, 80, 75, 70, 65, 60, 55]))
|
||||||
|
|
||||||
|
assert result['burnrateperday'] == 5.0
|
||||||
|
assert result['daysleft'] == 11 # 55 / 5
|
||||||
|
assert result['reason'] is None
|
||||||
|
assert result['basisdays'] == 9.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_estimate_from_two_readings():
|
||||||
|
"""Two points through a coarse gauge can prove any rate at all."""
|
||||||
|
result = analyse(series([100, 90]))
|
||||||
|
|
||||||
|
assert result['daysleft'] is None
|
||||||
|
assert result['reason'] == 'not enough history yet'
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_estimate_while_the_gauge_has_not_moved():
|
||||||
|
"""A printer reporting in 10% steps sits on a plateau for a fortnight."""
|
||||||
|
result = analyse(series([70, 70, 70, 70, 70, 70]))
|
||||||
|
|
||||||
|
assert result['daysleft'] is None
|
||||||
|
assert result['reason'] == 'level has not moved enough to estimate'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_recent_replacement_says_so_rather_than_guessing():
|
||||||
|
result = analyse(series([40, 30, 20, 10, 100, 98]))
|
||||||
|
|
||||||
|
assert result['daysleft'] is None
|
||||||
|
assert result['reason'] == 'replaced recently'
|
||||||
|
assert result['replacements'] == 1
|
||||||
|
assert result['lastreplaced'] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_history_is_reported_not_crashed():
|
||||||
|
result = analyse([])
|
||||||
|
|
||||||
|
assert result['reason'] == 'no history'
|
||||||
|
assert result['currentlevel'] is None
|
||||||
|
assert result['replacements'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_junk_rows_are_dropped_not_fatal():
|
||||||
|
points = normalise([('notaclock', '50'), ('1780000000', 'n/a'),
|
||||||
|
('1780000000', '50')])
|
||||||
|
|
||||||
|
assert len(points) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_days_left_never_goes_negative():
|
||||||
|
result = analyse(series([20, 15, 10, 5, 0]))
|
||||||
|
|
||||||
|
assert result['daysleft'] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_printer_sorts_by_its_soonest_supply():
|
||||||
|
"""A colour MFP is as urgent as its most pressing cartridge."""
|
||||||
|
supplies = [{'daysleft': 40}, {'daysleft': 6}, {'daysleft': None}]
|
||||||
|
|
||||||
|
assert soonest(supplies) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_soonest_is_none_when_nothing_can_be_estimated():
|
||||||
|
assert soonest([{'daysleft': None}, {'daysleft': None}]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_burn_rate_needs_elapsed_time():
|
||||||
|
"""Several readings in the same second is not a rate."""
|
||||||
|
clock = str(int(START.timestamp()))
|
||||||
|
points = normalise([(clock, '90'), (clock, '80'), (clock, '70'), (clock, '60')])
|
||||||
|
|
||||||
|
assert burn_rate(points) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
|
||||||
|
"""A dashboard must be told the data is missing, not shown an empty list
|
||||||
|
that reads as 'nothing runs out soon'."""
|
||||||
|
response = client.get('/api/printers/supplies/forecast')
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.get_json()['data']
|
||||||
|
assert body['available'] is False
|
||||||
|
assert 'not configured' in body['reason']
|
||||||
|
assert body['printers'] == []
|
||||||
55
tests/test_plugins/test_tools.py
Normal file
55
tests/test_plugins/test_tools.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Tests for the Tech Tools plugin.
|
||||||
|
|
||||||
|
The plugin owns no API and no tables, so the whole backend contract is: it
|
||||||
|
loads, it advertises one nav entry, and it claims no database. That last part
|
||||||
|
matters - a plugin that accidentally returns models would pull the tools
|
||||||
|
section into the migration and prune-schema machinery it has no business in.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from plugins.tools.plugin import ToolsPlugin
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_DIR = os.path.join(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)))), 'plugins', 'tools')
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_matches_manifest():
|
||||||
|
"""The manifest is the single source of truth per ADR-002."""
|
||||||
|
with open(os.path.join(PLUGIN_DIR, 'manifest.json'), encoding='utf-8') as handle:
|
||||||
|
manifest = json.load(handle)
|
||||||
|
|
||||||
|
meta = ToolsPlugin().meta
|
||||||
|
assert meta.name == manifest['name']
|
||||||
|
assert meta.version == manifest['version']
|
||||||
|
assert meta.core_version == manifest['core_version']
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_backend_surface():
|
||||||
|
"""Client-side only: no blueprint to register, no tables to migrate."""
|
||||||
|
plugin = ToolsPlugin()
|
||||||
|
assert plugin.get_blueprint() is None
|
||||||
|
assert plugin.get_models() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_item():
|
||||||
|
items = ToolsPlugin().get_navigation_items()
|
||||||
|
assert len(items) == 1
|
||||||
|
item = items[0]
|
||||||
|
assert item['name'] == 'Tech Tools'
|
||||||
|
assert item['route'] == '/tools'
|
||||||
|
# The icon has to exist in AppLayout's iconMap or the nav entry renders
|
||||||
|
# with no glyph.
|
||||||
|
assert item['icon'] == 'wrench'
|
||||||
|
|
||||||
|
|
||||||
|
def test_nav_icon_is_mapped_in_the_frontend():
|
||||||
|
"""Guard the one cross-file coupling: nav icon name -> AppLayout iconMap."""
|
||||||
|
layout = os.path.join(os.path.dirname(PLUGIN_DIR), '..', 'frontend', 'src',
|
||||||
|
'views', 'AppLayout.vue')
|
||||||
|
with open(os.path.normpath(layout), encoding='utf-8') as handle:
|
||||||
|
source = handle.read()
|
||||||
|
for item in ToolsPlugin().get_navigation_items():
|
||||||
|
assert f"'{item['icon']}':" in source
|
||||||
110
tests/test_plugins/test_warranty_proof.py
Normal file
110
tests/test_plugins/test_warranty_proof.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Tests for the warranty proof-of-cover document.
|
||||||
|
|
||||||
|
A provider lookup answers whether a unit is covered; it does not produce the
|
||||||
|
invoice or the extended-warranty certificate. Those had nowhere to live, so
|
||||||
|
they stayed in somebody's mailbox. These pin the parts that are easy to get
|
||||||
|
wrong: the document is authenticated on the way out, one per warranty, and the
|
||||||
|
vendor's own filename survives so a download is recognisable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from plugins.warranty.models import Warranty
|
||||||
|
|
||||||
|
|
||||||
|
def _warranty(db, vendor='Dell'):
|
||||||
|
w = Warranty(vendor=vendor, servicetag='ABC123', provider='manual')
|
||||||
|
db.session.add(w)
|
||||||
|
db.session.commit()
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_sets_url_and_keeps_the_original_filename(client, db, auth_headers):
|
||||||
|
w = _warranty(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'%PDF-1.4 invoice'),
|
||||||
|
'Dell invoice 4471.pdf')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200, response.get_json()
|
||||||
|
body = response.get_json()['data']
|
||||||
|
assert body['proofurl'] == f'/api/warranty/proof/warranty-{w.warrantyid}.pdf'
|
||||||
|
assert body['prooffilename'] == 'Dell invoice 4471.pdf'
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_needs_a_token_and_uses_the_vendor_filename(client, db, auth_headers):
|
||||||
|
"""An invoice carries pricing and a service tag - not open like an asset photo."""
|
||||||
|
w = _warranty(db)
|
||||||
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'%PDF-1.4 invoice'), 'Dell invoice 4471.pdf')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
path = f'/api/warranty/proof/warranty-{w.warrantyid}.pdf'
|
||||||
|
|
||||||
|
assert client.get(path).status_code == 401
|
||||||
|
|
||||||
|
authed = client.get(path, headers=auth_headers)
|
||||||
|
assert authed.status_code == 200
|
||||||
|
assert authed.data == b'%PDF-1.4 invoice'
|
||||||
|
disposition = authed.headers.get('Content-Disposition', '')
|
||||||
|
assert 'attachment' in disposition
|
||||||
|
assert 'Dell invoice 4471.pdf' in disposition
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_an_unsupported_type(client, db, auth_headers):
|
||||||
|
w = _warranty(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'MZ'), 'setup.exe')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'Unsupported document type' in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_an_oversize_document(client, db, auth_headers, monkeypatch):
|
||||||
|
from plugins.warranty.api import routes
|
||||||
|
monkeypatch.setattr(routes, 'MAX_PROOF_BYTES', 512)
|
||||||
|
w = _warranty(db)
|
||||||
|
|
||||||
|
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'x' * 2048), 'scan.pdf')},
|
||||||
|
content_type='multipart/form-data',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'the limit is' in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_replacing_keeps_one_document_per_warranty(client, db, auth_headers):
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
from flask import current_app
|
||||||
|
w = _warranty(db)
|
||||||
|
|
||||||
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'first'), 'scan.png')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'%PDF second'), 'invoice.pdf')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
|
proofdir = os.path.join(current_app.instance_path, 'warrantyproofs')
|
||||||
|
files = glob.glob(os.path.join(proofdir, f'warranty-{w.warrantyid}.*'))
|
||||||
|
assert len(files) == 1 and files[0].endswith('.pdf')
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_clears_both_columns(client, db, auth_headers):
|
||||||
|
w = _warranty(db)
|
||||||
|
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
data={'file': (io.BytesIO(b'%PDF'), 'invoice.pdf')},
|
||||||
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
|
response = client.delete(f'/api/warranty/{w.warrantyid}/proof',
|
||||||
|
headers=auth_headers)
|
||||||
|
|
||||||
|
body = response.get_json()['data']
|
||||||
|
assert body['proofurl'] is None
|
||||||
|
assert body['prooffilename'] is None
|
||||||
Reference in New Issue
Block a user