Buildings and levels for the floor map, and make every identifier searchable
This commit is contained in:
61
CHANGELOG.md
61
CHANGELOG.md
@@ -10,6 +10,67 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
The floor map became a set of drawings instead of one picture. A site can hold
|
||||||
|
more than one building, a building more than one level, and every map position
|
||||||
|
now records which drawing its coordinates belong to. Driven by a real move: a
|
||||||
|
second floor was added, a new blueprint changed size, and machines relocated.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Buildings and levels (ADR-017). Each level owns its own blueprint per theme
|
||||||
|
and its own native pixel size; `assets.mapx`/`mapy` are pixels of the level
|
||||||
|
named by the new `assets.levelid`, not of the site.
|
||||||
|
- A bulk repositioning tool on the map editor: filter by unplaced, by
|
||||||
|
needs-review, or by level, search, then place markers and confirm them. New
|
||||||
|
`POST /api/mappositions/positions` and `/verify`.
|
||||||
|
- Landmark recalibration (`POST /api/mappositions/transform`). Name two or more
|
||||||
|
points that appear on both the old and new drawing and every marker on the
|
||||||
|
level moves onto the new one. Defaults to a dry run that reports each old and
|
||||||
|
new position and anything that would land off the drawing. The transform is
|
||||||
|
solved PER AXIS from the landmarks, never derived from image dimensions: a
|
||||||
|
taller drawing that gained a level below did not rescale, and a
|
||||||
|
dimension-derived scale would stretch Y by 1.57 and be wrong everywhere.
|
||||||
|
- Position snapshots with restore, taken before any bulk change including a
|
||||||
|
restore, so an undo is itself undoable.
|
||||||
|
- A buildings and levels admin under Settings: name and order levels, see each
|
||||||
|
level's id and marker count, upload a blueprint per theme, choose the default.
|
||||||
|
- `assets.mapverifiedat`, the record of when a position was last confirmed
|
||||||
|
against the current drawing. A bulk transform clears it, because a transform
|
||||||
|
is a starting guess and nothing in the coordinates says which markers moved.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Plugin contract to 0.20.0 (additive; see CONTRACT-STABILITY.md). A plugin that
|
||||||
|
writes a map position must now write its level.
|
||||||
|
- A position whose level is unknown renders as "level unknown" and is NOT drawn
|
||||||
|
on the default level. Drawing it there would look entirely correct while
|
||||||
|
pointing at the wrong part of the building.
|
||||||
|
- `GET /api/printers/install-list?format=text` gained `levelid` as a NINTH
|
||||||
|
field, appended. Fields 0-7 are unchanged because the shipped Pascal installer
|
||||||
|
reads them by index.
|
||||||
|
- Knowledge base: an article whose topic is a retired (`isactive = 0`)
|
||||||
|
application no longer appears in listings, searches or the counts, and the
|
||||||
|
topic picker offers every active application rather than only installable
|
||||||
|
ones.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
- `7d33_buildings_and_levels` converts an existing single-map site: it creates
|
||||||
|
one building and one default level carrying the blueprint paths and dimensions
|
||||||
|
from the old `map_*` settings, then assigns every already-placed asset and
|
||||||
|
location to it. Nothing moves on screen. The old settings rows are left in
|
||||||
|
place so a rollback still finds them. Verified end to end on MySQL 5.6 from a
|
||||||
|
production-shaped database.
|
||||||
|
|
||||||
|
### Known gaps
|
||||||
|
|
||||||
|
- The Pascal printer-installer map still compiles in one drawing's dimensions and
|
||||||
|
bundles one blueprint, so it is accurate for the default level only. See
|
||||||
|
section 6 of PRINTER-INSTALLER.md.
|
||||||
|
- The setup wizard still asks for a single site-wide blueprint and writes
|
||||||
|
settings that no longer drive the map.
|
||||||
|
- Map PDF export covers the current level only.
|
||||||
|
|
||||||
## [0.10.0] - 2026-08-17
|
## [0.10.0] - 2026-08-17
|
||||||
|
|
||||||
A security release. The Windows installer left the directories it creates
|
A security release. The Windows installer left the directories it creates
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -187,6 +187,29 @@ example of the whole sequence.
|
|||||||
|
|
||||||
For every environment variable and Setting key see the CONFIG page in the project wiki.
|
For every environment variable and Setting key see the CONFIG page in the project wiki.
|
||||||
|
|
||||||
|
### What the Windows installer requires
|
||||||
|
|
||||||
|
The installer checks all of this before it changes anything and refuses rather
|
||||||
|
than half-installing.
|
||||||
|
|
||||||
|
| | Minimum | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows Server | **2019** (build 10.0.17763) | Ships **IIS 10.0**, which is therefore the earliest IIS supported. Server 2016 is also IIS 10.0 but falls below the build floor and is refused. |
|
||||||
|
| Windows client (test boxes) | **10 22H2** (build 10.0.19045) or 11 | **Pro or higher** - Home has no IIS at all. |
|
||||||
|
| Architecture | 64-bit | The payload is cp314 win_amd64. |
|
||||||
|
| IIS Web Server role | installed beforehand | `Install-WindowsFeature -Name Web-Server -IncludeManagementTools`. Needs no internet. |
|
||||||
|
| Free disk | 5 GB | Refused below this. 40 GB is comfortable once uploads and config backups accumulate. |
|
||||||
|
| Database | bundled MySQL 8.4 LTS, or your own | Bring credentials if you use an existing server. |
|
||||||
|
|
||||||
|
The binding constraint is the operating system rather than IIS: the payload is
|
||||||
|
Python 3.14 plus HttpPlatformHandler and URL Rewrite, and the handler itself runs
|
||||||
|
on older IIS. An older host is untested rather than known-broken.
|
||||||
|
|
||||||
|
If you must deploy onto something older, the INSTALL-WINDOWS-IIS page in the project wiki
|
||||||
|
is the manual procedure and has no OS gate - it even documents MySQL 5.6, which is
|
||||||
|
that era of machine. The trade is stated there: it produces a server the
|
||||||
|
installer will not subsequently upgrade.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Environment variables (`.env`):
|
Environment variables (`.env`):
|
||||||
|
|||||||
@@ -863,6 +863,63 @@ export const setupApi = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buildings and the levels within them (ADR-017). Reads are public - the
|
||||||
|
// printer installer map draws a blueprint before anyone logs in.
|
||||||
|
export const mapLevelsApi = {
|
||||||
|
list() {
|
||||||
|
return api.get('/maplevels')
|
||||||
|
},
|
||||||
|
get(levelid) {
|
||||||
|
return api.get(`/maplevels/${levelid}`)
|
||||||
|
},
|
||||||
|
createBuilding(payload) {
|
||||||
|
return api.post('/maplevels/buildings', payload)
|
||||||
|
},
|
||||||
|
updateBuilding(buildingid, payload) {
|
||||||
|
return api.patch(`/maplevels/buildings/${buildingid}`, payload)
|
||||||
|
},
|
||||||
|
create(payload) {
|
||||||
|
return api.post('/maplevels', payload)
|
||||||
|
},
|
||||||
|
update(levelid, payload) {
|
||||||
|
return api.patch(`/maplevels/${levelid}`, payload)
|
||||||
|
},
|
||||||
|
remove(levelid) {
|
||||||
|
return api.delete(`/maplevels/${levelid}`)
|
||||||
|
},
|
||||||
|
// Returns the image's real pixel size alongside the stored dimensions. On an
|
||||||
|
// empty level the server adopts them; on a populated one it refuses and says
|
||||||
|
// so, because changing the coordinate space moves every marker on it.
|
||||||
|
uploadBlueprint(levelid, theme, file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('theme', theme)
|
||||||
|
return api.post(`/maplevels/${levelid}/blueprint`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk marker positions: the landmark transform, bulk placement, review state
|
||||||
|
// and undo. Every write snapshots first.
|
||||||
|
export const mapPositionsApi = {
|
||||||
|
setPositions(positions, verified = true) {
|
||||||
|
return api.post('/mappositions/positions', { positions, verified })
|
||||||
|
},
|
||||||
|
transform(payload) {
|
||||||
|
return api.post('/mappositions/transform', payload)
|
||||||
|
},
|
||||||
|
verify(assetids, unverify = false) {
|
||||||
|
return api.post('/mappositions/verify', { assetids, unverify })
|
||||||
|
},
|
||||||
|
snapshots() {
|
||||||
|
return api.get('/mappositions/snapshots')
|
||||||
|
},
|
||||||
|
restore(snapshotid) {
|
||||||
|
return api.post(`/mappositions/snapshots/${snapshotid}/restore`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/settings', { params })
|
return api.get('/settings', { params })
|
||||||
|
|||||||
@@ -7,28 +7,35 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel } from '../composables/mapConfig'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
left: { type: Number, default: null },
|
left: { type: Number, default: null },
|
||||||
top: { type: Number, default: null },
|
top: { type: Number, default: null },
|
||||||
markerColor: { type: String, default: '#ff0000' },
|
markerColor: { type: String, default: '#ff0000' },
|
||||||
markerLabel: { type: String, default: '' }
|
markerLabel: { type: String, default: '' },
|
||||||
|
// Which drawing left/top belong to (ADR-017). Without it there is no honest
|
||||||
|
// blueprint to draw, so the map is not initialised at all.
|
||||||
|
levelid: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapContainer = ref(null)
|
const mapContainer = ref(null)
|
||||||
let map = null
|
let map = null
|
||||||
let marker = null
|
let marker = null
|
||||||
|
|
||||||
// Map dimensions - facility blueprint size, loaded from settings.
|
// This LEVEL's native size, which is what its marker coordinates mean.
|
||||||
let MAP_WIDTH = mapConfig.width
|
let MAP_WIDTH = 0
|
||||||
let MAP_HEIGHT = mapConfig.height
|
let MAP_HEIGHT = 0
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (!mapContainer.value || props.left === null || props.top === null) return
|
if (!mapContainer.value || props.left === null || props.top === null) return
|
||||||
|
// No level, no drawing. Rendering the default blueprint under these
|
||||||
|
// coordinates would look right and be wrong; an empty box is honest.
|
||||||
|
if (!hasLevel(props.levelid)) return
|
||||||
|
|
||||||
MAP_WIDTH = mapConfig.width
|
const dimensions = dimensionsFor(props.levelid)
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_WIDTH = dimensions.width
|
||||||
|
MAP_HEIGHT = dimensions.height
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
map = L.map(mapContainer.value, {
|
map = L.map(mapContainer.value, {
|
||||||
@@ -39,7 +46,7 @@ function initMap() {
|
|||||||
zoomControl: true
|
zoomControl: true
|
||||||
})
|
})
|
||||||
|
|
||||||
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
L.imageOverlay(blueprintUrlFor(currentTheme.value, props.levelid), bounds).addTo(map)
|
||||||
|
|
||||||
// Convert database coordinates to Leaflet (y is inverted)
|
// Convert database coordinates to Leaflet (y is inverted)
|
||||||
const leafletY = MAP_HEIGHT - props.top
|
const leafletY = MAP_HEIGHT - props.top
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
>
|
>
|
||||||
<div class="map-tooltip-content">
|
<div class="map-tooltip-content">
|
||||||
<div class="map-preview" ref="mapPreview">
|
<div v-if="levelUnknown" class="map-level-unknown">
|
||||||
|
<strong>Level unknown</strong>
|
||||||
|
<span>
|
||||||
|
This asset has a position ({{ props.left }}, {{ props.top }}) but no
|
||||||
|
level, so there is no drawing to show it on. Set its level on the
|
||||||
|
asset, or place it in the map editor.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="map-preview" ref="mapPreview">
|
||||||
<div
|
<div
|
||||||
class="map-transform"
|
class="map-transform"
|
||||||
:style="transformStyle"
|
:style="transformStyle"
|
||||||
@@ -43,7 +51,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, levelName, state as mapConfig } from '../composables/mapConfig'
|
||||||
|
|
||||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||||
// when it loads.
|
// when it loads.
|
||||||
@@ -52,7 +60,11 @@ loadMapConfig()
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
left: { type: Number, default: null },
|
left: { type: Number, default: null },
|
||||||
top: { type: Number, default: null },
|
top: { type: Number, default: null },
|
||||||
machineName: { type: String, default: '' }
|
machineName: { type: String, default: '' },
|
||||||
|
// Which drawing left/top are pixels of (ADR-017). A position without one
|
||||||
|
// cannot be rendered: the same coordinates land somewhere different on every
|
||||||
|
// level, so this shows what is missing instead of guessing the default.
|
||||||
|
levelid: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -67,18 +79,29 @@ const hasPosition = computed(() => {
|
|||||||
return props.left !== null && props.top !== null
|
return props.left !== null && props.top !== null
|
||||||
})
|
})
|
||||||
|
|
||||||
const blueprintUrl = computed(() => {
|
// A position we cannot place: coordinates but no level, or a level this instance
|
||||||
// Reading currentTheme keeps this reactive to theme changes.
|
// does not know. Rendering the default blueprint here would look correct and be
|
||||||
return blueprintUrlFor(currentTheme.value)
|
// wrong, so the tooltip says so instead.
|
||||||
|
const levelUnknown = computed(() => {
|
||||||
|
return hasPosition.value && !hasLevel(props.levelid)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate marker position as percentage of the facility blueprint size
|
const levelLabel = computed(() => levelName(props.levelid))
|
||||||
|
|
||||||
|
const blueprintUrl = computed(() => {
|
||||||
|
// Reading currentTheme keeps this reactive to theme changes.
|
||||||
|
return blueprintUrlFor(currentTheme.value, props.levelid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Marker position as a percentage of THIS LEVEL's native size. Percentages of
|
||||||
|
// the wrong level's dimensions is precisely how a marker ends up plausibly
|
||||||
|
// placed and wrong.
|
||||||
const markerX = computed(() => {
|
const markerX = computed(() => {
|
||||||
return (props.left / mapConfig.width) * 100
|
return (props.left / dimensionsFor(props.levelid).width) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
const markerY = computed(() => {
|
const markerY = computed(() => {
|
||||||
return (props.top / mapConfig.height) * 100
|
return (props.top / dimensionsFor(props.levelid).height) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
// Marker style with counter-scale to maintain constant size
|
// Marker style with counter-scale to maintain constant size
|
||||||
@@ -197,6 +220,20 @@ watch(currentTheme, () => {
|
|||||||
.location-tooltip-wrapper:hover {
|
.location-tooltip-wrapper:hover {
|
||||||
color: var(--primary, #1976d2);
|
color: var(--primary, #1976d2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-level-unknown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-level-unknown strong {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, state as mapConfig } from '../composables/mapConfig'
|
||||||
import { assetTypeLabel } from '../utils/assetTypes'
|
import { assetTypeLabel } from '../utils/assetTypes'
|
||||||
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
@@ -111,7 +111,11 @@ const props = defineProps({
|
|||||||
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
|
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
|
||||||
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
|
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
|
||||||
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
|
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
|
||||||
subtypeNames: { type: Object, default: () => ({}) } // Map of subtype ID to name
|
subtypeNames: { type: Object, default: () => ({}) }, // Map of subtype ID to name
|
||||||
|
// Which level this map is drawing (ADR-017). Defaults to the current level in
|
||||||
|
// the shared config, so an existing caller that has not been taught about
|
||||||
|
// levels still renders the level the user is looking at rather than nothing.
|
||||||
|
levelid: { type: Number, default: null }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['markerClick', 'positionPicked'])
|
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||||
@@ -139,10 +143,15 @@ const filters = ref({
|
|||||||
search: ''
|
search: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map dimensions - facility blueprint size, loaded from settings before
|
// The drawn level's native size, which is what its marker coordinates mean.
|
||||||
// initMap runs (mutable so the loaded values replace the fallback defaults).
|
// Mutable because the levels load after this module does, and because switching
|
||||||
let MAP_WIDTH = mapConfig.width
|
// level changes them.
|
||||||
let MAP_HEIGHT = mapConfig.height
|
function drawnLevelId() {
|
||||||
|
return props.levelid ?? mapConfig.currentlevelid
|
||||||
|
}
|
||||||
|
|
||||||
|
let MAP_WIDTH = dimensionsFor(drawnLevelId()).width
|
||||||
|
let MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
|
||||||
|
|
||||||
// Asset type colors (for unified map mode) - normalized lookup
|
// Asset type colors (for unified map mode) - normalized lookup
|
||||||
const assetTypeColorsMap = {
|
const assetTypeColorsMap = {
|
||||||
@@ -279,7 +288,7 @@ function initMap() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
|
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme, drawnLevelId()), bounds)
|
||||||
imageOverlay.addTo(map)
|
imageOverlay.addTo(map)
|
||||||
|
|
||||||
// Set initial view - zoom out to show full floor plan
|
// Set initial view - zoom out to show full floor plan
|
||||||
@@ -584,7 +593,7 @@ watch(() => props.machines, (newVal, oldVal) => {
|
|||||||
|
|
||||||
watch(() => props.theme, (newTheme) => {
|
watch(() => props.theme, (newTheme) => {
|
||||||
if (imageOverlay && map) {
|
if (imageOverlay && map) {
|
||||||
imageOverlay.setUrl(blueprintUrlFor(newTheme))
|
imageOverlay.setUrl(blueprintUrlFor(newTheme, drawnLevelId()))
|
||||||
// Marker rings are keyed on the surface, so they have to be redrawn too.
|
// Marker rings are keyed on the surface, so they have to be redrawn too.
|
||||||
renderMarkers()
|
renderMarkers()
|
||||||
}
|
}
|
||||||
@@ -594,8 +603,8 @@ 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.
|
||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
MAP_WIDTH = mapConfig.width
|
MAP_WIDTH = dimensionsFor(drawnLevelId()).width
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
|
||||||
initMap()
|
initMap()
|
||||||
loadOverlays()
|
loadOverlays()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,42 +1,59 @@
|
|||||||
// Facility floor-map blueprint config, read from the settings table so each
|
// Which drawing renders a marker, and at what native size (ADR-017).
|
||||||
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
|
//
|
||||||
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
|
// This used to hold ONE blueprint and ONE pixel size, read from four settings,
|
||||||
// Missing keys fall back to the generic placeholder so a fresh or offline
|
// because a site had one floor map. It now holds every level of every building,
|
||||||
// install still renders; each site uploads its own blueprint in Settings.
|
// because `assets.mapx`/`mapy` are pixels in a specific level's space and the
|
||||||
|
// same coordinates mean different places on different drawings.
|
||||||
|
//
|
||||||
|
// THE RULE THIS FILE ENFORCES: a position without a level is not rendered on the
|
||||||
|
// default level. `blueprintUrlFor(theme, levelid)` returns null for an unknown
|
||||||
|
// level, and every caller must show "level unknown" rather than draw something.
|
||||||
|
// Falling back would put one building's ground floor behind a marker positioned
|
||||||
|
// for another building's mezzanine - it renders perfectly and points at the
|
||||||
|
// wrong place, which is worse than rendering nothing.
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { settingsApi } from '../api'
|
|
||||||
|
import { mapLevelsApi } from '../api'
|
||||||
import { withBase } from '../utils/basePath'
|
import { withBase } from '../utils/basePath'
|
||||||
|
|
||||||
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
|
// Used until the levels load, and on a fresh install with none configured, so a
|
||||||
const DEFAULTS = {
|
// map still draws something rather than breaking.
|
||||||
blueprintLight: '/static/images/floorplan-placeholder.svg',
|
const PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
|
||||||
blueprintDark: '/static/images/floorplan-placeholder.svg',
|
const FALLBACK_WIDTH = 3300
|
||||||
width: 3300,
|
const FALLBACK_HEIGHT = 2550
|
||||||
height: 2550
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared reactive config. Import as `state` to read width/height/blueprint.
|
export const state = reactive({
|
||||||
export const state = reactive({ ...DEFAULTS, loaded: false })
|
buildings: [],
|
||||||
|
// Flat index by levelid, because every hover preview resolves an arbitrary
|
||||||
|
// asset's level and has no idea which building it is in.
|
||||||
|
levels: {},
|
||||||
|
defaultlevelid: null,
|
||||||
|
currentlevelid: null,
|
||||||
|
loaded: false,
|
||||||
|
})
|
||||||
|
|
||||||
let inflight = null
|
let inflight = null
|
||||||
|
|
||||||
function applySetting(key, value) {
|
function levelFor(levelid) {
|
||||||
if (value === null || value === undefined || value === '') return
|
if (levelid === null || levelid === undefined) return null
|
||||||
if (key === 'map_blueprint_light') state.blueprintLight = value
|
return state.levels[levelid] || null
|
||||||
else if (key === 'map_blueprint_dark') state.blueprintDark = value
|
|
||||||
else if (key === 'map_width') {
|
|
||||||
const n = parseInt(value, 10)
|
|
||||||
if (!isNaN(n) && n > 0) state.width = n
|
|
||||||
} else if (key === 'map_height') {
|
|
||||||
const n = parseInt(value, 10)
|
|
||||||
if (!isNaN(n) && n > 0) state.height = n
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchConfig() {
|
function fetchLevels() {
|
||||||
inflight = settingsApi.list({ category: 'map' })
|
inflight = mapLevelsApi.list()
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
;(data.data || []).forEach(s => applySetting(s.key, s.value))
|
const payload = data.data || {}
|
||||||
|
state.buildings = payload.buildings || []
|
||||||
|
state.levels = {}
|
||||||
|
state.buildings.forEach(building => {
|
||||||
|
;(building.levels || []).forEach(level => {
|
||||||
|
state.levels[level.levelid] = { ...level, buildingname: building.buildingname }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
state.defaultlevelid = payload.defaultlevelid || null
|
||||||
|
if (!state.currentlevelid || !state.levels[state.currentlevelid]) {
|
||||||
|
state.currentlevelid = state.defaultlevelid
|
||||||
|
}
|
||||||
state.loaded = true
|
state.loaded = true
|
||||||
})
|
})
|
||||||
.catch(() => { state.loaded = true })
|
.catch(() => { state.loaded = true })
|
||||||
@@ -44,26 +61,95 @@ function fetchConfig() {
|
|||||||
return inflight
|
return inflight
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the map config once (shared across all map components). Returns a
|
// Fetch once, shared across every map component. Await it before initialising a
|
||||||
// promise that resolves when state is populated, so a caller can await it
|
// Leaflet map, which needs the dimensions to set its bounds.
|
||||||
// before initializing a Leaflet map that needs the dimensions.
|
|
||||||
export function loadMapConfig() {
|
export function loadMapConfig() {
|
||||||
if (state.loaded) return Promise.resolve()
|
if (state.loaded) return Promise.resolve()
|
||||||
if (inflight) return inflight
|
if (inflight) return inflight
|
||||||
return fetchConfig()
|
return fetchLevels()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-read config from the server after a map setting changes.
|
// Re-read after the levels admin changes something.
|
||||||
export function reloadMapConfig() {
|
export function reloadMapConfig() {
|
||||||
return fetchConfig()
|
return fetchLevels()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blueprint image URL for the given theme ('light' | 'dark').
|
export function setCurrentLevel(levelid) {
|
||||||
export function blueprintUrlFor(theme) {
|
if (state.levels[levelid]) state.currentlevelid = levelid
|
||||||
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint URL for one level in one theme, or null when the level is unknown.
|
||||||
|
*
|
||||||
|
* Falls back to the OTHER theme's image before giving up, because a site that
|
||||||
|
* uploaded only a light blueprint should still render in dark mode - a
|
||||||
|
* hard-to-read floor plan beats no floor plan.
|
||||||
|
*/
|
||||||
|
export function blueprintUrlFor(theme, levelid) {
|
||||||
|
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
|
||||||
|
if (!level) return null
|
||||||
|
const wanted = theme === 'light' ? level.blueprintlight : level.blueprintdark
|
||||||
|
const other = theme === 'light' ? level.blueprintdark : level.blueprintlight
|
||||||
|
const chosen = wanted || other
|
||||||
|
return withBase(chosen || PLACEHOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native pixel size of a level, which is what its marker coordinates mean.
|
||||||
|
*
|
||||||
|
* Returns the fallback for an unknown level so arithmetic does not divide by
|
||||||
|
* undefined, but callers deciding WHETHER to draw must ask `hasLevel` - these
|
||||||
|
* numbers are a safe default, not evidence the level exists.
|
||||||
|
*/
|
||||||
|
export function dimensionsFor(levelid) {
|
||||||
|
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
|
||||||
|
return {
|
||||||
|
width: level?.mapwidth || FALLBACK_WIDTH,
|
||||||
|
height: level?.mapheight || FALLBACK_HEIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLevel(levelid) {
|
||||||
|
return !!levelFor(levelid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelName(levelid) {
|
||||||
|
const level = levelFor(levelid)
|
||||||
|
if (!level) return null
|
||||||
|
// Qualified by building only when there is more than one, so a single-building
|
||||||
|
// site is not made to read "Main / Ground floor" everywhere.
|
||||||
|
return state.buildings.length > 1
|
||||||
|
? `${level.buildingname} / ${level.levelname}`
|
||||||
|
: level.levelname
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every level flat, in building then level order, for a selector.
|
||||||
|
export function levelOptions() {
|
||||||
|
const options = []
|
||||||
|
state.buildings.forEach(building => {
|
||||||
|
;(building.levels || []).forEach(level => {
|
||||||
|
options.push({
|
||||||
|
levelid: level.levelid,
|
||||||
|
levelname: level.levelname,
|
||||||
|
buildingname: building.buildingname,
|
||||||
|
label: state.buildings.length > 1
|
||||||
|
? `${building.buildingname} / ${level.levelname}`
|
||||||
|
: level.levelname,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMapConfig() {
|
export function useMapConfig() {
|
||||||
loadMapConfig()
|
loadMapConfig()
|
||||||
return { state, blueprintUrlFor }
|
return {
|
||||||
|
state,
|
||||||
|
blueprintUrlFor,
|
||||||
|
dimensionsFor,
|
||||||
|
hasLevel,
|
||||||
|
levelName,
|
||||||
|
levelOptions,
|
||||||
|
setCurrentLevel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
frontend/src/composables/mapConfig.spec.js
Normal file
144
frontend/src/composables/mapConfig.spec.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// The invariant the whole map model rests on: a position without a level is not
|
||||||
|
// drawn on the default level.
|
||||||
|
//
|
||||||
|
// Nothing else catches this. Rewriting the composable left four components
|
||||||
|
// reading a property that no longer existed, which is `undefined` rather than a
|
||||||
|
// compile error - Vite built it happily and every marker would have been
|
||||||
|
// positioned at NaN percent. A build proves the code parses, not that a marker
|
||||||
|
// lands anywhere.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const list = vi.fn()
|
||||||
|
vi.mock('../api', () => ({ mapLevelsApi: { list: (...args) => list(...args) } }))
|
||||||
|
|
||||||
|
const {
|
||||||
|
state, loadMapConfig, reloadMapConfig, blueprintUrlFor, dimensionsFor,
|
||||||
|
hasLevel, levelName, levelOptions, setCurrentLevel,
|
||||||
|
} = await import('./mapConfig')
|
||||||
|
|
||||||
|
// Two buildings, three levels, sized like the real before and after: the ground
|
||||||
|
// floor at 3300x2550 and a second floor at 3308x4000.
|
||||||
|
const PAYLOAD = {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
buildings: [
|
||||||
|
{
|
||||||
|
buildingid: 1, buildingname: 'Main', levels: [
|
||||||
|
{ levelid: 1, levelname: 'Ground floor', sortorder: 0, mapwidth: 3300,
|
||||||
|
mapheight: 2550, blueprintlight: '/x/g-light.png',
|
||||||
|
blueprintdark: '/x/g-dark.png', isdefault: true },
|
||||||
|
{ levelid: 2, levelname: 'Second floor', sortorder: 1, mapwidth: 3308,
|
||||||
|
mapheight: 4000, blueprintlight: '/x/2-light.png',
|
||||||
|
blueprintdark: null, isdefault: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buildingid: 2, buildingname: 'Annex', levels: [
|
||||||
|
{ levelid: 3, levelname: 'Ground floor', sortorder: 0, mapwidth: 1200,
|
||||||
|
mapheight: 900, blueprintlight: '/x/a-light.png',
|
||||||
|
blueprintdark: '/x/a-dark.png', isdefault: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultlevelid: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
list.mockReset()
|
||||||
|
list.mockResolvedValue(PAYLOAD)
|
||||||
|
state.loaded = false
|
||||||
|
state.currentlevelid = null
|
||||||
|
await reloadMapConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading', () => {
|
||||||
|
it('indexes every level of every building and adopts the default', () => {
|
||||||
|
expect(Object.keys(state.levels)).toEqual(['1', '2', '3'])
|
||||||
|
expect(state.defaultlevelid).toBe(1)
|
||||||
|
expect(state.currentlevelid).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches once across concurrent callers', async () => {
|
||||||
|
list.mockClear()
|
||||||
|
state.loaded = false
|
||||||
|
await Promise.all([loadMapConfig(), loadMapConfig(), loadMapConfig()])
|
||||||
|
expect(list).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives an unreachable server without hanging every map on the page', async () => {
|
||||||
|
list.mockReset()
|
||||||
|
list.mockRejectedValue(new Error('network'))
|
||||||
|
state.loaded = false
|
||||||
|
await reloadMapConfig()
|
||||||
|
expect(state.loaded).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dimensions belong to the level, not the site', () => {
|
||||||
|
it('returns each level its own native size', () => {
|
||||||
|
expect(dimensionsFor(1)).toEqual({ width: 3300, height: 2550 })
|
||||||
|
expect(dimensionsFor(2)).toEqual({ width: 3308, height: 4000 })
|
||||||
|
expect(dimensionsFor(3)).toEqual({ width: 1200, height: 900 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not report one level size for another', () => {
|
||||||
|
// The bug this guards: a marker on level 2 measured against level 1's
|
||||||
|
// height renders at 2550/4000 of the way down - plausible, and wrong.
|
||||||
|
expect(dimensionsFor(2).height).not.toBe(dimensionsFor(1).height)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a position without a level is not drawn', () => {
|
||||||
|
it('has no blueprint for a null level', () => {
|
||||||
|
expect(blueprintUrlFor('light', null)).toBeNull()
|
||||||
|
expect(hasLevel(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no blueprint for a level this instance does not know', () => {
|
||||||
|
expect(blueprintUrlFor('light', 99)).toBeNull()
|
||||||
|
expect(hasLevel(99)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never substitutes the default level for a missing one', () => {
|
||||||
|
const groundfloor = blueprintUrlFor('light', 1)
|
||||||
|
expect(groundfloor).toContain('g-light.png')
|
||||||
|
// The failure mode: returning the default blueprint for an unknown level.
|
||||||
|
expect(blueprintUrlFor('light', null)).not.toBe(groundfloor)
|
||||||
|
expect(blueprintUrlFor('light', 99)).not.toBe(groundfloor)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('blueprints', () => {
|
||||||
|
it('serves the theme asked for', () => {
|
||||||
|
expect(blueprintUrlFor('light', 1)).toContain('g-light.png')
|
||||||
|
expect(blueprintUrlFor('dark', 1)).toContain('g-dark.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the other theme rather than showing nothing', () => {
|
||||||
|
// Level 2 has no dark blueprint. A hard-to-read floor plan beats no floor
|
||||||
|
// plan, and a site that uploaded one image should still work in both themes.
|
||||||
|
expect(blueprintUrlFor('dark', 2)).toContain('2-light.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('naming and selection', () => {
|
||||||
|
it('qualifies a level by building only when there is more than one', () => {
|
||||||
|
// Both buildings have a 'Ground floor', so the name alone is ambiguous.
|
||||||
|
expect(levelName(1)).toBe('Main / Ground floor')
|
||||||
|
expect(levelName(3)).toBe('Annex / Ground floor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers every level in building then level order', () => {
|
||||||
|
expect(levelOptions().map(option => option.levelid)).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to make an unknown level current', () => {
|
||||||
|
setCurrentLevel(99)
|
||||||
|
expect(state.currentlevelid).toBe(1)
|
||||||
|
setCurrentLevel(2)
|
||||||
|
expect(state.currentlevelid).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,7 +52,9 @@ export const searchDomains = [
|
|||||||
{ key: 'network_device', label: 'Network Devices' },
|
{ key: 'network_device', label: 'Network Devices' },
|
||||||
{ key: 'measuring_tool', label: 'Measuring Tools' },
|
{ key: 'measuring_tool', label: 'Measuring Tools' },
|
||||||
{ key: 'notification', label: 'Notifications' },
|
{ key: 'notification', label: 'Notifications' },
|
||||||
{ key: 'subnet', label: 'Subnets' }
|
{ key: 'subnet', label: 'Subnets' },
|
||||||
|
{ key: 'usb_device', label: 'USB Devices' },
|
||||||
|
{ key: 'printed_item', label: 'Printed Items' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useSystemSettings() {
|
export function useSystemSettings() {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Map Editor</h2>
|
<h2>Map Editor</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-secondary" @click="openTransform">Recalibrate level</button>
|
||||||
|
<button class="btn btn-secondary" @click="openSnapshots">Undo history</button>
|
||||||
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
|
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -12,6 +14,13 @@
|
|||||||
<div class="asset-panel">
|
<div class="asset-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>Assets</h3>
|
<h3>Assets</h3>
|
||||||
|
<select v-if="levelOptions().length > 1" v-model.number="editingLevelId"
|
||||||
|
class="form-control" title="Which drawing you are placing on">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="search" class="form-control" type="search"
|
||||||
|
placeholder="Search name or asset number" />
|
||||||
<select v-model="filterType" class="form-control">
|
<select v-model="filterType" class="form-control">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="machine">Machines</option>
|
<option value="machine">Machines</option>
|
||||||
@@ -25,7 +34,15 @@
|
|||||||
<div class="asset-filter">
|
<div class="asset-filter">
|
||||||
<label class="filter-checkbox">
|
<label class="filter-checkbox">
|
||||||
<input type="checkbox" v-model="showUnplacedOnly" />
|
<input type="checkbox" v-model="showUnplacedOnly" />
|
||||||
Show unplaced only
|
Unplaced only
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox" title="Positions a transform moved, which nobody has confirmed against the current drawing yet">
|
||||||
|
<input type="checkbox" v-model="showUnverifiedOnly" />
|
||||||
|
Needs review ({{ unverifiedCount }})
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox" title="Only assets on the level you are editing">
|
||||||
|
<input type="checkbox" v-model="thisLevelOnly" />
|
||||||
|
This level only
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,6 +64,15 @@
|
|||||||
<div class="asset-meta">
|
<div class="asset-meta">
|
||||||
<span class="badge badge-sm">{{ asset.assettype }}</span>
|
<span class="badge badge-sm">{{ asset.assettype }}</span>
|
||||||
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
|
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
|
||||||
|
<span v-if="asset.levelid && asset.levelid !== editingLevelId"
|
||||||
|
class="badge badge-sm badge-other-level"
|
||||||
|
:title="'On ' + levelName(asset.levelid)">{{ levelName(asset.levelid) }}</span>
|
||||||
|
<span v-if="asset.mapx && !asset.levelid" class="badge badge-sm badge-warning"
|
||||||
|
title="Has a position but no level, so it cannot be drawn">no level</span>
|
||||||
|
<button v-if="asset.mapx && asset.levelid && !asset.mapverifiedat"
|
||||||
|
class="badge badge-sm badge-review" type="button"
|
||||||
|
title="This position came from a transform. Click to confirm it is right."
|
||||||
|
@click.stop="markReviewed(asset)">confirm</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,19 +122,114 @@
|
|||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
:pickerMode="!!selectedAsset"
|
:pickerMode="!!selectedAsset"
|
||||||
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
|
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
|
||||||
|
:levelid="editingLevelId"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@markerClick="handleMarkerClick"
|
@markerClick="handleMarkerClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Recalibrate: move every marker on this level by a transform read off two
|
||||||
|
landmarks. Deriving it from the image dimensions instead would stretch
|
||||||
|
one axis and be wrong everywhere, so the numbers come from features
|
||||||
|
visible on both drawings. -->
|
||||||
|
<div v-if="showTransform" class="modal-overlay">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header"><h3>Recalibrate {{ levelName(editingLevelId) }}</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="input-hint">
|
||||||
|
Pick two features present on both the old and the new drawing. A
|
||||||
|
building corner plus something central beats two corners: a long
|
||||||
|
baseline makes the derived scale more forgiving.
|
||||||
|
</p>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Landmark</th><th>Old X</th><th>Old Y</th><th>New X</th><th>New Y</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(mark, index) in landmarks" :key="index">
|
||||||
|
<td>{{ index + 1 }}</td>
|
||||||
|
<td><input v-model.number="mark.fromx" type="number" class="form-control" /></td>
|
||||||
|
<td><input v-model.number="mark.fromy" type="number" class="form-control" /></td>
|
||||||
|
<td><input v-model.number="mark.tox" type="number" class="form-control" /></td>
|
||||||
|
<td><input v-model.number="mark.toy" type="number" class="form-control" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-small btn-secondary" @click="landmarks.push({})">
|
||||||
|
Add a third landmark
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="preview" class="transform-preview">
|
||||||
|
<h4>Preview</h4>
|
||||||
|
<p class="mono">
|
||||||
|
X: scale {{ preview.transform.scalex.toFixed(4) }}, offset {{ Math.round(preview.transform.offsetx) }}<br />
|
||||||
|
Y: scale {{ preview.transform.scaley.toFixed(4) }}, offset {{ Math.round(preview.transform.offsety) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ preview.assetcount }} marker(s) would move.
|
||||||
|
<strong v-if="preview.outofboundscount" class="warn">
|
||||||
|
{{ preview.outofboundscount }} would land outside the drawing.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p class="input-hint">
|
||||||
|
A scale near 1 means the drawing shifted rather than rescaled. A
|
||||||
|
scale far from 1 on an axis that only gained canvas is the sign of a
|
||||||
|
bad landmark pair.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="transformError" class="error-message">{{ transformError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @click="showTransform = false">Cancel</button>
|
||||||
|
<button class="btn btn-secondary" @click="runTransform(true)" :disabled="working">Preview</button>
|
||||||
|
<button class="btn btn-primary" @click="runTransform(false)" :disabled="working || !preview">
|
||||||
|
Move {{ preview ? preview.assetcount : 0 }} marker(s)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Undo. Every bulk write snapshots first, and a restore snapshots too, so a
|
||||||
|
second attempt is always possible. -->
|
||||||
|
<div v-if="showSnapshots" class="modal-overlay">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h3>Undo history</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>When</th><th>What</th><th>Markers</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="snapshot in snapshots" :key="snapshot.snapshotid">
|
||||||
|
<td class="mono">{{ (snapshot.createddate || '').slice(0, 16).replace('T', ' ') }}</td>
|
||||||
|
<td>{{ snapshot.reason }}</td>
|
||||||
|
<td>{{ snapshot.assetcount }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small" @click="restore(snapshot)" :disabled="working">
|
||||||
|
{{ snapshot.restoredat ? 'Restore again' : 'Restore' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!snapshots.length">
|
||||||
|
<td colspan="4" class="empty">Nothing to undo yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @click="showSnapshots = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
||||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||||
import { assetsApi } from '../api'
|
import { assetsApi, mapPositionsApi } from '../api'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, setCurrentLevel,
|
||||||
|
state as mapConfig } from '../composables/mapConfig'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { useToast } from '../composables/toast'
|
import { useToast } from '../composables/toast'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -119,6 +240,25 @@ const selectedAsset = ref(null)
|
|||||||
const pickedPosition = ref(null)
|
const pickedPosition = ref(null)
|
||||||
const filterType = ref('')
|
const filterType = ref('')
|
||||||
const showUnplacedOnly = ref(false)
|
const showUnplacedOnly = ref(false)
|
||||||
|
// A transform is a guess: it moves markers but cannot know which machines
|
||||||
|
// actually moved, so everything it touches needs confirming against the drawing.
|
||||||
|
// This is the working queue for that.
|
||||||
|
const showUnverifiedOnly = ref(false)
|
||||||
|
const thisLevelOnly = ref(true)
|
||||||
|
const search = ref('')
|
||||||
|
// Which drawing is being placed on. Every position saved from here belongs to it.
|
||||||
|
const editingLevelId = ref(null)
|
||||||
|
|
||||||
|
// Recalibration state. Two landmarks is the minimum that determines both axes;
|
||||||
|
// a third is offered because points picked by eye carry a few pixels of error and
|
||||||
|
// three let it cancel rather than accumulate.
|
||||||
|
const showTransform = ref(false)
|
||||||
|
const landmarks = ref([{}, {}])
|
||||||
|
const preview = ref(null)
|
||||||
|
const transformError = ref('')
|
||||||
|
const showSnapshots = ref(false)
|
||||||
|
const snapshots = ref([])
|
||||||
|
const working = ref(false)
|
||||||
|
|
||||||
const filteredAssets = computed(() => {
|
const filteredAssets = computed(() => {
|
||||||
let result = assets.value
|
let result = assets.value
|
||||||
@@ -131,17 +271,48 @@ const filteredAssets = computed(() => {
|
|||||||
result = result.filter(a => !a.mapx || !a.mapy)
|
result = result.filter(a => !a.mapx || !a.mapy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showUnverifiedOnly.value) {
|
||||||
|
// Placed, on a level, and never confirmed. An unplaced asset is not
|
||||||
|
// "unreviewed" - it is simply not on the map yet.
|
||||||
|
result = result.filter(a => a.mapx && a.levelid && !a.mapverifiedat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thisLevelOnly.value && editingLevelId.value) {
|
||||||
|
// Unplaced assets stay visible whatever the level filter says: they are the
|
||||||
|
// ones you are here to place, and they belong to no level yet.
|
||||||
|
result = result.filter(a => !a.mapx || a.levelid === editingLevelId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = search.value.trim().toLowerCase()
|
||||||
|
if (term) {
|
||||||
|
result = result.filter(a =>
|
||||||
|
(a.name || '').toLowerCase().includes(term) ||
|
||||||
|
(a.assetnumber || '').toLowerCase().includes(term))
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Only markers on the level being drawn. Markers from another level would appear
|
||||||
|
// at coordinates that mean nothing on this one.
|
||||||
const placedAssets = computed(() => {
|
const placedAssets = computed(() => {
|
||||||
return assets.value.filter(a => a.mapx && a.mapy)
|
return assets.value.filter(a => a.mapx && a.mapy &&
|
||||||
|
a.levelid === editingLevelId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unverifiedCount = computed(() =>
|
||||||
|
assets.value.filter(a => a.mapx && a.levelid && !a.mapverifiedat).length)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadMapConfig()
|
||||||
|
editingLevelId.value = mapConfig.defaultlevelid
|
||||||
await loadAssets()
|
await loadAssets()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(editingLevelId, (levelid) => {
|
||||||
|
if (levelid) setCurrentLevel(levelid)
|
||||||
|
})
|
||||||
|
|
||||||
async function loadAssets() {
|
async function loadAssets() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +325,80 @@ async function loadAssets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTransform() {
|
||||||
|
preview.value = null
|
||||||
|
transformError.value = ''
|
||||||
|
landmarks.value = [{}, {}]
|
||||||
|
showTransform.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTransform(dryrun) {
|
||||||
|
working.value = true
|
||||||
|
transformError.value = ''
|
||||||
|
try {
|
||||||
|
const { data } = await mapPositionsApi.transform({
|
||||||
|
levelid: editingLevelId.value,
|
||||||
|
landmarks: landmarks.value.filter(mark =>
|
||||||
|
[mark.fromx, mark.fromy, mark.tox, mark.toy].every(
|
||||||
|
value => value !== undefined && value !== null && value !== '')),
|
||||||
|
dryrun,
|
||||||
|
})
|
||||||
|
if (dryrun) {
|
||||||
|
preview.value = data.data
|
||||||
|
} else {
|
||||||
|
showTransform.value = false
|
||||||
|
preview.value = null
|
||||||
|
await loadAssets()
|
||||||
|
// Straight into the review queue: every moved marker is now unconfirmed,
|
||||||
|
// and that is the work the transform created.
|
||||||
|
showUnverifiedOnly.value = true
|
||||||
|
toast.success(data.message || 'Markers moved')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
transformError.value = err?.response?.data?.data?.error?.message
|
||||||
|
|| 'The transform could not be applied'
|
||||||
|
} finally {
|
||||||
|
working.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSnapshots() {
|
||||||
|
showSnapshots.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapPositionsApi.snapshots()
|
||||||
|
snapshots.value = data.data || []
|
||||||
|
} catch (err) {
|
||||||
|
snapshots.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(snapshot) {
|
||||||
|
if (!confirm(`Restore ${snapshot.assetcount} marker position(s) from ` +
|
||||||
|
`"${snapshot.reason}"? The current positions are snapshotted first.`)) return
|
||||||
|
working.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapPositionsApi.restore(snapshot.snapshotid)
|
||||||
|
await loadAssets()
|
||||||
|
await openSnapshots()
|
||||||
|
toast.success(data.message || 'Positions restored')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not restore that snapshot')
|
||||||
|
} finally {
|
||||||
|
working.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm a marker is in the right place without moving it - the common case in
|
||||||
|
// a review pass, and the reason the queue empties.
|
||||||
|
async function markReviewed(asset) {
|
||||||
|
try {
|
||||||
|
await mapPositionsApi.verify([asset.assetid])
|
||||||
|
asset.mapverifiedat = new Date().toISOString()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not mark that reviewed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getTypeIcon(assettype) {
|
function getTypeIcon(assettype) {
|
||||||
const icons = {
|
const icons = {
|
||||||
'machine': Cog,
|
'machine': Cog,
|
||||||
@@ -184,16 +429,23 @@ async function savePosition() {
|
|||||||
if (!selectedAsset.value || !pickedPosition.value) return
|
if (!selectedAsset.value || !pickedPosition.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assetsApi.update(selectedAsset.value.assetid, {
|
// Through the bulk endpoint, which requires a level and stamps the review
|
||||||
|
// state: placing a marker by hand IS the confirmation, and a snapshot is
|
||||||
|
// taken so the placement can be undone.
|
||||||
|
await mapPositionsApi.setPositions([{
|
||||||
|
assetid: selectedAsset.value.assetid,
|
||||||
mapx: Math.round(pickedPosition.value.left),
|
mapx: Math.round(pickedPosition.value.left),
|
||||||
mapy: Math.round(pickedPosition.value.top)
|
mapy: Math.round(pickedPosition.value.top),
|
||||||
})
|
levelid: editingLevelId.value,
|
||||||
|
}])
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
||||||
if (asset) {
|
if (asset) {
|
||||||
asset.mapx = Math.round(pickedPosition.value.left)
|
asset.mapx = Math.round(pickedPosition.value.left)
|
||||||
asset.mapy = Math.round(pickedPosition.value.top)
|
asset.mapy = Math.round(pickedPosition.value.top)
|
||||||
|
asset.levelid = editingLevelId.value
|
||||||
|
asset.mapverifiedat = new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedAsset.value = null
|
selectedAsset.value = null
|
||||||
@@ -409,4 +661,31 @@ function cancelEdit() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-review {
|
||||||
|
background: var(--warning);
|
||||||
|
color: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-other-level {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-light);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-preview {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-preview h4 { margin: 0 0 0.5rem; }
|
||||||
|
.mono { font-family: monospace; }
|
||||||
|
.warn { color: var(--warning); }
|
||||||
|
.modal-wide { min-width: 44rem; }
|
||||||
|
.input-hint { display: block; color: var(--text-light); font-size: 0.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ 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, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, 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'
|
||||||
@@ -245,9 +245,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'),
|
blueprintUrl: blueprintUrlFor('light', mapConfig.currentlevelid),
|
||||||
mapWidth: mapConfig.width,
|
mapWidth: dimensionsFor(mapConfig.currentlevelid).width,
|
||||||
mapHeight: mapConfig.height,
|
mapHeight: dimensionsFor(mapConfig.currentlevelid).height,
|
||||||
|
// 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.
|
||||||
|
levelname: levelName(mapConfig.currentlevelid),
|
||||||
selectedType: selectedType.value,
|
selectedType: selectedType.value,
|
||||||
subtypeColors: subtypeColorMap.value,
|
subtypeColors: subtypeColorMap.value,
|
||||||
subtypeNames: subtypeNameMap.value,
|
subtypeNames: subtypeNameMap.value,
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ const typeLabels = {
|
|||||||
measuring_tool: 'Measuring Tool',
|
measuring_tool: 'Measuring Tool',
|
||||||
employee: 'Employee',
|
employee: 'Employee',
|
||||||
notification: 'Notice',
|
notification: 'Notice',
|
||||||
subnet: 'Subnet'
|
subnet: 'Subnet',
|
||||||
|
usb_device: 'USB',
|
||||||
|
printed_item: 'Printed Part'
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterTypeMap = {
|
const filterTypeMap = {
|
||||||
@@ -132,7 +134,9 @@ const filterTypeMap = {
|
|||||||
applications: ['application'],
|
applications: ['application'],
|
||||||
knowledgebase: ['knowledgebase'],
|
knowledgebase: ['knowledgebase'],
|
||||||
notifications: ['notification'],
|
notifications: ['notification'],
|
||||||
employees: ['employee']
|
employees: ['employee'],
|
||||||
|
usb: ['usb_device'],
|
||||||
|
printedparts: ['printed_item']
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterList = [
|
const filterList = [
|
||||||
@@ -145,7 +149,9 @@ const filterList = [
|
|||||||
{ key: 'applications', label: 'Apps' },
|
{ key: 'applications', label: 'Apps' },
|
||||||
{ key: 'knowledgebase', label: 'KB' },
|
{ key: 'knowledgebase', label: 'KB' },
|
||||||
{ key: 'notifications', label: 'Notices' },
|
{ key: 'notifications', label: 'Notices' },
|
||||||
{ key: 'employees', label: 'Employees' }
|
{ key: 'employees', label: 'Employees' },
|
||||||
|
{ key: 'usb', label: 'USB' },
|
||||||
|
{ key: 'printedparts', label: 'Printed Parts' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function typeLabel(type) {
|
function typeLabel(type) {
|
||||||
|
|||||||
@@ -1,101 +1,416 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Floor Map</h2>
|
<h2>Floor Maps</h2>
|
||||||
|
<button class="btn btn-secondary" @click="showAddBuilding = true">Add building</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<p class="setting-description">
|
||||||
<div class="setting-group">
|
Each level is one drawing with its own blueprint and its own pixel size.
|
||||||
<h3>Facility Blueprint</h3>
|
Marker positions are pixels in the level's own space, so the level id below
|
||||||
<p class="setting-description">
|
is what a position belongs to - it is worth knowing when you run a
|
||||||
The floor-plan image and its pixel dimensions for this facility. Map
|
transform or ask about a marker that is in the wrong place.
|
||||||
markers are positioned against these dimensions, so the width and
|
</p>
|
||||||
height must match the native size of the blueprint image. Leave the
|
|
||||||
image paths at their defaults to use the bundled sitemap.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
<div v-if="loading" class="empty">Loading...</div>
|
||||||
<label>
|
|
||||||
<span>Blueprint image (light theme)</span>
|
<div v-for="building in buildings" :key="building.buildingid" class="section-card">
|
||||||
<input
|
<div class="building-header">
|
||||||
type="text"
|
<input
|
||||||
v-model="settings.map_blueprint_light"
|
v-model="building.buildingname"
|
||||||
placeholder="/static/images/sitemap2025-light.png"
|
class="building-name"
|
||||||
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
|
@blur="renameBuilding(building)"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
/>
|
||||||
<div class="map-upload-row">
|
<span class="muted">{{ building.levels.length }} level(s)</span>
|
||||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
<button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
|
||||||
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
|
</div>
|
||||||
</div>
|
|
||||||
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
|
<table class="data-table">
|
||||||
</label>
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th title="What a marker position on this level refers to">Level id</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Native size</th>
|
||||||
|
<th>Markers</th>
|
||||||
|
<th>Light</th>
|
||||||
|
<th>Dark</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="level in building.levels" :key="level.levelid">
|
||||||
|
<td><code class="levelid">{{ level.levelid }}</code></td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="level.levelname"
|
||||||
|
class="form-control"
|
||||||
|
@blur="renameLevel(level)"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model.number="level.sortorder"
|
||||||
|
type="number"
|
||||||
|
class="form-control order-input"
|
||||||
|
@blur="saveLevel(level, { sortorder: level.sortorder })"
|
||||||
|
:disabled="saving"
|
||||||
|
title="Lower sorts first. Ground 0, first floor 1, mezzanine 5 between them later."
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="mono">{{ level.mapwidth }} x {{ level.mapheight }}</span>
|
||||||
|
<small v-if="level.assetcount" class="input-hint">
|
||||||
|
fixed while {{ level.assetcount }} marker(s) are placed
|
||||||
|
</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>
|
||||||
|
<span :class="{ 'muted': !level.assetcount }">{{ level.assetcount }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<img v-if="level.blueprintlight" :src="withBase(level.blueprintlight)"
|
||||||
|
class="map-thumb" alt="light blueprint" />
|
||||||
|
<input type="file" accept="image/*" class="file-input"
|
||||||
|
@change="upload(level, 'light', $event)" :disabled="uploading" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<img v-if="level.blueprintdark" :src="withBase(level.blueprintdark)"
|
||||||
|
class="map-thumb map-thumb-dark" alt="dark blueprint" />
|
||||||
|
<input type="file" accept="image/*" class="file-input"
|
||||||
|
@change="upload(level, 'dark', $event)" :disabled="uploading" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="radio" :checked="level.isdefault" name="defaultlevel"
|
||||||
|
@change="saveLevel(level, { isdefault: true })"
|
||||||
|
title="Where an asset with no level lands, and what the map opens on" />
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button class="btn btn-small btn-danger" @click="remove(level)"
|
||||||
|
:disabled="saving">Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!building.levels.length">
|
||||||
|
<td colspan="9" class="empty">No levels yet. Add one, then upload its blueprint.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add building -->
|
||||||
|
<div v-if="showAddBuilding" class="modal-overlay">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h3>Add building</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input v-model="newBuilding" class="form-control" placeholder="Annex"
|
||||||
|
@keyup.enter="addBuilding" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
<div class="setting-row">
|
<button class="btn btn-secondary" @click="showAddBuilding = false">Cancel</button>
|
||||||
<label>
|
<button class="btn btn-primary" @click="addBuilding" :disabled="!newBuilding.trim()">Add</button>
|
||||||
<span>Blueprint image (dark theme)</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
v-model="settings.map_blueprint_dark"
|
|
||||||
placeholder="/static/images/sitemap2025-dark.png"
|
|
||||||
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
|
|
||||||
:disabled="saving"
|
|
||||||
>
|
|
||||||
<div class="map-upload-row">
|
|
||||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
|
||||||
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
|
|
||||||
</div>
|
|
||||||
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="setting-row">
|
<!-- Add level -->
|
||||||
<label>
|
<div v-if="addLevelFor" class="modal-overlay">
|
||||||
<span>Blueprint width (pixels)</span>
|
<div class="modal">
|
||||||
<input
|
<div class="modal-header"><h3>Add level to {{ addLevelFor.buildingname }}</h3></div>
|
||||||
type="number"
|
<div class="modal-body">
|
||||||
v-model="settings.map_width"
|
<div class="form-group">
|
||||||
min="1"
|
<label>Name</label>
|
||||||
placeholder="3300"
|
<input v-model="newLevel.levelname" class="form-control"
|
||||||
@blur="saveSetting('map_width', settings.map_width)"
|
placeholder="Second floor" @keyup.enter="addLevel" />
|
||||||
:disabled="saving"
|
<small class="input-hint">
|
||||||
>
|
Whatever the building calls it. Basement, Ground, Mezzanine, Roof.
|
||||||
<small class="input-hint">Native pixel width of the blueprint image</small>
|
</small>
|
||||||
</label>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Sort order</label>
|
||||||
|
<input v-model.number="newLevel.sortorder" type="number" class="form-control" />
|
||||||
|
<small class="input-hint">
|
||||||
|
Lower sorts first, and gaps are fine - leaving room lets a mezzanine
|
||||||
|
slot in later without renumbering anything.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">
|
||||||
|
Upload the blueprint after creating it. An empty level takes its pixel
|
||||||
|
size from the image, so there is nothing to measure by hand.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
<div class="setting-row">
|
<button class="btn btn-secondary" @click="addLevelFor = null">Cancel</button>
|
||||||
<label>
|
<button class="btn btn-primary" @click="addLevel"
|
||||||
<span>Blueprint height (pixels)</span>
|
:disabled="!newLevel.levelname.trim()">Add</button>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
v-model="settings.map_height"
|
|
||||||
min="1"
|
|
||||||
placeholder="2550"
|
|
||||||
@blur="saveSetting('map_height', settings.map_height)"
|
|
||||||
:disabled="saving"
|
|
||||||
>
|
|
||||||
<small class="input-hint">Native pixel height of the blueprint image</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
<div v-if="notice" class="settings-success">{{ notice }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { withBase } from '../../utils/basePath'
|
// Buildings and levels admin (ADR-017). This page replaced four site-wide
|
||||||
import { onMounted } from 'vue'
|
// settings that described a single blueprint, which could not express a second
|
||||||
import { useSystemSettings } from '../../composables/systemSettings'
|
// level drawn at a different size, let alone a second building.
|
||||||
|
//
|
||||||
|
// The level id is deliberately on screen. It is what `assets.levelid` points at,
|
||||||
|
// what a landmark transform takes as an argument, and the first thing worth
|
||||||
|
// knowing when a marker draws on the wrong drawing.
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const {
|
import { mapLevelsApi } from '@/api'
|
||||||
settings, saving, mapUploading, error, success,
|
import { withBase } from '@/utils/basePath'
|
||||||
loadSettings, saveSetting, uploadBlueprint,
|
import { reloadMapConfig } from '@/composables/mapConfig'
|
||||||
} = useSystemSettings()
|
|
||||||
|
|
||||||
onMounted(loadSettings)
|
const buildings = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
|
||||||
|
const showAddBuilding = ref(false)
|
||||||
|
const newBuilding = ref('')
|
||||||
|
const addLevelFor = ref(null)
|
||||||
|
const newLevel = ref({ levelname: '', sortorder: 0 })
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.list()
|
||||||
|
buildings.value = data.data.buildings || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not load the levels')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(err, fallback) {
|
||||||
|
return err?.response?.data?.data?.error?.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(text) {
|
||||||
|
notice.value = text
|
||||||
|
error.value = ''
|
||||||
|
// Long enough to read a sentence about what did not happen.
|
||||||
|
setTimeout(() => { notice.value = '' }, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addBuilding() {
|
||||||
|
const name = newBuilding.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await mapLevelsApi.createBuilding({
|
||||||
|
buildingname: name,
|
||||||
|
sortorder: buildings.value.length,
|
||||||
|
})
|
||||||
|
showAddBuilding.value = false
|
||||||
|
newBuilding.value = ''
|
||||||
|
await load()
|
||||||
|
report(`Building "${name}" added. Add its levels next.`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not add the building')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddLevel(building) {
|
||||||
|
addLevelFor.value = building
|
||||||
|
// Default to one past the last, so the common case needs no thought and the
|
||||||
|
// uncommon one is still editable.
|
||||||
|
newLevel.value = {
|
||||||
|
levelname: '',
|
||||||
|
sortorder: (building.levels.at(-1)?.sortorder ?? -1) + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLevel() {
|
||||||
|
const name = newLevel.value.levelname.trim()
|
||||||
|
if (!name || !addLevelFor.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.create({
|
||||||
|
buildingid: addLevelFor.value.buildingid,
|
||||||
|
levelname: name,
|
||||||
|
sortorder: newLevel.value.sortorder,
|
||||||
|
})
|
||||||
|
addLevelFor.value = null
|
||||||
|
await load()
|
||||||
|
report(`"${name}" created as level ${data.data.levelid}. Upload its blueprint to set its size.`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not add the level')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameBuilding(building) {
|
||||||
|
const name = (building.buildingname || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await mapLevelsApi.updateBuilding(building.buildingid, { buildingname: name })
|
||||||
|
report(`Building renamed to "${name}".`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not rename the building')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameLevel(level) {
|
||||||
|
const name = (level.levelname || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await saveLevel(level, { levelname: name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLevel(level, payload) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.update(level.levelid, payload)
|
||||||
|
// The server reports when a change leaves existing positions in an old
|
||||||
|
// coordinate space. Surfacing that verbatim matters more than a tidy
|
||||||
|
// message: it names the markers that are now wrong.
|
||||||
|
const warnings = data.data?.warnings
|
||||||
|
await load()
|
||||||
|
await reloadMapConfig()
|
||||||
|
report(warnings?.length ? warnings.join(' ') : 'Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not save the level')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(level, theme, event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.uploadBlueprint(level.levelid, theme, file)
|
||||||
|
await load()
|
||||||
|
await reloadMapConfig()
|
||||||
|
// sizenote is the interesting case: the image disagrees with the stored
|
||||||
|
// dimensions AND markers are already placed, so the server refused to
|
||||||
|
// change the coordinate space out from under them.
|
||||||
|
report(data.data.sizenote
|
||||||
|
|| `Blueprint uploaded (${data.data.detectedwidth} x ${data.data.detectedheight}).`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not upload the blueprint')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(level) {
|
||||||
|
const placed = level.assetcount
|
||||||
|
? ` It has ${level.assetcount} marker(s) on it, which the server will refuse.`
|
||||||
|
: ''
|
||||||
|
if (!confirm(`Remove "${level.levelname}" (level ${level.levelid})?${placed}`)) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await mapLevelsApi.remove(level.levelid)
|
||||||
|
await load()
|
||||||
|
report(`Level ${level.levelid} removed.`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not remove the level')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
color: var(--text-light);
|
||||||
|
max-width: 60rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-name {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-name:hover,
|
||||||
|
.building-name:focus {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelid {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: monospace; }
|
||||||
|
.order-input { width: 4.5rem; }
|
||||||
|
.size-input { width: 6rem; }
|
||||||
|
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.map-thumb {
|
||||||
|
max-width: 5rem;
|
||||||
|
max-height: 3rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-thumb-dark { background: #222; }
|
||||||
|
|
||||||
|
.muted { color: var(--text-light); }
|
||||||
|
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
|
||||||
|
.input-hint { display: block; color: var(--text-light); font-size: 0.75rem; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|||||||
213
migrations/versions/7d33_buildings_and_levels.py
Normal file
213
migrations/versions/7d33_buildings_and_levels.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""Buildings and levels: a map is a drawing per level, not one image per site.
|
||||||
|
|
||||||
|
See ADR-017. `map_blueprint_light`, `map_blueprint_dark`, `map_width` and
|
||||||
|
`map_height` described one image for the whole site, and `assets.mapx`/`mapy`
|
||||||
|
were pixels in it. A second level and a likely second building make that a
|
||||||
|
table.
|
||||||
|
|
||||||
|
This migration is written so nothing renders differently the day it lands: the
|
||||||
|
four settings become one building and one level, marked default, and every asset
|
||||||
|
that has a position points at it. The settings rows are left in place here and
|
||||||
|
retired separately, so a rollback does not lose the blueprint paths.
|
||||||
|
|
||||||
|
`levelid` is nullable because an asset with no position needs no level. A
|
||||||
|
position WITHOUT a level is the case the UI refuses to guess about, and after
|
||||||
|
this migration no such row exists.
|
||||||
|
|
||||||
|
Revision ID: 7d33_buildings_and_levels
|
||||||
|
Revises: 7d32_displayrole_kiosk_vocabulary
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = '7d33_buildings_and_levels'
|
||||||
|
down_revision = '7d32_displayrole_kiosk_vocabulary'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# What the settings said before this table existed. Read at upgrade time; these
|
||||||
|
# are only the fallbacks for a site that never set them.
|
||||||
|
DEFAULT_WIDTH = 3300
|
||||||
|
DEFAULT_HEIGHT = 2550
|
||||||
|
PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
|
||||||
|
|
||||||
|
|
||||||
|
def _existing(insp, name):
|
||||||
|
return name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
|
||||||
|
# Guarded like every other table-creating migration in this project: on a
|
||||||
|
# FRESH database the tables are built from the SQLAlchemy models, which
|
||||||
|
# already declare them, so an unconditional create fails.
|
||||||
|
if not _existing(insp, 'buildings'):
|
||||||
|
op.create_table(
|
||||||
|
'buildings',
|
||||||
|
sa.Column('buildingid', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('buildingname', sa.String(100), nullable=False,
|
||||||
|
unique=True),
|
||||||
|
sa.Column('sortorder', sa.Integer, nullable=False,
|
||||||
|
server_default='0'),
|
||||||
|
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('isactive', sa.Boolean, nullable=False,
|
||||||
|
server_default=sa.true()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _existing(insp, 'maplevels'):
|
||||||
|
op.create_table(
|
||||||
|
'maplevels',
|
||||||
|
sa.Column('levelid', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('buildingid', sa.Integer,
|
||||||
|
sa.ForeignKey('buildings.buildingid'), nullable=False,
|
||||||
|
index=True),
|
||||||
|
sa.Column('levelname', sa.String(100), nullable=False),
|
||||||
|
sa.Column('sortorder', sa.Integer, nullable=False,
|
||||||
|
server_default='0'),
|
||||||
|
sa.Column('blueprintlight', sa.String(255), nullable=True),
|
||||||
|
sa.Column('blueprintdark', sa.String(255), nullable=True),
|
||||||
|
sa.Column('mapwidth', sa.Integer, nullable=False,
|
||||||
|
server_default=str(DEFAULT_WIDTH)),
|
||||||
|
sa.Column('mapheight', sa.Integer, nullable=False,
|
||||||
|
server_default=str(DEFAULT_HEIGHT)),
|
||||||
|
sa.Column('isdefault', sa.Boolean, nullable=False,
|
||||||
|
server_default=sa.false()),
|
||||||
|
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('isactive', sa.Boolean, nullable=False,
|
||||||
|
server_default=sa.true()),
|
||||||
|
sa.UniqueConstraint('buildingid', 'levelname',
|
||||||
|
name='uq_maplevel_building_name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Positions have no history, and a bulk transform rewrites hundreds of them
|
||||||
|
# at once. A snapshot table is what makes that reversible - without it, the
|
||||||
|
# honest advice would be "back up the database first", which nobody does
|
||||||
|
# before a UI action.
|
||||||
|
if not _existing(insp, 'mappositionsnapshots'):
|
||||||
|
op.create_table(
|
||||||
|
'mappositionsnapshots',
|
||||||
|
sa.Column('snapshotid', sa.Integer, primary_key=True),
|
||||||
|
sa.Column('levelid', sa.Integer, nullable=True),
|
||||||
|
sa.Column('reason', sa.String(255), nullable=True),
|
||||||
|
sa.Column('assetcount', sa.Integer, nullable=False,
|
||||||
|
server_default='0'),
|
||||||
|
# The positions themselves, as JSON: assetid, mapx, mapy, levelid,
|
||||||
|
# mapverifiedat per row. Deliberately not a child table - a snapshot
|
||||||
|
# is read back whole or not at all, and one row per snapshot keeps
|
||||||
|
# restore a single statement.
|
||||||
|
sa.Column('positionsjson', sa.Text, nullable=False),
|
||||||
|
sa.Column('restoredat', sa.DateTime, nullable=True),
|
||||||
|
sa.Column('createdby', sa.String(100), nullable=True),
|
||||||
|
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||||
|
sa.Column('isactive', sa.Boolean, nullable=False,
|
||||||
|
server_default=sa.true()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
|
||||||
|
if 'levelid' not in assetcolumns:
|
||||||
|
op.add_column('assets', sa.Column('levelid', sa.Integer, nullable=True))
|
||||||
|
op.create_index('idx_assets_levelid', 'assets', ['levelid'])
|
||||||
|
# The FK is added separately from the column so a site whose assets
|
||||||
|
# table is large is not rewritten twice.
|
||||||
|
op.create_foreign_key('fk_assets_levelid', 'assets', 'maplevels',
|
||||||
|
['levelid'], ['levelid'])
|
||||||
|
if 'mapverifiedat' not in assetcolumns:
|
||||||
|
op.add_column('assets',
|
||||||
|
sa.Column('mapverifiedat', sa.DateTime, nullable=True))
|
||||||
|
|
||||||
|
# Locations carry map coordinates too - they are the default position for
|
||||||
|
# assets at that location - so they need a level for exactly the same
|
||||||
|
# reason. Missed on the first pass and caught by the payload gate.
|
||||||
|
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
|
||||||
|
if 'levelid' not in locationcolumns:
|
||||||
|
op.add_column('locations', sa.Column('levelid', sa.Integer, nullable=True))
|
||||||
|
op.create_index('idx_locations_levelid', 'locations', ['levelid'])
|
||||||
|
op.create_foreign_key('fk_locations_levelid', 'locations', 'maplevels',
|
||||||
|
['levelid'], ['levelid'])
|
||||||
|
|
||||||
|
# --- carry the settings forward -------------------------------------
|
||||||
|
# Only when there is nothing here yet: re-running must not create a second
|
||||||
|
# default level, and a site that has already set its levels up must not have
|
||||||
|
# them joined by a stale one built from retired settings.
|
||||||
|
existinglevels = bind.execute(
|
||||||
|
sa.text('SELECT COUNT(*) FROM maplevels')).scalar() or 0
|
||||||
|
if existinglevels:
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = dict(bind.execute(sa.text(
|
||||||
|
"SELECT `key`, value FROM settings WHERE `key` IN "
|
||||||
|
"('map_blueprint_light','map_blueprint_dark','map_width','map_height')"
|
||||||
|
)).fetchall())
|
||||||
|
|
||||||
|
def _int(value, fallback):
|
||||||
|
try:
|
||||||
|
number = int(str(value).strip())
|
||||||
|
return number if number > 0 else fallback
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
bind.execute(sa.text(
|
||||||
|
'INSERT INTO buildings (buildingname, sortorder, createddate, '
|
||||||
|
'modifieddate, isactive) VALUES (:name, 0, :now, :now, :active)'),
|
||||||
|
{'name': 'Main', 'now': sa.func.now(), 'active': True})
|
||||||
|
buildingid = bind.execute(sa.text(
|
||||||
|
'SELECT buildingid FROM buildings WHERE buildingname = :name'),
|
||||||
|
{'name': 'Main'}).scalar()
|
||||||
|
|
||||||
|
bind.execute(sa.text(
|
||||||
|
'INSERT INTO maplevels (buildingid, levelname, sortorder, '
|
||||||
|
'blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, '
|
||||||
|
'createddate, modifieddate, isactive) VALUES (:building, :name, 0, '
|
||||||
|
':light, :dark, :width, :height, :isdefault, :now, :now, :active)'),
|
||||||
|
{'building': buildingid,
|
||||||
|
'name': 'Ground floor',
|
||||||
|
'light': settings.get('map_blueprint_light') or PLACEHOLDER,
|
||||||
|
'dark': settings.get('map_blueprint_dark') or PLACEHOLDER,
|
||||||
|
'width': _int(settings.get('map_width'), DEFAULT_WIDTH),
|
||||||
|
'height': _int(settings.get('map_height'), DEFAULT_HEIGHT),
|
||||||
|
'isdefault': True, 'now': sa.func.now(), 'active': True})
|
||||||
|
|
||||||
|
levelid = bind.execute(sa.text(
|
||||||
|
'SELECT levelid FROM maplevels WHERE isdefault = :flag'),
|
||||||
|
{'flag': True}).scalar()
|
||||||
|
|
||||||
|
# Every asset that already has a position had it in this one drawing's
|
||||||
|
# coordinate space, so it belongs to this level. An asset with no position
|
||||||
|
# is left null: it needs no level until somebody places it.
|
||||||
|
bind.execute(sa.text(
|
||||||
|
'UPDATE assets SET levelid = :levelid '
|
||||||
|
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
|
||||||
|
{'levelid': levelid})
|
||||||
|
bind.execute(sa.text(
|
||||||
|
'UPDATE locations SET levelid = :levelid '
|
||||||
|
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
|
||||||
|
{'levelid': levelid})
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = sa.inspect(bind)
|
||||||
|
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
|
||||||
|
if 'levelid' in assetcolumns:
|
||||||
|
op.drop_constraint('fk_assets_levelid', 'assets', type_='foreignkey')
|
||||||
|
op.drop_index('idx_assets_levelid', table_name='assets')
|
||||||
|
op.drop_column('assets', 'levelid')
|
||||||
|
if 'mapverifiedat' in assetcolumns:
|
||||||
|
op.drop_column('assets', 'mapverifiedat')
|
||||||
|
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
|
||||||
|
if 'levelid' in locationcolumns:
|
||||||
|
op.drop_constraint('fk_locations_levelid', 'locations', type_='foreignkey')
|
||||||
|
op.drop_index('idx_locations_levelid', table_name='locations')
|
||||||
|
op.drop_column('locations', 'levelid')
|
||||||
|
if _existing(insp, 'mappositionsnapshots'):
|
||||||
|
op.drop_table('mappositionsnapshots')
|
||||||
|
if _existing(insp, 'maplevels'):
|
||||||
|
op.drop_table('maplevels')
|
||||||
|
if _existing(insp, 'buildings'):
|
||||||
|
op.drop_table('buildings')
|
||||||
@@ -560,6 +560,7 @@ def create_computer():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -658,7 +659,7 @@ def update_computer(computer_id: int):
|
|||||||
# Update asset fields
|
# Update asset fields
|
||||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||||
'maintenancereference', 'statusid',
|
'maintenancereference', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
old_val = getattr(asset, key)
|
old_val = getattr(asset, key)
|
||||||
|
|||||||
@@ -261,9 +261,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,8 +274,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -306,6 +319,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -336,6 +351,10 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -356,6 +375,7 @@ const form = ref({
|
|||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
|
levelid: null,
|
||||||
ipaddress: ''
|
ipaddress: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,6 +479,7 @@ onMounted(async () => {
|
|||||||
notes: pc.notes || '',
|
notes: pc.notes || '',
|
||||||
mapx: pc.mapx ?? null,
|
mapx: pc.mapx ?? null,
|
||||||
mapy: pc.mapy ?? null,
|
mapy: pc.mapy ?? null,
|
||||||
|
levelid: pc.levelid ?? null,
|
||||||
ipaddress: primaryComm?.ipaddress || ''
|
ipaddress: primaryComm?.ipaddress || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -474,10 +495,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -485,6 +517,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/computers",
|
"api_prefix": "/api/computers",
|
||||||
"provides": {
|
"provides": {
|
||||||
"asset_type": "computer",
|
"asset_type": "computer",
|
||||||
|
|||||||
@@ -960,6 +960,7 @@ def _asset_facts(hostnames):
|
|||||||
'assetnumber': asset.assetnumber,
|
'assetnumber': asset.assetnumber,
|
||||||
'location': (asset.location.locationname if asset.location else None),
|
'location': (asset.location.locationname if asset.location else None),
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'machinenumber': None, 'machineassetid': None,
|
'machinenumber': None, 'machineassetid': None,
|
||||||
'machinepluginid': None,
|
'machinepluginid': None,
|
||||||
|
|||||||
@@ -21,13 +21,35 @@ from shopdb.api import require_permission, apply_import_timestamps
|
|||||||
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_articles():
|
||||||
|
"""Active articles whose topic is not a retired application.
|
||||||
|
|
||||||
|
An article about a decommissioned application is not something anyone should
|
||||||
|
find by browsing or searching: it describes a thing that is no longer in
|
||||||
|
service, and presenting it alongside live documentation reads as though it
|
||||||
|
were current.
|
||||||
|
|
||||||
|
An article with NO topic still shows. Not every article is about an
|
||||||
|
application, and a null topic is not a retired one.
|
||||||
|
|
||||||
|
Expressed as a subquery rather than a join because the topic sort below joins
|
||||||
|
Application itself, and two joins onto the same table in one query collide.
|
||||||
|
"""
|
||||||
|
retired = db.session.query(Application.appid).filter(
|
||||||
|
Application.isactive.is_(False))
|
||||||
|
return KnowledgeBase.query.filter(
|
||||||
|
KnowledgeBase.isactive.is_(True),
|
||||||
|
db.or_(KnowledgeBase.appid.is_(None),
|
||||||
|
KnowledgeBase.appid.notin_(retired)))
|
||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('', methods=['GET'])
|
@knowledgebase_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def list_articles():
|
def list_articles():
|
||||||
"""List all knowledge base articles."""
|
"""List all knowledge base articles."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
|
|
||||||
query = KnowledgeBase.query.filter_by(isactive=True)
|
query = _visible_articles()
|
||||||
|
|
||||||
# Search: title, keywords, and the topic (its Application's name). The topic
|
# Search: title, keywords, and the topic (its Application's name). The topic
|
||||||
# is matched via an appid subquery instead of a join so it does not collide
|
# is matched via an appid subquery instead of a join so it does not collide
|
||||||
@@ -35,8 +57,13 @@ def list_articles():
|
|||||||
# clause and still match on title/keywords.
|
# clause and still match on title/keywords.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
like = f'%{search}%'
|
like = f'%{search}%'
|
||||||
|
# Active applications only. A retired application is not a topic anyone
|
||||||
|
# should be offered: matching its name surfaced its articles and printed
|
||||||
|
# the retired app as their subject, which reads as though it were still
|
||||||
|
# in service.
|
||||||
topic_appids = db.session.query(Application.appid).filter(
|
topic_appids = db.session.query(Application.appid).filter(
|
||||||
Application.appname.ilike(like))
|
Application.appname.ilike(like),
|
||||||
|
Application.isactive.is_(True))
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
KnowledgeBase.shortdescription.ilike(like),
|
KnowledgeBase.shortdescription.ilike(like),
|
||||||
@@ -100,11 +127,11 @@ def list_articles():
|
|||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get knowledge base statistics."""
|
"""Get knowledge base statistics."""
|
||||||
total_clicks = db.session.query(
|
# Counted over the same set the list shows. A total that includes articles
|
||||||
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
|
# nobody can see is a total nobody can reconcile.
|
||||||
).filter(KnowledgeBase.isactive == True).scalar()
|
visible = _visible_articles()
|
||||||
|
total_clicks = sum(article.clicks or 0 for article in visible)
|
||||||
total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
|
total_articles = visible.count()
|
||||||
|
|
||||||
return success_response({
|
return success_response({
|
||||||
'totalclicks': int(total_clicks),
|
'totalclicks': int(total_clicks),
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ const applications = ref([])
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Load applications for topic dropdown
|
// Load applications for topic dropdown
|
||||||
const appsRes = await applicationsApi.list({ perpage: 1000 })
|
const appsRes = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic:
|
||||||
|
// ishidden governs whether an application shows on the tiles page, which
|
||||||
|
// says nothing about whether it can be the subject of an article.
|
||||||
applications.value = appsRes.data.data || []
|
applications.value = appsRes.data.data || []
|
||||||
|
|
||||||
// Load article if editing
|
// Load article if editing
|
||||||
|
|||||||
@@ -171,7 +171,9 @@ async function loadArticles() {
|
|||||||
|
|
||||||
async function loadTopics() {
|
async function loadTopics() {
|
||||||
try {
|
try {
|
||||||
const response = await applicationsApi.list({ perpage: 1000 })
|
const response = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic:
|
||||||
|
// ishidden governs whether an application shows on the tiles page, which
|
||||||
|
// says nothing about whether it can be the subject of an article.
|
||||||
topics.value = response.data.data || []
|
topics.value = response.data.data || []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading topics:', error)
|
console.error('Error loading topics:', error)
|
||||||
|
|||||||
@@ -371,6 +371,7 @@ def create_machine():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -446,7 +447,7 @@ def update_machine(machine_id: int):
|
|||||||
# Update asset fields
|
# Update asset fields
|
||||||
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
||||||
'maintenancereference', 'serialnumber', 'statusid',
|
'maintenancereference', 'serialnumber', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||||
'notes', 'isactive']
|
'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
|
|||||||
@@ -239,9 +239,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,8 +252,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -349,6 +362,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '@/api'
|
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -371,6 +386,10 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -391,7 +410,8 @@ const form = ref({
|
|||||||
islocationonly: false,
|
islocationonly: false,
|
||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null
|
mapy: null,
|
||||||
|
levelid: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const machineTypes = ref([])
|
const machineTypes = ref([])
|
||||||
@@ -501,7 +521,8 @@ onMounted(async () => {
|
|||||||
islocationonly: data.machine?.islocationonly || false,
|
islocationonly: data.machine?.islocationonly || false,
|
||||||
notes: data.notes || '',
|
notes: data.notes || '',
|
||||||
mapx: data.mapx ?? null,
|
mapx: data.mapx ?? null,
|
||||||
mapy: data.mapy ?? null
|
mapy: data.mapy ?? null,
|
||||||
|
levelid: data.levelid ?? null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing relationships to find controlling PC
|
// Load existing relationships to find controlling PC
|
||||||
@@ -535,10 +556,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -546,6 +578,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
|
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/machines",
|
"api_prefix": "/api/machines",
|
||||||
"provides": {
|
"provides": {
|
||||||
"asset_type": "machine",
|
"asset_type": "machine",
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ def create_tool():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes'),
|
notes=data.get('notes'),
|
||||||
)
|
)
|
||||||
@@ -291,7 +292,7 @@ def create_tool():
|
|||||||
# Asset core fields writable through this plugin's write path.
|
# Asset core fields writable through this plugin's write path.
|
||||||
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
|
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
|
||||||
'maintenancereference', 'serialnumber',
|
'maintenancereference', 'serialnumber',
|
||||||
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy',
|
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||||
'notes', 'isactive')
|
'notes', 'isactive')
|
||||||
# Extension fields with plain assignment (dates handled separately).
|
# Extension fields with plain assignment (dates handled separately).
|
||||||
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
|
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.6.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/measuringtools",
|
"api_prefix": "/api/measuringtools",
|
||||||
"default_enabled": false,
|
"default_enabled": false,
|
||||||
"provides": {
|
"provides": {
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ def create_network_device():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -502,7 +503,7 @@ def update_network_device(device_id: int):
|
|||||||
# Update asset fields
|
# Update asset fields
|
||||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||||
'maintenancereference', 'statusid',
|
'maintenancereference', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
old_val = getattr(asset, key)
|
old_val = getattr(asset, key)
|
||||||
|
|||||||
@@ -234,9 +234,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,8 +248,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -307,6 +320,8 @@ import {
|
|||||||
} from '@/api'
|
} from '@/api'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
@@ -315,6 +330,10 @@ import { apiError } from '@/utils/apiError'
|
|||||||
const { isEnabled } = useIdentifierFlags()
|
const { isEnabled } = useIdentifierFlags()
|
||||||
|
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -348,6 +367,7 @@ const form = ref({
|
|||||||
ismanaged: false,
|
ismanaged: false,
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
|
levelid: null,
|
||||||
notes: ''
|
notes: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,6 +479,7 @@ async function loadDevice() {
|
|||||||
form.value.businessunitid = data.businessunitid || ''
|
form.value.businessunitid = data.businessunitid || ''
|
||||||
form.value.mapx = data.mapx
|
form.value.mapx = data.mapx
|
||||||
form.value.mapy = data.mapy
|
form.value.mapy = data.mapy
|
||||||
|
form.value.levelid = data.levelid
|
||||||
form.value.notes = data.notes || ''
|
form.value.notes = data.notes || ''
|
||||||
// The IP lives in a Communication row, not on the extension table; the API
|
// The IP lives in a Communication row, not on the extension table; the API
|
||||||
// flattens it onto the response as ipaddress.
|
// flattens it onto the response as ipaddress.
|
||||||
@@ -508,6 +529,7 @@ async function submitForm() {
|
|||||||
ismanaged: form.value.ismanaged,
|
ismanaged: form.value.ismanaged,
|
||||||
mapx: form.value.mapx,
|
mapx: form.value.mapx,
|
||||||
mapy: form.value.mapy,
|
mapy: form.value.mapy,
|
||||||
|
levelid: form.value.levelid,
|
||||||
notes: form.value.notes || null
|
notes: form.value.notes || null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,10 +572,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -561,6 +594,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/network",
|
"api_prefix": "/api/network",
|
||||||
"provides": {
|
"provides": {
|
||||||
"asset_type": "network_device",
|
"asset_type": "network_device",
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ def printer_install_list():
|
|||||||
'iscsf': printer.iscsf,
|
'iscsf': printer.iscsf,
|
||||||
'locationname': asset.location.locationname if asset.location else None,
|
'locationname': asset.location.locationname if asset.location else None,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -366,7 +367,7 @@ def printer_install_list():
|
|||||||
# hand-rolled JSON parser. The web map uses the default JSON.
|
# hand-rolled JSON parser. The web map uses the default JSON.
|
||||||
if request.args.get('format') == 'text':
|
if request.args.get('format') == 'text':
|
||||||
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
|
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
|
||||||
'hostname', 'ipaddress', 'mapx', 'mapy')
|
'hostname', 'ipaddress', 'mapx', 'mapy', 'levelid')
|
||||||
lines = [_text_line(row, fields) for row in rows]
|
lines = [_text_line(row, fields) for row in rows]
|
||||||
return Response('\n'.join(lines), mimetype='text/plain')
|
return Response('\n'.join(lines), mimetype='text/plain')
|
||||||
|
|
||||||
@@ -738,6 +739,7 @@ def create_printer():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -819,7 +821,7 @@ def update_printer(printer_id: int):
|
|||||||
# Update asset fields (optional identifiers gated per-type in Settings)
|
# Update asset fields (optional identifiers gated per-type in Settings)
|
||||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||||
'maintenancereference', 'statusid',
|
'maintenancereference', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||||
'notes', 'isactive']
|
'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
@@ -1035,6 +1037,7 @@ def _get_low_supplies_data():
|
|||||||
'model': model_number,
|
'model': model_number,
|
||||||
'location': location_name,
|
'location': location_name,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'supplies': annotated
|
'supplies': annotated
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -250,9 +250,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,8 +263,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -295,6 +308,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '@/api'
|
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -320,6 +335,10 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -337,6 +356,7 @@ const form = ref({
|
|||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
|
levelid: null,
|
||||||
// Printer-specific
|
// Printer-specific
|
||||||
ipaddress: '',
|
ipaddress: '',
|
||||||
csfname: '',
|
csfname: '',
|
||||||
@@ -511,6 +531,7 @@ onMounted(async () => {
|
|||||||
notes: printer.notes || '',
|
notes: printer.notes || '',
|
||||||
mapx: printer.mapx ?? null,
|
mapx: printer.mapx ?? null,
|
||||||
mapy: printer.mapy ?? null,
|
mapy: printer.mapy ?? null,
|
||||||
|
levelid: printer.levelid ?? null,
|
||||||
// Printer-specific
|
// Printer-specific
|
||||||
ipaddress: primaryComm?.ipaddress || '',
|
ipaddress: primaryComm?.ipaddress || '',
|
||||||
csfname: ext.sharename || '',
|
csfname: ext.sharename || '',
|
||||||
@@ -534,10 +555,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -545,6 +577,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '@/composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, state as mapConfig } from '@/composables/mapConfig'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -55,8 +55,10 @@ const selected = ref({}) // printerid -> true
|
|||||||
let map = null
|
let map = null
|
||||||
let imageOverlay = null
|
let imageOverlay = null
|
||||||
let markers = {} // printerid -> circleMarker
|
let markers = {} // printerid -> circleMarker
|
||||||
let MAP_WIDTH = mapConfig.width
|
// The level being shown. The installer map runs before anyone logs in, which is
|
||||||
let MAP_HEIGHT = mapConfig.height
|
// why /api/maplevels is public - without it there is no blueprint to draw.
|
||||||
|
let MAP_WIDTH = 0
|
||||||
|
let MAP_HEIGHT = 0
|
||||||
|
|
||||||
const SELECTED_COLOR = '#e53935'
|
const SELECTED_COLOR = '#e53935'
|
||||||
const NORMAL_COLOR = '#4CAF50'
|
const NORMAL_COLOR = '#4CAF50'
|
||||||
@@ -120,13 +122,13 @@ function renderMarkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(currentTheme, (theme) => {
|
watch(currentTheme, (theme) => {
|
||||||
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme))
|
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme, mapConfig.currentlevelid))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
MAP_WIDTH = mapConfig.width
|
MAP_WIDTH = dimensionsFor(mapConfig.currentlevelid).width
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_HEIGHT = dimensionsFor(mapConfig.currentlevelid).height
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await printersApi.installList()
|
const response = await printersApi.installList()
|
||||||
@@ -142,7 +144,7 @@ onMounted(async () => {
|
|||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
})
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
imageOverlay = L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
imageOverlay = L.imageOverlay(blueprintUrlFor(currentTheme.value, mapConfig.currentlevelid), bounds).addTo(map)
|
||||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], -2)
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], -2)
|
||||||
map.setMaxBounds(bounds)
|
map.setMaxBounds(bounds)
|
||||||
renderMarkers()
|
renderMarkers()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.16.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/printers",
|
"api_prefix": "/api/printers",
|
||||||
"provides": {
|
"provides": {
|
||||||
"machine_category": "Printer",
|
"machine_category": "Printer",
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ def _related_machine(asset):
|
|||||||
# worth showing, so the caller decides what to do with a missing
|
# worth showing, so the caller decides what to do with a missing
|
||||||
# position rather than the row being dropped.
|
# position rather than the row being dropped.
|
||||||
'mapx': candidate.mapx,
|
'mapx': candidate.mapx,
|
||||||
|
'levelid': candidate.levelid,
|
||||||
'mapy': candidate.mapy,
|
'mapy': candidate.mapy,
|
||||||
'locationid': candidate.locationid,
|
'locationid': candidate.locationid,
|
||||||
'locationname': (candidate.location.locationname
|
'locationname': (candidate.location.locationname
|
||||||
|
|||||||
@@ -158,6 +158,32 @@ if [ -n "$CODE_LIB_IMPORTS" ]; then
|
|||||||
VIOLATIONS=$((VIOLATIONS + 1))
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ADR-017: a marker position is pixels in ONE LEVEL's coordinate space, so a
|
||||||
|
# payload that emits mapx/mapy without levelid gives the consumer coordinates and
|
||||||
|
# no drawing to put them on. The consumer then either renders nothing or - worse,
|
||||||
|
# and what a reasonable implementation does - falls back to the default level,
|
||||||
|
# drawing one building's ground floor behind a marker positioned for another
|
||||||
|
# building's mezzanine. That renders perfectly and points at the wrong place.
|
||||||
|
#
|
||||||
|
# Eight files emit positions across twenty sites. This is the check that says
|
||||||
|
# whether all of them travel with their level, because reading them by eye is
|
||||||
|
# how the twentieth gets missed.
|
||||||
|
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
|
||||||
|
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \
|
||||||
|
| grep -v '/tests\?/' || true)
|
||||||
|
MISSING_LEVEL=""
|
||||||
|
for candidate in $POSITION_FILES; do
|
||||||
|
if ! grep -q "'levelid'" "$candidate"; then
|
||||||
|
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$MISSING_LEVEL" ]; then
|
||||||
|
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level"
|
||||||
|
echo " cannot be rendered on the right drawing:"
|
||||||
|
echo "$MISSING_LEVEL" | sed 's/^/ /'
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
||||||
# count then did not move for weeks - a rule that only prints is read as no rule.
|
# count then did not move for weeks - a rule that only prints is read as no rule.
|
||||||
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ from .plugins import plugin_manager
|
|||||||
# permission / empty / position. BREAKING for any plugin still using the old
|
# permission / empty / position. BREAKING for any plugin still using the old
|
||||||
# shape, which is why it is recorded here: the change itself shipped earlier
|
# shape, which is why it is recorded here: the change itself shipped earlier
|
||||||
# without a bump, and a contract that changes silently is not a contract.
|
# without a bump, and a contract that changes silently is not a contract.
|
||||||
__contract_version__ = '0.19.0'
|
__contract_version__ = '0.20.0'
|
||||||
|
|
||||||
# Product release version (see ADR-007). The product version and the
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
@@ -190,6 +190,8 @@ CORE_BLUEPRINT_NAMES = (
|
|||||||
'models',
|
'models',
|
||||||
'businessunits',
|
'businessunits',
|
||||||
'locations',
|
'locations',
|
||||||
|
'maplevels',
|
||||||
|
'mappositions',
|
||||||
'operatingsystems',
|
'operatingsystems',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'dashboarddefaults',
|
'dashboarddefaults',
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ def _walk_related_for_position(asset, visited, depth):
|
|||||||
n_mapx = getattr(neighbor, 'mapx', None)
|
n_mapx = getattr(neighbor, 'mapx', None)
|
||||||
n_mapy = getattr(neighbor, 'mapy', None)
|
n_mapy = getattr(neighbor, 'mapy', None)
|
||||||
if n_mapx is not None and n_mapy is not None:
|
if n_mapx is not None and n_mapy is not None:
|
||||||
return (n_mapx, n_mapy)
|
# The neighbour's LEVEL travels with its coordinates. Returning the
|
||||||
|
# pair alone would leave the caller drawing a machine's position on
|
||||||
|
# whatever level the PC that borrowed it happens to claim.
|
||||||
|
return (n_mapx, n_mapy, getattr(neighbor, 'levelid', None))
|
||||||
|
|
||||||
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
|
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
|
||||||
if recursed is not None:
|
if recursed is not None:
|
||||||
@@ -214,18 +217,28 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
|
|||||||
3. Asset's location coords (asset.location.mapx, .mapy)
|
3. Asset's location coords (asset.location.mapx, .mapy)
|
||||||
4. None (asset is unplaced, rendered in a tray)
|
4. None (asset is unplaced, rendered in a tray)
|
||||||
|
|
||||||
Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
|
Returns a dict {'mapx', 'mapy', 'levelid', 'positionsource'} where
|
||||||
is one of 'self', 'related', 'location'. Returns None when no priority
|
positionsource is one of 'self', 'related', 'location'. Returns None when no
|
||||||
yields coordinates.
|
priority yields coordinates.
|
||||||
|
|
||||||
|
LEVELID COMES FROM WHICHEVER SOURCE SUPPLIED THE COORDINATES, not from the
|
||||||
|
asset (ADR-017). A PC with no position of its own that inherits from the
|
||||||
|
machine it controls is at the MACHINE's coordinates on the MACHINE's level;
|
||||||
|
using the PC's own level - which may be null, or may be a different building
|
||||||
|
entirely - would draw those coordinates on the wrong drawing, and the result
|
||||||
|
looks entirely reasonable.
|
||||||
"""
|
"""
|
||||||
mapx = getattr(asset, 'mapx', None)
|
mapx = getattr(asset, 'mapx', None)
|
||||||
mapy = getattr(asset, 'mapy', None)
|
mapy = getattr(asset, 'mapy', None)
|
||||||
if mapx is not None and mapy is not None:
|
if mapx is not None and mapy is not None:
|
||||||
return {'mapx': mapx, 'mapy': mapy, 'positionsource': 'self'}
|
return {'mapx': mapx, 'mapy': mapy,
|
||||||
|
'levelid': getattr(asset, 'levelid', None),
|
||||||
|
'positionsource': 'self'}
|
||||||
|
|
||||||
related = _walk_related_for_position(asset, set(), 0)
|
related = _walk_related_for_position(asset, set(), 0)
|
||||||
if related is not None:
|
if related is not None:
|
||||||
return {'mapx': related[0], 'mapy': related[1], 'positionsource': 'related'}
|
return {'mapx': related[0], 'mapy': related[1], 'levelid': related[2],
|
||||||
|
'positionsource': 'related'}
|
||||||
|
|
||||||
location = getattr(asset, 'location', None)
|
location = getattr(asset, 'location', None)
|
||||||
if location is not None:
|
if location is not None:
|
||||||
@@ -235,6 +248,7 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
|
|||||||
return {
|
return {
|
||||||
'mapx': loc_mapx,
|
'mapx': loc_mapx,
|
||||||
'mapy': loc_mapy,
|
'mapy': loc_mapy,
|
||||||
|
'levelid': getattr(location, 'levelid', None),
|
||||||
'positionsource': 'location',
|
'positionsource': 'location',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .vendors import vendors_bp
|
|||||||
from .models import models_bp
|
from .models import models_bp
|
||||||
from .businessunits import businessunits_bp
|
from .businessunits import businessunits_bp
|
||||||
from .locations import locations_bp
|
from .locations import locations_bp
|
||||||
|
from .maplevels import maplevels_bp
|
||||||
|
from .mappositions import mappositions_bp
|
||||||
from .operatingsystems import operatingsystems_bp
|
from .operatingsystems import operatingsystems_bp
|
||||||
from .dashboard import dashboard_bp
|
from .dashboard import dashboard_bp
|
||||||
from .dashboarddefaults import dashboarddefaults_bp
|
from .dashboarddefaults import dashboarddefaults_bp
|
||||||
@@ -34,6 +36,8 @@ __all__ = [
|
|||||||
'models_bp',
|
'models_bp',
|
||||||
'businessunits_bp',
|
'businessunits_bp',
|
||||||
'locations_bp',
|
'locations_bp',
|
||||||
|
'maplevels_bp',
|
||||||
|
'mappositions_bp',
|
||||||
'operatingsystems_bp',
|
'operatingsystems_bp',
|
||||||
'dashboard_bp',
|
'dashboard_bp',
|
||||||
'dashboarddefaults_bp',
|
'dashboarddefaults_bp',
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ def create_asset():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -517,7 +518,7 @@ def update_asset(asset_id: int):
|
|||||||
# Update allowed fields
|
# Update allowed fields
|
||||||
allowed_fields = [
|
allowed_fields = [
|
||||||
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive'
|
||||||
]
|
]
|
||||||
|
|
||||||
for key in allowed_fields:
|
for key in allowed_fields:
|
||||||
@@ -1114,6 +1115,7 @@ def get_assets_map():
|
|||||||
'displayname': asset.display_name,
|
'displayname': asset.display_name,
|
||||||
'serialnumber': asset.serialnumber,
|
'serialnumber': asset.serialnumber,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'assettype': asset.assettype.assettype if asset.assettype else None,
|
'assettype': asset.assettype.assettype if asset.assettype else None,
|
||||||
'assettypeid': asset.assettypeid,
|
'assettypeid': asset.assettypeid,
|
||||||
|
|||||||
350
shopdb/core/api/maplevels.py
Normal file
350
shopdb/core/api/maplevels.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""Buildings and levels: the drawings a marker can be placed on (ADR-017).
|
||||||
|
|
||||||
|
Reads are UNAUTHENTICATED. The printer installer map runs before anyone logs in
|
||||||
|
and needs a blueprint to draw, exactly as the printer install-list and the slide
|
||||||
|
feed already do. A level name, a blueprint path and a pixel size are not
|
||||||
|
secrets - the marker positions drawn on them already render on kiosk pages.
|
||||||
|
|
||||||
|
Writes require admin: adding a level changes where every marker on it appears.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, request, current_app
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import Building, MapLevel, Asset, AuditLog
|
||||||
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.api import send_upload
|
||||||
|
from shopdb.utils.imagesize import image_size
|
||||||
|
|
||||||
|
maplevels_bp = Blueprint('maplevels', __name__)
|
||||||
|
|
||||||
|
# Same set the map blueprint upload already accepts. SVG stays allowed because a
|
||||||
|
# floor plan is vector by nature; it is served through send_upload, which sends
|
||||||
|
# the sandbox headers that stop one executing as script.
|
||||||
|
BLUEPRINT_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||||
|
|
||||||
|
|
||||||
|
def _blueprint_dir():
|
||||||
|
return os.path.join(current_app.instance_path, 'maps')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Read - public
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@maplevels_bp.route('', methods=['GET'])
|
||||||
|
def list_levels():
|
||||||
|
"""Every active level, grouped by building, in display order.
|
||||||
|
|
||||||
|
One call, because every consumer needs the whole list: the map switches
|
||||||
|
between levels, and a hover preview has to resolve an arbitrary asset's
|
||||||
|
level to a blueprint without knowing in advance which one it is.
|
||||||
|
"""
|
||||||
|
buildings = (Building.query.filter_by(isactive=True)
|
||||||
|
.order_by(Building.sortorder, Building.buildingid).all())
|
||||||
|
|
||||||
|
# How many markers sit on each level, in one grouped query rather than one
|
||||||
|
# per level. The admin page needs it to say what a rename or a resize is
|
||||||
|
# about to affect, and it is what makes the delete refusal predictable
|
||||||
|
# instead of a surprise.
|
||||||
|
counts = dict(db.session.query(Asset.levelid, db.func.count(Asset.assetid))
|
||||||
|
.filter(Asset.levelid.isnot(None), Asset.isactive.is_(True))
|
||||||
|
.group_by(Asset.levelid).all())
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for building in buildings:
|
||||||
|
entry = building.to_dict()
|
||||||
|
for level in entry.get('levels', []):
|
||||||
|
level['assetcount'] = counts.get(level['levelid'], 0)
|
||||||
|
payload.append(entry)
|
||||||
|
|
||||||
|
default = MapLevel.default_level()
|
||||||
|
return success_response({
|
||||||
|
'buildings': payload,
|
||||||
|
'defaultlevelid': default.levelid if default else None,
|
||||||
|
'totalplaced': sum(counts.values()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||||
|
def get_level(levelid):
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level or not level.isactive:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
return success_response(level.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||||
|
def serve_blueprint(filename, levelid=None):
|
||||||
|
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||||
|
return send_upload(_blueprint_dir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Write - admin
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@maplevels_bp.route('/buildings', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def create_building():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = (data.get('buildingname') or '').strip()
|
||||||
|
if not name:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'buildingname is required')
|
||||||
|
if Building.query.filter(db.func.lower(Building.buildingname)
|
||||||
|
== name.lower()).first():
|
||||||
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
|
f"Building '{name}' already exists",
|
||||||
|
http_code=409)
|
||||||
|
building = Building(buildingname=name,
|
||||||
|
sortorder=int(data.get('sortorder') or 0))
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('created', 'Building', entityid=building.buildingid,
|
||||||
|
entityname=name)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(building.to_dict(), message='Building created',
|
||||||
|
http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/buildings/<int:buildingid>', methods=['PUT', 'PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def update_building(buildingid):
|
||||||
|
"""Rename or reorder a building. Levels move with it; nothing repositions."""
|
||||||
|
building = db.session.get(Building, buildingid)
|
||||||
|
if not building:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||||
|
http_code=404)
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if 'buildingname' in data:
|
||||||
|
name = (data['buildingname'] or '').strip()
|
||||||
|
if not name:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'buildingname cannot be blank')
|
||||||
|
clash = Building.query.filter(
|
||||||
|
db.func.lower(Building.buildingname) == name.lower(),
|
||||||
|
Building.buildingid != buildingid).first()
|
||||||
|
if clash:
|
||||||
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
|
f"Building '{name}' already exists",
|
||||||
|
http_code=409)
|
||||||
|
building.buildingname = name
|
||||||
|
if data.get('sortorder') is not None:
|
||||||
|
building.sortorder = int(data['sortorder'])
|
||||||
|
if 'isactive' in data:
|
||||||
|
building.isactive = bool(data['isactive'])
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('updated', 'Building', entityid=buildingid,
|
||||||
|
entityname=building.buildingname)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(building.to_dict(), message='Building updated')
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def create_level():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = (data.get('levelname') or '').strip()
|
||||||
|
buildingid = data.get('buildingid')
|
||||||
|
if not name or not buildingid:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'buildingname and buildingid are required')
|
||||||
|
if not db.session.get(Building, buildingid):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
level = MapLevel(
|
||||||
|
buildingid=buildingid,
|
||||||
|
levelname=name,
|
||||||
|
sortorder=int(data.get('sortorder') or 0),
|
||||||
|
blueprintlight=data.get('blueprintlight'),
|
||||||
|
blueprintdark=data.get('blueprintdark'),
|
||||||
|
mapwidth=int(data.get('mapwidth') or 3300),
|
||||||
|
mapheight=int(data.get('mapheight') or 2550),
|
||||||
|
)
|
||||||
|
db.session.add(level)
|
||||||
|
db.session.flush()
|
||||||
|
_apply_default(level, data.get('isdefault'))
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('created', 'MapLevel', entityid=level.levelid,
|
||||||
|
entityname=name)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(level.to_dict(), message='Level created',
|
||||||
|
http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>', methods=['PUT', 'PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def update_level(levelid):
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
# The dimensions are the coordinate space every marker on this level is
|
||||||
|
# expressed in, so changing them moves every marker relative to the image.
|
||||||
|
# Report it rather than doing it silently; the caller decides whether to run
|
||||||
|
# a transform (POST /api/assets/map/transform).
|
||||||
|
warnings = []
|
||||||
|
for field in ('mapwidth', 'mapheight'):
|
||||||
|
if field in data and int(data[field] or 0) != getattr(level, field):
|
||||||
|
warnings.append(
|
||||||
|
'%s changed from %s to %s; existing positions on this level are '
|
||||||
|
'still in the old coordinate space' % (field,
|
||||||
|
getattr(level, field),
|
||||||
|
data[field]))
|
||||||
|
|
||||||
|
for field in ('levelname', 'blueprintlight', 'blueprintdark'):
|
||||||
|
if field in data:
|
||||||
|
setattr(level, field, data[field])
|
||||||
|
for field in ('sortorder', 'mapwidth', 'mapheight'):
|
||||||
|
if field in data and data[field] is not None:
|
||||||
|
setattr(level, field, int(data[field]))
|
||||||
|
if 'isactive' in data:
|
||||||
|
level.isactive = bool(data['isactive'])
|
||||||
|
_apply_default(level, data.get('isdefault'))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('updated', 'MapLevel', entityid=level.levelid,
|
||||||
|
entityname=level.levelname)
|
||||||
|
db.session.commit()
|
||||||
|
payload = level.to_dict()
|
||||||
|
if warnings:
|
||||||
|
payload['warnings'] = warnings
|
||||||
|
return success_response(payload, message='Level updated')
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def delete_level(levelid):
|
||||||
|
"""Deactivate a level, refusing while assets are still placed on it.
|
||||||
|
|
||||||
|
Deleting the drawing out from under a marker would leave a position in a
|
||||||
|
coordinate space that no longer exists - unrenderable, and indistinguishable
|
||||||
|
from a marker that was never placed.
|
||||||
|
"""
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||||
|
if placed:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
f'{placed} asset(s) are placed on this level. Move them to another '
|
||||||
|
f'level first.', http_code=409)
|
||||||
|
if level.isdefault:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'This is the default level. Make another level the default first.')
|
||||||
|
level.isactive = False
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('deleted', 'MapLevel', entityid=levelid,
|
||||||
|
entityname=level.levelname)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(message='Level deactivated')
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>/blueprint', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def upload_blueprint(levelid):
|
||||||
|
"""Upload this level's blueprint for one theme.
|
||||||
|
|
||||||
|
multipart/form-data: file=<image>, theme=light|dark. The native pixel size
|
||||||
|
is NOT inferred from the image - it is stated on the level, because that is
|
||||||
|
what existing coordinates mean and guessing it would move every marker.
|
||||||
|
"""
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
theme = (request.form.get('theme') or '').strip().lower()
|
||||||
|
if theme not in ('light', 'dark'):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'theme must be light or dark')
|
||||||
|
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 BLUEPRINT_EXTENSIONS:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
f'Unsupported image type {ext}')
|
||||||
|
|
||||||
|
os.makedirs(_blueprint_dir(), exist_ok=True)
|
||||||
|
filename = secure_filename(f'level-{levelid}-{theme}{ext}')
|
||||||
|
raw = upload.read()
|
||||||
|
upload.seek(0)
|
||||||
|
upload.save(os.path.join(_blueprint_dir(), filename))
|
||||||
|
url = f'/api/maplevels/{levelid}/blueprint/{filename}'
|
||||||
|
setattr(level, f'blueprint{theme}', url)
|
||||||
|
|
||||||
|
# The image's real pixel size, read from its header. What happens next
|
||||||
|
# depends entirely on whether anything is already placed on this level.
|
||||||
|
detectedwidth, detectedheight = image_size(raw)
|
||||||
|
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||||
|
sizenote = None
|
||||||
|
if detectedwidth and detectedheight:
|
||||||
|
matches = (detectedwidth == level.mapwidth
|
||||||
|
and detectedheight == level.mapheight)
|
||||||
|
if matches:
|
||||||
|
sizenote = None
|
||||||
|
elif not placed:
|
||||||
|
# Nothing is placed here yet, so no coordinate can be invalidated:
|
||||||
|
# adopt the image's own size, which is almost certainly what the
|
||||||
|
# operator wanted and saves them typing it.
|
||||||
|
level.mapwidth = detectedwidth
|
||||||
|
level.mapheight = detectedheight
|
||||||
|
sizenote = ('dimensions set from the image: %d x %d'
|
||||||
|
% (detectedwidth, detectedheight))
|
||||||
|
else:
|
||||||
|
# Markers exist in the OLD coordinate space. Silently adopting the
|
||||||
|
# new size would move every one of them relative to the drawing
|
||||||
|
# while looking like a successful upload, so this reports and
|
||||||
|
# changes nothing. Resizing is a transform, not an upload.
|
||||||
|
sizenote = (
|
||||||
|
'this image is %d x %d but the level is set to %d x %d, and %d '
|
||||||
|
'marker(s) are placed in the current space. The dimensions were '
|
||||||
|
'NOT changed: set them with a landmark transform '
|
||||||
|
'(POST /api/mappositions/transform) so the markers move with '
|
||||||
|
'them.' % (detectedwidth, detectedheight, level.mapwidth,
|
||||||
|
level.mapheight, placed))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('updated', 'MapLevel', entityid=levelid,
|
||||||
|
entityname=level.levelname,
|
||||||
|
changes={f'blueprint{theme}': {'new': url}})
|
||||||
|
db.session.commit()
|
||||||
|
payload = {'levelid': levelid, f'blueprint{theme}': url,
|
||||||
|
'mapwidth': level.mapwidth, 'mapheight': level.mapheight,
|
||||||
|
'detectedwidth': detectedwidth, 'detectedheight': detectedheight,
|
||||||
|
'placedassets': placed}
|
||||||
|
if sizenote:
|
||||||
|
payload['sizenote'] = sizenote
|
||||||
|
return success_response(payload, message='Blueprint uploaded')
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_default(level, requested):
|
||||||
|
"""Make this level the default, clearing the flag elsewhere.
|
||||||
|
|
||||||
|
Exactly-one-default is a rule across rows, which no column constraint can
|
||||||
|
express, so it is enforced here - the one place that sets the flag.
|
||||||
|
"""
|
||||||
|
if not requested:
|
||||||
|
return
|
||||||
|
MapLevel.query.filter(MapLevel.levelid != level.levelid).update(
|
||||||
|
{'isdefault': False})
|
||||||
|
level.isdefault = True
|
||||||
403
shopdb/core/api/mappositions.py
Normal file
403
shopdb/core/api/mappositions.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"""Bulk marker positions: transform, place, verify, undo (ADR-017).
|
||||||
|
|
||||||
|
A new blueprint invalidates every marker on a level at once, so the operations
|
||||||
|
here are deliberately bulk. Three rules shape all of them:
|
||||||
|
|
||||||
|
**A transform is derived from landmarks, never from image sizes.** The real case
|
||||||
|
that motivated this is 3300x2550 to 3308x4000, where the second level was added
|
||||||
|
below the first: the correct transform is identity scale with a Y offset, and a
|
||||||
|
scale derived from the dimension ratio would stretch every Y by 1.57 and be wrong
|
||||||
|
everywhere. Dimensions describe the canvas; landmarks describe the drawing.
|
||||||
|
|
||||||
|
**A transform is a guess, so it clears the review state.** The levels were
|
||||||
|
redrawn and machines moved. Nothing in a coordinate says whether the machine it
|
||||||
|
points at is still there, so every transformed marker is unreviewed until a human
|
||||||
|
confirms it.
|
||||||
|
|
||||||
|
**Nothing bulk happens without a snapshot.** Positions have no history, and an
|
||||||
|
operation that rewrites hundreds of rows needs a way back that is not a database
|
||||||
|
restore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import (Asset, AuditLog, MapLevel,
|
||||||
|
MapPositionSnapshot, User)
|
||||||
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
|
mappositions_bp = Blueprint('mappositions', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor():
|
||||||
|
try:
|
||||||
|
user = db.session.get(User, int(get_jwt_identity()))
|
||||||
|
return user.username if user else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# The transform
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def solve_axis(pairs):
|
||||||
|
"""Least-squares scale and offset for one axis: new = scale * old + offset.
|
||||||
|
|
||||||
|
Two pairs solve it exactly; more are averaged, which is worth having because
|
||||||
|
a landmark picked by eye on a scanned drawing carries a few pixels of error
|
||||||
|
and three points let that cancel instead of accumulate.
|
||||||
|
|
||||||
|
Returns None when the landmarks cannot determine the axis - every old value
|
||||||
|
identical, so the denominator is zero. That is a real user error (two
|
||||||
|
landmarks on the same column) and it must be reported rather than papered
|
||||||
|
over with a scale of 1, which would look like it worked.
|
||||||
|
"""
|
||||||
|
count = len(pairs)
|
||||||
|
if count < 2:
|
||||||
|
return None
|
||||||
|
sumold = sum(old for old, _ in pairs)
|
||||||
|
sumnew = sum(new for _, new in pairs)
|
||||||
|
sumoldsq = sum(old * old for old, _ in pairs)
|
||||||
|
sumcross = sum(old * new for old, new in pairs)
|
||||||
|
denominator = count * sumoldsq - sumold * sumold
|
||||||
|
if abs(denominator) < 1e-9:
|
||||||
|
return None
|
||||||
|
scale = (count * sumcross - sumold * sumnew) / denominator
|
||||||
|
offset = (sumnew - scale * sumold) / count
|
||||||
|
return scale, offset
|
||||||
|
|
||||||
|
|
||||||
|
def derive_transform(landmarks):
|
||||||
|
"""Per-axis transform from [{'fromx','fromy','tox','toy'}, ...].
|
||||||
|
|
||||||
|
Per-axis rather than uniform on purpose. A level added below another changes
|
||||||
|
the canvas height without rescaling anything, so Y gets an offset and X gets
|
||||||
|
neither; forcing one scale onto both axes cannot express that.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
xpairs = [(float(mark['fromx']), float(mark['tox'])) for mark in landmarks]
|
||||||
|
ypairs = [(float(mark['fromy']), float(mark['toy'])) for mark in landmarks]
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None, 'each landmark needs numeric fromx, fromy, tox and toy'
|
||||||
|
|
||||||
|
if len(landmarks) < 2:
|
||||||
|
return None, 'at least two landmarks are required'
|
||||||
|
|
||||||
|
xsolution = solve_axis(xpairs)
|
||||||
|
ysolution = solve_axis(ypairs)
|
||||||
|
if xsolution is None:
|
||||||
|
return None, ('the landmarks do not vary in X, so no horizontal scale '
|
||||||
|
'can be derived - pick points that differ across the drawing')
|
||||||
|
if ysolution is None:
|
||||||
|
return None, ('the landmarks do not vary in Y, so no vertical scale '
|
||||||
|
'can be derived - pick points that differ down the drawing')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scalex': xsolution[0], 'offsetx': xsolution[1],
|
||||||
|
'scaley': ysolution[0], 'offsety': ysolution[1],
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transform(asset, transform):
|
||||||
|
return (
|
||||||
|
int(round(asset.mapx * transform['scalex'] + transform['offsetx'])),
|
||||||
|
int(round(asset.mapy * transform['scaley'] + transform['offsety'])),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mappositions_bp.route('/transform', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def transform_positions():
|
||||||
|
"""Move every placed marker on a level by a transform read off landmarks.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
levelid the level whose markers are being moved (required)
|
||||||
|
landmarks [{fromx, fromy, tox, toy}, ...] - two or more points, each the
|
||||||
|
same physical feature on the old drawing and the new one
|
||||||
|
tolevelid optional: write the results to a different level, for splitting
|
||||||
|
one drawing into two
|
||||||
|
assetids optional: restrict to these assets
|
||||||
|
dryrun default TRUE. Nothing is written unless this is explicitly false.
|
||||||
|
|
||||||
|
Dry run returns every marker's old and new position and whether it lands
|
||||||
|
outside the target level, which is the only way to see a bad landmark pair
|
||||||
|
before it has moved 300 markers.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
levelid = data.get('levelid')
|
||||||
|
level = db.session.get(MapLevel, levelid) if levelid else None
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'levelid is required and must exist')
|
||||||
|
|
||||||
|
target = level
|
||||||
|
if data.get('tolevelid'):
|
||||||
|
target = db.session.get(MapLevel, data['tolevelid'])
|
||||||
|
if not target:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such tolevelid',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
transform, problem = derive_transform(data.get('landmarks') or [])
|
||||||
|
if problem:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
||||||
|
|
||||||
|
query = Asset.query.filter(
|
||||||
|
Asset.levelid == level.levelid,
|
||||||
|
Asset.mapx.isnot(None), Asset.mapy.isnot(None),
|
||||||
|
Asset.isactive.is_(True))
|
||||||
|
if data.get('assetids'):
|
||||||
|
query = query.filter(Asset.assetid.in_(data['assetids']))
|
||||||
|
assets = query.order_by(Asset.assetid).all()
|
||||||
|
|
||||||
|
moves = []
|
||||||
|
outofbounds = 0
|
||||||
|
for asset in assets:
|
||||||
|
newx, newy = apply_transform(asset, transform)
|
||||||
|
outside = not (0 <= newx <= target.mapwidth and
|
||||||
|
0 <= newy <= target.mapheight)
|
||||||
|
if outside:
|
||||||
|
outofbounds += 1
|
||||||
|
moves.append({
|
||||||
|
'assetid': asset.assetid,
|
||||||
|
'assetnumber': asset.assetnumber,
|
||||||
|
'name': asset.name,
|
||||||
|
'fromx': asset.mapx, 'fromy': asset.mapy,
|
||||||
|
'tox': newx, 'toy': newy,
|
||||||
|
'outofbounds': outside,
|
||||||
|
})
|
||||||
|
|
||||||
|
# The derived transform is reported back whichever mode this is, because it
|
||||||
|
# is the number a human can sanity-check: a Y scale of 1.57 on a level that
|
||||||
|
# only grew taller is the mistake this endpoint exists to avoid, and it is
|
||||||
|
# obvious in the response and invisible in the result.
|
||||||
|
payload = {
|
||||||
|
'transform': transform,
|
||||||
|
'levelid': level.levelid,
|
||||||
|
'tolevelid': target.levelid,
|
||||||
|
'assetcount': len(moves),
|
||||||
|
'outofboundscount': outofbounds,
|
||||||
|
'moves': moves,
|
||||||
|
'dryrun': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get('dryrun', True):
|
||||||
|
return success_response(payload)
|
||||||
|
|
||||||
|
snapshot = MapPositionSnapshot.capture(
|
||||||
|
assets,
|
||||||
|
reason='transform of %s (%d markers)' % (level.levelname, len(moves)),
|
||||||
|
levelid=level.levelid, createdby=_actor())
|
||||||
|
|
||||||
|
for asset, move in zip(assets, moves):
|
||||||
|
asset.mapx = move['tox']
|
||||||
|
asset.mapy = move['toy']
|
||||||
|
asset.levelid = target.levelid
|
||||||
|
# Cleared, not preserved: a transformed position is a guess, and the
|
||||||
|
# whole point of the review pass is to tell guesses from confirmations.
|
||||||
|
asset.mapverifiedat = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Asset', entityname='%d marker(s) transformed'
|
||||||
|
% len(moves),
|
||||||
|
changes={'transform': transform,
|
||||||
|
'snapshotid': snapshot.snapshotid})
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
payload['dryrun'] = False
|
||||||
|
payload['snapshotid'] = snapshot.snapshotid
|
||||||
|
return success_response(payload, message='%d marker(s) moved; snapshot %d '
|
||||||
|
'can restore them' % (len(moves),
|
||||||
|
snapshot.snapshotid))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Bulk place and verify
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@mappositions_bp.route('/positions', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def set_positions():
|
||||||
|
"""Set positions for many assets at once.
|
||||||
|
|
||||||
|
Body: {positions: [{assetid, mapx, mapy, levelid}], verified: bool}
|
||||||
|
|
||||||
|
`levelid` is required per position rather than taken from a single body-level
|
||||||
|
value, because a bulk save from the editor can legitimately span levels, and
|
||||||
|
inferring it would be the guess this whole feature exists to remove.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
rows = data.get('positions') or []
|
||||||
|
if not rows:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'positions is required and must not be empty')
|
||||||
|
|
||||||
|
wanted = {}
|
||||||
|
for row in rows:
|
||||||
|
assetid = row.get('assetid')
|
||||||
|
levelid = row.get('levelid')
|
||||||
|
if not assetid or not levelid:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'every position needs an assetid and a levelid; a position '
|
||||||
|
'without a level cannot be rendered')
|
||||||
|
if row.get('mapx') is None or row.get('mapy') is None:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'every position needs mapx and mapy')
|
||||||
|
wanted[int(assetid)] = row
|
||||||
|
|
||||||
|
levelids = {int(row['levelid']) for row in wanted.values()}
|
||||||
|
known = {level.levelid for level in
|
||||||
|
MapLevel.query.filter(MapLevel.levelid.in_(levelids)).all()}
|
||||||
|
missing = levelids - known
|
||||||
|
if missing:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'unknown levelid(s): %s'
|
||||||
|
% ', '.join(str(one) for one in sorted(missing)),
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
assets = Asset.query.filter(Asset.assetid.in_(wanted)).all()
|
||||||
|
found = {asset.assetid for asset in assets}
|
||||||
|
unknown = set(wanted) - found
|
||||||
|
if unknown:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'unknown assetid(s): %s'
|
||||||
|
% ', '.join(str(one) for one in sorted(unknown)),
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
snapshot = MapPositionSnapshot.capture(
|
||||||
|
assets, reason='bulk position set (%d markers)' % len(assets),
|
||||||
|
createdby=_actor())
|
||||||
|
|
||||||
|
verified = _now() if data.get('verified') else None
|
||||||
|
for asset in assets:
|
||||||
|
row = wanted[asset.assetid]
|
||||||
|
asset.mapx = int(row['mapx'])
|
||||||
|
asset.mapy = int(row['mapy'])
|
||||||
|
asset.levelid = int(row['levelid'])
|
||||||
|
# Placing a marker by hand IS the confirmation, so this stamps it. A
|
||||||
|
# caller that is only nudging things about can pass verified=false.
|
||||||
|
if verified or data.get('verified') is not False:
|
||||||
|
asset.mapverifiedat = verified or _now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Asset',
|
||||||
|
entityname='%d marker position(s) set' % len(assets),
|
||||||
|
changes={'snapshotid': snapshot.snapshotid})
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'updated': len(assets),
|
||||||
|
'snapshotid': snapshot.snapshotid},
|
||||||
|
message='%d position(s) saved' % len(assets))
|
||||||
|
|
||||||
|
|
||||||
|
@mappositions_bp.route('/verify', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def verify_positions():
|
||||||
|
"""Mark markers as reviewed against the current drawing without moving them.
|
||||||
|
|
||||||
|
This is the common case in a review pass: the transform put it in the right
|
||||||
|
place and a human agrees. No snapshot, because nothing about the position
|
||||||
|
changes - only the statement that somebody looked.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
assetids = data.get('assetids') or []
|
||||||
|
if not assetids:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'assetids is required')
|
||||||
|
stamp = None if data.get('unverify') else _now()
|
||||||
|
updated = (Asset.query.filter(Asset.assetid.in_(assetids))
|
||||||
|
.update({'mapverifiedat': stamp}, synchronize_session=False))
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'updated': updated},
|
||||||
|
message='%d marker(s) %s' %
|
||||||
|
(updated, 'unverified' if stamp is None
|
||||||
|
else 'marked reviewed'))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Undo
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@mappositions_bp.route('/snapshots', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.view')
|
||||||
|
def list_snapshots():
|
||||||
|
"""Snapshots, newest first. Metadata only - the positions are large."""
|
||||||
|
snapshots = (MapPositionSnapshot.query
|
||||||
|
.order_by(MapPositionSnapshot.snapshotid.desc())
|
||||||
|
.limit(50).all())
|
||||||
|
return success_response([one.to_dict() for one in snapshots])
|
||||||
|
|
||||||
|
|
||||||
|
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def restore_snapshot(snapshotid):
|
||||||
|
"""Put the positions in this snapshot back.
|
||||||
|
|
||||||
|
Takes its own snapshot first, so an undo is itself undoable - which matters
|
||||||
|
because the most likely reason to restore is a transform that looked right in
|
||||||
|
the preview and wrong on the drawing, and the second attempt is rarely the
|
||||||
|
last one either.
|
||||||
|
"""
|
||||||
|
snapshot = db.session.get(MapPositionSnapshot, snapshotid)
|
||||||
|
if not snapshot:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such snapshot',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
rows = snapshot.positions
|
||||||
|
if not rows:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'this snapshot holds no positions')
|
||||||
|
|
||||||
|
assetids = [row['assetid'] for row in rows]
|
||||||
|
assets = {asset.assetid: asset for asset in
|
||||||
|
Asset.query.filter(Asset.assetid.in_(assetids)).all()}
|
||||||
|
|
||||||
|
MapPositionSnapshot.capture(
|
||||||
|
list(assets.values()),
|
||||||
|
reason='before restoring snapshot %d' % snapshotid,
|
||||||
|
levelid=snapshot.levelid, createdby=_actor())
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
skipped = []
|
||||||
|
for row in rows:
|
||||||
|
asset = assets.get(row['assetid'])
|
||||||
|
if asset is None:
|
||||||
|
# The asset was deleted since the snapshot. Say so rather than
|
||||||
|
# failing the whole restore: the other 299 markers still want
|
||||||
|
# putting back.
|
||||||
|
skipped.append(row['assetid'])
|
||||||
|
continue
|
||||||
|
asset.mapx = row.get('mapx')
|
||||||
|
asset.mapy = row.get('mapy')
|
||||||
|
asset.levelid = row.get('levelid')
|
||||||
|
stamp = row.get('mapverifiedat')
|
||||||
|
asset.mapverifiedat = datetime.fromisoformat(stamp) if stamp else None
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
snapshot.restoredat = _now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Asset',
|
||||||
|
entityname='%d marker(s) restored from snapshot %d'
|
||||||
|
% (restored, snapshotid),
|
||||||
|
changes={'snapshotid': snapshotid, 'skipped': skipped})
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
{'restored': restored, 'skippedassetids': skipped},
|
||||||
|
message='%d marker(s) restored%s' %
|
||||||
|
(restored, '; %d asset(s) no longer exist' % len(skipped)
|
||||||
|
if skipped else ''))
|
||||||
@@ -236,13 +236,26 @@ def _search_applications(query, search_term):
|
|||||||
|
|
||||||
|
|
||||||
def _search_knowledgebase(query, search_term):
|
def _search_knowledgebase(query, search_term):
|
||||||
"""Search Knowledge Base by description and keywords."""
|
"""Search Knowledge Base by description and keywords.
|
||||||
|
|
||||||
|
An article whose topic is a RETIRED application is excluded, matching
|
||||||
|
GET /api/knowledgebase. Filtering it out of the plugin's own listing while
|
||||||
|
global search still returned it is not a rule at all: the article was two
|
||||||
|
keystrokes away, and the result printed the retired application's name as its
|
||||||
|
subject, which reads as though it were still in service.
|
||||||
|
|
||||||
|
A null topic still matches. Not every article is about an application.
|
||||||
|
"""
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
_require_enabled('knowledgebase')
|
_require_enabled('knowledgebase')
|
||||||
from plugins.knowledgebase.models import KnowledgeBase
|
from plugins.knowledgebase.models import KnowledgeBase
|
||||||
|
retired = db.session.query(Application.appid).filter(
|
||||||
|
Application.isactive.is_(False))
|
||||||
kb_articles = KnowledgeBase.query.filter(
|
kb_articles = KnowledgeBase.query.filter(
|
||||||
KnowledgeBase.isactive == True,
|
KnowledgeBase.isactive == True,
|
||||||
|
db.or_(KnowledgeBase.appid.is_(None),
|
||||||
|
KnowledgeBase.appid.notin_(retired)),
|
||||||
_word_match(query, KnowledgeBase.shortdescription,
|
_word_match(query, KnowledgeBase.shortdescription,
|
||||||
KnowledgeBase.keywords)
|
KnowledgeBase.keywords)
|
||||||
).limit(20).all()
|
).limit(20).all()
|
||||||
@@ -339,7 +352,22 @@ def _search_employees(query, search_term):
|
|||||||
|
|
||||||
|
|
||||||
def _search_assets(query, search_term):
|
def _search_assets(query, search_term):
|
||||||
"""Search unified Assets table by number, name, serial, notes."""
|
"""Search unified Assets table by number, name, serial, notes and the two
|
||||||
|
optional identifiers.
|
||||||
|
|
||||||
|
gaugelabreference and maintenancereference are searched for EVERY asset type
|
||||||
|
(ADR-001). Settings lets a site enable either identifier on machines, PCs,
|
||||||
|
printers and network devices, but only the measuring-tools searcher looked at
|
||||||
|
gaugelabreference and nothing looked at maintenancereference at all - so a
|
||||||
|
tag an operator was told to record was one nobody could search by. An
|
||||||
|
identifier that can be entered has to be findable, or it is a write-only
|
||||||
|
field.
|
||||||
|
|
||||||
|
The per-type `identifier_<name>_<assettype>_enabled` toggles are NOT applied
|
||||||
|
here. They govern whether the field is SHOWN on that type; a value already in
|
||||||
|
the row is still the tag written on the physical machine, and matching it is
|
||||||
|
strictly better than returning nothing to someone reading it off a label.
|
||||||
|
"""
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
assets = Asset.query.join(AssetType).options(
|
assets = Asset.query.join(AssetType).options(
|
||||||
@@ -348,7 +376,8 @@ def _search_assets(query, search_term):
|
|||||||
).filter(
|
).filter(
|
||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_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)
|
||||||
).limit(15).all()
|
).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
@@ -357,6 +386,10 @@ def _search_assets(query, search_term):
|
|||||||
relevance = 100
|
relevance = 100
|
||||||
elif asset.name and query.lower() == asset.name.lower():
|
elif asset.name and query.lower() == asset.name.lower():
|
||||||
relevance = 90
|
relevance = 90
|
||||||
|
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
|
||||||
|
relevance = 88
|
||||||
|
elif asset.maintenancereference and query.lower() == asset.maintenancereference.lower():
|
||||||
|
relevance = 86
|
||||||
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
||||||
relevance = 85
|
relevance = 85
|
||||||
elif asset.name and query.lower() in asset.name.lower():
|
elif asset.name and query.lower() in asset.name.lower():
|
||||||
@@ -408,6 +441,104 @@ def _search_measuringtools(query, search_term):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_usbdevices(query, search_term):
|
||||||
|
"""Search USB devices by serial, asset tag and product name.
|
||||||
|
|
||||||
|
A USB device is NOT an asset - it lives in the usb plugin's own table - so
|
||||||
|
the generic asset search cannot see it and these records were unreachable
|
||||||
|
from search entirely. Serial number is the field people actually have in
|
||||||
|
hand: it is what is printed on the stick they are holding.
|
||||||
|
|
||||||
|
currentusername is deliberately NOT searched. It records who holds the
|
||||||
|
device, and making search a way to list what a named person has checked out
|
||||||
|
is a different feature from finding a device.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
_require_enabled('usb')
|
||||||
|
from plugins.usb.models import USBDevice
|
||||||
|
devices = USBDevice.query.filter(
|
||||||
|
USBDevice.isactive == True,
|
||||||
|
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
||||||
|
USBDevice.label, USBDevice.productname)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
relevance = 20
|
||||||
|
if query.lower() == (device.serialnumber or '').lower():
|
||||||
|
relevance = 100
|
||||||
|
elif query.lower() == (device.assetnumber or '').lower():
|
||||||
|
relevance = 90
|
||||||
|
elif query.lower() == (device.label or '').lower():
|
||||||
|
relevance = 85
|
||||||
|
elif query.lower() in (device.label or '').lower():
|
||||||
|
relevance = 50
|
||||||
|
elif query.lower() in (device.productname or '').lower():
|
||||||
|
relevance = 40
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'type': 'usb_device',
|
||||||
|
'id': device.usbdeviceid,
|
||||||
|
'title': device.label or device.productname or device.serialnumber,
|
||||||
|
'subtitle': device.assetnumber or device.serialnumber,
|
||||||
|
'url': f'/usb/{device.usbdeviceid}',
|
||||||
|
'relevance': relevance,
|
||||||
|
})
|
||||||
|
except ImportError:
|
||||||
|
pass # usb plugin absent or disabled
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"USB device search failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_printeditems(query, search_term):
|
||||||
|
"""Search printed items by bin code, gage-lab tag, name and description.
|
||||||
|
|
||||||
|
Printed items are their own records, not assets, so the generic asset search
|
||||||
|
never covered them. itemcode (the bin label, e.g. 3DP-0042) and gagelabtag
|
||||||
|
are both unique and both printed on physical labels, which makes them the
|
||||||
|
likeliest thing anyone types into search.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
_require_enabled('printedparts')
|
||||||
|
from plugins.printedparts.models import PrintedItem
|
||||||
|
items = PrintedItem.query.filter(
|
||||||
|
PrintedItem.isactive == True,
|
||||||
|
_word_match(query, PrintedItem.itemcode, PrintedItem.gagelabtag,
|
||||||
|
PrintedItem.itemname, PrintedItem.itemdescription)
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
relevance = 20
|
||||||
|
if query.lower() == (item.itemcode or '').lower():
|
||||||
|
relevance = 100
|
||||||
|
elif query.lower() == (item.gagelabtag or '').lower():
|
||||||
|
relevance = 95
|
||||||
|
elif query.lower() == (item.itemname or '').lower():
|
||||||
|
relevance = 90
|
||||||
|
elif query.lower() in (item.itemname or '').lower():
|
||||||
|
relevance = 50
|
||||||
|
|
||||||
|
subtitle = item.itemcode or item.gagelabtag
|
||||||
|
if item.binlocation:
|
||||||
|
subtitle = f'{subtitle} - {item.binlocation}' if subtitle else item.binlocation
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'type': 'printed_item',
|
||||||
|
'id': item.printeditemid,
|
||||||
|
'title': item.itemname,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
'url': f'/printedparts/{item.printeditemid}',
|
||||||
|
'relevance': relevance,
|
||||||
|
})
|
||||||
|
except ImportError:
|
||||||
|
pass # printedparts plugin absent or disabled
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Printed item search failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _search_customfields(query, search_term):
|
def _search_customfields(query, search_term):
|
||||||
"""Search custom-field VALUES for fields flagged searchable.
|
"""Search custom-field VALUES for fields flagged searchable.
|
||||||
|
|
||||||
@@ -886,6 +1017,8 @@ def global_search():
|
|||||||
results.extend(_search_employees(query, search_term))
|
results.extend(_search_employees(query, search_term))
|
||||||
results.extend(_search_assets(query, search_term))
|
results.extend(_search_assets(query, search_term))
|
||||||
results.extend(_search_measuringtools(query, search_term))
|
results.extend(_search_measuringtools(query, search_term))
|
||||||
|
results.extend(_search_usbdevices(query, search_term))
|
||||||
|
results.extend(_search_printeditems(query, search_term))
|
||||||
results.extend(_search_customfields(query, search_term))
|
results.extend(_search_customfields(query, search_term))
|
||||||
results.extend(_search_notifications(query, search_term))
|
results.extend(_search_notifications(query, search_term))
|
||||||
results.extend(_search_hostnames(query, search_term))
|
results.extend(_search_hostnames(query, search_term))
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ SEARCH_DOMAINS = {
|
|||||||
'measuring_tool': 'Measuring Tools',
|
'measuring_tool': 'Measuring Tools',
|
||||||
'notification': 'Notifications',
|
'notification': 'Notifications',
|
||||||
'subnet': 'Subnets',
|
'subnet': 'Subnets',
|
||||||
|
'usb_device': 'USB Devices',
|
||||||
|
'printed_item': 'Printed Items',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _declared_default(key: str) -> dict:
|
def _declared_default(key: str) -> dict:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .model import Model
|
|||||||
from .businessunit import BusinessUnit
|
from .businessunit import BusinessUnit
|
||||||
from .dashboarddefault import DashboardDefault
|
from .dashboarddefault import DashboardDefault
|
||||||
from .location import Location, LocationType, derive_locationcode
|
from .location import Location, LocationType, derive_locationcode
|
||||||
|
from .maplevel import Building, MapLevel
|
||||||
|
from .mapsnapshot import MapPositionSnapshot
|
||||||
from .operatingsystem import OperatingSystem
|
from .operatingsystem import OperatingSystem
|
||||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||||
from .communication import Communication, CommunicationType
|
from .communication import Communication, CommunicationType
|
||||||
@@ -37,6 +39,10 @@ __all__ = [
|
|||||||
'DashboardDefault',
|
'DashboardDefault',
|
||||||
'Location',
|
'Location',
|
||||||
'LocationType',
|
'LocationType',
|
||||||
|
# Map model (ADR-017): which drawing renders an asset, at what size
|
||||||
|
'Building',
|
||||||
|
'MapLevel',
|
||||||
|
'MapPositionSnapshot',
|
||||||
'derive_locationcode',
|
'derive_locationcode',
|
||||||
'OperatingSystem',
|
'OperatingSystem',
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -117,9 +117,24 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Floor map position (ADR-001: asset-specific override; nullable)
|
# Floor map position (ADR-001: asset-specific override; nullable).
|
||||||
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
|
#
|
||||||
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
|
# Absolute pixels in the NATIVE COORDINATE SPACE OF ITS LEVEL, not of the
|
||||||
|
# site (ADR-017). levelid says which drawing they are pixels of, and without
|
||||||
|
# it a position cannot be rendered - a marker drawn on the wrong level's
|
||||||
|
# blueprint looks perfectly correct and points at the wrong place, so the UI
|
||||||
|
# shows "level unknown" rather than assuming the default.
|
||||||
|
mapx = db.Column(db.Integer, comment='X coordinate on this level (ADR-017)')
|
||||||
|
mapy = db.Column(db.Integer, comment='Y coordinate on this level (ADR-017)')
|
||||||
|
levelid = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('maplevels.levelid'), nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment='Which drawing mapx/mapy are pixels of (ADR-017)')
|
||||||
|
# When the position was last CONFIRMED against the current drawing. A bulk
|
||||||
|
# transform clears it, because a transform is a starting guess: the levels
|
||||||
|
# were redrawn and machines moved, and nothing in the coordinates says which
|
||||||
|
# markers are now stale. Null means "not yet reviewed on this drawing".
|
||||||
|
mapverifiedat = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
@@ -198,6 +213,10 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
'locationname': related.location.locationname if related.location else None,
|
'locationname': related.location.locationname if related.location else None,
|
||||||
'mapx': related.mapx,
|
'mapx': related.mapx,
|
||||||
'mapy': related.mapy,
|
'mapy': related.mapy,
|
||||||
|
# The level belongs to whichever asset supplied the
|
||||||
|
# coordinates (ADR-017). Inheriting a position without its
|
||||||
|
# level draws it on the borrower's drawing instead.
|
||||||
|
'levelid': related.levelid,
|
||||||
'inheritedfrom': related.assetnumber
|
'inheritedfrom': related.assetnumber
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +269,11 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
result['mapx'] = inherited['mapx']
|
result['mapx'] = inherited['mapx']
|
||||||
if result.get('mapy') is None:
|
if result.get('mapy') is None:
|
||||||
result['mapy'] = inherited['mapy']
|
result['mapy'] = inherited['mapy']
|
||||||
|
# Coordinates and their level move together, always. Copying the
|
||||||
|
# position while leaving levelid as this asset's own is how an
|
||||||
|
# inherited marker lands on the wrong drawing.
|
||||||
|
if result.get('levelid') is None:
|
||||||
|
result['levelid'] = inherited.get('levelid')
|
||||||
|
|
||||||
# Operation/short code of the resolved location (own or inherited).
|
# Operation/short code of the resolved location (own or inherited).
|
||||||
# Derived from the location name's leading token; labels can encode a
|
# Derived from the location name's leading token; labels can encode a
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class Location(BaseModel):
|
|||||||
# chain priority 3.
|
# chain priority 3.
|
||||||
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
||||||
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
||||||
|
# Which drawing those coordinates are pixels of (ADR-017). A location is on a
|
||||||
|
# level as much as an asset is, and an asset with no position of its own
|
||||||
|
# inherits BOTH the coordinates and the level from here - inheriting the
|
||||||
|
# coordinates alone would draw them on whatever level the asset claims.
|
||||||
|
levelid = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('maplevels.levelid'), nullable=True, index=True,
|
||||||
|
comment='Which level mapx/mapy are pixels of (ADR-017)')
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
locationtype = db.relationship('LocationType')
|
locationtype = db.relationship('LocationType')
|
||||||
|
|||||||
109
shopdb/core/models/maplevel.py
Normal file
109
shopdb/core/models/maplevel.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Building + MapLevel models: which drawing renders an asset, at what size.
|
||||||
|
|
||||||
|
See ADR-017. A site used to have one floor map, described by four settings, and
|
||||||
|
`assets.mapx`/`mapy` were pixels in that one image. A second level and a likely
|
||||||
|
second building make that a table rather than a setting.
|
||||||
|
|
||||||
|
The alternative - stacking levels on one tall canvas - was rejected because it
|
||||||
|
turns "which level is this on" into `mapy > 2550`: an inference over a magic
|
||||||
|
number that changes whenever the drawing is re-exported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Building(BaseModel):
|
||||||
|
"""A building at this site. Groups levels; holds nothing a level needs.
|
||||||
|
|
||||||
|
Separate from Location deliberately (ADR-017): a Location answers which
|
||||||
|
operation owns an asset, a building groups the drawings it appears on.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'buildings'
|
||||||
|
|
||||||
|
buildingid = db.Column(db.Integer, primary_key=True)
|
||||||
|
buildingname = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
# Display order. Buildings have no natural ordering and their names are not
|
||||||
|
# reliably ordinal ('Main', 'Annex', 'Building 2'), so the order is stated.
|
||||||
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
levels = db.relationship(
|
||||||
|
'MapLevel', back_populates='building',
|
||||||
|
order_by='MapLevel.sortorder', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Building {self.buildingname}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = super().to_dict()
|
||||||
|
data['levels'] = [level.to_dict() for level in self.levels
|
||||||
|
if level.isactive]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class MapLevel(BaseModel):
|
||||||
|
"""One drawing: a level of a building, with its own blueprint and size.
|
||||||
|
|
||||||
|
WHY THE DIMENSIONS LIVE HERE. They were site-wide settings, which cannot
|
||||||
|
express a mezzanine drawn at a different scale from the floor below it, and
|
||||||
|
certainly not a second building. `mapx`/`mapy` are absolute pixels in THIS
|
||||||
|
level's coordinate space, so a level without its own dimensions cannot place
|
||||||
|
a marker correctly.
|
||||||
|
|
||||||
|
Name and order are separate columns on purpose: levels are not reliably
|
||||||
|
numbered (basement, ground, mezzanine, roof), and `sortorder` gives
|
||||||
|
adjacency and up/down navigation without pretending the names are ordinal.
|
||||||
|
It also lets a mezzanine be inserted later without renumbering anything.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'maplevels'
|
||||||
|
|
||||||
|
levelid = db.Column(db.Integer, primary_key=True)
|
||||||
|
buildingid = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('buildings.buildingid'), nullable=False,
|
||||||
|
index=True)
|
||||||
|
levelname = db.Column(db.String(100), nullable=False)
|
||||||
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Both themes, because the map renders in whichever the viewer is using and
|
||||||
|
# a light-on-white blueprint is unreadable in dark mode. Either may be
|
||||||
|
# blank; the renderer falls back to the other rather than to nothing.
|
||||||
|
blueprintlight = db.Column(db.String(255), nullable=True)
|
||||||
|
blueprintdark = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
# Native pixel size of the blueprint. Positions are absolute pixels in this
|
||||||
|
# space (ADR-017), so these are what a marker's coordinates mean.
|
||||||
|
mapwidth = db.Column(db.Integer, nullable=False, default=3300)
|
||||||
|
mapheight = db.Column(db.Integer, nullable=False, default=2550)
|
||||||
|
|
||||||
|
# Exactly one level carries this. It is where an asset with no level lands,
|
||||||
|
# and what the map opens on. Enforced in the API rather than by a constraint,
|
||||||
|
# because "exactly one" across rows is not a column-level rule.
|
||||||
|
isdefault = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
building = db.relationship('Building', back_populates='levels')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('buildingid', 'levelname',
|
||||||
|
name='uq_maplevel_building_name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<MapLevel {self.levelname}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = super().to_dict()
|
||||||
|
data['buildingname'] = self.building.buildingname if self.building else None
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_level(cls):
|
||||||
|
"""The default level, or the lowest-sorted one if none is marked.
|
||||||
|
|
||||||
|
Never returns None on a seeded database: the migration that created this
|
||||||
|
table also created one level from the settings it replaced.
|
||||||
|
"""
|
||||||
|
level = cls.query.filter_by(isdefault=True, isactive=True).first()
|
||||||
|
if level is not None:
|
||||||
|
return level
|
||||||
|
return (cls.query.filter_by(isactive=True)
|
||||||
|
.order_by(cls.sortorder, cls.levelid).first())
|
||||||
76
shopdb/core/models/mapsnapshot.py
Normal file
76
shopdb/core/models/mapsnapshot.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""A saved set of marker positions, so a bulk change can be undone.
|
||||||
|
|
||||||
|
Positions had no history. A landmark transform rewrites every marker on a level
|
||||||
|
in one statement, and without a way back the honest instruction would be "take a
|
||||||
|
database backup first" - which nobody does before clicking a button in a UI, so
|
||||||
|
in practice the feature would either not be used or be used once, badly.
|
||||||
|
|
||||||
|
One row holds the whole set as JSON rather than a row per asset. A snapshot is
|
||||||
|
read back whole or not at all, so restore stays a single statement, and there is
|
||||||
|
no orphan-child case to reason about.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MapPositionSnapshot(BaseModel):
|
||||||
|
__tablename__ = 'mappositionsnapshots'
|
||||||
|
|
||||||
|
snapshotid = db.Column(db.Integer, primary_key=True)
|
||||||
|
# Which level the operation targeted. Nullable because a snapshot may span
|
||||||
|
# levels (moving assets between them), and then no single level owns it.
|
||||||
|
levelid = db.Column(db.Integer, nullable=True)
|
||||||
|
reason = db.Column(db.String(255), nullable=True)
|
||||||
|
assetcount = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
positionsjson = db.Column(db.Text, nullable=False)
|
||||||
|
# Set when this snapshot has been restored, so the history reads as what
|
||||||
|
# happened rather than as a list of identical-looking saves.
|
||||||
|
restoredat = db.Column(db.DateTime, nullable=True)
|
||||||
|
createdby = db.Column(db.String(100), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<MapPositionSnapshot {self.snapshotid} ({self.assetcount})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def positions(self):
|
||||||
|
try:
|
||||||
|
return json.loads(self.positionsjson or '[]')
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Metadata only. The positions themselves are large and nobody browsing
|
||||||
|
a list of snapshots wants them."""
|
||||||
|
data = super().to_dict()
|
||||||
|
data.pop('positionsjson', None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def capture(cls, assets, reason, levelid=None, createdby=None):
|
||||||
|
"""Record the CURRENT positions of these assets, before they change.
|
||||||
|
|
||||||
|
Includes levelid and mapverifiedat, not just the coordinates: a restore
|
||||||
|
has to put a marker back on the level it was on and with the review state
|
||||||
|
it had, or undo would silently mark reviewed work as unreviewed.
|
||||||
|
"""
|
||||||
|
rows = [{
|
||||||
|
'assetid': asset.assetid,
|
||||||
|
'mapx': asset.mapx,
|
||||||
|
'mapy': asset.mapy,
|
||||||
|
'levelid': asset.levelid,
|
||||||
|
'mapverifiedat': asset.mapverifiedat.isoformat()
|
||||||
|
if asset.mapverifiedat else None,
|
||||||
|
} for asset in assets]
|
||||||
|
snapshot = cls(
|
||||||
|
levelid=levelid,
|
||||||
|
reason=reason,
|
||||||
|
assetcount=len(rows),
|
||||||
|
positionsjson=json.dumps(rows, separators=(',', ':')),
|
||||||
|
createdby=createdby,
|
||||||
|
)
|
||||||
|
db.session.add(snapshot)
|
||||||
|
db.session.flush()
|
||||||
|
return snapshot
|
||||||
134
shopdb/utils/imagesize.py
Normal file
134
shopdb/utils/imagesize.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Read an image's pixel dimensions from its header, with no image library.
|
||||||
|
|
||||||
|
A level's `mapwidth`/`mapheight` are the coordinate space every marker on it is
|
||||||
|
expressed in, so getting them wrong moves every marker relative to the drawing.
|
||||||
|
Reading them off the uploaded file removes the most likely way to get them
|
||||||
|
wrong, which is somebody typing what they remember.
|
||||||
|
|
||||||
|
Pillow would do this in one line and is not a dependency. Adding it would mean a
|
||||||
|
new cp314 win_amd64 wheel in the offline installer's hash-pinned wheelhouse -
|
||||||
|
built on Windows, verified against bundle-lock.json, shipped in a 240 MB
|
||||||
|
installer - to read two integers out of a header. This is the cheaper trade.
|
||||||
|
|
||||||
|
Returns (width, height), or (None, None) when the format is not one of these or
|
||||||
|
the header is truncated. A caller must treat None as "ask the operator" and
|
||||||
|
never as a default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
def png_size(data):
|
||||||
|
# An IHDR chunk always follows the 8-byte signature, and its first two
|
||||||
|
# fields are width and height as big-endian 32-bit integers.
|
||||||
|
if len(data) < 24 or data[:8] != b'\x89PNG\r\n\x1a\n':
|
||||||
|
return None, None
|
||||||
|
if data[12:16] != b'IHDR':
|
||||||
|
return None, None
|
||||||
|
width, height = struct.unpack('>II', data[16:24])
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def gif_size(data):
|
||||||
|
if len(data) < 10 or data[:6] not in (b'GIF87a', b'GIF89a'):
|
||||||
|
return None, None
|
||||||
|
width, height = struct.unpack('<HH', data[6:10])
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
|
||||||
|
def webp_size(data):
|
||||||
|
if len(data) < 30 or data[:4] != b'RIFF' or data[8:12] != b'WEBP':
|
||||||
|
return None, None
|
||||||
|
kind = data[12:16]
|
||||||
|
if kind == b'VP8 ':
|
||||||
|
width, height = struct.unpack('<HH', data[26:30])
|
||||||
|
return width & 0x3FFF, height & 0x3FFF
|
||||||
|
if kind == b'VP8L':
|
||||||
|
bits = struct.unpack('<I', data[21:25])[0]
|
||||||
|
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||||
|
if kind == b'VP8X':
|
||||||
|
width = int.from_bytes(data[24:27], 'little') + 1
|
||||||
|
height = int.from_bytes(data[27:30], 'little') + 1
|
||||||
|
return width, height
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def jpeg_size(data):
|
||||||
|
"""Walk the marker segments to the start-of-frame, which carries the size.
|
||||||
|
|
||||||
|
JPEG has no fixed header offset - the dimensions live in whichever SOF
|
||||||
|
marker the encoder used, after any number of application and comment
|
||||||
|
segments of varying length. So this walks rather than indexes.
|
||||||
|
"""
|
||||||
|
if len(data) < 4 or data[:2] != b'\xff\xd8':
|
||||||
|
return None, None
|
||||||
|
index = 2
|
||||||
|
end = len(data)
|
||||||
|
while index < end - 9:
|
||||||
|
if data[index] != 0xFF:
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
marker = data[index + 1]
|
||||||
|
# SOF0 through SOF15, excluding the DHT/JPG/DAC markers interleaved
|
||||||
|
# in that range, all carry height then width at the same offset.
|
||||||
|
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
|
||||||
|
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
||||||
|
height, width = struct.unpack('>HH', data[index + 5:index + 9])
|
||||||
|
return width, height
|
||||||
|
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD9:
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
segment = struct.unpack('>H', data[index + 2:index + 4])[0]
|
||||||
|
index += 2 + segment
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def svg_size(data):
|
||||||
|
"""SVG states a size in attributes, or implies one through viewBox.
|
||||||
|
|
||||||
|
Width and height may carry units (mm, in, pt) or be percentages, and a
|
||||||
|
percentage says nothing about pixels - so a unit that is not px falls back to
|
||||||
|
the viewBox, which is unitless user space and is what a renderer scales to.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
head = data[:4096].decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
if '<svg' not in head:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def attribute(name):
|
||||||
|
found = re.search(r'\b%s\s*=\s*["\']([^"\']+)["\']' % name, head)
|
||||||
|
return found.group(1).strip() if found else None
|
||||||
|
|
||||||
|
def pixels(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
match = re.match(r'^([0-9.]+)\s*(px)?$', value)
|
||||||
|
return int(round(float(match.group(1)))) if match else None
|
||||||
|
|
||||||
|
width = pixels(attribute('width'))
|
||||||
|
height = pixels(attribute('height'))
|
||||||
|
if width and height:
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
viewbox = attribute('viewBox')
|
||||||
|
if viewbox:
|
||||||
|
parts = re.split(r'[\s,]+', viewbox.strip())
|
||||||
|
if len(parts) == 4:
|
||||||
|
try:
|
||||||
|
return (int(round(float(parts[2]))),
|
||||||
|
int(round(float(parts[3]))))
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def image_size(data):
|
||||||
|
"""Dimensions of PNG, JPEG, GIF, WEBP or SVG data. (None, None) otherwise."""
|
||||||
|
for reader in (png_size, jpeg_size, gif_size, webp_size, svg_size):
|
||||||
|
width, height = reader(data)
|
||||||
|
if width and height:
|
||||||
|
return width, height
|
||||||
|
return None, None
|
||||||
@@ -46,42 +46,60 @@ def test_resolve_asset_position_uses_self_when_set():
|
|||||||
class FakeLocation:
|
class FakeLocation:
|
||||||
mapx = 10
|
mapx = 10
|
||||||
mapy = 20
|
mapy = 20
|
||||||
|
levelid = 9
|
||||||
|
|
||||||
class FakeAsset:
|
class FakeAsset:
|
||||||
mapx = 100
|
mapx = 100
|
||||||
mapy = 200
|
mapy = 200
|
||||||
|
levelid = 7
|
||||||
location = FakeLocation()
|
location = FakeLocation()
|
||||||
|
|
||||||
result = resolve_asset_position(FakeAsset())
|
result = resolve_asset_position(FakeAsset())
|
||||||
assert result == {'mapx': 100, 'mapy': 200, 'positionsource': 'self'}
|
assert result == {'mapx': 100, 'mapy': 200, 'levelid': 7,
|
||||||
|
'positionsource': 'self'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_falls_back_to_location():
|
def test_resolve_asset_position_falls_back_to_location():
|
||||||
"""When asset has no coords, falls back to location coords."""
|
"""When asset has no coords, falls back to location coords.
|
||||||
|
|
||||||
|
The level comes with them (ADR-017). The asset here carries a STALE levelid
|
||||||
|
of its own with no coordinates to go with it - a leftover from a position
|
||||||
|
that was cleared - and returning that level beside the location's
|
||||||
|
coordinates would draw the location's spot on the wrong drawing.
|
||||||
|
"""
|
||||||
class FakeLocation:
|
class FakeLocation:
|
||||||
mapx = 50
|
mapx = 50
|
||||||
mapy = 75
|
mapy = 75
|
||||||
|
levelid = 9
|
||||||
|
|
||||||
class FakeAsset:
|
class FakeAsset:
|
||||||
mapx = None
|
mapx = None
|
||||||
mapy = None
|
mapy = None
|
||||||
|
levelid = 7
|
||||||
location = FakeLocation()
|
location = FakeLocation()
|
||||||
|
|
||||||
result = resolve_asset_position(FakeAsset())
|
result = resolve_asset_position(FakeAsset())
|
||||||
assert result == {'mapx': 50, 'mapy': 75, 'positionsource': 'location'}
|
assert result == {'mapx': 50, 'mapy': 75, 'levelid': 9,
|
||||||
|
'positionsource': 'location'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_handles_asset_without_mapx_attr():
|
def test_resolve_asset_position_handles_asset_without_mapx_attr():
|
||||||
"""Assets that don't yet have mapx/mapy columns degrade gracefully."""
|
"""Assets that don't yet have mapx/mapy columns degrade gracefully.
|
||||||
|
|
||||||
|
Nor a levelid attribute: a plugin extension row passed in here has none,
|
||||||
|
and reading it must not raise.
|
||||||
|
"""
|
||||||
class FakeLocation:
|
class FakeLocation:
|
||||||
mapx = 1
|
mapx = 1
|
||||||
mapy = 2
|
mapy = 2
|
||||||
|
levelid = 4
|
||||||
|
|
||||||
class FakeAsset:
|
class FakeAsset:
|
||||||
location = FakeLocation()
|
location = FakeLocation()
|
||||||
|
|
||||||
result = resolve_asset_position(FakeAsset())
|
result = resolve_asset_position(FakeAsset())
|
||||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'location'}
|
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 4,
|
||||||
|
'positionsource': 'location'}
|
||||||
|
|
||||||
|
|
||||||
# --- Relationship-walk path (priority 2 in the chain) ----------------------
|
# --- Relationship-walk path (priority 2 in the chain) ----------------------
|
||||||
@@ -105,7 +123,8 @@ def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
|
|||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
||||||
def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, location=None):
|
def _make_asset(assetid, mapx=None, mapy=None, levelid=None, outgoing=None,
|
||||||
|
incoming=None, location=None):
|
||||||
class FakeAsset:
|
class FakeAsset:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -113,6 +132,7 @@ def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, loc
|
|||||||
a.assetid = assetid
|
a.assetid = assetid
|
||||||
a.mapx = mapx
|
a.mapx = mapx
|
||||||
a.mapy = mapy
|
a.mapy = mapy
|
||||||
|
a.levelid = levelid
|
||||||
a.outgoing_relationships = outgoing or []
|
a.outgoing_relationships = outgoing or []
|
||||||
a.incoming_relationships = incoming or []
|
a.incoming_relationships = incoming or []
|
||||||
a.location = location
|
a.location = location
|
||||||
@@ -121,24 +141,26 @@ def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, loc
|
|||||||
|
|
||||||
def test_resolve_asset_position_walks_partof_edge():
|
def test_resolve_asset_position_walks_partof_edge():
|
||||||
"""Priority 2: inheritsposition=true partof edge resolves from neighbor."""
|
"""Priority 2: inheritsposition=true partof edge resolves from neighbor."""
|
||||||
parent = _make_asset(assetid=2, mapx=300, mapy=400)
|
parent = _make_asset(assetid=2, mapx=300, mapy=400, levelid=2)
|
||||||
rel = _make_rel('partof', parent)
|
rel = _make_rel('partof', parent)
|
||||||
child = _make_asset(assetid=1, outgoing=[rel])
|
child = _make_asset(assetid=1, outgoing=[rel])
|
||||||
|
|
||||||
result = resolve_asset_position(child)
|
result = resolve_asset_position(child)
|
||||||
assert result == {'mapx': 300, 'mapy': 400, 'positionsource': 'related'}
|
assert result == {'mapx': 300, 'mapy': 400, 'levelid': 2,
|
||||||
|
'positionsource': 'related'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_walks_controls_after_partof():
|
def test_resolve_asset_position_walks_controls_after_partof():
|
||||||
"""Priority 2 ordering: partof beats controls when both have coords."""
|
"""Priority 2 ordering: partof beats controls when both have coords."""
|
||||||
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12)
|
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12, levelid=3)
|
||||||
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99)
|
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99, levelid=8)
|
||||||
rel_partof = _make_rel('partof', partof_neighbor)
|
rel_partof = _make_rel('partof', partof_neighbor)
|
||||||
rel_controls = _make_rel('controls', controls_neighbor)
|
rel_controls = _make_rel('controls', controls_neighbor)
|
||||||
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
|
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
|
||||||
|
|
||||||
result = resolve_asset_position(asset)
|
result = resolve_asset_position(asset)
|
||||||
assert result == {'mapx': 11, 'mapy': 12, 'positionsource': 'related'}
|
assert result == {'mapx': 11, 'mapy': 12, 'levelid': 3,
|
||||||
|
'positionsource': 'related'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_skips_non_inheritable_type():
|
def test_resolve_asset_position_skips_non_inheritable_type():
|
||||||
@@ -161,12 +183,14 @@ def test_resolve_asset_position_skips_when_inheritsposition_false():
|
|||||||
|
|
||||||
def test_resolve_asset_position_walks_recursively():
|
def test_resolve_asset_position_walks_recursively():
|
||||||
"""The walk recurses: child -> middle -> root, where only root has coords."""
|
"""The walk recurses: child -> middle -> root, where only root has coords."""
|
||||||
root = _make_asset(assetid=3, mapx=1, mapy=2)
|
root = _make_asset(assetid=3, mapx=1, mapy=2, levelid=5)
|
||||||
middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)])
|
middle = _make_asset(assetid=2, outgoing=[_make_rel('partof', root)])
|
||||||
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
|
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
|
||||||
|
|
||||||
result = resolve_asset_position(child)
|
result = resolve_asset_position(child)
|
||||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'related'}
|
# Carried back up two hops from the node that actually has coordinates.
|
||||||
|
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 5,
|
||||||
|
'positionsource': 'related'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_breaks_cycles():
|
def test_resolve_asset_position_breaks_cycles():
|
||||||
@@ -195,12 +219,13 @@ def test_resolve_asset_position_depth_cap_is_three():
|
|||||||
def test_resolve_asset_position_self_beats_related():
|
def test_resolve_asset_position_self_beats_related():
|
||||||
"""Priority 1 beats priority 2: asset's own coords win even when a
|
"""Priority 1 beats priority 2: asset's own coords win even when a
|
||||||
related neighbor would also resolve."""
|
related neighbor would also resolve."""
|
||||||
neighbor = _make_asset(assetid=2, mapx=99, mapy=99)
|
neighbor = _make_asset(assetid=2, mapx=99, mapy=99, levelid=9)
|
||||||
rel = _make_rel('partof', neighbor)
|
rel = _make_rel('partof', neighbor)
|
||||||
asset = _make_asset(assetid=1, mapx=1, mapy=2, outgoing=[rel])
|
asset = _make_asset(assetid=1, mapx=1, mapy=2, levelid=1, outgoing=[rel])
|
||||||
|
|
||||||
result = resolve_asset_position(asset)
|
result = resolve_asset_position(asset)
|
||||||
assert result == {'mapx': 1, 'mapy': 2, 'positionsource': 'self'}
|
assert result == {'mapx': 1, 'mapy': 2, 'levelid': 1,
|
||||||
|
'positionsource': 'self'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_related_beats_location():
|
def test_resolve_asset_position_related_beats_location():
|
||||||
@@ -209,13 +234,18 @@ def test_resolve_asset_position_related_beats_location():
|
|||||||
class FakeLocation:
|
class FakeLocation:
|
||||||
mapx = 500
|
mapx = 500
|
||||||
mapy = 600
|
mapy = 600
|
||||||
|
levelid = 9
|
||||||
|
|
||||||
neighbor = _make_asset(assetid=2, mapx=10, mapy=20)
|
neighbor = _make_asset(assetid=2, mapx=10, mapy=20, levelid=2)
|
||||||
rel = _make_rel('partof', neighbor)
|
rel = _make_rel('partof', neighbor)
|
||||||
asset = _make_asset(assetid=1, outgoing=[rel], location=FakeLocation())
|
asset = _make_asset(assetid=1, levelid=7, outgoing=[rel],
|
||||||
|
location=FakeLocation())
|
||||||
|
|
||||||
result = resolve_asset_position(asset)
|
result = resolve_asset_position(asset)
|
||||||
assert result == {'mapx': 10, 'mapy': 20, 'positionsource': 'related'}
|
# Three levels are in play - the asset's own stale 7, the location's 9, and
|
||||||
|
# the neighbour's 2. Only the one that owns the coordinates is correct.
|
||||||
|
assert result == {'mapx': 10, 'mapy': 20, 'levelid': 2,
|
||||||
|
'positionsource': 'related'}
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_asset_position_inactive_edge_skipped():
|
def test_resolve_asset_position_inactive_edge_skipped():
|
||||||
|
|||||||
236
tests/test_core/test_map_transform.py
Normal file
236
tests/test_core/test_map_transform.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""The landmark transform, and the wrong answer it must not give.
|
||||||
|
|
||||||
|
The case this exists for: a site's blueprint goes from 3300x2550 to 3308x4000
|
||||||
|
because a second level was added below the first. The existing floor is drawn at
|
||||||
|
the same scale - so the correct transform is identity, or identity plus an
|
||||||
|
offset. A transform derived from the ratio of image dimensions would scale Y by
|
||||||
|
4000/2550 = 1.5686 and be wrong for every marker on the level, while looking
|
||||||
|
like arithmetic somebody had thought about.
|
||||||
|
|
||||||
|
So the first test here is not that the transform works. It is that it does not
|
||||||
|
produce that particular plausible wrong answer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Asset, AssetType, Building, MapLevel
|
||||||
|
from shopdb.core.api.mappositions import derive_transform, solve_axis
|
||||||
|
|
||||||
|
|
||||||
|
# -- the arithmetic, with no app or database involved ------------------------
|
||||||
|
|
||||||
|
def test_two_landmarks_give_identity_when_the_drawing_did_not_move():
|
||||||
|
"""A canvas that grew taller with the old floor untouched: scale 1, no offset.
|
||||||
|
|
||||||
|
This is the assertion that rules out the dimension-derived answer. Nothing
|
||||||
|
about these landmarks mentions 2550 or 4000, and the result must not either.
|
||||||
|
"""
|
||||||
|
transform, problem = derive_transform([
|
||||||
|
{'fromx': 100, 'fromy': 100, 'tox': 100, 'toy': 100},
|
||||||
|
{'fromx': 3000, 'fromy': 2000, 'tox': 3000, 'toy': 2000},
|
||||||
|
])
|
||||||
|
assert problem is None
|
||||||
|
assert transform['scalex'] == pytest.approx(1.0)
|
||||||
|
assert transform['scaley'] == pytest.approx(1.0)
|
||||||
|
assert transform['offsetx'] == pytest.approx(0.0)
|
||||||
|
assert transform['offsety'] == pytest.approx(0.0)
|
||||||
|
# The wrong answer, stated so a regression names itself.
|
||||||
|
assert transform['scaley'] != pytest.approx(4000 / 2550, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_level_added_above_gives_a_pure_y_offset():
|
||||||
|
"""Existing floor pushed down by 1450px: identity scale, offset 1450."""
|
||||||
|
transform, problem = derive_transform([
|
||||||
|
{'fromx': 100, 'fromy': 100, 'tox': 100, 'toy': 1550},
|
||||||
|
{'fromx': 3000, 'fromy': 2000, 'tox': 3000, 'toy': 3450},
|
||||||
|
])
|
||||||
|
assert problem is None
|
||||||
|
assert transform['scaley'] == pytest.approx(1.0)
|
||||||
|
assert transform['offsety'] == pytest.approx(1450.0)
|
||||||
|
assert transform['scalex'] == pytest.approx(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_genuine_rescale_is_derived_per_axis():
|
||||||
|
"""X and Y can scale differently, which one uniform factor cannot express."""
|
||||||
|
transform, _ = derive_transform([
|
||||||
|
{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 0},
|
||||||
|
{'fromx': 1000, 'fromy': 1000, 'tox': 2000, 'toy': 1500},
|
||||||
|
])
|
||||||
|
assert transform['scalex'] == pytest.approx(2.0)
|
||||||
|
assert transform['scaley'] == pytest.approx(1.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_third_landmark_averages_measurement_error():
|
||||||
|
"""Points picked by eye carry a few pixels of error; more points cancel it
|
||||||
|
rather than accumulating it."""
|
||||||
|
transform, _ = derive_transform([
|
||||||
|
{'fromx': 0, 'fromy': 0, 'tox': 2, 'toy': -2},
|
||||||
|
{'fromx': 1000, 'fromy': 1000, 'tox': 999, 'toy': 1001},
|
||||||
|
{'fromx': 2000, 'fromy': 2000, 'tox': 2001, 'toy': 1999},
|
||||||
|
])
|
||||||
|
assert transform['scalex'] == pytest.approx(1.0, abs=0.01)
|
||||||
|
assert transform['scaley'] == pytest.approx(1.0, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_landmarks_on_one_column_are_refused_not_guessed():
|
||||||
|
"""Two points with the same X cannot determine a horizontal scale.
|
||||||
|
|
||||||
|
Returning 1.0 here would look like success and silently leave X unscaled,
|
||||||
|
which is the failure a user would discover after the write.
|
||||||
|
"""
|
||||||
|
transform, problem = derive_transform([
|
||||||
|
{'fromx': 500, 'fromy': 100, 'tox': 600, 'toy': 100},
|
||||||
|
{'fromx': 500, 'fromy': 2000, 'tox': 600, 'toy': 2000},
|
||||||
|
])
|
||||||
|
assert transform is None
|
||||||
|
assert 'X' in problem
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_landmark_is_not_enough():
|
||||||
|
transform, problem = derive_transform([
|
||||||
|
{'fromx': 1, 'fromy': 1, 'tox': 2, 'toy': 2}])
|
||||||
|
assert transform is None
|
||||||
|
assert 'two landmarks' in problem
|
||||||
|
|
||||||
|
|
||||||
|
def test_solve_axis_reports_an_undetermined_axis():
|
||||||
|
assert solve_axis([(5, 9), (5, 11)]) is None
|
||||||
|
assert solve_axis([(0, 0), (10, 20)]) == (2.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
# -- end to end, against the endpoints --------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def floor(db):
|
||||||
|
"""One building, two levels sized like the real before and after."""
|
||||||
|
assettype = AssetType.query.filter_by(assettype='machine').first()
|
||||||
|
if not assettype:
|
||||||
|
assettype = AssetType(assettype='machine')
|
||||||
|
db.session.add(assettype)
|
||||||
|
db.session.flush()
|
||||||
|
building = Building(buildingname='Main')
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.flush()
|
||||||
|
ground = MapLevel(buildingid=building.buildingid, levelname='Ground floor',
|
||||||
|
sortorder=0, isdefault=True, mapwidth=3300, mapheight=2550)
|
||||||
|
second = MapLevel(buildingid=building.buildingid, levelname='Second floor',
|
||||||
|
sortorder=1, mapwidth=3308, mapheight=4000)
|
||||||
|
db.session.add_all([ground, second])
|
||||||
|
db.session.flush()
|
||||||
|
assets = []
|
||||||
|
for index, (x, y) in enumerate([(100, 100), (1500, 1200), (3200, 2500)]):
|
||||||
|
asset = Asset(assetnumber='M%03d' % index, assettypeid=assettype.assettypeid,
|
||||||
|
mapx=x, mapy=y, levelid=ground.levelid,
|
||||||
|
mapverifiedat=datetime(2026, 1, 1))
|
||||||
|
db.session.add(asset)
|
||||||
|
assets.append(asset)
|
||||||
|
db.session.commit()
|
||||||
|
return {'ground': ground, 'second': second, 'assets': assets}
|
||||||
|
|
||||||
|
|
||||||
|
def _transform(client, headers, **body):
|
||||||
|
return client.post('/api/mappositions/transform', json=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dry_run_writes_nothing(client, db, floor, auth_headers):
|
||||||
|
before = [(a.mapx, a.mapy, a.mapverifiedat) for a in floor['assets']]
|
||||||
|
resp = _transform(client, auth_headers,
|
||||||
|
levelid=floor['ground'].levelid,
|
||||||
|
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
|
||||||
|
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()['data']
|
||||||
|
assert body['dryrun'] is True
|
||||||
|
assert body['assetcount'] == 3
|
||||||
|
db.session.expire_all()
|
||||||
|
after = [(a.mapx, a.mapy, a.mapverifiedat)
|
||||||
|
for a in Asset.query.order_by(Asset.assetid).all()]
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_moves_markers_clears_review_and_snapshots(
|
||||||
|
client, db, floor, auth_headers):
|
||||||
|
resp = _transform(client, auth_headers,
|
||||||
|
levelid=floor['ground'].levelid, dryrun=False,
|
||||||
|
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
|
||||||
|
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_json()['data']
|
||||||
|
assert body['dryrun'] is False
|
||||||
|
assert body['snapshotid']
|
||||||
|
|
||||||
|
db.session.expire_all()
|
||||||
|
moved = Asset.query.filter_by(assetnumber='M000').first()
|
||||||
|
assert (moved.mapx, moved.mapy) == (100, 1550)
|
||||||
|
# A transform is a guess, so the review state it had is gone.
|
||||||
|
assert moved.mapverifiedat is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_markers_pushed_off_the_canvas_are_counted(client, db, floor, auth_headers):
|
||||||
|
"""A marker at y=2500 offset by 1600 lands at 4100, past the 4000px canvas.
|
||||||
|
|
||||||
|
Worth checking because a bad landmark pair is most likely to show up as
|
||||||
|
markers leaving the drawing entirely, and a preview that does not count them
|
||||||
|
lets that through.
|
||||||
|
"""
|
||||||
|
resp = _transform(client, auth_headers,
|
||||||
|
levelid=floor['ground'].levelid,
|
||||||
|
tolevelid=floor['second'].levelid,
|
||||||
|
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1600},
|
||||||
|
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2600}])
|
||||||
|
body = resp.get_json()['data']
|
||||||
|
assert body['outofboundscount'] == 1
|
||||||
|
offender = next(m for m in body['moves'] if m['outofbounds'])
|
||||||
|
assert offender['toy'] > floor['second'].mapheight
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_puts_positions_and_review_state_back(
|
||||||
|
client, db, floor, auth_headers):
|
||||||
|
original = {a.assetnumber: (a.mapx, a.mapy, a.mapverifiedat)
|
||||||
|
for a in floor['assets']}
|
||||||
|
applied = _transform(client, auth_headers,
|
||||||
|
levelid=floor['ground'].levelid, dryrun=False,
|
||||||
|
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
|
||||||
|
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
|
||||||
|
snapshotid = applied.get_json()['data']['snapshotid']
|
||||||
|
|
||||||
|
resp = client.post('/api/mappositions/snapshots/%d/restore' % snapshotid,
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()['data']['restored'] == 3
|
||||||
|
|
||||||
|
db.session.expire_all()
|
||||||
|
for asset in Asset.query.all():
|
||||||
|
assert (asset.mapx, asset.mapy, asset.mapverifiedat) == \
|
||||||
|
original[asset.assetnumber]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_position_without_a_level_is_refused(client, db, floor, auth_headers):
|
||||||
|
"""The rule the whole model rests on: a position with no level cannot be
|
||||||
|
stored, because it cannot be rendered."""
|
||||||
|
resp = client.post('/api/mappositions/positions', json={
|
||||||
|
'positions': [{'assetid': floor['assets'][0].assetid,
|
||||||
|
'mapx': 10, 'mapy': 20}]}, headers=auth_headers)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert 'levelid' in resp.get_json()['data']['error']['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_placing_by_hand_counts_as_review(client, db, floor, auth_headers):
|
||||||
|
asset = floor['assets'][0]
|
||||||
|
client.post('/api/mappositions/verify',
|
||||||
|
json={'assetids': [asset.assetid], 'unverify': True},
|
||||||
|
headers=auth_headers)
|
||||||
|
db.session.expire_all()
|
||||||
|
assert Asset.query.get(asset.assetid).mapverifiedat is None
|
||||||
|
|
||||||
|
resp = client.post('/api/mappositions/positions', json={
|
||||||
|
'positions': [{'assetid': asset.assetid, 'mapx': 42, 'mapy': 43,
|
||||||
|
'levelid': floor['second'].levelid}]},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db.session.expire_all()
|
||||||
|
moved = Asset.query.get(asset.assetid)
|
||||||
|
assert (moved.mapx, moved.mapy, moved.levelid) == (42, 43, floor['second'].levelid)
|
||||||
|
assert moved.mapverifiedat is not None
|
||||||
201
tests/test_core/test_search_coverage.py
Normal file
201
tests/test_core/test_search_coverage.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""What global search can reach, and what it must not return.
|
||||||
|
|
||||||
|
Two plugins own records that are NOT assets - USB devices and printed items -
|
||||||
|
so the generic asset search cannot see them, and nothing else did either: those
|
||||||
|
records were unreachable from search entirely while their list pages existed and
|
||||||
|
their labels were printed on physical bins and sticks.
|
||||||
|
|
||||||
|
The other half of this file is the retired-application rule. Hiding an article
|
||||||
|
about a decommissioned application from the plugin's own listing while global
|
||||||
|
search still returned it is not a rule at all - the article stayed two
|
||||||
|
keystrokes away, and the result printed the retired application as its subject.
|
||||||
|
A filter is only real if every path that reaches the row applies it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Application
|
||||||
|
|
||||||
|
|
||||||
|
def search(client, query):
|
||||||
|
"""Global search rows for a query."""
|
||||||
|
resp = client.get(f'/api/search?q={query}')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
return resp.get_json()['data']['results']
|
||||||
|
|
||||||
|
|
||||||
|
def titles(client, query, resulttype=None):
|
||||||
|
return [r['title'] for r in search(client, query)
|
||||||
|
if resulttype is None or r['type'] == resulttype]
|
||||||
|
|
||||||
|
|
||||||
|
# --- USB devices (usb plugin, not an asset) ---------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def usbdevices(db):
|
||||||
|
"""One live device and one deactivated one, distinctive enough that only the
|
||||||
|
USB searcher can match them."""
|
||||||
|
from plugins.usb.models import USBDevice
|
||||||
|
live = USBDevice(serialnumber='ZZSERIAL1234', label='Loaner stick 4',
|
||||||
|
assetnumber='USB-0004', productname='Kingston DataTraveler',
|
||||||
|
isactive=True)
|
||||||
|
retired = USBDevice(serialnumber='ZZSERIAL9999', label='Dead stick',
|
||||||
|
assetnumber='USB-0009', productname='Kingston DataTraveler',
|
||||||
|
isactive=False)
|
||||||
|
db.session.add_all([live, retired])
|
||||||
|
db.session.commit()
|
||||||
|
return {'live': live.usbdeviceid, 'retired': retired.usbdeviceid}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_usb_device_is_found_by_serial(client, usbdevices):
|
||||||
|
"""The serial is what is printed on the stick in someone's hand."""
|
||||||
|
rows = [r for r in search(client, 'ZZSERIAL1234') if r['type'] == 'usb_device']
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['id'] == usbdevices['live']
|
||||||
|
assert rows[0]['url'] == f"/usb/{usbdevices['live']}"
|
||||||
|
# An exact serial is the strongest possible match for this domain.
|
||||||
|
assert rows[0]['relevance'] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_usb_device_is_found_by_label_and_asset_tag(client, usbdevices):
|
||||||
|
assert 'Loaner stick 4' in titles(client, 'Loaner', 'usb_device')
|
||||||
|
assert 'Loaner stick 4' in titles(client, 'USB-0004', 'usb_device')
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_deactivated_usb_device_is_not_found(client, usbdevices):
|
||||||
|
assert titles(client, 'ZZSERIAL9999', 'usb_device') == []
|
||||||
|
# And it does not ride along on a query that matches both devices.
|
||||||
|
assert 'Dead stick' not in titles(client, 'Kingston', 'usb_device')
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_usb_holder_is_not_searchable_by_name(client, db):
|
||||||
|
"""currentusername records who holds a device. Making search a way to list
|
||||||
|
what a named person checked out is a different feature, deliberately not
|
||||||
|
built into this searcher."""
|
||||||
|
from plugins.usb.models import USBDevice
|
||||||
|
db.session.add(USBDevice(serialnumber='ZZSERIAL5555', label='Held stick',
|
||||||
|
currentusername='Distinctivesurname', isactive=True))
|
||||||
|
db.session.commit()
|
||||||
|
assert titles(client, 'Distinctivesurname', 'usb_device') == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Printed items (printedparts plugin, not an asset) ----------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def printeditems(db):
|
||||||
|
from plugins.printedparts.models import PrintedItem
|
||||||
|
live = PrintedItem(itemcode='3DP-8001', gagelabtag='WJRP8001',
|
||||||
|
itemname='Fixture clamp', itemdescription='Holds a part',
|
||||||
|
binlocation='Bin 12', quantityonhand=4,
|
||||||
|
lowstockthreshold=2, isactive=True)
|
||||||
|
retired = PrintedItem(itemcode='3DP-8009', gagelabtag='WJRP8009',
|
||||||
|
itemname='Obsolete clamp', quantityonhand=0,
|
||||||
|
lowstockthreshold=1, isactive=False)
|
||||||
|
db.session.add_all([live, retired])
|
||||||
|
db.session.commit()
|
||||||
|
return {'live': live.printeditemid, 'retired': retired.printeditemid}
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_printed_item_is_found_by_bin_code(client, printeditems):
|
||||||
|
"""itemcode is the bin label someone reads off the shelf."""
|
||||||
|
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]['id'] == printeditems['live']
|
||||||
|
assert rows[0]['url'] == f"/printedparts/{printeditems['live']}"
|
||||||
|
assert rows[0]['relevance'] == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_printed_item_is_found_by_gage_lab_tag(client, printeditems):
|
||||||
|
rows = [r for r in search(client, 'WJRP8001') if r['type'] == 'printed_item']
|
||||||
|
assert [r['title'] for r in rows] == ['Fixture clamp']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_printed_item_is_found_by_name(client, printeditems):
|
||||||
|
assert 'Fixture clamp' in titles(client, 'Fixture clamp', 'printed_item')
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_printed_item_subtitle_locates_it(client, printeditems):
|
||||||
|
"""A hit is only useful if it says where to go and get the part."""
|
||||||
|
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
|
||||||
|
assert 'Bin 12' in rows[0]['subtitle']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_deactivated_printed_item_is_not_found(client, printeditems):
|
||||||
|
assert titles(client, '3DP-8009', 'printed_item') == []
|
||||||
|
assert 'Obsolete clamp' not in titles(client, 'clamp', 'printed_item')
|
||||||
|
|
||||||
|
|
||||||
|
# --- The retired-application rule reaches global search too ----------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def kbarticles(db):
|
||||||
|
from plugins.knowledgebase.models import KnowledgeBase
|
||||||
|
live = Application(appname='Livesearchapp', isactive=True)
|
||||||
|
retired = Application(appname='Retiredsearchapp', isactive=False)
|
||||||
|
db.session.add_all([live, retired])
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add_all([
|
||||||
|
KnowledgeBase(shortdescription='Zzrunbook for the live one', linkurl='u',
|
||||||
|
keywords='zzkeyword', appid=live.appid, isactive=True),
|
||||||
|
KnowledgeBase(shortdescription='Zzrunbook for the retired one', linkurl='u',
|
||||||
|
keywords='zzkeyword', appid=retired.appid, isactive=True),
|
||||||
|
KnowledgeBase(shortdescription='Zzrunbook with no topic', linkurl='u',
|
||||||
|
keywords='zzkeyword', appid=None, isactive=True),
|
||||||
|
])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_search_hides_an_article_about_a_retired_application(client, kbarticles):
|
||||||
|
found = titles(client, 'zzkeyword', 'knowledgebase')
|
||||||
|
assert 'Zzrunbook for the retired one' not in found
|
||||||
|
assert 'Zzrunbook for the live one' in found
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_search_keeps_an_article_with_no_topic(client, kbarticles):
|
||||||
|
"""A null topic is not a retired one."""
|
||||||
|
assert 'Zzrunbook with no topic' in titles(client, 'zzkeyword', 'knowledgebase')
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_search_hides_a_retired_application_itself(client, kbarticles):
|
||||||
|
"""The application domain has always filtered isactive; pinned here so the
|
||||||
|
two rules stay together and cannot drift apart again."""
|
||||||
|
assert titles(client, 'Retiredsearchapp', 'application') == []
|
||||||
|
assert titles(client, 'Livesearchapp', 'application') == ['Livesearchapp']
|
||||||
|
|
||||||
|
|
||||||
|
# --- The optional asset identifiers (ADR-001) -------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def taggedmachine(client, db, auth_headers):
|
||||||
|
"""A machine carrying both optional identifiers.
|
||||||
|
|
||||||
|
A MACHINE on purpose, not a measuring tool: gaugelabreference was searchable
|
||||||
|
only through the measuring-tools searcher, so the same tag on a machine
|
||||||
|
matched nothing even though Settings offers the identifier for machines.
|
||||||
|
"""
|
||||||
|
from shopdb.core.models import AssetType, Asset
|
||||||
|
if not AssetType.query.filter_by(assettype='machine').first():
|
||||||
|
db.session.add(AssetType(assettype='machine', pluginname='machines',
|
||||||
|
tablename='machines', description='m'))
|
||||||
|
db.session.commit()
|
||||||
|
resp = client.post('/api/machines', json={'assetnumber': 'ZZMACH01'},
|
||||||
|
headers=auth_headers)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
asset = Asset.query.filter_by(assetnumber='ZZMACH01').first()
|
||||||
|
asset.gaugelabreference = 'ZZGAUGE777'
|
||||||
|
asset.maintenancereference = 'ZZMAINT888'
|
||||||
|
db.session.commit()
|
||||||
|
return asset.assetid
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_machine_is_found_by_its_gauge_lab_reference(client, taggedmachine):
|
||||||
|
rows = [r for r in search(client, 'ZZGAUGE777') if r['id'] == taggedmachine
|
||||||
|
or r['title'] == 'ZZMACH01']
|
||||||
|
assert rows, 'a gauge-lab tag on a machine matched nothing'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_machine_is_found_by_its_maintenance_reference(client, taggedmachine):
|
||||||
|
"""maintenancereference was searched by nothing at all, for any asset
|
||||||
|
type - a field the UI collects and search could not find."""
|
||||||
|
rows = [r for r in search(client, 'ZZMAINT888') if r['title'] == 'ZZMACH01']
|
||||||
|
assert rows, 'a maintenance reference matched nothing'
|
||||||
106
tests/test_plugins/test_knowledgebase_visibility.py
Normal file
106
tests/test_plugins/test_knowledgebase_visibility.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""What a knowledge base article's topic decides about seeing the article.
|
||||||
|
|
||||||
|
An article's topic is an Application. When that application is retired the
|
||||||
|
article describes something no longer in service, and showing it beside live
|
||||||
|
documentation reads as though it were current - so it does not show at all.
|
||||||
|
|
||||||
|
`isactive` is the ONLY property of an application that decides this. In
|
||||||
|
particular `ishidden`, which governs whether an application appears on the tiles
|
||||||
|
page, says nothing about whether it can be the subject of an article.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Application
|
||||||
|
from plugins.knowledgebase.models import KnowledgeBase
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def library(db):
|
||||||
|
"""One application of each interesting shape, with an article each."""
|
||||||
|
applications = {
|
||||||
|
'live': Application(appname='Live App', isinstallable=True,
|
||||||
|
ishidden=False, isactive=True),
|
||||||
|
'notinstallable': Application(appname='Manual App', isinstallable=False,
|
||||||
|
ishidden=False, isactive=True),
|
||||||
|
'hidden': Application(appname='Hidden App', isinstallable=False,
|
||||||
|
ishidden=True, isactive=True),
|
||||||
|
'retired': Application(appname='Retired App', isinstallable=True,
|
||||||
|
ishidden=False, isactive=False),
|
||||||
|
}
|
||||||
|
db.session.add_all(applications.values())
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
articles = {
|
||||||
|
'live': KnowledgeBase(shortdescription='Live runbook', linkurl='u',
|
||||||
|
appid=applications['live'].appid, clicks=3,
|
||||||
|
isactive=True),
|
||||||
|
'hidden': KnowledgeBase(shortdescription='Hidden runbook', linkurl='u',
|
||||||
|
appid=applications['hidden'].appid, clicks=2,
|
||||||
|
isactive=True),
|
||||||
|
'retired': KnowledgeBase(shortdescription='Retired runbook', linkurl='u',
|
||||||
|
appid=applications['retired'].appid, clicks=5,
|
||||||
|
isactive=True),
|
||||||
|
'topicless': KnowledgeBase(shortdescription='General note', linkurl='u',
|
||||||
|
appid=None, clicks=1, isactive=True),
|
||||||
|
}
|
||||||
|
db.session.add_all(articles.values())
|
||||||
|
db.session.commit()
|
||||||
|
return {'applications': applications, 'articles': articles}
|
||||||
|
|
||||||
|
|
||||||
|
def titles(client, url='/api/knowledgebase'):
|
||||||
|
return sorted(row['shortdescription']
|
||||||
|
for row in client.get(url).get_json()['data'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_article_about_a_retired_application_does_not_show(client, library):
|
||||||
|
assert 'Retired runbook' not in titles(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_article_with_no_topic_still_shows(client, library):
|
||||||
|
"""A null topic is not a retired one. Not every article is about an
|
||||||
|
application, and those must not be collateral damage."""
|
||||||
|
assert 'General note' in titles(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hidden_application_is_still_a_valid_topic(client, library):
|
||||||
|
"""ishidden keeps an application off the tiles page. It says nothing about
|
||||||
|
documentation, and isactive is the only filter that applies here."""
|
||||||
|
assert 'Hidden runbook' in titles(client)
|
||||||
|
|
||||||
|
|
||||||
|
def test_searching_a_retired_application_name_finds_nothing(client, library):
|
||||||
|
"""The topic search matched on application name without checking isactive,
|
||||||
|
which surfaced retired articles and printed the retired application as their
|
||||||
|
subject."""
|
||||||
|
assert titles(client, '/api/knowledgebase?search=Retired App') == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_searching_a_title_does_not_resurrect_a_retired_article(client, library):
|
||||||
|
"""Hiding it from the list and finding it by title would be no rule at all."""
|
||||||
|
found = titles(client, '/api/knowledgebase?search=runbook')
|
||||||
|
assert 'Retired runbook' not in found
|
||||||
|
assert 'Live runbook' in found
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_counts_agree_with_the_list(client, library):
|
||||||
|
"""A total that includes articles nobody can see is a total nobody can
|
||||||
|
reconcile against the page."""
|
||||||
|
visible = titles(client)
|
||||||
|
stats = client.get('/api/knowledgebase/stats').get_json()['data']
|
||||||
|
total = stats.get('total_articles', stats.get('totalarticles'))
|
||||||
|
clicks = stats.get('total_clicks', stats.get('totalclicks'))
|
||||||
|
assert total == len(visible) == 3
|
||||||
|
# 3 + 2 + 1 from the live, hidden-topic and topicless articles; the retired
|
||||||
|
# article's 5 clicks are excluded along with the article.
|
||||||
|
assert clicks == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_topic_list_offers_every_active_application(client, library):
|
||||||
|
"""What the article form's topic dropdown fetches. Installable or not,
|
||||||
|
hidden or not - only retired is excluded."""
|
||||||
|
offered = sorted(row['appname'] for row in client.get(
|
||||||
|
'/api/applications?perpage=100&showhidden=true').get_json()['data'])
|
||||||
|
assert offered == ['Hidden App', 'Live App', 'Manual App']
|
||||||
|
assert 'Retired App' not in offered
|
||||||
@@ -66,7 +66,14 @@ def test_install_list_vendorname_resolves_via_model(client, db, auth_headers,
|
|||||||
|
|
||||||
def test_install_list_text_format(client, db, auth_headers, printer_assettype):
|
def test_install_list_text_format(client, db, auth_headers, printer_assettype):
|
||||||
"""format=text returns one pipe-delimited line per printer (fixed field
|
"""format=text returns one pipe-delimited line per printer (fixed field
|
||||||
order) so the Inno installers split() instead of parsing JSON."""
|
order) so the Inno installers split() instead of parsing JSON.
|
||||||
|
|
||||||
|
FIELD ORDER 0..7 IS FROZEN. The shipped installer reads them by index
|
||||||
|
(`GetField(Line, 6)` for mapx), so a field inserted anywhere but the end
|
||||||
|
silently shifts every later one: the installer would read a model number as
|
||||||
|
a coordinate and still run. levelid is appended as field 8, which installers
|
||||||
|
built before levels existed simply never read.
|
||||||
|
"""
|
||||||
client.post('/api/printers', json={
|
client.post('/api/printers', json={
|
||||||
'assetnumber': 'CSF01-HP', 'windowsname': 'CSF01-HP', 'hostname': 'wjprn01',
|
'assetnumber': 'CSF01-HP', 'windowsname': 'CSF01-HP', 'hostname': 'wjprn01',
|
||||||
'mapx': 120, 'mapy': 240,
|
'mapx': 120, 'mapy': 240,
|
||||||
@@ -80,10 +87,14 @@ def test_install_list_text_format(client, db, auth_headers, printer_assettype):
|
|||||||
assert line is not None
|
assert line is not None
|
||||||
cols = line.split('|')
|
cols = line.split('|')
|
||||||
# printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy
|
# printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy
|
||||||
assert len(cols) == 8
|
# |levelid
|
||||||
|
assert len(cols) == 9
|
||||||
assert cols[1] == 'CSF01-HP' # windowsname (falls back to assetnumber name)
|
assert cols[1] == 'CSF01-HP' # windowsname (falls back to assetnumber name)
|
||||||
assert cols[4] == 'wjprn01' # hostname
|
assert cols[4] == 'wjprn01' # hostname
|
||||||
assert cols[6] == '120' and cols[7] == '240'
|
assert cols[6] == '120' and cols[7] == '240'
|
||||||
|
# levelid last, and empty rather than absent when the printer has no level:
|
||||||
|
# the field count must not vary between rows or a positional split breaks.
|
||||||
|
assert cols[8] == ''
|
||||||
|
|
||||||
|
|
||||||
def test_install_list_excludes_usb_only_printer(client, db, auth_headers,
|
def test_install_list_excludes_usb_only_printer(client, db, auth_headers,
|
||||||
|
|||||||
Reference in New Issue
Block a user