Buildings and levels for the floor map, and make every identifier searchable
Some checks failed
CI / backend (push) Failing after 9s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

The map was one picture of one floor. A second floor was added, the blueprint
changed size, and machines moved, so a position now records WHICH DRAWING its
coordinates belong to.

Buildings and levels (ADR-017). Each level owns its blueprint per theme and its
own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the
site. A position whose level is unknown renders "level unknown" and is never
drawn on the default level, because a marker on the wrong floor plan looks
entirely correct while pointing at the wrong place.

Repositioning in bulk: filter by unplaced, needs-review or level, search, place,
confirm. Landmark recalibration solves the transform PER AXIS from landmark
pairs and never from image dimensions - the canvas grew taller without
rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong
everywhere. It defaults to a dry run, reports what would land off the drawing,
snapshots before applying, and clears mapverifiedat because a transform is a
guess awaiting review. Snapshots restore, including the level and the review
state, and a restore snapshots first so an undo is undoable.

Search: gaugelabreference was matched only for measuring tools and
maintenancereference was matched nowhere at all, for any asset type, while
Settings happily offers both identifiers on machines and PCs. A tag an operator
is told to record has to be findable or it is a write-only field. USB devices
and printed items were unreachable from search entirely - neither is an asset,
so the generic asset search could not see them and no searcher existed; they
now match on serial, asset tag, label, bin code and gage-lab tag, honouring
isactive, with Settings toggles and result labels to match.

The retired-application rule was half a rule: GET /api/knowledgebase hid
articles whose topic application is retired while global search still returned
them and printed the retired application as the subject. A filter is only real
if every path that reaches the row applies it.

Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location
gained levelid, and resolve_asset_position returns the levelid belonging to
whichever source supplied the coordinates. The five plugins that write a map
position are re-pinned. The install-list text format gained levelid as a NINTH
field, appended, because the shipped Pascal installer reads fields 0-7 by index.

That installer still compiles in one drawing's dimensions and bundles one
blueprint, so its map is accurate for the default level only; /api/maplevels is
deliberately unauthenticated so it can read both at runtime once rebuilt.
Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps.

Migration 7d33 converts an existing single-map site into one building and one
default level carrying the old map_* settings, then assigns every placed asset
and location to it. Nothing moves on screen. Old settings rows are kept so a
rollback still finds them. Verified end to end on MySQL 5.6 from a
production-shaped database.
This commit is contained in:
cproudlock
2026-08-17 12:55:51 -04:00
parent 7d9a54ca0f
commit 3324dbd91e
60 changed files with 5313 additions and 895 deletions

View File

@@ -10,6 +10,67 @@ ADR-007 and ADR-002.
## [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
A security release. The Windows installer left the directories it creates

View File

@@ -187,6 +187,29 @@ example of the whole sequence.
For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
### 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, [docs/INSTALL-WINDOWS-IIS.md](docs/INSTALL-WINDOWS-IIS.md)
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
Environment variables (`.env`):

View File

@@ -39,6 +39,7 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.17.0 | Added `DashboardDefault` to the `shopdb.api` surface, so a plugin can resolve a display without reaching into core | additive surface (minor) |
| 0.18.0 | Added `DISPLAY_ROLES`, `DISPLAY_ROLE_PATHS` and `normalize_display_role`, and finally exported `DashboardDefault`, which 0.17.0 imported but left out of `__all__`. The role vocabulary became the kiosk's own - `Dashboard`, `Lobby`, `3DPrintRoom` - so a plugin holding its own copy of that map reads core's instead of drifting from it | additive surface (minor) |
| 0.19.0 | **BREAKING.** `get_dashboard_widgets` returns DATA AND SHAPE, not a component name. The old shape (`name` + `component` + `size`) named a Vue component per widget, which cannot survive a lean build - a plugin's component may never be staged into the frontend bundle (ADR-013) - and five plugins were declaring widgets that pointed at components nobody had written. A card now declares `id` / `title` / `endpoint` / `render` / `severity` / `permission` / `empty` / `position`; see PLUGIN-HOOKS.md. Also added `send_upload` so a plugin serving user-supplied bytes gets the headers that keep an SVG from running as script | **contract change (minor, pre-1.0)** |
| 0.20.0 | The `Asset` model gained `levelid` (which floor plan its `mapx`/`mapy` are pixels of) and `mapverifiedat`; `Location` gained `levelid` too, and `resolve_asset_position` now returns the `levelid` belonging to whichever source supplied the coordinates. A plugin that writes a map position MUST write the level with it - a position without one renders as "level unknown" rather than being drawn on the default drawing, because a marker on the wrong floor plan looks correct (ADR-017) | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
0.11.0, 0.16.0, 0.18.0 and 0.19.0; 0.12.0 through 0.15.0 and 0.17.0 are recorded

View File

@@ -46,19 +46,30 @@ Fields per row:
| `installpath` | Installer path for this printer (see install-batch). |
| `iscsf` | CSF flag. |
| `locationname` | Location name, if the asset has one. |
| `mapx` / `mapy` | Floor-map hotspot position. |
| `mapx` / `mapy` | Floor-map hotspot position, in the native pixels of `levelid`. |
| `levelid` | Which level (drawing) those pixels belong to. Null when unplaced. |
`?format=text` returns a pipe-delimited line per printer, one printer per line,
with a fixed field order so the Inno / Pascal installer does a `split()` instead
of parsing JSON:
```
printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy
printerid|windowsname|vendorname|modelnumber|hostname|ipaddress|mapx|mapy|levelid
```
Any pipe or newline inside a value is neutralized to a space so the field count
stays fixed. The web map uses the default JSON.
**Fields 0-7 are frozen.** The shipped installer reads them positionally
(`GetField(Line, 6)` is mapx), so a new field goes on the END and nowhere else -
inserting one shifts every later field and the installer keeps running while
reading a model number as a coordinate. `levelid` is field 8 for that reason, and
installers built before levels existed ignore it.
An installer that ignores `levelid` draws every printer on whichever single
blueprint it ships, which is correct only while a site has one level. See
ADR-017 and section 6.
---
## 3. `GET /api/printers/pc-default?machine=NNNN`
@@ -102,3 +113,27 @@ name / number.
The `common` scope's `printer map` manifest entry (see `GE-ENFORCE-DISPLAY.md`)
lays down the signed installer that consumes these endpoints. The web map page
covers the same install flow for a human at a browser.
---
## 6. The shipped installer predates levels (known gap)
The Pascal installer in the `inno` repo (`PrinterInstallerMap`) still assumes one
drawing per site, in two places that must change together:
- `MAP_SOURCE_W = 3300` / `MAP_SOURCE_H = 2550` are compiled-in constants, and
every hotspot is scaled by them. They are the dimensions of ONE level.
- A downsized copy of that level's blueprint is bundled into the installer
(`880x680`), so the picture is fixed at build time.
Two consequences, neither of which the installer can detect:
1. It ignores field 8, so printers on any level are drawn on the bundled image.
Coordinates from a different drawing land somewhere plausible and wrong.
2. When a level's blueprint is replaced with one of different dimensions, the
constants and the bundled image are both stale and every hotspot shifts.
The API side is ready: `GET /api/maplevels` is deliberately unauthenticated so
the installer can fetch level dimensions and blueprint URLs at runtime rather
than compiling them in, which is what fixes both. Until the installer is
rebuilt against it, treat its map as accurate for the default level only.

View File

