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:
@@ -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 = []
|
||||
|
||||
@@ -1,441 +1,452 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
|
||||
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
|
||||
</div>
|
||||
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
|
||||
<a v-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">🔗</span> Launch Application
|
||||
</a>
|
||||
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
<template v-if="contact.sso && contactEmailDomain">
|
||||
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
|
||||
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<!-- Notes are authored as HTML (form says "HTML supported");
|
||||
render sanitized so scripts/handlers cannot slip in. -->
|
||||
<div class="notes-text" v-html="sanitizedNotes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">
|
||||
<span
|
||||
v-for="(kw, i) in kbKeywords(kb.keywords)"
|
||||
:key="i"
|
||||
class="kb-tag"
|
||||
>{{ kw }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '@/api'
|
||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||
import { fileHref } from '@/utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const app = ref(null)
|
||||
const versions = ref([])
|
||||
const installedOn = ref([])
|
||||
const contactEmailDomain = ref('')
|
||||
|
||||
// Application notes are HTML; sanitize before binding with v-html.
|
||||
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
|
||||
|
||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||
function contactEmail(contact) {
|
||||
return `${contact.sso}@${contactEmailDomain.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
// Keywords are stored space-separated; split into chips for the KB card.
|
||||
function kbKeywords(keywords) {
|
||||
return (keywords || '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Knowledge Base card: each entry is a bordered block, not a run-on line.
|
||||
shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
|
||||
small chips below instead of a second wall of text. */
|
||||
.kb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.kb-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.kb-title {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kb-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.kb-tag {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notes-text :first-child { margin-top: 0; }
|
||||
.notes-text :last-child { margin-bottom: 0; }
|
||||
.notes-text p { margin: 0 0 0.6rem; }
|
||||
.notes-text ul,
|
||||
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
|
||||
.notes-text a { color: var(--link); }
|
||||
.notes-text code,
|
||||
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
|
||||
</style>
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="imageSrc" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
|
||||
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
|
||||
</div>
|
||||
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
|
||||
<a v-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">🔗</span> Launch Application
|
||||
</a>
|
||||
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
<template v-if="contact.sso && contactEmailDomain">
|
||||
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
|
||||
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<!-- Notes are authored as HTML (form says "HTML supported");
|
||||
render sanitized so scripts/handlers cannot slip in. -->
|
||||
<div class="notes-text" v-html="sanitizedNotes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">
|
||||
<span
|
||||
v-for="(kw, i) in kbKeywords(kb.keywords)"
|
||||
:key="i"
|
||||
class="kb-tag"
|
||||
>{{ kw }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '@/api'
|
||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||
import { fileHref } from '@/utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
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 installedOn = ref([])
|
||||
const contactEmailDomain = ref('')
|
||||
|
||||
// Application notes are HTML; sanitize before binding with v-html.
|
||||
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
|
||||
|
||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||
function contactEmail(contact) {
|
||||
return `${contact.sso}@${contactEmailDomain.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
// Keywords are stored space-separated; split into chips for the KB card.
|
||||
function kbKeywords(keywords) {
|
||||
return (keywords || '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Knowledge Base card: each entry is a bordered block, not a run-on line.
|
||||
shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
|
||||
small chips below instead of a second wall of text. */
|
||||
.kb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.kb-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.kb-title {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kb-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.kb-tag {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notes-text :first-child { margin-top: 0; }
|
||||
.notes-text :last-child { margin-bottom: 0; }
|
||||
.notes-text p { margin: 0 0 0.6rem; }
|
||||
.notes-text ul,
|
||||
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
|
||||
.notes-text a { color: var(--link); }
|
||||
.notes-text code,
|
||||
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
|
||||
</style>
|
||||
|
||||
@@ -111,18 +111,59 @@
|
||||
class="form-control"
|
||||
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 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
|
||||
id="image"
|
||||
v-model="form.image"
|
||||
type="text"
|
||||
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>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
||||
@@ -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() {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
@@ -19,3 +19,8 @@ export default [
|
||||
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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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']))
|
||||
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'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner-forecast',
|
||||
name: 'toner-forecast',
|
||||
component: () => import('./views/TonerForecast.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printertypes',
|
||||
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>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Asset #</th>
|
||||
<th>Location</th>
|
||||
<th>IP Address</th>
|
||||
<th>Hostname</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<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' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ printer.assetnumber }}</td>
|
||||
<td>{{ printer.location || '-' }}</td>
|
||||
<td>{{ printer.ipaddress }}</td>
|
||||
<td>
|
||||
<!-- Opens the printer's own web page. New tab: the report is a
|
||||
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">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
@@ -86,6 +106,16 @@
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -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-<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' }
|
||||
}
|
||||
]
|
||||
|
||||
// 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>
|
||||
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
|
||||
</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>
|
||||
<div class="modal-footer">
|
||||
@@ -167,7 +182,7 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||
import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import MachineMapChip from '../components/MachineMapChip.vue'
|
||||
@@ -315,7 +330,10 @@ function openModal(item = null) {
|
||||
} else {
|
||||
form.value = blankForm()
|
||||
selectedAssets.value = []
|
||||
proofName.value = ''
|
||||
proofUrl.value = ''
|
||||
}
|
||||
pendingProof.value = null
|
||||
assetQuery.value = ''
|
||||
assetResults.value = []
|
||||
error.value = ''
|
||||
@@ -323,15 +341,75 @@ function openModal(item = 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() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
|
||||
let warrantyid
|
||||
if (editing.value) {
|
||||
await warrantyApi.update(editing.value.warrantyid, payload)
|
||||
warrantyid = editing.value.warrantyid
|
||||
await warrantyApi.update(warrantyid, payload)
|
||||
} 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()
|
||||
loadData()
|
||||
@@ -390,6 +468,8 @@ async function refresh(w) {
|
||||
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.filters .form-control { max-width: 320px; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
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.
|
||||
lastcheckeddate = db.Column(db.DateTime)
|
||||
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')
|
||||
|
||||
links = db.relationship('WarrantyAsset', back_populates='warranty',
|
||||
@@ -68,6 +76,8 @@ class Warranty(db.Model):
|
||||
'enddate': self.enddate.isoformat() if self.enddate else None,
|
||||
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
|
||||
'notes': self.notes,
|
||||
'proofurl': self.proofurl,
|
||||
'prooffilename': self.prooffilename,
|
||||
'isactive': bool(self.isactive),
|
||||
'status': status,
|
||||
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
||||
|
||||
Reference in New Issue
Block a user