Buildings and levels for the floor map, and make every identifier searchable
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:
61
CHANGELOG.md
61
CHANGELOG.md
@@ -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
|
||||
|
||||
23
README.md
23
README.md
@@ -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`):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
143
docs/adr/ADR-017-buildings-and-levels.md
Normal file
143
docs/adr/ADR-017-buildings-and-levels.md
Normal 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.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
144
frontend/src/composables/mapConfig.spec.js
Normal file
144
frontend/src/composables/mapConfig.spec.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// The invariant the whole map model rests on: a position without a level is not
|
||||
// drawn on the default level.
|
||||
//
|
||||
// Nothing else catches this. Rewriting the composable left four components
|
||||
// reading a property that no longer existed, which is `undefined` rather than a
|
||||
// compile error - Vite built it happily and every marker would have been
|
||||
// positioned at NaN percent. A build proves the code parses, not that a marker
|
||||
// lands anywhere.
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const list = vi.fn()
|
||||
vi.mock('../api', () => ({ mapLevelsApi: { list: (...args) => list(...args) } }))
|
||||
|
||||
const {
|
||||
state, loadMapConfig, reloadMapConfig, blueprintUrlFor, dimensionsFor,
|
||||
hasLevel, levelName, levelOptions, setCurrentLevel,
|
||||
} = await import('./mapConfig')
|
||||
|
||||
// Two buildings, three levels, sized like the real before and after: the ground
|
||||
// floor at 3300x2550 and a second floor at 3308x4000.
|
||||
const PAYLOAD = {
|
||||
data: {
|
||||
data: {
|
||||
buildings: [
|
||||
{
|
||||
buildingid: 1, buildingname: 'Main', levels: [
|
||||
{ levelid: 1, levelname: 'Ground floor', sortorder: 0, mapwidth: 3300,
|
||||
mapheight: 2550, blueprintlight: '/x/g-light.png',
|
||||
blueprintdark: '/x/g-dark.png', isdefault: true },
|
||||
{ levelid: 2, levelname: 'Second floor', sortorder: 1, mapwidth: 3308,
|
||||
mapheight: 4000, blueprintlight: '/x/2-light.png',
|
||||
blueprintdark: null, isdefault: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
buildingid: 2, buildingname: 'Annex', levels: [
|
||||
{ levelid: 3, levelname: 'Ground floor', sortorder: 0, mapwidth: 1200,
|
||||
mapheight: 900, blueprintlight: '/x/a-light.png',
|
||||
blueprintdark: '/x/a-dark.png', isdefault: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
defaultlevelid: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
list.mockReset()
|
||||
list.mockResolvedValue(PAYLOAD)
|
||||
state.loaded = false
|
||||
state.currentlevelid = null
|
||||
await reloadMapConfig()
|
||||
})
|
||||
|
||||
describe('loading', () => {
|
||||
it('indexes every level of every building and adopts the default', () => {
|
||||
expect(Object.keys(state.levels)).toEqual(['1', '2', '3'])
|
||||
expect(state.defaultlevelid).toBe(1)
|
||||
expect(state.currentlevelid).toBe(1)
|
||||
})
|
||||
|
||||
it('fetches once across concurrent callers', async () => {
|
||||
list.mockClear()
|
||||
state.loaded = false
|
||||
await Promise.all([loadMapConfig(), loadMapConfig(), loadMapConfig()])
|
||||
expect(list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('survives an unreachable server without hanging every map on the page', async () => {
|
||||
list.mockReset()
|
||||
list.mockRejectedValue(new Error('network'))
|
||||
state.loaded = false
|
||||
await reloadMapConfig()
|
||||
expect(state.loaded).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dimensions belong to the level, not the site', () => {
|
||||
it('returns each level its own native size', () => {
|
||||
expect(dimensionsFor(1)).toEqual({ width: 3300, height: 2550 })
|
||||
expect(dimensionsFor(2)).toEqual({ width: 3308, height: 4000 })
|
||||
expect(dimensionsFor(3)).toEqual({ width: 1200, height: 900 })
|
||||
})
|
||||
|
||||
it('does not report one level size for another', () => {
|
||||
// The bug this guards: a marker on level 2 measured against level 1's
|
||||
// height renders at 2550/4000 of the way down - plausible, and wrong.
|
||||
expect(dimensionsFor(2).height).not.toBe(dimensionsFor(1).height)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a position without a level is not drawn', () => {
|
||||
it('has no blueprint for a null level', () => {
|
||||
expect(blueprintUrlFor('light', null)).toBeNull()
|
||||
expect(hasLevel(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('has no blueprint for a level this instance does not know', () => {
|
||||
expect(blueprintUrlFor('light', 99)).toBeNull()
|
||||
expect(hasLevel(99)).toBe(false)
|
||||
})
|
||||
|
||||
it('never substitutes the default level for a missing one', () => {
|
||||
const groundfloor = blueprintUrlFor('light', 1)
|
||||
expect(groundfloor).toContain('g-light.png')
|
||||
// The failure mode: returning the default blueprint for an unknown level.
|
||||
expect(blueprintUrlFor('light', null)).not.toBe(groundfloor)
|
||||
expect(blueprintUrlFor('light', 99)).not.toBe(groundfloor)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprints', () => {
|
||||
it('serves the theme asked for', () => {
|
||||
expect(blueprintUrlFor('light', 1)).toContain('g-light.png')
|
||||
expect(blueprintUrlFor('dark', 1)).toContain('g-dark.png')
|
||||
})
|
||||
|
||||
it('falls back to the other theme rather than showing nothing', () => {
|
||||
// Level 2 has no dark blueprint. A hard-to-read floor plan beats no floor
|
||||
// plan, and a site that uploaded one image should still work in both themes.
|
||||
expect(blueprintUrlFor('dark', 2)).toContain('2-light.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('naming and selection', () => {
|
||||
it('qualifies a level by building only when there is more than one', () => {
|
||||
// Both buildings have a 'Ground floor', so the name alone is ambiguous.
|
||||
expect(levelName(1)).toBe('Main / Ground floor')
|
||||
expect(levelName(3)).toBe('Annex / Ground floor')
|
||||
})
|
||||
|
||||
it('offers every level in building then level order', () => {
|
||||
expect(levelOptions().map(option => option.levelid)).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('refuses to make an unknown level current', () => {
|
||||
setCurrentLevel(99)
|
||||
expect(state.currentlevelid).toBe(1)
|
||||
setCurrentLevel(2)
|
||||
expect(state.currentlevelid).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,9 @@ export const searchDomains = [
|
||||
{ key: 'network_device', label: 'Network Devices' },
|
||||
{ key: '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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
213
migrations/versions/7d33_buildings_and_levels.py
Normal file
213
migrations/versions/7d33_buildings_and_levels.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Buildings and levels: a map is a drawing per level, not one image per site.
|
||||
|
||||
See ADR-017. `map_blueprint_light`, `map_blueprint_dark`, `map_width` and
|
||||
`map_height` described one image for the whole site, and `assets.mapx`/`mapy`
|
||||
were pixels in it. A second level and a likely second building make that a
|
||||
table.
|
||||
|
||||
This migration is written so nothing renders differently the day it lands: the
|
||||
four settings become one building and one level, marked default, and every asset
|
||||
that has a position points at it. The settings rows are left in place here and
|
||||
retired separately, so a rollback does not lose the blueprint paths.
|
||||
|
||||
`levelid` is nullable because an asset with no position needs no level. A
|
||||
position WITHOUT a level is the case the UI refuses to guess about, and after
|
||||
this migration no such row exists.
|
||||
|
||||
Revision ID: 7d33_buildings_and_levels
|
||||
Revises: 7d32_displayrole_kiosk_vocabulary
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d33_buildings_and_levels'
|
||||
down_revision = '7d32_displayrole_kiosk_vocabulary'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# What the settings said before this table existed. Read at upgrade time; these
|
||||
# are only the fallbacks for a site that never set them.
|
||||
DEFAULT_WIDTH = 3300
|
||||
DEFAULT_HEIGHT = 2550
|
||||
PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
|
||||
|
||||
|
||||
def _existing(insp, name):
|
||||
return name in insp.get_table_names()
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
|
||||
# Guarded like every other table-creating migration in this project: on a
|
||||
# FRESH database the tables are built from the SQLAlchemy models, which
|
||||
# already declare them, so an unconditional create fails.
|
||||
if not _existing(insp, 'buildings'):
|
||||
op.create_table(
|
||||
'buildings',
|
||||
sa.Column('buildingid', sa.Integer, primary_key=True),
|
||||
sa.Column('buildingname', sa.String(100), nullable=False,
|
||||
unique=True),
|
||||
sa.Column('sortorder', sa.Integer, nullable=False,
|
||||
server_default='0'),
|
||||
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||
sa.Column('isactive', sa.Boolean, nullable=False,
|
||||
server_default=sa.true()),
|
||||
)
|
||||
|
||||
if not _existing(insp, 'maplevels'):
|
||||
op.create_table(
|
||||
'maplevels',
|
||||
sa.Column('levelid', sa.Integer, primary_key=True),
|
||||
sa.Column('buildingid', sa.Integer,
|
||||
sa.ForeignKey('buildings.buildingid'), nullable=False,
|
||||
index=True),
|
||||
sa.Column('levelname', sa.String(100), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer, nullable=False,
|
||||
server_default='0'),
|
||||
sa.Column('blueprintlight', sa.String(255), nullable=True),
|
||||
sa.Column('blueprintdark', sa.String(255), nullable=True),
|
||||
sa.Column('mapwidth', sa.Integer, nullable=False,
|
||||
server_default=str(DEFAULT_WIDTH)),
|
||||
sa.Column('mapheight', sa.Integer, nullable=False,
|
||||
server_default=str(DEFAULT_HEIGHT)),
|
||||
sa.Column('isdefault', sa.Boolean, nullable=False,
|
||||
server_default=sa.false()),
|
||||
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||
sa.Column('isactive', sa.Boolean, nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.UniqueConstraint('buildingid', 'levelname',
|
||||
name='uq_maplevel_building_name'),
|
||||
)
|
||||
|
||||
# Positions have no history, and a bulk transform rewrites hundreds of them
|
||||
# at once. A snapshot table is what makes that reversible - without it, the
|
||||
# honest advice would be "back up the database first", which nobody does
|
||||
# before a UI action.
|
||||
if not _existing(insp, 'mappositionsnapshots'):
|
||||
op.create_table(
|
||||
'mappositionsnapshots',
|
||||
sa.Column('snapshotid', sa.Integer, primary_key=True),
|
||||
sa.Column('levelid', sa.Integer, nullable=True),
|
||||
sa.Column('reason', sa.String(255), nullable=True),
|
||||
sa.Column('assetcount', sa.Integer, nullable=False,
|
||||
server_default='0'),
|
||||
# The positions themselves, as JSON: assetid, mapx, mapy, levelid,
|
||||
# mapverifiedat per row. Deliberately not a child table - a snapshot
|
||||
# is read back whole or not at all, and one row per snapshot keeps
|
||||
# restore a single statement.
|
||||
sa.Column('positionsjson', sa.Text, nullable=False),
|
||||
sa.Column('restoredat', sa.DateTime, nullable=True),
|
||||
sa.Column('createdby', sa.String(100), nullable=True),
|
||||
sa.Column('createddate', sa.DateTime, nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime, nullable=False),
|
||||
sa.Column('isactive', sa.Boolean, nullable=False,
|
||||
server_default=sa.true()),
|
||||
)
|
||||
|
||||
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
|
||||
if 'levelid' not in assetcolumns:
|
||||
op.add_column('assets', sa.Column('levelid', sa.Integer, nullable=True))
|
||||
op.create_index('idx_assets_levelid', 'assets', ['levelid'])
|
||||
# The FK is added separately from the column so a site whose assets
|
||||
# table is large is not rewritten twice.
|
||||
op.create_foreign_key('fk_assets_levelid', 'assets', 'maplevels',
|
||||
['levelid'], ['levelid'])
|
||||
if 'mapverifiedat' not in assetcolumns:
|
||||
op.add_column('assets',
|
||||
sa.Column('mapverifiedat', sa.DateTime, nullable=True))
|
||||
|
||||
# Locations carry map coordinates too - they are the default position for
|
||||
# assets at that location - so they need a level for exactly the same
|
||||
# reason. Missed on the first pass and caught by the payload gate.
|
||||
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
|
||||
if 'levelid' not in locationcolumns:
|
||||
op.add_column('locations', sa.Column('levelid', sa.Integer, nullable=True))
|
||||
op.create_index('idx_locations_levelid', 'locations', ['levelid'])
|
||||
op.create_foreign_key('fk_locations_levelid', 'locations', 'maplevels',
|
||||
['levelid'], ['levelid'])
|
||||
|
||||
# --- carry the settings forward -------------------------------------
|
||||
# Only when there is nothing here yet: re-running must not create a second
|
||||
# default level, and a site that has already set its levels up must not have
|
||||
# them joined by a stale one built from retired settings.
|
||||
existinglevels = bind.execute(
|
||||
sa.text('SELECT COUNT(*) FROM maplevels')).scalar() or 0
|
||||
if existinglevels:
|
||||
return
|
||||
|
||||
settings = dict(bind.execute(sa.text(
|
||||
"SELECT `key`, value FROM settings WHERE `key` IN "
|
||||
"('map_blueprint_light','map_blueprint_dark','map_width','map_height')"
|
||||
)).fetchall())
|
||||
|
||||
def _int(value, fallback):
|
||||
try:
|
||||
number = int(str(value).strip())
|
||||
return number if number > 0 else fallback
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
bind.execute(sa.text(
|
||||
'INSERT INTO buildings (buildingname, sortorder, createddate, '
|
||||
'modifieddate, isactive) VALUES (:name, 0, :now, :now, :active)'),
|
||||
{'name': 'Main', 'now': sa.func.now(), 'active': True})
|
||||
buildingid = bind.execute(sa.text(
|
||||
'SELECT buildingid FROM buildings WHERE buildingname = :name'),
|
||||
{'name': 'Main'}).scalar()
|
||||
|
||||
bind.execute(sa.text(
|
||||
'INSERT INTO maplevels (buildingid, levelname, sortorder, '
|
||||
'blueprintlight, blueprintdark, mapwidth, mapheight, isdefault, '
|
||||
'createddate, modifieddate, isactive) VALUES (:building, :name, 0, '
|
||||
':light, :dark, :width, :height, :isdefault, :now, :now, :active)'),
|
||||
{'building': buildingid,
|
||||
'name': 'Ground floor',
|
||||
'light': settings.get('map_blueprint_light') or PLACEHOLDER,
|
||||
'dark': settings.get('map_blueprint_dark') or PLACEHOLDER,
|
||||
'width': _int(settings.get('map_width'), DEFAULT_WIDTH),
|
||||
'height': _int(settings.get('map_height'), DEFAULT_HEIGHT),
|
||||
'isdefault': True, 'now': sa.func.now(), 'active': True})
|
||||
|
||||
levelid = bind.execute(sa.text(
|
||||
'SELECT levelid FROM maplevels WHERE isdefault = :flag'),
|
||||
{'flag': True}).scalar()
|
||||
|
||||
# Every asset that already has a position had it in this one drawing's
|
||||
# coordinate space, so it belongs to this level. An asset with no position
|
||||
# is left null: it needs no level until somebody places it.
|
||||
bind.execute(sa.text(
|
||||
'UPDATE assets SET levelid = :levelid '
|
||||
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
|
||||
{'levelid': levelid})
|
||||
bind.execute(sa.text(
|
||||
'UPDATE locations SET levelid = :levelid '
|
||||
'WHERE mapx IS NOT NULL AND mapy IS NOT NULL AND levelid IS NULL'),
|
||||
{'levelid': levelid})
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
assetcolumns = {c['name'] for c in insp.get_columns('assets')}
|
||||
if 'levelid' in assetcolumns:
|
||||
op.drop_constraint('fk_assets_levelid', 'assets', type_='foreignkey')
|
||||
op.drop_index('idx_assets_levelid', table_name='assets')
|
||||
op.drop_column('assets', 'levelid')
|
||||
if 'mapverifiedat' in assetcolumns:
|
||||
op.drop_column('assets', 'mapverifiedat')
|
||||
locationcolumns = {c['name'] for c in insp.get_columns('locations')}
|
||||
if 'levelid' in locationcolumns:
|
||||
op.drop_constraint('fk_locations_levelid', 'locations', type_='foreignkey')
|
||||
op.drop_index('idx_locations_levelid', table_name='locations')
|
||||
op.drop_column('locations', 'levelid')
|
||||
if _existing(insp, 'mappositionsnapshots'):
|
||||
op.drop_table('mappositionsnapshots')
|
||||
if _existing(insp, 'maplevels'):
|
||||
op.drop_table('maplevels')
|
||||
if _existing(insp, 'buildings'):
|
||||
op.drop_table('buildings')
|
||||
@@ -560,6 +560,7 @@ def create_computer():
|
||||
locationid=data.get('locationid'),
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
350
shopdb/core/api/maplevels.py
Normal file
350
shopdb/core/api/maplevels.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Buildings and levels: the drawings a marker can be placed on (ADR-017).
|
||||
|
||||
Reads are UNAUTHENTICATED. The printer installer map runs before anyone logs in
|
||||
and needs a blueprint to draw, exactly as the printer install-list and the slide
|
||||
feed already do. A level name, a blueprint path and a pixel size are not
|
||||
secrets - the marker positions drawn on them already render on kiosk pages.
|
||||
|
||||
Writes require admin: adding a level changes where every marker on it appears.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Building, MapLevel, Asset, AuditLog
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_role
|
||||
from shopdb.api import send_upload
|
||||
from shopdb.utils.imagesize import image_size
|
||||
|
||||
maplevels_bp = Blueprint('maplevels', __name__)
|
||||
|
||||
# Same set the map blueprint upload already accepts. SVG stays allowed because a
|
||||
# floor plan is vector by nature; it is served through send_upload, which sends
|
||||
# the sandbox headers that stop one executing as script.
|
||||
BLUEPRINT_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||
|
||||
|
||||
def _blueprint_dir():
|
||||
return os.path.join(current_app.instance_path, 'maps')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Read - public
|
||||
# =============================================================================
|
||||
|
||||
@maplevels_bp.route('', methods=['GET'])
|
||||
def list_levels():
|
||||
"""Every active level, grouped by building, in display order.
|
||||
|
||||
One call, because every consumer needs the whole list: the map switches
|
||||
between levels, and a hover preview has to resolve an arbitrary asset's
|
||||
level to a blueprint without knowing in advance which one it is.
|
||||
"""
|
||||
buildings = (Building.query.filter_by(isactive=True)
|
||||
.order_by(Building.sortorder, Building.buildingid).all())
|
||||
|
||||
# How many markers sit on each level, in one grouped query rather than one
|
||||
# per level. The admin page needs it to say what a rename or a resize is
|
||||
# about to affect, and it is what makes the delete refusal predictable
|
||||
# instead of a surprise.
|
||||
counts = dict(db.session.query(Asset.levelid, db.func.count(Asset.assetid))
|
||||
.filter(Asset.levelid.isnot(None), Asset.isactive.is_(True))
|
||||
.group_by(Asset.levelid).all())
|
||||
|
||||
payload = []
|
||||
for building in buildings:
|
||||
entry = building.to_dict()
|
||||
for level in entry.get('levels', []):
|
||||
level['assetcount'] = counts.get(level['levelid'], 0)
|
||||
payload.append(entry)
|
||||
|
||||
default = MapLevel.default_level()
|
||||
return success_response({
|
||||
'buildings': payload,
|
||||
'defaultlevelid': default.levelid if default else None,
|
||||
'totalplaced': sum(counts.values()),
|
||||
})
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||
def get_level(levelid):
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level or not level.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
return success_response(level.to_dict())
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||
def serve_blueprint(filename, levelid=None):
|
||||
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||
return send_upload(_blueprint_dir(), filename)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Write - admin
|
||||
# =============================================================================
|
||||
|
||||
@maplevels_bp.route('/buildings', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_building():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('buildingname') or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname is required')
|
||||
if Building.query.filter(db.func.lower(Building.buildingname)
|
||||
== name.lower()).first():
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Building '{name}' already exists",
|
||||
http_code=409)
|
||||
building = Building(buildingname=name,
|
||||
sortorder=int(data.get('sortorder') or 0))
|
||||
db.session.add(building)
|
||||
db.session.commit()
|
||||
AuditLog.log('created', 'Building', entityid=building.buildingid,
|
||||
entityname=name)
|
||||
db.session.commit()
|
||||
return success_response(building.to_dict(), message='Building created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@maplevels_bp.route('/buildings/<int:buildingid>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_building(buildingid):
|
||||
"""Rename or reorder a building. Levels move with it; nothing repositions."""
|
||||
building = db.session.get(Building, buildingid)
|
||||
if not building:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||
http_code=404)
|
||||
data = request.get_json() or {}
|
||||
if 'buildingname' in data:
|
||||
name = (data['buildingname'] or '').strip()
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname cannot be blank')
|
||||
clash = Building.query.filter(
|
||||
db.func.lower(Building.buildingname) == name.lower(),
|
||||
Building.buildingid != buildingid).first()
|
||||
if clash:
|
||||
return error_response(ErrorCodes.CONFLICT,
|
||||
f"Building '{name}' already exists",
|
||||
http_code=409)
|
||||
building.buildingname = name
|
||||
if data.get('sortorder') is not None:
|
||||
building.sortorder = int(data['sortorder'])
|
||||
if 'isactive' in data:
|
||||
building.isactive = bool(data['isactive'])
|
||||
db.session.commit()
|
||||
AuditLog.log('updated', 'Building', entityid=buildingid,
|
||||
entityname=building.buildingname)
|
||||
db.session.commit()
|
||||
return success_response(building.to_dict(), message='Building updated')
|
||||
|
||||
|
||||
@maplevels_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def create_level():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('levelname') or '').strip()
|
||||
buildingid = data.get('buildingid')
|
||||
if not name or not buildingid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'buildingname and buildingid are required')
|
||||
if not db.session.get(Building, buildingid):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||
http_code=404)
|
||||
|
||||
level = MapLevel(
|
||||
buildingid=buildingid,
|
||||
levelname=name,
|
||||
sortorder=int(data.get('sortorder') or 0),
|
||||
blueprintlight=data.get('blueprintlight'),
|
||||
blueprintdark=data.get('blueprintdark'),
|
||||
mapwidth=int(data.get('mapwidth') or 3300),
|
||||
mapheight=int(data.get('mapheight') or 2550),
|
||||
)
|
||||
db.session.add(level)
|
||||
db.session.flush()
|
||||
_apply_default(level, data.get('isdefault'))
|
||||
db.session.commit()
|
||||
AuditLog.log('created', 'MapLevel', entityid=level.levelid,
|
||||
entityname=name)
|
||||
db.session.commit()
|
||||
return success_response(level.to_dict(), message='Level created',
|
||||
http_code=201)
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['PUT', 'PATCH'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def update_level(levelid):
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
data = request.get_json() or {}
|
||||
|
||||
# The dimensions are the coordinate space every marker on this level is
|
||||
# expressed in, so changing them moves every marker relative to the image.
|
||||
# Report it rather than doing it silently; the caller decides whether to run
|
||||
# a transform (POST /api/assets/map/transform).
|
||||
warnings = []
|
||||
for field in ('mapwidth', 'mapheight'):
|
||||
if field in data and int(data[field] or 0) != getattr(level, field):
|
||||
warnings.append(
|
||||
'%s changed from %s to %s; existing positions on this level are '
|
||||
'still in the old coordinate space' % (field,
|
||||
getattr(level, field),
|
||||
data[field]))
|
||||
|
||||
for field in ('levelname', 'blueprintlight', 'blueprintdark'):
|
||||
if field in data:
|
||||
setattr(level, field, data[field])
|
||||
for field in ('sortorder', 'mapwidth', 'mapheight'):
|
||||
if field in data and data[field] is not None:
|
||||
setattr(level, field, int(data[field]))
|
||||
if 'isactive' in data:
|
||||
level.isactive = bool(data['isactive'])
|
||||
_apply_default(level, data.get('isdefault'))
|
||||
|
||||
db.session.commit()
|
||||
AuditLog.log('updated', 'MapLevel', entityid=level.levelid,
|
||||
entityname=level.levelname)
|
||||
db.session.commit()
|
||||
payload = level.to_dict()
|
||||
if warnings:
|
||||
payload['warnings'] = warnings
|
||||
return success_response(payload, message='Level updated')
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def delete_level(levelid):
|
||||
"""Deactivate a level, refusing while assets are still placed on it.
|
||||
|
||||
Deleting the drawing out from under a marker would leave a position in a
|
||||
coordinate space that no longer exists - unrenderable, and indistinguishable
|
||||
from a marker that was never placed.
|
||||
"""
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||
if placed:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f'{placed} asset(s) are placed on this level. Move them to another '
|
||||
f'level first.', http_code=409)
|
||||
if level.isdefault:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'This is the default level. Make another level the default first.')
|
||||
level.isactive = False
|
||||
db.session.commit()
|
||||
AuditLog.log('deleted', 'MapLevel', entityid=levelid,
|
||||
entityname=level.levelname)
|
||||
db.session.commit()
|
||||
return success_response(message='Level deactivated')
|
||||
|
||||
|
||||
@maplevels_bp.route('/<int:levelid>/blueprint', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def upload_blueprint(levelid):
|
||||
"""Upload this level's blueprint for one theme.
|
||||
|
||||
multipart/form-data: file=<image>, theme=light|dark. The native pixel size
|
||||
is NOT inferred from the image - it is stated on the level, because that is
|
||||
what existing coordinates mean and guessing it would move every marker.
|
||||
"""
|
||||
level = db.session.get(MapLevel, levelid)
|
||||
if not level:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||
http_code=404)
|
||||
theme = (request.form.get('theme') or '').strip().lower()
|
||||
if theme not in ('light', 'dark'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'theme must be light or dark')
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in BLUEPRINT_EXTENSIONS:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported image type {ext}')
|
||||
|
||||
os.makedirs(_blueprint_dir(), exist_ok=True)
|
||||
filename = secure_filename(f'level-{levelid}-{theme}{ext}')
|
||||
raw = upload.read()
|
||||
upload.seek(0)
|
||||
upload.save(os.path.join(_blueprint_dir(), filename))
|
||||
url = f'/api/maplevels/{levelid}/blueprint/{filename}'
|
||||
setattr(level, f'blueprint{theme}', url)
|
||||
|
||||
# The image's real pixel size, read from its header. What happens next
|
||||
# depends entirely on whether anything is already placed on this level.
|
||||
detectedwidth, detectedheight = image_size(raw)
|
||||
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||
sizenote = None
|
||||
if detectedwidth and detectedheight:
|
||||
matches = (detectedwidth == level.mapwidth
|
||||
and detectedheight == level.mapheight)
|
||||
if matches:
|
||||
sizenote = None
|
||||
elif not placed:
|
||||
# Nothing is placed here yet, so no coordinate can be invalidated:
|
||||
# adopt the image's own size, which is almost certainly what the
|
||||
# operator wanted and saves them typing it.
|
||||
level.mapwidth = detectedwidth
|
||||
level.mapheight = detectedheight
|
||||
sizenote = ('dimensions set from the image: %d x %d'
|
||||
% (detectedwidth, detectedheight))
|
||||
else:
|
||||
# Markers exist in the OLD coordinate space. Silently adopting the
|
||||
# new size would move every one of them relative to the drawing
|
||||
# while looking like a successful upload, so this reports and
|
||||
# changes nothing. Resizing is a transform, not an upload.
|
||||
sizenote = (
|
||||
'this image is %d x %d but the level is set to %d x %d, and %d '
|
||||
'marker(s) are placed in the current space. The dimensions were '
|
||||
'NOT changed: set them with a landmark transform '
|
||||
'(POST /api/mappositions/transform) so the markers move with '
|
||||
'them.' % (detectedwidth, detectedheight, level.mapwidth,
|
||||
level.mapheight, placed))
|
||||
|
||||
db.session.commit()
|
||||
AuditLog.log('updated', 'MapLevel', entityid=levelid,
|
||||
entityname=level.levelname,
|
||||
changes={f'blueprint{theme}': {'new': url}})
|
||||
db.session.commit()
|
||||
payload = {'levelid': levelid, f'blueprint{theme}': url,
|
||||
'mapwidth': level.mapwidth, 'mapheight': level.mapheight,
|
||||
'detectedwidth': detectedwidth, 'detectedheight': detectedheight,
|
||||
'placedassets': placed}
|
||||
if sizenote:
|
||||
payload['sizenote'] = sizenote
|
||||
return success_response(payload, message='Blueprint uploaded')
|
||||
|
||||
|
||||
def _apply_default(level, requested):
|
||||
"""Make this level the default, clearing the flag elsewhere.
|
||||
|
||||
Exactly-one-default is a rule across rows, which no column constraint can
|
||||
express, so it is enforced here - the one place that sets the flag.
|
||||
"""
|
||||
if not requested:
|
||||
return
|
||||
MapLevel.query.filter(MapLevel.levelid != level.levelid).update(
|
||||
{'isdefault': False})
|
||||
level.isdefault = True
|
||||
403
shopdb/core/api/mappositions.py
Normal file
403
shopdb/core/api/mappositions.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Bulk marker positions: transform, place, verify, undo (ADR-017).
|
||||
|
||||
A new blueprint invalidates every marker on a level at once, so the operations
|
||||
here are deliberately bulk. Three rules shape all of them:
|
||||
|
||||
**A transform is derived from landmarks, never from image sizes.** The real case
|
||||
that motivated this is 3300x2550 to 3308x4000, where the second level was added
|
||||
below the first: the correct transform is identity scale with a Y offset, and a
|
||||
scale derived from the dimension ratio would stretch every Y by 1.57 and be wrong
|
||||
everywhere. Dimensions describe the canvas; landmarks describe the drawing.
|
||||
|
||||
**A transform is a guess, so it clears the review state.** The levels were
|
||||
redrawn and machines moved. Nothing in a coordinate says whether the machine it
|
||||
points at is still there, so every transformed marker is unreviewed until a human
|
||||
confirms it.
|
||||
|
||||
**Nothing bulk happens without a snapshot.** Positions have no history, and an
|
||||
operation that rewrites hundreds of rows needs a way back that is not a database
|
||||
restore.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import (Asset, AuditLog, MapLevel,
|
||||
MapPositionSnapshot, User)
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_permission
|
||||
|
||||
mappositions_bp = Blueprint('mappositions', __name__)
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _actor():
|
||||
try:
|
||||
user = db.session.get(User, int(get_jwt_identity()))
|
||||
return user.username if user else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The transform
|
||||
# =============================================================================
|
||||
|
||||
def solve_axis(pairs):
|
||||
"""Least-squares scale and offset for one axis: new = scale * old + offset.
|
||||
|
||||
Two pairs solve it exactly; more are averaged, which is worth having because
|
||||
a landmark picked by eye on a scanned drawing carries a few pixels of error
|
||||
and three points let that cancel instead of accumulate.
|
||||
|
||||
Returns None when the landmarks cannot determine the axis - every old value
|
||||
identical, so the denominator is zero. That is a real user error (two
|
||||
landmarks on the same column) and it must be reported rather than papered
|
||||
over with a scale of 1, which would look like it worked.
|
||||
"""
|
||||
count = len(pairs)
|
||||
if count < 2:
|
||||
return None
|
||||
sumold = sum(old for old, _ in pairs)
|
||||
sumnew = sum(new for _, new in pairs)
|
||||
sumoldsq = sum(old * old for old, _ in pairs)
|
||||
sumcross = sum(old * new for old, new in pairs)
|
||||
denominator = count * sumoldsq - sumold * sumold
|
||||
if abs(denominator) < 1e-9:
|
||||
return None
|
||||
scale = (count * sumcross - sumold * sumnew) / denominator
|
||||
offset = (sumnew - scale * sumold) / count
|
||||
return scale, offset
|
||||
|
||||
|
||||
def derive_transform(landmarks):
|
||||
"""Per-axis transform from [{'fromx','fromy','tox','toy'}, ...].
|
||||
|
||||
Per-axis rather than uniform on purpose. A level added below another changes
|
||||
the canvas height without rescaling anything, so Y gets an offset and X gets
|
||||
neither; forcing one scale onto both axes cannot express that.
|
||||
"""
|
||||
try:
|
||||
xpairs = [(float(mark['fromx']), float(mark['tox'])) for mark in landmarks]
|
||||
ypairs = [(float(mark['fromy']), float(mark['toy'])) for mark in landmarks]
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None, 'each landmark needs numeric fromx, fromy, tox and toy'
|
||||
|
||||
if len(landmarks) < 2:
|
||||
return None, 'at least two landmarks are required'
|
||||
|
||||
xsolution = solve_axis(xpairs)
|
||||
ysolution = solve_axis(ypairs)
|
||||
if xsolution is None:
|
||||
return None, ('the landmarks do not vary in X, so no horizontal scale '
|
||||
'can be derived - pick points that differ across the drawing')
|
||||
if ysolution is None:
|
||||
return None, ('the landmarks do not vary in Y, so no vertical scale '
|
||||
'can be derived - pick points that differ down the drawing')
|
||||
|
||||
return {
|
||||
'scalex': xsolution[0], 'offsetx': xsolution[1],
|
||||
'scaley': ysolution[0], 'offsety': ysolution[1],
|
||||
}, None
|
||||
|
||||
|
||||
def apply_transform(asset, transform):
|
||||
return (
|
||||
int(round(asset.mapx * transform['scalex'] + transform['offsetx'])),
|
||||
int(round(asset.mapy * transform['scaley'] + transform['offsety'])),
|
||||
)
|
||||
|
||||
|
||||
@mappositions_bp.route('/transform', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.edit')
|
||||
def transform_positions():
|
||||
"""Move every placed marker on a level by a transform read off landmarks.
|
||||
|
||||
Body:
|
||||
levelid the level whose markers are being moved (required)
|
||||
landmarks [{fromx, fromy, tox, toy}, ...] - two or more points, each the
|
||||
same physical feature on the old drawing and the new one
|
||||
tolevelid optional: write the results to a different level, for splitting
|
||||
one drawing into two
|
||||
assetids optional: restrict to these assets
|
||||
dryrun default TRUE. Nothing is written unless this is explicitly false.
|
||||
|
||||
Dry run returns every marker's old and new position and whether it lands
|
||||
outside the target level, which is the only way to see a bad landmark pair
|
||||
before it has moved 300 markers.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
levelid = data.get('levelid')
|
||||
level = db.session.get(MapLevel, levelid) if levelid else None
|
||||
if not level:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'levelid is required and must exist')
|
||||
|
||||
target = level
|
||||
if data.get('tolevelid'):
|
||||
target = db.session.get(MapLevel, data['tolevelid'])
|
||||
if not target:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such tolevelid',
|
||||
http_code=404)
|
||||
|
||||
transform, problem = derive_transform(data.get('landmarks') or [])
|
||||
if problem:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
||||
|
||||
query = Asset.query.filter(
|
||||
Asset.levelid == level.levelid,
|
||||
Asset.mapx.isnot(None), Asset.mapy.isnot(None),
|
||||
Asset.isactive.is_(True))
|
||||
if data.get('assetids'):
|
||||
query = query.filter(Asset.assetid.in_(data['assetids']))
|
||||
assets = query.order_by(Asset.assetid).all()
|
||||
|
||||
moves = []
|
||||
outofbounds = 0
|
||||
for asset in assets:
|
||||
newx, newy = apply_transform(asset, transform)
|
||||
outside = not (0 <= newx <= target.mapwidth and
|
||||
0 <= newy <= target.mapheight)
|
||||
if outside:
|
||||
outofbounds += 1
|
||||
moves.append({
|
||||
'assetid': asset.assetid,
|
||||
'assetnumber': asset.assetnumber,
|
||||
'name': asset.name,
|
||||
'fromx': asset.mapx, 'fromy': asset.mapy,
|
||||
'tox': newx, 'toy': newy,
|
||||
'outofbounds': outside,
|
||||
})
|
||||
|
||||
# The derived transform is reported back whichever mode this is, because it
|
||||
# is the number a human can sanity-check: a Y scale of 1.57 on a level that
|
||||
# only grew taller is the mistake this endpoint exists to avoid, and it is
|
||||
# obvious in the response and invisible in the result.
|
||||
payload = {
|
||||
'transform': transform,
|
||||
'levelid': level.levelid,
|
||||
'tolevelid': target.levelid,
|
||||
'assetcount': len(moves),
|
||||
'outofboundscount': outofbounds,
|
||||
'moves': moves,
|
||||
'dryrun': True,
|
||||
}
|
||||
|
||||
if data.get('dryrun', True):
|
||||
return success_response(payload)
|
||||
|
||||
snapshot = MapPositionSnapshot.capture(
|
||||
assets,
|
||||
reason='transform of %s (%d markers)' % (level.levelname, len(moves)),
|
||||
levelid=level.levelid, createdby=_actor())
|
||||
|
||||
for asset, move in zip(assets, moves):
|
||||
asset.mapx = move['tox']
|
||||
asset.mapy = move['toy']
|
||||
asset.levelid = target.levelid
|
||||
# Cleared, not preserved: a transformed position is a guess, and the
|
||||
# whole point of the review pass is to tell guesses from confirmations.
|
||||
asset.mapverifiedat = None
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Asset', entityname='%d marker(s) transformed'
|
||||
% len(moves),
|
||||
changes={'transform': transform,
|
||||
'snapshotid': snapshot.snapshotid})
|
||||
db.session.commit()
|
||||
|
||||
payload['dryrun'] = False
|
||||
payload['snapshotid'] = snapshot.snapshotid
|
||||
return success_response(payload, message='%d marker(s) moved; snapshot %d '
|
||||
'can restore them' % (len(moves),
|
||||
snapshot.snapshotid))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Bulk place and verify
|
||||
# =============================================================================
|
||||
|
||||
@mappositions_bp.route('/positions', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.edit')
|
||||
def set_positions():
|
||||
"""Set positions for many assets at once.
|
||||
|
||||
Body: {positions: [{assetid, mapx, mapy, levelid}], verified: bool}
|
||||
|
||||
`levelid` is required per position rather than taken from a single body-level
|
||||
value, because a bulk save from the editor can legitimately span levels, and
|
||||
inferring it would be the guess this whole feature exists to remove.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
rows = data.get('positions') or []
|
||||
if not rows:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'positions is required and must not be empty')
|
||||
|
||||
wanted = {}
|
||||
for row in rows:
|
||||
assetid = row.get('assetid')
|
||||
levelid = row.get('levelid')
|
||||
if not assetid or not levelid:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'every position needs an assetid and a levelid; a position '
|
||||
'without a level cannot be rendered')
|
||||
if row.get('mapx') is None or row.get('mapy') is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'every position needs mapx and mapy')
|
||||
wanted[int(assetid)] = row
|
||||
|
||||
levelids = {int(row['levelid']) for row in wanted.values()}
|
||||
known = {level.levelid for level in
|
||||
MapLevel.query.filter(MapLevel.levelid.in_(levelids)).all()}
|
||||
missing = levelids - known
|
||||
if missing:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'unknown levelid(s): %s'
|
||||
% ', '.join(str(one) for one in sorted(missing)),
|
||||
http_code=404)
|
||||
|
||||
assets = Asset.query.filter(Asset.assetid.in_(wanted)).all()
|
||||
found = {asset.assetid for asset in assets}
|
||||
unknown = set(wanted) - found
|
||||
if unknown:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'unknown assetid(s): %s'
|
||||
% ', '.join(str(one) for one in sorted(unknown)),
|
||||
http_code=404)
|
||||
|
||||
snapshot = MapPositionSnapshot.capture(
|
||||
assets, reason='bulk position set (%d markers)' % len(assets),
|
||||
createdby=_actor())
|
||||
|
||||
verified = _now() if data.get('verified') else None
|
||||
for asset in assets:
|
||||
row = wanted[asset.assetid]
|
||||
asset.mapx = int(row['mapx'])
|
||||
asset.mapy = int(row['mapy'])
|
||||
asset.levelid = int(row['levelid'])
|
||||
# Placing a marker by hand IS the confirmation, so this stamps it. A
|
||||
# caller that is only nudging things about can pass verified=false.
|
||||
if verified or data.get('verified') is not False:
|
||||
asset.mapverifiedat = verified or _now()
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Asset',
|
||||
entityname='%d marker position(s) set' % len(assets),
|
||||
changes={'snapshotid': snapshot.snapshotid})
|
||||
db.session.commit()
|
||||
return success_response({'updated': len(assets),
|
||||
'snapshotid': snapshot.snapshotid},
|
||||
message='%d position(s) saved' % len(assets))
|
||||
|
||||
|
||||
@mappositions_bp.route('/verify', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.edit')
|
||||
def verify_positions():
|
||||
"""Mark markers as reviewed against the current drawing without moving them.
|
||||
|
||||
This is the common case in a review pass: the transform put it in the right
|
||||
place and a human agrees. No snapshot, because nothing about the position
|
||||
changes - only the statement that somebody looked.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
assetids = data.get('assetids') or []
|
||||
if not assetids:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'assetids is required')
|
||||
stamp = None if data.get('unverify') else _now()
|
||||
updated = (Asset.query.filter(Asset.assetid.in_(assetids))
|
||||
.update({'mapverifiedat': stamp}, synchronize_session=False))
|
||||
db.session.commit()
|
||||
return success_response({'updated': updated},
|
||||
message='%d marker(s) %s' %
|
||||
(updated, 'unverified' if stamp is None
|
||||
else 'marked reviewed'))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Undo
|
||||
# =============================================================================
|
||||
|
||||
@mappositions_bp.route('/snapshots', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.view')
|
||||
def list_snapshots():
|
||||
"""Snapshots, newest first. Metadata only - the positions are large."""
|
||||
snapshots = (MapPositionSnapshot.query
|
||||
.order_by(MapPositionSnapshot.snapshotid.desc())
|
||||
.limit(50).all())
|
||||
return success_response([one.to_dict() for one in snapshots])
|
||||
|
||||
|
||||
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('assets.edit')
|
||||
def restore_snapshot(snapshotid):
|
||||
"""Put the positions in this snapshot back.
|
||||
|
||||
Takes its own snapshot first, so an undo is itself undoable - which matters
|
||||
because the most likely reason to restore is a transform that looked right in
|
||||
the preview and wrong on the drawing, and the second attempt is rarely the
|
||||
last one either.
|
||||
"""
|
||||
snapshot = db.session.get(MapPositionSnapshot, snapshotid)
|
||||
if not snapshot:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such snapshot',
|
||||
http_code=404)
|
||||
|
||||
rows = snapshot.positions
|
||||
if not rows:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'this snapshot holds no positions')
|
||||
|
||||
assetids = [row['assetid'] for row in rows]
|
||||
assets = {asset.assetid: asset for asset in
|
||||
Asset.query.filter(Asset.assetid.in_(assetids)).all()}
|
||||
|
||||
MapPositionSnapshot.capture(
|
||||
list(assets.values()),
|
||||
reason='before restoring snapshot %d' % snapshotid,
|
||||
levelid=snapshot.levelid, createdby=_actor())
|
||||
|
||||
restored = 0
|
||||
skipped = []
|
||||
for row in rows:
|
||||
asset = assets.get(row['assetid'])
|
||||
if asset is None:
|
||||
# The asset was deleted since the snapshot. Say so rather than
|
||||
# failing the whole restore: the other 299 markers still want
|
||||
# putting back.
|
||||
skipped.append(row['assetid'])
|
||||
continue
|
||||
asset.mapx = row.get('mapx')
|
||||
asset.mapy = row.get('mapy')
|
||||
asset.levelid = row.get('levelid')
|
||||
stamp = row.get('mapverifiedat')
|
||||
asset.mapverifiedat = datetime.fromisoformat(stamp) if stamp else None
|
||||
restored += 1
|
||||
|
||||
snapshot.restoredat = _now()
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Asset',
|
||||
entityname='%d marker(s) restored from snapshot %d'
|
||||
% (restored, snapshotid),
|
||||
changes={'snapshotid': snapshotid, 'skipped': skipped})
|
||||
db.session.commit()
|
||||
|
||||
return success_response(
|
||||
{'restored': restored, 'skippedassetids': skipped},
|
||||
message='%d marker(s) restored%s' %
|
||||
(restored, '; %d asset(s) no longer exist' % len(skipped)
|
||||
if skipped else ''))
|
||||
@@ -236,13 +236,26 @@ def _search_applications(query, search_term):
|
||||
|
||||
|
||||
def _search_knowledgebase(query, search_term):
|
||||
"""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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
109
shopdb/core/models/maplevel.py
Normal file
109
shopdb/core/models/maplevel.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Building + MapLevel models: which drawing renders an asset, at what size.
|
||||
|
||||
See ADR-017. A site used to have one floor map, described by four settings, and
|
||||
`assets.mapx`/`mapy` were pixels in that one image. A second level and a likely
|
||||
second building make that a table rather than a setting.
|
||||
|
||||
The alternative - stacking levels on one tall canvas - was rejected because it
|
||||
turns "which level is this on" into `mapy > 2550`: an inference over a magic
|
||||
number that changes whenever the drawing is re-exported.
|
||||
"""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class Building(BaseModel):
|
||||
"""A building at this site. Groups levels; holds nothing a level needs.
|
||||
|
||||
Separate from Location deliberately (ADR-017): a Location answers which
|
||||
operation owns an asset, a building groups the drawings it appears on.
|
||||
"""
|
||||
__tablename__ = 'buildings'
|
||||
|
||||
buildingid = db.Column(db.Integer, primary_key=True)
|
||||
buildingname = db.Column(db.String(100), nullable=False, unique=True)
|
||||
# Display order. Buildings have no natural ordering and their names are not
|
||||
# reliably ordinal ('Main', 'Annex', 'Building 2'), so the order is stated.
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
levels = db.relationship(
|
||||
'MapLevel', back_populates='building',
|
||||
order_by='MapLevel.sortorder', cascade='all, delete-orphan')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Building {self.buildingname}>"
|
||||
|
||||
def to_dict(self):
|
||||
data = super().to_dict()
|
||||
data['levels'] = [level.to_dict() for level in self.levels
|
||||
if level.isactive]
|
||||
return data
|
||||
|
||||
|
||||
class MapLevel(BaseModel):
|
||||
"""One drawing: a level of a building, with its own blueprint and size.
|
||||
|
||||
WHY THE DIMENSIONS LIVE HERE. They were site-wide settings, which cannot
|
||||
express a mezzanine drawn at a different scale from the floor below it, and
|
||||
certainly not a second building. `mapx`/`mapy` are absolute pixels in THIS
|
||||
level's coordinate space, so a level without its own dimensions cannot place
|
||||
a marker correctly.
|
||||
|
||||
Name and order are separate columns on purpose: levels are not reliably
|
||||
numbered (basement, ground, mezzanine, roof), and `sortorder` gives
|
||||
adjacency and up/down navigation without pretending the names are ordinal.
|
||||
It also lets a mezzanine be inserted later without renumbering anything.
|
||||
"""
|
||||
__tablename__ = 'maplevels'
|
||||
|
||||
levelid = db.Column(db.Integer, primary_key=True)
|
||||
buildingid = db.Column(
|
||||
db.Integer, db.ForeignKey('buildings.buildingid'), nullable=False,
|
||||
index=True)
|
||||
levelname = db.Column(db.String(100), nullable=False)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
# Both themes, because the map renders in whichever the viewer is using and
|
||||
# a light-on-white blueprint is unreadable in dark mode. Either may be
|
||||
# blank; the renderer falls back to the other rather than to nothing.
|
||||
blueprintlight = db.Column(db.String(255), nullable=True)
|
||||
blueprintdark = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Native pixel size of the blueprint. Positions are absolute pixels in this
|
||||
# space (ADR-017), so these are what a marker's coordinates mean.
|
||||
mapwidth = db.Column(db.Integer, nullable=False, default=3300)
|
||||
mapheight = db.Column(db.Integer, nullable=False, default=2550)
|
||||
|
||||
# Exactly one level carries this. It is where an asset with no level lands,
|
||||
# and what the map opens on. Enforced in the API rather than by a constraint,
|
||||
# because "exactly one" across rows is not a column-level rule.
|
||||
isdefault = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
building = db.relationship('Building', back_populates='levels')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('buildingid', 'levelname',
|
||||
name='uq_maplevel_building_name'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MapLevel {self.levelname}>"
|
||||
|
||||
def to_dict(self):
|
||||
data = super().to_dict()
|
||||
data['buildingname'] = self.building.buildingname if self.building else None
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def default_level(cls):
|
||||
"""The default level, or the lowest-sorted one if none is marked.
|
||||
|
||||
Never returns None on a seeded database: the migration that created this
|
||||
table also created one level from the settings it replaced.
|
||||
"""
|
||||
level = cls.query.filter_by(isdefault=True, isactive=True).first()
|
||||
if level is not None:
|
||||
return level
|
||||
return (cls.query.filter_by(isactive=True)
|
||||
.order_by(cls.sortorder, cls.levelid).first())
|
||||
76
shopdb/core/models/mapsnapshot.py
Normal file
76
shopdb/core/models/mapsnapshot.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""A saved set of marker positions, so a bulk change can be undone.
|
||||
|
||||
Positions had no history. A landmark transform rewrites every marker on a level
|
||||
in one statement, and without a way back the honest instruction would be "take a
|
||||
database backup first" - which nobody does before clicking a button in a UI, so
|
||||
in practice the feature would either not be used or be used once, badly.
|
||||
|
||||
One row holds the whole set as JSON rather than a row per asset. A snapshot is
|
||||
read back whole or not at all, so restore stays a single statement, and there is
|
||||
no orphan-child case to reason about.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class MapPositionSnapshot(BaseModel):
|
||||
__tablename__ = 'mappositionsnapshots'
|
||||
|
||||
snapshotid = db.Column(db.Integer, primary_key=True)
|
||||
# Which level the operation targeted. Nullable because a snapshot may span
|
||||
# levels (moving assets between them), and then no single level owns it.
|
||||
levelid = db.Column(db.Integer, nullable=True)
|
||||
reason = db.Column(db.String(255), nullable=True)
|
||||
assetcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
positionsjson = db.Column(db.Text, nullable=False)
|
||||
# Set when this snapshot has been restored, so the history reads as what
|
||||
# happened rather than as a list of identical-looking saves.
|
||||
restoredat = db.Column(db.DateTime, nullable=True)
|
||||
createdby = db.Column(db.String(100), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MapPositionSnapshot {self.snapshotid} ({self.assetcount})>"
|
||||
|
||||
@property
|
||||
def positions(self):
|
||||
try:
|
||||
return json.loads(self.positionsjson or '[]')
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
def to_dict(self):
|
||||
"""Metadata only. The positions themselves are large and nobody browsing
|
||||
a list of snapshots wants them."""
|
||||
data = super().to_dict()
|
||||
data.pop('positionsjson', None)
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def capture(cls, assets, reason, levelid=None, createdby=None):
|
||||
"""Record the CURRENT positions of these assets, before they change.
|
||||
|
||||
Includes levelid and mapverifiedat, not just the coordinates: a restore
|
||||
has to put a marker back on the level it was on and with the review state
|
||||
it had, or undo would silently mark reviewed work as unreviewed.
|
||||
"""
|
||||
rows = [{
|
||||
'assetid': asset.assetid,
|
||||
'mapx': asset.mapx,
|
||||
'mapy': asset.mapy,
|
||||
'levelid': asset.levelid,
|
||||
'mapverifiedat': asset.mapverifiedat.isoformat()
|
||||
if asset.mapverifiedat else None,
|
||||
} for asset in assets]
|
||||
snapshot = cls(
|
||||
levelid=levelid,
|
||||
reason=reason,
|
||||
assetcount=len(rows),
|
||||
positionsjson=json.dumps(rows, separators=(',', ':')),
|
||||
createdby=createdby,
|
||||
)
|
||||
db.session.add(snapshot)
|
||||
db.session.flush()
|
||||
return snapshot
|
||||
134
shopdb/utils/imagesize.py
Normal file
134
shopdb/utils/imagesize.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Read an image's pixel dimensions from its header, with no image library.
|
||||
|
||||
A level's `mapwidth`/`mapheight` are the coordinate space every marker on it is
|
||||
expressed in, so getting them wrong moves every marker relative to the drawing.
|
||||
Reading them off the uploaded file removes the most likely way to get them
|
||||
wrong, which is somebody typing what they remember.
|
||||
|
||||
Pillow would do this in one line and is not a dependency. Adding it would mean a
|
||||
new cp314 win_amd64 wheel in the offline installer's hash-pinned wheelhouse -
|
||||
built on Windows, verified against bundle-lock.json, shipped in a 240 MB
|
||||
installer - to read two integers out of a header. This is the cheaper trade.
|
||||
|
||||
Returns (width, height), or (None, None) when the format is not one of these or
|
||||
the header is truncated. A caller must treat None as "ask the operator" and
|
||||
never as a default.
|
||||
"""
|
||||
|
||||
import re
|
||||
import struct
|
||||
|
||||
|
||||
def png_size(data):
|
||||
# An IHDR chunk always follows the 8-byte signature, and its first two
|
||||
# fields are width and height as big-endian 32-bit integers.
|
||||
if len(data) < 24 or data[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
return None, None
|
||||
if data[12:16] != b'IHDR':
|
||||
return None, None
|
||||
width, height = struct.unpack('>II', data[16:24])
|
||||
return width, height
|
||||
|
||||
|
||||
def gif_size(data):
|
||||
if len(data) < 10 or data[:6] not in (b'GIF87a', b'GIF89a'):
|
||||
return None, None
|
||||
width, height = struct.unpack('<HH', data[6:10])
|
||||
return width, height
|
||||
|
||||
|
||||
def webp_size(data):
|
||||
if len(data) < 30 or data[:4] != b'RIFF' or data[8:12] != b'WEBP':
|
||||
return None, None
|
||||
kind = data[12:16]
|
||||
if kind == b'VP8 ':
|
||||
width, height = struct.unpack('<HH', data[26:30])
|
||||
return width & 0x3FFF, height & 0x3FFF
|
||||
if kind == b'VP8L':
|
||||
bits = struct.unpack('<I', data[21:25])[0]
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
if kind == b'VP8X':
|
||||
width = int.from_bytes(data[24:27], 'little') + 1
|
||||
height = int.from_bytes(data[27:30], 'little') + 1
|
||||
return width, height
|
||||
return None, None
|
||||
|
||||
|
||||
def jpeg_size(data):
|
||||
"""Walk the marker segments to the start-of-frame, which carries the size.
|
||||
|
||||
JPEG has no fixed header offset - the dimensions live in whichever SOF
|
||||
marker the encoder used, after any number of application and comment
|
||||
segments of varying length. So this walks rather than indexes.
|
||||
"""
|
||||
if len(data) < 4 or data[:2] != b'\xff\xd8':
|
||||
return None, None
|
||||
index = 2
|
||||
end = len(data)
|
||||
while index < end - 9:
|
||||
if data[index] != 0xFF:
|
||||
index += 1
|
||||
continue
|
||||
marker = data[index + 1]
|
||||
# SOF0 through SOF15, excluding the DHT/JPG/DAC markers interleaved
|
||||
# in that range, all carry height then width at the same offset.
|
||||
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
|
||||
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
||||
height, width = struct.unpack('>HH', data[index + 5:index + 9])
|
||||
return width, height
|
||||
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD9:
|
||||
index += 2
|
||||
continue
|
||||
segment = struct.unpack('>H', data[index + 2:index + 4])[0]
|
||||
index += 2 + segment
|
||||
return None, None
|
||||
|
||||
|
||||
def svg_size(data):
|
||||
"""SVG states a size in attributes, or implies one through viewBox.
|
||||
|
||||
Width and height may carry units (mm, in, pt) or be percentages, and a
|
||||
percentage says nothing about pixels - so a unit that is not px falls back to
|
||||
the viewBox, which is unitless user space and is what a renderer scales to.
|
||||
"""
|
||||
try:
|
||||
head = data[:4096].decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
return None, None
|
||||
if '<svg' not in head:
|
||||
return None, None
|
||||
|
||||
def attribute(name):
|
||||
found = re.search(r'\b%s\s*=\s*["\']([^"\']+)["\']' % name, head)
|
||||
return found.group(1).strip() if found else None
|
||||
|
||||
def pixels(value):
|
||||
if not value:
|
||||
return None
|
||||
match = re.match(r'^([0-9.]+)\s*(px)?$', value)
|
||||
return int(round(float(match.group(1)))) if match else None
|
||||
|
||||
width = pixels(attribute('width'))
|
||||
height = pixels(attribute('height'))
|
||||
if width and height:
|
||||
return width, height
|
||||
|
||||
viewbox = attribute('viewBox')
|
||||
if viewbox:
|
||||
parts = re.split(r'[\s,]+', viewbox.strip())
|
||||
if len(parts) == 4:
|
||||
try:
|
||||
return (int(round(float(parts[2]))),
|
||||
int(round(float(parts[3]))))
|
||||
except ValueError:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def image_size(data):
|
||||
"""Dimensions of PNG, JPEG, GIF, WEBP or SVG data. (None, None) otherwise."""
|
||||
for reader in (png_size, jpeg_size, gif_size, webp_size, svg_size):
|
||||
width, height = reader(data)
|
||||
if width and height:
|
||||
return width, height
|
||||
return None, None
|
||||
@@ -46,42 +46,60 @@ def test_resolve_asset_position_uses_self_when_set():
|
||||
class FakeLocation:
|
||||
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():
|
||||
|
||||
236
tests/test_core/test_map_transform.py
Normal file
236
tests/test_core/test_map_transform.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""The landmark transform, and the wrong answer it must not give.
|
||||
|
||||
The case this exists for: a site's blueprint goes from 3300x2550 to 3308x4000
|
||||
because a second level was added below the first. The existing floor is drawn at
|
||||
the same scale - so the correct transform is identity, or identity plus an
|
||||
offset. A transform derived from the ratio of image dimensions would scale Y by
|
||||
4000/2550 = 1.5686 and be wrong for every marker on the level, while looking
|
||||
like arithmetic somebody had thought about.
|
||||
|
||||
So the first test here is not that the transform works. It is that it does not
|
||||
produce that particular plausible wrong answer.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Asset, AssetType, Building, MapLevel
|
||||
from shopdb.core.api.mappositions import derive_transform, solve_axis
|
||||
|
||||
|
||||
# -- the arithmetic, with no app or database involved ------------------------
|
||||
|
||||
def test_two_landmarks_give_identity_when_the_drawing_did_not_move():
|
||||
"""A canvas that grew taller with the old floor untouched: scale 1, no offset.
|
||||
|
||||
This is the assertion that rules out the dimension-derived answer. Nothing
|
||||
about these landmarks mentions 2550 or 4000, and the result must not either.
|
||||
"""
|
||||
transform, problem = derive_transform([
|
||||
{'fromx': 100, 'fromy': 100, 'tox': 100, 'toy': 100},
|
||||
{'fromx': 3000, 'fromy': 2000, 'tox': 3000, 'toy': 2000},
|
||||
])
|
||||
assert problem is None
|
||||
assert transform['scalex'] == pytest.approx(1.0)
|
||||
assert transform['scaley'] == pytest.approx(1.0)
|
||||
assert transform['offsetx'] == pytest.approx(0.0)
|
||||
assert transform['offsety'] == pytest.approx(0.0)
|
||||
# The wrong answer, stated so a regression names itself.
|
||||
assert transform['scaley'] != pytest.approx(4000 / 2550, abs=0.01)
|
||||
|
||||
|
||||
def test_a_level_added_above_gives_a_pure_y_offset():
|
||||
"""Existing floor pushed down by 1450px: identity scale, offset 1450."""
|
||||
transform, problem = derive_transform([
|
||||
{'fromx': 100, 'fromy': 100, 'tox': 100, 'toy': 1550},
|
||||
{'fromx': 3000, 'fromy': 2000, 'tox': 3000, 'toy': 3450},
|
||||
])
|
||||
assert problem is None
|
||||
assert transform['scaley'] == pytest.approx(1.0)
|
||||
assert transform['offsety'] == pytest.approx(1450.0)
|
||||
assert transform['scalex'] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_a_genuine_rescale_is_derived_per_axis():
|
||||
"""X and Y can scale differently, which one uniform factor cannot express."""
|
||||
transform, _ = derive_transform([
|
||||
{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 0},
|
||||
{'fromx': 1000, 'fromy': 1000, 'tox': 2000, 'toy': 1500},
|
||||
])
|
||||
assert transform['scalex'] == pytest.approx(2.0)
|
||||
assert transform['scaley'] == pytest.approx(1.5)
|
||||
|
||||
|
||||
def test_a_third_landmark_averages_measurement_error():
|
||||
"""Points picked by eye carry a few pixels of error; more points cancel it
|
||||
rather than accumulating it."""
|
||||
transform, _ = derive_transform([
|
||||
{'fromx': 0, 'fromy': 0, 'tox': 2, 'toy': -2},
|
||||
{'fromx': 1000, 'fromy': 1000, 'tox': 999, 'toy': 1001},
|
||||
{'fromx': 2000, 'fromy': 2000, 'tox': 2001, 'toy': 1999},
|
||||
])
|
||||
assert transform['scalex'] == pytest.approx(1.0, abs=0.01)
|
||||
assert transform['scaley'] == pytest.approx(1.0, abs=0.01)
|
||||
|
||||
|
||||
def test_landmarks_on_one_column_are_refused_not_guessed():
|
||||
"""Two points with the same X cannot determine a horizontal scale.
|
||||
|
||||
Returning 1.0 here would look like success and silently leave X unscaled,
|
||||
which is the failure a user would discover after the write.
|
||||
"""
|
||||
transform, problem = derive_transform([
|
||||
{'fromx': 500, 'fromy': 100, 'tox': 600, 'toy': 100},
|
||||
{'fromx': 500, 'fromy': 2000, 'tox': 600, 'toy': 2000},
|
||||
])
|
||||
assert transform is None
|
||||
assert 'X' in problem
|
||||
|
||||
|
||||
def test_one_landmark_is_not_enough():
|
||||
transform, problem = derive_transform([
|
||||
{'fromx': 1, 'fromy': 1, 'tox': 2, 'toy': 2}])
|
||||
assert transform is None
|
||||
assert 'two landmarks' in problem
|
||||
|
||||
|
||||
def test_solve_axis_reports_an_undetermined_axis():
|
||||
assert solve_axis([(5, 9), (5, 11)]) is None
|
||||
assert solve_axis([(0, 0), (10, 20)]) == (2.0, 0.0)
|
||||
|
||||
|
||||
# -- end to end, against the endpoints --------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def floor(db):
|
||||
"""One building, two levels sized like the real before and after."""
|
||||
assettype = AssetType.query.filter_by(assettype='machine').first()
|
||||
if not assettype:
|
||||
assettype = AssetType(assettype='machine')
|
||||
db.session.add(assettype)
|
||||
db.session.flush()
|
||||
building = Building(buildingname='Main')
|
||||
db.session.add(building)
|
||||
db.session.flush()
|
||||
ground = MapLevel(buildingid=building.buildingid, levelname='Ground floor',
|
||||
sortorder=0, isdefault=True, mapwidth=3300, mapheight=2550)
|
||||
second = MapLevel(buildingid=building.buildingid, levelname='Second floor',
|
||||
sortorder=1, mapwidth=3308, mapheight=4000)
|
||||
db.session.add_all([ground, second])
|
||||
db.session.flush()
|
||||
assets = []
|
||||
for index, (x, y) in enumerate([(100, 100), (1500, 1200), (3200, 2500)]):
|
||||
asset = Asset(assetnumber='M%03d' % index, assettypeid=assettype.assettypeid,
|
||||
mapx=x, mapy=y, levelid=ground.levelid,
|
||||
mapverifiedat=datetime(2026, 1, 1))
|
||||
db.session.add(asset)
|
||||
assets.append(asset)
|
||||
db.session.commit()
|
||||
return {'ground': ground, 'second': second, 'assets': assets}
|
||||
|
||||
|
||||
def _transform(client, headers, **body):
|
||||
return client.post('/api/mappositions/transform', json=body, headers=headers)
|
||||
|
||||
|
||||
def test_a_dry_run_writes_nothing(client, db, floor, auth_headers):
|
||||
before = [(a.mapx, a.mapy, a.mapverifiedat) for a in floor['assets']]
|
||||
resp = _transform(client, auth_headers,
|
||||
levelid=floor['ground'].levelid,
|
||||
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
|
||||
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()['data']
|
||||
assert body['dryrun'] is True
|
||||
assert body['assetcount'] == 3
|
||||
db.session.expire_all()
|
||||
after = [(a.mapx, a.mapy, a.mapverifiedat)
|
||||
for a in Asset.query.order_by(Asset.assetid).all()]
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_applying_moves_markers_clears_review_and_snapshots(
|
||||
client, db, floor, auth_headers):
|
||||
resp = _transform(client, auth_headers,
|
||||
levelid=floor['ground'].levelid, dryrun=False,
|
||||
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
|
||||
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()['data']
|
||||
assert body['dryrun'] is False
|
||||
assert body['snapshotid']
|
||||
|
||||
db.session.expire_all()
|
||||
moved = Asset.query.filter_by(assetnumber='M000').first()
|
||||
assert (moved.mapx, moved.mapy) == (100, 1550)
|
||||
# A transform is a guess, so the review state it had is gone.
|
||||
assert moved.mapverifiedat is None
|
||||
|
||||
|
||||
def test_markers_pushed_off_the_canvas_are_counted(client, db, floor, auth_headers):
|
||||
"""A marker at y=2500 offset by 1600 lands at 4100, past the 4000px canvas.
|
||||
|
||||
Worth checking because a bad landmark pair is most likely to show up as
|
||||
markers leaving the drawing entirely, and a preview that does not count them
|
||||
lets that through.
|
||||
"""
|
||||
resp = _transform(client, auth_headers,
|
||||
levelid=floor['ground'].levelid,
|
||||
tolevelid=floor['second'].levelid,
|
||||
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1600},
|
||||
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2600}])
|
||||
body = resp.get_json()['data']
|
||||
assert body['outofboundscount'] == 1
|
||||
offender = next(m for m in body['moves'] if m['outofbounds'])
|
||||
assert offender['toy'] > floor['second'].mapheight
|
||||
|
||||
|
||||
def test_restore_puts_positions_and_review_state_back(
|
||||
client, db, floor, auth_headers):
|
||||
original = {a.assetnumber: (a.mapx, a.mapy, a.mapverifiedat)
|
||||
for a in floor['assets']}
|
||||
applied = _transform(client, auth_headers,
|
||||
levelid=floor['ground'].levelid, dryrun=False,
|
||||
landmarks=[{'fromx': 0, 'fromy': 0, 'tox': 0, 'toy': 1450},
|
||||
{'fromx': 1000, 'fromy': 1000, 'tox': 1000, 'toy': 2450}])
|
||||
snapshotid = applied.get_json()['data']['snapshotid']
|
||||
|
||||
resp = client.post('/api/mappositions/snapshots/%d/restore' % snapshotid,
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['restored'] == 3
|
||||
|
||||
db.session.expire_all()
|
||||
for asset in Asset.query.all():
|
||||
assert (asset.mapx, asset.mapy, asset.mapverifiedat) == \
|
||||
original[asset.assetnumber]
|
||||
|
||||
|
||||
def test_a_position_without_a_level_is_refused(client, db, floor, auth_headers):
|
||||
"""The rule the whole model rests on: a position with no level cannot be
|
||||
stored, because it cannot be rendered."""
|
||||
resp = client.post('/api/mappositions/positions', json={
|
||||
'positions': [{'assetid': floor['assets'][0].assetid,
|
||||
'mapx': 10, 'mapy': 20}]}, headers=auth_headers)
|
||||
assert resp.status_code == 400
|
||||
assert 'levelid' in resp.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_placing_by_hand_counts_as_review(client, db, floor, auth_headers):
|
||||
asset = floor['assets'][0]
|
||||
client.post('/api/mappositions/verify',
|
||||
json={'assetids': [asset.assetid], 'unverify': True},
|
||||
headers=auth_headers)
|
||||
db.session.expire_all()
|
||||
assert Asset.query.get(asset.assetid).mapverifiedat is None
|
||||
|
||||
resp = client.post('/api/mappositions/positions', json={
|
||||
'positions': [{'assetid': asset.assetid, 'mapx': 42, 'mapy': 43,
|
||||
'levelid': floor['second'].levelid}]},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
db.session.expire_all()
|
||||
moved = Asset.query.get(asset.assetid)
|
||||
assert (moved.mapx, moved.mapy, moved.levelid) == (42, 43, floor['second'].levelid)
|
||||
assert moved.mapverifiedat is not None
|
||||
201
tests/test_core/test_search_coverage.py
Normal file
201
tests/test_core/test_search_coverage.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""What global search can reach, and what it must not return.
|
||||
|
||||
Two plugins own records that are NOT assets - USB devices and printed items -
|
||||
so the generic asset search cannot see them, and nothing else did either: those
|
||||
records were unreachable from search entirely while their list pages existed and
|
||||
their labels were printed on physical bins and sticks.
|
||||
|
||||
The other half of this file is the retired-application rule. Hiding an article
|
||||
about a decommissioned application from the plugin's own listing while global
|
||||
search still returned it is not a rule at all - the article stayed two
|
||||
keystrokes away, and the result printed the retired application as its subject.
|
||||
A filter is only real if every path that reaches the row applies it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
def search(client, query):
|
||||
"""Global search rows for a query."""
|
||||
resp = client.get(f'/api/search?q={query}')
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data']['results']
|
||||
|
||||
|
||||
def titles(client, query, resulttype=None):
|
||||
return [r['title'] for r in search(client, query)
|
||||
if resulttype is None or r['type'] == resulttype]
|
||||
|
||||
|
||||
# --- USB devices (usb plugin, not an asset) ---------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def usbdevices(db):
|
||||
"""One live device and one deactivated one, distinctive enough that only the
|
||||
USB searcher can match them."""
|
||||
from plugins.usb.models import USBDevice
|
||||
live = USBDevice(serialnumber='ZZSERIAL1234', label='Loaner stick 4',
|
||||
assetnumber='USB-0004', productname='Kingston DataTraveler',
|
||||
isactive=True)
|
||||
retired = USBDevice(serialnumber='ZZSERIAL9999', label='Dead stick',
|
||||
assetnumber='USB-0009', productname='Kingston DataTraveler',
|
||||
isactive=False)
|
||||
db.session.add_all([live, retired])
|
||||
db.session.commit()
|
||||
return {'live': live.usbdeviceid, 'retired': retired.usbdeviceid}
|
||||
|
||||
|
||||
def test_a_usb_device_is_found_by_serial(client, usbdevices):
|
||||
"""The serial is what is printed on the stick in someone's hand."""
|
||||
rows = [r for r in search(client, 'ZZSERIAL1234') if r['type'] == 'usb_device']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == usbdevices['live']
|
||||
assert rows[0]['url'] == f"/usb/{usbdevices['live']}"
|
||||
# An exact serial is the strongest possible match for this domain.
|
||||
assert rows[0]['relevance'] == 100
|
||||
|
||||
|
||||
def test_a_usb_device_is_found_by_label_and_asset_tag(client, usbdevices):
|
||||
assert 'Loaner stick 4' in titles(client, 'Loaner', 'usb_device')
|
||||
assert 'Loaner stick 4' in titles(client, 'USB-0004', 'usb_device')
|
||||
|
||||
|
||||
def test_a_deactivated_usb_device_is_not_found(client, usbdevices):
|
||||
assert titles(client, 'ZZSERIAL9999', 'usb_device') == []
|
||||
# And it does not ride along on a query that matches both devices.
|
||||
assert 'Dead stick' not in titles(client, 'Kingston', 'usb_device')
|
||||
|
||||
|
||||
def test_a_usb_holder_is_not_searchable_by_name(client, db):
|
||||
"""currentusername records who holds a device. Making search a way to list
|
||||
what a named person checked out is a different feature, deliberately not
|
||||
built into this searcher."""
|
||||
from plugins.usb.models import USBDevice
|
||||
db.session.add(USBDevice(serialnumber='ZZSERIAL5555', label='Held stick',
|
||||
currentusername='Distinctivesurname', isactive=True))
|
||||
db.session.commit()
|
||||
assert titles(client, 'Distinctivesurname', 'usb_device') == []
|
||||
|
||||
|
||||
# --- Printed items (printedparts plugin, not an asset) ----------------------
|
||||
|
||||
@pytest.fixture
|
||||
def printeditems(db):
|
||||
from plugins.printedparts.models import PrintedItem
|
||||
live = PrintedItem(itemcode='3DP-8001', gagelabtag='WJRP8001',
|
||||
itemname='Fixture clamp', itemdescription='Holds a part',
|
||||
binlocation='Bin 12', quantityonhand=4,
|
||||
lowstockthreshold=2, isactive=True)
|
||||
retired = PrintedItem(itemcode='3DP-8009', gagelabtag='WJRP8009',
|
||||
itemname='Obsolete clamp', quantityonhand=0,
|
||||
lowstockthreshold=1, isactive=False)
|
||||
db.session.add_all([live, retired])
|
||||
db.session.commit()
|
||||
return {'live': live.printeditemid, 'retired': retired.printeditemid}
|
||||
|
||||
|
||||
def test_a_printed_item_is_found_by_bin_code(client, printeditems):
|
||||
"""itemcode is the bin label someone reads off the shelf."""
|
||||
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == printeditems['live']
|
||||
assert rows[0]['url'] == f"/printedparts/{printeditems['live']}"
|
||||
assert rows[0]['relevance'] == 100
|
||||
|
||||
|
||||
def test_a_printed_item_is_found_by_gage_lab_tag(client, printeditems):
|
||||
rows = [r for r in search(client, 'WJRP8001') if r['type'] == 'printed_item']
|
||||
assert [r['title'] for r in rows] == ['Fixture clamp']
|
||||
|
||||
|
||||
def test_a_printed_item_is_found_by_name(client, printeditems):
|
||||
assert 'Fixture clamp' in titles(client, 'Fixture clamp', 'printed_item')
|
||||
|
||||
|
||||
def test_a_printed_item_subtitle_locates_it(client, printeditems):
|
||||
"""A hit is only useful if it says where to go and get the part."""
|
||||
rows = [r for r in search(client, '3DP-8001') if r['type'] == 'printed_item']
|
||||
assert 'Bin 12' in rows[0]['subtitle']
|
||||
|
||||
|
||||
def test_a_deactivated_printed_item_is_not_found(client, printeditems):
|
||||
assert titles(client, '3DP-8009', 'printed_item') == []
|
||||
assert 'Obsolete clamp' not in titles(client, 'clamp', 'printed_item')
|
||||
|
||||
|
||||
# --- The retired-application rule reaches global search too ----------------
|
||||
|
||||
@pytest.fixture
|
||||
def kbarticles(db):
|
||||
from plugins.knowledgebase.models import KnowledgeBase
|
||||
live = Application(appname='Livesearchapp', isactive=True)
|
||||
retired = Application(appname='Retiredsearchapp', isactive=False)
|
||||
db.session.add_all([live, retired])
|
||||
db.session.flush()
|
||||
db.session.add_all([
|
||||
KnowledgeBase(shortdescription='Zzrunbook for the live one', linkurl='u',
|
||||
keywords='zzkeyword', appid=live.appid, isactive=True),
|
||||
KnowledgeBase(shortdescription='Zzrunbook for the retired one', linkurl='u',
|
||||
keywords='zzkeyword', appid=retired.appid, isactive=True),
|
||||
KnowledgeBase(shortdescription='Zzrunbook with no topic', linkurl='u',
|
||||
keywords='zzkeyword', appid=None, isactive=True),
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_global_search_hides_an_article_about_a_retired_application(client, kbarticles):
|
||||
found = titles(client, 'zzkeyword', 'knowledgebase')
|
||||
assert 'Zzrunbook for the retired one' not in found
|
||||
assert 'Zzrunbook for the live one' in found
|
||||
|
||||
|
||||
def test_global_search_keeps_an_article_with_no_topic(client, kbarticles):
|
||||
"""A null topic is not a retired one."""
|
||||
assert 'Zzrunbook with no topic' in titles(client, 'zzkeyword', 'knowledgebase')
|
||||
|
||||
|
||||
def test_global_search_hides_a_retired_application_itself(client, kbarticles):
|
||||
"""The application domain has always filtered isactive; pinned here so the
|
||||
two rules stay together and cannot drift apart again."""
|
||||
assert titles(client, 'Retiredsearchapp', 'application') == []
|
||||
assert titles(client, 'Livesearchapp', 'application') == ['Livesearchapp']
|
||||
|
||||
|
||||
# --- The optional asset identifiers (ADR-001) -------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def taggedmachine(client, db, auth_headers):
|
||||
"""A machine carrying both optional identifiers.
|
||||
|
||||
A MACHINE on purpose, not a measuring tool: gaugelabreference was searchable
|
||||
only through the measuring-tools searcher, so the same tag on a machine
|
||||
matched nothing even though Settings offers the identifier for machines.
|
||||
"""
|
||||
from shopdb.core.models import AssetType, Asset
|
||||
if not AssetType.query.filter_by(assettype='machine').first():
|
||||
db.session.add(AssetType(assettype='machine', pluginname='machines',
|
||||
tablename='machines', description='m'))
|
||||
db.session.commit()
|
||||
resp = client.post('/api/machines', json={'assetnumber': 'ZZMACH01'},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
asset = Asset.query.filter_by(assetnumber='ZZMACH01').first()
|
||||
asset.gaugelabreference = 'ZZGAUGE777'
|
||||
asset.maintenancereference = 'ZZMAINT888'
|
||||
db.session.commit()
|
||||
return asset.assetid
|
||||
|
||||
|
||||
def test_a_machine_is_found_by_its_gauge_lab_reference(client, taggedmachine):
|
||||
rows = [r for r in search(client, 'ZZGAUGE777') if r['id'] == taggedmachine
|
||||
or r['title'] == 'ZZMACH01']
|
||||
assert rows, 'a gauge-lab tag on a machine matched nothing'
|
||||
|
||||
|
||||
def test_a_machine_is_found_by_its_maintenance_reference(client, taggedmachine):
|
||||
"""maintenancereference was searched by nothing at all, for any asset
|
||||
type - a field the UI collects and search could not find."""
|
||||
rows = [r for r in search(client, 'ZZMAINT888') if r['title'] == 'ZZMACH01']
|
||||
assert rows, 'a maintenance reference matched nothing'
|
||||
106
tests/test_plugins/test_knowledgebase_visibility.py
Normal file
106
tests/test_plugins/test_knowledgebase_visibility.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""What a knowledge base article's topic decides about seeing the article.
|
||||
|
||||
An article's topic is an Application. When that application is retired the
|
||||
article describes something no longer in service, and showing it beside live
|
||||
documentation reads as though it were current - so it does not show at all.
|
||||
|
||||
`isactive` is the ONLY property of an application that decides this. In
|
||||
particular `ishidden`, which governs whether an application appears on the tiles
|
||||
page, says nothing about whether it can be the subject of an article.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import Application
|
||||
from plugins.knowledgebase.models import KnowledgeBase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library(db):
|
||||
"""One application of each interesting shape, with an article each."""
|
||||
applications = {
|
||||
'live': Application(appname='Live App', isinstallable=True,
|
||||
ishidden=False, isactive=True),
|
||||
'notinstallable': Application(appname='Manual App', isinstallable=False,
|
||||
ishidden=False, isactive=True),
|
||||
'hidden': Application(appname='Hidden App', isinstallable=False,
|
||||
ishidden=True, isactive=True),
|
||||
'retired': Application(appname='Retired App', isinstallable=True,
|
||||
ishidden=False, isactive=False),
|
||||
}
|
||||
db.session.add_all(applications.values())
|
||||
db.session.flush()
|
||||
|
||||
articles = {
|
||||
'live': KnowledgeBase(shortdescription='Live runbook', linkurl='u',
|
||||
appid=applications['live'].appid, clicks=3,
|
||||
isactive=True),
|
||||
'hidden': KnowledgeBase(shortdescription='Hidden runbook', linkurl='u',
|
||||
appid=applications['hidden'].appid, clicks=2,
|
||||
isactive=True),
|
||||
'retired': KnowledgeBase(shortdescription='Retired runbook', linkurl='u',
|
||||
appid=applications['retired'].appid, clicks=5,
|
||||
isactive=True),
|
||||
'topicless': KnowledgeBase(shortdescription='General note', linkurl='u',
|
||||
appid=None, clicks=1, isactive=True),
|
||||
}
|
||||
db.session.add_all(articles.values())
|
||||
db.session.commit()
|
||||
return {'applications': applications, 'articles': articles}
|
||||
|
||||
|
||||
def titles(client, url='/api/knowledgebase'):
|
||||
return sorted(row['shortdescription']
|
||||
for row in client.get(url).get_json()['data'])
|
||||
|
||||
|
||||
def test_an_article_about_a_retired_application_does_not_show(client, library):
|
||||
assert 'Retired runbook' not in titles(client)
|
||||
|
||||
|
||||
def test_an_article_with_no_topic_still_shows(client, library):
|
||||
"""A null topic is not a retired one. Not every article is about an
|
||||
application, and those must not be collateral damage."""
|
||||
assert 'General note' in titles(client)
|
||||
|
||||
|
||||
def test_a_hidden_application_is_still_a_valid_topic(client, library):
|
||||
"""ishidden keeps an application off the tiles page. It says nothing about
|
||||
documentation, and isactive is the only filter that applies here."""
|
||||
assert 'Hidden runbook' in titles(client)
|
||||
|
||||
|
||||
def test_searching_a_retired_application_name_finds_nothing(client, library):
|
||||
"""The topic search matched on application name without checking isactive,
|
||||
which surfaced retired articles and printed the retired application as their
|
||||
subject."""
|
||||
assert titles(client, '/api/knowledgebase?search=Retired App') == []
|
||||
|
||||
|
||||
def test_searching_a_title_does_not_resurrect_a_retired_article(client, library):
|
||||
"""Hiding it from the list and finding it by title would be no rule at all."""
|
||||
found = titles(client, '/api/knowledgebase?search=runbook')
|
||||
assert 'Retired runbook' not in found
|
||||
assert 'Live runbook' in found
|
||||
|
||||
|
||||
def test_the_counts_agree_with_the_list(client, library):
|
||||
"""A total that includes articles nobody can see is a total nobody can
|
||||
reconcile against the page."""
|
||||
visible = titles(client)
|
||||
stats = client.get('/api/knowledgebase/stats').get_json()['data']
|
||||
total = stats.get('total_articles', stats.get('totalarticles'))
|
||||
clicks = stats.get('total_clicks', stats.get('totalclicks'))
|
||||
assert total == len(visible) == 3
|
||||
# 3 + 2 + 1 from the live, hidden-topic and topicless articles; the retired
|
||||
# article's 5 clicks are excluded along with the article.
|
||||
assert clicks == 6
|
||||
|
||||
|
||||
def test_the_topic_list_offers_every_active_application(client, library):
|
||||
"""What the article form's topic dropdown fetches. Installable or not,
|
||||
hidden or not - only retired is excluded."""
|
||||
offered = sorted(row['appname'] for row in client.get(
|
||||
'/api/applications?perpage=100&showhidden=true').get_json()['data'])
|
||||
assert offered == ['Hidden App', 'Live App', 'Manual App']
|
||||
assert 'Retired App' not in offered
|
||||
@@ -66,7 +66,14 @@ def test_install_list_vendorname_resolves_via_model(client, db, auth_headers,
|
||||
|
||||
def test_install_list_text_format(client, db, auth_headers, printer_assettype):
|
||||
"""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,
|
||||
|
||||
Reference in New Issue
Block a user