Reset to page one when a filter changes, and let the catalog carry a real type
Two unrelated things found while looking at blank printer types. Selecting a filter while past page one returned an empty list. The filter asked the server for page 5 of a result set that now had one page, and the screen said nothing matched. useListQuery already resets the page - setSearch and setExtra both do - but the filter dropdowns bypassed it and called the loader directly. Nine list pages now route through applyFilter, which calls setPage(1) when it needs to and loads directly when already on page one, so the composable's URL watcher does not also fire and fetch twice. scripts/retype_models.py addresses why printer types cannot be derived. The catalog types every printer model "Printer": true, and useless, since it does not say whether the product is a laser, a plotter or a label printer. That answer is a property of the model - every VersaLink C405 is a laser MFP - but nothing recorded it, so nothing could derive it. Recording it on the MODEL means the existing backfill fills every printer by exact name match, and a printer added later inherits the right type the moment its model is chosen. It exports the models needing a decision to CSV with a type suggested from the model number, a person corrects the column, and applying it is a dry run unless given --commit. A suggested type is refused unless it already exists in that asset class's own vocabulary, which is what keeps the later name match working. The suggestion order matters and got this wrong first time: a generic plotter pattern matched "Zebra ZT411" and filed a label printer as a plotter. Brands now come before generic patterns, and the review step exists precisely because a confident wrong guess would type every asset using that model. Verified on the development database: 24 printer models need a decision, 22 got a sensible suggestion, applying them let all 42 printers match a printertype by name, and the transaction rolled back cleanly.
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
placeholder="Search models..."
|
placeholder="Search models..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="vendorFilter" class="form-control" @change="loadModels">
|
<select v-model="vendorFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Vendors</option>
|
<option value="">All Vendors</option>
|
||||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||||
{{ v.vendor }}
|
{{ v.vendor }}
|
||||||
@@ -272,6 +272,19 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadModels()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadModels() {
|
async function loadModels() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
placeholder="Search applications..."
|
placeholder="Search applications..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="filter" class="form-control" @change="loadApplications">
|
<select v-model="filter" class="form-control" @change="applyFilter">
|
||||||
<option value="installable">Installable Applications</option>
|
<option value="installable">Installable Applications</option>
|
||||||
<option value="all">All Applications</option>
|
<option value="all">All Applications</option>
|
||||||
<option value="hidden">Hidden Applications</option>
|
<option value="hidden">Hidden Applications</option>
|
||||||
@@ -117,6 +117,19 @@ onMounted(() => {
|
|||||||
loadApplications()
|
loadApplications()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadApplications()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadApplications() {
|
async function loadApplications() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,263 +1,276 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h2>Knowledge Base</h2>
|
<h2>Knowledge Base</h2>
|
||||||
<span v-if="stats" class="stats-badge">
|
<span v-if="stats" class="stats-badge">
|
||||||
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
|
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
|
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input
|
<input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Search articles..."
|
placeholder="Search articles..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="topicFilter" class="form-control" @change="loadArticles">
|
<select v-model="topicFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Topics</option>
|
<option value="">All Topics</option>
|
||||||
<option
|
<option
|
||||||
v-for="app in topics"
|
v-for="app in topics"
|
||||||
:key="app.appid"
|
:key="app.appid"
|
||||||
:value="app.appid"
|
:value="app.appid"
|
||||||
>
|
>
|
||||||
{{ app.appname }}
|
{{ app.appname }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable" @click="toggleSort('topic')">
|
<th class="sortable" @click="toggleSort('topic')">
|
||||||
Topic
|
Topic
|
||||||
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sortable" @click="toggleSort('description')">
|
<th class="sortable" @click="toggleSort('description')">
|
||||||
Description
|
Description
|
||||||
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||||
</th>
|
</th>
|
||||||
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
|
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
|
||||||
Clicks
|
Clicks
|
||||||
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||||
</th>
|
</th>
|
||||||
<th style="width: 120px;">Actions</th>
|
<th style="width: 120px;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="article in articles" :key="article.linkid">
|
<tr v-for="article in articles" :key="article.linkid">
|
||||||
<td>
|
<td>
|
||||||
<router-link
|
<router-link
|
||||||
v-if="article.application"
|
v-if="article.application"
|
||||||
:to="`/applications/${article.application.appid}`"
|
:to="`/applications/${article.application.appid}`"
|
||||||
>
|
>
|
||||||
{{ article.application.appname }}
|
{{ article.application.appname }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else class="text-muted">-</span>
|
<span v-else class="text-muted">-</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="article-link"
|
class="article-link"
|
||||||
@click.prevent="openArticle(article)"
|
@click.prevent="openArticle(article)"
|
||||||
:title="article.linkurl"
|
:title="article.linkurl"
|
||||||
>
|
>
|
||||||
{{ truncate(article.shortdescription, 95) }}
|
{{ truncate(article.shortdescription, 95) }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
|
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
|
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
|
||||||
View
|
View
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
|
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
|
||||||
Edit
|
Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="articles.length === 0">
|
<tr v-if="articles.length === 0">
|
||||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||||
No articles found
|
No articles found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<PaginationBar
|
<PaginationBar
|
||||||
:page="page"
|
:page="page"
|
||||||
:totalPages="totalPages"
|
:totalPages="totalPages"
|
||||||
:perPage="perPage"
|
:perPage="perPage"
|
||||||
@update:page="goToPage"
|
@update:page="goToPage"
|
||||||
@update:perPage="changePerPage"
|
@update:perPage="changePerPage"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { knowledgebaseApi, applicationsApi } from '@/api'
|
import { knowledgebaseApi, applicationsApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const topics = ref([])
|
const topics = ref([])
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const topicFilter = ref('')
|
const topicFilter = ref('')
|
||||||
const sort = ref('clicks')
|
const sort = ref('clicks')
|
||||||
const order = ref('desc')
|
const order = ref('desc')
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadArticles(),
|
loadArticles(),
|
||||||
loadTopics(),
|
loadTopics(),
|
||||||
loadStats()
|
loadStats()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadArticles() {
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
loading.value = true
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
try {
|
// list came back empty as though the filter matched nothing.
|
||||||
const params = {
|
//
|
||||||
page: page.value,
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
perpage: perPage.value,
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
sort: sort.value,
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
order: order.value
|
// watcher stays silent.
|
||||||
}
|
function applyFilter() {
|
||||||
if (search.value) params.search = search.value
|
if (page.value > 1) setPage(1)
|
||||||
if (topicFilter.value) params.appid = topicFilter.value
|
else loadArticles()
|
||||||
|
}
|
||||||
const response = await knowledgebaseApi.list(params)
|
|
||||||
articles.value = response.data.data || []
|
async function loadArticles() {
|
||||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
loading.value = true
|
||||||
} catch (error) {
|
try {
|
||||||
console.error('Error loading articles:', error)
|
const params = {
|
||||||
} finally {
|
page: page.value,
|
||||||
loading.value = false
|
perpage: perPage.value,
|
||||||
}
|
sort: sort.value,
|
||||||
}
|
order: order.value
|
||||||
|
}
|
||||||
async function loadTopics() {
|
if (search.value) params.search = search.value
|
||||||
try {
|
if (topicFilter.value) params.appid = topicFilter.value
|
||||||
const response = await applicationsApi.list({ perpage: 1000 })
|
|
||||||
topics.value = response.data.data || []
|
const response = await knowledgebaseApi.list(params)
|
||||||
} catch (error) {
|
articles.value = response.data.data || []
|
||||||
console.error('Error loading topics:', error)
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
}
|
} catch (error) {
|
||||||
}
|
console.error('Error loading articles:', error)
|
||||||
|
} finally {
|
||||||
async function loadStats() {
|
loading.value = false
|
||||||
try {
|
}
|
||||||
const response = await knowledgebaseApi.getStats()
|
}
|
||||||
stats.value = response.data.data
|
|
||||||
} catch (error) {
|
async function loadTopics() {
|
||||||
console.error('Error loading stats:', error)
|
try {
|
||||||
}
|
const response = await applicationsApi.list({ perpage: 1000 })
|
||||||
}
|
topics.value = response.data.data || []
|
||||||
|
} catch (error) {
|
||||||
function debouncedSearch() {
|
console.error('Error loading topics:', error)
|
||||||
clearTimeout(searchTimeout)
|
}
|
||||||
searchTimeout = setTimeout(() => {
|
}
|
||||||
setSearch(search.value)
|
|
||||||
loadArticles()
|
async function loadStats() {
|
||||||
}, 300)
|
try {
|
||||||
}
|
const response = await knowledgebaseApi.getStats()
|
||||||
|
stats.value = response.data.data
|
||||||
function goToPage(p) {
|
} catch (error) {
|
||||||
setPage(p)
|
console.error('Error loading stats:', error)
|
||||||
loadArticles()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePerPage(newPerPage) {
|
function debouncedSearch() {
|
||||||
perPage.value = newPerPage
|
clearTimeout(searchTimeout)
|
||||||
setPage(1)
|
searchTimeout = setTimeout(() => {
|
||||||
loadArticles()
|
setSearch(search.value)
|
||||||
}
|
loadArticles()
|
||||||
|
}, 300)
|
||||||
function toggleSort(column) {
|
}
|
||||||
if (sort.value === column) {
|
|
||||||
order.value = order.value === 'desc' ? 'asc' : 'desc'
|
function goToPage(p) {
|
||||||
} else {
|
setPage(p)
|
||||||
sort.value = column
|
loadArticles()
|
||||||
order.value = 'desc'
|
}
|
||||||
}
|
|
||||||
loadArticles()
|
function changePerPage(newPerPage) {
|
||||||
}
|
perPage.value = newPerPage
|
||||||
|
setPage(1)
|
||||||
function truncate(text, length) {
|
loadArticles()
|
||||||
if (!text) return ''
|
}
|
||||||
if (text.length <= length) return text
|
|
||||||
return text.substring(0, length) + '...'
|
function toggleSort(column) {
|
||||||
}
|
if (sort.value === column) {
|
||||||
|
order.value = order.value === 'desc' ? 'asc' : 'desc'
|
||||||
async function openArticle(article) {
|
} else {
|
||||||
try {
|
sort.value = column
|
||||||
await knowledgebaseApi.trackClick(article.linkid)
|
order.value = 'desc'
|
||||||
article.clicks = (article.clicks || 0) + 1
|
}
|
||||||
if (stats.value) {
|
loadArticles()
|
||||||
stats.value.totalclicks++
|
}
|
||||||
}
|
|
||||||
if (article.linkurl) {
|
function truncate(text, length) {
|
||||||
window.open(article.linkurl, '_blank')
|
if (!text) return ''
|
||||||
}
|
if (text.length <= length) return text
|
||||||
} catch (error) {
|
return text.substring(0, length) + '...'
|
||||||
console.error('Error tracking click:', error)
|
}
|
||||||
if (article.linkurl) {
|
|
||||||
window.open(article.linkurl, '_blank')
|
async function openArticle(article) {
|
||||||
}
|
try {
|
||||||
}
|
await knowledgebaseApi.trackClick(article.linkid)
|
||||||
}
|
article.clicks = (article.clicks || 0) + 1
|
||||||
</script>
|
if (stats.value) {
|
||||||
|
stats.value.totalclicks++
|
||||||
<style scoped>
|
}
|
||||||
/* Knowledge Base specific styles only */
|
if (article.linkurl) {
|
||||||
.header-left {
|
window.open(article.linkurl, '_blank')
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
} catch (error) {
|
||||||
gap: 1rem;
|
console.error('Error tracking click:', error)
|
||||||
}
|
if (article.linkurl) {
|
||||||
|
window.open(article.linkurl, '_blank')
|
||||||
.header-left h2 {
|
}
|
||||||
margin: 0;
|
}
|
||||||
}
|
}
|
||||||
|
</script>
|
||||||
.stats-badge {
|
|
||||||
background: var(--bg);
|
<style scoped>
|
||||||
color: var(--link);
|
/* Knowledge Base specific styles only */
|
||||||
padding: 0.5rem 1rem;
|
.header-left {
|
||||||
border-radius: 6px;
|
display: flex;
|
||||||
font-size: 1rem;
|
align-items: center;
|
||||||
font-weight: 500;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-link {
|
.header-left h2 {
|
||||||
color: var(--text);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-link:hover {
|
.stats-badge {
|
||||||
color: var(--link);
|
background: var(--bg);
|
||||||
}
|
color: var(--link);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
.text-muted {
|
border-radius: 6px;
|
||||||
color: var(--text-light);
|
font-size: 1rem;
|
||||||
}
|
font-weight: 500;
|
||||||
</style>
|
}
|
||||||
|
|
||||||
|
.article-link {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-link:hover {
|
||||||
|
color: var(--link);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -33,13 +33,13 @@
|
|||||||
placeholder="Search by hostname, asset #, serial..."
|
placeholder="Search by hostname, asset #, serial..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
|
<select v-model="vendorFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Vendors</option>
|
<option value="">All Vendors</option>
|
||||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||||
{{ v.vendor }}
|
{{ v.vendor }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="locationFilter" class="form-control" @change="loadDevices">
|
<select v-model="locationFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Locations</option>
|
<option value="">All Locations</option>
|
||||||
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
||||||
{{ loc.locationname }}
|
{{ loc.locationname }}
|
||||||
@@ -193,6 +193,19 @@ async function loadLocations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
placeholder="Search subnets..."
|
placeholder="Search subnets..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="vlanFilter" class="form-control" @change="loadSubnets">
|
<select v-model="vlanFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All VLANs</option>
|
<option value="">All VLANs</option>
|
||||||
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
|
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
|
||||||
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
|
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="typeFilter" class="form-control" @change="loadSubnets">
|
<select v-model="typeFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="ipv4">IPv4</option>
|
<option value="ipv4">IPv4</option>
|
||||||
<option value="ipv6">IPv6</option>
|
<option value="ipv6">IPv6</option>
|
||||||
@@ -359,6 +359,19 @@ async function loadLocations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadSubnets()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSubnets() {
|
async function loadSubnets() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
placeholder="Search VLANs..."
|
placeholder="Search VLANs..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
|
<select v-model="typeFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="data">Data</option>
|
<option value="data">Data</option>
|
||||||
<option value="voice">Voice</option>
|
<option value="voice">Voice</option>
|
||||||
@@ -220,6 +220,19 @@ onMounted(() => {
|
|||||||
loadVLANs()
|
loadVLANs()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadVLANs()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadVLANs() {
|
async function loadVLANs() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,185 +1,198 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Notifications</h1>
|
<h1>Notifications</h1>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
|
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
|
||||||
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
|
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Search notifications..."
|
placeholder="Search notifications..."
|
||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<select v-model="selectedType" class="form-control" @change="loadNotifications">
|
<select v-model="selectedType" class="form-control" @change="applyFilter">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
|
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
|
||||||
{{ type.typename }}
|
{{ type.typename }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<select v-model="currentFilter" class="form-control" @change="loadNotifications">
|
<select v-model="currentFilter" class="form-control" @change="applyFilter">
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="current">Current Only</option>
|
<option value="current">Current Only</option>
|
||||||
<option value="pinned">Pinned Only</option>
|
<option value="pinned">Pinned Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
<div v-else-if="notifications.length === 0" class="empty">
|
<div v-else-if="notifications.length === 0" class="empty">
|
||||||
No notifications found.
|
No notifications found.
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="table-container">
|
<div v-else class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Start Date</th>
|
<th>Start Date</th>
|
||||||
<th>End Date</th>
|
<th>End Date</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="notification in notifications" :key="notification.notificationid">
|
<tr v-for="notification in notifications" :key="notification.notificationid">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/notifications/${notification.notificationid}/edit`">
|
<router-link :to="`/notifications/${notification.notificationid}/edit`">
|
||||||
{{ notification.title }}
|
{{ notification.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
|
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
|
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
|
||||||
{{ notification.typename }}
|
{{ notification.typename }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(notification.startdate) }}</td>
|
<td>{{ formatDate(notification.startdate) }}</td>
|
||||||
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
|
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
|
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
|
||||||
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
|
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
|
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
|
||||||
Edit
|
Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<PaginationBar
|
<PaginationBar
|
||||||
:page="page"
|
:page="page"
|
||||||
:totalPages="totalPages"
|
:totalPages="totalPages"
|
||||||
:perPage="perPage"
|
:perPage="perPage"
|
||||||
@update:page="goToPage"
|
@update:page="goToPage"
|
||||||
@update:perPage="changePerPage"
|
@update:perPage="changePerPage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { notificationsApi, settingsApi } from '@/api'
|
import { notificationsApi, settingsApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
|
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
|
||||||
|
|
||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const types = ref([])
|
const types = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
|
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
|
||||||
const selectedType = ref('')
|
const selectedType = ref('')
|
||||||
const currentFilter = ref('')
|
const currentFilter = ref('')
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const siteTimezone = ref(DEFAULT_TZ)
|
const siteTimezone = ref(DEFAULT_TZ)
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const tzRes = await settingsApi.get('site_timezone')
|
const tzRes = await settingsApi.get('site_timezone')
|
||||||
const tzValue = tzRes?.data?.data?.value
|
const tzValue = tzRes?.data?.data?.value
|
||||||
if (tzValue) siteTimezone.value = tzValue
|
if (tzValue) siteTimezone.value = tzValue
|
||||||
} catch (e) { /* keep default tz */ }
|
} catch (e) { /* keep default tz */ }
|
||||||
await loadTypes()
|
await loadTypes()
|
||||||
await loadNotifications()
|
await loadNotifications()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadTypes() {
|
async function loadTypes() {
|
||||||
try {
|
try {
|
||||||
const response = await notificationsApi.types.list()
|
const response = await notificationsApi.types.list()
|
||||||
types.value = response.data.data
|
types.value = response.data.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading types:', error)
|
console.error('Error loading types:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadNotifications() {
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
loading.value = true
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
try {
|
// list came back empty as though the filter matched nothing.
|
||||||
const params = {
|
//
|
||||||
page: page.value,
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
perpage: perPage.value
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
}
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
if (searchQuery.value) {
|
function applyFilter() {
|
||||||
params.search = searchQuery.value
|
if (page.value > 1) setPage(1)
|
||||||
}
|
else loadNotifications()
|
||||||
if (selectedType.value) {
|
}
|
||||||
params.typeid = selectedType.value
|
|
||||||
}
|
async function loadNotifications() {
|
||||||
if (currentFilter.value === 'current') {
|
loading.value = true
|
||||||
params.current = 'true'
|
try {
|
||||||
} else if (currentFilter.value === 'pinned') {
|
const params = {
|
||||||
params.pinned = 'true'
|
page: page.value,
|
||||||
}
|
perpage: perPage.value
|
||||||
|
}
|
||||||
const response = await notificationsApi.list(params)
|
|
||||||
notifications.value = response.data.data
|
if (searchQuery.value) {
|
||||||
total.value = response.data.meta?.pagination?.total || notifications.value.length
|
params.search = searchQuery.value
|
||||||
totalPages.value = Math.ceil(total.value / perPage.value)
|
}
|
||||||
} catch (error) {
|
if (selectedType.value) {
|
||||||
console.error('Error loading notifications:', error)
|
params.typeid = selectedType.value
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
if (currentFilter.value === 'current') {
|
||||||
}
|
params.current = 'true'
|
||||||
}
|
} else if (currentFilter.value === 'pinned') {
|
||||||
|
params.pinned = 'true'
|
||||||
function debouncedSearch() {
|
}
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
searchTimeout = setTimeout(() => {
|
const response = await notificationsApi.list(params)
|
||||||
setSearch(searchQuery.value)
|
notifications.value = response.data.data
|
||||||
loadNotifications()
|
total.value = response.data.meta?.pagination?.total || notifications.value.length
|
||||||
}, 300)
|
totalPages.value = Math.ceil(total.value / perPage.value)
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('Error loading notifications:', error)
|
||||||
function goToPage(p) {
|
} finally {
|
||||||
setPage(p)
|
loading.value = false
|
||||||
loadNotifications()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePerPage(newPerPage) {
|
function debouncedSearch() {
|
||||||
perPage.value = newPerPage
|
clearTimeout(searchTimeout)
|
||||||
setPage(1)
|
searchTimeout = setTimeout(() => {
|
||||||
loadNotifications()
|
setSearch(searchQuery.value)
|
||||||
}
|
loadNotifications()
|
||||||
|
}, 300)
|
||||||
function formatDate(dateStr) {
|
}
|
||||||
if (!dateStr) return ''
|
|
||||||
// startdate/enddate are UTC; show the site-zone calendar date.
|
function goToPage(p) {
|
||||||
return formatInZone(dateStr, siteTimezone.value, {
|
setPage(p)
|
||||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined
|
loadNotifications()
|
||||||
})
|
}
|
||||||
}
|
|
||||||
</script>
|
function changePerPage(newPerPage) {
|
||||||
|
perPage.value = newPerPage
|
||||||
|
setPage(1)
|
||||||
|
loadNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
// startdate/enddate are UTC; show the site-zone calendar date.
|
||||||
|
return formatInZone(dateStr, siteTimezone.value, {
|
||||||
|
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<label class="lowstock-filter">
|
<label class="lowstock-filter">
|
||||||
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" />
|
<input v-model="lowstockOnly" type="checkbox" @change="applyFilter" />
|
||||||
Low stock only
|
Low stock only
|
||||||
</label>
|
</label>
|
||||||
<label class="lowstock-filter">
|
<label class="lowstock-filter">
|
||||||
<input v-model="includeRetired" type="checkbox" @change="loadItems" />
|
<input v-model="includeRetired" type="checkbox" @change="applyFilter" />
|
||||||
Include retired
|
Include retired
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,6 +108,19 @@ let searchTimeout = null
|
|||||||
|
|
||||||
onMounted(loadItems)
|
onMounted(loadItems)
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
@input="debouncedSearch"
|
@input="debouncedSearch"
|
||||||
/>
|
/>
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" v-model="showAvailableOnly" @change="loadDevices" />
|
<input type="checkbox" v-model="showAvailableOnly" @change="applyFilter" />
|
||||||
Available Only
|
Available Only
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,6 +172,19 @@ onMounted(() => {
|
|||||||
loadDevices()
|
loadDevices()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
||||||
|
// asked the server for page 5 of a result set that now has one page, and the
|
||||||
|
// list came back empty as though the filter matched nothing.
|
||||||
|
//
|
||||||
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
||||||
|
// answers with onChange - so calling the loader as well would fetch twice.
|
||||||
|
// Load directly only when already on page 1, where nothing changes and the
|
||||||
|
// watcher stays silent.
|
||||||
|
function applyFilter() {
|
||||||
|
if (page.value > 1) setPage(1)
|
||||||
|
else loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
230
scripts/retype_models.py
Normal file
230
scripts/retype_models.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""Give catalog models a useful type, so their assets can inherit it.
|
||||||
|
|
||||||
|
The catalog types every printer model as "Printer" - true, and useless. It says
|
||||||
|
the product is a printer, not whether it is a laser, a plotter or a label
|
||||||
|
printer. That finer answer is a property of the MODEL (every VersaLink C405 ever
|
||||||
|
made is a laser MFP), but nothing records it, so nothing can derive it.
|
||||||
|
|
||||||
|
Recording it in the catalog rather than on each printer means:
|
||||||
|
|
||||||
|
- backfill_vendor_from_model.py then fills every printer automatically, since
|
||||||
|
it matches an asset's type to its model's type BY EXACT NAME;
|
||||||
|
- a printer added later inherits the right type as soon as its model is
|
||||||
|
chosen, with no second step, ever;
|
||||||
|
- the catalog stops claiming a plotter and a label printer are the same thing.
|
||||||
|
|
||||||
|
Two passes, because guessing at a model's type unreviewed is how a plotter ends
|
||||||
|
up filed as a laser:
|
||||||
|
|
||||||
|
python scripts/retype_models.py --export printer-types.csv
|
||||||
|
... open it, correct the newtype column, save ...
|
||||||
|
python scripts/retype_models.py --apply printer-types.csv
|
||||||
|
python scripts/retype_models.py --apply printer-types.csv --commit
|
||||||
|
|
||||||
|
A newtype is REFUSED unless it already exists in that asset class's own type
|
||||||
|
vocabulary - "Laser" is accepted for printers because printertypes has it,
|
||||||
|
"Laserjet" is not. That is what keeps the name match working afterwards.
|
||||||
|
|
||||||
|
Defaults to printers. --class machines|computers|network for the others.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Each asset class, its own type vocabulary, and the modeltypes.category that a
|
||||||
|
# newly created model type should carry.
|
||||||
|
CLASSES = {
|
||||||
|
'printers': ('printers', 'printertypes', 'printertype', 'Printer'),
|
||||||
|
'machines': ('machines', 'machinetypes', 'machinetype', 'Equipment'),
|
||||||
|
'computers': ('computers', 'computertypes', 'computertype', 'PC'),
|
||||||
|
'network': ('networkdevices', 'networkdevicetypes', 'networkdevicetype', 'Network'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Model-number keywords that suggest a type. A SUGGESTION only - it lands in the
|
||||||
|
# CSV for a person to accept or correct, and is never applied on its own.
|
||||||
|
# ORDER MATTERS - first match wins, so the specific brands come before the
|
||||||
|
# generic patterns. A bare t\d{3,4} for plotters used to match "Zebra ZT411" and
|
||||||
|
# file a label printer as a plotter, which is exactly the sort of confident
|
||||||
|
# mistake the review step exists to catch. The pattern is now explicit instead.
|
||||||
|
HINTS = [
|
||||||
|
(r'zebra|\bzt\d|\bzd\d|\bgk\d|\bgx\d|label|datamax|intermec', 'Label'),
|
||||||
|
(r'designjet|plotter|latex|\bdj\s?t\d{3,4}', 'Plotter'),
|
||||||
|
(r'datacard|zxp|card|cr80', 'Card'),
|
||||||
|
(r'thermal|tsp\d|tm-t', 'Thermal'),
|
||||||
|
(r'deskjet|officejet|inkjet|pixma', 'Inkjet'),
|
||||||
|
(r'\bml-\d|dot ?matrix|lq-\d|fx-\d', 'Dot Matrix'),
|
||||||
|
# Multifunction before plain laser: an MFP is also a laser, and the more
|
||||||
|
# specific answer is the useful one.
|
||||||
|
(r'mfp|mfc|workcentre|altalink|versalink|\bm\d{3}f', 'MFP'),
|
||||||
|
(r'laserjet|laser|phaser|\bls\b|\bp\d{4}', 'Laser'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Types the catalog uses that say nothing about the product. LocationOnly is the
|
||||||
|
# classic-ASP placeholder that also left 134 machines untyped.
|
||||||
|
PLACEHOLDERS = {'printer', 'locationonly', 'pc', 'equipment', 'network', 'computer', ''}
|
||||||
|
|
||||||
|
|
||||||
|
def suggest(modelnumber, description=''):
|
||||||
|
haystack = ('%s %s' % (modelnumber or '', description or '')).lower()
|
||||||
|
for pattern, answer in HINTS:
|
||||||
|
if re.search(pattern, haystack):
|
||||||
|
return answer
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def vocabulary(connection, typetable, typename):
|
||||||
|
from sqlalchemy import text
|
||||||
|
rows = connection.execute(text('SELECT %s FROM %s ORDER BY 1' % (typename, typetable))).fetchall()
|
||||||
|
return [r[0] for r in rows if r[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def export(connection, classname, path, include_all):
|
||||||
|
from sqlalchemy import text
|
||||||
|
assettable, typetable, typename, _ = CLASSES[classname]
|
||||||
|
rows = connection.execute(text("""
|
||||||
|
SELECT mo.modelnumberid AS modelnumberid,
|
||||||
|
mo.modelnumber AS modelnumber,
|
||||||
|
COALESCE(v.vendor, '') AS vendor,
|
||||||
|
COALESCE(mo.description, '') AS description,
|
||||||
|
COALESCE(mt.modeltype, '') AS currenttype,
|
||||||
|
COUNT(a.modelnumberid) AS assetcount
|
||||||
|
FROM {assettable} a
|
||||||
|
JOIN models mo ON a.modelnumberid = mo.modelnumberid
|
||||||
|
LEFT JOIN vendors v ON mo.vendorid = v.vendorid
|
||||||
|
LEFT JOIN modeltypes mt ON mo.modeltypeid = mt.modeltypeid
|
||||||
|
GROUP BY mo.modelnumberid, mo.modelnumber, v.vendor, mo.description, mt.modeltype
|
||||||
|
ORDER BY assetcount DESC, mo.modelnumber
|
||||||
|
""".format(assettable=assettable))).fetchall()
|
||||||
|
|
||||||
|
known = {v.lower() for v in vocabulary(connection, typetable, typename)}
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
current = (row.currenttype or '').strip()
|
||||||
|
# Already a real answer in this class's vocabulary: nothing to decide.
|
||||||
|
if not include_all and current.lower() in known:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
'modelnumberid': row.modelnumberid,
|
||||||
|
'modelnumber': row.modelnumber,
|
||||||
|
'vendor': row.vendor,
|
||||||
|
'assets': row.assetcount,
|
||||||
|
'currenttype': current,
|
||||||
|
'suggested': suggest(row.modelnumber, row.description),
|
||||||
|
'newtype': suggest(row.modelnumber, row.description),
|
||||||
|
})
|
||||||
|
|
||||||
|
with open(path, 'w', newline='', encoding='utf-8-sig') as handle:
|
||||||
|
writer = csv.DictWriter(handle, fieldnames=[
|
||||||
|
'modelnumberid', 'modelnumber', 'vendor', 'assets',
|
||||||
|
'currenttype', 'suggested', 'newtype'])
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(out)
|
||||||
|
|
||||||
|
print('Wrote %s with %d model(s) to review.' % (path, len(out)))
|
||||||
|
print('Valid values for newtype (%s):' % classname)
|
||||||
|
print(' %s' % ', '.join(vocabulary(connection, typetable, typename)))
|
||||||
|
print('')
|
||||||
|
print('The newtype column is pre-filled with a guess from the model number.')
|
||||||
|
print('CHECK EVERY ROW - a wrong guess types every asset using that model.')
|
||||||
|
print('Clear newtype to leave a model alone.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def apply(connection, classname, path, commit):
|
||||||
|
from sqlalchemy import text
|
||||||
|
_, typetable, typename, category = CLASSES[classname]
|
||||||
|
known = {v.lower(): v for v in vocabulary(connection, typetable, typename)}
|
||||||
|
|
||||||
|
with open(path, newline='', encoding='utf-8-sig') as handle:
|
||||||
|
rows = list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
planned, refused = [], []
|
||||||
|
for row in rows:
|
||||||
|
newtype = (row.get('newtype') or '').strip()
|
||||||
|
if not newtype:
|
||||||
|
continue
|
||||||
|
if newtype.lower() not in known:
|
||||||
|
refused.append((row.get('modelnumber', '?'), newtype))
|
||||||
|
continue
|
||||||
|
planned.append((int(row['modelnumberid']), row.get('modelnumber', '?'),
|
||||||
|
known[newtype.lower()], int(row.get('assets') or 0)))
|
||||||
|
|
||||||
|
for modelnumber, newtype in refused:
|
||||||
|
print(' REFUSED %-28s "%s" is not in %s' % (modelnumber, newtype, typetable))
|
||||||
|
if refused:
|
||||||
|
print(' (add it to %s first if it is a real type, or correct the spelling)' % typetable)
|
||||||
|
print('')
|
||||||
|
|
||||||
|
print('%d model(s) would be retyped, covering %d asset(s):'
|
||||||
|
% (len(planned), sum(p[3] for p in planned)))
|
||||||
|
for _, modelnumber, newtype, count in planned[:40]:
|
||||||
|
print(' %-32s -> %-12s (%d asset(s))' % (modelnumber[:32], newtype, count))
|
||||||
|
if len(planned) > 40:
|
||||||
|
print(' ... and %d more' % (len(planned) - 40))
|
||||||
|
|
||||||
|
if not commit:
|
||||||
|
print('')
|
||||||
|
print('DRY RUN - nothing written. Re-run with --commit to apply.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for modelnumberid, _, newtype, _ in planned:
|
||||||
|
typeid = connection.execute(
|
||||||
|
text('SELECT modeltypeid FROM modeltypes WHERE modeltype = :n'),
|
||||||
|
{'n': newtype}).scalar()
|
||||||
|
if typeid is None:
|
||||||
|
# The name is valid for this asset class but the catalog has no such
|
||||||
|
# model type yet. Create it, tagged with this class's category.
|
||||||
|
connection.execute(
|
||||||
|
text('INSERT INTO modeltypes (modeltype, category) VALUES (:n, :c)'),
|
||||||
|
{'n': newtype, 'c': category})
|
||||||
|
typeid = connection.execute(
|
||||||
|
text('SELECT modeltypeid FROM modeltypes WHERE modeltype = :n'),
|
||||||
|
{'n': newtype}).scalar()
|
||||||
|
connection.execute(
|
||||||
|
text('UPDATE models SET modeltypeid = :t WHERE modelnumberid = :m'),
|
||||||
|
{'t': typeid, 'm': modelnumberid})
|
||||||
|
|
||||||
|
print('')
|
||||||
|
print('Committed. %d model(s) retyped.' % len(planned))
|
||||||
|
print('Now run: python scripts/backfill_vendor_from_model.py --commit')
|
||||||
|
print('which copies the type onto every asset using those models.')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument('--class', dest='classname', default='printers',
|
||||||
|
choices=sorted(CLASSES), help='asset class (default printers)')
|
||||||
|
parser.add_argument('--export', metavar='CSV', help='write models needing a type')
|
||||||
|
parser.add_argument('--apply', metavar='CSV', help='apply a reviewed file')
|
||||||
|
parser.add_argument('--all', action='store_true',
|
||||||
|
help='with --export, include models that already have a valid type')
|
||||||
|
parser.add_argument('--commit', action='store_true', help='write the changes')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.export and not args.apply:
|
||||||
|
parser.error('give --export or --apply')
|
||||||
|
|
||||||
|
from shopdb import create_app
|
||||||
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
connection = db.session.connection()
|
||||||
|
if args.export:
|
||||||
|
code = export(connection, args.classname, args.export, args.all)
|
||||||
|
else:
|
||||||
|
code = apply(connection, args.classname, args.apply, args.commit)
|
||||||
|
if args.commit:
|
||||||
|
db.session.commit()
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user