@@ -12,7 +12,7 @@ never by editing this file.
| series | value | governed by |
|---|---|---|
| product `__version__` | `0.10.0` | ADR-007 |
| plugin contract `__contract_version__` | `0.19.0` | ADR-002 |
| plugin contract `__contract_version__` | `0.20.0` | ADR-002 |
They move independently. A contract bump is not a release.
@@ -23,7 +23,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| chain | head |
|---|---|
| core | `7d32_displayrole_kiosk_vocabulary` |
| core | `7d33_buildings_and_levels` |
| backups | `backups0003clearlastseen` |
| computers | `computers0001anchor` |
| employees | `employees0002photo` |
@@ -44,16 +44,16 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
| plugin | version | core_version | owns migrations |
|---|---|---|---|
| backups | 1.0.0 | >=0.16.0,<1.0.0 | yes |
| computers | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| computers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| employees | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| geenforce | 0.1.0 | >=0.18.0,<1.0.0 | yes |
| knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| machines | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| measuringtools | 1.0.0 | >=0.6.0,<1.0.0 | yes |
| network | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| machines | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| measuringtools | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| network | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| notifications | 1.0.0 | >=0.1.0,<1.0.0 | yes |
| printedparts | 0.1.0 | >=0.16.0,<1.0.0 | yes |
| printers | 1.0.0 | >=0.16.0,<1.0.0 | yes |
| printers | 1.0.0 | >=0.20.0,<1.0.0 | yes |
| slides | 2.0.0 | >=0.2.0,<1.0.0 | yes |
| tools | 1.0.0 | >=0.16.0,<1.0.0 | no |
| usb | 1.0.0 | >=0.1.0,<1.0.0 | yes |
@@ -81,9 +81,10 @@ Manifest-less directories under `plugins/` are core frontend surface and always
| ADR-014-schema-lean-per-site.md | ADR-014: Schema-lean per-site builds (retire cross-plugin FKs, lift plugin tables) | ACCEPTED |
| ADR-015-site-specific-configuration.md | ADR-015: Where a site's own data is allowed to live | ACCEPTED |
| ADR-016-credential-delivery.md | ADR-016: Credential delivery to the fleet | ACCEPTED (decided; NOT yet implemented - |
| ADR-017-buildings-and-levels.md | ADR-017: Buildings and levels as the map model | ACCEPTED |
## Size
- test functions defined: **1050** (parametrised cases collect higher)
- documented API paths: **265** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
- test functions defined: **1084** (parametrised cases collect higher)
- documented API paths: **276** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -0,0 +1,143 @@
# ADR-017: Buildings and levels as the map model
- Status: ACCEPTED
- Date: 2026-08-17
- Deciders: ShopDB maintainers
- Relates to: ADR-001 (asset as the platform contract), ADR-004 (per-site
instances), ADR-010 (frontend plugin hooks), ADR-015 (site-specific
configuration)
- Supersedes: the site-wide `map_blueprint_light` / `map_blueprint_dark` /
`map_width` / `map_height` settings
## Context
A site's floor map is one image. Four settings describe it - two blueprints
(light and dark) and the native pixel width and height - and `assets.mapx` /
`assets.mapy` are absolute pixel coordinates in that image's space.
That model has one floor in one building, which was true when it was written and
is no longer. The reference site is adding a second level, and a second building
is likely within a year or two. The immediate trigger is a new blueprint at
3308x4000 where the old one was 3300x2550.
The obvious cheap answer is to stack levels on one canvas: draw the second level
below the first and keep one image. It works, and it was seriously considered.
It was rejected because it makes the level an inference rather than a fact:
- "Which level is this asset on" becomes `mapy > 2550`. Every per-level filter,
count, report and export has to know that constant, and re-exporting the
drawing at a different height silently changes the answer everywhere. Not
broken - wrong, which is worse.
- Distance between two markers becomes computable and meaningless, so anything
doing nearest-asset or clustering quietly answers nonsense across levels.
- One canvas forces one scale. A mezzanine drawn at a different scale than the
floor below cannot be expressed at all.
- 3308x4000 is portrait for a building whose floor is landscape. At a zoom where
a marker is clickable, roughly half a level is visible, and "fit to level" is
not expressible.
- A third level makes each of these worse, and adds a second threshold.
Timing decided it. Because the level layout changed and machines moved, every
marker is going to be repositioned anyway. Introducing levels now costs one pass
over the positions; stacking now and splitting later costs two, and in between
every position placed encodes the threshold into real data - which a later
migration would then have to un-guess by comparing Y against it.
## Decision
Two tables, and assets reference the level.
```
buildings buildingid, buildingname, sortorder, isactive
maplevels levelid, buildingid, levelname, sortorder,
blueprintlight, blueprintdark, mapwidth, mapheight,
isdefault, isactive
assets levelid (nullable; the default level for existing rows)
```
**1. An asset references the level, never the building.** The building derives
from the level, so the two cannot disagree. Storing both would be a fact
recorded twice with no difference in granularity to justify it.
**2. Blueprints and native dimensions belong to the level.** This is what the
site-wide settings could not express: two levels in one building may be drawn at
different sizes and scales, and two buildings certainly are.
**3. Name and order are separate.** `levelname` is text and `sortorder` is an
integer. Levels are not reliably numbered - basement, ground, mezzanine, roof,
tunnel - and sort order gives adjacency and up/down navigation without
pretending the names are ordinal. It also lets a mezzanine be inserted between
two existing levels without renumbering anything.
**4. `mapx` / `mapy` keep their meaning, scoped to the level.** They stay
absolute pixels in the native coordinate space of the level's blueprint. No
normalisation to fractions: pixels are what the drawing tools produce, what an
operator can read off an image, and what the existing data already holds.
**5. A level is required to render a position, and absence is not a default.**
Where a payload carries a position without a level, the UI renders "level
unknown" rather than falling back to the default level. Falling back draws one
building's ground floor with a marker positioned for another building's
mezzanine: it renders perfectly and points at the wrong place, and nothing about
the result looks wrong. A visible gap is worth more than a confident wrong
answer.
**6. The level list is readable without authentication.** The printer installer
map runs optional-auth, before anyone logs in, and it needs a blueprint. This
follows the precedent already set for printer install-list and the slide feed.
Blueprint paths and level names are not secrets; the positions of assets on them
already render on public kiosk pages.
**7. Levels are not Locations.** Assets carry `locationid` already, and reusing
it is tempting. A Location answers "which operation owns this"; a level answers
"which drawing renders it, at what native size". Overloading Location with
blueprint images and pixel dimensions makes both concepts worse. They coexist:
an asset on level 2 in operation 0613.
## Migration
The existing four settings become one building and one level, marked default,
and every asset with a position points at it. Nothing renders differently on the
day it lands. The settings keys are then retired rather than left as a second
source of truth that can disagree with the rows.
## Consequences
Positive:
- A second building costs a row and a blueprint. So does a third level.
- Per-level export, per-level zoom, and per-level calibration all become
expressible. Calibrating one level against its own landmarks is more correct
than transforming a whole site at once.
- Level becomes queryable - counts and filters per level or per building are
ordinary queries rather than coordinate arithmetic.
Negative:
- Every surface that draws a marker learns there is more than one drawing. That
is two components, seven views, four asset position pickers, the printer
installer map, the PDF export, and eight API payloads that must emit `levelid`
beside `mapx`. A payload that forgets it produces the silent-wrong-map failure
described above, which is why rule 5 exists and why a build gate checks that
the two fields travel together.
- `get_map_overlays` gains level context, so this is a plugin contract change
(0.20.0) and plugins rendering overlays need to declare which level they are
for.
- One more admin surface: levels and buildings have to be managed somewhere, and
the existing floor-map settings page becomes that.
## Alternatives considered
**Stack levels on one canvas.** Rejected above. Zero cost today, and it makes
the level an inference over a magic number.
**Normalise coordinates to fractions of the image.** Would make a rescaled
blueprint self-correcting, which is genuinely attractive. Rejected for now
because it converts every existing integer position through a lossy division,
and because it does not help the actual problem: the levels changed and machines
moved, so the positions need human review regardless. Worth revisiting
independently.
**A `level` string on the asset, with one blueprint per level in settings.**
Rejected: settings keyed by level name is a table with extra steps, and it gives
no place for per-level dimensions or ordering.

View File

@@ -346,7 +346,7 @@
{
"method": "GET",
"path": "/api/search",
"purpose": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search_<type>_enabled settings",
"purpose": "Global search across assets (including the gaugelabreference and maintenancereference identifiers), applications, KB, employees, USB devices, printed items, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search_<type>_enabled settings. Retired (isactive=0) rows are excluded everywhere, including KB articles whose topic application is retired",
"auth": "jwt-optional",
"params": "q (required, 2-200 chars)",
"example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'"
@@ -3232,5 +3232,122 @@
"example": "curl -I http://localhost:5001/api/docs/redoc.standalone.js"
}
]
},
{
"surface": "core-map",
"endpoints": [
{
"method": "GET",
"path": "/api/maplevels",
"auth": "none",
"params": "none",
"purpose": "Every building with its levels in display order, each carrying blueprint paths, native pixel size and marker count, plus defaultlevelid. PUBLIC: the printer installer map draws a blueprint before anyone logs in",
"example": "curl http://localhost:5001/api/maplevels"
},
{
"method": "GET",
"path": "/api/maplevels/<levelid>",
"auth": "none",
"params": "levelid in path",
"purpose": "One level: name, building, blueprints and native size. That size is what mapx/mapy on this level are pixels of (ADR-017)",
"example": "curl http://localhost:5001/api/maplevels/2"
},
{
"method": "GET",
"path": "/api/maplevels/<levelid>/blueprint/<filename>",
"auth": "none",
"params": "levelid and filename in path",
"purpose": "Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script",
"example": "curl -I http://localhost:5001/api/maplevels/2/blueprint/level-2-light.png"
},
{
"method": "POST",
"path": "/api/maplevels/buildings",
"auth": "jwt + role:admin",
"params": "body: buildingname (required), sortorder",
"purpose": "Create a building. An asset references the level, never the building, so the two cannot disagree",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"buildingname\":\"Annex\"}' -X POST http://localhost:5001/api/maplevels/buildings"
},
{
"method": "PUT|PATCH",
"path": "/api/maplevels/buildings/<buildingid>",
"auth": "jwt + role:admin",
"params": "buildingid in path; body: buildingname, sortorder, isactive",
"purpose": "Rename or reorder a building. Levels move with it and nothing repositions",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/buildings/1"
},
{
"method": "POST",
"path": "/api/maplevels",
"auth": "jwt + role:admin",
"params": "body: buildingid and levelname (required), sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault",
"purpose": "Create a level. Name and sort order are separate because levels are not reliably numbered (basement, mezzanine, roof), and gaps let one be inserted later without renumbering",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -X POST http://localhost:5001/api/maplevels"
},
{
"method": "PUT|PATCH",
"path": "/api/maplevels/<levelid>",
"auth": "jwt + role:admin",
"params": "levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive",
"purpose": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate space every marker is expressed in, so a resize moves them all relative to the drawing - use a landmark transform instead",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/1"
},
{
"method": "DELETE",
"path": "/api/maplevels/<levelid>",
"auth": "jwt + role:admin",
"params": "levelid in path",
"purpose": "Deactivate a level. Refused with 409 while assets are placed on it, and refused for the default level: deleting a drawing out from under a marker leaves a position in a coordinate space that no longer exists",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -X DELETE http://localhost:5001/api/maplevels/3"
},
{
"method": "POST",
"path": "/api/maplevels/<levelid>/blueprint",
"auth": "jwt + role:admin",
"params": "levelid in path; multipart/form-data: file=<image>, theme=light|dark",
"purpose": "Upload a level's blueprint. Reads the image's real pixel size from its header and adopts it when the level is EMPTY; with markers already placed it reports the mismatch and changes nothing, because adopting a new coordinate space silently moves every marker while looking like a successful upload",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -F file=@second-floor.png -F theme=light -X POST http://localhost:5001/api/maplevels/2/blueprint"
},
{
"method": "POST",
"path": "/api/mappositions/transform",
"auth": "permission:assets.edit",
"params": "body: levelid (required), landmarks [{fromx,fromy,tox,toy}] (two or more), tolevelid, assetids, dryrun (defaults TRUE)",
"purpose": "Move every placed marker on a level by a transform derived per axis from landmark pairs. NEVER from image dimensions: a level added below another changes canvas height without rescaling anything, and a dimension-derived scale would stretch Y and be wrong everywhere. Dry run returns every old and new position plus which land outside the target. Applying snapshots first and clears mapverifiedat, because a transformed position is a guess awaiting review",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"levelid\":1,\"landmarks\":[{\"fromx\":0,\"fromy\":0,\"tox\":0,\"toy\":1450},{\"fromx\":1000,\"fromy\":1000,\"tox\":1000,\"toy\":2450}]}' -X POST http://localhost:5001/api/mappositions/transform"
},
{
"method": "POST",
"path": "/api/mappositions/positions",
"auth": "permission:assets.edit",
"params": "body: positions [{assetid, mapx, mapy, levelid}] - levelid required per row - and verified",
"purpose": "Set many positions at once, snapshotting first. levelid is per position rather than per request because a bulk save can span levels and inferring it is the guess this model exists to remove. Placing by hand counts as review, so mapverifiedat is stamped",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"positions\":[{\"assetid\":42,\"mapx\":1200,\"mapy\":900,\"levelid\":2}]}' -X POST http://localhost:5001/api/mappositions/positions"
},
{
"method": "POST",
"path": "/api/mappositions/verify",
"auth": "permission:assets.edit",
"params": "body: assetids (required), unverify",
"purpose": "Mark markers reviewed against the current drawing without moving them - the common case in a review pass. No snapshot, because no position changes",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetids\":[42,43]}' -X POST http://localhost:5001/api/mappositions/verify"
},
{
"method": "GET",
"path": "/api/mappositions/snapshots",
"auth": "permission:assets.view",
"params": "none; newest 50",
"purpose": "Position snapshots, newest first, with what caused each and whether it has been restored. Metadata only - the positions are large",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/mappositions/snapshots"
},
{
"method": "POST",
"path": "/api/mappositions/snapshots/<snapshotid>/restore",
"auth": "permission:assets.edit",
"params": "snapshotid in path",
"purpose": "Put a snapshot back, snapshotting first so an undo is itself undoable. Restores level and review state, not just coordinates, and reports assets that no longer exist rather than failing the whole restore",
"example": "curl -H \"Authorization: Bearer $TOKEN\" -X POST http://localhost:5001/api/mappositions/snapshots/7/restore"
}
]
}
]

