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