Fix the levels viewer: markers were drawn on whichever plan was showing
This commit is contained in:
21
CHANGELOG.md
21
CHANGELOG.md
@@ -10,6 +10,17 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.11.1] - 2026-08-17
|
||||||
|
|
||||||
|
A patch release: the pagination-cap fixes, plus the documentation that missed the
|
||||||
|
0.11.0 installer. No application behaviour changed on the server and the plugin
|
||||||
|
contract stays at 0.20.0.
|
||||||
|
|
||||||
|
`docs/UPGRADE.md` is one of the documents the Windows installer puts on the box,
|
||||||
|
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
|
||||||
|
shipped the page an operator reads WHILE upgrading without the guidance about
|
||||||
|
that upgrade. An air-gapped server has no other way to reach it.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
|
- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
|
||||||
@@ -25,6 +36,16 @@ ADR-007 and ADR-002.
|
|||||||
`PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
|
`PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
|
||||||
a page at a time are unchanged - they page deliberately.
|
a page at a time are unchanged - they page deliberately.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The installer now also ships `FLOOR-MAP.md`, the operator's page for loading
|
||||||
|
floor plans, placing markers and moving them when a plan changes. Same reason
|
||||||
|
the Windows runbooks ship: the box cannot reach the wiki.
|
||||||
|
- The bundled `UPGRADE.md` now carries the 0.11.0 section: an existing map is
|
||||||
|
preserved and nothing moves on screen, and replacing a blueprint with one of
|
||||||
|
different dimensions moves every marker on that level, so recalibrate from
|
||||||
|
landmarks rather than editing the level's width and height.
|
||||||
|
|
||||||
## [0.11.0] - 2026-08-17
|
## [0.11.0] - 2026-08-17
|
||||||
|
|
||||||
The floor map became a set of drawings instead of one picture. A site can hold
|
The floor map became a set of drawings instead of one picture. A site can hold
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shopdb-frontend",
|
"name": "shopdb-frontend",
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
--sidebar-bg: #00003d;
|
--sidebar-bg: #00003d;
|
||||||
--sidebar-text: #ffffff;
|
--sidebar-text: #ffffff;
|
||||||
--sidebar-width: 250px;
|
--sidebar-width: 250px;
|
||||||
|
/* Vertical chrome around .main-content. A full-height page subtracts these;
|
||||||
|
hardcoding a different number is what put a scrollbar on the map. */
|
||||||
|
--main-pad-top: 20px;
|
||||||
|
--main-pad-bottom: 70px;
|
||||||
|
|
||||||
/* Hover variants */
|
/* Hover variants */
|
||||||
--secondary-dark: #82503f;
|
--secondary-dark: #82503f;
|
||||||
@@ -243,7 +247,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: var(--sidebar-width);
|
margin-left: var(--sidebar-width);
|
||||||
padding: 20px 10px 70px 10px;
|
padding: var(--main-pad-top) 10px var(--main-pad-bottom) 10px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -437,8 +437,18 @@ function renderMarkers() {
|
|||||||
overlayLayers.forEach(layer => layer.remove())
|
overlayLayers.forEach(layer => layer.remove())
|
||||||
overlayLayers = []
|
overlayLayers = []
|
||||||
|
|
||||||
|
// Markers belong to ONE level (ADR-017). This is the drawing being shown, and
|
||||||
|
// anything positioned against a different one is not drawn on it.
|
||||||
|
const drawnLevel = drawnLevelId() ?? null
|
||||||
|
|
||||||
props.machines.forEach(item => {
|
props.machines.forEach(item => {
|
||||||
if (item.mapx == null || item.mapy == null) return
|
if (item.mapx == null || item.mapy == null) return
|
||||||
|
// A marker from another level, placed on THIS blueprint, looks entirely
|
||||||
|
// correct and points at the wrong part of the building - so it is omitted
|
||||||
|
// rather than approximated. A position with no level at all is omitted for
|
||||||
|
// the same reason: the map editor lists both, badged, so they can be fixed
|
||||||
|
// rather than silently misplaced here.
|
||||||
|
if ((item.levelid ?? null) !== drawnLevel) return
|
||||||
|
|
||||||
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
||||||
const leafletY = MAP_HEIGHT - item.mapy
|
const leafletY = MAP_HEIGHT - item.mapy
|
||||||
@@ -599,6 +609,31 @@ watch(() => props.theme, (newTheme) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Switching level changes the drawing, the coordinate space AND which markers
|
||||||
|
// belong on it. All three move together: the size is what marker coordinates
|
||||||
|
// mean, so swapping the image without the bounds would place every marker
|
||||||
|
// against the wrong scale, and keeping the markers would show the previous
|
||||||
|
// floor's assets on this floor's plan.
|
||||||
|
watch(() => drawnLevelId(), (levelid) => {
|
||||||
|
if (!map || !imageOverlay) return
|
||||||
|
|
||||||
|
MAP_WIDTH = dimensionsFor(levelid).width
|
||||||
|
MAP_HEIGHT = dimensionsFor(levelid).height
|
||||||
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
|
const url = blueprintUrlFor(props.theme, levelid)
|
||||||
|
// A level with no blueprint is left blank rather than showing the previous
|
||||||
|
// one, which would be a floor plan labelled as somewhere it is not.
|
||||||
|
imageOverlay.setUrl(url || '')
|
||||||
|
imageOverlay.setBounds(bounds)
|
||||||
|
|
||||||
|
map.setMaxBounds(bounds)
|
||||||
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], map.getZoom())
|
||||||
|
|
||||||
|
renderMarkers()
|
||||||
|
loadOverlays()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load this facility's blueprint + dimensions before building the map so
|
// Load this facility's blueprint + dimensions before building the map so
|
||||||
// bounds and coordinate math use the right size. Falls back to defaults.
|
// bounds and coordinate math use the right size. Falls back to defaults.
|
||||||
|
|||||||
@@ -492,7 +492,7 @@ function cancelEdit() {
|
|||||||
.map-editor {
|
.map-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 40px);
|
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Filter Controls -->
|
<!-- Filter Controls -->
|
||||||
<div class="map-filters">
|
<div class="map-filters">
|
||||||
|
<select
|
||||||
|
v-if="levelChoices.length > 1"
|
||||||
|
v-model.number="shownLevelId"
|
||||||
|
@change="onLevelChange"
|
||||||
|
title="Which floor plan to show"
|
||||||
|
>
|
||||||
|
<option v-for="level in levelChoices" :key="level.levelid" :value="level.levelid">
|
||||||
|
{{ level.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<select v-model="selectedType" @change="onTypeChange">
|
<select v-model="selectedType" @change="onTypeChange">
|
||||||
<option value="">All Asset Types</option>
|
<option value="">All Asset Types</option>
|
||||||
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
||||||
@@ -60,6 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
|
:levelid="shownLevelId"
|
||||||
:machines="filteredAssets"
|
:machines="filteredAssets"
|
||||||
:machinetypes="[]"
|
:machinetypes="[]"
|
||||||
:businessunits="businessunits"
|
:businessunits="businessunits"
|
||||||
@@ -82,7 +94,8 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
|
|||||||
import { assetsApi } from '../api'
|
import { assetsApi } from '../api'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, state as mapConfig } from '@/composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, levelOptions,
|
||||||
|
setCurrentLevel, state as mapConfig } from '@/composables/mapConfig'
|
||||||
import { exportMapPdf } from '../utils/mapPdf'
|
import { exportMapPdf } from '../utils/mapPdf'
|
||||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||||
import { getSubtypeId } from '../utils/mapColors'
|
import { getSubtypeId } from '../utils/mapColors'
|
||||||
@@ -104,6 +117,41 @@ const selectedSubtype = ref('')
|
|||||||
const selectedBusinessUnit = ref('')
|
const selectedBusinessUnit = ref('')
|
||||||
const selectedStatus = ref('')
|
const selectedStatus = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Which floor plan is on screen. Every marker coordinate is pixels of ONE level
|
||||||
|
// (ADR-017), so this decides both the drawing and which markers belong on it.
|
||||||
|
const shownLevelId = ref(null)
|
||||||
|
|
||||||
|
const levelChoices = computed(() => levelOptions())
|
||||||
|
|
||||||
|
function onLevelChange() {
|
||||||
|
// Keep the shared composable in step, so a map-position picker opened later
|
||||||
|
// starts on the level being looked at rather than the site default.
|
||||||
|
setCurrentLevel(shownLevelId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A search whose only matches are on another floor would otherwise show an empty
|
||||||
|
// map: the assets matched, the filter counted them, and nothing was drawn.
|
||||||
|
// Follow the results to the level that actually holds them - the level with the
|
||||||
|
// most matches, so a search matching several floors lands on the best one.
|
||||||
|
function followSearchAcrossLevels() {
|
||||||
|
if (!searchQuery.value.trim()) return
|
||||||
|
const matches = filteredAssets.value.filter(a => a.mapx != null && a.mapy != null)
|
||||||
|
if (!matches.length) return
|
||||||
|
if (matches.some(a => (a.levelid ?? null) === shownLevelId.value)) return
|
||||||
|
|
||||||
|
const tally = new Map()
|
||||||
|
matches.forEach(asset => {
|
||||||
|
const levelid = asset.levelid ?? null
|
||||||
|
if (levelid === null) return
|
||||||
|
tally.set(levelid, (tally.get(levelid) || 0) + 1)
|
||||||
|
})
|
||||||
|
if (!tally.size) return
|
||||||
|
|
||||||
|
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||||
|
shownLevelId.value = best
|
||||||
|
setCurrentLevel(best)
|
||||||
|
}
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
@@ -245,12 +293,12 @@ async function exportPdf() {
|
|||||||
// blueprintUrlFor applies withBase - the raw setting value is a
|
// blueprintUrlFor applies withBase - the raw setting value is a
|
||||||
// root-relative /api path, which 404s under a subpath mount like /ops.
|
// root-relative /api path, which 404s under a subpath mount like /ops.
|
||||||
// The PDF always uses the light blueprint: it prints on white paper.
|
// The PDF always uses the light blueprint: it prints on white paper.
|
||||||
blueprintUrl: blueprintUrlFor('light', mapConfig.currentlevelid),
|
blueprintUrl: blueprintUrlFor('light', shownLevelId.value),
|
||||||
mapWidth: dimensionsFor(mapConfig.currentlevelid).width,
|
mapWidth: dimensionsFor(shownLevelId.value).width,
|
||||||
mapHeight: dimensionsFor(mapConfig.currentlevelid).height,
|
mapHeight: dimensionsFor(shownLevelId.value).height,
|
||||||
// Named on the sheet, because a floor plan with no level on it is not
|
// Named on the sheet, because a floor plan with no level on it is not
|
||||||
// identifiable once it is printed and carried to the floor.
|
// identifiable once it is printed and carried to the floor.
|
||||||
levelname: levelName(mapConfig.currentlevelid),
|
levelname: levelName(shownLevelId.value),
|
||||||
selectedType: selectedType.value,
|
selectedType: selectedType.value,
|
||||||
subtypeColors: subtypeColorMap.value,
|
subtypeColors: subtypeColorMap.value,
|
||||||
subtypeNames: subtypeNameMap.value,
|
subtypeNames: subtypeNameMap.value,
|
||||||
@@ -265,7 +313,8 @@ async function exportPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadMapConfig()
|
await loadMapConfig()
|
||||||
|
shownLevelId.value = mapConfig.currentlevelid ?? mapConfig.defaultlevelid ?? null
|
||||||
try {
|
try {
|
||||||
const response = await assetsApi.getMap()
|
const response = await assetsApi.getMap()
|
||||||
const data = response.data.data || {}
|
const data = response.data.data || {}
|
||||||
@@ -305,6 +354,7 @@ function updateMapLayers() {
|
|||||||
function debouncedSearch() {
|
function debouncedSearch() {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
|
followSearchAcrossLevels()
|
||||||
updateMapLayers()
|
updateMapLayers()
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@@ -318,7 +368,7 @@ function handleMarkerClick(asset) {
|
|||||||
.map-page {
|
.map-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 2rem);
|
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page .page-header {
|
.map-page .page-header {
|
||||||
|
|||||||
@@ -213,9 +213,18 @@ async function search(q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performSearch() {
|
function performSearch() {
|
||||||
if (searchInput.value.trim()) {
|
const term = searchInput.value.trim()
|
||||||
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
if (!term) return
|
||||||
|
// Searching a term that is ALREADY in the URL is a duplicate navigation: the
|
||||||
|
// router aborts it, so the route watcher never fires and the button does
|
||||||
|
// nothing at all. Re-run the query directly in that case. The sidebar box
|
||||||
|
// never hits this, because it is always navigating from somewhere else -
|
||||||
|
// which is why the two search boxes appeared to behave differently.
|
||||||
|
if (term === route.query.q) {
|
||||||
|
search(term)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
router.push({ path: '/search', query: { q: term } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openKBArticle(result) {
|
async function openKBArticle(result) {
|
||||||
|
|||||||
@@ -62,18 +62,36 @@
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="mono">{{ level.mapwidth }} x {{ level.mapheight }}</span>
|
<div class="size-fields">
|
||||||
|
<input
|
||||||
|
v-model.number="level.mapwidth"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-control size-input"
|
||||||
|
aria-label="Blueprint width in pixels"
|
||||||
|
@blur="saveSize(level)"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<span class="size-x">x</span>
|
||||||
|
<input
|
||||||
|
v-model.number="level.mapheight"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-control size-input"
|
||||||
|
aria-label="Blueprint height in pixels"
|
||||||
|
@blur="saveSize(level)"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<small v-if="level.assetcount" class="input-hint">
|
<small v-if="level.assetcount" class="input-hint">
|
||||||
fixed while {{ level.assetcount }} marker(s) are placed
|
Taken from the image while a level is empty. Changing it now
|
||||||
|
re-scales where all {{ level.assetcount }} marker(s) sit relative
|
||||||
|
to the drawing - set the new size, then Recalibrate from
|
||||||
|
landmarks in the map editor.
|
||||||
|
</small>
|
||||||
|
<small v-else class="input-hint">
|
||||||
|
Set from the blueprint you upload.
|
||||||
</small>
|
</small>
|
||||||
<input
|
|
||||||
v-else
|
|
||||||
v-model.number="level.mapwidth"
|
|
||||||
type="number"
|
|
||||||
class="form-control size-input"
|
|
||||||
@blur="saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })"
|
|
||||||
:disabled="saving"
|
|
||||||
/>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
|
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
|
||||||
@@ -311,6 +329,29 @@ async function saveLevel(level, payload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveSize(level) {
|
||||||
|
if (!(level.mapwidth > 0) || !(level.mapheight > 0)) {
|
||||||
|
error.value = 'Width and height must both be positive.'
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A level with markers keeps its coordinates when the size changes, so every
|
||||||
|
// marker moves relative to the drawing. That is sometimes exactly right (a
|
||||||
|
// re-export of the same plan at a new resolution) and sometimes the start of
|
||||||
|
// a recalibration, but it is never something to do by accident.
|
||||||
|
if (level.assetcount) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`${level.levelname} has ${level.assetcount} marker(s) placed against ` +
|
||||||
|
`${level.mapwidth} x ${level.mapheight}. Changing the size moves all of ` +
|
||||||
|
`them relative to the drawing. Recalibrate from landmarks afterwards. Continue?`)
|
||||||
|
if (!ok) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })
|
||||||
|
}
|
||||||
|
|
||||||
async function upload(level, theme, event) {
|
async function upload(level, theme, event) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -398,6 +439,8 @@ async function remove(level) {
|
|||||||
.mono { font-family: monospace; }
|
.mono { font-family: monospace; }
|
||||||
.order-input { width: 4.5rem; }
|
.order-input { width: 4.5rem; }
|
||||||
.size-input { width: 6rem; }
|
.size-input { width: 6rem; }
|
||||||
|
.size-fields { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.size-x { color: var(--text-light); }
|
||||||
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
|
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
.map-thumb {
|
.map-thumb {
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ python3 "$REPO/scripts/generate_sbom.py" "$REPO" -o "$OUT/sbom.cdx.json"
|
|||||||
echo "==> Staging docs ..."
|
echo "==> Staging docs ..."
|
||||||
mkdir -p "$OUT/docs"
|
mkdir -p "$OUT/docs"
|
||||||
for doc in openapi.json llms.txt api-inventory.json \
|
for doc in openapi.json llms.txt api-inventory.json \
|
||||||
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md; do
|
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md \
|
||||||
|
FLOOR-MAP.md; do
|
||||||
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
|
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
|
||||||
done
|
done
|
||||||
# Never let a missing optional doc fail the build - the loop's last test governs
|
# Never let a missing optional doc fail the build - the loop's last test governs
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ __contract_version__ = '0.20.0'
|
|||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
# bump rules. Not part of the shopdb.api contract surface, so it is
|
# bump rules. Not part of the shopdb.api contract surface, so it is
|
||||||
# not re-exported there.
|
# not re-exported there.
|
||||||
__version__ = '0.11.0'
|
__version__ = '0.11.1'
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name: str = None) -> Flask:
|
def create_app(config_name: str = None) -> Flask:
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
|
|||||||
search_bp = Blueprint('search', __name__)
|
search_bp = Blueprint('search', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# EVERY searcher below truncates with .limit(). An unordered LIMIT lets the
|
||||||
|
# database return a DIFFERENT subset of the matching rows between two identical
|
||||||
|
# requests - the same search run twice came back with different hits, which reads
|
||||||
|
# as the search being broken rather than as a missing ORDER BY. Each query
|
||||||
|
# therefore ends with a TOTAL order: a display key plus the primary key, so ties
|
||||||
|
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
|
||||||
|
|
||||||
|
|
||||||
def _word_match(query, *columns):
|
def _word_match(query, *columns):
|
||||||
"""SQL clause matching rows that contain EVERY word of the query, each word
|
"""SQL clause matching rows that contain EVERY word of the query, each word
|
||||||
in any of the columns, in any order.
|
in any of the columns, in any order.
|
||||||
@@ -213,7 +221,7 @@ def _search_applications(query, search_term):
|
|||||||
apps = Application.query.filter(
|
apps = Application.query.filter(
|
||||||
Application.isactive == True,
|
Application.isactive == True,
|
||||||
_word_match(query, Application.appname, Application.appdescription)
|
_word_match(query, Application.appname, Application.appdescription)
|
||||||
).limit(10).all()
|
).order_by(Application.appname, Application.appid).limit(10).all()
|
||||||
|
|
||||||
for app in apps:
|
for app in apps:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -258,7 +266,8 @@ def _search_knowledgebase(query, search_term):
|
|||||||
KnowledgeBase.appid.notin_(retired)),
|
KnowledgeBase.appid.notin_(retired)),
|
||||||
_word_match(query, KnowledgeBase.shortdescription,
|
_word_match(query, KnowledgeBase.shortdescription,
|
||||||
KnowledgeBase.keywords)
|
KnowledgeBase.keywords)
|
||||||
).limit(20).all()
|
).order_by(KnowledgeBase.clicks.desc(),
|
||||||
|
KnowledgeBase.linkid).limit(20).all()
|
||||||
|
|
||||||
for kb in kb_articles:
|
for kb in kb_articles:
|
||||||
relevance = 10 + (kb.clicks or 0) * 0.1
|
relevance = 10 + (kb.clicks or 0) * 0.1
|
||||||
@@ -378,7 +387,7 @@ def _search_assets(query, search_term):
|
|||||||
_word_match(query, Asset.assetnumber, Asset.name,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.notes,
|
Asset.serialnumber, Asset.notes,
|
||||||
Asset.gaugelabreference, Asset.maintenancereference)
|
Asset.gaugelabreference, Asset.maintenancereference)
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
@@ -421,7 +430,7 @@ def _search_measuringtools(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Asset.assetnumber, Asset.name,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.gaugelabreference)
|
Asset.serialnumber, Asset.gaugelabreference)
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
@@ -461,7 +470,8 @@ def _search_usbdevices(query, search_term):
|
|||||||
USBDevice.isactive == True,
|
USBDevice.isactive == True,
|
||||||
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
||||||
USBDevice.label, USBDevice.productname)
|
USBDevice.label, USBDevice.productname)
|
||||||
).limit(10).all()
|
).order_by(USBDevice.serialnumber,
|
||||||
|
USBDevice.usbdeviceid).limit(10).all()
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -507,7 +517,8 @@ def _search_printeditems(query, search_term):
|
|||||||
PrintedItem.isactive == True,
|
PrintedItem.isactive == True,
|
||||||
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
||||||
PrintedItem.itemname, PrintedItem.itemdescription)
|
PrintedItem.itemname, PrintedItem.itemdescription)
|
||||||
).limit(10).all()
|
).order_by(PrintedItem.itemcode,
|
||||||
|
PrintedItem.printeditemid).limit(10).all()
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -563,7 +574,7 @@ def _search_customfields(query, search_term):
|
|||||||
CustomField.searchable == True,
|
CustomField.searchable == True,
|
||||||
CustomField.isactive == True,
|
CustomField.isactive == True,
|
||||||
_word_match(query, CustomFieldValue.value),
|
_word_match(query, CustomFieldValue.value),
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, CustomField.fieldid).limit(15).all()
|
||||||
|
|
||||||
for asset, field in rows:
|
for asset, field in rows:
|
||||||
result = _get_asset_result(asset, query, relevance=40)
|
result = _get_asset_result(asset, query, relevance=40)
|
||||||
@@ -583,7 +594,8 @@ def _search_by_ip(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Communication.asset).joinedload(Asset.assettype),
|
joinedload(Communication.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Communication.asset).joinedload(Asset.location),
|
joinedload(Communication.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Communication.ipaddress,
|
||||||
|
Communication.communicationid).limit(10).all()
|
||||||
|
|
||||||
seen_assets = set()
|
seen_assets = set()
|
||||||
for comm in comms:
|
for comm in comms:
|
||||||
@@ -643,7 +655,7 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Computer.asset).joinedload(Asset.assettype),
|
joinedload(Computer.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Computer.asset).joinedload(Asset.location),
|
joinedload(Computer.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Computer.hostname, Computer.computerid).limit(10).all()
|
||||||
|
|
||||||
for comp in computers:
|
for comp in computers:
|
||||||
if comp.asset and comp.asset.isactive:
|
if comp.asset and comp.asset.isactive:
|
||||||
@@ -666,7 +678,7 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Printer.asset).joinedload(Asset.assettype),
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Printer.asset).joinedload(Asset.location),
|
joinedload(Printer.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Printer.hostname, Printer.printerid).limit(10).all()
|
||||||
|
|
||||||
for printer in printers:
|
for printer in printers:
|
||||||
if printer.asset and printer.asset.isactive:
|
if printer.asset and printer.asset.isactive:
|
||||||
@@ -689,7 +701,8 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
||||||
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(NetworkDevice.hostname,
|
||||||
|
NetworkDevice.networkdeviceid).limit(10).all()
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
if device.asset and device.asset.isactive:
|
if device.asset and device.asset.isactive:
|
||||||
@@ -773,7 +786,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor, Model.modelnumber,
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
||||||
MachineType.machinetype)
|
MachineType.machinetype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in machine_assets:
|
for asset in machine_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -801,7 +814,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor, Model.modelnumber,
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
||||||
PrinterType.printertype)
|
PrinterType.printertype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in printer_assets:
|
for asset in printer_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -827,7 +840,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor,
|
_word_match(query, Vendor.vendor,
|
||||||
NetworkDeviceType.networkdevicetype)
|
NetworkDeviceType.networkdevicetype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in netdev_assets:
|
for asset in netdev_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
|
|||||||
Reference in New Issue
Block a user