View File

@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "ShopDB Flask API",
"version": "0.9.0",
"version": "0.10.0",
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
},
"servers": [
@@ -173,6 +173,9 @@
},
{
"name": "core-docs"
},
{
"name": "core-map"
}
],
"paths": {
@@ -2061,8 +2064,8 @@
"tags": [
"core-platform"
],
"summary": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets...",
"description": "Global search across assets, applications, KB, employees, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search_<type>_enabled settings\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search?q=WKSTN0042'\n```",
"summary": "Global search across assets (including the gaugelabreference and maintenancereference identifiers), applications, KB...",
"description": "Global search across assets (including the gaugelabreference and maintenancereference identifiers), applications, KB, employees, USB devices, printed items, notifications, custom fields, hostnames, IPs/subnets, vendor/model/type; ServiceNOW ticket prefixes and smart redirects; results capped at 50, types filterable via search_<type>_enabled settings. Retired (isactive=0) rows are excluded everywhere, including KB articles whose topic application is retired\n\n**Auth:** jwt-optional\n\n**Params:** q (required, 2-200 chars)\n\n**Example:**\n```\ncurl 'http://localhost:5001/api/search?q=WKSTN0042'\n```",
"security": [
{},
{
@@ -18539,6 +18542,831 @@
}
}
}
},
"/api/maplevels": {
"get": {
"tags": [
"core-map"
],
"summary": "Every building with its levels in display order, each carrying blueprint paths, native pixel size and marker count...",
"description": "Every building with its levels in display order, each carrying blueprint paths, native pixel size and marker count, plus defaultlevelid. PUBLIC: the printer installer map draws a blueprint before anyone logs in\n\n**Auth:** none\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels\n```",
"security": [],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
}
}
},
"post": {
"tags": [
"core-map"
],
"summary": "Create a level. Name and sort order are separate because levels are not reliably numbered (basement, mezzanine, roof)...",
"description": "Create a level. Name and sort order are separate because levels are not reliably numbered (basement, mezzanine, roof), and gaps let one be inserted later without renumbering\n\n**Auth:** jwt + role:admin\n\n**Params:** body: buildingid and levelname (required), sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X POST http://localhost:5001/api/maplevels\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: buildingid and levelname (required), sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault"
}
}
}
}
}
},
"/api/maplevels/{levelid}": {
"get": {
"tags": [
"core-map"
],
"summary": "One level: name, building, blueprints and native size. That size is what mapx/mapy on this level are pixels of (ADR-017)",
"description": "One level: name, building, blueprints and native size. That size is what mapx/mapy on this level are pixels of (ADR-017)\n\n**Auth:** none\n\n**Params:** levelid in path\n\n**Example:**\n```\ncurl http://localhost:5001/api/maplevels/2\n```",
"security": [],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "levelid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"put": {
"tags": [
"core-map"
],
"summary": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate...",
"description": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate space every marker is expressed in, so a resize moves them all relative to the drawing - use a landmark transform instead\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/1\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "levelid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive"
}
}
}
}
},
"patch": {
"tags": [
"core-map"
],
"summary": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate...",
"description": "Update a level. Changing mapwidth/mapheight returns a warning naming what it affects: the dimensions are the coordinate space every marker is expressed in, so a resize moves them all relative to the drawing - use a landmark transform instead\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/1\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "levelid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "levelid in path; body: levelname, sortorder, blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, isactive"
}
}
}
}
},
"delete": {
"tags": [
"core-map"
],
"summary": "Deactivate a level. Refused with 409 while assets are placed on it, and refused for the default level: deleting a...",
"description": "Deactivate a level. Refused with 409 while assets are placed on it, and refused for the default level: deleting a drawing out from under a marker leaves a position in a coordinate space that no longer exists\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X DELETE http://localhost:5001/api/maplevels/3\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "levelid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/maplevels/{levelid}/blueprint/{filename}": {
"get": {
"tags": [
"core-map"
],
"summary": "Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script",
"description": "Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script\n\n**Auth:** none\n\n**Params:** levelid and filename in path\n\n**Example:**\n```\ncurl -I http://localhost:5001/api/maplevels/2/blueprint/level-2-light.png\n```",
"security": [],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "levelid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "filename",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/api/maplevels/buildings": {
"post": {
"tags": [
"core-map"
],
"summary": "Create a building. An asset references the level, never the building, so the two cannot disagree",
"description": "Create a building. An asset references the level, never the building, so the two cannot disagree\n\n**Auth:** jwt + role:admin\n\n**Params:** body: buildingname (required), sortorder\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"buildingname\":\"Annex\"}' -X POST http://localhost:5001/api/maplevels/buildings\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: buildingname (required), sortorder"
}
}
}
}
}
},
"/api/maplevels/buildings/{buildingid}": {
"put": {
"tags": [
"core-map"
],
"summary": "Rename or reorder a building. Levels move with it and nothing repositions",
"description": "Rename or reorder a building. Levels move with it and nothing repositions\n\n**Auth:** jwt + role:admin\n\n**Params:** buildingid in path; body: buildingname, sortorder, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/buildings/1\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "buildingid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "buildingid in path; body: buildingname, sortorder, isactive"
}
}
}
}
},
"patch": {
"tags": [
"core-map"
],
"summary": "Rename or reorder a building. Levels move with it and nothing repositions",
"description": "Rename or reorder a building. Levels move with it and nothing repositions\n\n**Auth:** jwt + role:admin\n\n**Params:** buildingid in path; body: buildingname, sortorder, isactive\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X PATCH http://localhost:5001/api/maplevels/buildings/1\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "buildingid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "buildingid in path; body: buildingname, sortorder, isactive"
}
}
}
}
}
},
"/api/maplevels/{levelid}/blueprint": {
"post": {
"tags": [
"core-map"
],
"summary": "Upload a level's blueprint. Reads the image's real pixel size from its header and adopts it when the level is EMPTY...",
"description": "Upload a level's blueprint. Reads the image's real pixel size from its header and adopts it when the level is EMPTY; with markers already placed it reports the mismatch and changes nothing, because adopting a new coordinate space silently moves every marker while looking like a successful upload\n\n**Auth:** jwt + role:admin\n\n**Params:** levelid in path; multipart/form-data: file=<image>, theme=light|dark\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -F file=@second-floor.png -F theme=light -X POST http://localhost:5001/api/maplevels/2/blueprint\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "levelid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "levelid in path; multipart/form-data: file=<image>, theme=light|dark"
}
}
}
}
}
},
"/api/mappositions/transform": {
"post": {
"tags": [
"core-map"
],
"summary": "Move every placed marker on a level by a transform derived per axis from landmark pairs. NEVER from image dimensions: a...",
"description": "Move every placed marker on a level by a transform derived per axis from landmark pairs. NEVER from image dimensions: a level added below another changes canvas height without rescaling anything, and a dimension-derived scale would stretch Y and be wrong everywhere. Dry run returns every old and new position plus which land outside the target. Applying snapshots first and clears mapverifiedat, because a transformed position is a guess awaiting review\n\n**Auth:** permission:assets.edit\n\n**Params:** body: levelid (required), landmarks [{fromx,fromy,tox,toy}] (two or more), tolevelid, assetids, dryrun (defaults TRUE)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"levelid\":1,\"landmarks\":[{\"fromx\":0,\"fromy\":0,\"tox\":0,\"toy\":1450},{\"fromx\":1000,\"fromy\":1000,\"tox\":1000,\"toy\":2450}]}' -X POST http://localhost:5001/api/mappositions/transform\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: levelid (required), landmarks [{fromx,fromy,tox,toy}] (two or more), tolevelid, assetids, dryrun (defaults TRUE)"
}
}
}
}
}
},
"/api/mappositions/positions": {
"post": {
"tags": [
"core-map"
],
"summary": "Set many positions at once, snapshotting first. levelid is per position rather than per request because a bulk save can...",
"description": "Set many positions at once, snapshotting first. levelid is per position rather than per request because a bulk save can span levels and inferring it is the guess this model exists to remove. Placing by hand counts as review, so mapverifiedat is stamped\n\n**Auth:** permission:assets.edit\n\n**Params:** body: positions [{assetid, mapx, mapy, levelid}] - levelid required per row - and verified\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"positions\":[{\"assetid\":42,\"mapx\":1200,\"mapy\":900,\"levelid\":2}]}' -X POST http://localhost:5001/api/mappositions/positions\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: positions [{assetid, mapx, mapy, levelid}] - levelid required per row - and verified"
}
}
}
}
}
},
"/api/mappositions/verify": {
"post": {
"tags": [
"core-map"
],
"summary": "Mark markers reviewed against the current drawing without moving them - the common case in a review pass. No snapshot...",
"description": "Mark markers reviewed against the current drawing without moving them - the common case in a review pass. No snapshot, because no position changes\n\n**Auth:** permission:assets.edit\n\n**Params:** body: assetids (required), unverify\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"assetids\":[42,43]}' -X POST http://localhost:5001/api/mappositions/verify\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: assetids (required), unverify"
}
}
}
}
}
},
"/api/mappositions/snapshots": {
"get": {
"tags": [
"core-map"
],
"summary": "Position snapshots, newest first, with what caused each and whether it has been restored. Metadata only - the positions...",
"description": "Position snapshots, newest first, with what caused each and whether it has been restored. Metadata only - the positions are large\n\n**Auth:** permission:assets.view\n\n**Params:** none; newest 50\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/mappositions/snapshots\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/mappositions/snapshots/{snapshotid}/restore": {
"post": {
"tags": [
"core-map"
],
"summary": "Put a snapshot back, snapshotting first so an undo is itself undoable. Restores level and review state, not just...",
"description": "Put a snapshot back, snapshotting first so an undo is itself undoable. Restores level and review state, not just coordinates, and reports assets that no longer exist rather than failing the whole restore\n\n**Auth:** permission:assets.edit\n\n**Params:** snapshotid in path\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" -X POST http://localhost:5001/api/mappositions/snapshots/7/restore\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "snapshotid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "snapshotid in path"
}
}
}
}
}
}
}
}

