diff --git a/deploy/site-profile-universal.json b/deploy/site-profile-universal.json index 03454d5b..64b7539e 100644 --- a/deploy/site-profile-universal.json +++ b/deploy/site-profile-universal.json @@ -13,6 +13,7 @@ "printedparts", "printers", "slides", + "tools", "usb", "warranty" ], diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index b488b84b..d95d150c 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -306,6 +306,9 @@ export const printersApi = { getDrivers(id) { return api.get(`/printers/${id}/drivers`) }, + supplyForecast(days = 90) { + return api.get('/printers/supplies/forecast', { params: { days } }) + }, lowSupplies() { return api.get('/printers/lowsupplies') }, @@ -477,6 +480,33 @@ export const applicationsApi = { delete(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 getVersions(appId) { return api.get(`/applications/${appId}/versions`) @@ -1088,6 +1118,15 @@ export const warrantyApi = { }, 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`) } } diff --git a/frontend/src/components/DashboardCards.vue b/frontend/src/components/DashboardCards.vue index 93e13a7b..72c37ab6 100644 --- a/frontend/src/components/DashboardCards.vue +++ b/frontend/src/components/DashboardCards.vue @@ -120,16 +120,27 @@ defineExpose({ load }) - - + + + + + + + diff --git a/frontend/src/utils/fiscalWeek.js b/frontend/src/utils/fiscalWeek.js new file mode 100644 index 00000000..883fed35 --- /dev/null +++ b/frontend/src/utils/fiscalWeek.js @@ -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() +} diff --git a/frontend/src/utils/fiscalWeek.spec.js b/frontend/src/utils/fiscalWeek.spec.js new file mode 100644 index 00000000..a65888a5 --- /dev/null +++ b/frontend/src/utils/fiscalWeek.spec.js @@ -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)) + }) +}) diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 6ae28bce..bc8146d4 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -104,7 +104,7 @@ import ToastHost from '../components/ToastHost.vue' import { Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor, Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler, - Box, KeyRound, LogOut + Box, KeyRound, LogOut, Wrench } from 'lucide-vue-next' import { useAuthStore } from '../stores/auth' import { currentTheme, toggleTheme } from '../stores/theme' @@ -166,6 +166,7 @@ const iconMap = { 'shield': ShieldCheck, 'ruler': Ruler, 'box': Box, + 'wrench': Wrench, } // Default navigation (used as fallback if API fails) diff --git a/plugins/applications/frontend/routes.js b/plugins/applications/frontend/routes.js index 9c1ea751..8404acc9 100644 --- a/plugins/applications/frontend/routes.js +++ b/plugins/applications/frontend/routes.js @@ -25,3 +25,8 @@ export default [ 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 = [] diff --git a/plugins/applications/frontend/views/ApplicationDetail.vue b/plugins/applications/frontend/views/ApplicationDetail.vue index 833df5c0..88fb5734 100644 --- a/plugins/applications/frontend/views/ApplicationDetail.vue +++ b/plugins/applications/frontend/views/ApplicationDetail.vue @@ -1,441 +1,452 @@ - - - - - + + + + + diff --git a/plugins/applications/frontend/views/ApplicationForm.vue b/plugins/applications/frontend/views/ApplicationForm.vue index d6fc1a61..5a7736a5 100644 --- a/plugins/applications/frontend/views/ApplicationForm.vue +++ b/plugins/applications/frontend/views/ApplicationForm.vue @@ -111,18 +111,59 @@ class="form-control" placeholder="Network path or URL to install files" /> + + A share path or URL, or upload the installer below and this fills + itself in. +
- + +
+ {{ uploadedPackage }} + +
+ +
+
+ {{ packageProgress }}% +
+ + Up to 500MB. Anything larger belongs on the share - put its path in + Install Path instead. Downloads require a login. + +
+ +
+ +
+ + +
+ + + PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now. + +
+ +
+ - Image should be placed in /images/applications/ + + 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. +

Notes

@@ -184,6 +225,76 @@ const form = 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 () => { try { // Load support teams @@ -210,6 +321,10 @@ onMounted(async () => { image: app.image || '', applicationnotes: app.applicationnotes || '' } + imagePreview.value = imageSrcFor(app.image) + if ((app.installpath || '').startsWith('/api/applications/package/')) { + uploadedPackage.value = app.installpath.split('/').pop() + } } } catch (err) { console.error('Error loading data:', err) @@ -240,16 +355,34 @@ async function saveApplication() { applicationnotes: form.value.applicationnotes || null } + let appid = route.params.id if (isEdit.value) { - await applicationsApi.update(route.params.id, appData) + await applicationsApi.update(appid, appData) } 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') } catch (err) { console.error('Error saving application:', err) error.value = apiError(err, 'Failed to save application') + packageProgress.value = null } finally { saving.value = false } @@ -257,6 +390,46 @@ async function saveApplication() { diff --git a/plugins/printers/frontend/views/TonerReport.vue b/plugins/printers/frontend/views/TonerReport.vue index b0c35000..59717eaf 100644 --- a/plugins/printers/frontend/views/TonerReport.vue +++ b/plugins/printers/frontend/views/TonerReport.vue @@ -53,22 +53,42 @@ Printer Name - Asset # - Location - IP Address + Hostname Supplies - + + + + {{ printer.printername || 'Unknown' }} + + + {{ printer.printername || 'Unknown' }} - {{ printer.assetnumber }} - {{ printer.location || '-' }} - {{ printer.ipaddress }} + + + {{ fqdnFor(printer) }} + {{ printer.ipaddress || '-' }} +
{{ supply.level }}% + + {{ part.partnumber }} + - +
@@ -102,13 +132,14 @@ import { ref, computed, onMounted } from 'vue' import { printersApi } from '@/api' import EmailReportButton from '@/components/EmailReportButton.vue' +import LocationMapTooltip from '@/components/LocationMapTooltip.vue' +import { getPrinterHostnameTemplate } from '@/utils/siteSettings' const emailColumns = [ { key: 'printer', label: 'Printer' }, - { key: 'assetnumber', label: 'Asset #' }, - { key: 'location', label: 'Location' }, - { key: 'ipaddress', label: 'IP Address' }, + { key: 'hostname', label: 'Hostname' }, { key: 'supply', label: 'Supply' }, + { key: 'partnumber', label: 'Part Number' }, { key: 'level', label: 'Level' }, { key: 'status', label: 'Status' }, ] @@ -125,6 +156,47 @@ const filterOptions = [ { 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(() => { if (filter.value === 'all') return printers.value return printers.value.filter(p => @@ -132,36 +204,17 @@ const filteredPrinters = computed(() => { ) }) -// One row per supply, honoring the active filter, for the emailed table. -const emailRows = computed(() => { - const rows = [] - 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 -}) +// Emailed table, honoring the active filter. +const emailRows = computed(() => + reportRows().map(row => ({ ...row, level: row.level + '%' })) +) function exportCSV() { - // one row per supply, honoring the active filter const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"` - const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']] - for (const printer of filteredPrinters.value) { - for (const supply of printer.supplies || []) { - rows.push([ - printer.printername || '', printer.assetnumber || '', printer.location || '', - printer.ipaddress || '', supply.name || '', supply.level, supply.status || '' - ]) - } + const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status'] + const rows = [header] + for (const row of reportRows()) { + rows.push(header.map(key => row[key])) } const csv = rows.map(row => row.map(quote).join(',')).join('\n') const link = document.createElement('a') @@ -173,6 +226,7 @@ function exportCSV() { onMounted(async () => { try { + hostnameTemplate.value = await getPrinterHostnameTemplate() const response = await printersApi.lowSupplies() const data = response.data.data printers.value = data.printers || [] @@ -218,32 +272,31 @@ onMounted(async () => { 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 { - 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 { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.35rem; -} - -.supply-row:last-child { - margin-bottom: 0; + display: contents; } .supply-name { - flex: 0 0 120px; font-size: 0.85rem; color: var(--text-light); - overflow: hidden; - text-overflow: ellipsis; white-space: nowrap; } .supply-bar-track { - flex: 1; height: 8px; background: var(--border); border-radius: 4px; @@ -269,12 +322,33 @@ onMounted(async () => { } .supply-level { - flex: 0 0 40px; + min-width: 40px; text-align: right; font-size: 0.85rem; 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 { color: var(--success); } diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 96c684d9..4e6b0758 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -358,6 +358,17 @@ class PrintersPlugin(BasePlugin): 'category': 'printers', '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: diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py index 10cde9a9..ffe803b4 100644 --- a/plugins/printers/services/__init__.py +++ b/plugins/printers/services/__init__.py @@ -9,6 +9,7 @@ from .supply_parts import ( alerttier, ) from .supply_alerts import check_supplies +from .supply_history import analyse, soonest from .seed_supplies import seedsupplies __all__ = [ @@ -19,5 +20,7 @@ __all__ = [ 'lookupsupplies', 'alerttier', 'check_supplies', + 'analyse', + 'soonest', 'seedsupplies', ] diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py new file mode 100644 index 00000000..15b19665 --- /dev/null +++ b/plugins/printers/services/supply_history.py @@ -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 diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py index ddd8e603..e49db039 100644 --- a/plugins/printers/services/zabbix_service.py +++ b/plugins/printers/services/zabbix_service.py @@ -19,6 +19,7 @@ Configuration (database Setting overrides env var): """ import logging +import time from typing import Dict, List, Optional import requests @@ -219,6 +220,39 @@ class ZabbixService: }) 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: """ICMP ping state for a printer: '1' up, '0' down, '-1' unknown.""" hostid = self.gethostidbyip(ip) diff --git a/plugins/warranty/api/routes.py b/plugins/warranty/api/routes.py index 85fd0da1..11011a42 100644 --- a/plugins/warranty/api/routes.py +++ b/plugins/warranty/api/routes.py @@ -5,11 +5,14 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though the common case is one warranty per asset. """ +import glob +import os 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 sqlalchemy.orm import joinedload +from werkzeug.utils import secure_filename from shopdb.api import ( db, Asset, @@ -22,6 +25,21 @@ from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError 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): """Accept 'YYYY-MM-DD' (or None/empty) -> date or None.""" @@ -466,3 +484,100 @@ def _hostname(asset): return None computer = Computer.query.filter_by(assetid=asset.assetid).first() 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-, one per warranty, with the vendor's own +# filename kept alongside so a person recognises it later. +# ============================================================================= + +@warranty_bp.route('//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/', 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('//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') diff --git a/plugins/warranty/frontend/routes.js b/plugins/warranty/frontend/routes.js index b5c2c291..a535b415 100644 --- a/plugins/warranty/frontend/routes.js +++ b/plugins/warranty/frontend/routes.js @@ -21,3 +21,8 @@ export default [ 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 = [] diff --git a/plugins/warranty/frontend/views/WarrantiesList.vue b/plugins/warranty/frontend/views/WarrantiesList.vue index fdbf34e6..e208cc04 100644 --- a/plugins/warranty/frontend/views/WarrantiesList.vue +++ b/plugins/warranty/frontend/views/WarrantiesList.vue @@ -151,6 +151,21 @@ + +
+ +
+ {{ proofName }} + {{ proofName }} + +
+ + + Invoice, certificate or a saved email - up to 25MB. Downloading + needs a login, since it carries pricing and a service tag. + +
{{ error }}