View File

@@ -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 = {
list(params = {}) {
return api.get('/settings', { params })

View File

@@ -1,141 +1,148 @@
<template>
<div class="embedded-map" ref="mapContainer"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
markerColor: { type: String, default: '#ff0000' },
markerLabel: { type: String, default: '' }
})
const mapContainer = ref(null)
let map = null
let marker = null
// Map dimensions - facility blueprint size, loaded from settings.
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
maxZoom: 2,
attributionControl: false,
zoomControl: true
})
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
const leafletX = props.left
// Create marker
const icon = L.divIcon({
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: 'location-marker'
})
marker = L.marker([leafletY, leafletX], { icon })
if (props.markerLabel) {
marker.bindTooltip(props.markerLabel, {
permanent: true,
direction: 'top',
offset: [0, -10],
className: 'location-label'
})
}
marker.addTo(map)
// Center on marker with appropriate zoom
map.setView([leafletY, leafletX], -1)
map.setMaxBounds(bounds)
}
onMounted(async () => {
await loadMapConfig()
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
watch([() => props.left, () => props.top], () => {
if (map) {
map.remove()
map = null
}
initMap()
})
</script>
<style scoped>
.embedded-map {
width: 100%;
height: 300px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
:deep(.location-marker) {
background: transparent !important;
border: none !important;
}
:deep(.location-marker-dot) {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
}
}
:deep(.location-label) {
background: rgba(0, 0, 0, 0.85);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
:deep(.location-label::before) {
border-top-color: rgba(0, 0, 0, 0.85);
}
</style>
<template>
<div class="embedded-map" ref="mapContainer"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel } from '../composables/mapConfig'
const props = defineProps({
left: { type: Number, default: null },
top: { type: Number, default: null },
markerColor: { type: String, default: '#ff0000' },
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)
let map = null
let marker = null
// This LEVEL's native size, which is what its marker coordinates mean.
let MAP_WIDTH = 0
let MAP_HEIGHT = 0
function initMap() {
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
const dimensions = dimensionsFor(props.levelid)
MAP_WIDTH = dimensions.width
MAP_HEIGHT = dimensions.height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
maxZoom: 2,
attributionControl: false,
zoomControl: true
})
L.imageOverlay(blueprintUrlFor(currentTheme.value, props.levelid), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
const leafletX = props.left
// Create marker
const icon = L.divIcon({
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
className: 'location-marker'
})
marker = L.marker([leafletY, leafletX], { icon })
if (props.markerLabel) {
marker.bindTooltip(props.markerLabel, {
permanent: true,
direction: 'top',
offset: [0, -10],
className: 'location-label'
})
}
marker.addTo(map)
// Center on marker with appropriate zoom
map.setView([leafletY, leafletX], -1)
map.setMaxBounds(bounds)
}
onMounted(async () => {
await loadMapConfig()
initMap()
})
onUnmounted(() => {
if (map) {
map.remove()
map = null
}
})
watch([() => props.left, () => props.top], () => {
if (map) {
map.remove()
map = null
}
initMap()
})
</script>
<style scoped>
.embedded-map {
width: 100%;
height: 300px;
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
:deep(.location-marker) {
background: transparent !important;
border: none !important;
}
:deep(.location-marker-dot) {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
}
50% {
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
}
}
:deep(.location-label) {
background: rgba(0, 0, 0, 0.85);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.875rem;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
:deep(.location-label::before) {
border-top-color: rgba(0, 0, 0, 0.85);
}
</style>

View File

@@ -12,7 +12,15 @@
@wheel.prevent="onWheel"
>
<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
class="map-transform"
:style="transformStyle"
@@ -43,7 +51,7 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
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
// when it loads.
@@ -52,7 +60,11 @@ loadMapConfig()
const props = defineProps({
left: { 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)
@@ -67,18 +79,29 @@ const hasPosition = computed(() => {
return props.left !== null && props.top !== null
})
const blueprintUrl = computed(() => {
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value)
// A position we cannot place: coordinates but no level, or a level this instance
// does not know. Rendering the default blueprint here would look correct and be
// 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(() => {
return (props.left / mapConfig.width) * 100
return (props.left / dimensionsFor(props.levelid).width) * 100
})
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
@@ -197,6 +220,20 @@ watch(currentTheme, () => {
.location-tooltip-wrapper:hover {
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>

View File

@@ -95,7 +95,7 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import L from 'leaflet'
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 { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
import api from '../api'
@@ -111,7 +111,11 @@ const props = defineProps({
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
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'])
@@ -139,10 +143,15 @@ const filters = ref({
search: ''
})
// Map dimensions - facility blueprint size, loaded from settings before
// initMap runs (mutable so the loaded values replace the fallback defaults).
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
// The drawn level's native size, which is what its marker coordinates mean.
// Mutable because the levels load after this module does, and because switching
// level changes them.
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
const assetTypeColorsMap = {
@@ -279,7 +288,7 @@ function initMap() {
})
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)
// Set initial view - zoom out to show full floor plan
@@ -584,7 +593,7 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => {
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.
renderMarkers()
}
@@ -594,8 +603,8 @@ onMounted(async () => {
// Load this facility's blueprint + dimensions before building the map so
// bounds and coordinate math use the right size. Falls back to defaults.
await loadMapConfig()
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
MAP_WIDTH = dimensionsFor(drawnLevelId()).width
MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
initMap()
loadOverlays()
})

View File

@@ -1,42 +1,59 @@
// Facility floor-map blueprint config, read from the settings table so each
// 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.
// Missing keys fall back to the generic placeholder so a fresh or offline
// install still renders; each site uploads its own blueprint in Settings.
// Which drawing renders a marker, and at what native size (ADR-017).
//
// This used to hold ONE blueprint and ONE pixel size, read from four settings,
// because a site had one floor map. It now holds every level of every building,
// 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 { settingsApi } from '../api'
import { mapLevelsApi } from '../api'
import { withBase } from '../utils/basePath'
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
const DEFAULTS = {
blueprintLight: '/static/images/floorplan-placeholder.svg',
blueprintDark: '/static/images/floorplan-placeholder.svg',
width: 3300,
height: 2550
}
// Used until the levels load, and on a fresh install with none configured, so a
// map still draws something rather than breaking.
const PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
const FALLBACK_WIDTH = 3300
const FALLBACK_HEIGHT = 2550
// Shared reactive config. Import as `state` to read width/height/blueprint.
export const state = reactive({ ...DEFAULTS, loaded: false })
export const state = reactive({
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
function applySetting(key, value) {
if (value === null || value === undefined || value === '') return
if (key === 'map_blueprint_light') state.blueprintLight = value
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 levelFor(levelid) {
if (levelid === null || levelid === undefined) return null
return state.levels[levelid] || null
}
function fetchConfig() {
inflight = settingsApi.list({ category: 'map' })
function fetchLevels() {
inflight = mapLevelsApi.list()
.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
})
.catch(() => { state.loaded = true })
@@ -44,26 +61,95 @@ function fetchConfig() {
return inflight
}
// Fetch the map config once (shared across all map components). Returns a
// promise that resolves when state is populated, so a caller can await it
// before initializing a Leaflet map that needs the dimensions.
// Fetch once, shared across every map component. Await it before initialising a
// Leaflet map, which needs the dimensions to set its bounds.
export function loadMapConfig() {
if (state.loaded) return Promise.resolve()
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() {
return fetchConfig()
return fetchLevels()
}
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
export function setCurrentLevel(levelid) {
if (state.levels[levelid]) state.currentlevelid = levelid
}
/**
* 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() {
loadMapConfig()
return { state, blueprintUrlFor }
return {
state,
blueprintUrlFor,
dimensionsFor,
hasLevel,
levelName,
levelOptions,
setCurrentLevel,
}
}

View 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)
})
})

View File

@@ -52,7 +52,9 @@ export const searchDomains = [
{ key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' },
{ 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() {

View File

@@ -3,6 +3,8 @@
<div class="page-header">
<h2>Map Editor</h2>
<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>
</div>
</div>
@@ -12,6 +14,13 @@
<div class="asset-panel">
<div class="panel-header">
<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">
<option value="">All Types</option>
<option value="machine">Machines</option>
@@ -25,7 +34,15 @@
<div class="asset-filter">
<label class="filter-checkbox">
<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>
</div>
@@ -47,6 +64,15 @@
<div class="asset-meta">
<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.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>
@@ -96,19 +122,114 @@
:theme="currentTheme"
:pickerMode="!!selectedAsset"
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
:levelid="editingLevelId"
@positionPicked="handlePositionPicked"
@markerClick="handleMarkerClick"
/>
</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>
</template>
<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 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 { useToast } from '../composables/toast'
const toast = useToast()
@@ -119,6 +240,25 @@ const selectedAsset = ref(null)
const pickedPosition = ref(null)
const filterType = ref('')
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(() => {
let result = assets.value
@@ -131,17 +271,48 @@ const filteredAssets = computed(() => {
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
})
// Only markers on the level being drawn. Markers from another level would appear
// at coordinates that mean nothing on this one.
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 () => {
await loadMapConfig()
editingLevelId.value = mapConfig.defaultlevelid
await loadAssets()
})
watch(editingLevelId, (levelid) => {
if (levelid) setCurrentLevel(levelid)
})
async function loadAssets() {
loading.value = true
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) {
const icons = {
'machine': Cog,
@@ -184,16 +429,23 @@ async function savePosition() {
if (!selectedAsset.value || !pickedPosition.value) return
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),
mapy: Math.round(pickedPosition.value.top)
})
mapy: Math.round(pickedPosition.value.top),
levelid: editingLevelId.value,
}])
// Update local state
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
if (asset) {
asset.mapx = Math.round(pickedPosition.value.left)
asset.mapy = Math.round(pickedPosition.value.top)
asset.levelid = editingLevelId.value
asset.mapverifiedat = new Date().toISOString()
}
selectedAsset.value = null
@@ -409,4 +661,31 @@ function cancelEdit() {
text-align: center;
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>

View File

@@ -82,7 +82,7 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
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 { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { getSubtypeId } from '../utils/mapColors'
@@ -245,9 +245,12 @@ async function exportPdf() {
// blueprintUrlFor applies withBase - the raw setting value is a
// root-relative /api path, which 404s under a subpath mount like /ops.
// The PDF always uses the light blueprint: it prints on white paper.
blueprintUrl: blueprintUrlFor('light'),
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
blueprintUrl: blueprintUrlFor('light', mapConfig.currentlevelid),
mapWidth: dimensionsFor(mapConfig.currentlevelid).width,
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,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,

View File

@@ -1,479 +1,485 @@
<template>
<div class="search-results">
<div class="page-header">
<h2>Search Results</h2>
<span v-if="results.length" class="results-count">
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
</span>
</div>
<div class="search-box">
<input
v-model="searchInput"
type="text"
class="form-control"
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
@keyup.enter="performSearch"
/>
<button class="btn btn-primary" @click="performSearch">Search</button>
</div>
<div v-if="results.length" class="filter-buttons">
<button
v-for="filter in filterList"
:key="filter.key"
class="filter-btn"
:class="{ active: activeFilter === filter.key }"
@click="activeFilter = filter.key"
>
{{ filter.label }}
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query">
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
</div>
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
</div>
<div v-else class="results-list">
<div
v-for="result in filteredResults"
:key="`${result.type}-${result.id}`"
:id="`result-${result.type}-${result.id}`"
class="result-item"
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
>
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
<div class="result-content">
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
{{ result.title }}
</router-link>
<a
v-else
href="#"
class="result-title"
@click.prevent="openKBArticle(result)"
>
{{ result.title }}
</a>
<div class="result-meta">
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
<span v-if="result.location" class="result-location">{{ result.location }}</span>
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
</div>
</div>
<button
class="share-btn"
@click="shareResult(result)"
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
>
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
</button>
</div>
</div>
</template>
<div v-else class="no-results">
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
const typeCounts = ref({})
const totalAll = ref(0)
const highlightId = ref(null)
const copiedId = ref(null)
const typeLabels = {
machine: 'Machine',
pc: 'PC',
computer: 'PC',
application: 'App',
knowledgebase: 'KB',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
employee: 'Employee',
notification: 'Notice',
subnet: 'Subnet'
}
const filterTypeMap = {
all: null,
machines: ['machine'],
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
notifications: ['notification'],
employees: ['employee']
}
const filterList = [
{ key: 'all', label: 'All' },
{ key: 'machines', label: 'Machines' },
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' }
]
function typeLabel(type) {
return typeLabels[type] || type
}
function getFilterCount(filterKey) {
if (filterKey === 'all') return totalAll.value || results.value.length
const types = filterTypeMap[filterKey]
if (!types) return 0
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
}
const filteredResults = computed(() => {
if (activeFilter.value === 'all') return results.value
const types = filterTypeMap[activeFilter.value]
if (!types) return results.value
return results.value.filter(r => types.includes(r.type))
})
async function search(q) {
if (!q || q.length < 2) {
results.value = []
return
}
loading.value = true
activeFilter.value = 'all'
try {
const response = await searchApi.search(q)
const data = response.data.data
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
window.open(data.redirect.url, '_blank')
query.value = q
results.value = []
loading.value = false
return
}
// Smart redirect - auto-navigate to exact match
if (data?.redirect) {
router.replace(data.redirect.url)
return
}
results.value = data?.results || []
typeCounts.value = data?.counts || {}
totalAll.value = data?.total_all || results.value.length
query.value = q
} catch (error) {
console.error('Search error:', error)
results.value = []
} finally {
loading.value = false
}
}
function performSearch() {
if (searchInput.value.trim()) {
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
}
}
async function openKBArticle(result) {
try {
await knowledgebaseApi.trackClick(result.id)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
} catch (error) {
console.error('Error tracking click:', error)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
}
}
function shareResult(result) {
const url = new URL(window.location.href)
url.searchParams.set('highlight', `${result.type}-${result.id}`)
navigator.clipboard.writeText(url.toString()).then(() => {
copiedId.value = `${result.type}-${result.id}`
setTimeout(() => { copiedId.value = null }, 2000)
})
}
function scrollToHighlight() {
if (highlightId.value) {
nextTick(() => {
const el = document.getElementById(`result-${highlightId.value}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightId.value = null }, 3000)
}
})
}
}
onMounted(() => {
const q = route.query.q
const hl = route.query.highlight
if (hl) highlightId.value = hl
if (q) {
searchInput.value = q
search(q)
}
})
watch(() => route.query.q, (newQ) => {
if (newQ) {
searchInput.value = newQ
const hl = route.query.highlight
if (hl) highlightId.value = hl
search(newQ)
}
})
watch(results, () => {
scrollToHighlight()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0;
}
.results-count {
color: var(--text-light);
font-size: 0.9rem;
}
.search-box {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-box input {
flex: 1;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.filter-btn:hover:not(.active) {
border-color: var(--primary);
}
.filter-count {
background: rgba(128, 128, 128, 0.15);
padding: 0.1rem 0.35rem;
border-radius: 8px;
font-size: 0.75rem;
min-width: 1.25rem;
text-align: center;
}
.filter-btn.active .filter-count {
background: rgba(255, 255, 255, 0.25);
}
.no-results {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
transition: background 0.3s ease;
}
.result-item:last-child {
border-bottom: none;
}
.result-item.highlighted {
background: rgba(65, 129, 255, 0.08);
border-left: 3px solid var(--primary);
}
.result-type {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.2rem 0.45rem;
border-radius: 4px;
min-width: 65px;
text-align: center;
flex-shrink: 0;
}
/* Per-domain badge palette. Values live in CSS variables on the container so
the dark theme overrides them in one place (below) instead of restating
every selector. Each badge rule just references its pair. */
.search-results {
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
}
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
.result-type.pc,
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
}
.result-title:hover {
text-decoration: underline;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.result-subtitle {
color: var(--text-light);
}
.result-ticket {
font-family: monospace;
font-size: 0.75rem;
}
.share-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text-light);
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
flex-shrink: 0;
}
.share-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
@media (prefers-color-scheme: dark) {
.search-results {
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
</style>
<template>
<div class="search-results">
<div class="page-header">
<h2>Search Results</h2>
<span v-if="results.length" class="results-count">
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
</span>
</div>
<div class="search-box">
<input
v-model="searchInput"
type="text"
class="form-control"
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
@keyup.enter="performSearch"
/>
<button class="btn btn-primary" @click="performSearch">Search</button>
</div>
<div v-if="results.length" class="filter-buttons">
<button
v-for="filter in filterList"
:key="filter.key"
class="filter-btn"
:class="{ active: activeFilter === filter.key }"
@click="activeFilter = filter.key"
>
{{ filter.label }}
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Searching...</div>
<template v-else-if="query">
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
No {{ activeFilter }} results for "{{ query }}"
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
</div>
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
</div>
<div v-else class="results-list">
<div
v-for="result in filteredResults"
:key="`${result.type}-${result.id}`"
:id="`result-${result.type}-${result.id}`"
class="result-item"
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
>
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
<div class="result-content">
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
{{ result.title }}
</router-link>
<a
v-else
href="#"
class="result-title"
@click.prevent="openKBArticle(result)"
>
{{ result.title }}
</a>
<div class="result-meta">
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
<span v-if="result.location" class="result-location">{{ result.location }}</span>
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
</div>
</div>
<button
class="share-btn"
@click="shareResult(result)"
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
>
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
</button>
</div>
</div>
</template>
<div v-else class="no-results">
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
const typeCounts = ref({})
const totalAll = ref(0)
const highlightId = ref(null)
const copiedId = ref(null)
const typeLabels = {
machine: 'Machine',
pc: 'PC',
computer: 'PC',
application: 'App',
knowledgebase: 'KB',
printer: 'Printer',
network_device: 'Network',
measuring_tool: 'Measuring Tool',
employee: 'Employee',
notification: 'Notice',
subnet: 'Subnet',
usb_device: 'USB',
printed_item: 'Printed Part'
}
const filterTypeMap = {
all: null,
machines: ['machine'],
computers: ['computer'],
printers: ['printer'],
network: ['network_device', 'subnet'],
measuringtools: ['measuring_tool'],
applications: ['application'],
knowledgebase: ['knowledgebase'],
notifications: ['notification'],
employees: ['employee'],
usb: ['usb_device'],
printedparts: ['printed_item']
}
const filterList = [
{ key: 'all', label: 'All' },
{ key: 'machines', label: 'Machines' },
{ key: 'computers', label: 'PCs' },
{ key: 'printers', label: 'Printers' },
{ key: 'network', label: 'Network' },
{ key: 'measuringtools', label: 'Measuring Tools' },
{ key: 'applications', label: 'Apps' },
{ key: 'knowledgebase', label: 'KB' },
{ key: 'notifications', label: 'Notices' },
{ key: 'employees', label: 'Employees' },
{ key: 'usb', label: 'USB' },
{ key: 'printedparts', label: 'Printed Parts' }
]
function typeLabel(type) {
return typeLabels[type] || type
}
function getFilterCount(filterKey) {
if (filterKey === 'all') return totalAll.value || results.value.length
const types = filterTypeMap[filterKey]
if (!types) return 0
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
}
const filteredResults = computed(() => {
if (activeFilter.value === 'all') return results.value
const types = filterTypeMap[activeFilter.value]
if (!types) return results.value
return results.value.filter(r => types.includes(r.type))
})
async function search(q) {
if (!q || q.length < 2) {
results.value = []
return
}
loading.value = true
activeFilter.value = 'all'
try {
const response = await searchApi.search(q)
const data = response.data.data
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
window.open(data.redirect.url, '_blank')
query.value = q
results.value = []
loading.value = false
return
}
// Smart redirect - auto-navigate to exact match
if (data?.redirect) {
router.replace(data.redirect.url)
return
}
results.value = data?.results || []
typeCounts.value = data?.counts || {}
totalAll.value = data?.total_all || results.value.length
query.value = q
} catch (error) {
console.error('Search error:', error)
results.value = []
} finally {
loading.value = false
}
}
function performSearch() {
if (searchInput.value.trim()) {
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
}
}
async function openKBArticle(result) {
try {
await knowledgebaseApi.trackClick(result.id)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
} catch (error) {
console.error('Error tracking click:', error)
if (result.linkurl) {
window.open(result.linkurl, '_blank')
} else {
router.push(result.url)
}
}
}
function shareResult(result) {
const url = new URL(window.location.href)
url.searchParams.set('highlight', `${result.type}-${result.id}`)
navigator.clipboard.writeText(url.toString()).then(() => {
copiedId.value = `${result.type}-${result.id}`
setTimeout(() => { copiedId.value = null }, 2000)
})
}
function scrollToHighlight() {
if (highlightId.value) {
nextTick(() => {
const el = document.getElementById(`result-${highlightId.value}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
setTimeout(() => { highlightId.value = null }, 3000)
}
})
}
}
onMounted(() => {
const q = route.query.q
const hl = route.query.highlight
if (hl) highlightId.value = hl
if (q) {
searchInput.value = q
search(q)
}
})
watch(() => route.query.q, (newQ) => {
if (newQ) {
searchInput.value = newQ
const hl = route.query.highlight
if (hl) highlightId.value = hl
search(newQ)
}
})
watch(results, () => {
scrollToHighlight()
})
</script>
<style scoped>
.page-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0;
}
.results-count {
color: var(--text-light);
font-size: 0.9rem;
}
.search-box {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.search-box input {
flex: 1;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 1rem;
}
.filter-btn {
padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
cursor: pointer;
font-size: 0.8rem;
display: flex;
align-items: center;
gap: 0.3rem;
}
.filter-btn.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.filter-btn:hover:not(.active) {
border-color: var(--primary);
}
.filter-count {
background: rgba(128, 128, 128, 0.15);
padding: 0.1rem 0.35rem;
border-radius: 8px;
font-size: 0.75rem;
min-width: 1.25rem;
text-align: center;
}
.filter-btn.active .filter-count {
background: rgba(255, 255, 255, 0.25);
}
.no-results {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
.results-list {
display: flex;
flex-direction: column;
}
.result-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
transition: background 0.3s ease;
}
.result-item:last-child {
border-bottom: none;
}
.result-item.highlighted {
background: rgba(65, 129, 255, 0.08);
border-left: 3px solid var(--primary);
}
.result-type {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.2rem 0.45rem;
border-radius: 4px;
min-width: 65px;
text-align: center;
flex-shrink: 0;
}
/* Per-domain badge palette. Values live in CSS variables on the container so
the dark theme overrides them in one place (below) instead of restating
every selector. Each badge rule just references its pair. */
.search-results {
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
}
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
.result-type.pc,
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
.result-content {
flex: 1;
min-width: 0;
}
.result-title {
color: var(--link);
text-decoration: none;
font-weight: 500;
}
.result-title:hover {
text-decoration: underline;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.result-subtitle {
color: var(--text-light);
}
.result-ticket {
font-family: monospace;
font-size: 0.75rem;
}
.share-btn {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
color: var(--text-light);
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
flex-shrink: 0;
}
.share-btn:hover {
color: var(--primary);
border-color: var(--primary);
}
@media (prefers-color-scheme: dark) {
.search-results {
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
}
}
</style>

View File

@@ -1,101 +1,416 @@
<template>
<div>
<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 class="section-card">
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<p class="setting-description">
Each level is one drawing with its own blueprint and its own pixel size.
Marker positions are pixels in the level's own space, so the level id below
is what a position belongs to - it is worth knowing when you run a
transform or ask about a marker that is in the wrong place.
</p>
<div class="setting-row">
<label>
<span>Blueprint image (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
</div>
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
</label>
<div v-if="loading" class="empty">Loading...</div>
<div v-for="building in buildings" :key="building.buildingid" class="section-card">
<div class="building-header">
<input
v-model="building.buildingname"
class="building-name"
@blur="renameBuilding(building)"
:disabled="saving"
/>
<span class="muted">{{ building.levels.length }} level(s)</span>
<button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
</div>
<table class="data-table">
<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 class="setting-row">
<label>
<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 class="modal-footer">
<button class="btn btn-secondary" @click="showAddBuilding = false">Cancel</button>
<button class="btn btn-primary" @click="addBuilding" :disabled="!newBuilding.trim()">Add</button>
</div>
</div>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
<!-- Add level -->
<div v-if="addLevelFor" class="modal-overlay">
<div class="modal">
<div class="modal-header"><h3>Add level to {{ addLevelFor.buildingname }}</h3></div>
<div class="modal-body">
<div class="form-group">
<label>Name</label>
<input v-model="newLevel.levelname" class="form-control"
placeholder="Second floor" @keyup.enter="addLevel" />
<small class="input-hint">
Whatever the building calls it. Basement, Ground, Mezzanine, Roof.
</small>
</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 class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<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 class="modal-footer">
<button class="btn btn-secondary" @click="addLevelFor = null">Cancel</button>
<button class="btn btn-primary" @click="addLevel"
:disabled="!newLevel.levelname.trim()">Add</button>
</div>
</div>
</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>
</template>
<script setup>
import { withBase } from '../../utils/basePath'
import { onMounted } from 'vue'
import { useSystemSettings } from '../../composables/systemSettings'
// Buildings and levels admin (ADR-017). This page replaced four site-wide
// settings that described a single blueprint, which could not express a second
// 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 {
settings, saving, mapUploading, error, success,
loadSettings, saveSetting, uploadBlueprint,
} = useSystemSettings()
import { mapLevelsApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { reloadMapConfig } from '@/composables/mapConfig'
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>
<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>

View 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')

View File

@@ -560,6 +560,7 @@ def create_computer():
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
@@ -658,7 +659,7 @@ def update_computer(computer_id: int):
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'maintenancereference', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
for key in asset_fields:
if key in data:
old_val = getattr(asset, key)

View File

@@ -261,9 +261,11 @@
<div class="map-location-control">
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
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>
</div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
<button type="button" class="btn btn-secondary" @click="openMapPicker">
Set Location on Map
</button>
</div>
@@ -272,8 +274,19 @@
<!-- Map Picker Modal -->
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
<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
:pickerMode="true"
:levelid="pickerLevelId"
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
@@ -306,6 +319,8 @@ import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import { currentTheme } from '@/stores/theme'
@@ -336,6 +351,10 @@ const loading = ref(true)
const saving = ref(false)
const error = ref('')
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 form = ref({
@@ -356,6 +375,7 @@ const form = ref({
notes: '',
mapx: null,
mapy: null,
levelid: null,
ipaddress: ''
})
@@ -459,6 +479,7 @@ onMounted(async () => {
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
levelid: pc.levelid ?? null,
ipaddress: primaryComm?.ipaddress || ''
}
}
@@ -474,10 +495,21 @@ function handlePositionPicked(position) {
tempMapPosition.value = position
}
function openMapPicker() {
loadMapConfig().then(() => {
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
showMapPicker.value = true
})
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapx = tempMapPosition.value.left
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
}
@@ -485,6 +517,7 @@ function confirmMapPosition() {
function clearMapPosition() {
form.value.mapx = null
form.value.mapy = null
form.value.levelid = null
tempMapPosition.value = null
}

View File

@@ -1,23 +1,23 @@
{
"name": "computers",
"version": "1.0.0",
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/computers",
"provides": {
"asset_type": "computer",
"features": [
"computer_tracking",
"software_inventory",
"remote_access",
"os_management"
]
},
"settings": {
"enable_winrm": true,
"enable_vnc": true,
"auto_report_interval_hours": 24
}
}
{
"name": "computers",
"version": "1.0.0",
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/computers",
"provides": {
"asset_type": "computer",
"features": [
"computer_tracking",
"software_inventory",
"remote_access",
"os_management"
]
},
"settings": {
"enable_winrm": true,
"enable_vnc": true,
"auto_report_interval_hours": 24
}
}

View File

@@ -960,6 +960,7 @@ def _asset_facts(hostnames):
'assetnumber': asset.assetnumber,
'location': (asset.location.locationname if asset.location else None),
'mapx': asset.mapx,
'levelid': asset.levelid,
'mapy': asset.mapy,
'machinenumber': None, 'machineassetid': None,
'machinepluginid': None,

View File

@@ -21,13 +21,35 @@ from shopdb.api import require_permission, apply_import_timestamps
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'])
@jwt_required(optional=True)
def list_articles():
"""List all knowledge base articles."""
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
# 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.
if search := request.args.get('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(
Application.appname.ilike(like))
Application.appname.ilike(like),
Application.isactive.is_(True))
query = query.filter(
db.or_(
KnowledgeBase.shortdescription.ilike(like),
@@ -100,11 +127,11 @@ def list_articles():
@jwt_required(optional=True)
def get_stats():
"""Get knowledge base statistics."""
total_clicks = db.session.query(
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
).filter(KnowledgeBase.isactive == True).scalar()
total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
# Counted over the same set the list shows. A total that includes articles
# nobody can see is a total nobody can reconcile.
visible = _visible_articles()
total_clicks = sum(article.clicks or 0 for article in visible)
total_articles = visible.count()
return success_response({
'totalclicks': int(total_clicks),

View File

@@ -106,7 +106,9 @@ const applications = ref([])
onMounted(async () => {
try {
// 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 || []
// Load article if editing

View File

@@ -171,7 +171,9 @@ async function loadArticles() {
async function loadTopics() {
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 || []
} catch (error) {
console.error('Error loading topics:', error)

View File

@@ -371,6 +371,7 @@ def create_machine():
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
@@ -446,7 +447,7 @@ def update_machine(machine_id: int):
# Update asset fields
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
'maintenancereference', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy',
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
'notes', 'isactive']
for key in asset_fields:
if key in data:

View File

@@ -239,9 +239,11 @@
<div class="map-location-control">
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
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>
</div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
<button type="button" class="btn btn-secondary" @click="openMapPicker">
Set Location on Map
</button>
</div>
@@ -250,8 +252,19 @@
<!-- Map Picker Modal -->
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
<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
:pickerMode="true"
:levelid="pickerLevelId"
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
@@ -349,6 +362,8 @@ import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import { currentTheme } from '@/stores/theme'
@@ -371,6 +386,10 @@ const loading = ref(true)
const saving = ref(false)
const error = ref('')
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 form = ref({
@@ -391,7 +410,8 @@ const form = ref({
islocationonly: false,
notes: '',
mapx: null,
mapy: null
mapy: null,
levelid: null
})
const machineTypes = ref([])
@@ -501,7 +521,8 @@ onMounted(async () => {
islocationonly: data.machine?.islocationonly || false,
notes: data.notes || '',
mapx: data.mapx ?? null,
mapy: data.mapy ?? null
mapy: data.mapy ?? null,
levelid: data.levelid ?? null,
}
// Load existing relationships to find controlling PC
@@ -535,10 +556,21 @@ function handlePositionPicked(position) {
tempMapPosition.value = position
}
function openMapPicker() {
loadMapConfig().then(() => {
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
showMapPicker.value = true
})
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapx = tempMapPosition.value.left
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
}
@@ -546,6 +578,7 @@ function confirmMapPosition() {
function clearMapPosition() {
form.value.mapx = null
form.value.mapy = null
form.value.levelid = null
tempMapPosition.value = null
}

View File

@@ -4,7 +4,7 @@
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/machines",
"provides": {
"asset_type": "machine",

View File

@@ -263,6 +263,7 @@ def create_tool():
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes'),
)
@@ -291,7 +292,7 @@ def create_tool():
# Asset core fields writable through this plugin's write path.
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
'maintenancereference', 'serialnumber',
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy',
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
'notes', 'isactive')
# Extension fields with plain assignment (dates handled separately).
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',

View File

@@ -5,7 +5,7 @@
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.6.0,<1.0.0",
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/measuringtools",
"default_enabled": false,
"provides": {

View File

@@ -412,6 +412,7 @@ def create_network_device():
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
@@ -502,7 +503,7 @@ def update_network_device(device_id: int):
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'maintenancereference', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
for key in asset_fields:
if key in data:
old_val = getattr(asset, key)

View File

@@ -234,9 +234,11 @@
<div class="map-location-control">
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
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>
</div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
<button type="button" class="btn btn-secondary" @click="openMapPicker">
Set Location on Map
</button>
</div>
@@ -246,8 +248,19 @@
<!-- Map Picker Modal -->
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
<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
:pickerMode="true"
:levelid="pickerLevelId"
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
@@ -307,6 +320,8 @@ import {
} from '@/api'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
@@ -315,6 +330,10 @@ import { apiError } from '@/utils/apiError'
const { isEnabled } = useIdentifierFlags()
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 route = useRoute()
const router = useRouter()
@@ -348,6 +367,7 @@ const form = ref({
ismanaged: false,
mapx: null,
mapy: null,
levelid: null,
notes: ''
})
@@ -459,6 +479,7 @@ async function loadDevice() {
form.value.businessunitid = data.businessunitid || ''
form.value.mapx = data.mapx
form.value.mapy = data.mapy
form.value.levelid = data.levelid
form.value.notes = data.notes || ''
// The IP lives in a Communication row, not on the extension table; the API
// flattens it onto the response as ipaddress.
@@ -508,6 +529,7 @@ async function submitForm() {
ismanaged: form.value.ismanaged,
mapx: form.value.mapx,
mapy: form.value.mapy,
levelid: form.value.levelid,
notes: form.value.notes || null
}
@@ -550,10 +572,21 @@ function handlePositionPicked(position) {
tempMapPosition.value = position
}
function openMapPicker() {
loadMapConfig().then(() => {
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
showMapPicker.value = true
})
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapx = tempMapPosition.value.left
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
}
@@ -561,6 +594,7 @@ function confirmMapPosition() {
function clearMapPosition() {
form.value.mapx = null
form.value.mapy = null
form.value.levelid = null
tempMapPosition.value = null
}

View File

@@ -1,22 +1,22 @@
{
"name": "network",
"version": "1.0.0",
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/network",
"provides": {
"asset_type": "network_device",
"features": [
"network_device_tracking",
"port_management",
"firmware_tracking",
"poe_monitoring"
]
},
"settings": {
"enable_snmp_polling": false,
"snmp_community": "public"
}
}
{
"name": "network",
"version": "1.0.0",
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/network",
"provides": {
"asset_type": "network_device",
"features": [
"network_device_tracking",
"port_management",
"firmware_tracking",
"poe_monitoring"
]
},
"settings": {
"enable_snmp_polling": false,
"snmp_community": "public"
}
}

View File

@@ -358,6 +358,7 @@ def printer_install_list():
'iscsf': printer.iscsf,
'locationname': asset.location.locationname if asset.location else None,
'mapx': asset.mapx,
'levelid': asset.levelid,
'mapy': asset.mapy,
})
@@ -366,7 +367,7 @@ def printer_install_list():
# hand-rolled JSON parser. The web map uses the default JSON.
if request.args.get('format') == 'text':
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
'hostname', 'ipaddress', 'mapx', 'mapy')
'hostname', 'ipaddress', 'mapx', 'mapy', 'levelid')
lines = [_text_line(row, fields) for row in rows]
return Response('\n'.join(lines), mimetype='text/plain')
@@ -738,6 +739,7 @@ def create_printer():
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
@@ -819,7 +821,7 @@ def update_printer(printer_id: int):
# Update asset fields (optional identifiers gated per-type in Settings)
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'maintenancereference', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy',
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
'notes', 'isactive']
for key in asset_fields:
if key in data:
@@ -1035,6 +1037,7 @@ def _get_low_supplies_data():
'model': model_number,
'location': location_name,
'mapx': asset.mapx,
'levelid': asset.levelid,
'mapy': asset.mapy,
'supplies': annotated
})

View File

@@ -250,9 +250,11 @@
<div class="map-location-control">
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
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>
</div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
<button type="button" class="btn btn-secondary" @click="openMapPicker">
Set Location on Map
</button>
</div>
@@ -261,8 +263,19 @@
<!-- Map Picker Modal -->
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
<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
:pickerMode="true"
:levelid="pickerLevelId"
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
@@ -295,6 +308,8 @@ import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import { currentTheme } from '@/stores/theme'
@@ -320,6 +335,10 @@ const loading = ref(true)
const saving = ref(false)
const error = ref('')
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 form = ref({
@@ -337,6 +356,7 @@ const form = ref({
notes: '',
mapx: null,
mapy: null,
levelid: null,
// Printer-specific
ipaddress: '',
csfname: '',
@@ -511,6 +531,7 @@ onMounted(async () => {
notes: printer.notes || '',
mapx: printer.mapx ?? null,
mapy: printer.mapy ?? null,
levelid: printer.levelid ?? null,
// Printer-specific
ipaddress: primaryComm?.ipaddress || '',
csfname: ext.sharename || '',
@@ -534,10 +555,21 @@ function handlePositionPicked(position) {
tempMapPosition.value = position
}
function openMapPicker() {
loadMapConfig().then(() => {
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
showMapPicker.value = true
})
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapx = tempMapPosition.value.left
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
}
@@ -545,6 +577,7 @@ function confirmMapPosition() {
function clearMapPosition() {
form.value.mapx = null
form.value.mapy = null
form.value.levelid = null
tempMapPosition.value = null
}

View File

@@ -43,7 +43,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
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 { withBase } from '@/utils/basePath'
import { currentTheme } from '@/stores/theme'
@@ -55,8 +55,10 @@ const selected = ref({}) // printerid -> true
let map = null
let imageOverlay = null
let markers = {} // printerid -> circleMarker
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
// The level being shown. The installer map runs before anyone logs in, which is
// 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 NORMAL_COLOR = '#4CAF50'
@@ -120,13 +122,13 @@ function renderMarkers() {
}
watch(currentTheme, (theme) => {
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme))
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme, mapConfig.currentlevelid))
})
onMounted(async () => {
await loadMapConfig()
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
MAP_WIDTH = dimensionsFor(mapConfig.currentlevelid).width
MAP_HEIGHT = dimensionsFor(mapConfig.currentlevelid).height
try {
const response = await printersApi.installList()
@@ -142,7 +144,7 @@ onMounted(async () => {
attributionControl: false,
})
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.setMaxBounds(bounds)
renderMarkers()

View File

@@ -4,7 +4,7 @@
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.16.0,<1.0.0",
"core_version": ">=0.20.0,<1.0.0",
"api_prefix": "/api/printers",
"provides": {
"machine_category": "Printer",

View File

@@ -88,6 +88,7 @@ def _related_machine(asset):
# worth showing, so the caller decides what to do with a missing
# position rather than the row being dropped.
'mapx': candidate.mapx,
'levelid': candidate.levelid,
'mapy': candidate.mapy,
'locationid': candidate.locationid,
'locationname': (candidate.location.locationname

View File

@@ -158,6 +158,32 @@ if [ -n "$CODE_LIB_IMPORTS" ]; then
VIOLATIONS=$((VIOLATIONS + 1))
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
# 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.

View File

@@ -57,7 +57,7 @@ from .plugins import plugin_manager
# permission / empty / position. BREAKING for any plugin still using the old
# 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.
__contract_version__ = '0.19.0'
__contract_version__ = '0.20.0'
# Product release version (see ADR-007). The product version and the
# plugin-contract version above are distinct series with independent
@@ -190,6 +190,8 @@ CORE_BLUEPRINT_NAMES = (
'models',
'businessunits',
'locations',
'maplevels',
'mappositions',
'operatingsystems',
'dashboard',
'dashboarddefaults',

View File

@@ -196,7 +196,10 @@ def _walk_related_for_position(asset, visited, depth):
n_mapx = getattr(neighbor, 'mapx', None)
n_mapy = getattr(neighbor, 'mapy', 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)
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)
4. None (asset is unplaced, rendered in a tray)
Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
is one of 'self', 'related', 'location'. Returns None when no priority
yields coordinates.
Returns a dict {'mapx', 'mapy', 'levelid', 'positionsource'} where
positionsource is one of 'self', 'related', 'location'. Returns None when no
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)
mapy = getattr(asset, 'mapy', 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)
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)
if location is not None:
@@ -235,6 +248,7 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
return {
'mapx': loc_mapx,
'mapy': loc_mapy,
'levelid': getattr(location, 'levelid', None),
'positionsource': 'location',
}

View File

@@ -8,6 +8,8 @@ from .vendors import vendors_bp
from .models import models_bp
from .businessunits import businessunits_bp
from .locations import locations_bp
from .maplevels import maplevels_bp
from .mappositions import mappositions_bp
from .operatingsystems import operatingsystems_bp
from .dashboard import dashboard_bp
from .dashboarddefaults import dashboarddefaults_bp
@@ -34,6 +36,8 @@ __all__ = [
'models_bp',
'businessunits_bp',
'locations_bp',
'maplevels_bp',
'mappositions_bp',
'operatingsystems_bp',
'dashboard_bp',
'dashboarddefaults_bp',

View File

@@ -476,6 +476,7 @@ def create_asset():
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
levelid=data.get('levelid'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
@@ -517,7 +518,7 @@ def update_asset(asset_id: int):
# Update allowed fields
allowed_fields = [
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive'
]
for key in allowed_fields:
@@ -1114,6 +1115,7 @@ def get_assets_map():
'displayname': asset.display_name,
'serialnumber': asset.serialnumber,
'mapx': asset.mapx,
'levelid': asset.levelid,
'mapy': asset.mapy,
'assettype': asset.assettype.assettype if asset.assettype else None,
'assettypeid': asset.assettypeid,

View 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

View 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 ''))

View File

@@ -236,13 +236,26 @@ def _search_applications(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 = []
try:
_require_enabled('knowledgebase')
from plugins.knowledgebase.models import KnowledgeBase
retired = db.session.query(Application.appid).filter(
Application.isactive.is_(False))
kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True,
db.or_(KnowledgeBase.appid.is_(None),
KnowledgeBase.appid.notin_(retired)),
_word_match(query, KnowledgeBase.shortdescription,
KnowledgeBase.keywords)
).limit(20).all()
@@ -339,7 +352,22 @@ def _search_employees(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 = []
try:
assets = Asset.query.join(AssetType).options(
@@ -348,7 +376,8 @@ def _search_assets(query, search_term):
).filter(
Asset.isactive == True,
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.notes)
Asset.serialnumber, Asset.notes,
Asset.gaugelabreference, Asset.maintenancereference)
).limit(15).all()
for asset in assets:
@@ -357,6 +386,10 @@ def _search_assets(query, search_term):
relevance = 100
elif asset.name and query.lower() == asset.name.lower():
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():
relevance = 85
elif asset.name and query.lower() in asset.name.lower():
@@ -408,6 +441,104 @@ def _search_measuringtools(query, search_term):
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):
"""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_assets(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_notifications(query, search_term))
results.extend(_search_hostnames(query, search_term))

View File

@@ -109,6 +109,8 @@ SEARCH_DOMAINS = {
'measuring_tool': 'Measuring Tools',
'notification': 'Notifications',
'subnet': 'Subnets',
'usb_device': 'USB Devices',
'printed_item': 'Printed Items',
}
def _declared_default(key: str) -> dict:

View File

@@ -8,6 +8,8 @@ from .model import Model
from .businessunit import BusinessUnit
from .dashboarddefault import DashboardDefault
from .location import Location, LocationType, derive_locationcode
from .maplevel import Building, MapLevel
from .mapsnapshot import MapPositionSnapshot
from .operatingsystem import OperatingSystem
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
from .communication import Communication, CommunicationType
@@ -37,6 +39,10 @@ __all__ = [
'DashboardDefault',
'Location',
'LocationType',
# Map model (ADR-017): which drawing renders an asset, at what size
'Building',
'MapLevel',
'MapPositionSnapshot',
'derive_locationcode',
'OperatingSystem',
# Relationships

View File

@@ -117,9 +117,24 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
nullable=True
)
# 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)')
# Floor map position (ADR-001: asset-specific override; nullable).
#
# 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 = db.Column(db.Text, nullable=True)
@@ -198,6 +213,10 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
'locationname': related.location.locationname if related.location else None,
'mapx': related.mapx,
'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
}
@@ -250,6 +269,11 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
result['mapx'] = inherited['mapx']
if result.get('mapy') is None:
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).
# Derived from the location name's leading token; labels can encode a

View File

@@ -70,6 +70,13 @@ class Location(BaseModel):
# chain priority 3.
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')
# 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
locationtype = db.relationship('LocationType')

View 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())

View 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
View 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

View File

@@ -46,42 +46,60 @@ def test_resolve_asset_position_uses_self_when_set():
class FakeLocation:
mapx = 10
mapy = 20
levelid = 9
class FakeAsset:
mapx = 100
mapy = 200
levelid = 7
location = FakeLocation()
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():
"""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:
mapx = 50
mapy = 75
levelid = 9
class FakeAsset:
mapx = None
mapy = None
levelid = 7
location = FakeLocation()
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():
"""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:
mapx = 1
mapy = 2
levelid = 4
class FakeAsset:
location = FakeLocation()
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) ----------------------
@@ -105,7 +123,8 @@ def _make_rel(rtype_name, neighbor, inheritsposition=True, isactive=True):
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:
pass
@@ -113,6 +132,7 @@ def _make_asset(assetid, mapx=None, mapy=None, outgoing=None, incoming=None, loc
a.assetid = assetid
a.mapx = mapx
a.mapy = mapy
a.levelid = levelid
a.outgoing_relationships = outgoing or []
a.incoming_relationships = incoming or []
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():
"""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)
child = _make_asset(assetid=1, outgoing=[rel])
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():
"""Priority 2 ordering: partof beats controls when both have coords."""
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12)
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99)
partof_neighbor = _make_asset(assetid=10, mapx=11, mapy=12, levelid=3)
controls_neighbor = _make_asset(assetid=20, mapx=99, mapy=99, levelid=8)
rel_partof = _make_rel('partof', partof_neighbor)
rel_controls = _make_rel('controls', controls_neighbor)
asset = _make_asset(assetid=1, outgoing=[rel_controls, rel_partof])
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():
@@ -161,12 +183,14 @@ def test_resolve_asset_position_skips_when_inheritsposition_false():
def test_resolve_asset_position_walks_recursively():
"""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)])
child = _make_asset(assetid=1, outgoing=[_make_rel('partof', middle)])
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():
@@ -195,12 +219,13 @@ def test_resolve_asset_position_depth_cap_is_three():
def test_resolve_asset_position_self_beats_related():
"""Priority 1 beats priority 2: asset's own coords win even when a
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)
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)
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():
@@ -209,13 +234,18 @@ def test_resolve_asset_position_related_beats_location():
class FakeLocation:
mapx = 500
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)
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)
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():

View 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

View 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'

View 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

View File

@@ -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):
"""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={
'assetnumber': 'CSF01-HP', 'windowsname': 'CSF01-HP', 'hostname': 'wjprn01',
'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
cols = line.split('|')
# 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[4] == 'wjprn01' # hostname
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,