Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b3904f71 | ||
|
|
8cedf674fb | ||
|
|
0dc0ac13c8 | ||
|
|
03d0754fdc | ||
|
|
2083029ff2 | ||
|
|
96f127f8c8 | ||
|
|
f34b9ca710 | ||
|
|
8bde89c47e | ||
|
|
afd3dce493 | ||
|
|
89248407e7 | ||
|
|
aa6db94179 | ||
|
|
dd503be4ba | ||
|
|
5f5ed7b1d4 | ||
|
|
62f4a42210 | ||
|
|
9c1c6c5729 | ||
|
|
741dda5be7 | ||
|
|
24647aa8e8 | ||
|
|
aede5ac621 | ||
|
|
3324dbd91e |
236
CHANGELOG.md
236
CHANGELOG.md
@@ -10,6 +10,242 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.11.3] - 2026-08-19
|
||||||
|
|
||||||
|
Fixes the last of the buildings-and-levels bugs, and brings the shop-floor
|
||||||
|
clients into the product: the collector reporter, EventSaver and a printer
|
||||||
|
assignment feature that lets a bay install its own printers.
|
||||||
|
|
||||||
|
**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this
|
||||||
|
asset has a position but no level" for every asset, and the map PDF printed
|
||||||
|
markers from every floor onto one sheet.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a
|
||||||
|
levelid, none of its seven call sites were taught to pass one - printer,
|
||||||
|
machine and PC detail, the toner report, enforcement reports, the warranty chip
|
||||||
|
and the dashboard cards. Two payloads behind them also emitted coordinates with
|
||||||
|
no level.
|
||||||
|
- **Map PDF export printed other floors' markers.** It plotted every filtered
|
||||||
|
asset onto one sheet, which is worse than the on-screen version was: nobody can
|
||||||
|
correct a sheet once it is printed and carried onto the floor.
|
||||||
|
- **The legacy import loader created undrawable markers**, sending mapleft/maptop
|
||||||
|
with no level at three call sites. That loader is the one still to run against
|
||||||
|
production.
|
||||||
|
- The build gate that should have caught all of this asked whether a FILE
|
||||||
|
mentions `levelid`, not whether each position does - one module emitted `mapx`
|
||||||
|
six times and `levelid` once and passed. It now checks per occurrence, covers
|
||||||
|
`scripts/`, and fails any Vue file that binds tooltip coordinates without a
|
||||||
|
level.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC
|
||||||
|
controls it, so a reimaged or swapped PC comes back with the bay's printers and
|
||||||
|
nothing had to be saved off the old one. New `usesprinter` relationship type,
|
||||||
|
propagating through `controls` at read time; `GET /api/printers/for-host/<host>`
|
||||||
|
for the client; `PUT /api/printers/assignments/for-asset/<id>` to reconcile a
|
||||||
|
whole set in one call.
|
||||||
|
- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal
|
||||||
|
drivers cover 41 of the reference site's 44 printers; binding a driver to a
|
||||||
|
single model meant 21 near-duplicate rows. Resolution is model, then vendor,
|
||||||
|
then the pre-vendorid name convention.
|
||||||
|
- **Client scripts** in `plugins/printers/client/`: stage a site's driver set
|
||||||
|
(`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a
|
||||||
|
bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default
|
||||||
|
(`Apply-ShopdbDefaultPrinter.ps1`).
|
||||||
|
- **The collector reporter is in the repository** at
|
||||||
|
`plugins/computers/client/`, with no site in it, and the server generates a
|
||||||
|
copy carrying this site's URL and ranges: Settings > Computers > Asset
|
||||||
|
reporter, or `GET /api/computers/client-script`. The collector key is
|
||||||
|
deliberately never stamped into it.
|
||||||
|
- **EventSaver source** at `plugins/slides/client/`. The site UNC that was
|
||||||
|
compiled into it is gone; a missing ini now fails visibly rather than pointing
|
||||||
|
a new site at the reference site's file server.
|
||||||
|
- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the
|
||||||
|
buildings-and-levels admin gains editable width AND height.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The map editor accepts a click. Its handler was bound only if the map was
|
||||||
|
already a picker when it mounted, and the editor opens with nothing selected,
|
||||||
|
so placing a marker by hand was impossible for the life of the page.
|
||||||
|
- Global search orders every query before truncating, so the same search cannot
|
||||||
|
return a different subset twice.
|
||||||
|
|
||||||
|
## [0.11.2] - 2026-08-17
|
||||||
|
|
||||||
|
Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it.
|
||||||
|
Anyone on 0.11.0 or 0.11.1 with more than one level should take this: until now
|
||||||
|
the map drew markers from every level on whichever floor plan was showing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Markers from other levels were drawn on the displayed level.** The map
|
||||||
|
component filtered out positions with no coordinates and drew everything else,
|
||||||
|
never checking which drawing a position belonged to - the exact failure ADR-017
|
||||||
|
exists to prevent, in the one component that draws the map. Markers are now
|
||||||
|
filtered to the level being shown, and a position with no level is omitted
|
||||||
|
rather than approximated onto the default.
|
||||||
|
- **The map page had no way to choose a level.** It read the current level only
|
||||||
|
to title a PDF export, so a second floor was unreachable from the viewer most
|
||||||
|
people use. A level selector appears when a site has more than one, and
|
||||||
|
switching moves the drawing, the bounds, the coordinate space and the markers
|
||||||
|
together.
|
||||||
|
- **The map editor never accepted a click.** Its click handler was bound only if
|
||||||
|
the map was already in picker mode when it mounted, and the editor opens with
|
||||||
|
nothing selected, so selecting an asset and clicking did nothing at all for the
|
||||||
|
life of the page. The asset forms were unaffected, because their picker is
|
||||||
|
mounted inside a dialog that is already in picker mode.
|
||||||
|
- Existing markers are now drawn while placing one. Positioning an asset relative
|
||||||
|
to the machines already on the floor is the whole task.
|
||||||
|
- Searching the map follows results across levels: a search whose matches are all
|
||||||
|
on another floor showed an empty map while the filter counted them.
|
||||||
|
- **A level's native size could not be set.** The settings page had a width field
|
||||||
|
and no height field at all, and locked both once any marker was placed - which
|
||||||
|
blocked the case the feature was built for, a new blueprint of new dimensions
|
||||||
|
on a floor that already has markers. Both fields are editable, with a
|
||||||
|
confirmation and a pointer to landmark recalibration.
|
||||||
|
- **The same search could return different results.** Fourteen of the sixteen
|
||||||
|
searchers behind global search truncated with `LIMIT` and no `ORDER BY`, so the
|
||||||
|
database was free to return a different subset of matching rows each time.
|
||||||
|
Every searcher now ends in a total order.
|
||||||
|
- Re-searching a term already in the address bar did nothing: the router treats
|
||||||
|
it as a duplicate navigation and aborts, so the results page never re-queried.
|
||||||
|
This is why the sidebar box and the results-page box appeared to disagree.
|
||||||
|
- The empty legend no longer draws its bar and border, which read as a stray
|
||||||
|
input box under the map toolbar.
|
||||||
|
- The map editor's panel header no longer crams a heading and three controls onto
|
||||||
|
one row, which had squeezed the search box until its placeholder read
|
||||||
|
"Search na".
|
||||||
|
- Both map pages had a scrollbar. They subtracted the wrong amount from the
|
||||||
|
viewport height for the page chrome, so each overflowed by the difference; the
|
||||||
|
padding is now a value the pages and the layout share.
|
||||||
|
|
||||||
|
## [0.11.1] - 2026-08-17
|
||||||
|
|
||||||
|
A patch release: the pagination-cap fixes, plus the documentation that missed the
|
||||||
|
0.11.0 installer. No application behaviour changed on the server and the plugin
|
||||||
|
contract stays at 0.20.0.
|
||||||
|
|
||||||
|
`docs/UPGRADE.md` is one of the documents the Windows installer puts on the box,
|
||||||
|
and its buildings-and-levels section landed after 0.11.0 was tagged - so 0.11.0
|
||||||
|
shipped the page an operator reads WHILE upgrading without the guidance about
|
||||||
|
that upgrade. An air-gapped server has no other way to reach it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Pickers and batch label sheets no longer stop at 100 rows. `perpage` is
|
||||||
|
clamped to `MAX_PAGE_SIZE` (100) server-side and the response says nothing
|
||||||
|
about it, so a control asking for 500 or 1000 received the first 100 rows and
|
||||||
|
a success. On a site with 126 applications the 26 sorting last could not be
|
||||||
|
chosen as a knowledge-base topic; batch label printing rendered a sheet for
|
||||||
|
the first 100 of 262 machines that looked complete. New `fetchAllPages()`
|
||||||
|
helper plus `listAll()` on the applications, models, machines, computers,
|
||||||
|
printers, network, measuring-tools, USB and printed-parts APIs, used by the
|
||||||
|
KB article form and topic filter, the notification form, the report filter
|
||||||
|
builder, `AssetLabelBatch`, `PrinterQRBatch`, `USBLabelBatch`,
|
||||||
|
`PrintedPartsLabels`, `MachineForm` and `NetworkDeviceForm`. Lists that render
|
||||||
|
a page at a time are unchanged - they page deliberately.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The installer now also ships `FLOOR-MAP.md`, the operator's page for loading
|
||||||
|
floor plans, placing markers and moving them when a plan changes. Same reason
|
||||||
|
the Windows runbooks ship: the box cannot reach the wiki.
|
||||||
|
- The bundled `UPGRADE.md` now carries the 0.11.0 section: an existing map is
|
||||||
|
preserved and nothing moves on screen, and replacing a blueprint with one of
|
||||||
|
different dimensions moves every marker on that level, so recalibrate from
|
||||||
|
landmarks rather than editing the level's width and height.
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-08-17
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Alongside it, a sweep of what search can actually reach. Two identifiers the UI
|
||||||
|
collects were findable in almost no way, and two plugins' records were findable
|
||||||
|
in no way at all.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `gaugelabreference` was matched only on measuring tools and
|
||||||
|
`maintenancereference` was matched nowhere at all, for any asset type, while
|
||||||
|
Settings offers both identifiers on machines, PCs, printers and network
|
||||||
|
devices. Both are now matched by global search AND by every asset list's
|
||||||
|
`?search=`. An identifier that can be entered has to be findable, or it is a
|
||||||
|
write-only field, and the tag is read off the physical machine by someone
|
||||||
|
looking at the list page.
|
||||||
|
- USB devices and printed items were unreachable from global 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 and label, and on bin
|
||||||
|
code (`itemcode`), gage-lab tag, name and description respectively - both
|
||||||
|
honouring `isactive`, both with Settings toggles, result labels and filter
|
||||||
|
chips.
|
||||||
|
- Global search returned knowledge-base articles whose topic application is
|
||||||
|
retired, while `GET /api/knowledgebase` hid them. A filter that only some paths
|
||||||
|
apply is not a filter: the article stayed two keystrokes away and the result
|
||||||
|
printed the retired application as its subject.
|
||||||
|
|
||||||
|
### Known gaps
|
||||||
|
|
||||||
|
- The Pascal printer-installer map still compiles in one drawing's dimensions and
|
||||||
|
bundles one blueprint, so it is accurate for the default level only. See
|
||||||
|
section 6 of PRINTER-INSTALLER.md.
|
||||||
|
- The setup wizard still asks for a single site-wide blueprint and writes
|
||||||
|
settings that no longer drive the map.
|
||||||
|
- Map PDF export covers the current level only.
|
||||||
|
|
||||||
## [0.10.0] - 2026-08-17
|
## [0.10.0] - 2026-08-17
|
||||||
|
|
||||||
A security release. The Windows installer left the directories it creates
|
A security release. The Windows installer left the directories it creates
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -187,6 +187,29 @@ example of the whole sequence.
|
|||||||
|
|
||||||
For every environment variable and Setting key see [docs/CONFIG.md](docs/CONFIG.md).
|
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
|
## Configuration
|
||||||
|
|
||||||
Environment variables (`.env`):
|
Environment variables (`.env`):
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ at all yet.
|
|||||||
| Artifact | Where it is today |
|
| Artifact | Where it is today |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
|
||||||
| `Report-AssetToShopDB.ps1` | **Not in this repository.** It lives on the reference site's imaging share and is provided on request. It is planned to move to `plugins/computers/client/` so it versions with the collector contract it implements. |
|
| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
|
||||||
| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). |
|
| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
|
||||||
|
|
||||||
Ask the maintainers for the two that are not here. A site that would rather not
|
A site that would rather not run a binary it cannot rebuild should take
|
||||||
run a binary it cannot rebuild should take EventSaver's source and compile it
|
EventSaver's source and compile it locally - the build needs no SDK and is one
|
||||||
locally - the build needs no SDK and is one command.
|
command.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
|
|||||||
|
|
||||||
| Input | Where it comes from |
|
| Input | Where it comes from |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` |
|
| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
|
||||||
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` |
|
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
|
||||||
|
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
|
||||||
|
|
||||||
|
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
|
||||||
|
generates this script with your server's URL and ranges already in the parameter
|
||||||
|
defaults, and shows its SHA-256. The key is deliberately not included.
|
||||||
|
|
||||||
### Minting the key on your ShopDB
|
### Minting the key on your ShopDB
|
||||||
|
|
||||||
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
|
|||||||
Ensure = 'Present'
|
Ensure = 'Present'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Optional. Only for a site whose bays carry both a controller NIC and a
|
||||||
|
# corporate one AND whose default route is not the corporate NIC. Leave
|
||||||
|
# this resource out otherwise - the script picks the default-route NIC.
|
||||||
|
Registry CollectorRanges
|
||||||
|
{
|
||||||
|
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
|
||||||
|
ValueName = 'CollectorRanges'
|
||||||
|
ValueData = '10.20.0.0/23,10.21.4.0/26'
|
||||||
|
ValueType = 'String'
|
||||||
|
Ensure = 'Present'
|
||||||
|
}
|
||||||
|
|
||||||
Script ReportingTask
|
Script ReportingTask
|
||||||
{
|
{
|
||||||
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
|
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
|
||||||
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
|
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
|
||||||
SetScript = {
|
SetScript = {
|
||||||
|
# No -ApiUrl: BaseUrl above is where the script reads it from,
|
||||||
|
# and one source beats two that can disagree.
|
||||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
|
||||||
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"'
|
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
|
||||||
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
|
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
|
||||||
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
|
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
|
||||||
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
|
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ Two auth patterns dominate the reads:
|
|||||||
Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is
|
Every mutating endpoint (POST / PUT / PATCH / DELETE) requires a JWT and is
|
||||||
gated by `require_role` or `require_permission`; none are public.
|
gated by `require_role` or `require_permission`; none are public.
|
||||||
|
|
||||||
|
The map write surface splits along that line. Repositioning markers
|
||||||
|
(`/api/mappositions/*`) needs `assets.edit` - the same permission the old
|
||||||
|
per-asset `PUT /api/assets/<id>` always needed, so bulk editing grants nobody
|
||||||
|
anything new. Creating buildings and levels or uploading a blueprint
|
||||||
|
(`POST|PUT|PATCH|DELETE /api/maplevels/*`) needs `role:admin`, because a level's
|
||||||
|
dimensions are the coordinate space every marker on it is expressed in.
|
||||||
|
|
||||||
Two GETs deliberately break the optional-auth convention and require a
|
Two GETs deliberately break the optional-auth convention and require a
|
||||||
permission, because what they return is not asset metadata but a file that
|
permission, because what they return is not asset metadata but a file that
|
||||||
carries value or liability of its own:
|
carries value or liability of its own:
|
||||||
@@ -115,7 +122,10 @@ like any other mutation.
|
|||||||
| `POST /api/auth/login` | Obtain a JWT. |
|
| `POST /api/auth/login` | Obtain a JWT. |
|
||||||
| `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. |
|
| `GET /api/setup/needs-admin` | First-run check: does the instance have zero users. |
|
||||||
| `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. |
|
| `POST /api/setup/create-admin` | First-run only; creates the first admin, then 403s forever. |
|
||||||
| `GET /api/settings/map-blueprint/<filename>` | Serve the floor-map blueprint image. |
|
| `GET /api/settings/map-blueprint/<filename>` | Serve a floor-map blueprint image. Pre-0.11.0 single-map path, retained; new uploads are served per level below. |
|
||||||
|
| `GET /api/maplevels` | Buildings and their levels, each with blueprint paths, native pixel size and marker count. Public because the printer-installer map draws a floor plan before anyone logs in; it exposes level names and image paths, nothing about assets. |
|
||||||
|
| `GET /api/maplevels/<levelid>` | One level's name, building, blueprints and size. |
|
||||||
|
| `GET /api/maplevels/<levelid>/blueprint/<filename>` | Serve a level's blueprint image, with sandbox headers so an SVG floor plan cannot execute as script. |
|
||||||
| `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). |
|
| `GET /api/settings/branding/<filename>` | Serve site branding assets (logo, etc.). |
|
||||||
| `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. |
|
| `GET /api/settings` and `GET /api/settings/<key>` | Read-only, and only the public allowlist: the `branding` and `map` categories, a few named site keys, plus any key a plugin declares `public` in `get_settings_defaults` (e.g. `printedparts_label_prefix`, which the logged-out parts kiosk renders). Every other key answers 404 to an anonymous caller. |
|
||||||
| `GET /api/models/image/<filename>` | Serve a model image. |
|
| `GET /api/models/image/<filename>` | Serve a model image. |
|
||||||
|
|||||||
@@ -196,12 +196,21 @@ The batch generator at `/print/asset-label-batch/<assettype>` (reached from the
|
|||||||
|
|
||||||
### map
|
### map
|
||||||
|
|
||||||
|
**SUPERSEDED as of 0.11.0.** A blueprint and its pixel dimensions are properties
|
||||||
|
of a LEVEL now, not of the site (ADR-017), because a site can have more than one
|
||||||
|
building and a building more than one floor. Manage them in
|
||||||
|
**Settings > Buildings and levels**, or through `/api/maplevels`.
|
||||||
|
|
||||||
|
These four keys are still present so that downgrading finds the blueprint it had,
|
||||||
|
and the levels migration copied their values onto the first level. Editing them
|
||||||
|
changes nothing on the map.
|
||||||
|
|
||||||
| Key | Default | Notes |
|
| Key | Default | Notes |
|
||||||
|-----|---------|-------|
|
|-----|---------|-------|
|
||||||
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Floor Map. |
|
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Superseded by `maplevels.blueprintlight`. Retained for downgrade only. |
|
||||||
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). |
|
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Superseded by `maplevels.blueprintdark`. |
|
||||||
| `map_width` | `3300` | Blueprint native width in pixels. |
|
| `map_width` | `3300` | Superseded by `maplevels.mapwidth`. |
|
||||||
| `map_height` | `2550` | Blueprint native height in pixels. |
|
| `map_height` | `2550` | Superseded by `maplevels.mapheight`. |
|
||||||
|
|
||||||
### integrations
|
### integrations
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.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.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,
|
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
|
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
|
||||||
|
|||||||
@@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor.
|
|||||||
**Folder mode** predates the server and stays as a fallback for a site with no
|
**Folder mode** predates the server and stays as a fallback for a site with no
|
||||||
ShopDB instance yet, or for content nobody wants in the database.
|
ShopDB instance yet, or for content nobody wants in the database.
|
||||||
|
|
||||||
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the
|
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
|
||||||
> binary falls back to a path compiled into `EventSaver.cs` - and that path
|
> no source to read and the screensaver shows nothing. That is deliberate: the
|
||||||
> belongs to the site it was first built for. Ship the ini. A missing ini is not
|
> compiled-in fallback used to be the path of the site it was first built for, so
|
||||||
> a neutral default.
|
> a missing ini silently pointed a new site at someone else's file server. It is
|
||||||
|
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
|
||||||
|
> the ini.
|
||||||
|
|
||||||
## What decides the running order
|
## What decides the running order
|
||||||
|
|
||||||
@@ -98,8 +100,9 @@ covering something someone needed to see.
|
|||||||
|
|
||||||
## Building it
|
## Building it
|
||||||
|
|
||||||
No SDK required - it compiles with the in-box .NET Framework compiler on any
|
The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
|
||||||
Windows 10 or 11 machine:
|
compiles with the in-box .NET Framework compiler on any Windows 10 or 11
|
||||||
|
machine:
|
||||||
|
|
||||||
```
|
```
|
||||||
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
|
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
|
||||||
|
|||||||
135
docs/FLOOR-MAP.md
Normal file
135
docs/FLOOR-MAP.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# The floor map: buildings, levels and marker positions
|
||||||
|
|
||||||
|
The map shows where things physically are. This page is for whoever maintains it:
|
||||||
|
loading a floor plan, adding a second floor or a second building, and moving the
|
||||||
|
markers when the plan changes.
|
||||||
|
|
||||||
|
The reasoning behind the model is in
|
||||||
|
[ADR-017](adr/ADR-017-buildings-and-levels.md). This page is the operator's view.
|
||||||
|
|
||||||
|
## The model in three sentences
|
||||||
|
|
||||||
|
A **building** holds one or more **levels**. A level owns a floor-plan image (one
|
||||||
|
per theme) and the pixel size of that image. A marker's position is an X and Y in
|
||||||
|
the pixels of ONE level, so every position names the level it belongs to.
|
||||||
|
|
||||||
|
That last point is the whole design. Coordinates alone are meaningless: `1200,900`
|
||||||
|
is a spot on the ground floor and a different spot on the second floor. A marker
|
||||||
|
whose level is unknown shows as **level unknown** and is not drawn, because
|
||||||
|
drawing it on the default plan would look completely correct while pointing at
|
||||||
|
the wrong part of the building.
|
||||||
|
|
||||||
|
## Loading a floor plan
|
||||||
|
|
||||||
|
**Settings > Buildings and levels.**
|
||||||
|
|
||||||
|
1. Add a building if you do not have one. A single-building site needs exactly
|
||||||
|
one, and it can be called anything.
|
||||||
|
2. Add a level. Give it a name people use out loud - "Ground floor", "Mezzanine",
|
||||||
|
"Second floor" - and a sort order that puts the list in the order someone
|
||||||
|
would walk it. Name and order are separate on purpose: levels are not reliably
|
||||||
|
numbered, and leaving gaps in the order lets you insert one later without
|
||||||
|
renumbering everything.
|
||||||
|
3. Upload the plan, per theme. A light-theme image is enough to start; if there
|
||||||
|
is no dark version, the light one is used in both.
|
||||||
|
|
||||||
|
**The level takes its pixel size from the image you upload, while the level is
|
||||||
|
still empty.** Once markers are on it, an upload whose size does not match reports
|
||||||
|
the mismatch and changes nothing. That is deliberate - see below.
|
||||||
|
|
||||||
|
Supported: PNG, JPEG, GIF, WEBP and SVG. An SVG is served with headers that stop
|
||||||
|
it executing as script, so a plan exported from a CAD tool is safe to use.
|
||||||
|
|
||||||
|
## Placing markers
|
||||||
|
|
||||||
|
Two ways, both fine:
|
||||||
|
|
||||||
|
- **One at a time**, from the asset's own form: the map-position picker opens on
|
||||||
|
the level the marker is already on, and you can switch level inside the picker.
|
||||||
|
The saved position states which drawing it is on.
|
||||||
|
- **Many at once**, in the map editor. Filter to what you are working through -
|
||||||
|
unplaced, needs-review, or one level - search for a specific thing, place it,
|
||||||
|
and confirm. Placing a marker by hand counts as confirming it.
|
||||||
|
|
||||||
|
A location can carry a position too, which acts as the default for assets in it.
|
||||||
|
|
||||||
|
## When the floor plan changes
|
||||||
|
|
||||||
|
This is the case that needs care, and the reason the tooling exists.
|
||||||
|
|
||||||
|
**A new plan of the SAME dimensions**: upload it and you are done. Markers keep
|
||||||
|
their coordinates and still line up.
|
||||||
|
|
||||||
|
**A new plan of DIFFERENT dimensions, or a real move**: the coordinates are pixels
|
||||||
|
of the old image, so every marker is now in the wrong place relative to the
|
||||||
|
drawing. Do NOT fix this by editing the level's width and height - that just
|
||||||
|
reinterprets every existing position against a new scale.
|
||||||
|
|
||||||
|
Use **Recalibrate** in the map editor:
|
||||||
|
|
||||||
|
1. Pick two or more **landmarks** - features you can find on both the old and the
|
||||||
|
new plan. Corners of the building, a doorway, a column. Two is the minimum;
|
||||||
|
three or four spread across the plan is better.
|
||||||
|
2. For each, give the old coordinates and the new ones.
|
||||||
|
3. Review the **dry run**. It lists every marker's old and new position and flags
|
||||||
|
anything that would land off the drawing. Nothing has changed yet.
|
||||||
|
4. Apply it. The previous positions are snapshotted first.
|
||||||
|
|
||||||
|
The transform is worked out separately for the X and Y axes from your landmarks,
|
||||||
|
and never guessed from the image dimensions. That matters in the exact case this
|
||||||
|
was built for: a plan that gained a floor below it got taller without anything
|
||||||
|
being rescaled, and a size-derived scale would have stretched everything
|
||||||
|
vertically.
|
||||||
|
|
||||||
|
After a recalibration every affected marker is marked **needs review**, because a
|
||||||
|
transform is a good starting guess and nothing in the coordinates can tell you
|
||||||
|
which machines actually moved. Work the needs-review filter, correct what is off,
|
||||||
|
and confirm as you go.
|
||||||
|
|
||||||
|
## Undo
|
||||||
|
|
||||||
|
Every bulk change snapshots the positions first, and a restore snapshots before
|
||||||
|
it restores, so an undo is itself undoable. The history is in the map editor:
|
||||||
|
what caused each snapshot, when, and whether it has been restored. A restore puts
|
||||||
|
back the level and the review state, not only the coordinates.
|
||||||
|
|
||||||
|
Assets deleted since a snapshot are reported and skipped rather than failing the
|
||||||
|
whole restore.
|
||||||
|
|
||||||
|
## Adding a second building or a second floor
|
||||||
|
|
||||||
|
Add it, upload its plan, then move the markers that belong on it. In the map
|
||||||
|
editor, markers on other levels are badged, so you can see what still needs
|
||||||
|
moving.
|
||||||
|
|
||||||
|
Do not stack two floors onto one tall image. It looks simpler and it makes the
|
||||||
|
level implicit in the Y coordinate - "above 2550 means upstairs" - which nothing
|
||||||
|
can validate and which breaks the moment either plan is redrawn.
|
||||||
|
|
||||||
|
## Things that are not levels
|
||||||
|
|
||||||
|
- **Locations** are where something is in words ("Cell 4", "Gage lab"). A level is
|
||||||
|
which drawing. A location can name a level, but they are different things.
|
||||||
|
- **Business units** are organisational, not physical.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- The printer-installer map (the standalone program that installs printers by
|
||||||
|
clicking a floor plan) has one plan compiled into it and does not know about
|
||||||
|
levels. It is accurate for the default level only. See
|
||||||
|
[PRINTER-INSTALLER](PRINTER-INSTALLER.md) section 6.
|
||||||
|
- The first-run setup wizard still asks for one site-wide blueprint; those
|
||||||
|
settings no longer drive the map. Use Settings > Buildings and levels.
|
||||||
|
- Map PDF export covers the level you are looking at.
|
||||||
|
|
||||||
|
## For integrators
|
||||||
|
|
||||||
|
`GET /api/maplevels` is unauthenticated - the printer installer draws a plan
|
||||||
|
before anyone logs in - and returns every building with its levels, each level's
|
||||||
|
blueprint paths, native size and marker count, plus which level is the default.
|
||||||
|
Writes require an admin. Repositioning is `/api/mappositions/*` and needs
|
||||||
|
`assets.edit`. Full parameters in [API-REFERENCE](API-REFERENCE.md) and the
|
||||||
|
generated spec at `/api/docs`.
|
||||||
|
|
||||||
|
If you import positions from another system, send `levelid` with every
|
||||||
|
coordinate - see [IMPORT-API](IMPORT-API.md).
|
||||||
@@ -190,12 +190,28 @@ Common `machines` columns -> core Asset fields (same for every target plugin):
|
|||||||
| `serialnumber` | `serialnumber` | |
|
| `serialnumber` | `serialnumber` | |
|
||||||
| `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses |
|
| `machinestatusid` | `statusid` | remap via `machinestatus` -> asset statuses |
|
||||||
| `businessunitid` | `businessunitid` | remap via imported business units |
|
| `businessunitid` | `businessunitid` | remap via imported business units |
|
||||||
| `mapleft` | `mapx` | |
|
| `mapleft` | `mapx` | pixels of a LEVEL; send `levelid` with it |
|
||||||
| `maptop` | `mapy` | |
|
| `maptop` | `mapy` | as above |
|
||||||
|
| n/a | `levelid` | which floor plan the coordinates belong to (ADR-017) |
|
||||||
| `machinenotes` | `notes` | |
|
| `machinenotes` | `notes` | |
|
||||||
| `dateadded` | `createddate` | import mode only |
|
| `dateadded` | `createddate` | import mode only |
|
||||||
| `lastupdated` | `modifieddate` | import mode only |
|
| `lastupdated` | `modifieddate` | import mode only |
|
||||||
|
|
||||||
|
**A position needs its level.** `mapx`/`mapy` are absolute pixels in the native
|
||||||
|
coordinate space of ONE level (ADR-017), so an import that sends coordinates
|
||||||
|
without `levelid` produces markers the map renders as "level unknown" rather than
|
||||||
|
drawing them on the default level. A legacy source that predates levels has one
|
||||||
|
floor plan, so the right value is that site's default level:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s http://server/api/maplevels | python -c "import json,sys; print(json.load(sys.stdin)['data']['defaultlevelid'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Send that `levelid` on every row carrying coordinates. If an import already ran
|
||||||
|
without it, the levels migration's backfill is not retroactive - assign the rows
|
||||||
|
with `POST /api/mappositions/positions`, which requires `levelid` per row for
|
||||||
|
exactly this reason.
|
||||||
|
|
||||||
Per-plugin extension fields:
|
Per-plugin extension fields:
|
||||||
|
|
||||||
- **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`,
|
- **computers** (`POST /api/computers`): `hostname` <- `machines.hostname`,
|
||||||
|
|||||||
@@ -369,6 +369,12 @@ class MeasuringToolsPlugin(BasePlugin):
|
|||||||
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
|
Consumed by `GET /api/pluginui/map-overlays` (disabled plugins skipped; broken
|
||||||
plugin isolated in prod, re-raised in dev/test).
|
plugin isolated in prod, re-raised in dev/test).
|
||||||
|
|
||||||
|
An overlay keys on `assetid` and never on coordinates, so it needs to know
|
||||||
|
nothing about buildings or levels (ADR-017): it decorates whichever marker that
|
||||||
|
asset already has, on whatever level that marker is on. Do not return `mapx` /
|
||||||
|
`mapy` / `levelid` from an overlay endpoint - a position belongs to the asset,
|
||||||
|
and a second copy of it in an overlay is one that can disagree.
|
||||||
|
|
||||||
### `get_asset_presentation() -> List[Dict]`
|
### `get_asset_presentation() -> List[Dict]`
|
||||||
|
|
||||||
Returns asset-type presentation/routing definitions. Added in contract 0.7.0
|
Returns asset-type presentation/routing definitions. Added in contract 0.7.0
|
||||||
|
|||||||
@@ -46,19 +46,30 @@ Fields per row:
|
|||||||
| `installpath` | Installer path for this printer (see install-batch). |
|
| `installpath` | Installer path for this printer (see install-batch). |
|
||||||
| `iscsf` | CSF flag. |
|
| `iscsf` | CSF flag. |
|
||||||
| `locationname` | Location name, if the asset has one. |
|
| `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,
|
`?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
|
with a fixed field order so the Inno / Pascal installer does a `split()` instead
|
||||||
of parsing JSON:
|
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
|
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.
|
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`
|
## 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`)
|
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
|
lays down the signed installer that consumes these endpoints. The web map page
|
||||||
covers the same install flow for a human at a browser.
|
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.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ never by editing this file.
|
|||||||
|
|
||||||
| series | value | governed by |
|
| series | value | governed by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| product `__version__` | `0.10.0` | ADR-007 |
|
| product `__version__` | `0.11.3` | 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.
|
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 |
|
| chain | head |
|
||||||
|---|---|
|
|---|---|
|
||||||
| core | `7d32_displayrole_kiosk_vocabulary` |
|
| core | `7d33_buildings_and_levels` |
|
||||||
| backups | `backups0003clearlastseen` |
|
| backups | `backups0003clearlastseen` |
|
||||||
| computers | `computers0001anchor` |
|
| computers | `computers0001anchor` |
|
||||||
| employees | `employees0002photo` |
|
| employees | `employees0002photo` |
|
||||||
@@ -34,7 +34,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|
|||||||
| network | `network0003prefix` |
|
| network | `network0003prefix` |
|
||||||
| notifications | `notifications0005boardorder` |
|
| notifications | `notifications0005boardorder` |
|
||||||
| printedparts | `printedparts0004txnrev` |
|
| printedparts | `printedparts0004txnrev` |
|
||||||
| printers | `printers0002supplyalerts` |
|
| printers | `printers0004drivervendor` |
|
||||||
| slides | `slides0001anchor` |
|
| slides | `slides0001anchor` |
|
||||||
| usb | `usb0002dropmachineid` |
|
| usb | `usb0002dropmachineid` |
|
||||||
| warranty | `warranty0002proof` |
|
| warranty | `warranty0002proof` |
|
||||||
@@ -44,16 +44,16 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|
|||||||
| plugin | version | core_version | owns migrations |
|
| plugin | version | core_version | owns migrations |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| backups | 1.0.0 | >=0.16.0,<1.0.0 | yes |
|
| 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 |
|
| employees | 1.0.0 | >=0.1.0,<1.0.0 | yes |
|
||||||
| geenforce | 0.1.0 | >=0.18.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 |
|
| knowledgebase | 1.0.0 | >=0.1.0,<1.0.0 | yes |
|
||||||
| machines | 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.6.0,<1.0.0 | yes |
|
| measuringtools | 1.0.0 | >=0.20.0,<1.0.0 | yes |
|
||||||
| network | 1.0.0 | >=0.1.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 |
|
| notifications | 1.0.0 | >=0.1.0,<1.0.0 | yes |
|
||||||
| printedparts | 0.1.0 | >=0.16.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 |
|
| slides | 2.0.0 | >=0.2.0,<1.0.0 | yes |
|
||||||
| tools | 1.0.0 | >=0.16.0,<1.0.0 | no |
|
| tools | 1.0.0 | >=0.16.0,<1.0.0 | no |
|
||||||
| usb | 1.0.0 | >=0.1.0,<1.0.0 | yes |
|
| 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-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-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-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
|
## Size
|
||||||
|
|
||||||
- test functions defined: **1050** (parametrised cases collect higher)
|
- test functions defined: **1113** (parametrised cases collect higher)
|
||||||
- documented API paths: **265** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
- documented API paths: **279** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ exists.
|
|||||||
2. [OPERATE-WINDOWS](OPERATE-WINDOWS.md) - restart, logs, backups, upgrades.
|
2. [OPERATE-WINDOWS](OPERATE-WINDOWS.md) - restart, logs, backups, upgrades.
|
||||||
3. [CSV-IMPORT](CSV-IMPORT.md) if the site's data is in spreadsheets, or
|
3. [CSV-IMPORT](CSV-IMPORT.md) if the site's data is in spreadsheets, or
|
||||||
[IMPORT-API](IMPORT-API.md) if there is a source database to script against.
|
[IMPORT-API](IMPORT-API.md) if there is a source database to script against.
|
||||||
|
4. [FLOOR-MAP](FLOOR-MAP.md) - load the site's floor plans and place markers.
|
||||||
|
Also the page to read BEFORE a plan changes: it covers moving every marker
|
||||||
|
onto a redrawn plan without doing it one at a time.
|
||||||
|
|
||||||
**Do not** follow [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) for a new site.
|
**Do not** follow [INSTALL-WINDOWS-IIS](INSTALL-WINDOWS-IIS.md) for a new site.
|
||||||
That is the manual procedure, kept for hand-built servers that predate the
|
That is the manual procedure, kept for hand-built servers that predate the
|
||||||
|
|||||||
@@ -89,6 +89,36 @@ Confirm the app is healthy (login page renders, `/api/auth/login` returns a
|
|||||||
|
|
||||||
## Version-specific notes
|
## Version-specific notes
|
||||||
|
|
||||||
|
### Upgrading to v0.11.0: the floor map became buildings and levels
|
||||||
|
|
||||||
|
Nothing to do, and nothing moves on screen. Run both migration steps as always:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flask db upgrade
|
||||||
|
flask plugin upgrade-all
|
||||||
|
```
|
||||||
|
|
||||||
|
`7d33_buildings_and_levels` creates one building ("Main") and one default level
|
||||||
|
("Ground floor") carrying the blueprint paths and dimensions from your `map_*`
|
||||||
|
settings, then assigns every already-placed asset and location to that level.
|
||||||
|
Unplaced things stay unplaced. The old settings rows are deliberately left in
|
||||||
|
place, so a downgrade still finds the blueprint.
|
||||||
|
|
||||||
|
Two things to know afterwards:
|
||||||
|
|
||||||
|
- **A second floor or building is a Settings task**, not a migration: add it under
|
||||||
|
Settings > Buildings and levels, upload its blueprint, then move the markers
|
||||||
|
that belong on it. `POST /api/mappositions/transform` moves a whole level's
|
||||||
|
markers onto a redrawn plan from landmark pairs, dry run by default.
|
||||||
|
- **Replacing a blueprint with one of DIFFERENT dimensions moves every marker on
|
||||||
|
that level**, because the coordinates are pixels of the old size. Upload it, then
|
||||||
|
recalibrate with landmarks rather than editing the level's width and height by
|
||||||
|
hand. The API refuses to adopt a new size on a level that already has markers,
|
||||||
|
for this reason.
|
||||||
|
|
||||||
|
If you wrote a plugin that stores map positions, see CONTRACT-STABILITY.md: the
|
||||||
|
contract is 0.20.0 and a position must now carry its `levelid`.
|
||||||
|
|
||||||
### Upgrading to v0.5.0 or later: bundled the reference site floor plan removed
|
### Upgrading to v0.5.0 or later: bundled the reference site floor plan removed
|
||||||
|
|
||||||
Versions before 0.5 shipped the the reference site facility floor-plan PNGs as the
|
Versions before 0.5 shipped the the reference site facility floor-plan PNGs as the
|
||||||
|
|||||||
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",
|
"method": "GET",
|
||||||
"path": "/api/search",
|
"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",
|
"auth": "jwt-optional",
|
||||||
"params": "q (required, 2-200 chars)",
|
"params": "q (required, 2-200 chars)",
|
||||||
"example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'"
|
"example": "curl 'http://localhost:5001/api/search?q=WKSTN0042'"
|
||||||
@@ -1582,7 +1582,7 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/computers",
|
"path": "/api/computers",
|
||||||
"auth": "jwt-optional",
|
"auth": "jwt-optional",
|
||||||
"params": "page, per_page, active, search (assetnumber/name/serialnumber/hostname ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)",
|
"params": "page, per_page, active, search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference ilike), assetnumber (exact, for idempotent import), typeid|type_id, osid|os_id, locationid|location_id, businessunitid|businessunit_id, shopfloor (true/false), sort (hostname|assetnumber|name|lastreporteddate), dir (asc|desc)",
|
||||||
"purpose": "List computers (asset + computer + resolved access links), filtered and paginated",
|
"purpose": "List computers (asset + computer + resolved access links), filtered and paginated",
|
||||||
"example": "curl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'"
|
"example": "curl 'http://localhost:5001/api/computers?shopfloor=true&sort=lastreporteddate&dir=desc&per_page=25'"
|
||||||
},
|
},
|
||||||
@@ -1697,6 +1697,14 @@
|
|||||||
"params": "protocol_id in path; body: any of protocolname, port, isactive",
|
"params": "protocol_id in path; body: any of protocolname, port, isactive",
|
||||||
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
|
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
|
||||||
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
|
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/computers/client-script",
|
||||||
|
"auth": "jwt + role:admin",
|
||||||
|
"params": "none",
|
||||||
|
"purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched",
|
||||||
|
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"surface": "plugin-computers"
|
"surface": "plugin-computers"
|
||||||
@@ -1749,7 +1757,7 @@
|
|||||||
"path": "/api/measuringtools",
|
"path": "/api/measuringtools",
|
||||||
"purpose": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.",
|
"purpose": "List measuring tools (Asset core merged with extension), filtered + paginated; derived calibrationstatus filter applied post-pagination.",
|
||||||
"auth": "jwt-optional",
|
"auth": "jwt-optional",
|
||||||
"params": "query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage",
|
"params": "query: active (default true), assetnumber (exact match, for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid, locationid, statusid, calibrationstatus (overdue|duesoon|current|unknown), page, perpage",
|
||||||
"example": "curl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'"
|
"example": "curl 'http://localhost:5001/api/measuringtools?typeid=2&calibrationstatus=overdue&page=1&perpage=50'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1858,7 +1866,7 @@
|
|||||||
"path": "/api/machines",
|
"path": "/api/machines",
|
||||||
"purpose": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with dualpathpartner) when site setting enabled",
|
"purpose": "List machines (Asset+Machine join) with filters, sorting, pagination; collapses Dualpath dual-bay pairs to one row (annotated with dualpathpartner) when site setting enabled",
|
||||||
"auth": "jwt-optional",
|
"auth": "jwt-optional",
|
||||||
"params": "query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)",
|
"params": "query: page, per_page, active ('false' includes inactive), assetnumber (exact-match for idempotent import), search (assetnumber/name/serialnumber/gaugelabreference/maintenancereference ilike), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (assetnumber|name), dir (asc|desc)",
|
||||||
"example": "curl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'"
|
"example": "curl 'http://localhost:5001/api/machines?search=2007&typeid=2&sort=name&dir=desc&page=1&per_page=50'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2546,7 +2554,7 @@
|
|||||||
"path": "/api/network",
|
"path": "/api/network",
|
||||||
"purpose": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated",
|
"purpose": "List network devices (Asset joined with NetworkDevice extension + primary IP), filtered/sorted/paginated",
|
||||||
"auth": "jwt-optional",
|
"auth": "jwt-optional",
|
||||||
"params": "page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)",
|
"params": "page, per_page, active, assetnumber (exact match for idempotent import), search (assetnumber/name/serialnumber/hostname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, poe (true/false), managed (true/false), sort (hostname|assetnumber|name), dir (asc|desc)",
|
||||||
"example": "curl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'"
|
"example": "curl 'http://localhost:5001/api/network?typeid=2&poe=true&sort=hostname&dir=asc'"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -2852,7 +2860,7 @@
|
|||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/printers",
|
"path": "/api/printers",
|
||||||
"auth": "jwt-optional",
|
"auth": "jwt-optional",
|
||||||
"params": "page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)",
|
"params": "page, per_page, active, assetnumber (exact-match for import idempotency), search (assetnumber/name/serial/hostname/windowsname/gaugelabreference/maintenancereference), typeid|type_id, vendorid|vendor_id, locationid|location_id, businessunitid|businessunit_id, sort (hostname|assetnumber|name), dir (asc|desc)",
|
||||||
"purpose": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.",
|
"purpose": "List printers (joined Asset+Printer) with filters, search, sorting, pagination; each row includes primary IP.",
|
||||||
"example": "curl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'"
|
"example": "curl 'http://localhost:5001/api/printers?search=csf&typeid=2&sort=assetnumber&dir=desc'"
|
||||||
},
|
},
|
||||||
@@ -2880,6 +2888,22 @@
|
|||||||
"purpose": "Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp); used by installer to preselect map hotspot.",
|
"purpose": "Look up a PC's default printer via the defaultprinter asset relationship (parity with classic apipcdefaultprinter.asp); used by installer to preselect map hotspot.",
|
||||||
"example": "curl 'http://localhost:5001/api/printers/pc-default?machine=0421&format=text'"
|
"example": "curl 'http://localhost:5001/api/printers/pc-default?machine=0421&format=text'"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/printers/for-host/<hostname>",
|
||||||
|
"auth": "jwt-optional",
|
||||||
|
"params": "path: hostname (matched case-insensitively, as sent by the client's COMPUTERNAME); no query parameters",
|
||||||
|
"purpose": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.",
|
||||||
|
"example": "curl http://localhost:5001/api/printers/for-host/workstation01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "PUT",
|
||||||
|
"path": "/api/printers/assignments/for-asset/<asset_id>",
|
||||||
|
"auth": "permission:printers.edit",
|
||||||
|
"params": "path: asset_id (the machine or PC asset the printers belong to); body: printerassetids (list of printer asset IDs, required, empty list clears the assignment), defaultprinterassetid (int or null; 400 unless it is one of printerassetids)",
|
||||||
|
"purpose": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.",
|
||||||
|
"example": "curl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/api/printers/<printer_id>",
|
"path": "/api/printers/<printer_id>",
|
||||||
@@ -3232,5 +3256,122 @@
|
|||||||
"example": "curl -I http://localhost:5001/api/docs/redoc.standalone.js"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
331
docs/proposals/printer-assignment.md
Normal file
331
docs/proposals/printer-assignment.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# Proposal: assign printers to a machine in ShopDB, let the PC install them
|
||||||
|
|
||||||
|
Status: ACCEPTED. Server half being built 2026-08-18 (relationship types and
|
||||||
|
rails, resolution helper, two endpoints, PC form section, `drivername`). The
|
||||||
|
client script is not built.
|
||||||
|
Author: planning session 2026-08-18.
|
||||||
|
|
||||||
|
## 1. What this is
|
||||||
|
|
||||||
|
Today a printer reaches a shop-floor PC because a person walks up to it, opens
|
||||||
|
the printer installer, finds the printer on a floor plan and clicks it. That is
|
||||||
|
fine for someone choosing a printer, and wrong for a bay whose printers are a
|
||||||
|
property of the bay.
|
||||||
|
|
||||||
|
This proposal makes the assignment data. The printers belong to the MACHINE, not
|
||||||
|
to the box currently driving it: tick the printers that belong on the machine,
|
||||||
|
mark one default, and the assignment reaches whichever PC controls that machine.
|
||||||
|
The PC converges on its next GE-Enforce cycle - installing what is missing and
|
||||||
|
setting the default - and keeps converging, so a reimaged bay comes back with
|
||||||
|
its printers and a bay that drifts is corrected.
|
||||||
|
|
||||||
|
The point of putting the assignment on the machine is that a reimaged PC needs
|
||||||
|
no backup and no restore step. The asset register is the source of truth, and a
|
||||||
|
replacement PC that inherits the `controls` edge inherits the printers with it.
|
||||||
|
|
||||||
|
The map installer stays, for the case it is actually good at: a person at an
|
||||||
|
unmanaged or office PC picking a printer that nobody assigned.
|
||||||
|
|
||||||
|
## 2. Why it is worth doing
|
||||||
|
|
||||||
|
- **The assignment becomes a record.** "Which printers does that bay have" is a
|
||||||
|
question ShopDB can answer, and today it cannot.
|
||||||
|
- **A reimage stops costing a visit.** The bay reinstalls its own printers, from
|
||||||
|
the machine's record, with nothing saved off the old PC.
|
||||||
|
- **Swapping the PC keeps the printers.** They were never the PC's.
|
||||||
|
- **Drift is corrected, not just detected.** A queue deleted by a user comes
|
||||||
|
back.
|
||||||
|
- **It removes the walk-up from the common case.** The installer's map remains
|
||||||
|
for the uncommon one.
|
||||||
|
|
||||||
|
## 3. What already exists
|
||||||
|
|
||||||
|
Most of the model is in place, which is why this is a small feature rather than
|
||||||
|
a project.
|
||||||
|
|
||||||
|
| piece | state |
|
||||||
|
|---|---|
|
||||||
|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
|
||||||
|
| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" |
|
||||||
|
| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position |
|
||||||
|
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
|
||||||
|
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
|
||||||
|
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
|
||||||
|
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
|
||||||
|
| Per-printer install path | `Printer.installpath` |
|
||||||
|
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
|
||||||
|
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
|
||||||
|
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
|
||||||
|
|
||||||
|
## 4. The data model
|
||||||
|
|
||||||
|
### 4.1 One new relationship type
|
||||||
|
|
||||||
|
`usesprinter`, directional, source -> printer, meaning "this printer is
|
||||||
|
installed here". It is seeded next to `defaultprinter`, which already exists and
|
||||||
|
means "which of them is the default". Both are seed data, not a migration, which
|
||||||
|
is how every other relationship type shipped.
|
||||||
|
|
||||||
|
### 4.2 Two propagation rails, consumed at READ time
|
||||||
|
|
||||||
|
`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both
|
||||||
|
are rows in `relationshiptypepropagations`, the same mechanism map positions
|
||||||
|
use. Inventing a second mechanism for this was the alternative, and it was
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
The rails are inert at write time on purpose. The create-time fan-out
|
||||||
|
(`propagate_relationship`) skips directional through-types, and `controls` is
|
||||||
|
directional, so assigning a printer to a machine does not copy rows onto its PC.
|
||||||
|
The walk happens when something asks, which is what makes the next rule possible.
|
||||||
|
|
||||||
|
### 4.3 Resolution order for a PC
|
||||||
|
|
||||||
|
1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any.
|
||||||
|
2. Otherwise, one hop out along its `controls` edges to the machines it drives,
|
||||||
|
and those machines' rows instead, tagged as inherited.
|
||||||
|
|
||||||
|
Own beats inherited, whole set at a time: a PC with its own assignment is
|
||||||
|
overriding the bay, not adding to it. An office PC controls no machine and still
|
||||||
|
works, because step 1 is the normal case for it.
|
||||||
|
|
||||||
|
The override is a real trap and the UI has to say so. A tech who "fixes" a bay
|
||||||
|
by editing the PC has shadowed the machine's record, and the machine will keep
|
||||||
|
disagreeing until someone clears the PC's own rows.
|
||||||
|
|
||||||
|
### 4.4 One default, optional, and never dangling
|
||||||
|
|
||||||
|
The unique constraint is `(source, target, type)`, which happily accepts two
|
||||||
|
different defaults. So the rule is enforced in the API on write:
|
||||||
|
|
||||||
|
- Exactly one `defaultprinter` per asset. Setting a default replaces the
|
||||||
|
existing one.
|
||||||
|
- A default is OPTIONAL. A bay with three printers and no default is valid.
|
||||||
|
- The default must be one of the assigned printers. Unassigning the printer that
|
||||||
|
is currently default clears the default rather than leaving it dangling.
|
||||||
|
|
||||||
|
### 4.5 One column on `printerdrivers`
|
||||||
|
|
||||||
|
`drivername` - the driver's exact name as the INF declares it, e.g.
|
||||||
|
`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not
|
||||||
|
on `name`, which is ours to choose, and a mismatch is the usual failure.
|
||||||
|
Deriving it by parsing the INF on hundreds of bays is fragile; a human
|
||||||
|
confirming it once in ShopDB is not.
|
||||||
|
|
||||||
|
It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a
|
||||||
|
re-run is a no-op. `printerdrivers` was created by a core migration but its DDL
|
||||||
|
moved to the printers chain at the ADR-008 cutover.
|
||||||
|
|
||||||
|
The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being
|
||||||
|
built. See section 8: if production confirms no Brother printers, everything is
|
||||||
|
`pnputil` and the column has no second value to hold.
|
||||||
|
|
||||||
|
## 5. What has to be built
|
||||||
|
|
||||||
|
### 5.1 A resolution helper in core
|
||||||
|
|
||||||
|
The read-time walk of section 4.3, beside `resolve_asset_position` and exported
|
||||||
|
on the `shopdb.api` contract surface (an additive minor bump). It has to live in
|
||||||
|
core because `RelationshipTypePropagation` is not on the contract surface, and a
|
||||||
|
plugin may not reach past it (ADR-002).
|
||||||
|
|
||||||
|
The through-type comes from the seeded rails, not from a hardcoded `'controls'`,
|
||||||
|
so a site that adds a rail gets the behaviour without a code change.
|
||||||
|
|
||||||
|
### 5.2 Two endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/printers/for-host/<hostname>
|
||||||
|
```
|
||||||
|
|
||||||
|
The desired printer set for one PC, resolved per section 4.3, each entry with
|
||||||
|
what a client needs to install it: queue name, host or IP, port, driver name,
|
||||||
|
driver location, and which one is default.
|
||||||
|
|
||||||
|
Resolved by hostname, not machine number: the collector already upserts PCs by
|
||||||
|
hostname, and an office PC has no machine number. Matched case-insensitively -
|
||||||
|
`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not.
|
||||||
|
|
||||||
|
An unknown host, a site without the computers plugin, or nothing assigned all
|
||||||
|
return an empty set. That is the client's designed no-op and it must stay
|
||||||
|
indistinguishable from "assigned nothing".
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/printers/assignments/for-asset/<asset_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
The whole assignment for one asset - machine or PC - in one call:
|
||||||
|
`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather
|
||||||
|
than inserting: rows that went away are soft-deleted, rows that come back
|
||||||
|
REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a
|
||||||
|
blind insert is an integrity error on assign, unassign, re-assign), new rows are
|
||||||
|
created, and the single default is replaced.
|
||||||
|
|
||||||
|
The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic
|
||||||
|
relationships path leave two-default windows and know nothing of the subset rule.
|
||||||
|
|
||||||
|
**Removing an assignment NEVER uninstalls anything.** Server-side the row simply
|
||||||
|
goes: no cascade, no side effects, nothing queued for the client to undo.
|
||||||
|
|
||||||
|
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
|
||||||
|
and PC type and sync broadly; putting per-PC rows there would leak every bay's
|
||||||
|
configuration to every bay and grow without limit. One manifest entry runs one
|
||||||
|
script that asks the API what THIS host gets - the mirror image of
|
||||||
|
`Report-AssetToShopDB.ps1`.
|
||||||
|
|
||||||
|
### 5.3 UI on the PC form
|
||||||
|
|
||||||
|
A printer multi-select plus a default dropdown whose options are only the
|
||||||
|
currently selected printers, clearing itself when its printer is deselected.
|
||||||
|
Saved through the reconcile endpoint against the PC's asset.
|
||||||
|
|
||||||
|
The computers plugin does not depend on the printers plugin and must not start:
|
||||||
|
the section hides itself when the printers API is not there.
|
||||||
|
|
||||||
|
The machine-side picker is out of scope for now, which means the machine's
|
||||||
|
assignment is editable only through the generic relationships card. That is the
|
||||||
|
side the design says is primary, so it is the obvious next piece of UI.
|
||||||
|
|
||||||
|
### 5.4 One client script, in two contexts
|
||||||
|
|
||||||
|
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
|
||||||
|
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
|
||||||
|
|
||||||
|
*As SYSTEM, every cycle:*
|
||||||
|
|
||||||
|
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
|
||||||
|
2. For each assigned printer with no queue: trust the driver catalog's cert,
|
||||||
|
`pnputil /add-driver`, create the port, create the queue
|
||||||
|
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
|
||||||
|
4. Ensure the per-user task exists
|
||||||
|
|
||||||
|
*In the user's context, at logon and on a repeat:*
|
||||||
|
|
||||||
|
5. Read that value, compare with the current default, set it if it differs, and
|
||||||
|
clear "Let Windows manage my default printer" - otherwise Windows silently
|
||||||
|
overrides the choice the next time someone prints elsewhere
|
||||||
|
|
||||||
|
The default printer is per-user state, which is the only reason this needs two
|
||||||
|
contexts. Everything else is machine state and belongs to the cycle.
|
||||||
|
|
||||||
|
Converge, do not reinstall: when the state matches, the script does nothing.
|
||||||
|
Nothing here needs the manifest to know when a printer changes, because the
|
||||||
|
desired state is fetched, not declared.
|
||||||
|
|
||||||
|
## 6. Desired state and observed state are not the same thing
|
||||||
|
|
||||||
|
Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in
|
||||||
|
this feature knows what IS installed on it. The client reads the desired state,
|
||||||
|
converges toward it, and reports nothing back.
|
||||||
|
|
||||||
|
Reporting the observed state is the obvious next feature and is deliberately not
|
||||||
|
this one. If the collector sent the installed queues per host - name, port,
|
||||||
|
driver, which is default - then comparing that against the resolved assignment
|
||||||
|
gives drift detection for free: "this bay is missing the label printer", "this
|
||||||
|
PC has three queues nobody assigned", "the default is not the assigned one".
|
||||||
|
|
||||||
|
Keeping them apart is a rule, not a preference:
|
||||||
|
|
||||||
|
- **Observed data never writes `usesprinter` rows.** A register that learns from
|
||||||
|
what it finds mirrors the drift instead of correcting it, and the fault
|
||||||
|
becomes the desired state.
|
||||||
|
- **Observed data belongs on the computer record, timestamped**, like the rest
|
||||||
|
of the collector payload. It is an observation with an age, not a decision.
|
||||||
|
- **An empty answer from the API means "nothing assigned", not "nothing
|
||||||
|
installed"**, which is exactly why section 5.2 refuses to make removal
|
||||||
|
uninstall anything.
|
||||||
|
- The two can disagree indefinitely and that is a report to read, not an error
|
||||||
|
to resolve automatically.
|
||||||
|
|
||||||
|
## 7. Decisions to take before writing the client
|
||||||
|
|
||||||
|
1. **Never remove a queue by default.** A transient API failure would otherwise
|
||||||
|
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
|
||||||
|
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
|
||||||
|
user who chose their own default - correct for a locked bay, irritating on an
|
||||||
|
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
|
||||||
|
rather than one global answer.
|
||||||
|
3. **Failure is silent and safe**: unreachable API means change nothing, log,
|
||||||
|
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
|
||||||
|
|
||||||
|
Open on the server side, and each one changes the response contract:
|
||||||
|
|
||||||
|
4. **How a universal driver resolves.** `PrinterDriver` links to a printer by
|
||||||
|
exact `modelnumberid`, and the target state is roughly four rows dominated by
|
||||||
|
HP UPD and Xerox GPD, which match no single model. Either the driver row
|
||||||
|
gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's
|
||||||
|
resolved vendor name. Until this is settled, `for-host` returns no driver for
|
||||||
|
41 of 44 printers.
|
||||||
|
5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose
|
||||||
|
job it is to apply it - server or script - has to be written down once.
|
||||||
|
6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous;
|
||||||
|
the collector and the GE-Enforce fetch use scoped service tokens. This one
|
||||||
|
discloses per-PC configuration keyed by hostname.
|
||||||
|
7. **Two inherited defaults.** A PC can legitimately control both bays of a
|
||||||
|
dual-bay machine, or several machines. The union of assigned printers is
|
||||||
|
easy; the default needs a deterministic rule, or none when it is ambiguous.
|
||||||
|
8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they
|
||||||
|
predate the type. Either an active default implies assignment on read
|
||||||
|
(zero-touch, preferred) or a one-time backfill writes the missing rows.
|
||||||
|
Otherwise existing defaults vanish from `for-host` while still showing in
|
||||||
|
`pc-default`.
|
||||||
|
9. **Deletions through the generic relationships card bypass the reconcile
|
||||||
|
endpoint** and can strand an active default pointing at an unassigned
|
||||||
|
printer. Either the resolver drops dangling defaults or the core delete path
|
||||||
|
learns the rule.
|
||||||
|
|
||||||
|
## 8. What the fleet data says, and the one prerequisite
|
||||||
|
|
||||||
|
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
|
||||||
|
|
||||||
|
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
|
||||||
|
Xerox Global Print Driver). One driver record each serves every queue of that
|
||||||
|
make.
|
||||||
|
- **There are no Brother printers at all**, yet the installer carries 208 files
|
||||||
|
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
|
||||||
|
with no Printer-class INF, which is the only reason a second staging method
|
||||||
|
(DPInst) exists. If production confirms no Brother, that payload and that code
|
||||||
|
path can both go - and with them the `installmethod` column.
|
||||||
|
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
|
||||||
|
a fourth special case - a PostScript device the UPD does not cover.
|
||||||
|
|
||||||
|
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
|
||||||
|
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
|
||||||
|
universal driver - the opposite of how a UPD should be used. The table needs
|
||||||
|
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
|
||||||
|
payload is restored. Each needs `drivername` copied verbatim from its INF.
|
||||||
|
Nothing in this proposal works until a printer can resolve to a driver.
|
||||||
|
|
||||||
|
## 9. Deployment constraint that shapes the design
|
||||||
|
|
||||||
|
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
|
||||||
|
a share path must run as a manifest entry inside that cycle, never as its own
|
||||||
|
scheduled task. The failure is silent - the task reports 0 processed, 0
|
||||||
|
installed, 0 failed - and it has cost a session before.
|
||||||
|
|
||||||
|
This is why driver staging belongs in the cycle even though the per-user default
|
||||||
|
does not, and why "the assignment script schedules a task that installs drivers"
|
||||||
|
is the wrong shape.
|
||||||
|
|
||||||
|
## 10. Upgrading an existing site
|
||||||
|
|
||||||
|
Three steps, and the third is the one that gets forgotten:
|
||||||
|
|
||||||
|
1. `flask db upgrade` - no core migration in this feature, but a deploy runs it.
|
||||||
|
2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic
|
||||||
|
1054 unknown-column error.
|
||||||
|
3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and
|
||||||
|
both propagation rails do not exist, and `for-host` resolves nothing, quietly,
|
||||||
|
because empty is also the healthy answer.
|
||||||
|
|
||||||
|
Pair the upgrade with a smoke check against a known bay. A site with reversed
|
||||||
|
legacy `controls` rows (machine -> PC) should run
|
||||||
|
`flask relationships fix-controls-direction` first, or inheritance resolves for
|
||||||
|
none of those PCs.
|
||||||
|
|
||||||
|
## 11. What this does not change
|
||||||
|
|
||||||
|
- The printer installer keeps working, for walk-up and self-service.
|
||||||
|
- Nothing about how printers are modelled, mapped or reported.
|
||||||
|
- The collector contract. Section 6 would change it; this feature does not.
|
||||||
|
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
|
||||||
|
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "shopdb-frontend",
|
"name": "shopdb-frontend",
|
||||||
"version": "0.10.0",
|
"version": "0.11.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -50,6 +50,30 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
|
// The backend clamps perpage to MAX_PAGE_SIZE (100) and says nothing about it,
|
||||||
|
// so asking for `perpage: 1000` silently returns the first 100 rows and drops
|
||||||
|
// the rest. A picker built that way looks complete and is not: with 126
|
||||||
|
// applications on a live site, the 26 sorting last were simply unselectable.
|
||||||
|
//
|
||||||
|
// Use this wherever a control needs the WHOLE list (dropdowns, pickers, label
|
||||||
|
// batches) rather than a page of it. Returns the full array directly, not an
|
||||||
|
// axios response. Anything that renders a paged table should keep calling
|
||||||
|
// list() with a real page number instead.
|
||||||
|
export async function fetchAllPages(path, params = {}) {
|
||||||
|
const first = await api.get(path, { params: { ...params, perpage: 100, page: 1 } })
|
||||||
|
let items = first.data.data || []
|
||||||
|
const totalpages = first.data.meta?.pagination?.totalpages || 1
|
||||||
|
if (totalpages > 1) {
|
||||||
|
const rest = await Promise.all(
|
||||||
|
Array.from({ length: totalpages - 1 }, (_, i) =>
|
||||||
|
api.get(path, { params: { ...params, perpage: 100, page: i + 2 } })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rest.forEach(response => { items = items.concat(response.data.data || []) })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
// Auth API
|
// Auth API
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login(username, password) {
|
login(username, password) {
|
||||||
@@ -77,6 +101,13 @@ export const machinesApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/machines', { params })
|
return api.get('/machines', { params })
|
||||||
},
|
},
|
||||||
|
// Every machine, paged past the backend's 100-row cap. Batch label printing
|
||||||
|
// and "pick any record" dropdowns must use this: list() with a large
|
||||||
|
// perpage is clamped to 100 and still returns a success response, so
|
||||||
|
// the tail simply goes missing.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/machines', params)
|
||||||
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/machines/${id}`)
|
return api.get(`/machines/${id}`)
|
||||||
},
|
},
|
||||||
@@ -120,6 +151,13 @@ export const computersApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/computers', { params })
|
return api.get('/computers', { params })
|
||||||
},
|
},
|
||||||
|
// Every PC, paged past the backend's 100-row cap. Batch label printing
|
||||||
|
// and "pick any record" dropdowns must use this: list() with a large
|
||||||
|
// perpage is clamped to 100 and still returns a success response, so
|
||||||
|
// the tail simply goes missing.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/computers', params)
|
||||||
|
},
|
||||||
displayKiosks() {
|
displayKiosks() {
|
||||||
return api.get('/computers/display-kiosks')
|
return api.get('/computers/display-kiosks')
|
||||||
},
|
},
|
||||||
@@ -268,6 +306,13 @@ export const printersApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/printers', { params })
|
return api.get('/printers', { params })
|
||||||
},
|
},
|
||||||
|
// Every printer, paged past the backend's 100-row cap. Batch label printing
|
||||||
|
// and "pick any record" dropdowns must use this: list() with a large
|
||||||
|
// perpage is clamped to 100 and still returns a success response, so
|
||||||
|
// the tail simply goes missing.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/printers', params)
|
||||||
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/printers/${id}`)
|
return api.get(`/printers/${id}`)
|
||||||
},
|
},
|
||||||
@@ -388,19 +433,8 @@ export const modelsApi = {
|
|||||||
// Backend caps perpage at 100, so page through every model. Returns the
|
// Backend caps perpage at 100, so page through every model. Returns the
|
||||||
// full array directly (not an axios response). Use in forms whose model
|
// full array directly (not an axios response). Use in forms whose model
|
||||||
// dropdown must include the editing record's model regardless of page.
|
// dropdown must include the editing record's model regardless of page.
|
||||||
async listAll() {
|
listAll(params = {}) {
|
||||||
const first = await api.get('/models', { params: { perpage: 100, page: 1 } })
|
return fetchAllPages('/models', params)
|
||||||
let items = first.data.data || []
|
|
||||||
const totalpages = first.data.meta?.pagination?.totalpages || 1
|
|
||||||
if (totalpages > 1) {
|
|
||||||
const rest = await Promise.all(
|
|
||||||
Array.from({ length: totalpages - 1 }, (_, i) =>
|
|
||||||
api.get('/models', { params: { perpage: 100, page: i + 2 } })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rest.forEach(r => { items = items.concat(r.data.data || []) })
|
|
||||||
}
|
|
||||||
return items
|
|
||||||
},
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/models/${id}`)
|
return api.get(`/models/${id}`)
|
||||||
@@ -468,6 +502,12 @@ export const applicationsApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/applications', { params })
|
return api.get('/applications', { params })
|
||||||
},
|
},
|
||||||
|
// Every application, paged past the backend's 100-row cap. The catalogue is
|
||||||
|
// already over 100 entries on a live site, so any picker offering "all
|
||||||
|
// applications" must use this and not list({ perpage: <big number> }).
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/applications', params)
|
||||||
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/applications/${id}`)
|
return api.get(`/applications/${id}`)
|
||||||
},
|
},
|
||||||
@@ -713,6 +753,13 @@ export const usbApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/usb', { params })
|
return api.get('/usb', { params })
|
||||||
},
|
},
|
||||||
|
// Every USB device, paged past the backend's 100-row cap. Batch label printing
|
||||||
|
// and "pick any record" dropdowns must use this: list() with a large
|
||||||
|
// perpage is clamped to 100 and still returns a success response, so
|
||||||
|
// the tail simply goes missing.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/usb', params)
|
||||||
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/usb/${id}`)
|
return api.get(`/usb/${id}`)
|
||||||
},
|
},
|
||||||
@@ -863,6 +910,63 @@ export const setupApi = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buildings and the levels within them (ADR-017). Reads are public - the
|
||||||
|
// printer installer map draws a blueprint before anyone logs in.
|
||||||
|
export const mapLevelsApi = {
|
||||||
|
list() {
|
||||||
|
return api.get('/maplevels')
|
||||||
|
},
|
||||||
|
get(levelid) {
|
||||||
|
return api.get(`/maplevels/${levelid}`)
|
||||||
|
},
|
||||||
|
createBuilding(payload) {
|
||||||
|
return api.post('/maplevels/buildings', payload)
|
||||||
|
},
|
||||||
|
updateBuilding(buildingid, payload) {
|
||||||
|
return api.patch(`/maplevels/buildings/${buildingid}`, payload)
|
||||||
|
},
|
||||||
|
create(payload) {
|
||||||
|
return api.post('/maplevels', payload)
|
||||||
|
},
|
||||||
|
update(levelid, payload) {
|
||||||
|
return api.patch(`/maplevels/${levelid}`, payload)
|
||||||
|
},
|
||||||
|
remove(levelid) {
|
||||||
|
return api.delete(`/maplevels/${levelid}`)
|
||||||
|
},
|
||||||
|
// Returns the image's real pixel size alongside the stored dimensions. On an
|
||||||
|
// empty level the server adopts them; on a populated one it refuses and says
|
||||||
|
// so, because changing the coordinate space moves every marker on it.
|
||||||
|
uploadBlueprint(levelid, theme, file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
form.append('theme', theme)
|
||||||
|
return api.post(`/maplevels/${levelid}/blueprint`, form, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk marker positions: the landmark transform, bulk placement, review state
|
||||||
|
// and undo. Every write snapshots first.
|
||||||
|
export const mapPositionsApi = {
|
||||||
|
setPositions(positions, verified = true) {
|
||||||
|
return api.post('/mappositions/positions', { positions, verified })
|
||||||
|
},
|
||||||
|
transform(payload) {
|
||||||
|
return api.post('/mappositions/transform', payload)
|
||||||
|
},
|
||||||
|
verify(assetids, unverify = false) {
|
||||||
|
return api.post('/mappositions/verify', { assetids, unverify })
|
||||||
|
},
|
||||||
|
snapshots() {
|
||||||
|
return api.get('/mappositions/snapshots')
|
||||||
|
},
|
||||||
|
restore(snapshotid) {
|
||||||
|
return api.post(`/mappositions/snapshots/${snapshotid}/restore`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/settings', { params })
|
return api.get('/settings', { params })
|
||||||
@@ -989,6 +1093,13 @@ export const networkApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/network', { params })
|
return api.get('/network', { params })
|
||||||
},
|
},
|
||||||
|
// Every network device, paged past the backend's 100-row cap. Batch label
|
||||||
|
// printing and "pick any record" dropdowns must use this: list() with a
|
||||||
|
// large perpage is clamped to 100 and still returns a success response, so
|
||||||
|
// the tail simply goes missing.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/network', params)
|
||||||
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/network/${id}`)
|
return api.get(`/network/${id}`)
|
||||||
},
|
},
|
||||||
@@ -1135,6 +1246,13 @@ export const measuringtoolsApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/measuringtools', { params })
|
return api.get('/measuringtools', { params })
|
||||||
},
|
},
|
||||||
|
// Every measuring tool, paged past the backend's 100-row cap. Batch label printing
|
||||||
|
// and "pick any record" dropdowns must use this: list() with a large
|
||||||
|
// perpage is clamped to 100 and still returns a success response, so
|
||||||
|
// the tail simply goes missing.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/measuringtools', params)
|
||||||
|
},
|
||||||
get(id) {
|
get(id) {
|
||||||
return api.get(`/measuringtools/${id}`)
|
return api.get(`/measuringtools/${id}`)
|
||||||
},
|
},
|
||||||
@@ -1178,6 +1296,11 @@ export const printedpartsApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/printedparts/items', { params })
|
return api.get('/printedparts/items', { params })
|
||||||
},
|
},
|
||||||
|
// Every printed item, paged past the backend's 100-row cap. The label sheet
|
||||||
|
// must print the whole selection, not the first page of it.
|
||||||
|
listAll(params = {}) {
|
||||||
|
return fetchAllPages('/printedparts/items', params)
|
||||||
|
},
|
||||||
get(printeditemid) {
|
get(printeditemid) {
|
||||||
return api.get(`/printedparts/items/${printeditemid}`)
|
return api.get(`/printedparts/items/${printeditemid}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
--sidebar-bg: #00003d;
|
--sidebar-bg: #00003d;
|
||||||
--sidebar-text: #ffffff;
|
--sidebar-text: #ffffff;
|
||||||
--sidebar-width: 250px;
|
--sidebar-width: 250px;
|
||||||
|
/* Vertical chrome around .main-content. A full-height page subtracts these;
|
||||||
|
hardcoding a different number is what put a scrollbar on the map. */
|
||||||
|
--main-pad-top: 20px;
|
||||||
|
--main-pad-bottom: 70px;
|
||||||
|
|
||||||
/* Hover variants */
|
/* Hover variants */
|
||||||
--secondary-dark: #82503f;
|
--secondary-dark: #82503f;
|
||||||
@@ -243,7 +247,7 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-left: var(--sidebar-width);
|
margin-left: var(--sidebar-width);
|
||||||
padding: 20px 10px 70px 10px;
|
padding: var(--main-pad-top) 10px var(--main-pad-bottom) 10px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<!-- With coordinates, the name carries the same floor-plan preview
|
<!-- With coordinates, the name carries the same floor-plan preview
|
||||||
the asset's own page uses. Without them, a plain link. -->
|
the asset's own page uses. Without them, a plain link. -->
|
||||||
<LocationMapTooltip v-if="row.link && row.maphover"
|
<LocationMapTooltip v-if="row.link && row.maphover"
|
||||||
:left="row.maphover.x" :top="row.maphover.y"
|
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
|
||||||
:machineName="row.maphover.label">
|
:machineName="row.maphover.label">
|
||||||
<router-link :to="row.link" class="dc-row-title"
|
<router-link :to="row.link" class="dc-row-title"
|
||||||
:title="row.titletip || undefined">
|
:title="row.titletip || undefined">
|
||||||
|
|||||||
@@ -7,28 +7,35 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel } from '../composables/mapConfig'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
left: { type: Number, default: null },
|
left: { type: Number, default: null },
|
||||||
top: { type: Number, default: null },
|
top: { type: Number, default: null },
|
||||||
markerColor: { type: String, default: '#ff0000' },
|
markerColor: { type: String, default: '#ff0000' },
|
||||||
markerLabel: { type: String, default: '' }
|
markerLabel: { type: String, default: '' },
|
||||||
|
// Which drawing left/top belong to (ADR-017). Without it there is no honest
|
||||||
|
// blueprint to draw, so the map is not initialised at all.
|
||||||
|
levelid: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapContainer = ref(null)
|
const mapContainer = ref(null)
|
||||||
let map = null
|
let map = null
|
||||||
let marker = null
|
let marker = null
|
||||||
|
|
||||||
// Map dimensions - facility blueprint size, loaded from settings.
|
// This LEVEL's native size, which is what its marker coordinates mean.
|
||||||
let MAP_WIDTH = mapConfig.width
|
let MAP_WIDTH = 0
|
||||||
let MAP_HEIGHT = mapConfig.height
|
let MAP_HEIGHT = 0
|
||||||
|
|
||||||
function initMap() {
|
function initMap() {
|
||||||
if (!mapContainer.value || props.left === null || props.top === null) return
|
if (!mapContainer.value || props.left === null || props.top === null) return
|
||||||
|
// No level, no drawing. Rendering the default blueprint under these
|
||||||
|
// coordinates would look right and be wrong; an empty box is honest.
|
||||||
|
if (!hasLevel(props.levelid)) return
|
||||||
|
|
||||||
MAP_WIDTH = mapConfig.width
|
const dimensions = dimensionsFor(props.levelid)
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_WIDTH = dimensions.width
|
||||||
|
MAP_HEIGHT = dimensions.height
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
map = L.map(mapContainer.value, {
|
map = L.map(mapContainer.value, {
|
||||||
@@ -39,7 +46,7 @@ function initMap() {
|
|||||||
zoomControl: true
|
zoomControl: true
|
||||||
})
|
})
|
||||||
|
|
||||||
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
L.imageOverlay(blueprintUrlFor(currentTheme.value, props.levelid), bounds).addTo(map)
|
||||||
|
|
||||||
// Convert database coordinates to Leaflet (y is inverted)
|
// Convert database coordinates to Leaflet (y is inverted)
|
||||||
const leafletY = MAP_HEIGHT - props.top
|
const leafletY = MAP_HEIGHT - props.top
|
||||||
|
|||||||
@@ -12,7 +12,15 @@
|
|||||||
@wheel.prevent="onWheel"
|
@wheel.prevent="onWheel"
|
||||||
>
|
>
|
||||||
<div class="map-tooltip-content">
|
<div class="map-tooltip-content">
|
||||||
<div class="map-preview" ref="mapPreview">
|
<div v-if="levelUnknown" class="map-level-unknown">
|
||||||
|
<strong>Level unknown</strong>
|
||||||
|
<span>
|
||||||
|
This asset has a position ({{ props.left }}, {{ props.top }}) but no
|
||||||
|
level, so there is no drawing to show it on. Set its level on the
|
||||||
|
asset, or place it in the map editor.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="map-preview" ref="mapPreview">
|
||||||
<div
|
<div
|
||||||
class="map-transform"
|
class="map-transform"
|
||||||
:style="transformStyle"
|
:style="transformStyle"
|
||||||
@@ -43,7 +51,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
import { ref, computed, nextTick, watch } from 'vue'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, levelName, state as mapConfig } from '../composables/mapConfig'
|
||||||
|
|
||||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||||
// when it loads.
|
// when it loads.
|
||||||
@@ -52,7 +60,11 @@ loadMapConfig()
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
left: { type: Number, default: null },
|
left: { type: Number, default: null },
|
||||||
top: { type: Number, default: null },
|
top: { type: Number, default: null },
|
||||||
machineName: { type: String, default: '' }
|
machineName: { type: String, default: '' },
|
||||||
|
// Which drawing left/top are pixels of (ADR-017). A position without one
|
||||||
|
// cannot be rendered: the same coordinates land somewhere different on every
|
||||||
|
// level, so this shows what is missing instead of guessing the default.
|
||||||
|
levelid: { type: Number, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
@@ -67,18 +79,29 @@ const hasPosition = computed(() => {
|
|||||||
return props.left !== null && props.top !== null
|
return props.left !== null && props.top !== null
|
||||||
})
|
})
|
||||||
|
|
||||||
const blueprintUrl = computed(() => {
|
// A position we cannot place: coordinates but no level, or a level this instance
|
||||||
// Reading currentTheme keeps this reactive to theme changes.
|
// does not know. Rendering the default blueprint here would look correct and be
|
||||||
return blueprintUrlFor(currentTheme.value)
|
// wrong, so the tooltip says so instead.
|
||||||
|
const levelUnknown = computed(() => {
|
||||||
|
return hasPosition.value && !hasLevel(props.levelid)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate marker position as percentage of the facility blueprint size
|
const levelLabel = computed(() => levelName(props.levelid))
|
||||||
|
|
||||||
|
const blueprintUrl = computed(() => {
|
||||||
|
// Reading currentTheme keeps this reactive to theme changes.
|
||||||
|
return blueprintUrlFor(currentTheme.value, props.levelid)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Marker position as a percentage of THIS LEVEL's native size. Percentages of
|
||||||
|
// the wrong level's dimensions is precisely how a marker ends up plausibly
|
||||||
|
// placed and wrong.
|
||||||
const markerX = computed(() => {
|
const markerX = computed(() => {
|
||||||
return (props.left / mapConfig.width) * 100
|
return (props.left / dimensionsFor(props.levelid).width) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
const markerY = computed(() => {
|
const markerY = computed(() => {
|
||||||
return (props.top / mapConfig.height) * 100
|
return (props.top / dimensionsFor(props.levelid).height) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
// Marker style with counter-scale to maintain constant size
|
// Marker style with counter-scale to maintain constant size
|
||||||
@@ -197,6 +220,20 @@ watch(currentTheme, () => {
|
|||||||
.location-tooltip-wrapper:hover {
|
.location-tooltip-wrapper:hover {
|
||||||
color: var(--primary, #1976d2);
|
color: var(--primary, #1976d2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.map-level-unknown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-level-unknown strong {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
|
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
|
||||||
<div class="map-legend" v-if="!pickerMode && assetTypeMode">
|
<div class="map-legend" v-if="!pickerMode && assetTypeMode && hasLegendEntries">
|
||||||
<!-- Show subtype legend when a specific type is selected -->
|
<!-- Show subtype legend when a specific type is selected -->
|
||||||
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
|
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
|
||||||
<span
|
<span
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, hasLevel, state as mapConfig } from '../composables/mapConfig'
|
||||||
import { assetTypeLabel } from '../utils/assetTypes'
|
import { assetTypeLabel } from '../utils/assetTypes'
|
||||||
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
@@ -111,11 +111,23 @@ const props = defineProps({
|
|||||||
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
|
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
|
||||||
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
|
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
|
||||||
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
|
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
|
||||||
subtypeNames: { type: Object, default: () => ({}) } // Map of subtype ID to name
|
subtypeNames: { type: Object, default: () => ({}) }, // Map of subtype ID to name
|
||||||
|
// Which level this map is drawing (ADR-017). Defaults to the current level in
|
||||||
|
// the shared config, so an existing caller that has not been taught about
|
||||||
|
// levels still renders the level the user is looking at rather than nothing.
|
||||||
|
levelid: { type: Number, default: null }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['markerClick', 'positionPicked'])
|
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||||
|
|
||||||
|
// An empty legend still drew its bar and border, which looked like a stray input
|
||||||
|
// box under the toolbar. It renders only when it has something in it.
|
||||||
|
const hasLegendEntries = computed(() => {
|
||||||
|
if (props.selectedAssetType && Object.keys(visibleSubtypes.value).length) return true
|
||||||
|
if (Object.keys(visibleAssetTypes.value).length) return true
|
||||||
|
return overlayLegend.value.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
const mapContainer = ref(null)
|
const mapContainer = ref(null)
|
||||||
let map = null
|
let map = null
|
||||||
let imageOverlay = null
|
let imageOverlay = null
|
||||||
@@ -139,10 +151,15 @@ const filters = ref({
|
|||||||
search: ''
|
search: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map dimensions - facility blueprint size, loaded from settings before
|
// The drawn level's native size, which is what its marker coordinates mean.
|
||||||
// initMap runs (mutable so the loaded values replace the fallback defaults).
|
// Mutable because the levels load after this module does, and because switching
|
||||||
let MAP_WIDTH = mapConfig.width
|
// level changes them.
|
||||||
let MAP_HEIGHT = mapConfig.height
|
function drawnLevelId() {
|
||||||
|
return props.levelid ?? mapConfig.currentlevelid
|
||||||
|
}
|
||||||
|
|
||||||
|
let MAP_WIDTH = dimensionsFor(drawnLevelId()).width
|
||||||
|
let MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
|
||||||
|
|
||||||
// Asset type colors (for unified map mode) - normalized lookup
|
// Asset type colors (for unified map mode) - normalized lookup
|
||||||
const assetTypeColorsMap = {
|
const assetTypeColorsMap = {
|
||||||
@@ -279,7 +296,7 @@ function initMap() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
|
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme, drawnLevelId()), bounds)
|
||||||
imageOverlay.addTo(map)
|
imageOverlay.addTo(map)
|
||||||
|
|
||||||
// Set initial view - zoom out to show full floor plan
|
// Set initial view - zoom out to show full floor plan
|
||||||
@@ -287,17 +304,22 @@ function initMap() {
|
|||||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
||||||
map.setMaxBounds(bounds)
|
map.setMaxBounds(bounds)
|
||||||
|
|
||||||
// Picker mode: click to set position
|
// Bound UNCONDITIONALLY, and handleMapClick ignores the click when picker mode
|
||||||
if (props.pickerMode) {
|
// is off. Binding it only when pickerMode was true AT MOUNT meant a map that
|
||||||
|
// becomes a picker later never got a click handler at all: the map editor
|
||||||
|
// opens with nothing selected, so clicking to place a marker did nothing, for
|
||||||
|
// the whole life of the page. The asset forms only worked because their picker
|
||||||
|
// is mounted inside a modal that is already in picker mode.
|
||||||
map.on('click', handleMapClick)
|
map.on('click', handleMapClick)
|
||||||
|
|
||||||
// Show initial position if provided
|
if (props.pickerMode && props.initialPosition) {
|
||||||
if (props.initialPosition) {
|
|
||||||
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
|
// Existing markers are drawn in picker mode too: placing a marker relative to
|
||||||
|
// the ones already on the floor is the whole task. The forms pass no markers,
|
||||||
|
// so this costs them nothing.
|
||||||
renderMarkers()
|
renderMarkers()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMapClick(e) {
|
function handleMapClick(e) {
|
||||||
@@ -428,8 +450,18 @@ function renderMarkers() {
|
|||||||
overlayLayers.forEach(layer => layer.remove())
|
overlayLayers.forEach(layer => layer.remove())
|
||||||
overlayLayers = []
|
overlayLayers = []
|
||||||
|
|
||||||
|
// Markers belong to ONE level (ADR-017). This is the drawing being shown, and
|
||||||
|
// anything positioned against a different one is not drawn on it.
|
||||||
|
const drawnLevel = drawnLevelId() ?? null
|
||||||
|
|
||||||
props.machines.forEach(item => {
|
props.machines.forEach(item => {
|
||||||
if (item.mapx == null || item.mapy == null) return
|
if (item.mapx == null || item.mapy == null) return
|
||||||
|
// A marker from another level, placed on THIS blueprint, looks entirely
|
||||||
|
// correct and points at the wrong part of the building - so it is omitted
|
||||||
|
// rather than approximated. A position with no level at all is omitted for
|
||||||
|
// the same reason: the map editor lists both, badged, so they can be fixed
|
||||||
|
// rather than silently misplaced here.
|
||||||
|
if ((item.levelid ?? null) !== drawnLevel) return
|
||||||
|
|
||||||
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
||||||
const leafletY = MAP_HEIGHT - item.mapy
|
const leafletY = MAP_HEIGHT - item.mapy
|
||||||
@@ -584,18 +616,43 @@ watch(() => props.machines, (newVal, oldVal) => {
|
|||||||
|
|
||||||
watch(() => props.theme, (newTheme) => {
|
watch(() => props.theme, (newTheme) => {
|
||||||
if (imageOverlay && map) {
|
if (imageOverlay && map) {
|
||||||
imageOverlay.setUrl(blueprintUrlFor(newTheme))
|
imageOverlay.setUrl(blueprintUrlFor(newTheme, drawnLevelId()))
|
||||||
// Marker rings are keyed on the surface, so they have to be redrawn too.
|
// Marker rings are keyed on the surface, so they have to be redrawn too.
|
||||||
renderMarkers()
|
renderMarkers()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Switching level changes the drawing, the coordinate space AND which markers
|
||||||
|
// belong on it. All three move together: the size is what marker coordinates
|
||||||
|
// mean, so swapping the image without the bounds would place every marker
|
||||||
|
// against the wrong scale, and keeping the markers would show the previous
|
||||||
|
// floor's assets on this floor's plan.
|
||||||
|
watch(() => drawnLevelId(), (levelid) => {
|
||||||
|
if (!map || !imageOverlay) return
|
||||||
|
|
||||||
|
MAP_WIDTH = dimensionsFor(levelid).width
|
||||||
|
MAP_HEIGHT = dimensionsFor(levelid).height
|
||||||
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
|
|
||||||
|
const url = blueprintUrlFor(props.theme, levelid)
|
||||||
|
// A level with no blueprint is left blank rather than showing the previous
|
||||||
|
// one, which would be a floor plan labelled as somewhere it is not.
|
||||||
|
imageOverlay.setUrl(url || '')
|
||||||
|
imageOverlay.setBounds(bounds)
|
||||||
|
|
||||||
|
map.setMaxBounds(bounds)
|
||||||
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], map.getZoom())
|
||||||
|
|
||||||
|
renderMarkers()
|
||||||
|
loadOverlays()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Load this facility's blueprint + dimensions before building the map so
|
// Load this facility's blueprint + dimensions before building the map so
|
||||||
// bounds and coordinate math use the right size. Falls back to defaults.
|
// bounds and coordinate math use the right size. Falls back to defaults.
|
||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
MAP_WIDTH = mapConfig.width
|
MAP_WIDTH = dimensionsFor(drawnLevelId()).width
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_HEIGHT = dimensionsFor(drawnLevelId()).height
|
||||||
initMap()
|
initMap()
|
||||||
loadOverlays()
|
loadOverlays()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -127,7 +127,15 @@ export function mapHover(card, item) {
|
|||||||
const x = item[spec.x]
|
const x = item[spec.x]
|
||||||
const y = item[spec.y]
|
const y = item[spec.y]
|
||||||
if (x === null || x === undefined || y === null || y === undefined) return null
|
if (x === null || x === undefined || y === null || y === undefined) return null
|
||||||
return { x, y, label: spec.label ? (item[spec.label] || '') : '' }
|
// The level travels with the coordinates (ADR-017) - they are pixels of ONE
|
||||||
|
// drawing. Read `levelid` unless the card names another field, so a card that
|
||||||
|
// predates levels still previews on the right floor instead of none.
|
||||||
|
const levelid = item[spec.level || 'levelid']
|
||||||
|
return {
|
||||||
|
x, y,
|
||||||
|
levelid: levelid === undefined ? null : levelid,
|
||||||
|
label: spec.label ? (item[spec.label] || '') : '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cardRows(card) {
|
export function cardRows(card) {
|
||||||
|
|||||||
@@ -1,42 +1,59 @@
|
|||||||
// Facility floor-map blueprint config, read from the settings table so each
|
// Which drawing renders a marker, and at what native size (ADR-017).
|
||||||
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
|
//
|
||||||
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
|
// This used to hold ONE blueprint and ONE pixel size, read from four settings,
|
||||||
// Missing keys fall back to the generic placeholder so a fresh or offline
|
// because a site had one floor map. It now holds every level of every building,
|
||||||
// install still renders; each site uploads its own blueprint in Settings.
|
// because `assets.mapx`/`mapy` are pixels in a specific level's space and the
|
||||||
|
// same coordinates mean different places on different drawings.
|
||||||
|
//
|
||||||
|
// THE RULE THIS FILE ENFORCES: a position without a level is not rendered on the
|
||||||
|
// default level. `blueprintUrlFor(theme, levelid)` returns null for an unknown
|
||||||
|
// level, and every caller must show "level unknown" rather than draw something.
|
||||||
|
// Falling back would put one building's ground floor behind a marker positioned
|
||||||
|
// for another building's mezzanine - it renders perfectly and points at the
|
||||||
|
// wrong place, which is worse than rendering nothing.
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { settingsApi } from '../api'
|
|
||||||
|
import { mapLevelsApi } from '../api'
|
||||||
import { withBase } from '../utils/basePath'
|
import { withBase } from '../utils/basePath'
|
||||||
|
|
||||||
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
|
// Used until the levels load, and on a fresh install with none configured, so a
|
||||||
const DEFAULTS = {
|
// map still draws something rather than breaking.
|
||||||
blueprintLight: '/static/images/floorplan-placeholder.svg',
|
const PLACEHOLDER = '/static/images/floorplan-placeholder.svg'
|
||||||
blueprintDark: '/static/images/floorplan-placeholder.svg',
|
const FALLBACK_WIDTH = 3300
|
||||||
width: 3300,
|
const FALLBACK_HEIGHT = 2550
|
||||||
height: 2550
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared reactive config. Import as `state` to read width/height/blueprint.
|
export const state = reactive({
|
||||||
export const state = reactive({ ...DEFAULTS, loaded: false })
|
buildings: [],
|
||||||
|
// Flat index by levelid, because every hover preview resolves an arbitrary
|
||||||
|
// asset's level and has no idea which building it is in.
|
||||||
|
levels: {},
|
||||||
|
defaultlevelid: null,
|
||||||
|
currentlevelid: null,
|
||||||
|
loaded: false,
|
||||||
|
})
|
||||||
|
|
||||||
let inflight = null
|
let inflight = null
|
||||||
|
|
||||||
function applySetting(key, value) {
|
function levelFor(levelid) {
|
||||||
if (value === null || value === undefined || value === '') return
|
if (levelid === null || levelid === undefined) return null
|
||||||
if (key === 'map_blueprint_light') state.blueprintLight = value
|
return state.levels[levelid] || null
|
||||||
else if (key === 'map_blueprint_dark') state.blueprintDark = value
|
|
||||||
else if (key === 'map_width') {
|
|
||||||
const n = parseInt(value, 10)
|
|
||||||
if (!isNaN(n) && n > 0) state.width = n
|
|
||||||
} else if (key === 'map_height') {
|
|
||||||
const n = parseInt(value, 10)
|
|
||||||
if (!isNaN(n) && n > 0) state.height = n
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchConfig() {
|
function fetchLevels() {
|
||||||
inflight = settingsApi.list({ category: 'map' })
|
inflight = mapLevelsApi.list()
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
;(data.data || []).forEach(s => applySetting(s.key, s.value))
|
const payload = data.data || {}
|
||||||
|
state.buildings = payload.buildings || []
|
||||||
|
state.levels = {}
|
||||||
|
state.buildings.forEach(building => {
|
||||||
|
;(building.levels || []).forEach(level => {
|
||||||
|
state.levels[level.levelid] = { ...level, buildingname: building.buildingname }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
state.defaultlevelid = payload.defaultlevelid || null
|
||||||
|
if (!state.currentlevelid || !state.levels[state.currentlevelid]) {
|
||||||
|
state.currentlevelid = state.defaultlevelid
|
||||||
|
}
|
||||||
state.loaded = true
|
state.loaded = true
|
||||||
})
|
})
|
||||||
.catch(() => { state.loaded = true })
|
.catch(() => { state.loaded = true })
|
||||||
@@ -44,26 +61,95 @@ function fetchConfig() {
|
|||||||
return inflight
|
return inflight
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the map config once (shared across all map components). Returns a
|
// Fetch once, shared across every map component. Await it before initialising a
|
||||||
// promise that resolves when state is populated, so a caller can await it
|
// Leaflet map, which needs the dimensions to set its bounds.
|
||||||
// before initializing a Leaflet map that needs the dimensions.
|
|
||||||
export function loadMapConfig() {
|
export function loadMapConfig() {
|
||||||
if (state.loaded) return Promise.resolve()
|
if (state.loaded) return Promise.resolve()
|
||||||
if (inflight) return inflight
|
if (inflight) return inflight
|
||||||
return fetchConfig()
|
return fetchLevels()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-read config from the server after a map setting changes.
|
// Re-read after the levels admin changes something.
|
||||||
export function reloadMapConfig() {
|
export function reloadMapConfig() {
|
||||||
return fetchConfig()
|
return fetchLevels()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blueprint image URL for the given theme ('light' | 'dark').
|
export function setCurrentLevel(levelid) {
|
||||||
export function blueprintUrlFor(theme) {
|
if (state.levels[levelid]) state.currentlevelid = levelid
|
||||||
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blueprint URL for one level in one theme, or null when the level is unknown.
|
||||||
|
*
|
||||||
|
* Falls back to the OTHER theme's image before giving up, because a site that
|
||||||
|
* uploaded only a light blueprint should still render in dark mode - a
|
||||||
|
* hard-to-read floor plan beats no floor plan.
|
||||||
|
*/
|
||||||
|
export function blueprintUrlFor(theme, levelid) {
|
||||||
|
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
|
||||||
|
if (!level) return null
|
||||||
|
const wanted = theme === 'light' ? level.blueprintlight : level.blueprintdark
|
||||||
|
const other = theme === 'light' ? level.blueprintdark : level.blueprintlight
|
||||||
|
const chosen = wanted || other
|
||||||
|
return withBase(chosen || PLACEHOLDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native pixel size of a level, which is what its marker coordinates mean.
|
||||||
|
*
|
||||||
|
* Returns the fallback for an unknown level so arithmetic does not divide by
|
||||||
|
* undefined, but callers deciding WHETHER to draw must ask `hasLevel` - these
|
||||||
|
* numbers are a safe default, not evidence the level exists.
|
||||||
|
*/
|
||||||
|
export function dimensionsFor(levelid) {
|
||||||
|
const level = levelFor(levelid === undefined ? state.currentlevelid : levelid)
|
||||||
|
return {
|
||||||
|
width: level?.mapwidth || FALLBACK_WIDTH,
|
||||||
|
height: level?.mapheight || FALLBACK_HEIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLevel(levelid) {
|
||||||
|
return !!levelFor(levelid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelName(levelid) {
|
||||||
|
const level = levelFor(levelid)
|
||||||
|
if (!level) return null
|
||||||
|
// Qualified by building only when there is more than one, so a single-building
|
||||||
|
// site is not made to read "Main / Ground floor" everywhere.
|
||||||
|
return state.buildings.length > 1
|
||||||
|
? `${level.buildingname} / ${level.levelname}`
|
||||||
|
: level.levelname
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every level flat, in building then level order, for a selector.
|
||||||
|
export function levelOptions() {
|
||||||
|
const options = []
|
||||||
|
state.buildings.forEach(building => {
|
||||||
|
;(building.levels || []).forEach(level => {
|
||||||
|
options.push({
|
||||||
|
levelid: level.levelid,
|
||||||
|
levelname: level.levelname,
|
||||||
|
buildingname: building.buildingname,
|
||||||
|
label: state.buildings.length > 1
|
||||||
|
? `${building.buildingname} / ${level.levelname}`
|
||||||
|
: level.levelname,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMapConfig() {
|
export function useMapConfig() {
|
||||||
loadMapConfig()
|
loadMapConfig()
|
||||||
return { state, blueprintUrlFor }
|
return {
|
||||||
|
state,
|
||||||
|
blueprintUrlFor,
|
||||||
|
dimensionsFor,
|
||||||
|
hasLevel,
|
||||||
|
levelName,
|
||||||
|
levelOptions,
|
||||||
|
setCurrentLevel,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
144
frontend/src/composables/mapConfig.spec.js
Normal file
144
frontend/src/composables/mapConfig.spec.js
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// The invariant the whole map model rests on: a position without a level is not
|
||||||
|
// drawn on the default level.
|
||||||
|
//
|
||||||
|
// Nothing else catches this. Rewriting the composable left four components
|
||||||
|
// reading a property that no longer existed, which is `undefined` rather than a
|
||||||
|
// compile error - Vite built it happily and every marker would have been
|
||||||
|
// positioned at NaN percent. A build proves the code parses, not that a marker
|
||||||
|
// lands anywhere.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const list = vi.fn()
|
||||||
|
vi.mock('../api', () => ({ mapLevelsApi: { list: (...args) => list(...args) } }))
|
||||||
|
|
||||||
|
const {
|
||||||
|
state, loadMapConfig, reloadMapConfig, blueprintUrlFor, dimensionsFor,
|
||||||
|
hasLevel, levelName, levelOptions, setCurrentLevel,
|
||||||
|
} = await import('./mapConfig')
|
||||||
|
|
||||||
|
// Two buildings, three levels, sized like the real before and after: the ground
|
||||||
|
// floor at 3300x2550 and a second floor at 3308x4000.
|
||||||
|
const PAYLOAD = {
|
||||||
|
data: {
|
||||||
|
data: {
|
||||||
|
buildings: [
|
||||||
|
{
|
||||||
|
buildingid: 1, buildingname: 'Main', levels: [
|
||||||
|
{ levelid: 1, levelname: 'Ground floor', sortorder: 0, mapwidth: 3300,
|
||||||
|
mapheight: 2550, blueprintlight: '/x/g-light.png',
|
||||||
|
blueprintdark: '/x/g-dark.png', isdefault: true },
|
||||||
|
{ levelid: 2, levelname: 'Second floor', sortorder: 1, mapwidth: 3308,
|
||||||
|
mapheight: 4000, blueprintlight: '/x/2-light.png',
|
||||||
|
blueprintdark: null, isdefault: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
buildingid: 2, buildingname: 'Annex', levels: [
|
||||||
|
{ levelid: 3, levelname: 'Ground floor', sortorder: 0, mapwidth: 1200,
|
||||||
|
mapheight: 900, blueprintlight: '/x/a-light.png',
|
||||||
|
blueprintdark: '/x/a-dark.png', isdefault: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultlevelid: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
list.mockReset()
|
||||||
|
list.mockResolvedValue(PAYLOAD)
|
||||||
|
state.loaded = false
|
||||||
|
state.currentlevelid = null
|
||||||
|
await reloadMapConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loading', () => {
|
||||||
|
it('indexes every level of every building and adopts the default', () => {
|
||||||
|
expect(Object.keys(state.levels)).toEqual(['1', '2', '3'])
|
||||||
|
expect(state.defaultlevelid).toBe(1)
|
||||||
|
expect(state.currentlevelid).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches once across concurrent callers', async () => {
|
||||||
|
list.mockClear()
|
||||||
|
state.loaded = false
|
||||||
|
await Promise.all([loadMapConfig(), loadMapConfig(), loadMapConfig()])
|
||||||
|
expect(list).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('survives an unreachable server without hanging every map on the page', async () => {
|
||||||
|
list.mockReset()
|
||||||
|
list.mockRejectedValue(new Error('network'))
|
||||||
|
state.loaded = false
|
||||||
|
await reloadMapConfig()
|
||||||
|
expect(state.loaded).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dimensions belong to the level, not the site', () => {
|
||||||
|
it('returns each level its own native size', () => {
|
||||||
|
expect(dimensionsFor(1)).toEqual({ width: 3300, height: 2550 })
|
||||||
|
expect(dimensionsFor(2)).toEqual({ width: 3308, height: 4000 })
|
||||||
|
expect(dimensionsFor(3)).toEqual({ width: 1200, height: 900 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not report one level size for another', () => {
|
||||||
|
// The bug this guards: a marker on level 2 measured against level 1's
|
||||||
|
// height renders at 2550/4000 of the way down - plausible, and wrong.
|
||||||
|
expect(dimensionsFor(2).height).not.toBe(dimensionsFor(1).height)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a position without a level is not drawn', () => {
|
||||||
|
it('has no blueprint for a null level', () => {
|
||||||
|
expect(blueprintUrlFor('light', null)).toBeNull()
|
||||||
|
expect(hasLevel(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no blueprint for a level this instance does not know', () => {
|
||||||
|
expect(blueprintUrlFor('light', 99)).toBeNull()
|
||||||
|
expect(hasLevel(99)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never substitutes the default level for a missing one', () => {
|
||||||
|
const groundfloor = blueprintUrlFor('light', 1)
|
||||||
|
expect(groundfloor).toContain('g-light.png')
|
||||||
|
// The failure mode: returning the default blueprint for an unknown level.
|
||||||
|
expect(blueprintUrlFor('light', null)).not.toBe(groundfloor)
|
||||||
|
expect(blueprintUrlFor('light', 99)).not.toBe(groundfloor)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('blueprints', () => {
|
||||||
|
it('serves the theme asked for', () => {
|
||||||
|
expect(blueprintUrlFor('light', 1)).toContain('g-light.png')
|
||||||
|
expect(blueprintUrlFor('dark', 1)).toContain('g-dark.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the other theme rather than showing nothing', () => {
|
||||||
|
// Level 2 has no dark blueprint. A hard-to-read floor plan beats no floor
|
||||||
|
// plan, and a site that uploaded one image should still work in both themes.
|
||||||
|
expect(blueprintUrlFor('dark', 2)).toContain('2-light.png')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('naming and selection', () => {
|
||||||
|
it('qualifies a level by building only when there is more than one', () => {
|
||||||
|
// Both buildings have a 'Ground floor', so the name alone is ambiguous.
|
||||||
|
expect(levelName(1)).toBe('Main / Ground floor')
|
||||||
|
expect(levelName(3)).toBe('Annex / Ground floor')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers every level in building then level order', () => {
|
||||||
|
expect(levelOptions().map(option => option.levelid)).toEqual([1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to make an unknown level current', () => {
|
||||||
|
setCurrentLevel(99)
|
||||||
|
expect(state.currentlevelid).toBe(1)
|
||||||
|
setCurrentLevel(2)
|
||||||
|
expect(state.currentlevelid).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,7 +52,9 @@ export const searchDomains = [
|
|||||||
{ key: 'network_device', label: 'Network Devices' },
|
{ key: 'network_device', label: 'Network Devices' },
|
||||||
{ key: 'measuring_tool', label: 'Measuring Tools' },
|
{ key: 'measuring_tool', label: 'Measuring Tools' },
|
||||||
{ key: 'notification', label: 'Notifications' },
|
{ key: 'notification', label: 'Notifications' },
|
||||||
{ key: 'subnet', label: 'Subnets' }
|
{ key: 'subnet', label: 'Subnets' },
|
||||||
|
{ key: 'usb_device', label: 'USB Devices' },
|
||||||
|
{ key: 'printed_item', label: 'Printed Items' }
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useSystemSettings() {
|
export function useSystemSettings() {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Map Editor</h2>
|
<h2>Map Editor</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-secondary" @click="openTransform">Recalibrate level</button>
|
||||||
|
<button class="btn btn-secondary" @click="openSnapshots">Undo history</button>
|
||||||
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
|
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -12,6 +14,13 @@
|
|||||||
<div class="asset-panel">
|
<div class="asset-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>Assets</h3>
|
<h3>Assets</h3>
|
||||||
|
<select v-if="levelOptions().length > 1" v-model.number="editingLevelId"
|
||||||
|
class="form-control" title="Which drawing you are placing on">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="search" class="form-control" type="search"
|
||||||
|
placeholder="Search name or asset number" />
|
||||||
<select v-model="filterType" class="form-control">
|
<select v-model="filterType" class="form-control">
|
||||||
<option value="">All Types</option>
|
<option value="">All Types</option>
|
||||||
<option value="machine">Machines</option>
|
<option value="machine">Machines</option>
|
||||||
@@ -25,7 +34,15 @@
|
|||||||
<div class="asset-filter">
|
<div class="asset-filter">
|
||||||
<label class="filter-checkbox">
|
<label class="filter-checkbox">
|
||||||
<input type="checkbox" v-model="showUnplacedOnly" />
|
<input type="checkbox" v-model="showUnplacedOnly" />
|
||||||
Show unplaced only
|
Unplaced only
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox" title="Positions a transform moved, which nobody has confirmed against the current drawing yet">
|
||||||
|
<input type="checkbox" v-model="showUnverifiedOnly" />
|
||||||
|
Needs review ({{ unverifiedCount }})
|
||||||
|
</label>
|
||||||
|
<label class="filter-checkbox" title="Only assets on the level you are editing">
|
||||||
|
<input type="checkbox" v-model="thisLevelOnly" />
|
||||||
|
This level only
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,6 +64,15 @@
|
|||||||
<div class="asset-meta">
|
<div class="asset-meta">
|
||||||
<span class="badge badge-sm">{{ asset.assettype }}</span>
|
<span class="badge badge-sm">{{ asset.assettype }}</span>
|
||||||
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
|
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
|
||||||
|
<span v-if="asset.levelid && asset.levelid !== editingLevelId"
|
||||||
|
class="badge badge-sm badge-other-level"
|
||||||
|
:title="'On ' + levelName(asset.levelid)">{{ levelName(asset.levelid) }}</span>
|
||||||
|
<span v-if="asset.mapx && !asset.levelid" class="badge badge-sm badge-warning"
|
||||||
|
title="Has a position but no level, so it cannot be drawn">no level</span>
|
||||||
|
<button v-if="asset.mapx && asset.levelid && !asset.mapverifiedat"
|
||||||
|
class="badge badge-sm badge-review" type="button"
|
||||||
|
title="This position came from a transform. Click to confirm it is right."
|
||||||
|
@click.stop="markReviewed(asset)">confirm</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,19 +122,114 @@
|
|||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
:pickerMode="!!selectedAsset"
|
:pickerMode="!!selectedAsset"
|
||||||
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
|
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
|
||||||
|
:levelid="editingLevelId"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@markerClick="handleMarkerClick"
|
@markerClick="handleMarkerClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Recalibrate: move every marker on this level by a transform read off two
|
||||||
|
landmarks. Deriving it from the image dimensions instead would stretch
|
||||||
|
one axis and be wrong everywhere, so the numbers come from features
|
||||||
|
visible on both drawings. -->
|
||||||
|
<div v-if="showTransform" class="modal-overlay">
|
||||||
|
<div class="modal modal-wide">
|
||||||
|
<div class="modal-header"><h3>Recalibrate {{ levelName(editingLevelId) }}</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="input-hint">
|
||||||
|
Pick two features present on both the old and the new drawing. A
|
||||||
|
building corner plus something central beats two corners: a long
|
||||||
|
baseline makes the derived scale more forgiving.
|
||||||
|
</p>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Landmark</th><th>Old X</th><th>Old Y</th><th>New X</th><th>New Y</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(mark, index) in landmarks" :key="index">
|
||||||
|
<td>{{ index + 1 }}</td>
|
||||||
|
<td><input v-model.number="mark.fromx" type="number" class="form-control" /></td>
|
||||||
|
<td><input v-model.number="mark.fromy" type="number" class="form-control" /></td>
|
||||||
|
<td><input v-model.number="mark.tox" type="number" class="form-control" /></td>
|
||||||
|
<td><input v-model.number="mark.toy" type="number" class="form-control" /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button class="btn btn-small btn-secondary" @click="landmarks.push({})">
|
||||||
|
Add a third landmark
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="preview" class="transform-preview">
|
||||||
|
<h4>Preview</h4>
|
||||||
|
<p class="mono">
|
||||||
|
X: scale {{ preview.transform.scalex.toFixed(4) }}, offset {{ Math.round(preview.transform.offsetx) }}<br />
|
||||||
|
Y: scale {{ preview.transform.scaley.toFixed(4) }}, offset {{ Math.round(preview.transform.offsety) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ preview.assetcount }} marker(s) would move.
|
||||||
|
<strong v-if="preview.outofboundscount" class="warn">
|
||||||
|
{{ preview.outofboundscount }} would land outside the drawing.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p class="input-hint">
|
||||||
|
A scale near 1 means the drawing shifted rather than rescaled. A
|
||||||
|
scale far from 1 on an axis that only gained canvas is the sign of a
|
||||||
|
bad landmark pair.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="transformError" class="error-message">{{ transformError }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @click="showTransform = false">Cancel</button>
|
||||||
|
<button class="btn btn-secondary" @click="runTransform(true)" :disabled="working">Preview</button>
|
||||||
|
<button class="btn btn-primary" @click="runTransform(false)" :disabled="working || !preview">
|
||||||
|
Move {{ preview ? preview.assetcount : 0 }} marker(s)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Undo. Every bulk write snapshots first, and a restore snapshots too, so a
|
||||||
|
second attempt is always possible. -->
|
||||||
|
<div v-if="showSnapshots" class="modal-overlay">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header"><h3>Undo history</h3></div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>When</th><th>What</th><th>Markers</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="snapshot in snapshots" :key="snapshot.snapshotid">
|
||||||
|
<td class="mono">{{ (snapshot.createddate || '').slice(0, 16).replace('T', ' ') }}</td>
|
||||||
|
<td>{{ snapshot.reason }}</td>
|
||||||
|
<td>{{ snapshot.assetcount }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-small" @click="restore(snapshot)" :disabled="working">
|
||||||
|
{{ snapshot.restoredat ? 'Restore again' : 'Restore' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!snapshots.length">
|
||||||
|
<td colspan="4" class="empty">Nothing to undo yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" @click="showSnapshots = false">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
||||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||||
import { assetsApi } from '../api'
|
import { assetsApi, mapPositionsApi } from '../api'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, setCurrentLevel,
|
||||||
|
state as mapConfig } from '../composables/mapConfig'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { useToast } from '../composables/toast'
|
import { useToast } from '../composables/toast'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -119,6 +240,25 @@ const selectedAsset = ref(null)
|
|||||||
const pickedPosition = ref(null)
|
const pickedPosition = ref(null)
|
||||||
const filterType = ref('')
|
const filterType = ref('')
|
||||||
const showUnplacedOnly = ref(false)
|
const showUnplacedOnly = ref(false)
|
||||||
|
// A transform is a guess: it moves markers but cannot know which machines
|
||||||
|
// actually moved, so everything it touches needs confirming against the drawing.
|
||||||
|
// This is the working queue for that.
|
||||||
|
const showUnverifiedOnly = ref(false)
|
||||||
|
const thisLevelOnly = ref(true)
|
||||||
|
const search = ref('')
|
||||||
|
// Which drawing is being placed on. Every position saved from here belongs to it.
|
||||||
|
const editingLevelId = ref(null)
|
||||||
|
|
||||||
|
// Recalibration state. Two landmarks is the minimum that determines both axes;
|
||||||
|
// a third is offered because points picked by eye carry a few pixels of error and
|
||||||
|
// three let it cancel rather than accumulate.
|
||||||
|
const showTransform = ref(false)
|
||||||
|
const landmarks = ref([{}, {}])
|
||||||
|
const preview = ref(null)
|
||||||
|
const transformError = ref('')
|
||||||
|
const showSnapshots = ref(false)
|
||||||
|
const snapshots = ref([])
|
||||||
|
const working = ref(false)
|
||||||
|
|
||||||
const filteredAssets = computed(() => {
|
const filteredAssets = computed(() => {
|
||||||
let result = assets.value
|
let result = assets.value
|
||||||
@@ -131,17 +271,48 @@ const filteredAssets = computed(() => {
|
|||||||
result = result.filter(a => !a.mapx || !a.mapy)
|
result = result.filter(a => !a.mapx || !a.mapy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showUnverifiedOnly.value) {
|
||||||
|
// Placed, on a level, and never confirmed. An unplaced asset is not
|
||||||
|
// "unreviewed" - it is simply not on the map yet.
|
||||||
|
result = result.filter(a => a.mapx && a.levelid && !a.mapverifiedat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thisLevelOnly.value && editingLevelId.value) {
|
||||||
|
// Unplaced assets stay visible whatever the level filter says: they are the
|
||||||
|
// ones you are here to place, and they belong to no level yet.
|
||||||
|
result = result.filter(a => !a.mapx || a.levelid === editingLevelId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = search.value.trim().toLowerCase()
|
||||||
|
if (term) {
|
||||||
|
result = result.filter(a =>
|
||||||
|
(a.name || '').toLowerCase().includes(term) ||
|
||||||
|
(a.assetnumber || '').toLowerCase().includes(term))
|
||||||
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Only markers on the level being drawn. Markers from another level would appear
|
||||||
|
// at coordinates that mean nothing on this one.
|
||||||
const placedAssets = computed(() => {
|
const placedAssets = computed(() => {
|
||||||
return assets.value.filter(a => a.mapx && a.mapy)
|
return assets.value.filter(a => a.mapx && a.mapy &&
|
||||||
|
a.levelid === editingLevelId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const unverifiedCount = computed(() =>
|
||||||
|
assets.value.filter(a => a.mapx && a.levelid && !a.mapverifiedat).length)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadMapConfig()
|
||||||
|
editingLevelId.value = mapConfig.defaultlevelid
|
||||||
await loadAssets()
|
await loadAssets()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(editingLevelId, (levelid) => {
|
||||||
|
if (levelid) setCurrentLevel(levelid)
|
||||||
|
})
|
||||||
|
|
||||||
async function loadAssets() {
|
async function loadAssets() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +325,80 @@ async function loadAssets() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTransform() {
|
||||||
|
preview.value = null
|
||||||
|
transformError.value = ''
|
||||||
|
landmarks.value = [{}, {}]
|
||||||
|
showTransform.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTransform(dryrun) {
|
||||||
|
working.value = true
|
||||||
|
transformError.value = ''
|
||||||
|
try {
|
||||||
|
const { data } = await mapPositionsApi.transform({
|
||||||
|
levelid: editingLevelId.value,
|
||||||
|
landmarks: landmarks.value.filter(mark =>
|
||||||
|
[mark.fromx, mark.fromy, mark.tox, mark.toy].every(
|
||||||
|
value => value !== undefined && value !== null && value !== '')),
|
||||||
|
dryrun,
|
||||||
|
})
|
||||||
|
if (dryrun) {
|
||||||
|
preview.value = data.data
|
||||||
|
} else {
|
||||||
|
showTransform.value = false
|
||||||
|
preview.value = null
|
||||||
|
await loadAssets()
|
||||||
|
// Straight into the review queue: every moved marker is now unconfirmed,
|
||||||
|
// and that is the work the transform created.
|
||||||
|
showUnverifiedOnly.value = true
|
||||||
|
toast.success(data.message || 'Markers moved')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
transformError.value = err?.response?.data?.data?.error?.message
|
||||||
|
|| 'The transform could not be applied'
|
||||||
|
} finally {
|
||||||
|
working.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSnapshots() {
|
||||||
|
showSnapshots.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapPositionsApi.snapshots()
|
||||||
|
snapshots.value = data.data || []
|
||||||
|
} catch (err) {
|
||||||
|
snapshots.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restore(snapshot) {
|
||||||
|
if (!confirm(`Restore ${snapshot.assetcount} marker position(s) from ` +
|
||||||
|
`"${snapshot.reason}"? The current positions are snapshotted first.`)) return
|
||||||
|
working.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapPositionsApi.restore(snapshot.snapshotid)
|
||||||
|
await loadAssets()
|
||||||
|
await openSnapshots()
|
||||||
|
toast.success(data.message || 'Positions restored')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not restore that snapshot')
|
||||||
|
} finally {
|
||||||
|
working.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm a marker is in the right place without moving it - the common case in
|
||||||
|
// a review pass, and the reason the queue empties.
|
||||||
|
async function markReviewed(asset) {
|
||||||
|
try {
|
||||||
|
await mapPositionsApi.verify([asset.assetid])
|
||||||
|
asset.mapverifiedat = new Date().toISOString()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Could not mark that reviewed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getTypeIcon(assettype) {
|
function getTypeIcon(assettype) {
|
||||||
const icons = {
|
const icons = {
|
||||||
'machine': Cog,
|
'machine': Cog,
|
||||||
@@ -184,16 +429,23 @@ async function savePosition() {
|
|||||||
if (!selectedAsset.value || !pickedPosition.value) return
|
if (!selectedAsset.value || !pickedPosition.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assetsApi.update(selectedAsset.value.assetid, {
|
// Through the bulk endpoint, which requires a level and stamps the review
|
||||||
|
// state: placing a marker by hand IS the confirmation, and a snapshot is
|
||||||
|
// taken so the placement can be undone.
|
||||||
|
await mapPositionsApi.setPositions([{
|
||||||
|
assetid: selectedAsset.value.assetid,
|
||||||
mapx: Math.round(pickedPosition.value.left),
|
mapx: Math.round(pickedPosition.value.left),
|
||||||
mapy: Math.round(pickedPosition.value.top)
|
mapy: Math.round(pickedPosition.value.top),
|
||||||
})
|
levelid: editingLevelId.value,
|
||||||
|
}])
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
||||||
if (asset) {
|
if (asset) {
|
||||||
asset.mapx = Math.round(pickedPosition.value.left)
|
asset.mapx = Math.round(pickedPosition.value.left)
|
||||||
asset.mapy = Math.round(pickedPosition.value.top)
|
asset.mapy = Math.round(pickedPosition.value.top)
|
||||||
|
asset.levelid = editingLevelId.value
|
||||||
|
asset.mapverifiedat = new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedAsset.value = null
|
selectedAsset.value = null
|
||||||
@@ -240,7 +492,7 @@ function cancelEdit() {
|
|||||||
.map-editor {
|
.map-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 40px);
|
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -266,13 +518,30 @@ function cancelEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
|
/* The heading takes its own row and the three controls share the next one(s).
|
||||||
|
All four on a single row inside a 320px panel squeezed the search box until
|
||||||
|
its placeholder read "Search na". */
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header .form-control {
|
||||||
|
/* One control per row. Two of them side by side in a 318px panel left the
|
||||||
|
search box 139px for a placeholder needing 216px, so it read "Search name
|
||||||
|
o" - a control whose own label does not fit is not a narrower control, it is
|
||||||
|
an unlabelled one. */
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-header h3 {
|
.panel-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -409,4 +678,31 @@ function cancelEdit() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-review {
|
||||||
|
background: var(--warning);
|
||||||
|
color: #1a1a1a;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-other-level {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-light);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-preview {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-preview h4 { margin: 0 0 0.5rem; }
|
||||||
|
.mono { font-family: monospace; }
|
||||||
|
.warn { color: var(--warning); }
|
||||||
|
.modal-wide { min-width: 44rem; }
|
||||||
|
.input-hint { display: block; color: var(--text-light); font-size: 0.8rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,17 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Filter Controls -->
|
<!-- Filter Controls -->
|
||||||
<div class="map-filters">
|
<div class="map-filters">
|
||||||
|
<select
|
||||||
|
v-if="levelChoices.length > 1"
|
||||||
|
v-model.number="shownLevelId"
|
||||||
|
@change="onLevelChange"
|
||||||
|
title="Which floor plan to show"
|
||||||
|
>
|
||||||
|
<option v-for="level in levelChoices" :key="level.levelid" :value="level.levelid">
|
||||||
|
{{ level.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<select v-model="selectedType" @change="onTypeChange">
|
<select v-model="selectedType" @change="onTypeChange">
|
||||||
<option value="">All Asset Types</option>
|
<option value="">All Asset Types</option>
|
||||||
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
||||||
@@ -60,6 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
|
:levelid="shownLevelId"
|
||||||
:machines="filteredAssets"
|
:machines="filteredAssets"
|
||||||
:machinetypes="[]"
|
:machinetypes="[]"
|
||||||
:businessunits="businessunits"
|
:businessunits="businessunits"
|
||||||
@@ -82,7 +94,8 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
|
|||||||
import { assetsApi } from '../api'
|
import { assetsApi } from '../api'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, levelName, levelOptions,
|
||||||
|
setCurrentLevel, state as mapConfig } from '@/composables/mapConfig'
|
||||||
import { exportMapPdf } from '../utils/mapPdf'
|
import { exportMapPdf } from '../utils/mapPdf'
|
||||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||||
import { getSubtypeId } from '../utils/mapColors'
|
import { getSubtypeId } from '../utils/mapColors'
|
||||||
@@ -104,6 +117,41 @@ const selectedSubtype = ref('')
|
|||||||
const selectedBusinessUnit = ref('')
|
const selectedBusinessUnit = ref('')
|
||||||
const selectedStatus = ref('')
|
const selectedStatus = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Which floor plan is on screen. Every marker coordinate is pixels of ONE level
|
||||||
|
// (ADR-017), so this decides both the drawing and which markers belong on it.
|
||||||
|
const shownLevelId = ref(null)
|
||||||
|
|
||||||
|
const levelChoices = computed(() => levelOptions())
|
||||||
|
|
||||||
|
function onLevelChange() {
|
||||||
|
// Keep the shared composable in step, so a map-position picker opened later
|
||||||
|
// starts on the level being looked at rather than the site default.
|
||||||
|
setCurrentLevel(shownLevelId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A search whose only matches are on another floor would otherwise show an empty
|
||||||
|
// map: the assets matched, the filter counted them, and nothing was drawn.
|
||||||
|
// Follow the results to the level that actually holds them - the level with the
|
||||||
|
// most matches, so a search matching several floors lands on the best one.
|
||||||
|
function followSearchAcrossLevels() {
|
||||||
|
if (!searchQuery.value.trim()) return
|
||||||
|
const matches = filteredAssets.value.filter(a => a.mapx != null && a.mapy != null)
|
||||||
|
if (!matches.length) return
|
||||||
|
if (matches.some(a => (a.levelid ?? null) === shownLevelId.value)) return
|
||||||
|
|
||||||
|
const tally = new Map()
|
||||||
|
matches.forEach(asset => {
|
||||||
|
const levelid = asset.levelid ?? null
|
||||||
|
if (levelid === null) return
|
||||||
|
tally.set(levelid, (tally.get(levelid) || 0) + 1)
|
||||||
|
})
|
||||||
|
if (!tally.size) return
|
||||||
|
|
||||||
|
const best = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]
|
||||||
|
shownLevelId.value = best
|
||||||
|
setCurrentLevel(best)
|
||||||
|
}
|
||||||
const exporting = ref(false)
|
const exporting = ref(false)
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
@@ -241,13 +289,21 @@ async function exportPdf() {
|
|||||||
try {
|
try {
|
||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
await exportMapPdf({
|
await exportMapPdf({
|
||||||
assets: filteredAssets.value,
|
// Only this level's markers. The sheet is one drawing, so a marker
|
||||||
|
// positioned against another level would be printed on the wrong floor
|
||||||
|
// plan - the same failure the on-screen map had, in a form nobody can
|
||||||
|
// correct after it is printed and carried onto the floor.
|
||||||
|
assets: filteredAssets.value.filter(
|
||||||
|
asset => (asset.levelid ?? null) === shownLevelId.value),
|
||||||
// blueprintUrlFor applies withBase - the raw setting value is a
|
// blueprintUrlFor applies withBase - the raw setting value is a
|
||||||
// root-relative /api path, which 404s under a subpath mount like /ops.
|
// root-relative /api path, which 404s under a subpath mount like /ops.
|
||||||
// The PDF always uses the light blueprint: it prints on white paper.
|
// The PDF always uses the light blueprint: it prints on white paper.
|
||||||
blueprintUrl: blueprintUrlFor('light'),
|
blueprintUrl: blueprintUrlFor('light', shownLevelId.value),
|
||||||
mapWidth: mapConfig.width,
|
mapWidth: dimensionsFor(shownLevelId.value).width,
|
||||||
mapHeight: mapConfig.height,
|
mapHeight: dimensionsFor(shownLevelId.value).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(shownLevelId.value),
|
||||||
selectedType: selectedType.value,
|
selectedType: selectedType.value,
|
||||||
subtypeColors: subtypeColorMap.value,
|
subtypeColors: subtypeColorMap.value,
|
||||||
subtypeNames: subtypeNameMap.value,
|
subtypeNames: subtypeNameMap.value,
|
||||||
@@ -262,7 +318,8 @@ async function exportPdf() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadMapConfig()
|
await loadMapConfig()
|
||||||
|
shownLevelId.value = mapConfig.currentlevelid ?? mapConfig.defaultlevelid ?? null
|
||||||
try {
|
try {
|
||||||
const response = await assetsApi.getMap()
|
const response = await assetsApi.getMap()
|
||||||
const data = response.data.data || {}
|
const data = response.data.data || {}
|
||||||
@@ -302,6 +359,7 @@ function updateMapLayers() {
|
|||||||
function debouncedSearch() {
|
function debouncedSearch() {
|
||||||
clearTimeout(searchTimeout)
|
clearTimeout(searchTimeout)
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
|
followSearchAcrossLevels()
|
||||||
updateMapLayers()
|
updateMapLayers()
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
@@ -315,7 +373,7 @@ function handleMarkerClick(asset) {
|
|||||||
.map-page {
|
.map-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - 2rem);
|
height: calc(100vh - var(--main-pad-top) - var(--main-pad-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-page .page-header {
|
.map-page .page-header {
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ const typeLabels = {
|
|||||||
measuring_tool: 'Measuring Tool',
|
measuring_tool: 'Measuring Tool',
|
||||||
employee: 'Employee',
|
employee: 'Employee',
|
||||||
notification: 'Notice',
|
notification: 'Notice',
|
||||||
subnet: 'Subnet'
|
subnet: 'Subnet',
|
||||||
|
usb_device: 'USB',
|
||||||
|
printed_item: 'Printed Part'
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterTypeMap = {
|
const filterTypeMap = {
|
||||||
@@ -132,7 +134,9 @@ const filterTypeMap = {
|
|||||||
applications: ['application'],
|
applications: ['application'],
|
||||||
knowledgebase: ['knowledgebase'],
|
knowledgebase: ['knowledgebase'],
|
||||||
notifications: ['notification'],
|
notifications: ['notification'],
|
||||||
employees: ['employee']
|
employees: ['employee'],
|
||||||
|
usb: ['usb_device'],
|
||||||
|
printedparts: ['printed_item']
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterList = [
|
const filterList = [
|
||||||
@@ -145,7 +149,9 @@ const filterList = [
|
|||||||
{ key: 'applications', label: 'Apps' },
|
{ key: 'applications', label: 'Apps' },
|
||||||
{ key: 'knowledgebase', label: 'KB' },
|
{ key: 'knowledgebase', label: 'KB' },
|
||||||
{ key: 'notifications', label: 'Notices' },
|
{ key: 'notifications', label: 'Notices' },
|
||||||
{ key: 'employees', label: 'Employees' }
|
{ key: 'employees', label: 'Employees' },
|
||||||
|
{ key: 'usb', label: 'USB' },
|
||||||
|
{ key: 'printedparts', label: 'Printed Parts' }
|
||||||
]
|
]
|
||||||
|
|
||||||
function typeLabel(type) {
|
function typeLabel(type) {
|
||||||
@@ -207,9 +213,18 @@ async function search(q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function performSearch() {
|
function performSearch() {
|
||||||
if (searchInput.value.trim()) {
|
const term = searchInput.value.trim()
|
||||||
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
if (!term) return
|
||||||
|
// Searching a term that is ALREADY in the URL is a duplicate navigation: the
|
||||||
|
// router aborts it, so the route watcher never fires and the button does
|
||||||
|
// nothing at all. Re-run the query directly in that case. The sidebar box
|
||||||
|
// never hits this, because it is always navigating from somewhere else -
|
||||||
|
// which is why the two search boxes appeared to behave differently.
|
||||||
|
if (term === route.query.q) {
|
||||||
|
search(term)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
router.push({ path: '/search', query: { q: term } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openKBArticle(result) {
|
async function openKBArticle(result) {
|
||||||
|
|||||||
@@ -295,8 +295,10 @@ onMounted(async () => {
|
|||||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||||
encodes.value = await resolveDefaultEncodes(assettype)
|
encodes.value = await resolveDefaultEncodes(assettype)
|
||||||
try {
|
try {
|
||||||
const response = await config.api.list({ perpage: 500 })
|
// listAll, not list: perpage is clamped to 100 server-side, so a batch
|
||||||
assets.value = response.data.data || []
|
// sheet built from list() silently omitted every asset past the first
|
||||||
|
// 100 and printed a short run that looked complete.
|
||||||
|
assets.value = await config.api.listAll()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading assets:', err)
|
console.error('Error loading assets:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -270,8 +270,10 @@ async function loadFilterOptions(fields) {
|
|||||||
filterOptions.value.locations = response.data.data || []
|
filterOptions.value.locations = response.data.data || []
|
||||||
}
|
}
|
||||||
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
||||||
const response = await applicationsApi.list({ perpage: 100 })
|
// listAll: 100 is the server-side cap, not a generous limit, and the
|
||||||
filterOptions.value.applications = response.data.data || []
|
// catalogue is past it - a report filtered by a late-alphabet
|
||||||
|
// application could not be built.
|
||||||
|
filterOptions.value.applications = await applicationsApi.listAll()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading filter options:', error)
|
console.error('Error loading filter options:', error)
|
||||||
|
|||||||
@@ -1,101 +1,459 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Floor Map</h2>
|
<h2>Floor Maps</h2>
|
||||||
|
<button class="btn btn-secondary" @click="showAddBuilding = true">Add building</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
|
||||||
<div class="setting-group">
|
|
||||||
<h3>Facility Blueprint</h3>
|
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
The floor-plan image and its pixel dimensions for this facility. Map
|
Each level is one drawing with its own blueprint and its own pixel size.
|
||||||
markers are positioned against these dimensions, so the width and
|
Marker positions are pixels in the level's own space, so the level id below
|
||||||
height must match the native size of the blueprint image. Leave the
|
is what a position belongs to - it is worth knowing when you run a
|
||||||
image paths at their defaults to use the bundled sitemap.
|
transform or ask about a marker that is in the wrong place.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div v-if="loading" class="empty">Loading...</div>
|
||||||
<label>
|
|
||||||
<span>Blueprint image (light theme)</span>
|
<div v-for="building in buildings" :key="building.buildingid" class="section-card">
|
||||||
|
<div class="building-header">
|
||||||
<input
|
<input
|
||||||
type="text"
|
v-model="building.buildingname"
|
||||||
v-model="settings.map_blueprint_light"
|
class="building-name"
|
||||||
placeholder="/static/images/sitemap2025-light.png"
|
@blur="renameBuilding(building)"
|
||||||
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
|
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
/>
|
||||||
<div class="map-upload-row">
|
<span class="muted">{{ building.levels.length }} level(s)</span>
|
||||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
<button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
|
||||||
<img v-if="settings.map_blueprint_light" :src="withBase(settings.map_blueprint_light)" class="map-thumb" alt="light blueprint" />
|
|
||||||
</div>
|
|
||||||
<small class="input-hint">Upload an image, or type a path/URL to the light-theme floor plan</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-row">
|
<table class="data-table">
|
||||||
<label>
|
<thead>
|
||||||
<span>Blueprint image (dark theme)</span>
|
<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
|
<input
|
||||||
type="text"
|
v-model="level.levelname"
|
||||||
v-model="settings.map_blueprint_dark"
|
class="form-control"
|
||||||
placeholder="/static/images/sitemap2025-dark.png"
|
@blur="renameLevel(level)"
|
||||||
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
|
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
/>
|
||||||
<div class="map-upload-row">
|
</td>
|
||||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
<td>
|
||||||
<img v-if="settings.map_blueprint_dark" :src="withBase(settings.map_blueprint_dark)" class="map-thumb map-thumb-dark" alt="dark blueprint" />
|
|
||||||
</div>
|
|
||||||
<small class="input-hint">Upload an image, or type a path/URL to the dark-theme floor plan</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-row">
|
|
||||||
<label>
|
|
||||||
<span>Blueprint width (pixels)</span>
|
|
||||||
<input
|
<input
|
||||||
|
v-model.number="level.sortorder"
|
||||||
type="number"
|
type="number"
|
||||||
v-model="settings.map_width"
|
class="form-control order-input"
|
||||||
min="1"
|
@blur="saveLevel(level, { sortorder: level.sortorder })"
|
||||||
placeholder="3300"
|
|
||||||
@blur="saveSetting('map_width', settings.map_width)"
|
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
title="Lower sorts first. Ground 0, first floor 1, mezzanine 5 between them later."
|
||||||
<small class="input-hint">Native pixel width of the blueprint image</small>
|
/>
|
||||||
</label>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="size-fields">
|
||||||
|
<input
|
||||||
|
v-model.number="level.mapwidth"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-control size-input"
|
||||||
|
aria-label="Blueprint width in pixels"
|
||||||
|
@blur="saveSize(level)"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<span class="size-x">x</span>
|
||||||
|
<input
|
||||||
|
v-model.number="level.mapheight"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="form-control size-input"
|
||||||
|
aria-label="Blueprint height in pixels"
|
||||||
|
@blur="saveSize(level)"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small v-if="level.assetcount" class="input-hint">
|
||||||
|
Taken from the image while a level is empty. Changing it now
|
||||||
|
re-scales where all {{ level.assetcount }} marker(s) sit relative
|
||||||
|
to the drawing - set the new size, then Recalibrate from
|
||||||
|
landmarks in the map editor.
|
||||||
|
</small>
|
||||||
|
<small v-else class="input-hint">
|
||||||
|
Set from the blueprint you upload.
|
||||||
|
</small>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="setting-row">
|
<!-- Add building -->
|
||||||
<label>
|
<div v-if="showAddBuilding" class="modal-overlay">
|
||||||
<span>Blueprint height (pixels)</span>
|
<div class="modal">
|
||||||
<input
|
<div class="modal-header"><h3>Add building</h3></div>
|
||||||
type="number"
|
<div class="modal-body">
|
||||||
v-model="settings.map_height"
|
<div class="form-group">
|
||||||
min="1"
|
<label>Name</label>
|
||||||
placeholder="2550"
|
<input v-model="newBuilding" class="form-control" placeholder="Annex"
|
||||||
@blur="saveSetting('map_height', settings.map_height)"
|
@keyup.enter="addBuilding" />
|
||||||
:disabled="saving"
|
</div>
|
||||||
>
|
</div>
|
||||||
<small class="input-hint">Native pixel height of the blueprint image</small>
|
<div class="modal-footer">
|
||||||
</label>
|
<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>
|
||||||
|
|
||||||
|
<!-- 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="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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
<div v-if="notice" class="settings-success">{{ notice }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { withBase } from '../../utils/basePath'
|
// Buildings and levels admin (ADR-017). This page replaced four site-wide
|
||||||
import { onMounted } from 'vue'
|
// settings that described a single blueprint, which could not express a second
|
||||||
import { useSystemSettings } from '../../composables/systemSettings'
|
// level drawn at a different size, let alone a second building.
|
||||||
|
//
|
||||||
|
// The level id is deliberately on screen. It is what `assets.levelid` points at,
|
||||||
|
// what a landmark transform takes as an argument, and the first thing worth
|
||||||
|
// knowing when a marker draws on the wrong drawing.
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
const {
|
import { mapLevelsApi } from '@/api'
|
||||||
settings, saving, mapUploading, error, success,
|
import { withBase } from '@/utils/basePath'
|
||||||
loadSettings, saveSetting, uploadBlueprint,
|
import { reloadMapConfig } from '@/composables/mapConfig'
|
||||||
} = useSystemSettings()
|
|
||||||
|
|
||||||
onMounted(loadSettings)
|
const buildings = ref([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const notice = ref('')
|
||||||
|
|
||||||
|
const showAddBuilding = ref(false)
|
||||||
|
const newBuilding = ref('')
|
||||||
|
const addLevelFor = ref(null)
|
||||||
|
const newLevel = ref({ levelname: '', sortorder: 0 })
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.list()
|
||||||
|
buildings.value = data.data.buildings || []
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not load the levels')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(err, fallback) {
|
||||||
|
return err?.response?.data?.data?.error?.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(text) {
|
||||||
|
notice.value = text
|
||||||
|
error.value = ''
|
||||||
|
// Long enough to read a sentence about what did not happen.
|
||||||
|
setTimeout(() => { notice.value = '' }, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addBuilding() {
|
||||||
|
const name = newBuilding.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await mapLevelsApi.createBuilding({
|
||||||
|
buildingname: name,
|
||||||
|
sortorder: buildings.value.length,
|
||||||
|
})
|
||||||
|
showAddBuilding.value = false
|
||||||
|
newBuilding.value = ''
|
||||||
|
await load()
|
||||||
|
report(`Building "${name}" added. Add its levels next.`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not add the building')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddLevel(building) {
|
||||||
|
addLevelFor.value = building
|
||||||
|
// Default to one past the last, so the common case needs no thought and the
|
||||||
|
// uncommon one is still editable.
|
||||||
|
newLevel.value = {
|
||||||
|
levelname: '',
|
||||||
|
sortorder: (building.levels.at(-1)?.sortorder ?? -1) + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLevel() {
|
||||||
|
const name = newLevel.value.levelname.trim()
|
||||||
|
if (!name || !addLevelFor.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.create({
|
||||||
|
buildingid: addLevelFor.value.buildingid,
|
||||||
|
levelname: name,
|
||||||
|
sortorder: newLevel.value.sortorder,
|
||||||
|
})
|
||||||
|
addLevelFor.value = null
|
||||||
|
await load()
|
||||||
|
report(`"${name}" created as level ${data.data.levelid}. Upload its blueprint to set its size.`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not add the level')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameBuilding(building) {
|
||||||
|
const name = (building.buildingname || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await mapLevelsApi.updateBuilding(building.buildingid, { buildingname: name })
|
||||||
|
report(`Building renamed to "${name}".`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not rename the building')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameLevel(level) {
|
||||||
|
const name = (level.levelname || '').trim()
|
||||||
|
if (!name) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await saveLevel(level, { levelname: name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLevel(level, payload) {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.update(level.levelid, payload)
|
||||||
|
// The server reports when a change leaves existing positions in an old
|
||||||
|
// coordinate space. Surfacing that verbatim matters more than a tidy
|
||||||
|
// message: it names the markers that are now wrong.
|
||||||
|
const warnings = data.data?.warnings
|
||||||
|
await load()
|
||||||
|
await reloadMapConfig()
|
||||||
|
report(warnings?.length ? warnings.join(' ') : 'Saved.')
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not save the level')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSize(level) {
|
||||||
|
if (!(level.mapwidth > 0) || !(level.mapheight > 0)) {
|
||||||
|
error.value = 'Width and height must both be positive.'
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A level with markers keeps its coordinates when the size changes, so every
|
||||||
|
// marker moves relative to the drawing. That is sometimes exactly right (a
|
||||||
|
// re-export of the same plan at a new resolution) and sometimes the start of
|
||||||
|
// a recalibration, but it is never something to do by accident.
|
||||||
|
if (level.assetcount) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`${level.levelname} has ${level.assetcount} marker(s) placed against ` +
|
||||||
|
`${level.mapwidth} x ${level.mapheight}. Changing the size moves all of ` +
|
||||||
|
`them relative to the drawing. Recalibrate from landmarks afterwards. Continue?`)
|
||||||
|
if (!ok) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveLevel(level, { mapwidth: level.mapwidth, mapheight: level.mapheight })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(level, theme, event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const { data } = await mapLevelsApi.uploadBlueprint(level.levelid, theme, file)
|
||||||
|
await load()
|
||||||
|
await reloadMapConfig()
|
||||||
|
// sizenote is the interesting case: the image disagrees with the stored
|
||||||
|
// dimensions AND markers are already placed, so the server refused to
|
||||||
|
// change the coordinate space out from under them.
|
||||||
|
report(data.data.sizenote
|
||||||
|
|| `Blueprint uploaded (${data.data.detectedwidth} x ${data.data.detectedheight}).`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not upload the blueprint')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
event.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(level) {
|
||||||
|
const placed = level.assetcount
|
||||||
|
? ` It has ${level.assetcount} marker(s) on it, which the server will refuse.`
|
||||||
|
: ''
|
||||||
|
if (!confirm(`Remove "${level.levelname}" (level ${level.levelid})?${placed}`)) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await mapLevelsApi.remove(level.levelid)
|
||||||
|
await load()
|
||||||
|
report(`Level ${level.levelid} removed.`)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = message(err, 'Could not remove the level')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-description {
|
||||||
|
color: var(--text-light);
|
||||||
|
max-width: 60rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-name {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.building-name:hover,
|
||||||
|
.building-name:focus {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelid {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: monospace; }
|
||||||
|
.order-input { width: 4.5rem; }
|
||||||
|
.size-input { width: 6rem; }
|
||||||
|
.size-fields { display: flex; align-items: center; gap: 0.35rem; }
|
||||||
|
.size-x { color: var(--text-light); }
|
||||||
|
.file-input { display: block; margin-top: 0.25rem; font-size: 0.75rem; }
|
||||||
|
|
||||||
|
.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')
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Computers plugin API endpoints."""
|
"""Computers plugin API endpoints."""
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, Response, current_app
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||||
|
|
||||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||||
|
|
||||||
from shopdb.api import require_permission, apply_import_timestamps
|
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||||
|
|
||||||
computers_bp = Blueprint('computers', __name__)
|
computers_bp = Blueprint('computers', __name__)
|
||||||
|
|
||||||
@@ -375,6 +375,11 @@ def list_computers():
|
|||||||
Asset.assetnumber.ilike(pattern),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(pattern),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(pattern),
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
# The optional identifiers too (ADR-001). Global search matches
|
||||||
|
# these, and a tag read off the machine has to find it here as
|
||||||
|
# well - this box is where someone holding the label looks.
|
||||||
|
Asset.gaugelabreference.ilike(pattern),
|
||||||
|
Asset.maintenancereference.ilike(pattern),
|
||||||
Computer.hostname.ilike(pattern),
|
Computer.hostname.ilike(pattern),
|
||||||
ComputerType.computertype.ilike(pattern)
|
ComputerType.computertype.ilike(pattern)
|
||||||
)
|
)
|
||||||
@@ -560,6 +565,7 @@ def create_computer():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -658,7 +664,7 @@ def update_computer(computer_id: int):
|
|||||||
# Update asset fields
|
# Update asset fields
|
||||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||||
'maintenancereference', 'statusid',
|
'maintenancereference', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
old_val = getattr(asset, key)
|
old_val = getattr(asset, key)
|
||||||
@@ -1058,3 +1064,112 @@ def dashboard_sharedmachines():
|
|||||||
|
|
||||||
out.sort(key=lambda r: -r['pccount'])
|
out.sort(key=lambda r: -r['pccount'])
|
||||||
return success_response(out)
|
return success_response(out)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Collector client script
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
|
||||||
|
|
||||||
|
|
||||||
|
def _client_script_path():
|
||||||
|
"""The reporter shipped with this plugin, which is the single source."""
|
||||||
|
import os
|
||||||
|
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
'client', CLIENT_SCRIPT_NAME)
|
||||||
|
|
||||||
|
|
||||||
|
def _setting_value(key):
|
||||||
|
row = Setting.query.filter_by(key=key).first()
|
||||||
|
return ((row.value if row else '') or '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_client_script(source: str, baseurl: str, ranges: str,
|
||||||
|
version: str, generatedon: str) -> str:
|
||||||
|
"""Stamp a site's own values into the reporter's parameter defaults.
|
||||||
|
|
||||||
|
ONLY the defaults are substituted, never the body: the file in the repo
|
||||||
|
stays runnable as-is, so there is no second copy to drift. Everything
|
||||||
|
stamped here is overridable at runtime - the parameter still wins, then the
|
||||||
|
registry - because a bay may need to differ from its site.
|
||||||
|
|
||||||
|
The collector key is NOT stamped in. This file lands on every shop-floor PC,
|
||||||
|
and a token in a file on hundreds of bays cannot be rotated quietly; it is
|
||||||
|
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
|
||||||
|
ADOPTING-AT-ANOTHER-SITE.md.
|
||||||
|
"""
|
||||||
|
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
|
||||||
|
header = (
|
||||||
|
'# GENERATED by ShopDB {version} on {generatedon}\n'
|
||||||
|
'# for {baseurl}\n'
|
||||||
|
'#\n'
|
||||||
|
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
|
||||||
|
'# collector contract. Edits here are lost on the next download - change\n'
|
||||||
|
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
|
||||||
|
'#\n'
|
||||||
|
'# The collector key is deliberately NOT in this file. Provision it as\n'
|
||||||
|
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
|
||||||
|
'\n'
|
||||||
|
).format(version=version, generatedon=generatedon,
|
||||||
|
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
|
||||||
|
|
||||||
|
out = source
|
||||||
|
if apiurl:
|
||||||
|
old = "[string]$ApiUrl = ''"
|
||||||
|
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
|
||||||
|
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
|
||||||
|
if ranges:
|
||||||
|
old = "[string]$AllowedRanges = ''"
|
||||||
|
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
|
||||||
|
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
|
||||||
|
return header + out
|
||||||
|
|
||||||
|
|
||||||
|
@computers_bp.route('/client-script', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def download_client_script():
|
||||||
|
"""The collector reporter, stamped with THIS site's values.
|
||||||
|
|
||||||
|
Admin-only. It carries no secret, but it does state a site's URL and its
|
||||||
|
internal ranges, which is configuration rather than something to hand out.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = _client_script_path()
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'The collector script is not present in this install',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
with open(path, 'r', encoding='utf-8') as handle:
|
||||||
|
source = handle.read()
|
||||||
|
|
||||||
|
# A site that has not set its public URL still gets a usable script: the
|
||||||
|
# browsing origin is the server the admin is talking to right now.
|
||||||
|
baseurl = _setting_value('site_base_url') or request.url_root
|
||||||
|
# From config, not an import: a plugin reaching into core is an ADR-002
|
||||||
|
# violation and the contract test fails the build for it.
|
||||||
|
version = current_app.config.get('VERSION') or 'unknown'
|
||||||
|
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = _generate_client_script(
|
||||||
|
source, baseurl.strip(), _setting_value('computers_routableranges'),
|
||||||
|
version, generatedon)
|
||||||
|
except AssertionError as exc:
|
||||||
|
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||||
|
|
||||||
|
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
|
||||||
|
return Response(
|
||||||
|
body,
|
||||||
|
mimetype='text/plain; charset=utf-8',
|
||||||
|
headers={
|
||||||
|
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
|
||||||
|
# Published so a deployment can verify what it fetched, the same way
|
||||||
|
# the installer publishes one.
|
||||||
|
'X-Script-Sha256': digest,
|
||||||
|
})
|
||||||
|
|||||||
328
plugins/computers/client/Report-AssetToShopDB.ps1
Normal file
328
plugins/computers/client/Report-AssetToShopDB.ps1
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
# Report-AssetToShopDB.ps1
|
||||||
|
#
|
||||||
|
# Reports a PC's identity to ShopDB (Flask) so the computers/machines record
|
||||||
|
# stays current with whatever the PC actually is right now: hostname, BIOS
|
||||||
|
# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when
|
||||||
|
# present) and its corp/AESFMA IPv4 address.
|
||||||
|
#
|
||||||
|
# TARGET: the ADR-006 collector API.
|
||||||
|
# POST <shopdb>/api/collector/computers
|
||||||
|
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
|
||||||
|
# which Install-GEEnforce.ps1 provisions and the enforcement client already
|
||||||
|
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
|
||||||
|
# product code is a defect, and this script ships to every site.
|
||||||
|
# The computers plugin's apply_collector_payload upserts idempotently by
|
||||||
|
# hostname (create if missing, patch-style update if present - only the fields
|
||||||
|
# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps
|
||||||
|
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
|
||||||
|
#
|
||||||
|
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
|
||||||
|
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
|
||||||
|
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
|
||||||
|
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
|
||||||
|
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
|
||||||
|
# key into the manifest JSON on the share.
|
||||||
|
#
|
||||||
|
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
|
||||||
|
# identity with no machineNo, so no PC-to-machine link is built - by design.
|
||||||
|
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
|
||||||
|
# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are
|
||||||
|
# logged, never thrown.
|
||||||
|
#
|
||||||
|
# WHY ONE NIC ONLY:
|
||||||
|
# Some bays carry two NICs - a private controller NIC and the routable
|
||||||
|
# corporate NIC. Only the routable one belongs in ShopDB. A site may name its
|
||||||
|
# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with
|
||||||
|
# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
|
||||||
|
# the same intent without knowing any site's addressing.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# Flask collector endpoint for the computers plugin. Empty resolves from
|
||||||
|
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
|
||||||
|
[string]$ApiUrl = '',
|
||||||
|
|
||||||
|
# Comma-separated CIDRs naming this site's routable ranges, e.g.
|
||||||
|
# '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead.
|
||||||
|
[string]$AllowedRanges = '',
|
||||||
|
|
||||||
|
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
|
||||||
|
# store in the registry. Override with -ApiKey via Args for testing.
|
||||||
|
[string]$ApiKey = '',
|
||||||
|
|
||||||
|
[int]$TimeoutSec = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
|
||||||
|
# protocol the site rejects; the collector POST is HTTPS.
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
$logDir = 'C:\Logs\Shopfloor'
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||||
|
|
||||||
|
function Log([string]$msg) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
||||||
|
# running this script is running the enforcement client, which cannot work
|
||||||
|
# without BaseUrl, so it is present wherever this is deployed.
|
||||||
|
if (-not $ApiUrl) {
|
||||||
|
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||||
|
try {
|
||||||
|
if (Test-Path $regPath) {
|
||||||
|
$base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl
|
||||||
|
if ($base -and $base.Trim()) {
|
||||||
|
$ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $ApiUrl) {
|
||||||
|
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# A site may name its routable ranges rather than rely on the default route.
|
||||||
|
if (-not $AllowedRanges) {
|
||||||
|
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||||
|
try {
|
||||||
|
if (Test-Path $regPath) {
|
||||||
|
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges
|
||||||
|
if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# collector key from the GE-Enforce secret store when not passed via Args.
|
||||||
|
if (-not $ApiKey) {
|
||||||
|
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||||
|
try {
|
||||||
|
if (Test-Path $regPath) {
|
||||||
|
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey
|
||||||
|
if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $ApiKey) {
|
||||||
|
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an
|
||||||
|
# unconfigured site falls through to the default-route NIC below (ADR-015).
|
||||||
|
#
|
||||||
|
# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so
|
||||||
|
# that collides with the [string] parameter above and the array is silently
|
||||||
|
# COERCED to a string. .Count on a string is 1, so the script then believes a
|
||||||
|
# range is configured and never falls back to the default route.
|
||||||
|
$rangeList = @()
|
||||||
|
foreach ($cidr in ($AllowedRanges -split ',')) {
|
||||||
|
$cidr = $cidr.Trim()
|
||||||
|
if (-not $cidr) { continue }
|
||||||
|
$parts = $cidr -split '/'
|
||||||
|
if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue }
|
||||||
|
$rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConvertTo-Uint32([string]$ip) {
|
||||||
|
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
|
||||||
|
[Array]::Reverse($bytes)
|
||||||
|
return [BitConverter]::ToUInt32($bytes, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-InAllowedRange([string]$ip) {
|
||||||
|
try {
|
||||||
|
$ipInt = ConvertTo-Uint32 $ip
|
||||||
|
foreach ($r in $rangeList) {
|
||||||
|
$netInt = ConvertTo-Uint32 $r.Network
|
||||||
|
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
|
||||||
|
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
Log '=== Report asset to ShopDB (collector) ==='
|
||||||
|
|
||||||
|
# hostname - the collector identity field. required.
|
||||||
|
$hostname = $env:COMPUTERNAME
|
||||||
|
|
||||||
|
# BIOS serial - optional now (collector keys on hostname). Sent when present.
|
||||||
|
$serialNumber = ''
|
||||||
|
try {
|
||||||
|
$serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
|
||||||
|
if ($serialNumber) { $serialNumber = $serialNumber.Trim() }
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not read BIOS serial: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber
|
||||||
|
# server-side (9999 placeholder skipped there and here).
|
||||||
|
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
|
||||||
|
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
|
||||||
|
# 3. C:\Enrollment\machine-number.txt - imaging value.
|
||||||
|
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
|
||||||
|
$machineNo = ''
|
||||||
|
foreach ($regPath in @(
|
||||||
|
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
||||||
|
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
|
||||||
|
)) {
|
||||||
|
if ($machineNo) { break }
|
||||||
|
try {
|
||||||
|
if (Test-Path $regPath) {
|
||||||
|
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
|
||||||
|
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $machineNo) {
|
||||||
|
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
|
||||||
|
if (Test-Path -LiteralPath $cmmFile) {
|
||||||
|
try {
|
||||||
|
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
|
||||||
|
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
|
||||||
|
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $machineNo) {
|
||||||
|
$mnFile = 'C:\Enrollment\machine-number.txt'
|
||||||
|
if (Test-Path -LiteralPath $mnFile) {
|
||||||
|
try {
|
||||||
|
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
|
||||||
|
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
|
||||||
|
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS name string (caption + feature-update + build), e.g.
|
||||||
|
# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each
|
||||||
|
# distinct string into operatingsystems.
|
||||||
|
$osVersion = ''
|
||||||
|
$lastBootTime = ''
|
||||||
|
try {
|
||||||
|
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||||
|
$osVersion = "$($os.Caption)".Trim()
|
||||||
|
$displayVersion = ''
|
||||||
|
try {
|
||||||
|
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
|
||||||
|
} catch {}
|
||||||
|
if ($displayVersion) { $osVersion += " $displayVersion" }
|
||||||
|
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
|
||||||
|
$osVersion = $osVersion.Trim()
|
||||||
|
# ISO 8601 - collector parses via datetime.fromisoformat.
|
||||||
|
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName
|
||||||
|
# (console session owner). empty when nobody logged on -> omitted so an
|
||||||
|
# unattended bay does not blank the last-known user.
|
||||||
|
$loggedInUser = ''
|
||||||
|
try {
|
||||||
|
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
|
||||||
|
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not read logged-in user: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
|
||||||
|
# when present; absent -> server leaves existing pctype untouched (a bare report
|
||||||
|
# never re-types a PC). Unmapped values return a warning, not an error.
|
||||||
|
$pcType = ''
|
||||||
|
$ptFile = 'C:\Enrollment\pc-type.txt'
|
||||||
|
if (Test-Path -LiteralPath $ptFile) {
|
||||||
|
try {
|
||||||
|
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
|
||||||
|
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when
|
||||||
|
# present so a WMI read failure does not blank the model.
|
||||||
|
$manufacturer = ''
|
||||||
|
$model = ''
|
||||||
|
try {
|
||||||
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||||
|
$manufacturer = "$($cs.Manufacturer)".Trim()
|
||||||
|
$model = "$($cs.Model)".Trim()
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not read make/model: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The routable IPv4 - a physical, connected NIC. The collector schema takes a
|
||||||
|
# single ipaddress string, so report the corporate NIC and drop any
|
||||||
|
# controller/machine-LAN NIC. With ranges configured the IP must be in one;
|
||||||
|
# with none, the NIC carrying the default route is the routable one by
|
||||||
|
# definition, which needs no knowledge of a site's addressing.
|
||||||
|
$corpIp = ''
|
||||||
|
$defaultRouteIfIndexes = @()
|
||||||
|
if ($rangeList.Count -eq 0) {
|
||||||
|
try {
|
||||||
|
$defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
|
||||||
|
Sort-Object RouteMetric |
|
||||||
|
Select-Object -ExpandProperty InterfaceIndex -Unique)
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not read the route table: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
|
||||||
|
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
|
||||||
|
foreach ($ipo in $ipObjs) {
|
||||||
|
if ($corpIp) { break }
|
||||||
|
$adapter = $null
|
||||||
|
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
|
||||||
|
if (-not $adapter) { continue }
|
||||||
|
if (-not $adapter.HardwareInterface) { continue }
|
||||||
|
if ($adapter.Status -ne 'Up') { continue }
|
||||||
|
if ($rangeList.Count -gt 0) {
|
||||||
|
if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress }
|
||||||
|
} elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) {
|
||||||
|
$corpIp = $ipo.IPAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Log "WARN interface enumeration failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' }
|
||||||
|
|
||||||
|
# collector schema fields (lowercase concatenated). hostname is required; the
|
||||||
|
# rest are sent only when present so a partial read never blanks a good value.
|
||||||
|
$body = @{ hostname = $hostname }
|
||||||
|
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
|
||||||
|
if ($machineNo) { $body['machinenumber'] = $machineNo }
|
||||||
|
if ($pcType) { $body['pctype'] = $pcType }
|
||||||
|
if ($manufacturer) { $body['vendorname'] = $manufacturer }
|
||||||
|
if ($model) { $body['modelnumber'] = $model }
|
||||||
|
if ($osVersion) { $body['osname'] = $osVersion }
|
||||||
|
if ($lastBootTime) { $body['lastboottime'] = $lastBootTime }
|
||||||
|
if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser }
|
||||||
|
if ($corpIp) { $body['ipaddress'] = $corpIp }
|
||||||
|
$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
|
||||||
|
|
||||||
|
$json = $body | ConvertTo-Json -Compress -Depth 4
|
||||||
|
|
||||||
|
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f `
|
||||||
|
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||||
|
-ContentType 'application/json' `
|
||||||
|
-Headers @{ 'X-API-Key' = $ApiKey } `
|
||||||
|
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||||
|
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
|
||||||
|
} catch {
|
||||||
|
Log "ERROR POST failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -39,6 +39,12 @@ export default [
|
|||||||
meta: { requiresAuth: true, plugin: 'computers' }
|
meta: { requiresAuth: true, plugin: 'computers' }
|
||||||
},
|
},
|
||||||
// Computer-specific settings
|
// Computer-specific settings
|
||||||
|
{
|
||||||
|
path: 'settings/collector',
|
||||||
|
name: 'collector-settings',
|
||||||
|
component: () => import('./views/CollectorSettings.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/pctypes',
|
path: 'settings/pctypes',
|
||||||
name: 'pctypes',
|
name: 'pctypes',
|
||||||
|
|||||||
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Asset reporter</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="setting-description">
|
||||||
|
Shop-floor PCs report what they are to this server. The script below is
|
||||||
|
generated with THIS site's values, so it downloads ready to deploy - there
|
||||||
|
is nothing in it to find and edit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="card form-card">
|
||||||
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||||
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Routable ranges</label>
|
||||||
|
<input v-model="ranges" type="text" class="form-control"
|
||||||
|
placeholder="10.20.0.0/23,10.21.4.0/26" />
|
||||||
|
<p class="field-hint">
|
||||||
|
Comma-separated CIDRs for this site's corporate network. A bay with two
|
||||||
|
NICs - a private controller NIC and a routable one - reports the
|
||||||
|
address in these ranges. Leave it empty and the PC reports whichever
|
||||||
|
NIC carries the default route, which is correct at most sites and needs
|
||||||
|
no configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||||
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card form-card">
|
||||||
|
<h3>Download the reporter</h3>
|
||||||
|
<p class="field-hint">
|
||||||
|
Stamped with this server's URL and the ranges above, and with the version
|
||||||
|
that generated it, so a script found on a bay can be traced back here.
|
||||||
|
Re-download after upgrading ShopDB.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" @click="download" :disabled="downloading">
|
||||||
|
{{ downloading ? 'Generating...' : 'Download Report-AssetToShopDB.ps1' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p v-if="digest" class="field-hint mono">
|
||||||
|
SHA-256 {{ digest }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="field-hint">
|
||||||
|
<strong>The collector key is not in this file, deliberately.</strong> It
|
||||||
|
lands on every shop-floor PC, and a token spread across hundreds of bays
|
||||||
|
cannot be rotated quietly. Mint a token scoped to
|
||||||
|
<code>collector.ingest</code> and provision it as
|
||||||
|
<code>HKLM:\SOFTWARE\GE\ShopDB</code> value <code>CollectorKey</code> -
|
||||||
|
the adoption guide has worked examples for Intune, DSC and GE-Enforce.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { settingsApi } from '@/api'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const RANGES_KEY = 'computers_routableranges'
|
||||||
|
|
||||||
|
const ranges = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
const downloading = ref(false)
|
||||||
|
const digest = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.list({ category: 'computers' })
|
||||||
|
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
|
||||||
|
if (row) ranges.value = row.value || ''
|
||||||
|
} catch (loadError) {
|
||||||
|
error.value = 'Could not load settings'
|
||||||
|
console.error(loadError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
message.value = ''
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
await settingsApi.update(RANGES_KEY, String(ranges.value ?? ''))
|
||||||
|
message.value = 'Saved. Re-download the script so it carries the new ranges.'
|
||||||
|
} catch (saveError) {
|
||||||
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
downloading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
// responseType text: this is a script, not JSON, and the hash the server
|
||||||
|
// publishes is of exactly these bytes.
|
||||||
|
const response = await api.get('/computers/client-script', { responseType: 'text' })
|
||||||
|
digest.value = response.headers['x-script-sha256'] || ''
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = 'Report-AssetToShopDB.ps1'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch (downloadError) {
|
||||||
|
error.value = downloadError.response?.status === 403
|
||||||
|
? 'Only an administrator can download the reporter'
|
||||||
|
: 'Could not generate the script'
|
||||||
|
console.error(downloadError)
|
||||||
|
} finally {
|
||||||
|
downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mono { font-family: monospace; word-break: break-all; }
|
||||||
|
.form-card h3 { margin-top: 0; }
|
||||||
|
</style>
|
||||||
@@ -185,6 +185,7 @@
|
|||||||
v-if="computer.mapx != null && computer.mapy != null"
|
v-if="computer.mapx != null && computer.mapy != null"
|
||||||
:left="computer.mapx"
|
:left="computer.mapx"
|
||||||
:top="computer.mapy"
|
:top="computer.mapy"
|
||||||
|
:levelid="computer.levelid"
|
||||||
:machineName="computer.assetnumber"
|
:machineName="computer.assetnumber"
|
||||||
>
|
>
|
||||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||||
|
|||||||
@@ -245,6 +245,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Printer assignment. Written as plain asset relationships, so this
|
||||||
|
section is absent at a site without the printers plugin. -->
|
||||||
|
<template v-if="printersEnabled">
|
||||||
|
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printers</h4>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="printersearch">Assigned Printers</label>
|
||||||
|
<input
|
||||||
|
id="printersearch"
|
||||||
|
v-model="printerSearch"
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Filter printers..."
|
||||||
|
/>
|
||||||
|
<div class="printer-list">
|
||||||
|
<label
|
||||||
|
v-for="printerAsset in filteredPrinters"
|
||||||
|
:key="printerAsset.assetid"
|
||||||
|
class="printer-item"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isPrinterAssigned(printerAsset.assetid)"
|
||||||
|
@change="togglePrinter(printerAsset.assetid, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ printerLabel(printerAsset) }}</span>
|
||||||
|
<span v-if="printerAsset.printer?.modelname" class="printer-meta">
|
||||||
|
{{ printerAsset.printer.modelname }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span v-if="!filteredPrinters.length" class="muted">No printers match.</span>
|
||||||
|
</div>
|
||||||
|
<small class="form-hint">
|
||||||
|
{{ assignedPrinters.length }} assigned. Printers ticked here belong to this PC
|
||||||
|
and take the place of any assigned to the machine it controls.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="defaultprinterassetid">Default Printer</label>
|
||||||
|
<select
|
||||||
|
id="defaultprinterassetid"
|
||||||
|
v-model="defaultPrinterAssetId"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<option :value="null">No default</option>
|
||||||
|
<option
|
||||||
|
v-for="printerAsset in assignedPrinters"
|
||||||
|
:key="printerAsset.assetid"
|
||||||
|
:value="printerAsset.assetid"
|
||||||
|
>
|
||||||
|
{{ printerLabel(printerAsset) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-hint">
|
||||||
|
Optional, and only ever one of the printers assigned above.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="notes">Notes</label>
|
<label for="notes">Notes</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -261,9 +321,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,8 +334,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -304,14 +377,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||||
|
printersApi, relationshipTypesApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||||
|
import { useToast } from '@/composables/toast'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
const { isEnabled } = useIdentifierFlags()
|
const { isEnabled } = useIdentifierFlags()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -336,6 +415,10 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -356,6 +439,7 @@ const form = ref({
|
|||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
|
levelid: null,
|
||||||
ipaddress: ''
|
ipaddress: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -392,6 +476,191 @@ const models = ref([])
|
|||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
const operatingsystems = ref([])
|
const operatingsystems = ref([])
|
||||||
|
|
||||||
|
// Printer assignment. The rows are ordinary asset relationships - usesprinter
|
||||||
|
// for "installed here", defaultprinter for which of them wins - so the picker
|
||||||
|
// reads and writes them through the generic relationship endpoints.
|
||||||
|
const printersEnabled = ref(false)
|
||||||
|
const printers = ref([])
|
||||||
|
const printerSearch = ref('')
|
||||||
|
const assignedPrinterAssetIds = ref([])
|
||||||
|
const defaultPrinterAssetId = ref(null)
|
||||||
|
const printerRelationshipTypes = ref({ usesprinter: null, defaultprinter: null })
|
||||||
|
// The rows as loaded, so saving writes only what actually changed.
|
||||||
|
const existingPrinterRelationships = ref([])
|
||||||
|
|
||||||
|
function printerLabel(printerAsset) {
|
||||||
|
const name = printerAsset.name && printerAsset.name.toUpperCase() !== 'NONE'
|
||||||
|
? printerAsset.name
|
||||||
|
: null
|
||||||
|
return name || printerAsset.printer?.hostname || printerAsset.assetnumber
|
||||||
|
|| `Asset ${printerAsset.assetid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedPrinters = computed(() =>
|
||||||
|
[...printers.value].sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||||
|
|
||||||
|
const filteredPrinters = computed(() => {
|
||||||
|
const term = printerSearch.value.trim().toLowerCase()
|
||||||
|
if (!term) return sortedPrinters.value
|
||||||
|
return sortedPrinters.value.filter(printerAsset => {
|
||||||
|
const haystack = [
|
||||||
|
printerLabel(printerAsset),
|
||||||
|
printerAsset.assetnumber || '',
|
||||||
|
printerAsset.printer?.modelname || ''
|
||||||
|
].join(' ').toLowerCase()
|
||||||
|
return haystack.includes(term)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Drives the default dropdown, so the default can only ever be one of the
|
||||||
|
// assigned printers. A printer filtered out of the list above is still here.
|
||||||
|
const assignedPrinters = computed(() =>
|
||||||
|
assignedPrinterAssetIds.value
|
||||||
|
.map(assetid => printers.value.find(printerAsset => printerAsset.assetid === assetid))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||||
|
|
||||||
|
function isPrinterAssigned(assetid) {
|
||||||
|
return assignedPrinterAssetIds.value.includes(assetid)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrinter(assetid, on) {
|
||||||
|
if (on) {
|
||||||
|
if (!isPrinterAssigned(assetid)) {
|
||||||
|
assignedPrinterAssetIds.value = [...assignedPrinterAssetIds.value, assetid]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assignedPrinterAssetIds.value = assignedPrinterAssetIds.value.filter(id => id !== assetid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassigning the printer that is currently default clears the default rather
|
||||||
|
// than leaving one pointing at a printer this PC no longer has.
|
||||||
|
watch(assignedPrinterAssetIds, (assetids) => {
|
||||||
|
if (defaultPrinterAssetId.value && !assetids.includes(defaultPrinterAssetId.value)) {
|
||||||
|
defaultPrinterAssetId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Printer list + the two relationship type ids. Both types are seed data
|
||||||
|
// (flask seed reference-data); without them there is nothing to write, so the
|
||||||
|
// section stays hidden rather than offering a control that cannot save.
|
||||||
|
async function loadPrinterOptions() {
|
||||||
|
try {
|
||||||
|
await loadEnabledPlugins()
|
||||||
|
if (!isPluginEnabled('printers')) return
|
||||||
|
const [printerRows, typeResponse] = await Promise.all([
|
||||||
|
printersApi.listAll(),
|
||||||
|
relationshipTypesApi.list()
|
||||||
|
])
|
||||||
|
const types = typeResponse.data.data || []
|
||||||
|
const typeIdFor = (name) =>
|
||||||
|
types.find(t => t.relationshiptype === name)?.relationshiptypeid || null
|
||||||
|
printerRelationshipTypes.value = {
|
||||||
|
usesprinter: typeIdFor('usesprinter'),
|
||||||
|
defaultprinter: typeIdFor('defaultprinter')
|
||||||
|
}
|
||||||
|
printers.value = printerRows || []
|
||||||
|
printersEnabled.value = !!(printerRelationshipTypes.value.usesprinter
|
||||||
|
&& printerRelationshipTypes.value.defaultprinter)
|
||||||
|
} catch (printerError) {
|
||||||
|
console.error('Error loading printers:', printerError)
|
||||||
|
printersEnabled.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPrinterAssignments(assetid) {
|
||||||
|
if (!printersEnabled.value || !assetid) return
|
||||||
|
try {
|
||||||
|
const response = await assetsApi.getRelationships(assetid)
|
||||||
|
const types = printerRelationshipTypes.value
|
||||||
|
existingPrinterRelationships.value = (response.data.data?.outgoing || []).filter(
|
||||||
|
rel => rel.relationshiptypeid === types.usesprinter
|
||||||
|
|| rel.relationshiptypeid === types.defaultprinter
|
||||||
|
)
|
||||||
|
assignedPrinterAssetIds.value = existingPrinterRelationships.value
|
||||||
|
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||||
|
.map(rel => rel.targetassetid)
|
||||||
|
const currentDefault = existingPrinterRelationships.value
|
||||||
|
.find(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||||
|
defaultPrinterAssetId.value = currentDefault ? currentDefault.targetassetid : null
|
||||||
|
// Defaults set before this form existed have no usesprinter row. Show that
|
||||||
|
// printer as assigned: a default missing from the list reads as data loss,
|
||||||
|
// and saving then writes the row that was never there.
|
||||||
|
if (defaultPrinterAssetId.value
|
||||||
|
&& !assignedPrinterAssetIds.value.includes(defaultPrinterAssetId.value)) {
|
||||||
|
assignedPrinterAssetIds.value = [
|
||||||
|
...assignedPrinterAssetIds.value, defaultPrinterAssetId.value
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// /printers lists active printers only, so a retired one that is still
|
||||||
|
// assigned would be missing from every control on this form - unable to be
|
||||||
|
// unticked, and blank in the default box. The relationship carries the
|
||||||
|
// asset, so add it to the list it fell out of.
|
||||||
|
for (const rel of existingPrinterRelationships.value) {
|
||||||
|
const target = rel.targetasset
|
||||||
|
if (target && !printers.value.some(known => known.assetid === target.assetid)) {
|
||||||
|
printers.value = [...printers.value, target]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (printerError) {
|
||||||
|
console.error('Error loading printer assignment:', printerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the PC's own printer rows against the picker. Row at a time
|
||||||
|
// through the generic relationship endpoints - there is no single assignment
|
||||||
|
// endpoint yet - so the order matters: the outgoing default goes before the
|
||||||
|
// incoming one lands, because the unique key is (source, target, type) and
|
||||||
|
// would let two different defaults sit side by side. Re-creating a row that
|
||||||
|
// was removed earlier is safe; the create path reactivates the soft-deleted
|
||||||
|
// one instead of inserting a duplicate.
|
||||||
|
async function savePrinterAssignments(assetid) {
|
||||||
|
const types = printerRelationshipTypes.value
|
||||||
|
const assigned = assignedPrinterAssetIds.value
|
||||||
|
const wanteddefault = defaultPrinterAssetId.value
|
||||||
|
|
||||||
|
const assignedRows = existingPrinterRelationships.value
|
||||||
|
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||||
|
const defaultRows = existingPrinterRelationships.value
|
||||||
|
.filter(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Removing an assignment removes the row and nothing else. It never
|
||||||
|
// uninstalls a queue anywhere.
|
||||||
|
for (const rel of assignedRows) {
|
||||||
|
if (!assigned.includes(rel.targetassetid)) {
|
||||||
|
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const printerassetid of assigned) {
|
||||||
|
if (!assignedRows.some(rel => rel.targetassetid === printerassetid)) {
|
||||||
|
await assetsApi.createRelationship({
|
||||||
|
sourceassetid: assetid,
|
||||||
|
targetassetid: printerassetid,
|
||||||
|
relationshiptypeid: types.usesprinter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const rel of defaultRows) {
|
||||||
|
if (rel.targetassetid !== wanteddefault) {
|
||||||
|
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wanteddefault && !defaultRows.some(rel => rel.targetassetid === wanteddefault)) {
|
||||||
|
await assetsApi.createRelationship({
|
||||||
|
sourceassetid: assetid,
|
||||||
|
targetassetid: wanteddefault,
|
||||||
|
relationshiptypeid: types.defaultprinter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Part of the reconcile may have landed, so what the form believes is
|
||||||
|
// stored has to come from the server before anyone saves again.
|
||||||
|
await loadPrinterAssignments(assetid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||||
watch(() => form.value.serialnumber, (serial) => {
|
watch(() => form.value.serialnumber, (serial) => {
|
||||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||||
@@ -418,7 +687,9 @@ onMounted(async () => {
|
|||||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
locationsApi.list({ perpage: 100 }),
|
locationsApi.list({ perpage: 100 }),
|
||||||
operatingsystemsApi.list({ perpage: 100 }),
|
operatingsystemsApi.list({ perpage: 100 }),
|
||||||
computersApi.protocols.list()
|
computersApi.protocols.list(),
|
||||||
|
// Handles its own failure: a site without printers still gets a form.
|
||||||
|
loadPrinterOptions()
|
||||||
])
|
])
|
||||||
|
|
||||||
pcTypes.value = ptRes.data.data || []
|
pcTypes.value = ptRes.data.data || []
|
||||||
@@ -459,8 +730,11 @@ onMounted(async () => {
|
|||||||
notes: pc.notes || '',
|
notes: pc.notes || '',
|
||||||
mapx: pc.mapx ?? null,
|
mapx: pc.mapx ?? null,
|
||||||
mapy: pc.mapy ?? null,
|
mapy: pc.mapy ?? null,
|
||||||
|
levelid: pc.levelid ?? null,
|
||||||
ipaddress: primaryComm?.ipaddress || ''
|
ipaddress: primaryComm?.ipaddress || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadPrinterAssignments(currentAssetId.value)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading data:', err)
|
console.error('Error loading data:', err)
|
||||||
@@ -474,10 +748,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -485,6 +770,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +823,19 @@ async function savePC() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
|
||||||
|
// whose Save would create a second PC is the worse failure - but a printer
|
||||||
|
// assignment that quietly did not happen is the bug this feature exists to
|
||||||
|
// stop, so it has to be said out loud.
|
||||||
|
if (assetId && printersEnabled.value) {
|
||||||
|
try {
|
||||||
|
await savePrinterAssignments(assetId)
|
||||||
|
} catch (printerError) {
|
||||||
|
console.error('Error saving printer assignment:', printerError)
|
||||||
|
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.push('/pcs')
|
router.push('/pcs')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error saving PC:', err)
|
console.error('Error saving PC:', err)
|
||||||
@@ -570,6 +869,30 @@ async function savePC() {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
|
||||||
|
dozens of printers. */
|
||||||
|
.printer-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.printer-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.printer-meta {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.map-location-control {
|
.map-location-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
"description": "Computer management plugin for PCs, servers, and workstations with software tracking",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/computers",
|
"api_prefix": "/api/computers",
|
||||||
"provides": {
|
"provides": {
|
||||||
"asset_type": "computer",
|
"asset_type": "computer",
|
||||||
|
|||||||
@@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_settings_cards(self) -> List[dict]:
|
||||||
|
"""The asset reporter's own settings page.
|
||||||
|
|
||||||
|
It is a settings card rather than a docs page because it does two things
|
||||||
|
an operator needs at the same moment: name this site's routable ranges,
|
||||||
|
and download the reporter that carries them.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'group': 'Computers',
|
||||||
|
'to': '/settings/collector',
|
||||||
|
'icon': 'download',
|
||||||
|
'title': 'Asset reporter',
|
||||||
|
'description': 'Download the collector script stamped with this '
|
||||||
|
'site\'s URL and ranges',
|
||||||
|
'position': 26,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def get_settings_defaults(self) -> List[dict]:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
"""Settings this plugin owns.
|
"""Settings this plugin owns.
|
||||||
|
|
||||||
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'description': 'Hours without a collector report before a PC '
|
'description': 'Hours without a collector report before a PC '
|
||||||
'is listed as not reporting on the dashboard.',
|
'is listed as not reporting on the dashboard.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'key': 'computers_routableranges',
|
||||||
|
'value': '',
|
||||||
|
'valuetype': 'string',
|
||||||
|
'category': 'computers',
|
||||||
|
'description': 'Comma-separated CIDRs naming this site\'s '
|
||||||
|
'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. '
|
||||||
|
'Stamped into the collector script this server '
|
||||||
|
'generates, so a bay with a controller NIC and a '
|
||||||
|
'corporate NIC reports the right one. Blank uses '
|
||||||
|
'the NIC carrying the default route, which needs '
|
||||||
|
'no knowledge of a site\'s addressing.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'key': 'computers_machinelink_alerts',
|
'key': 'computers_machinelink_alerts',
|
||||||
'value': 'false',
|
'value': 'false',
|
||||||
|
|||||||
@@ -960,6 +960,7 @@ def _asset_facts(hostnames):
|
|||||||
'assetnumber': asset.assetnumber,
|
'assetnumber': asset.assetnumber,
|
||||||
'location': (asset.location.locationname if asset.location else None),
|
'location': (asset.location.locationname if asset.location else None),
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'machinenumber': None, 'machineassetid': None,
|
'machinenumber': None, 'machineassetid': None,
|
||||||
'machinepluginid': None,
|
'machinepluginid': None,
|
||||||
@@ -1222,6 +1223,9 @@ def list_reports():
|
|||||||
'location': known.get('location'),
|
'location': known.get('location'),
|
||||||
'mapx': known.get('mapx'),
|
'mapx': known.get('mapx'),
|
||||||
'mapy': known.get('mapy'),
|
'mapy': known.get('mapy'),
|
||||||
|
# Coordinates are pixels of ONE level (ADR-017), so the level goes
|
||||||
|
# with them or the hover preview has nothing to draw on.
|
||||||
|
'levelid': known.get('levelid'),
|
||||||
'machinenumber': known.get('machinenumber'),
|
'machinenumber': known.get('machinenumber'),
|
||||||
'machineassetid': known.get('machineassetid'),
|
'machineassetid': known.get('machineassetid'),
|
||||||
'machinepluginid': known.get('machinepluginid'),
|
'machinepluginid': known.get('machinepluginid'),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
empty map would be worse than none. -->
|
empty map would be worse than none. -->
|
||||||
<LocationMapTooltip
|
<LocationMapTooltip
|
||||||
v-if="report.mapx != null && report.mapy != null"
|
v-if="report.mapx != null && report.mapy != null"
|
||||||
:left="report.mapx" :top="report.mapy"
|
:left="report.mapx" :top="report.mapy" :levelid="report.levelid"
|
||||||
:machineName="report.hostname"
|
:machineName="report.hostname"
|
||||||
>
|
>
|
||||||
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
||||||
|
|||||||
@@ -21,13 +21,35 @@ from shopdb.api import require_permission, apply_import_timestamps
|
|||||||
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_articles():
|
||||||
|
"""Active articles whose topic is not a retired application.
|
||||||
|
|
||||||
|
An article about a decommissioned application is not something anyone should
|
||||||
|
find by browsing or searching: it describes a thing that is no longer in
|
||||||
|
service, and presenting it alongside live documentation reads as though it
|
||||||
|
were current.
|
||||||
|
|
||||||
|
An article with NO topic still shows. Not every article is about an
|
||||||
|
application, and a null topic is not a retired one.
|
||||||
|
|
||||||
|
Expressed as a subquery rather than a join because the topic sort below joins
|
||||||
|
Application itself, and two joins onto the same table in one query collide.
|
||||||
|
"""
|
||||||
|
retired = db.session.query(Application.appid).filter(
|
||||||
|
Application.isactive.is_(False))
|
||||||
|
return KnowledgeBase.query.filter(
|
||||||
|
KnowledgeBase.isactive.is_(True),
|
||||||
|
db.or_(KnowledgeBase.appid.is_(None),
|
||||||
|
KnowledgeBase.appid.notin_(retired)))
|
||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('', methods=['GET'])
|
@knowledgebase_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def list_articles():
|
def list_articles():
|
||||||
"""List all knowledge base articles."""
|
"""List all knowledge base articles."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
|
|
||||||
query = KnowledgeBase.query.filter_by(isactive=True)
|
query = _visible_articles()
|
||||||
|
|
||||||
# Search: title, keywords, and the topic (its Application's name). The topic
|
# Search: title, keywords, and the topic (its Application's name). The topic
|
||||||
# is matched via an appid subquery instead of a join so it does not collide
|
# is matched via an appid subquery instead of a join so it does not collide
|
||||||
@@ -35,8 +57,13 @@ def list_articles():
|
|||||||
# clause and still match on title/keywords.
|
# clause and still match on title/keywords.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
like = f'%{search}%'
|
like = f'%{search}%'
|
||||||
|
# Active applications only. A retired application is not a topic anyone
|
||||||
|
# should be offered: matching its name surfaced its articles and printed
|
||||||
|
# the retired app as their subject, which reads as though it were still
|
||||||
|
# in service.
|
||||||
topic_appids = db.session.query(Application.appid).filter(
|
topic_appids = db.session.query(Application.appid).filter(
|
||||||
Application.appname.ilike(like))
|
Application.appname.ilike(like),
|
||||||
|
Application.isactive.is_(True))
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
KnowledgeBase.shortdescription.ilike(like),
|
KnowledgeBase.shortdescription.ilike(like),
|
||||||
@@ -100,11 +127,11 @@ def list_articles():
|
|||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get knowledge base statistics."""
|
"""Get knowledge base statistics."""
|
||||||
total_clicks = db.session.query(
|
# Counted over the same set the list shows. A total that includes articles
|
||||||
db.func.coalesce(db.func.sum(KnowledgeBase.clicks), 0)
|
# nobody can see is a total nobody can reconcile.
|
||||||
).filter(KnowledgeBase.isactive == True).scalar()
|
visible = _visible_articles()
|
||||||
|
total_clicks = sum(article.clicks or 0 for article in visible)
|
||||||
total_articles = KnowledgeBase.query.filter_by(isactive=True).count()
|
total_articles = visible.count()
|
||||||
|
|
||||||
return success_response({
|
return success_response({
|
||||||
'totalclicks': int(total_clicks),
|
'totalclicks': int(total_clicks),
|
||||||
|
|||||||
@@ -106,8 +106,13 @@ const applications = ref([])
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Load applications for topic dropdown
|
// Load applications for topic dropdown
|
||||||
const appsRes = await applicationsApi.list({ perpage: 1000 })
|
// listAll, not list: the backend clamps perpage to 100 without saying so,
|
||||||
applications.value = appsRes.data.data || []
|
// and the catalogue is past that, so a topic sorting late in the alphabet
|
||||||
|
// was silently missing from this dropdown.
|
||||||
|
// 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 = await applicationsApi.listAll({ showhidden: true })
|
||||||
|
|
||||||
// Load article if editing
|
// Load article if editing
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
|
|||||||
@@ -171,8 +171,13 @@ async function loadArticles() {
|
|||||||
|
|
||||||
async function loadTopics() {
|
async function loadTopics() {
|
||||||
try {
|
try {
|
||||||
const response = await applicationsApi.list({ perpage: 1000 })
|
// listAll, not list: the backend clamps perpage to 100 without saying so,
|
||||||
topics.value = response.data.data || []
|
// and the catalogue is past that, so topics sorting late in the alphabet
|
||||||
|
// were silently missing from this filter.
|
||||||
|
// 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 = await applicationsApi.listAll({ showhidden: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading topics:', error)
|
console.error('Error loading topics:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ def list_machines():
|
|||||||
Asset.assetnumber.ilike(pattern),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(pattern),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(pattern),
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
# The optional identifiers too (ADR-001). Global search matches
|
||||||
|
# these, and a tag read off the machine has to find it here as
|
||||||
|
# well - this box is where someone holding the label looks.
|
||||||
|
Asset.gaugelabreference.ilike(pattern),
|
||||||
|
Asset.maintenancereference.ilike(pattern),
|
||||||
MachineType.machinetype.ilike(pattern),
|
MachineType.machinetype.ilike(pattern),
|
||||||
Vendor.vendor.ilike(pattern)
|
Vendor.vendor.ilike(pattern)
|
||||||
)
|
)
|
||||||
@@ -371,6 +376,7 @@ def create_machine():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -446,7 +452,7 @@ def update_machine(machine_id: int):
|
|||||||
# Update asset fields
|
# Update asset fields
|
||||||
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'gaugelabreference',
|
||||||
'maintenancereference', 'serialnumber', 'statusid',
|
'maintenancereference', 'serialnumber', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||||
'notes', 'isactive']
|
'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
|
|||||||
@@ -167,6 +167,7 @@
|
|||||||
v-if="machine.mapx != null && machine.mapy != null"
|
v-if="machine.mapx != null && machine.mapy != null"
|
||||||
:left="machine.mapx"
|
:left="machine.mapx"
|
||||||
:top="machine.mapy"
|
:top="machine.mapy"
|
||||||
|
:levelid="machine.levelid"
|
||||||
:machineName="machine.assetnumber"
|
:machineName="machine.assetnumber"
|
||||||
>
|
>
|
||||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||||
|
|||||||
@@ -239,9 +239,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,8 +252,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -349,6 +362,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '@/api'
|
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -371,6 +386,10 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -391,7 +410,8 @@ const form = ref({
|
|||||||
islocationonly: false,
|
islocationonly: false,
|
||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null
|
mapy: null,
|
||||||
|
levelid: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const machineTypes = ref([])
|
const machineTypes = ref([])
|
||||||
@@ -449,7 +469,7 @@ onMounted(async () => {
|
|||||||
locationsApi.list({ perpage: 500 }),
|
locationsApi.list({ perpage: 500 }),
|
||||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
businessunitsApi.list({ perpage: 500 }),
|
businessunitsApi.list({ perpage: 500 }),
|
||||||
computersApi.list({ perpage: 500 }),
|
computersApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
assetsApi.types.list() // Used for relationship types, will fix below
|
assetsApi.types.list() // Used for relationship types, will fix below
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -501,7 +521,8 @@ onMounted(async () => {
|
|||||||
islocationonly: data.machine?.islocationonly || false,
|
islocationonly: data.machine?.islocationonly || false,
|
||||||
notes: data.notes || '',
|
notes: data.notes || '',
|
||||||
mapx: data.mapx ?? null,
|
mapx: data.mapx ?? null,
|
||||||
mapy: data.mapy ?? null
|
mapy: data.mapy ?? null,
|
||||||
|
levelid: data.levelid ?? null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing relationships to find controlling PC
|
// Load existing relationships to find controlling PC
|
||||||
@@ -535,10 +556,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -546,6 +578,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
|
"description": "Machine management plugin for CNCs, CMMs, lathes, grinders, and other manufacturing machines",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/machines",
|
"api_prefix": "/api/machines",
|
||||||
"provides": {
|
"provides": {
|
||||||
"asset_type": "machine",
|
"asset_type": "machine",
|
||||||
|
|||||||
@@ -179,6 +179,11 @@ def list_tools():
|
|||||||
Asset.assetnumber.ilike(pattern),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(pattern),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(pattern),
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
# The optional identifiers too (ADR-001). Global search matches
|
||||||
|
# these, and a tag read off the machine has to find it here as
|
||||||
|
# well - this box is where someone holding the label looks.
|
||||||
|
Asset.gaugelabreference.ilike(pattern),
|
||||||
|
Asset.maintenancereference.ilike(pattern),
|
||||||
MeasuringToolType.name.ilike(pattern),
|
MeasuringToolType.name.ilike(pattern),
|
||||||
))
|
))
|
||||||
if type_id := request.args.get('typeid', type=int):
|
if type_id := request.args.get('typeid', type=int):
|
||||||
@@ -263,6 +268,7 @@ def create_tool():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes'),
|
notes=data.get('notes'),
|
||||||
)
|
)
|
||||||
@@ -291,7 +297,7 @@ def create_tool():
|
|||||||
# Asset core fields writable through this plugin's write path.
|
# Asset core fields writable through this plugin's write path.
|
||||||
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
|
_ASSET_FIELDS = ('assetnumber', 'name', 'gaugelabreference',
|
||||||
'maintenancereference', 'serialnumber',
|
'maintenancereference', 'serialnumber',
|
||||||
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy',
|
'statusid', 'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||||
'notes', 'isactive')
|
'notes', 'isactive')
|
||||||
# Extension fields with plain assignment (dates handled separately).
|
# Extension fields with plain assignment (dates handled separately).
|
||||||
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
_TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
|
"description": "Metrology and inspection instruments (gauges, calipers, thread gages, bore gages) with a calibration lifecycle and derived calibration status.",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.6.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/measuringtools",
|
"api_prefix": "/api/measuringtools",
|
||||||
"default_enabled": false,
|
"default_enabled": false,
|
||||||
"provides": {
|
"provides": {
|
||||||
|
|||||||
@@ -223,6 +223,11 @@ def list_network_devices():
|
|||||||
Asset.assetnumber.ilike(pattern),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(pattern),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(pattern),
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
# The optional identifiers too (ADR-001). Global search matches
|
||||||
|
# these, and a tag read off the machine has to find it here as
|
||||||
|
# well - this box is where someone holding the label looks.
|
||||||
|
Asset.gaugelabreference.ilike(pattern),
|
||||||
|
Asset.maintenancereference.ilike(pattern),
|
||||||
NetworkDevice.hostname.ilike(pattern),
|
NetworkDevice.hostname.ilike(pattern),
|
||||||
NetworkDeviceType.networkdevicetype.ilike(pattern),
|
NetworkDeviceType.networkdevicetype.ilike(pattern),
|
||||||
Vendor.vendor.ilike(pattern)
|
Vendor.vendor.ilike(pattern)
|
||||||
@@ -412,6 +417,7 @@ def create_network_device():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -502,7 +508,7 @@ def update_network_device(device_id: int):
|
|||||||
# Update asset fields
|
# Update asset fields
|
||||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||||
'maintenancereference', 'statusid',
|
'maintenancereference', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
old_val = getattr(asset, key)
|
old_val = getattr(asset, key)
|
||||||
|
|||||||
@@ -234,9 +234,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,8 +248,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -307,6 +320,8 @@ import {
|
|||||||
} from '@/api'
|
} from '@/api'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
@@ -315,6 +330,10 @@ import { apiError } from '@/utils/apiError'
|
|||||||
const { isEnabled } = useIdentifierFlags()
|
const { isEnabled } = useIdentifierFlags()
|
||||||
|
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -348,6 +367,7 @@ const form = ref({
|
|||||||
ismanaged: false,
|
ismanaged: false,
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
|
levelid: null,
|
||||||
notes: ''
|
notes: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -408,8 +428,9 @@ async function loadVendors() {
|
|||||||
try {
|
try {
|
||||||
const response = await vendorsApi.list({ perpage: 100 })
|
const response = await vendorsApi.list({ perpage: 100 })
|
||||||
vendors.value = response.data.data || []
|
vendors.value = response.data.data || []
|
||||||
const modelResponse = await modelsApi.list({ perpage: 500 })
|
// listAll: the models catalogue is already past the backend's 100-row
|
||||||
models.value = modelResponse.data.data || []
|
// cap, so list() left the tail of it unselectable.
|
||||||
|
models.value = await modelsApi.listAll()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading vendors:', err)
|
console.error('Error loading vendors:', err)
|
||||||
}
|
}
|
||||||
@@ -459,6 +480,7 @@ async function loadDevice() {
|
|||||||
form.value.businessunitid = data.businessunitid || ''
|
form.value.businessunitid = data.businessunitid || ''
|
||||||
form.value.mapx = data.mapx
|
form.value.mapx = data.mapx
|
||||||
form.value.mapy = data.mapy
|
form.value.mapy = data.mapy
|
||||||
|
form.value.levelid = data.levelid
|
||||||
form.value.notes = data.notes || ''
|
form.value.notes = data.notes || ''
|
||||||
// The IP lives in a Communication row, not on the extension table; the API
|
// The IP lives in a Communication row, not on the extension table; the API
|
||||||
// flattens it onto the response as ipaddress.
|
// flattens it onto the response as ipaddress.
|
||||||
@@ -508,6 +530,7 @@ async function submitForm() {
|
|||||||
ismanaged: form.value.ismanaged,
|
ismanaged: form.value.ismanaged,
|
||||||
mapx: form.value.mapx,
|
mapx: form.value.mapx,
|
||||||
mapy: form.value.mapy,
|
mapy: form.value.mapy,
|
||||||
|
levelid: form.value.levelid,
|
||||||
notes: form.value.notes || null
|
notes: form.value.notes || null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,10 +573,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -561,6 +595,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.1.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/network",
|
"api_prefix": "/api/network",
|
||||||
"provides": {
|
"provides": {
|
||||||
"asset_type": "network_device",
|
"asset_type": "network_device",
|
||||||
|
|||||||
@@ -318,7 +318,10 @@ onMounted(async () => {
|
|||||||
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
|
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
|
||||||
notificationsApi.types.list(),
|
notificationsApi.types.list(),
|
||||||
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
|
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
|
||||||
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } })),
|
// listAll: perpage is clamped to 100 server-side, and the catalogue is
|
||||||
|
// already past that, so this dropdown was missing its tail.
|
||||||
|
applicationsApi.listAll().then(items => ({ data: { data: items } }))
|
||||||
|
.catch(() => ({ data: { data: [] } })),
|
||||||
settingsApi.get('site_timezone').catch(() => null)
|
settingsApi.get('site_timezone').catch(() => null)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ function labelText(item) {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await printedpartsApi.list({ perpage: 500 })
|
// listAll: perpage is clamped to 100, and a label sheet must cover every
|
||||||
items.value = response.data.data || []
|
// item, not the first page of them.
|
||||||
|
items.value = await printedpartsApi.listAll()
|
||||||
// ?item=<id> preselects one part (the Detail-page print button)
|
// ?item=<id> preselects one part (the Detail-page print button)
|
||||||
const preselect = new URLSearchParams(window.location.search).get('item')
|
const preselect = new URLSearchParams(window.location.search).get('item')
|
||||||
if (preselect) {
|
if (preselect) {
|
||||||
|
|||||||
@@ -254,6 +254,11 @@ def list_printers():
|
|||||||
Asset.assetnumber.ilike(pattern),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(pattern),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(pattern),
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
# The optional identifiers too (ADR-001). Global search matches
|
||||||
|
# these, and a tag read off the machine has to find it here as
|
||||||
|
# well - this box is where someone holding the label looks.
|
||||||
|
Asset.gaugelabreference.ilike(pattern),
|
||||||
|
Asset.maintenancereference.ilike(pattern),
|
||||||
Printer.hostname.ilike(pattern),
|
Printer.hostname.ilike(pattern),
|
||||||
Printer.windowsname.ilike(pattern),
|
Printer.windowsname.ilike(pattern),
|
||||||
PrinterType.printertype.ilike(pattern),
|
PrinterType.printertype.ilike(pattern),
|
||||||
@@ -358,6 +363,7 @@ def printer_install_list():
|
|||||||
'iscsf': printer.iscsf,
|
'iscsf': printer.iscsf,
|
||||||
'locationname': asset.location.locationname if asset.location else None,
|
'locationname': asset.location.locationname if asset.location else None,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -366,7 +372,7 @@ def printer_install_list():
|
|||||||
# hand-rolled JSON parser. The web map uses the default JSON.
|
# hand-rolled JSON parser. The web map uses the default JSON.
|
||||||
if request.args.get('format') == 'text':
|
if request.args.get('format') == 'text':
|
||||||
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
|
fields = ('printerid', 'windowsname', 'vendorname', 'modelnumber',
|
||||||
'hostname', 'ipaddress', 'mapx', 'mapy')
|
'hostname', 'ipaddress', 'mapx', 'mapy', 'levelid')
|
||||||
lines = [_text_line(row, fields) for row in rows]
|
lines = [_text_line(row, fields) for row in rows]
|
||||||
return Response('\n'.join(lines), mimetype='text/plain')
|
return Response('\n'.join(lines), mimetype='text/plain')
|
||||||
|
|
||||||
@@ -632,6 +638,481 @@ def pc_default_printer():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Printer assignment resolution (which printers belong on a PC)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# The assignment edges. usesprinter says a printer is installed here;
|
||||||
|
# defaultprinter says which of them Windows should default to.
|
||||||
|
_USES_PRINTER = 'usesprinter'
|
||||||
|
_DEFAULT_PRINTER = 'defaultprinter'
|
||||||
|
_CONTROLS = 'controls'
|
||||||
|
|
||||||
|
|
||||||
|
def _relationship_typeids(*names):
|
||||||
|
"""{name: [relationshiptypeid, ...]} for the named relationship types.
|
||||||
|
|
||||||
|
A list per name, not an id: MySQL's default collation is case-insensitive,
|
||||||
|
so a legacy 'Controls' row lives happily beside 'controls' and a walk that
|
||||||
|
picked one of them would silently miss half the data. Names absent from the
|
||||||
|
table map to an empty list, which resolves to no printers rather than an
|
||||||
|
error - an un-seeded database is a deployment step missed, not a bad request.
|
||||||
|
"""
|
||||||
|
wanted = {name.lower(): [] for name in names}
|
||||||
|
rows = RelationshipType.query.filter(
|
||||||
|
RelationshipType.relationshiptype.in_(names)).all()
|
||||||
|
for row in rows:
|
||||||
|
key = (row.relationshiptype or '').lower()
|
||||||
|
if key in wanted:
|
||||||
|
wanted[key].append(row.relationshiptypeid)
|
||||||
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
|
def _outgoing_rows(assetid, typeids):
|
||||||
|
"""Active outgoing relationships of the given types, oldest first."""
|
||||||
|
if not typeids:
|
||||||
|
return []
|
||||||
|
return (AssetRelationship.query
|
||||||
|
.filter(AssetRelationship.sourceassetid == assetid,
|
||||||
|
AssetRelationship.relationshiptypeid.in_(typeids),
|
||||||
|
AssetRelationship.isactive == True)
|
||||||
|
.order_by(AssetRelationship.relationshipid)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
|
||||||
|
def _own_assignment(assetid, typeids):
|
||||||
|
"""One asset's OWN assignment: (ordered printer assetids, default assetid).
|
||||||
|
|
||||||
|
On an asset with NO usesprinter rows, a defaultprinter row is the whole
|
||||||
|
assignment. Those rows predate this feature - the installer preselect and
|
||||||
|
the collector both write them - and ignoring them would take printers away
|
||||||
|
from every PC recorded before assignment existed. Once an asset has
|
||||||
|
usesprinter rows it is managed, and a default outside that set is stale
|
||||||
|
rather than legacy, so it is dropped by _assignment_result.
|
||||||
|
|
||||||
|
Two active defaults cannot be prevented by the schema - the unique
|
||||||
|
constraint is (source, target, type) - so the oldest row wins and the rest
|
||||||
|
are ignored, which at least makes the answer the same on every read.
|
||||||
|
"""
|
||||||
|
printerassetids = []
|
||||||
|
for rel in _outgoing_rows(assetid, typeids[_USES_PRINTER]):
|
||||||
|
if rel.targetassetid not in printerassetids:
|
||||||
|
printerassetids.append(rel.targetassetid)
|
||||||
|
ismanaged = bool(printerassetids)
|
||||||
|
|
||||||
|
defaultassetid = None
|
||||||
|
for rel in _outgoing_rows(assetid, typeids[_DEFAULT_PRINTER]):
|
||||||
|
if not ismanaged and rel.targetassetid not in printerassetids:
|
||||||
|
printerassetids.append(rel.targetassetid)
|
||||||
|
if defaultassetid is None:
|
||||||
|
defaultassetid = rel.targetassetid
|
||||||
|
|
||||||
|
return printerassetids, defaultassetid
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_asset_printers(asset):
|
||||||
|
"""Which printers an asset gets, and which one is default.
|
||||||
|
|
||||||
|
Own rows first; only when the asset has none does the walk follow its
|
||||||
|
outgoing controls edges one hop and take the assignment of whatever it
|
||||||
|
controls.
|
||||||
|
|
||||||
|
THE INHERITANCE IS THE FEATURE. Printers are a property of the bay, not of
|
||||||
|
the box sat next to it: the machine holds the assignment, and whichever PC
|
||||||
|
controls that machine picks it up. So a PC that is reimaged, or swapped for
|
||||||
|
a different chassis entirely, resolves the same printers on its next cycle
|
||||||
|
with nothing backed up and nothing restored. A PC that controls no machine -
|
||||||
|
an office PC - has only its own rows, which is the same code path with an
|
||||||
|
empty walk.
|
||||||
|
|
||||||
|
A PC's own rows SHADOW what it would inherit rather than adding to it, so a
|
||||||
|
one-off printer on a bay PC is expressed by assigning that PC everything it
|
||||||
|
should have, not by hoping two sets merge.
|
||||||
|
|
||||||
|
Returns {'assignments': [{'assetid', 'isdefault', 'inheritedfromassetid'}],
|
||||||
|
'source': 'self' | 'inherited' | 'none'}.
|
||||||
|
"""
|
||||||
|
assetid = getattr(asset, 'assetid', None)
|
||||||
|
if assetid is None:
|
||||||
|
return {'assignments': [], 'source': 'none'}
|
||||||
|
|
||||||
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
||||||
|
|
||||||
|
printerassetids, defaultassetid = _own_assignment(assetid, typeids)
|
||||||
|
if printerassetids:
|
||||||
|
return _assignment_result(printerassetids, defaultassetid, None)
|
||||||
|
|
||||||
|
# Nothing of its own: take the bay's. Outgoing controls only (PC -> machine,
|
||||||
|
# the direction `flask relationships fix-controls-direction` enforces).
|
||||||
|
inherited = []
|
||||||
|
defaults = []
|
||||||
|
suppliers = {}
|
||||||
|
for rel in _outgoing_rows(assetid, typeids[_CONTROLS]):
|
||||||
|
machine = rel.targetasset
|
||||||
|
if machine is None or not getattr(machine, 'isactive', True):
|
||||||
|
continue
|
||||||
|
machineprinters, machinedefault = _own_assignment(machine.assetid, typeids)
|
||||||
|
for printerassetid in machineprinters:
|
||||||
|
if printerassetid not in inherited:
|
||||||
|
inherited.append(printerassetid)
|
||||||
|
suppliers[printerassetid] = machine.assetid
|
||||||
|
if machinedefault is not None and machinedefault not in defaults:
|
||||||
|
defaults.append(machinedefault)
|
||||||
|
|
||||||
|
if not inherited:
|
||||||
|
return {'assignments': [], 'source': 'none'}
|
||||||
|
|
||||||
|
# A PC controlling several machines (or both bays of a dualpath pair) can
|
||||||
|
# inherit two different defaults. Union the printers, but refuse to guess a
|
||||||
|
# default: no default is a state the client already handles, a coin toss is
|
||||||
|
# not.
|
||||||
|
if len(defaults) > 1:
|
||||||
|
logger.warning(
|
||||||
|
'Asset %s inherits %d conflicting default printers; leaving default unset',
|
||||||
|
assetid, len(defaults))
|
||||||
|
inheriteddefault = None
|
||||||
|
else:
|
||||||
|
inheriteddefault = defaults[0] if defaults else None
|
||||||
|
|
||||||
|
return _assignment_result(inherited, inheriteddefault, suppliers)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_result(printerassetids, defaultassetid, suppliers):
|
||||||
|
"""Shape the resolver's answer. suppliers is None for an asset's own rows."""
|
||||||
|
# Settled rule: the default must be one of the assigned printers. A dangling
|
||||||
|
# default happens when a printer is unassigned through the generic
|
||||||
|
# relationships card, which knows nothing about this pairing.
|
||||||
|
if defaultassetid not in printerassetids:
|
||||||
|
defaultassetid = None
|
||||||
|
return {
|
||||||
|
'assignments': [{
|
||||||
|
'assetid': printerassetid,
|
||||||
|
'isdefault': printerassetid == defaultassetid,
|
||||||
|
'inheritedfromassetid': (suppliers or {}).get(printerassetid),
|
||||||
|
} for printerassetid in printerassetids],
|
||||||
|
'source': 'inherited' if suppliers is not None else 'self',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _printer_driver(printer, universaldrivers):
|
||||||
|
"""Driver record to install this printer with, or None.
|
||||||
|
|
||||||
|
Three steps, most specific first:
|
||||||
|
|
||||||
|
1. A driver bound to the printer's MODEL. A plotter, a card printer and a
|
||||||
|
label printer each need their own, and a per-model row must beat the
|
||||||
|
universal one.
|
||||||
|
2. A driver bound to the printer's VENDOR with no model. HP's and Xerox's
|
||||||
|
universal drivers cover 41 of the reference site's 44 printers between
|
||||||
|
them; binding those to one model each would mean a near-duplicate row per
|
||||||
|
model, which is a table nobody keeps true.
|
||||||
|
3. Failing both, a model-less driver whose NAME carries the vendor word.
|
||||||
|
This is the pre-vendorid convention, kept so a site that populated its
|
||||||
|
table before the column existed does not lose its drivers on upgrade.
|
||||||
|
"""
|
||||||
|
if printer.modelnumberid:
|
||||||
|
driver = (PrinterDriver.query
|
||||||
|
.filter_by(modelnumberid=printer.modelnumberid, isactive=True)
|
||||||
|
.order_by(PrinterDriver.name).first())
|
||||||
|
if driver:
|
||||||
|
return driver
|
||||||
|
|
||||||
|
if printer.vendorid:
|
||||||
|
for driver in universaldrivers:
|
||||||
|
if driver.vendorid == printer.vendorid:
|
||||||
|
return driver
|
||||||
|
|
||||||
|
vendor = _printer_vendor(printer).lower()
|
||||||
|
if not vendor:
|
||||||
|
return None
|
||||||
|
for driver in universaldrivers:
|
||||||
|
# Only the legacy convention here: a row WITH a vendorid that did not
|
||||||
|
# match above must not be matched by its name instead, or a mis-set
|
||||||
|
# vendor silently resolves to the wrong package.
|
||||||
|
if driver.vendorid:
|
||||||
|
continue
|
||||||
|
if vendor in (driver.name or '').lower():
|
||||||
|
return driver
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _computer_by_hostname(hostname):
|
||||||
|
"""Active computer asset matching a reported hostname, or None.
|
||||||
|
|
||||||
|
Case-folded on both sides: COMPUTERNAME arrives uppercase, MySQL forgives
|
||||||
|
that and SQLite does not, so an uncompared case would work in production and
|
||||||
|
fail in the tests (or the other way round on a binary collation).
|
||||||
|
|
||||||
|
A short name also matches a stored FQDN, and an FQDN matches a stored short
|
||||||
|
name, because which of the two a site records is a matter of how its PCs
|
||||||
|
were enrolled and the client only ever knows its own COMPUTERNAME.
|
||||||
|
"""
|
||||||
|
from plugins.computers.models import Computer
|
||||||
|
|
||||||
|
name = (hostname or '').strip().lower()
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = db.session.query(Computer, Asset).join(
|
||||||
|
Asset, Asset.assetid == Computer.assetid).filter(Asset.isactive == True)
|
||||||
|
|
||||||
|
row = query.filter(db.func.lower(Computer.hostname) == name).first()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
|
||||||
|
shortname = name.split('.')[0]
|
||||||
|
if shortname != name:
|
||||||
|
row = query.filter(db.func.lower(Computer.hostname) == shortname).first()
|
||||||
|
if row:
|
||||||
|
return row
|
||||||
|
# Prefix match only for a plain hostname: LIKE wildcards in a path segment
|
||||||
|
# would otherwise let '%' pull back somebody else's printers.
|
||||||
|
if not re.match(r'^[a-z0-9-]+$', shortname):
|
||||||
|
return None
|
||||||
|
return query.filter(
|
||||||
|
db.func.lower(Computer.hostname).like(shortname + '.%')).first()
|
||||||
|
|
||||||
|
|
||||||
|
@printers_asset_bp.route('/for-host/<hostname>', methods=['GET'])
|
||||||
|
@jwt_required(optional=True)
|
||||||
|
def printers_for_host(hostname: str):
|
||||||
|
"""Printers assigned to a PC, by hostname, with what it takes to install one.
|
||||||
|
|
||||||
|
The endpoint the convergence client asks on every cycle: give me the state
|
||||||
|
this host should be in. Resolution is own rows, else the assignment of the
|
||||||
|
machine this PC controls (see resolve_asset_printers) - which is why a
|
||||||
|
reimaged bay reinstalls its own printers.
|
||||||
|
|
||||||
|
Resolved by hostname rather than machine number because the collector
|
||||||
|
upserts PCs by hostname and an office PC has no machine number at all.
|
||||||
|
|
||||||
|
404 when the host is unknown. A known host with nothing assigned is an
|
||||||
|
empty list and a null default, not an error: that is the client's no-op.
|
||||||
|
|
||||||
|
Each printer carries queuename (what to call the queue), hostname/ipaddress
|
||||||
|
(where to point the port), port (null means the client's own default raw
|
||||||
|
port), drivername (verbatim from the INF, what Add-PrinterDriver matches on)
|
||||||
|
and driverlocation (where the package lives).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
row = _computer_by_hostname(hostname)
|
||||||
|
except ImportError:
|
||||||
|
# No computers plugin, no way to resolve a hostname to an asset.
|
||||||
|
row = None
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
f'No computer found with hostname {hostname}',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
computer, asset = row
|
||||||
|
resolved = resolve_asset_printers(asset)
|
||||||
|
assignments = resolved['assignments']
|
||||||
|
|
||||||
|
printers = []
|
||||||
|
if assignments:
|
||||||
|
assetids = [item['assetid'] for item in assignments]
|
||||||
|
rows = (db.session.query(Printer)
|
||||||
|
.join(Asset, Asset.assetid == Printer.assetid)
|
||||||
|
.filter(Printer.assetid.in_(assetids))
|
||||||
|
.filter(Asset.isactive == True)
|
||||||
|
.all())
|
||||||
|
byassetid = {printer.assetid: printer for printer in rows}
|
||||||
|
|
||||||
|
# Fetched once: the universal-driver fallback would otherwise re-read
|
||||||
|
# the same handful of rows per printer.
|
||||||
|
universaldrivers = (PrinterDriver.query
|
||||||
|
.filter(PrinterDriver.modelnumberid.is_(None),
|
||||||
|
PrinterDriver.isactive == True)
|
||||||
|
.order_by(PrinterDriver.name).all())
|
||||||
|
|
||||||
|
for item in assignments:
|
||||||
|
printer = byassetid.get(item['assetid'])
|
||||||
|
if not printer:
|
||||||
|
# Assigned asset is retired, or is not a printer at all.
|
||||||
|
continue
|
||||||
|
printerasset = printer.asset
|
||||||
|
primary = Communication.query.filter_by(
|
||||||
|
assetid=printer.assetid, isprimary=True).first() \
|
||||||
|
or Communication.query.filter_by(assetid=printer.assetid).first()
|
||||||
|
driver = _printer_driver(printer, universaldrivers)
|
||||||
|
printers.append({
|
||||||
|
'printerid': printer.printerid,
|
||||||
|
'assetid': printer.assetid,
|
||||||
|
'queuename': _install_name(printer, printerasset),
|
||||||
|
'windowsname': printer.windowsname,
|
||||||
|
'sharename': printer.sharename,
|
||||||
|
'hostname': printer.hostname,
|
||||||
|
'ipaddress': primary.ipaddress if primary else None,
|
||||||
|
'port': primary.port if primary else None,
|
||||||
|
'driverid': driver.driverid if driver else None,
|
||||||
|
'drivername': driver.drivername if driver else None,
|
||||||
|
'driverlocation': driver.location if driver else None,
|
||||||
|
'installpath': printer.installpath,
|
||||||
|
'isdefault': item['isdefault'],
|
||||||
|
'inheritedfromassetid': item['inheritedfromassetid'],
|
||||||
|
})
|
||||||
|
|
||||||
|
default = next((p for p in printers if p['isdefault']), None)
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'hostname': computer.hostname,
|
||||||
|
'assetid': asset.assetid,
|
||||||
|
'assetnumber': asset.assetnumber,
|
||||||
|
# Where the assignment came from, so a technician reading a client log
|
||||||
|
# can tell a bay's printers from the PC's own overrides.
|
||||||
|
'source': resolved['source'],
|
||||||
|
'defaultprinterid': default['printerid'] if default else None,
|
||||||
|
'printers': printers,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('printers.edit')
|
||||||
|
def set_asset_printer_assignment(asset_id: int):
|
||||||
|
"""Reconcile one asset's whole printer assignment in a single call.
|
||||||
|
|
||||||
|
Body: {"printerassetids": [...], "defaultprinterassetid": N or null}.
|
||||||
|
|
||||||
|
The WHOLE set, not a delta, because the caller knows the intended end state
|
||||||
|
and a row-at-a-time edit is a non-atomic reconcile: an HTTP failure part way
|
||||||
|
leaves an asset half-assigned, with nothing recording what was meant.
|
||||||
|
|
||||||
|
Written against the MACHINE for a bay - that is the point of the feature, so
|
||||||
|
a reimaged PC inherits it - but an asset is an asset here, and writing to a
|
||||||
|
PC deliberately shadows its machine (see resolve_asset_printers).
|
||||||
|
|
||||||
|
Rows that go away are SOFT-deleted and rows that come back are REACTIVATED
|
||||||
|
rather than inserted: the unique constraint (source, target, type) spans
|
||||||
|
inactive rows, so a blind insert after an unassign raises IntegrityError on
|
||||||
|
MySQL while passing on SQLite.
|
||||||
|
|
||||||
|
Removal here uninstalls nothing. It changes what the bay is told to have;
|
||||||
|
the client never deletes a queue.
|
||||||
|
"""
|
||||||
|
asset = db.session.get(Asset, asset_id)
|
||||||
|
if not asset or not asset.isactive:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if data is None:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||||
|
|
||||||
|
raw = data.get('printerassetids')
|
||||||
|
if raw is None or not isinstance(raw, list):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'printerassetids must be a list of asset ids')
|
||||||
|
|
||||||
|
# Ordered, de-duplicated: the same printer twice is one assignment, and the
|
||||||
|
# order is the order the client is told to install them in.
|
||||||
|
wanted = []
|
||||||
|
for value in raw:
|
||||||
|
try:
|
||||||
|
assetid = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'printerassetids must be integers')
|
||||||
|
if assetid not in wanted:
|
||||||
|
wanted.append(assetid)
|
||||||
|
|
||||||
|
defaultid = data.get('defaultprinterassetid')
|
||||||
|
if defaultid is not None:
|
||||||
|
try:
|
||||||
|
defaultid = int(defaultid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'defaultprinterassetid must be an asset id or null')
|
||||||
|
# Checked BEFORE any write, so a rejected request changes nothing. A
|
||||||
|
# default outside the set tells the client to default to a queue it was
|
||||||
|
# never told to install: it fails, and nothing in ShopDB says why.
|
||||||
|
if defaultid not in wanted:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'defaultprinterassetid must be one of printerassetids')
|
||||||
|
|
||||||
|
# Every target must exist and be a printer. Assigning a machine to a machine
|
||||||
|
# is a typo that would otherwise sit in the data until a bay tried it.
|
||||||
|
if wanted:
|
||||||
|
found = {row.assetid: row for row in
|
||||||
|
Asset.query.filter(Asset.assetid.in_(wanted)).all()}
|
||||||
|
missing = [assetid for assetid in wanted if assetid not in found]
|
||||||
|
if missing:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.NOT_FOUND,
|
||||||
|
'Unknown printer asset(s): {0}'.format(
|
||||||
|
', '.join(str(assetid) for assetid in missing)),
|
||||||
|
http_code=404)
|
||||||
|
notprinters = [assetid for assetid, row in found.items()
|
||||||
|
if not (row.assettype and row.assettype.assettype == 'printer')]
|
||||||
|
if notprinters:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Not printer assets: {0}'.format(
|
||||||
|
', '.join(str(assetid) for assetid in sorted(notprinters))))
|
||||||
|
|
||||||
|
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
||||||
|
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
||||||
|
# Seed data, not a migration. An un-seeded database cannot hold an
|
||||||
|
# assignment, and saying so beats writing rows nothing can read.
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
'Relationship types are not seeded - run: flask seed reference-data',
|
||||||
|
http_code=500)
|
||||||
|
|
||||||
|
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
||||||
|
typeids[_USES_PRINTER], wanted)
|
||||||
|
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
||||||
|
typeids[_DEFAULT_PRINTER],
|
||||||
|
[defaultid] if defaultid is not None else [])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
||||||
|
return success_response({
|
||||||
|
'assetid': asset_id,
|
||||||
|
'printerassetids': printerassetids,
|
||||||
|
'defaultprinterassetid': defaultassetid,
|
||||||
|
}, message='Printer assignment updated')
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
|
||||||
|
"""Make the active edges of one type be exactly `wantedtargets`.
|
||||||
|
|
||||||
|
Reads across every case-variant type id (a legacy 'DefaultPrinter' row is
|
||||||
|
the same edge) but writes new rows with one, so the table converges on a
|
||||||
|
single spelling instead of accumulating both.
|
||||||
|
"""
|
||||||
|
existing = {}
|
||||||
|
rows = (AssetRelationship.query
|
||||||
|
.filter(AssetRelationship.sourceassetid == sourceassetid,
|
||||||
|
AssetRelationship.relationshiptypeid.in_(readtypeids))
|
||||||
|
.order_by(AssetRelationship.relationshipid)
|
||||||
|
.all())
|
||||||
|
for row in rows:
|
||||||
|
existing.setdefault(row.targetassetid, []).append(row)
|
||||||
|
|
||||||
|
for targetassetid, rowlist in existing.items():
|
||||||
|
if targetassetid in wantedtargets:
|
||||||
|
# Keep the oldest, retire any duplicate: two active rows for one
|
||||||
|
# edge is how an asset ends up with two defaults.
|
||||||
|
keep = rowlist[0]
|
||||||
|
keep.isactive = True
|
||||||
|
for extra in rowlist[1:]:
|
||||||
|
extra.isactive = False
|
||||||
|
else:
|
||||||
|
for row in rowlist:
|
||||||
|
row.isactive = False
|
||||||
|
|
||||||
|
for targetassetid in wantedtargets:
|
||||||
|
if targetassetid not in existing:
|
||||||
|
db.session.add(AssetRelationship(
|
||||||
|
sourceassetid=sourceassetid,
|
||||||
|
targetassetid=targetassetid,
|
||||||
|
relationshiptypeid=writetypeid,
|
||||||
|
isactive=True))
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@jwt_required(optional=True)
|
||||||
def get_printer(printer_id: int):
|
def get_printer(printer_id: int):
|
||||||
@@ -738,6 +1219,7 @@ def create_printer():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -819,7 +1301,7 @@ def update_printer(printer_id: int):
|
|||||||
# Update asset fields (optional identifiers gated per-type in Settings)
|
# Update asset fields (optional identifiers gated per-type in Settings)
|
||||||
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
|
||||||
'maintenancereference', 'statusid',
|
'maintenancereference', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy',
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid',
|
||||||
'notes', 'isactive']
|
'notes', 'isactive']
|
||||||
for key in asset_fields:
|
for key in asset_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
@@ -1035,6 +1517,7 @@ def _get_low_supplies_data():
|
|||||||
'model': model_number,
|
'model': model_number,
|
||||||
'location': location_name,
|
'location': location_name,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'supplies': annotated
|
'supplies': annotated
|
||||||
})
|
})
|
||||||
@@ -1471,6 +1954,10 @@ def dashboard_supplies():
|
|||||||
# card, it just has nothing to preview.
|
# card, it just has nothing to preview.
|
||||||
'mapx': printer.get('mapx'),
|
'mapx': printer.get('mapx'),
|
||||||
'mapy': printer.get('mapy'),
|
'mapy': printer.get('mapy'),
|
||||||
|
# The level those pixels belong to (ADR-017). Without it the hover
|
||||||
|
# preview cannot draw the marker and says so, which is what the
|
||||||
|
# dashboard card and the toner report were both doing.
|
||||||
|
'levelid': printer.get('levelid'),
|
||||||
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
||||||
'supplies': [{
|
'supplies': [{
|
||||||
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
||||||
|
|||||||
94
plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1
Normal file
94
plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Apply-ShopdbDefaultPrinter.ps1
|
||||||
|
#
|
||||||
|
# Sets the logged-on user's default printer to the one ShopDB assigned. Runs IN
|
||||||
|
# THE USER'S CONTEXT, at logon and on a repeat, because a default printer is
|
||||||
|
# per-user state that SYSTEM cannot set for somebody else.
|
||||||
|
#
|
||||||
|
# Set-ShopdbPrinters.ps1 records the desired queue in
|
||||||
|
# HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter during the enforcement cycle. This
|
||||||
|
# reads it. Splitting the two is not tidiness: the machine half needs SYSTEM and
|
||||||
|
# the share, the user half needs a user - no single process has both.
|
||||||
|
#
|
||||||
|
# IT ALSO TURNS OFF "Let Windows manage my default printer". Leaving it on means
|
||||||
|
# Windows silently overwrites the choice the next time somebody prints to another
|
||||||
|
# queue, and the bay drifts back with nothing in any log to say why.
|
||||||
|
#
|
||||||
|
# Converges: when the current default already matches, it does nothing, so a
|
||||||
|
# repeating trigger costs a registry read. A user who deliberately picks another
|
||||||
|
# default WILL be corrected on the next run - that is the intent for a shared
|
||||||
|
# bay. For a PC where that is wrong, schedule it at logon only.
|
||||||
|
#
|
||||||
|
# Exits 0 always.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# Override for testing. Normally read from the machine hive.
|
||||||
|
[string]$PrinterName = ''
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
$logDir = "$env:LOCALAPPDATA\ShopDB"
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
$logFile = Join-Path $logDir 'default-printer.log'
|
||||||
|
function Log([string]$msg) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PrinterName) {
|
||||||
|
foreach ($path in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||||
|
try {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
$value = [string](Get-ItemProperty -Path $path -Name DefaultPrinter -ErrorAction Stop).DefaultPrinter
|
||||||
|
if ($value -and $value.Trim()) { $PrinterName = $value.Trim(); break }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PrinterName) {
|
||||||
|
# No default assigned is a legitimate state - a bay with three printers and
|
||||||
|
# no favourite - so leave whatever the user has.
|
||||||
|
Log 'no default assigned in ShopDB; leaving the current one alone'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Windows 10+ overrides any default the moment the user prints elsewhere, unless
|
||||||
|
# this is off. Setting the default without clearing this is a fix that undoes
|
||||||
|
# itself within a day.
|
||||||
|
try {
|
||||||
|
$windowsKey = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows'
|
||||||
|
$managed = (Get-ItemProperty -Path $windowsKey -Name LegacyDefaultPrinterMode -ErrorAction SilentlyContinue).LegacyDefaultPrinterMode
|
||||||
|
if ($managed -ne 1) {
|
||||||
|
Set-ItemProperty -Path $windowsKey -Name LegacyDefaultPrinterMode -Value 1 -Type DWord
|
||||||
|
Log 'turned off "Let Windows manage my default printer"'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not turn off Windows-managed defaults: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$queue = Get-Printer -Name $PrinterName -ErrorAction SilentlyContinue
|
||||||
|
if (-not $queue) {
|
||||||
|
# The enforcement cycle creates queues; this runs at logon and may simply be
|
||||||
|
# earlier than the first cycle on a new bay. Next run picks it up.
|
||||||
|
Log "assigned default '$PrinterName' is not installed yet; nothing to do"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = (Get-CimInstance -ClassName Win32_Printer -Filter 'Default = True' -ErrorAction SilentlyContinue).Name
|
||||||
|
if ($current -eq $PrinterName) {
|
||||||
|
Log "already default: $PrinterName"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$target = Get-CimInstance -ClassName Win32_Printer -Filter ("Name = '{0}'" -f $PrinterName.Replace("'", "''")) -ErrorAction Stop
|
||||||
|
Invoke-CimMethod -InputObject $target -MethodName SetDefaultPrinter -ErrorAction Stop | Out-Null
|
||||||
|
Log "default set: $PrinterName (was '$current')"
|
||||||
|
} catch {
|
||||||
|
Log "ERROR setting the default to '$PrinterName': $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
144
plugins/printers/client/DEPLOYING-DRIVERS.md
Normal file
144
plugins/printers/client/DEPLOYING-DRIVERS.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Deploying the printer driver set
|
||||||
|
|
||||||
|
The driver set is a package: `Install-ShopdbPrinterDrivers.ps1`, the
|
||||||
|
single-driver worker it wraps, a `drivers.json` naming each driver and where its
|
||||||
|
files are, and the driver packages themselves.
|
||||||
|
|
||||||
|
Staging drivers is deliberately SEPARATE from assigning printers. Drivers are
|
||||||
|
large, change rarely and are identical across a fleet; assignments are small,
|
||||||
|
per-bay and change often. Keeping them apart means creating a queue never waits
|
||||||
|
on a download, and a driver never has to be fetched at the moment someone is
|
||||||
|
trying to print.
|
||||||
|
|
||||||
|
## The shape
|
||||||
|
|
||||||
|
```
|
||||||
|
ShopdbPrinterDrivers\
|
||||||
|
Install-ShopdbPrinterDrivers.ps1 the whole set, manifest driven
|
||||||
|
Install-ShopdbPrinterDriver.ps1 one driver (this does the work)
|
||||||
|
drivers.json what this site deploys
|
||||||
|
drivers\
|
||||||
|
hp_upd_ps\ xerox_gpd\ hp_designjet\ zebra_zt411\ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`drivers.json` paths may be relative to the package or absolute. A site whose
|
||||||
|
packages already live on a share points at the share and ships only the two
|
||||||
|
scripts and the manifest.
|
||||||
|
|
||||||
|
## GE-Enforce, in the `common` scope
|
||||||
|
|
||||||
|
Every shop-floor PC gets every driver, once. After the first cycle each run is a
|
||||||
|
`Get-PrinterDriver` check per driver and nothing else, so the cost is a few
|
||||||
|
milliseconds, not a re-install.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_comment": "Stage the site's printer drivers. Runs in-cycle because the share is only mounted then. Idempotent: a driver already present is skipped.",
|
||||||
|
"Name": "ShopDB printer drivers",
|
||||||
|
"Type": "PS1",
|
||||||
|
"Script": "scripts/Install-ShopdbPrinterDrivers.ps1",
|
||||||
|
"DetectionMethod": "Always"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**It must be a manifest entry, not its own scheduled task.** The SFLD share is
|
||||||
|
mounted only for the duration of the enforcement cycle; off-cycle the paths
|
||||||
|
simply do not exist and every run logs "package not found" forever.
|
||||||
|
|
||||||
|
## Azure Machine Configuration / DSC
|
||||||
|
|
||||||
|
The script answers a compliance question, which is what makes it a clean `Script`
|
||||||
|
resource: `-TestOnly` reports whether every driver in the manifest is present and
|
||||||
|
exits 0 or 1 without changing anything.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Configuration ShopdbPrinterDrivers
|
||||||
|
{
|
||||||
|
Import-DscResource -ModuleName PSDesiredStateConfiguration
|
||||||
|
|
||||||
|
Node localhost
|
||||||
|
{
|
||||||
|
Script PrinterDrivers
|
||||||
|
{
|
||||||
|
GetScript = {
|
||||||
|
@{ Result = (Get-PrinterDriver | Select-Object -ExpandProperty Name) -join ', ' }
|
||||||
|
}
|
||||||
|
TestScript = {
|
||||||
|
$p = Start-Process -FilePath 'powershell.exe' -PassThru -Wait -WindowStyle Hidden `
|
||||||
|
-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',
|
||||||
|
'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1','-TestOnly'
|
||||||
|
return ($p.ExitCode -eq 0)
|
||||||
|
}
|
||||||
|
SetScript = {
|
||||||
|
Start-Process -FilePath 'powershell.exe' -Wait -WindowStyle Hidden `
|
||||||
|
-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',
|
||||||
|
'C:\ProgramData\ShopDB\Drivers\Install-ShopdbPrinterDrivers.ps1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliver the package to `C:\ProgramData\ShopDB\Drivers` however that estate
|
||||||
|
already delivers files - a Win32 app, a File resource, or the imaging step.
|
||||||
|
|
||||||
|
## Intune
|
||||||
|
|
||||||
|
Package the folder as a Win32 app.
|
||||||
|
|
||||||
|
- Install: `powershell.exe -NoProfile -ExecutionPolicy Bypass -File Install-ShopdbPrinterDrivers.ps1`
|
||||||
|
- Detection: a script running the same file with `-TestOnly`, exit 0 = detected
|
||||||
|
- Run as SYSTEM. Adding a printer driver has required administrator rights since
|
||||||
|
the 2021 print hardening, and SYSTEM satisfies it.
|
||||||
|
|
||||||
|
## Why not have the assignment client fetch drivers
|
||||||
|
|
||||||
|
It was considered and rejected. A bay would then download a driver at the moment
|
||||||
|
a printer is assigned, which is the worst time: someone is waiting, the share may
|
||||||
|
be unmounted, and a 48 MB package would be pulled per bay per change. Staging the
|
||||||
|
set in `common` makes assignment a queue creation and nothing more.
|
||||||
|
|
||||||
|
## One driver per package, named exactly
|
||||||
|
|
||||||
|
`drivers.json` carries the driver name as its INF declares it - `Add-PrinterDriver`
|
||||||
|
matches that string and nothing else. The names verified on Windows for the
|
||||||
|
reference site's fleet:
|
||||||
|
|
||||||
|
| driver | covers |
|
||||||
|
|---|---|
|
||||||
|
| `HP Universal Printing PS` | HP office printers |
|
||||||
|
| `Xerox Global Print Driver PCL6` | Xerox office printers |
|
||||||
|
| `HP DesignJet T1700dr V4` | DesignJet plotters (a v4 class driver) |
|
||||||
|
| `ZDesigner ZT411-300dpi ZPL` | Zebra ZT411 labels |
|
||||||
|
| `EPSON TM-C3500` | Epson ColorWorks labels |
|
||||||
|
| `DTC4500e Card Printer` | HID FARGO card printer |
|
||||||
|
|
||||||
|
## The other half: assigning printers
|
||||||
|
|
||||||
|
Staging drivers is only delivery. `Set-ShopdbPrinters.ps1` is what makes a bay's
|
||||||
|
queues match ShopDB, and `Apply-ShopdbDefaultPrinter.ps1` applies the default in
|
||||||
|
the user's context. Two manifest entries, both `DetectionMethod: Always`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_comment": "Create the queues this bay is assigned. Converges: existing queues are left alone, and nothing is ever removed.",
|
||||||
|
"Name": "ShopDB printers",
|
||||||
|
"Type": "PS1",
|
||||||
|
"Script": "scripts/Set-ShopdbPrinters.ps1",
|
||||||
|
"DetectionMethod": "Always"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The default printer is per-user, so SYSTEM cannot set it for the person logged
|
||||||
|
on. `Set-ShopdbPrinters.ps1` records it in `HKLM:\SOFTWARE\GE\ShopDB`
|
||||||
|
`DefaultPrinter`, and `Apply-ShopdbDefaultPrinter.ps1` runs as the user - at
|
||||||
|
logon, and on a repeat if the site wants drift corrected.
|
||||||
|
|
||||||
|
Order matters on a new bay: drivers, then queues, then the default. Each step is
|
||||||
|
a no-op once satisfied, so running all three every cycle costs a few registry
|
||||||
|
reads.
|
||||||
|
|
||||||
|
Verified end to end on Windows 11 against a live ShopDB: printers assigned to a
|
||||||
|
MACHINE, a PC controlling it and holding no rows of its own, and the bay created
|
||||||
|
both queues with the right universal driver, recorded the default, and set it -
|
||||||
|
then a second run changed nothing.
|
||||||
193
plugins/printers/client/Install-ShopdbPrinterDriver.ps1
Normal file
193
plugins/printers/client/Install-ShopdbPrinterDriver.ps1
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Install-ShopdbPrinterDriver.ps1
|
||||||
|
#
|
||||||
|
# Stages a printer driver into the Windows Driver Store and makes it available
|
||||||
|
# to the spooler, silently and offline. Deployable as a DSC Script resource, an
|
||||||
|
# Intune platform script, or a GE-Enforce manifest entry - it needs no user, no
|
||||||
|
# network beyond the driver source, and no vendor setup.exe.
|
||||||
|
#
|
||||||
|
# WHY NOT THE VENDOR INSTALLER: HP's and Xerox's universal drivers are ordinary
|
||||||
|
# INF driver packages. pnputil stages them without a UI, which is the only way
|
||||||
|
# this works on a locked bay with nobody logged in. The vendor bundles add a
|
||||||
|
# wizard and a service nobody wants.
|
||||||
|
#
|
||||||
|
# WHY IT IS SILENT: the signing certificate is added to Trusted Publishers first.
|
||||||
|
# Without that, pnputil prompts to trust the publisher and the install stalls
|
||||||
|
# forever behind a dialog no one will ever see. This mirrors the sequence the
|
||||||
|
# printer installer has used in production.
|
||||||
|
#
|
||||||
|
# IDEMPOTENT: if the spooler already has the driver by name, it does nothing.
|
||||||
|
# Safe to run every enforcement cycle.
|
||||||
|
#
|
||||||
|
# DRIVER NAME: -DriverName must be the name the INF declares, verbatim, e.g.
|
||||||
|
# 'HP Universal Printing PCL 6'. A near-miss fails at Add-PrinterDriver with an
|
||||||
|
# unhelpful error, which is why ShopDB stores the name rather than guessing it.
|
||||||
|
#
|
||||||
|
# SHARE PATHS: on a GE-Enforce site the driver source usually lives on the SFLD
|
||||||
|
# share, which is mounted ONLY during the enforcement cycle. Run this as a
|
||||||
|
# manifest entry inside that cycle, never as its own scheduled task - off-cycle
|
||||||
|
# the path is simply absent and this logs "source not reachable" forever.
|
||||||
|
#
|
||||||
|
# Exits 0 always. A driver that cannot be staged is logged, not thrown: a failed
|
||||||
|
# printer must never fail an enforcement run.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# Exact driver name from the INF, e.g. 'Xerox Global Print Driver PCL6'.
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DriverName,
|
||||||
|
|
||||||
|
# Folder holding the driver package, or a path to a specific .inf.
|
||||||
|
# UNC or local. This is PrinterDriver.location in ShopDB.
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Source,
|
||||||
|
|
||||||
|
# Stage every .inf found under Source rather than picking one. Universal
|
||||||
|
# driver packages ship several INFs and the needed one is not always
|
||||||
|
# obvious; staging all of them is cheap and avoids guessing.
|
||||||
|
[switch]$AllInf,
|
||||||
|
|
||||||
|
[int]$TimeoutSec = 600
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
$logDir = 'C:\Logs\Shopfloor'
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||||
|
|
||||||
|
function Log([string]$msg) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Log "=== Install printer driver: $DriverName ==="
|
||||||
|
|
||||||
|
# Already present: nothing to do. This is the common case on every cycle after
|
||||||
|
# the first, so it is checked before anything touches the share.
|
||||||
|
$existing = Get-PrinterDriver -Name $DriverName -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
Log "already installed, nothing to do"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $Source)) {
|
||||||
|
Log "ERROR source not reachable: $Source"
|
||||||
|
Log " (on a GE-Enforce site, is this running inside the cycle? the share is"
|
||||||
|
Log " mounted only for the duration of the run.)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Collect the INFs to stage.
|
||||||
|
$infs = @()
|
||||||
|
if ((Get-Item $Source).PSIsContainer) {
|
||||||
|
$found = Get-ChildItem -Path $Source -Filter '*.inf' -Recurse -ErrorAction SilentlyContinue
|
||||||
|
if (-not $AllInf) {
|
||||||
|
# Prefer an INF whose name hints at the architecture in use; otherwise
|
||||||
|
# take them all. Staging a surplus INF costs disk, missing one costs a
|
||||||
|
# site visit.
|
||||||
|
$infs = @($found)
|
||||||
|
} else {
|
||||||
|
$infs = @($found)
|
||||||
|
}
|
||||||
|
} elseif ($Source -like '*.inf') {
|
||||||
|
$infs = @(Get-Item $Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $infs -or $infs.Count -eq 0) {
|
||||||
|
Log "ERROR no .inf found under $Source"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Log "found $($infs.Count) inf file(s)"
|
||||||
|
|
||||||
|
# Trust the package's SIGNER FIRST, or pnputil refuses with "The publisher of an
|
||||||
|
# Authenticode(tm) signed catalog has not yet been established as trusted" - and
|
||||||
|
# on a bay with nobody logged in there is no prompt to answer, so the install
|
||||||
|
# simply never happens.
|
||||||
|
#
|
||||||
|
# The certificate is EXTRACTED from the catalog and added to Trusted Publishers.
|
||||||
|
# Adding the .cat file itself with certutil -addstore is not the same thing: it
|
||||||
|
# stores the catalog, not the publisher, and whether that satisfies pnputil
|
||||||
|
# varies by vendor. It worked for one universal driver and failed for another,
|
||||||
|
# which is a coin toss, not a mechanism.
|
||||||
|
#
|
||||||
|
# Every catalog under the source is trusted, not just the ones beside the first
|
||||||
|
# INF: a universal driver package holds several, and the one that matters is not
|
||||||
|
# predictably the first.
|
||||||
|
$cats = @(Get-ChildItem -Path $Source -Filter '*.cat' -Recurse -ErrorAction SilentlyContinue)
|
||||||
|
$trusted = 0
|
||||||
|
if ($cats.Count -gt 0) {
|
||||||
|
try {
|
||||||
|
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store(
|
||||||
|
'TrustedPublisher', 'LocalMachine')
|
||||||
|
$store.Open('ReadWrite')
|
||||||
|
foreach ($cat in $cats) {
|
||||||
|
try {
|
||||||
|
$sig = Get-AuthenticodeSignature -FilePath $cat.FullName -ErrorAction Stop
|
||||||
|
if ($sig -and $sig.SignerCertificate) {
|
||||||
|
$store.Add($sig.SignerCertificate)
|
||||||
|
$trusted++
|
||||||
|
} else {
|
||||||
|
Log "WARN no signer certificate on $($cat.Name)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not trust $($cat.Name): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$store.Close()
|
||||||
|
} catch {
|
||||||
|
Log "WARN could not open the Trusted Publishers store: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log "trusted $trusted of $($cats.Count) catalog(s)"
|
||||||
|
|
||||||
|
# Stage into the Driver Store. Deliberately WITHOUT /install: that runs a PnP
|
||||||
|
# device-match pass which is pointless for a network printer and slow across a
|
||||||
|
# universal driver's thousands of models. Add-PrinterDriver binds it afterwards.
|
||||||
|
$staged = $false
|
||||||
|
foreach ($inf in $infs) {
|
||||||
|
$null = & pnputil.exe /add-driver $inf.FullName 2>&1
|
||||||
|
# 259 = no more data (nothing new to add), 3010 = success, reboot queued.
|
||||||
|
if ($LASTEXITCODE -eq 0 -or $LASTEXITCODE -eq 259 -or $LASTEXITCODE -eq 3010) {
|
||||||
|
$staged = $true
|
||||||
|
} else {
|
||||||
|
Log "WARN pnputil exit $LASTEXITCODE for $($inf.Name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $staged) {
|
||||||
|
Log "ERROR nothing staged from $Source"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Log "staged into the driver store"
|
||||||
|
|
||||||
|
# Make it known to the spooler under the name ShopDB holds.
|
||||||
|
try {
|
||||||
|
Add-PrinterDriver -Name $DriverName -ErrorAction Stop
|
||||||
|
Log "installed: $DriverName"
|
||||||
|
} catch {
|
||||||
|
Log "ERROR Add-PrinterDriver failed for '$DriverName': $($_.Exception.Message)"
|
||||||
|
# Display names live in the INF's [Strings] section as token="Some Name",
|
||||||
|
# referenced elsewhere as %token%. Reading the model lines instead just
|
||||||
|
# reports the manufacturer, which is no help to whoever has to fix this.
|
||||||
|
Log " the name must match the INF verbatim. Names these packages offer:"
|
||||||
|
$offered = @()
|
||||||
|
foreach ($inf in $infs) {
|
||||||
|
$hits = Select-String -Path $inf.FullName -Encoding unicode `
|
||||||
|
-Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue
|
||||||
|
if (-not $hits) {
|
||||||
|
$hits = Select-String -Path $inf.FullName `
|
||||||
|
-Pattern '^[A-Za-z0-9_]+\s*=\s*"([^"]{8,})"' -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
foreach ($h in $hits) {
|
||||||
|
$value = $h.Matches[0].Groups[1].Value
|
||||||
|
# A driver name has a space in it; version strings and paths do not.
|
||||||
|
if ($value -match '^[A-Za-z].*\s') { $offered += $value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($name in ($offered | Sort-Object -Unique | Select-Object -First 10)) {
|
||||||
|
Log " $name"
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
126
plugins/printers/client/Install-ShopdbPrinterDrivers.ps1
Normal file
126
plugins/printers/client/Install-ShopdbPrinterDrivers.ps1
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Install-ShopdbPrinterDrivers.ps1
|
||||||
|
#
|
||||||
|
# Installs a SITE'S WHOLE DRIVER SET from a manifest, so a bay ends up with every
|
||||||
|
# printer driver it might need in one converging run. Wraps
|
||||||
|
# Install-ShopdbPrinterDriver.ps1, which does one driver.
|
||||||
|
#
|
||||||
|
# DESIGNED FOR DSC / Intune / GE-Enforce. It declares state rather than
|
||||||
|
# performing an install: a driver already present is skipped, so this is safe to
|
||||||
|
# run on a schedule and cheap when there is nothing to do. That is what lets a
|
||||||
|
# DSC Script resource call it from TestScript as well as SetScript.
|
||||||
|
#
|
||||||
|
# THE MANIFEST, not arguments, is the contract. drivers.json lists each driver by
|
||||||
|
# the name its INF declares - what Add-PrinterDriver matches on, verbatim - and
|
||||||
|
# where its package lives. Paths are relative to this script, or absolute (a UNC
|
||||||
|
# path on a site's share is normal).
|
||||||
|
#
|
||||||
|
# EXIT CODE: 0 when every driver in the manifest is present at the end, 1 when
|
||||||
|
# one or more could not be installed. DSC needs a real answer here, unlike the
|
||||||
|
# single-driver script which never fails an enforcement run. The per-driver log
|
||||||
|
# says which and why.
|
||||||
|
#
|
||||||
|
# SHARE PATHS: on a GE-Enforce site the packages usually live on the SFLD share,
|
||||||
|
# which is mounted ONLY during the enforcement cycle. Run this as a manifest
|
||||||
|
# entry inside that cycle, not as its own scheduled task.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# Defaults to drivers.json beside this script.
|
||||||
|
[string]$Manifest = '',
|
||||||
|
|
||||||
|
# Install only these driver names; everything else in the manifest is
|
||||||
|
# ignored. For a bay that needs one driver out of a site-wide set.
|
||||||
|
[string[]]$Only = @(),
|
||||||
|
|
||||||
|
# Report what is missing and change nothing. This is what a DSC TestScript
|
||||||
|
# calls: exit 0 means compliant.
|
||||||
|
[switch]$TestOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
|
||||||
|
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
if (-not $Manifest) { $Manifest = Join-Path $here 'drivers.json' }
|
||||||
|
|
||||||
|
$logDir = 'C:\Logs\Shopfloor'
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
$logFile = Join-Path $logDir ('printer-drivers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||||
|
function Log([string]$msg) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
"$ts [set] $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $Manifest)) {
|
||||||
|
Log "ERROR manifest not found: $Manifest"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$config = Get-Content -Raw -Path $Manifest | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
Log "ERROR manifest is not valid JSON: $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$wanted = @($config.drivers)
|
||||||
|
if ($Only.Count -gt 0) {
|
||||||
|
$wanted = @($wanted | Where-Object { $Only -contains $_.drivername })
|
||||||
|
}
|
||||||
|
if ($wanted.Count -eq 0) {
|
||||||
|
Log "nothing to do: the manifest selects no drivers"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$single = Join-Path $here 'Install-ShopdbPrinterDriver.ps1'
|
||||||
|
if (-not (Test-Path $single)) {
|
||||||
|
Log "ERROR Install-ShopdbPrinterDriver.ps1 is not beside this script"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$missing = @()
|
||||||
|
foreach ($driver in $wanted) {
|
||||||
|
$name = $driver.drivername
|
||||||
|
if (-not $name) { continue }
|
||||||
|
|
||||||
|
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
|
||||||
|
Log "present: $name"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($TestOnly) {
|
||||||
|
Log "MISSING: $name"
|
||||||
|
$missing += $name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Relative paths are resolved against the package, so the whole thing can be
|
||||||
|
# copied anywhere - a share, C:\ProgramData, an Intune staging folder - and
|
||||||
|
# still find its own payloads.
|
||||||
|
$path = $driver.path
|
||||||
|
if ($path -and -not [System.IO.Path]::IsPathRooted($path)) {
|
||||||
|
$path = Join-Path $here $path
|
||||||
|
}
|
||||||
|
if (-not $path -or -not (Test-Path $path)) {
|
||||||
|
Log "ERROR package not found for '$name': $path"
|
||||||
|
$missing += $name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Log "installing: $name"
|
||||||
|
& $single -DriverName $name -Source $path | Out-Null
|
||||||
|
|
||||||
|
if (Get-PrinterDriver -Name $name -ErrorAction SilentlyContinue) {
|
||||||
|
Log "installed: $name"
|
||||||
|
} else {
|
||||||
|
Log "FAILED: $name (see the per-driver lines above)"
|
||||||
|
$missing += $name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing.Count -gt 0) {
|
||||||
|
Log ("not present: {0}" -f ($missing -join ', '))
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Log "all $($wanted.Count) driver(s) present"
|
||||||
|
exit 0
|
||||||
166
plugins/printers/client/Set-ShopdbPrinters.ps1
Normal file
166
plugins/printers/client/Set-ShopdbPrinters.ps1
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Set-ShopdbPrinters.ps1
|
||||||
|
#
|
||||||
|
# Makes this PC's printers match what ShopDB says the bay should have. Asks
|
||||||
|
# GET /api/printers/for-host/<hostname> and creates any queue that is missing.
|
||||||
|
#
|
||||||
|
# WHY THE ASSIGNMENT IS NOT ON THIS PC: it is on the MACHINE, and reaches
|
||||||
|
# whichever PC controls it. A reimaged or swapped box inherits the bay's printers
|
||||||
|
# with nothing saved off the old one - the asset register is the backup.
|
||||||
|
#
|
||||||
|
# CONVERGES, does not install. A queue that already exists is left alone, so this
|
||||||
|
# is cheap to run every enforcement cycle and safe to run twice.
|
||||||
|
#
|
||||||
|
# NEVER REMOVES A QUEUE. If a printer disappears from the response - because the
|
||||||
|
# API had a bad minute, or someone unassigned it - the bay keeps printing. Taking
|
||||||
|
# printers away from a working bay because of a transient error is the one
|
||||||
|
# failure this must not have.
|
||||||
|
#
|
||||||
|
# DRIVERS ARE NOT FETCHED HERE. Install-ShopdbPrinterDrivers.ps1 stages the site's
|
||||||
|
# set in the common scope, once per bay. A queue is created against a driver that
|
||||||
|
# is already present; if it is not, that is logged and the printer is skipped,
|
||||||
|
# because downloading 48 MB while somebody waits to print is the wrong moment.
|
||||||
|
#
|
||||||
|
# THE DEFAULT PRINTER IS PER USER. This runs as SYSTEM and cannot set it for the
|
||||||
|
# logged-on person, so it records the desired default in HKLM and leaves applying
|
||||||
|
# it to a logon task. Without that, SYSTEM would set a default nobody sees.
|
||||||
|
#
|
||||||
|
# Exits 0 always: a printer problem must not fail an enforcement run.
|
||||||
|
|
||||||
|
param(
|
||||||
|
# ShopDB base URL. Empty resolves from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
|
||||||
|
# written by Install-GEEnforce.ps1 and already present wherever this runs.
|
||||||
|
[string]$BaseUrl = '',
|
||||||
|
|
||||||
|
# Defaults to this machine's name, which is what the collector upserts by.
|
||||||
|
[string]$Hostname = $env:COMPUTERNAME,
|
||||||
|
|
||||||
|
[int]$TimeoutSec = 30,
|
||||||
|
|
||||||
|
# Report what would change and touch nothing.
|
||||||
|
[switch]$WhatIfOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
$logDir = 'C:\Logs\Shopfloor'
|
||||||
|
if (-not (Test-Path $logDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||||
|
}
|
||||||
|
$logFile = Join-Path $logDir ('printers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||||
|
function Log([string]$msg) {
|
||||||
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||||
|
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$REGPATH = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||||
|
|
||||||
|
if (-not $BaseUrl) {
|
||||||
|
foreach ($path in @($REGPATH, 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||||
|
try {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
$value = [string](Get-ItemProperty -Path $path -Name BaseUrl -ErrorAction Stop).BaseUrl
|
||||||
|
if ($value -and $value.Trim()) { $BaseUrl = $value.Trim(); break }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $BaseUrl) {
|
||||||
|
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -BaseUrl). Skipping.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Log "=== Set printers for $Hostname ==="
|
||||||
|
|
||||||
|
$url = $BaseUrl.TrimEnd('/') + '/api/printers/for-host/' + [uri]::EscapeDataString($Hostname)
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec $TimeoutSec
|
||||||
|
} catch {
|
||||||
|
# An unreachable server means "no information", not "no printers". Changing
|
||||||
|
# nothing is the only safe response.
|
||||||
|
Log "ERROR could not read $url : $($_.Exception.Message)"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $response.data
|
||||||
|
if ($null -eq $payload) { $payload = $response }
|
||||||
|
$wanted = @($payload.printers)
|
||||||
|
$defaultid = $payload.defaultprinterid
|
||||||
|
|
||||||
|
if ($wanted.Count -eq 0) {
|
||||||
|
Log 'nothing assigned to this host'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Log "assigned: $($wanted.Count) printer(s)"
|
||||||
|
|
||||||
|
$existing = @{}
|
||||||
|
foreach ($queue in (Get-Printer -ErrorAction SilentlyContinue)) {
|
||||||
|
$existing[$queue.Name] = $queue
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultname = ''
|
||||||
|
foreach ($printer in $wanted) {
|
||||||
|
$name = $printer.queuename
|
||||||
|
if (-not $name) { continue }
|
||||||
|
if ($printer.printerid -eq $defaultid) { $defaultname = $name }
|
||||||
|
|
||||||
|
if ($existing.ContainsKey($name)) {
|
||||||
|
Log "present: $name"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $printer.hostname
|
||||||
|
if (-not $address) { $address = $printer.ipaddress }
|
||||||
|
if (-not $address) {
|
||||||
|
Log "SKIP $name : no hostname or IP to point a port at"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$drivername = $printer.drivername
|
||||||
|
if (-not $drivername) {
|
||||||
|
Log "SKIP $name : ShopDB has no driver name for it"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (-not (Get-PrinterDriver -Name $drivername -ErrorAction SilentlyContinue)) {
|
||||||
|
# Deliberately not fetched here - see the header.
|
||||||
|
Log "SKIP $name : driver '$drivername' is not staged on this PC"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($WhatIfOnly) {
|
||||||
|
Log "WOULD create: $name -> $address ($drivername)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$portname = 'IP_' + $address
|
||||||
|
try {
|
||||||
|
if (-not (Get-PrinterPort -Name $portname -ErrorAction SilentlyContinue)) {
|
||||||
|
Add-PrinterPort -Name $portname -PrinterHostAddress $address -ErrorAction Stop
|
||||||
|
Log "port: $portname"
|
||||||
|
}
|
||||||
|
Add-Printer -Name $name -DriverName $drivername -PortName $portname -ErrorAction Stop
|
||||||
|
Log "created: $name -> $address ($drivername)"
|
||||||
|
} catch {
|
||||||
|
Log "ERROR creating ${name}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The default is recorded, not applied: this process is SYSTEM and the setting
|
||||||
|
# is per user. Apply-ShopdbDefaultPrinter.ps1 reads it at logon.
|
||||||
|
if ($defaultname) {
|
||||||
|
if ($WhatIfOnly) {
|
||||||
|
Log "WOULD record default: $defaultname"
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path $REGPATH)) { New-Item -Path $REGPATH -Force | Out-Null }
|
||||||
|
Set-ItemProperty -Path $REGPATH -Name DefaultPrinter -Value $defaultname
|
||||||
|
Log "default recorded for the logon task: $defaultname"
|
||||||
|
} catch {
|
||||||
|
Log "ERROR recording the default: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log 'no default assigned'
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
35
plugins/printers/client/drivers.example.json
Normal file
35
plugins/printers/client/drivers.example.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Driver set for a site. Each entry names a driver EXACTLY as its INF declares it (what Add-PrinterDriver matches on) and where its package lives, relative to the package root or as an absolute UNC path. Copy to drivers.json and edit for the site.",
|
||||||
|
"drivers": [
|
||||||
|
{
|
||||||
|
"drivername": "HP Universal Printing PS",
|
||||||
|
"path": "drivers/hp_upd_ps",
|
||||||
|
"covers": "HP office printers (universal)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivername": "Xerox Global Print Driver PCL6",
|
||||||
|
"path": "drivers/xerox_gpd",
|
||||||
|
"covers": "Xerox office printers (universal)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivername": "HP DesignJet T1700dr V4",
|
||||||
|
"path": "drivers/hp_designjet",
|
||||||
|
"covers": "DesignJet T1700 / T1700dr plotters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivername": "ZDesigner ZT411-300dpi ZPL",
|
||||||
|
"path": "drivers/zebra_zt411",
|
||||||
|
"covers": "Zebra ZT411 label printers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivername": "EPSON TM-C3500",
|
||||||
|
"path": "drivers/epson_tmc3500",
|
||||||
|
"covers": "Epson ColorWorks C3500 label printers"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"drivername": "DTC4500e Card Printer",
|
||||||
|
"path": "drivers/hid_dtc4500e",
|
||||||
|
"covers": "HID FARGO DTC4500e card printer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -169,6 +169,7 @@
|
|||||||
v-if="printer.mapx != null && printer.mapy != null"
|
v-if="printer.mapx != null && printer.mapy != null"
|
||||||
:left="printer.mapx"
|
:left="printer.mapx"
|
||||||
:top="printer.mapy"
|
:top="printer.mapy"
|
||||||
|
:levelid="printer.levelid"
|
||||||
:machineName="printer.name || printer.assetnumber"
|
:machineName="printer.name || printer.assetnumber"
|
||||||
>
|
>
|
||||||
<span class="location-link">View on Map</span>
|
<span class="location-link">View on Map</span>
|
||||||
|
|||||||
@@ -250,9 +250,11 @@
|
|||||||
<div class="map-location-control">
|
<div class="map-location-control">
|
||||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
|
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
|
||||||
|
<span v-else class="position-level position-level-missing">level not set</span>
|
||||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
<button type="button" class="btn btn-secondary" @click="openMapPicker">
|
||||||
Set Location on Map
|
Set Location on Map
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,8 +263,19 @@
|
|||||||
<!-- Map Picker Modal -->
|
<!-- Map Picker Modal -->
|
||||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
<div class="map-modal-content">
|
<div class="map-modal-content">
|
||||||
|
<div v-if="levelOptions().length > 1" class="map-level-picker">
|
||||||
|
<label>Level</label>
|
||||||
|
<select v-model.number="pickerLevelId" class="form-control">
|
||||||
|
<option v-for="option in levelOptions()" :key="option.levelid"
|
||||||
|
:value="option.levelid">{{ option.label }}</option>
|
||||||
|
</select>
|
||||||
|
<span class="input-hint">
|
||||||
|
The position is pixels on this drawing, so pick the level first.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<ShopFloorMap
|
<ShopFloorMap
|
||||||
:pickerMode="true"
|
:pickerMode="true"
|
||||||
|
:levelid="pickerLevelId"
|
||||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
:theme="currentTheme"
|
:theme="currentTheme"
|
||||||
@positionPicked="handlePositionPicked"
|
@positionPicked="handlePositionPicked"
|
||||||
@@ -295,6 +308,8 @@ import { ref, onMounted, computed, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '@/api'
|
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
|
from '@/composables/mapConfig'
|
||||||
import Modal from '@/components/Modal.vue'
|
import Modal from '@/components/Modal.vue'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -320,6 +335,10 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showMapPicker = ref(false)
|
const showMapPicker = ref(false)
|
||||||
|
// Which drawing the picker shows, and therefore which level the coordinates it
|
||||||
|
// returns belong to (ADR-017). Opens on the position's existing level so editing
|
||||||
|
// a marker does not silently move it to the default one.
|
||||||
|
const pickerLevelId = ref(null)
|
||||||
const tempMapPosition = ref(null)
|
const tempMapPosition = ref(null)
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -337,6 +356,7 @@ const form = ref({
|
|||||||
notes: '',
|
notes: '',
|
||||||
mapx: null,
|
mapx: null,
|
||||||
mapy: null,
|
mapy: null,
|
||||||
|
levelid: null,
|
||||||
// Printer-specific
|
// Printer-specific
|
||||||
ipaddress: '',
|
ipaddress: '',
|
||||||
csfname: '',
|
csfname: '',
|
||||||
@@ -511,6 +531,7 @@ onMounted(async () => {
|
|||||||
notes: printer.notes || '',
|
notes: printer.notes || '',
|
||||||
mapx: printer.mapx ?? null,
|
mapx: printer.mapx ?? null,
|
||||||
mapy: printer.mapy ?? null,
|
mapy: printer.mapy ?? null,
|
||||||
|
levelid: printer.levelid ?? null,
|
||||||
// Printer-specific
|
// Printer-specific
|
||||||
ipaddress: primaryComm?.ipaddress || '',
|
ipaddress: primaryComm?.ipaddress || '',
|
||||||
csfname: ext.sharename || '',
|
csfname: ext.sharename || '',
|
||||||
@@ -534,10 +555,21 @@ function handlePositionPicked(position) {
|
|||||||
tempMapPosition.value = position
|
tempMapPosition.value = position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
loadMapConfig().then(() => {
|
||||||
|
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
|
||||||
|
showMapPicker.value = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function confirmMapPosition() {
|
function confirmMapPosition() {
|
||||||
if (tempMapPosition.value) {
|
if (tempMapPosition.value) {
|
||||||
form.value.mapx = tempMapPosition.value.left
|
form.value.mapx = tempMapPosition.value.left
|
||||||
form.value.mapy = tempMapPosition.value.top
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
// Never one without the other: coordinates saved with no level render as
|
||||||
|
// "level unknown", and coordinates saved against the wrong level render
|
||||||
|
// convincingly in the wrong place.
|
||||||
|
form.value.levelid = pickerLevelId.value
|
||||||
}
|
}
|
||||||
showMapPicker.value = false
|
showMapPicker.value = false
|
||||||
}
|
}
|
||||||
@@ -545,6 +577,7 @@ function confirmMapPosition() {
|
|||||||
function clearMapPosition() {
|
function clearMapPosition() {
|
||||||
form.value.mapx = null
|
form.value.mapx = null
|
||||||
form.value.mapy = null
|
form.value.mapy = null
|
||||||
|
form.value.levelid = null
|
||||||
tempMapPosition.value = null
|
tempMapPosition.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '@/composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, dimensionsFor, state as mapConfig } from '@/composables/mapConfig'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
@@ -55,8 +55,10 @@ const selected = ref({}) // printerid -> true
|
|||||||
let map = null
|
let map = null
|
||||||
let imageOverlay = null
|
let imageOverlay = null
|
||||||
let markers = {} // printerid -> circleMarker
|
let markers = {} // printerid -> circleMarker
|
||||||
let MAP_WIDTH = mapConfig.width
|
// The level being shown. The installer map runs before anyone logs in, which is
|
||||||
let MAP_HEIGHT = mapConfig.height
|
// why /api/maplevels is public - without it there is no blueprint to draw.
|
||||||
|
let MAP_WIDTH = 0
|
||||||
|
let MAP_HEIGHT = 0
|
||||||
|
|
||||||
const SELECTED_COLOR = '#e53935'
|
const SELECTED_COLOR = '#e53935'
|
||||||
const NORMAL_COLOR = '#4CAF50'
|
const NORMAL_COLOR = '#4CAF50'
|
||||||
@@ -120,13 +122,13 @@ function renderMarkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(currentTheme, (theme) => {
|
watch(currentTheme, (theme) => {
|
||||||
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme))
|
if (imageOverlay) imageOverlay.setUrl(blueprintUrlFor(theme, mapConfig.currentlevelid))
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
MAP_WIDTH = mapConfig.width
|
MAP_WIDTH = dimensionsFor(mapConfig.currentlevelid).width
|
||||||
MAP_HEIGHT = mapConfig.height
|
MAP_HEIGHT = dimensionsFor(mapConfig.currentlevelid).height
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await printersApi.installList()
|
const response = await printersApi.installList()
|
||||||
@@ -142,7 +144,7 @@ onMounted(async () => {
|
|||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
})
|
||||||
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
|
||||||
imageOverlay = L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
imageOverlay = L.imageOverlay(blueprintUrlFor(currentTheme.value, mapConfig.currentlevelid), bounds).addTo(map)
|
||||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], -2)
|
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], -2)
|
||||||
map.setMaxBounds(bounds)
|
map.setMaxBounds(bounds)
|
||||||
renderMarkers()
|
renderMarkers()
|
||||||
|
|||||||
@@ -43,14 +43,13 @@
|
|||||||
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
||||||
>
|
>
|
||||||
<template v-if="page[pos - 1]">
|
<template v-if="page[pos - 1]">
|
||||||
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
<div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
|
||||||
<div class="info-inner">
|
<div class="info-inner">
|
||||||
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
|
<div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
|
||||||
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { renderQrDataUrl } from '@/utils/codes'
|
import { renderQrDataUrl } from '@/utils/codes'
|
||||||
import { buildQrUrl } from '@/utils/qrTarget'
|
import { buildQrUrl } from '@/utils/qrTarget'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
const printers = ref([])
|
const printers = ref([])
|
||||||
const selectedPrinters = ref([])
|
const selectedPrinters = ref([])
|
||||||
@@ -91,8 +91,10 @@ const pages = computed(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await printersApi.list({ perpage: 500 })
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
printers.value = response.data.data || []
|
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
||||||
|
// printer, not the first page of them.
|
||||||
|
printers.value = await printersApi.listAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading printers:', error)
|
console.error('Error loading printers:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -128,6 +130,29 @@ async function generateQRCodes() {
|
|||||||
qrImages.value = next
|
qrImages.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
|
||||||
|
// from the IP through the printer_hostname_template setting (ADR-015), which is
|
||||||
|
// how PrinterForm and the toner report derive it.
|
||||||
|
const hostnameTemplate = ref('')
|
||||||
|
|
||||||
|
function fqdnFor(item) {
|
||||||
|
const stored = item?.printer?.hostname
|
||||||
|
if (stored) return stored
|
||||||
|
const ip = getIp(item)
|
||||||
|
if (!ip || !hostnameTemplate.value) return ''
|
||||||
|
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
||||||
|
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
||||||
|
// queue name when one is set, otherwise the asset's name, and in practice most
|
||||||
|
// printers carry it as the assetnumber and nothing else, so that is the last
|
||||||
|
// fallback rather than a blank label. This is a name, not an identifier line:
|
||||||
|
// the label deliberately carries no separate "Asset #".
|
||||||
|
function labelName(item) {
|
||||||
|
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
|
||||||
|
}
|
||||||
|
|
||||||
function displayName(printer) {
|
function displayName(printer) {
|
||||||
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
||||||
}
|
}
|
||||||
@@ -285,7 +310,7 @@ function print() {
|
|||||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||||
.info-inner { text-align: left; }
|
.info-inner { text-align: left; }
|
||||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
||||||
.empty-label { color: #999; font-size: 14px; }
|
.empty-label { color: #999; font-size: 14px; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
|
|||||||
@@ -24,14 +24,13 @@
|
|||||||
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
||||||
>
|
>
|
||||||
<template v-if="pos === parseInt(position)">
|
<template v-if="pos === parseInt(position)">
|
||||||
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
|
<div class="csf-name">{{ labelName }}</div>
|
||||||
<div class="qr-container">
|
<div class="qr-container">
|
||||||
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info-section">
|
<div class="info-section">
|
||||||
<div class="csf-name">{{ printer.assetnumber }}</div>
|
|
||||||
<div class="info-inner">
|
<div class="info-inner">
|
||||||
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
|
<div v-if="fqdn" class="info-row">{{ fqdn }}</div>
|
||||||
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
|
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { renderQrDataUrl } from '@/utils/codes'
|
import { renderQrDataUrl } from '@/utils/codes'
|
||||||
import { buildQrUrl } from '@/utils/qrTarget'
|
import { buildQrUrl } from '@/utils/qrTarget'
|
||||||
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -58,6 +58,18 @@ const position = ref('1')
|
|||||||
// in print output, images print every time.
|
// in print output, images print every time.
|
||||||
const qrImage = ref('')
|
const qrImage = ref('')
|
||||||
|
|
||||||
|
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
||||||
|
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
||||||
|
// queue name when one is set, otherwise the asset's name, and in practice most
|
||||||
|
// printers carry it as the assetnumber and nothing else, so that is the last
|
||||||
|
// fallback rather than a blank label. This is a name, not an identifier line:
|
||||||
|
// the label deliberately carries no separate "Asset #".
|
||||||
|
const labelName = computed(() =>
|
||||||
|
printer.value?.printer?.windowsname
|
||||||
|
|| printer.value?.name
|
||||||
|
|| printer.value?.assetnumber
|
||||||
|
|| '')
|
||||||
|
|
||||||
const ipAddress = computed(() => {
|
const ipAddress = computed(() => {
|
||||||
// Check direct ipaddress field first (from list API)
|
// Check direct ipaddress field first (from list API)
|
||||||
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
|
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
|
||||||
@@ -67,8 +79,21 @@ const ipAddress = computed(() => {
|
|||||||
return primary?.ipaddress || primary?.address || null
|
return primary?.ipaddress || primary?.address || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The printer's FQDN. A stored hostname wins; otherwise the site builds one from
|
||||||
|
// the IP through the printer_hostname_template setting (ADR-015), the same way
|
||||||
|
// PrinterForm and the toner report derive it.
|
||||||
|
const hostnameTemplate = ref('')
|
||||||
|
|
||||||
|
const fqdn = computed(() => {
|
||||||
|
const stored = printer.value?.printer?.hostname
|
||||||
|
if (stored) return stored
|
||||||
|
if (!ipAddress.value || !hostnameTemplate.value) return ''
|
||||||
|
return hostnameTemplate.value.replace('{ip}', ipAddress.value.replace(/\./g, '-'))
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||||
const response = await printersApi.get(route.params.id)
|
const response = await printersApi.get(route.params.id)
|
||||||
printer.value = response.data.data
|
printer.value = response.data.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -169,7 +194,7 @@ function print() {
|
|||||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||||
.info-inner { text-align: left; }
|
.info-inner { text-align: left; }
|
||||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
/* Force rendered images/colors to print even when "Background graphics" is
|
/* Force rendered images/colors to print even when "Background graphics" is
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
v-if="printer.mapx != null && printer.mapy != null"
|
v-if="printer.mapx != null && printer.mapy != null"
|
||||||
:left="printer.mapx"
|
:left="printer.mapx"
|
||||||
:top="printer.mapy"
|
:top="printer.mapy"
|
||||||
|
:levelid="printer.levelid"
|
||||||
:machineName="printer.printername || printer.assetnumber"
|
:machineName="printer.printername || printer.assetnumber"
|
||||||
>
|
>
|
||||||
<router-link :to="`/printers/${printer.printerid}`">
|
<router-link :to="`/printers/${printer.printerid}`">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
"description": "Printer management plugin with Zabbix integration, supply tracking, and QR codes",
|
||||||
"author": "ShopDB Team",
|
"author": "ShopDB Team",
|
||||||
"dependencies": [],
|
"dependencies": [],
|
||||||
"core_version": ">=0.16.0,<1.0.0",
|
"core_version": ">=0.20.0,<1.0.0",
|
||||||
"api_prefix": "/api/printers",
|
"api_prefix": "/api/printers",
|
||||||
"provides": {
|
"provides": {
|
||||||
"machine_category": "Printer",
|
"machine_category": "Printer",
|
||||||
|
|||||||
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Add drivername to printerdrivers (exact INF driver name).
|
||||||
|
|
||||||
|
`location` points at the driver package; `name` is what a human calls it.
|
||||||
|
Add-PrinterDriver needs neither - it needs the driver name exactly as the INF
|
||||||
|
declares it ('HP Universal Printing PCL 6'), which nothing in the row carried.
|
||||||
|
Nullable: existing rows have no INF name until someone types it in.
|
||||||
|
|
||||||
|
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
|
||||||
|
column already exists (e.g. a test DB built by db.create_all() from the model).
|
||||||
|
|
||||||
|
Revision ID: printers0003drivername
|
||||||
|
Revises: printers0002supplyalerts
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'printers0003drivername'
|
||||||
|
down_revision = 'printers0002supplyalerts'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if 'printerdrivers' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||||
|
if 'drivername' not in columns:
|
||||||
|
op.add_column('printerdrivers',
|
||||||
|
sa.Column('drivername', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if 'printerdrivers' not in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||||
|
if 'drivername' in columns:
|
||||||
|
op.drop_column('printerdrivers', 'drivername')
|
||||||
44
plugins/printers/migrations/versions/0004_drivervendor.py
Normal file
44
plugins/printers/migrations/versions/0004_drivervendor.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Give a printer driver a vendor, so one row can serve a whole make.
|
||||||
|
|
||||||
|
HP's and Xerox's universal drivers cover 41 of the reference site's 44 printers
|
||||||
|
between them, but a driver could only be bound to ONE modelnumberid - so covering
|
||||||
|
them meant 21 near-duplicate rows all pointing at the same package, a table
|
||||||
|
nobody would keep true. A driver with no model and a vendor now serves every
|
||||||
|
printer of that make, and a per-model row still wins where one genuinely differs
|
||||||
|
(a plotter, a card printer, a label printer).
|
||||||
|
|
||||||
|
Nullable and guarded: a re-run is a no-op, and existing rows keep working
|
||||||
|
unchanged because model matching is still tried first.
|
||||||
|
|
||||||
|
Revision ID: printers0004drivervendor
|
||||||
|
Revises: printers0003drivername
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = 'printers0004drivervendor'
|
||||||
|
down_revision = 'printers0003drivername'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||||
|
if 'vendorid' not in columns:
|
||||||
|
op.add_column('printerdrivers', sa.Column('vendorid', sa.Integer(), nullable=True))
|
||||||
|
# No FK constraint: printerdrivers is a plugin table and vendors is core.
|
||||||
|
# ADR-008 keeps plugin chains from writing constraints across that line,
|
||||||
|
# and the resolver treats a vendor that no longer exists as no match.
|
||||||
|
op.create_index('idx_printerdriver_vendor', 'printerdrivers', ['vendorid'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||||
|
if 'vendorid' in columns:
|
||||||
|
op.drop_index('idx_printerdriver_vendor', table_name='printerdrivers')
|
||||||
|
op.drop_column('printerdrivers', 'vendorid')
|
||||||
@@ -11,6 +11,15 @@ class PrinterDriver(db.Model):
|
|||||||
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
||||||
location = db.Column(db.String(500), nullable=False)
|
location = db.Column(db.String(500), nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
|
# Exact driver name as the INF declares it: Add-PrinterDriver matches on
|
||||||
|
# this string, not on `name`, which is ours to choose
|
||||||
|
drivername = db.Column(db.String(255))
|
||||||
|
# Optional: attach a driver to a whole VENDOR rather than one model. A
|
||||||
|
# universal driver (HP UPD, Xerox GPD) serves every printer of that make, and
|
||||||
|
# binding it to one model would mean a near-duplicate row per model.
|
||||||
|
# Model wins over vendor when both match - see _printer_driver.
|
||||||
|
vendorid = db.Column(db.Integer, nullable=True, index=True)
|
||||||
|
|
||||||
# Optional: attach a driver to a specific printer model
|
# Optional: attach a driver to a specific printer model
|
||||||
modelnumberid = db.Column(
|
modelnumberid = db.Column(
|
||||||
db.Integer,
|
db.Integer,
|
||||||
@@ -27,6 +36,8 @@ class PrinterDriver(db.Model):
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'location': self.location,
|
'location': self.location,
|
||||||
'description': self.description,
|
'description': self.description,
|
||||||
|
'drivername': self.drivername,
|
||||||
|
'vendorid': self.vendorid,
|
||||||
'modelnumberid': self.modelnumberid,
|
'modelnumberid': self.modelnumberid,
|
||||||
'modelname': self.model.modelnumber if self.model else None,
|
'modelname': self.model.modelnumber if self.model else None,
|
||||||
'isactive': bool(self.isactive),
|
'isactive': bool(self.isactive),
|
||||||
|
|||||||
495
plugins/slides/client/EventSaver.cs
Normal file
495
plugins/slides/client/EventSaver.cs
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
// EventSaver - shopfloor event-advert screensaver.
|
||||||
|
// Two source modes, set in EventSaver.ini next to the .scr (no recompile):
|
||||||
|
// url=https://.../shopdb/api/slides/feed?surface=shopfloor -> pull from shopdb over
|
||||||
|
// HTTP, cache images locally, rotate the cache (no file share needed).
|
||||||
|
// folder=\\server\share\path -> read an SMB/local folder.
|
||||||
|
// url wins if both set. Strict order + per-slide seconds via order.txt (or the
|
||||||
|
// API's slides[].seconds). Cache survives a network blip (keeps last-good).
|
||||||
|
//
|
||||||
|
// Screensaver arg contract:
|
||||||
|
// /s show (fullscreen)
|
||||||
|
// /p <hwnd> preview (we no-op - keeps Windows happy)
|
||||||
|
// /c config (points user at the ini)
|
||||||
|
//
|
||||||
|
// Build (in-box .NET Framework, no SDK):
|
||||||
|
// C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^
|
||||||
|
// /target:winexe /out:EventSaver.scr ^
|
||||||
|
// /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^
|
||||||
|
// EventSaver.cs
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Drawing.Drawing2D;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Web.Script.Serialization;
|
||||||
|
using System.Windows.Forms;
|
||||||
|
|
||||||
|
namespace EventSaver
|
||||||
|
{
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
private static void Main(string[] args)
|
||||||
|
{
|
||||||
|
string mode = "/s";
|
||||||
|
if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim();
|
||||||
|
// strip a trailing ":hwnd" some callers append (e.g. /p:12345)
|
||||||
|
if (mode.StartsWith("/p")) mode = "/p";
|
||||||
|
if (mode.StartsWith("/c")) mode = "/c";
|
||||||
|
if (mode.StartsWith("/s")) mode = "/s";
|
||||||
|
|
||||||
|
if (mode == "/test")
|
||||||
|
{
|
||||||
|
// headless self-test: print the resolved playlist order and exit.
|
||||||
|
// lets CI / a display-less VM verify folder-read + order.txt + sort.
|
||||||
|
// winexe has no console in session 0, so write results to a
|
||||||
|
// file next to the exe (and Console too, for interactive runs).
|
||||||
|
Config tc = Config.Load();
|
||||||
|
string tf = tc.SourceFolder(true); // http mode: sync cache first
|
||||||
|
List<Slide> pl = Playlist.Build(tf, tc.Shuffle);
|
||||||
|
List<string> lines = new List<string>();
|
||||||
|
lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + " cache=" : "folder=") + tf);
|
||||||
|
lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle);
|
||||||
|
lines.Add("count=" + pl.Count);
|
||||||
|
for (int i = 0; i < pl.Count; i++)
|
||||||
|
lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds));
|
||||||
|
foreach (string l in lines) Console.WriteLine(l);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string outDir = Path.GetDirectoryName(Application.ExecutablePath);
|
||||||
|
File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray());
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == "/render")
|
||||||
|
{
|
||||||
|
// headless render check: draw the first slide onto a 1280x720
|
||||||
|
// black canvas with the same fit logic as OnPaint, save a PNG.
|
||||||
|
// Lets a display-less VM prove decode + letterbox actually work.
|
||||||
|
Config rc = Config.Load();
|
||||||
|
List<Slide> rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle);
|
||||||
|
string outPng = args.Length > 1 ? args[1] : Path.Combine(
|
||||||
|
Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png");
|
||||||
|
using (Bitmap canvas = new Bitmap(1280, 720))
|
||||||
|
using (Graphics g = Graphics.FromImage(canvas))
|
||||||
|
{
|
||||||
|
g.Clear(Color.Black);
|
||||||
|
if (rpl.Count > 0)
|
||||||
|
{
|
||||||
|
using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||||
|
using (Image img = Image.FromStream(fs))
|
||||||
|
{
|
||||||
|
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||||
|
Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size);
|
||||||
|
g.DrawImage(img, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == "/c")
|
||||||
|
{
|
||||||
|
MessageBox.Show(
|
||||||
|
"Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.",
|
||||||
|
"EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == "/p")
|
||||||
|
{
|
||||||
|
// preview pane - do nothing, exit clean
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
|
||||||
|
Config cfg = Config.Load();
|
||||||
|
|
||||||
|
// one form per screen: primary shows slideshow, others stay black
|
||||||
|
List<Form> forms = new List<Form>();
|
||||||
|
Screen primary = Screen.PrimaryScreen;
|
||||||
|
foreach (Screen scr in Screen.AllScreens)
|
||||||
|
{
|
||||||
|
bool isPrimary = scr.Equals(primary);
|
||||||
|
SaverForm f = new SaverForm(scr, isPrimary ? cfg : null);
|
||||||
|
forms.Add(f);
|
||||||
|
}
|
||||||
|
foreach (Form f in forms) f.Show();
|
||||||
|
|
||||||
|
// Keep the monitor awake while the screensaver shows, so a shorter
|
||||||
|
// monitor-sleep policy can't blank the ads out from under us. Held
|
||||||
|
// for the life of the message loop, released on exit.
|
||||||
|
SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED);
|
||||||
|
Application.Run(forms[0]);
|
||||||
|
SetThreadExecutionState(ES_CONTINUOUS);
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")]
|
||||||
|
private static extern uint SetThreadExecutionState(uint esFlags);
|
||||||
|
private const uint ES_CONTINUOUS = 0x80000000;
|
||||||
|
private const uint ES_DISPLAY_REQUIRED = 0x00000002;
|
||||||
|
private const uint ES_SYSTEM_REQUIRED = 0x00000001;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ config
|
||||||
|
internal sealed class Config
|
||||||
|
{
|
||||||
|
// No default: a share path belongs to a site, not to this program
|
||||||
|
// (ADR-015). Folder mode is the fallback for a site with no HTTP
|
||||||
|
// reach to ShopDB, and it must name its own path in EventSaver.ini.
|
||||||
|
public string Folder = "";
|
||||||
|
public string Url = ""; // set -> HTTP mode (pull from shopdb)
|
||||||
|
public string CacheDir = ""; // local cache for HTTP mode (computed)
|
||||||
|
public int IntervalSeconds = 10;
|
||||||
|
public bool Shuffle = false;
|
||||||
|
public int FadeMs = 600;
|
||||||
|
|
||||||
|
public static Config Load()
|
||||||
|
{
|
||||||
|
Config c = new Config();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string dir = Path.GetDirectoryName(Application.ExecutablePath);
|
||||||
|
string ini = Path.Combine(dir, "EventSaver.ini");
|
||||||
|
if (!File.Exists(ini)) return c;
|
||||||
|
|
||||||
|
foreach (string raw in File.ReadAllLines(ini))
|
||||||
|
{
|
||||||
|
string line = raw.Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
|
||||||
|
int eq = line.IndexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
string key = line.Substring(0, eq).Trim().ToLowerInvariant();
|
||||||
|
string val = line.Substring(eq + 1).Trim();
|
||||||
|
|
||||||
|
if (key == "url" && val.Length > 0) c.Url = val;
|
||||||
|
else if (key == "folder" && val.Length > 0) c.Folder = val;
|
||||||
|
else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; }
|
||||||
|
else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true");
|
||||||
|
else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* bad ini - fall back to defaults */ }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder the playlist reads: the local cache in HTTP mode (synced first
|
||||||
|
// when sync=true), else the configured share/folder. HTTP failures leave
|
||||||
|
// the last-good cache in place.
|
||||||
|
public string SourceFolder(bool sync)
|
||||||
|
{
|
||||||
|
if (Url.Length == 0) return Folder;
|
||||||
|
if (CacheDir.Length == 0)
|
||||||
|
CacheDir = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"EventSaver", "cache");
|
||||||
|
if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } }
|
||||||
|
return CacheDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- http sync
|
||||||
|
// Pull the slide list from shopdb (/api/slides/feed), download images into a
|
||||||
|
// local cache dir, and write order.txt there so the normal Playlist logic
|
||||||
|
// reads the cache exactly like a folder. Idempotent: only downloads images
|
||||||
|
// not already cached, prunes ones no longer listed, keeps last-good on error.
|
||||||
|
internal static class HttpSync
|
||||||
|
{
|
||||||
|
public static void Sync(string apiUrl, string cacheDir)
|
||||||
|
{
|
||||||
|
try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { }
|
||||||
|
if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir);
|
||||||
|
|
||||||
|
string json;
|
||||||
|
using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); }
|
||||||
|
|
||||||
|
JavaScriptSerializer js = new JavaScriptSerializer();
|
||||||
|
IDictionary<string, object> root = js.DeserializeObject(json) as IDictionary<string, object>;
|
||||||
|
if (root == null) return;
|
||||||
|
object ok;
|
||||||
|
if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return;
|
||||||
|
string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : "";
|
||||||
|
object slidesObj;
|
||||||
|
if (!root.TryGetValue("slides", out slidesObj)) return;
|
||||||
|
object[] arr = slidesObj as object[];
|
||||||
|
if (arr == null) return;
|
||||||
|
|
||||||
|
Uri apiUri = new Uri(apiUrl);
|
||||||
|
// The feed's basepath is host-absolute (/api/slides/img/...) and omits
|
||||||
|
// the app's mount (e.g. /shopdb) - the web client adds it via withBase,
|
||||||
|
// so we must too, else images resolve to the host root and 404. Derive
|
||||||
|
// the mount from the feed URL's path (everything before "/api/").
|
||||||
|
string mount = "";
|
||||||
|
int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx);
|
||||||
|
HashSet<string> keep = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
List<string> order = new List<string>();
|
||||||
|
order.Add("# generated by EventSaver from " + apiUrl);
|
||||||
|
|
||||||
|
foreach (object o in arr)
|
||||||
|
{
|
||||||
|
IDictionary<string, object> s = o as IDictionary<string, object>;
|
||||||
|
if (s == null) continue;
|
||||||
|
string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null;
|
||||||
|
if (string.IsNullOrEmpty(fn)) continue;
|
||||||
|
string safe = Path.GetFileName(fn); // strip any path component
|
||||||
|
if (safe.Length == 0) continue;
|
||||||
|
int secs = 0;
|
||||||
|
if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; }
|
||||||
|
|
||||||
|
string local = Path.Combine(cacheDir, safe);
|
||||||
|
if (!File.Exists(local))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Host-absolute basepath -> prepend the mount; a full URL passes through.
|
||||||
|
string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath;
|
||||||
|
Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe));
|
||||||
|
using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); }
|
||||||
|
}
|
||||||
|
catch { continue; } // couldn't fetch this one - skip it this round
|
||||||
|
}
|
||||||
|
keep.Add(safe);
|
||||||
|
order.Add(secs > 0 ? safe + "|" + secs : safe);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { }
|
||||||
|
|
||||||
|
// prune cache images no longer referenced
|
||||||
|
foreach (string f in Directory.GetFiles(cacheDir))
|
||||||
|
{
|
||||||
|
string n = Path.GetFileName(f);
|
||||||
|
if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
if (!keep.Contains(n)) { try { File.Delete(f); } catch { } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- playlist
|
||||||
|
// Builds ordered file list. order.txt wins (strict sequence, one name per
|
||||||
|
// line, optional "name|seconds" per-slide duration). Else sort by name.
|
||||||
|
internal sealed class Slide
|
||||||
|
{
|
||||||
|
public string Path;
|
||||||
|
public int Seconds; // 0 = use default interval
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class Playlist
|
||||||
|
{
|
||||||
|
private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" };
|
||||||
|
|
||||||
|
public static List<Slide> Build(string folder, bool shuffle)
|
||||||
|
{
|
||||||
|
List<Slide> list = new List<Slide>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(folder)) return list;
|
||||||
|
|
||||||
|
string orderFile = Path.Combine(folder, "order.txt");
|
||||||
|
if (File.Exists(orderFile))
|
||||||
|
{
|
||||||
|
// strict sequence from order.txt
|
||||||
|
foreach (string raw in File.ReadAllLines(orderFile))
|
||||||
|
{
|
||||||
|
string line = raw.Trim();
|
||||||
|
if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue;
|
||||||
|
int secs = 0;
|
||||||
|
string name = line;
|
||||||
|
int bar = line.IndexOf('|');
|
||||||
|
if (bar > 0)
|
||||||
|
{
|
||||||
|
name = line.Substring(0, bar).Trim();
|
||||||
|
int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n;
|
||||||
|
}
|
||||||
|
string full = Path.Combine(folder, name);
|
||||||
|
if (IsImage(full) && File.Exists(full))
|
||||||
|
list.Add(new Slide { Path = full, Seconds = secs });
|
||||||
|
}
|
||||||
|
return list; // order.txt is authoritative - do not append extras
|
||||||
|
}
|
||||||
|
|
||||||
|
// no order.txt - all images, sorted by filename
|
||||||
|
List<string> files = new List<string>();
|
||||||
|
foreach (string f in Directory.GetFiles(folder))
|
||||||
|
if (IsImage(f)) files.Add(f);
|
||||||
|
files.Sort(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (shuffle) Shuf(files);
|
||||||
|
foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 });
|
||||||
|
}
|
||||||
|
catch { /* share unreachable - return what we have (maybe empty) */ }
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsImage(string path)
|
||||||
|
{
|
||||||
|
string e = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
foreach (string x in Exts) if (x == e) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// deterministic-enough shuffle; screensaver so exact randomness irrelevant
|
||||||
|
private static void Shuf(List<string> l)
|
||||||
|
{
|
||||||
|
Random r = new Random();
|
||||||
|
for (int i = l.Count - 1; i > 0; i--)
|
||||||
|
{
|
||||||
|
int j = r.Next(i + 1);
|
||||||
|
string t = l[i]; l[i] = l[j]; l[j] = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- saver form
|
||||||
|
internal sealed class SaverForm : Form
|
||||||
|
{
|
||||||
|
private readonly Config cfg; // null on non-primary screens (black only)
|
||||||
|
private readonly Timer timer;
|
||||||
|
private List<Slide> slides = new List<Slide>();
|
||||||
|
private int idx = -1;
|
||||||
|
private Image current;
|
||||||
|
private Point lastMouse = Point.Empty;
|
||||||
|
private bool mouseSeen = false;
|
||||||
|
private DateTime lastScan = DateTime.MinValue;
|
||||||
|
|
||||||
|
public SaverForm(Screen screen, Config c)
|
||||||
|
{
|
||||||
|
cfg = c;
|
||||||
|
FormBorderStyle = FormBorderStyle.None;
|
||||||
|
Bounds = screen.Bounds;
|
||||||
|
StartPosition = FormStartPosition.Manual;
|
||||||
|
BackColor = Color.Black;
|
||||||
|
TopMost = true;
|
||||||
|
ShowInTaskbar = false;
|
||||||
|
DoubleBuffered = true;
|
||||||
|
Cursor.Hide();
|
||||||
|
|
||||||
|
KeyPreview = true;
|
||||||
|
// Left/Right step through the slides by hand; ANY other key still
|
||||||
|
// wakes the machine, which is what a screensaver must do. Without
|
||||||
|
// that exception an operator tapping an arrow to get back to work
|
||||||
|
// would be stuck watching slides.
|
||||||
|
KeyDown += (s, e) =>
|
||||||
|
{
|
||||||
|
if (e.KeyCode == Keys.Left) { e.Handled = true; Step(-1); return; }
|
||||||
|
if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1); return; }
|
||||||
|
Quit();
|
||||||
|
};
|
||||||
|
MouseDown += (s, e) => Quit();
|
||||||
|
MouseMove += OnMove;
|
||||||
|
|
||||||
|
if (cfg != null)
|
||||||
|
{
|
||||||
|
cfg.SourceFolder(true); // HTTP mode: initial sync + set CacheDir
|
||||||
|
Rescan();
|
||||||
|
timer = new Timer();
|
||||||
|
timer.Interval = 1000; // tick every second; advance when slide's time is up
|
||||||
|
timer.Tick += OnTick;
|
||||||
|
timer.Start();
|
||||||
|
Advance(); // show first immediately
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int slideElapsed = 0;
|
||||||
|
private DateTime lastSync = DateTime.Now; // ctor already did the first sync
|
||||||
|
private void OnTick(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
// HTTP mode: re-pull from shopdb every 60s so manager edits propagate.
|
||||||
|
if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60)
|
||||||
|
{
|
||||||
|
lastSync = DateTime.Now;
|
||||||
|
try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { }
|
||||||
|
lastScan = DateTime.MinValue; // force the rescan below
|
||||||
|
}
|
||||||
|
// periodic rescan so edits appear without restarting the saver
|
||||||
|
if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan();
|
||||||
|
|
||||||
|
slideElapsed++;
|
||||||
|
int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0)
|
||||||
|
? slides[idx].Seconds : cfg.IntervalSeconds;
|
||||||
|
if (slideElapsed >= want) Advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Rescan()
|
||||||
|
{
|
||||||
|
lastScan = DateTime.Now;
|
||||||
|
List<Slide> fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle);
|
||||||
|
slides = fresh;
|
||||||
|
if (idx >= slides.Count) idx = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Advance()
|
||||||
|
{
|
||||||
|
Step(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// delta of +1 is the timer advancing, -1 is the operator going back.
|
||||||
|
// Resets the dwell timer either way: stepping by hand and then having it
|
||||||
|
// move again a moment later, because the tick was nearly up, reads as
|
||||||
|
// the screensaver ignoring the keypress.
|
||||||
|
private void Step(int delta)
|
||||||
|
{
|
||||||
|
slideElapsed = 0;
|
||||||
|
if (slides.Count == 0) { SetImage(null); return; }
|
||||||
|
if (idx < 0) idx = (delta < 0) ? 0 : -1; // first Step lands on slide 0
|
||||||
|
idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// load without locking the file on the share
|
||||||
|
Image img;
|
||||||
|
using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||||
|
img = Image.FromStream(fs);
|
||||||
|
SetImage(img);
|
||||||
|
}
|
||||||
|
catch { SetImage(null); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetImage(Image img)
|
||||||
|
{
|
||||||
|
Image old = current;
|
||||||
|
current = img;
|
||||||
|
if (old != null) old.Dispose();
|
||||||
|
Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPaint(PaintEventArgs e)
|
||||||
|
{
|
||||||
|
e.Graphics.Clear(Color.Black);
|
||||||
|
if (current == null) return;
|
||||||
|
e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||||
|
Rectangle r = FitZoom(current.Size, ClientSize);
|
||||||
|
e.Graphics.DrawImage(current, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test hook - same math as OnPaint, exposed for the /render self-check.
|
||||||
|
public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); }
|
||||||
|
|
||||||
|
// scale image to fit while preserving aspect (letterbox)
|
||||||
|
private static Rectangle FitZoom(Size img, Size box)
|
||||||
|
{
|
||||||
|
if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height);
|
||||||
|
double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height);
|
||||||
|
int w = (int)(img.Width * s);
|
||||||
|
int h = (int)(img.Height * s);
|
||||||
|
return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMove(object sender, MouseEventArgs e)
|
||||||
|
{
|
||||||
|
// ignore the first synthetic move; require real movement to exit
|
||||||
|
if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; }
|
||||||
|
if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Quit()
|
||||||
|
{
|
||||||
|
try { Cursor.Show(); } catch { }
|
||||||
|
Application.Exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
plugins/slides/client/EventSaver.ini
Normal file
19
plugins/slides/client/EventSaver.ini
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# EventSaver config. Lives next to EventSaver.scr.
|
||||||
|
# Change these without recompiling. Screensaver re-reads on each launch.
|
||||||
|
|
||||||
|
# HTTP mode (recommended): pull slides from shopdb over HTTP, cache locally.
|
||||||
|
# No file share needed. Point at the shopdb slides feed (/api/slides/feed) for this
|
||||||
|
# surface. FIX THE BASE URL if the shopdb path differs on the live box.
|
||||||
|
url=https://shopdb.example.net/api/slides/feed?surface=shopfloor
|
||||||
|
|
||||||
|
# Folder mode (fallback): used only if url is blank. SMB/local path.
|
||||||
|
# folder=\\fileserver.example.net\share\tv\shopfloor
|
||||||
|
|
||||||
|
# Seconds per image (default when a slide has no per-slide time).
|
||||||
|
interval=10
|
||||||
|
|
||||||
|
# 1 = random order, 0 = ordered. Ignored when order.txt / API order is present.
|
||||||
|
shuffle=0
|
||||||
|
|
||||||
|
# Crossfade length in ms (0 = hard cut). Reserved - hard cut in v1.
|
||||||
|
fadems=600
|
||||||
@@ -145,8 +145,9 @@ const sheetPages = computed(() => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
|
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
|
||||||
try {
|
try {
|
||||||
const response = await usbApi.list({ perpage: 500 })
|
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
||||||
devices.value = response.data.data || []
|
// device, not the first page of them.
|
||||||
|
devices.value = await usbApi.listAll()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading USB devices:', error)
|
console.error('Error loading USB devices:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ def _related_machine(asset):
|
|||||||
# worth showing, so the caller decides what to do with a missing
|
# worth showing, so the caller decides what to do with a missing
|
||||||
# position rather than the row being dropped.
|
# position rather than the row being dropped.
|
||||||
'mapx': candidate.mapx,
|
'mapx': candidate.mapx,
|
||||||
|
'levelid': candidate.levelid,
|
||||||
'mapy': candidate.mapy,
|
'mapy': candidate.mapy,
|
||||||
'locationid': candidate.locationid,
|
'locationid': candidate.locationid,
|
||||||
'locationname': (candidate.location.locationname
|
'locationname': (candidate.location.locationname
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
v-if="machine && hasPosition"
|
v-if="machine && hasPosition"
|
||||||
:left="machine.mapx"
|
:left="machine.mapx"
|
||||||
:top="machine.mapy"
|
:top="machine.mapy"
|
||||||
|
:levelid="machine.levelid"
|
||||||
:machineName="machine.machinenumber || machine.name || ''"
|
:machineName="machine.machinenumber || machine.name || ''"
|
||||||
>
|
>
|
||||||
<span class="mmc-chip" :title="hoverTitle">
|
<span class="mmc-chip" :title="hoverTitle">
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ python3 "$REPO/scripts/generate_sbom.py" "$REPO" -o "$OUT/sbom.cdx.json"
|
|||||||
echo "==> Staging docs ..."
|
echo "==> Staging docs ..."
|
||||||
mkdir -p "$OUT/docs"
|
mkdir -p "$OUT/docs"
|
||||||
for doc in openapi.json llms.txt api-inventory.json \
|
for doc in openapi.json llms.txt api-inventory.json \
|
||||||
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md; do
|
INSTALL-WINDOWS.md OPERATE-WINDOWS.md BACKUP-RESTORE.md UPGRADE.md \
|
||||||
|
FLOOR-MAP.md; do
|
||||||
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
|
[ -f "$REPO/docs/$doc" ] && cp -a "$REPO/docs/$doc" "$OUT/docs/"
|
||||||
done
|
done
|
||||||
# Never let a missing optional doc fail the build - the loop's last test governs
|
# Never let a missing optional doc fail the build - the loop's last test governs
|
||||||
|
|||||||
@@ -158,6 +158,55 @@ if [ -n "$CODE_LIB_IMPORTS" ]; then
|
|||||||
VIOLATIONS=$((VIOLATIONS + 1))
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ADR-017: a marker position is pixels in ONE LEVEL's coordinate space, so a
|
||||||
|
# payload that emits mapx/mapy without levelid gives the consumer coordinates and
|
||||||
|
# no drawing to put them on. The consumer then either renders nothing or - worse,
|
||||||
|
# and what a reasonable implementation does - falls back to the default level,
|
||||||
|
# drawing one building's ground floor behind a marker positioned for another
|
||||||
|
# building's mezzanine. That renders perfectly and points at the wrong place.
|
||||||
|
#
|
||||||
|
# Eight files emit positions across twenty sites. This is the check that says
|
||||||
|
# whether all of them travel with their level, because reading them by eye is
|
||||||
|
# how the twentieth gets missed.
|
||||||
|
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
|
||||||
|
# PER OCCURRENCE, not per file. The file-level form passed a module that emitted
|
||||||
|
# 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
|
||||||
|
# the toner report and the enforcement report, both feeding a hover preview that
|
||||||
|
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
|
||||||
|
# the window is wide enough for a comment between them, and no wider.
|
||||||
|
MISSING_LEVEL=""
|
||||||
|
while IFS= read -r hit; do
|
||||||
|
[ -z "$hit" ] && continue
|
||||||
|
file=${hit%%:*}
|
||||||
|
line=${hit#*:}; line=${line%%:*}
|
||||||
|
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
|
||||||
|
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
|
||||||
|
fi
|
||||||
|
done <<EOF
|
||||||
|
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
|
||||||
|
EOF
|
||||||
|
if [ -n "$MISSING_LEVEL" ]; then
|
||||||
|
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
|
||||||
|
echo " level cannot be drawn on the right floor plan (ADR-017):"
|
||||||
|
echo "$MISSING_LEVEL" | sed 's/^/ /'
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The same rule for the hover preview: binding coordinates into
|
||||||
|
# LocationMapTooltip without a level makes it report "no level" for every asset,
|
||||||
|
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
|
||||||
|
TOOLTIP_MISSING=""
|
||||||
|
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
|
||||||
|
grep -q ':left=' "$candidate" || continue
|
||||||
|
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
|
||||||
|
done
|
||||||
|
if [ -n "$TOOLTIP_MISSING" ]; then
|
||||||
|
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
|
||||||
|
echo " preview cannot know which drawing to use (ADR-017):"
|
||||||
|
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
||||||
# count then did not move for weeks - a rule that only prints is read as no rule.
|
# count then did not move for weeks - a rule that only prints is read as no rule.
|
||||||
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
||||||
|
|||||||
@@ -100,6 +100,22 @@ class Harness:
|
|||||||
self.ids = IdMap(idmap_path or default)
|
self.ids = IdMap(idmap_path or default)
|
||||||
self.source = Source()
|
self.source = Source()
|
||||||
self.errors = []
|
self.errors = []
|
||||||
|
self._defaultlevelid = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def defaultlevelid(self):
|
||||||
|
"""The level imported map positions belong to (ADR-017).
|
||||||
|
|
||||||
|
The legacy schema predates levels: it has ONE floor plan, so every
|
||||||
|
mapleft/maptop it carries is a coordinate on this site's default level.
|
||||||
|
Importing them without a level produces markers the map refuses to draw
|
||||||
|
- it will not guess a drawing for coordinates that do not name one.
|
||||||
|
"""
|
||||||
|
if self._defaultlevelid is None:
|
||||||
|
from shopdb.core.models import MapLevel
|
||||||
|
level = MapLevel.default_level()
|
||||||
|
self._defaultlevelid = level.levelid if level else None
|
||||||
|
return self._defaultlevelid
|
||||||
|
|
||||||
def _silence_sql_logging(self):
|
def _silence_sql_logging(self):
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ def stage_assets(h):
|
|||||||
'serialnumber': (m['serialnumber'] or '').strip() or None,
|
'serialnumber': (m['serialnumber'] or '').strip() or None,
|
||||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||||
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||||
|
'levelid': h.defaultlevelid,
|
||||||
'notes': m['machinenotes'],
|
'notes': m['machinenotes'],
|
||||||
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
|
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
|
||||||
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
|
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
|
||||||
@@ -345,6 +346,7 @@ def stage_printers(h):
|
|||||||
'iscsf': _truthy_bit(p['iscsf']),
|
'iscsf': _truthy_bit(p['iscsf']),
|
||||||
'installpath': p['installpath'], 'pin': p['printerpin'],
|
'installpath': p['installpath'], 'pin': p['printerpin'],
|
||||||
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
|
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
|
||||||
|
'levelid': h.defaultlevelid,
|
||||||
'modelnumberid': h.ids.get('model', p['modelid']),
|
'modelnumberid': h.ids.get('model', p['modelid']),
|
||||||
'locationid': h.ids.get('location', p['machineid']),
|
'locationid': h.ids.get('location', p['machineid']),
|
||||||
}
|
}
|
||||||
@@ -386,7 +388,8 @@ def stage_metrology(h):
|
|||||||
'name': (f"{pcname} {toolname}").strip() or toolname,
|
'name': (f"{pcname} {toolname}").strip() or toolname,
|
||||||
'measuringtooltypeid': typeid,
|
'measuringtooltypeid': typeid,
|
||||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||||
'mapx': m['mapleft'], 'mapy': m['maptop']})
|
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||||
|
'levelid': h.defaultlevelid})
|
||||||
if status not in (200, 201):
|
if status not in (200, 201):
|
||||||
continue
|
continue
|
||||||
tool_assetid = _id_of(data, 'assetid')
|
tool_assetid = _id_of(data, 'assetid')
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ from .plugins import plugin_manager
|
|||||||
# permission / empty / position. BREAKING for any plugin still using the old
|
# permission / empty / position. BREAKING for any plugin still using the old
|
||||||
# shape, which is why it is recorded here: the change itself shipped earlier
|
# shape, which is why it is recorded here: the change itself shipped earlier
|
||||||
# without a bump, and a contract that changes silently is not a contract.
|
# without a bump, and a contract that changes silently is not a contract.
|
||||||
__contract_version__ = '0.19.0'
|
__contract_version__ = '0.20.0'
|
||||||
|
|
||||||
# Product release version (see ADR-007). The product version and the
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# plugin-contract version above are distinct series with independent
|
||||||
# bump rules. Not part of the shopdb.api contract surface, so it is
|
# bump rules. Not part of the shopdb.api contract surface, so it is
|
||||||
# not re-exported there.
|
# not re-exported there.
|
||||||
__version__ = '0.10.0'
|
__version__ = '0.11.3'
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_name: str = None) -> Flask:
|
def create_app(config_name: str = None) -> Flask:
|
||||||
@@ -89,6 +89,13 @@ def create_app(config_name: str = None) -> Flask:
|
|||||||
|
|
||||||
app.config.from_object(config_class)
|
app.config.from_object(config_class)
|
||||||
|
|
||||||
|
# The product and contract versions, in config so a PLUGIN can read them
|
||||||
|
# through current_app instead of importing core (ADR-002 forbids that, and
|
||||||
|
# the contract test enforces it). Core code may still use the module
|
||||||
|
# constants directly.
|
||||||
|
app.config['VERSION'] = __version__
|
||||||
|
app.config['CONTRACT_VERSION'] = __contract_version__
|
||||||
|
|
||||||
# Load instance config if exists
|
# Load instance config if exists
|
||||||
app.config.from_pyfile('config.py', silent=True)
|
app.config.from_pyfile('config.py', silent=True)
|
||||||
|
|
||||||
@@ -190,6 +197,8 @@ CORE_BLUEPRINT_NAMES = (
|
|||||||
'models',
|
'models',
|
||||||
'businessunits',
|
'businessunits',
|
||||||
'locations',
|
'locations',
|
||||||
|
'maplevels',
|
||||||
|
'mappositions',
|
||||||
'operatingsystems',
|
'operatingsystems',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'dashboarddefaults',
|
'dashboarddefaults',
|
||||||
|
|||||||
@@ -196,7 +196,10 @@ def _walk_related_for_position(asset, visited, depth):
|
|||||||
n_mapx = getattr(neighbor, 'mapx', None)
|
n_mapx = getattr(neighbor, 'mapx', None)
|
||||||
n_mapy = getattr(neighbor, 'mapy', None)
|
n_mapy = getattr(neighbor, 'mapy', None)
|
||||||
if n_mapx is not None and n_mapy is not None:
|
if n_mapx is not None and n_mapy is not None:
|
||||||
return (n_mapx, n_mapy)
|
# The neighbour's LEVEL travels with its coordinates. Returning the
|
||||||
|
# pair alone would leave the caller drawing a machine's position on
|
||||||
|
# whatever level the PC that borrowed it happens to claim.
|
||||||
|
return (n_mapx, n_mapy, getattr(neighbor, 'levelid', None))
|
||||||
|
|
||||||
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
|
recursed = _walk_related_for_position(neighbor, visited, depth + 1)
|
||||||
if recursed is not None:
|
if recursed is not None:
|
||||||
@@ -214,18 +217,28 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
|
|||||||
3. Asset's location coords (asset.location.mapx, .mapy)
|
3. Asset's location coords (asset.location.mapx, .mapy)
|
||||||
4. None (asset is unplaced, rendered in a tray)
|
4. None (asset is unplaced, rendered in a tray)
|
||||||
|
|
||||||
Returns a dict {'mapx', 'mapy', 'positionsource'} where positionsource
|
Returns a dict {'mapx', 'mapy', 'levelid', 'positionsource'} where
|
||||||
is one of 'self', 'related', 'location'. Returns None when no priority
|
positionsource is one of 'self', 'related', 'location'. Returns None when no
|
||||||
yields coordinates.
|
priority yields coordinates.
|
||||||
|
|
||||||
|
LEVELID COMES FROM WHICHEVER SOURCE SUPPLIED THE COORDINATES, not from the
|
||||||
|
asset (ADR-017). A PC with no position of its own that inherits from the
|
||||||
|
machine it controls is at the MACHINE's coordinates on the MACHINE's level;
|
||||||
|
using the PC's own level - which may be null, or may be a different building
|
||||||
|
entirely - would draw those coordinates on the wrong drawing, and the result
|
||||||
|
looks entirely reasonable.
|
||||||
"""
|
"""
|
||||||
mapx = getattr(asset, 'mapx', None)
|
mapx = getattr(asset, 'mapx', None)
|
||||||
mapy = getattr(asset, 'mapy', None)
|
mapy = getattr(asset, 'mapy', None)
|
||||||
if mapx is not None and mapy is not None:
|
if mapx is not None and mapy is not None:
|
||||||
return {'mapx': mapx, 'mapy': mapy, 'positionsource': 'self'}
|
return {'mapx': mapx, 'mapy': mapy,
|
||||||
|
'levelid': getattr(asset, 'levelid', None),
|
||||||
|
'positionsource': 'self'}
|
||||||
|
|
||||||
related = _walk_related_for_position(asset, set(), 0)
|
related = _walk_related_for_position(asset, set(), 0)
|
||||||
if related is not None:
|
if related is not None:
|
||||||
return {'mapx': related[0], 'mapy': related[1], 'positionsource': 'related'}
|
return {'mapx': related[0], 'mapy': related[1], 'levelid': related[2],
|
||||||
|
'positionsource': 'related'}
|
||||||
|
|
||||||
location = getattr(asset, 'location', None)
|
location = getattr(asset, 'location', None)
|
||||||
if location is not None:
|
if location is not None:
|
||||||
@@ -235,6 +248,7 @@ def resolve_asset_position(asset) -> Optional[Dict[str, Any]]:
|
|||||||
return {
|
return {
|
||||||
'mapx': loc_mapx,
|
'mapx': loc_mapx,
|
||||||
'mapy': loc_mapy,
|
'mapy': loc_mapy,
|
||||||
|
'levelid': getattr(location, 'levelid', None),
|
||||||
'positionsource': 'location',
|
'positionsource': 'location',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,12 +516,33 @@ def seed_reference_data():
|
|||||||
for at in adr_types:
|
for at in adr_types:
|
||||||
if not _lookup_binary(at['relationshiptype']):
|
if not _lookup_binary(at['relationshiptype']):
|
||||||
db.session.add(RelationshipType(**at))
|
db.session.add(RelationshipType(**at))
|
||||||
|
|
||||||
|
# Printer assignment edges. usesprinter = "this printer is installed here",
|
||||||
|
# defaultprinter = which of them is the default. Assignment lives on the
|
||||||
|
# MACHINE asset and reaches whichever PC controls it through the controls
|
||||||
|
# rail seeded below, so a reimaged PC gets its printers back with no
|
||||||
|
# restore step. Created before the rails: _seed_propagation no-ops in
|
||||||
|
# silence when either type is missing.
|
||||||
|
printer_types = [
|
||||||
|
{'relationshiptype': 'usesprinter',
|
||||||
|
'description': 'Asset to a printer installed on it (ADR-001)',
|
||||||
|
'isdirectional': True},
|
||||||
|
{'relationshiptype': 'defaultprinter',
|
||||||
|
'description': 'PC to its default printer (installer preselect, ADR-001)',
|
||||||
|
'isdirectional': True},
|
||||||
|
]
|
||||||
|
for pt in printer_types:
|
||||||
|
if not _lookup_binary(pt['relationshiptype']):
|
||||||
|
db.session.add(RelationshipType(**pt))
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# Seed `controls` propagation rails as M:N rows. controls -> partof
|
# Seed propagation rails as M:N rows. controls -> partof (declared;
|
||||||
# (declared; directional rail, not consumed yet) and controls -> Dualpath
|
# directional rail, not consumed yet) and controls -> Dualpath (consumed;
|
||||||
# (consumed; a dual-bay pair shares one controller so both bays carry
|
# a dual-bay pair shares one controller so both bays carry controls).
|
||||||
# controls). Idempotent, resolved by name, skipped if a type is missing.
|
# The two printer rails are READ-TIME only: the create-time fan-out skips
|
||||||
|
# directional through-types, and controls is directional, so a PC's own
|
||||||
|
# rows keep beating what it inherits from the machine it controls.
|
||||||
|
# Idempotent, resolved by name, skipped if a type is missing.
|
||||||
from shopdb.core.models.relationship import RelationshipTypePropagation
|
from shopdb.core.models.relationship import RelationshipTypePropagation
|
||||||
|
|
||||||
def _seed_propagation(sourcename, throughname):
|
def _seed_propagation(sourcename, throughname):
|
||||||
@@ -541,15 +562,8 @@ def seed_reference_data():
|
|||||||
|
|
||||||
_seed_propagation('controls', 'partof')
|
_seed_propagation('controls', 'partof')
|
||||||
_seed_propagation('controls', 'Dualpath')
|
_seed_propagation('controls', 'Dualpath')
|
||||||
|
_seed_propagation('usesprinter', 'controls')
|
||||||
# Default-printer link: a PC asset -> its default printer asset. Read by the
|
_seed_propagation('defaultprinter', 'controls')
|
||||||
# printer-installer endpoint (parity with classic apipcdefaultprinter.asp).
|
|
||||||
# Attribute-style edge, not a position rail, so no propagation.
|
|
||||||
if not _lookup_binary('defaultprinter'):
|
|
||||||
db.session.add(RelationshipType(
|
|
||||||
relationshiptype='defaultprinter',
|
|
||||||
description='PC to its default printer (installer preselect, ADR-001)'
|
|
||||||
))
|
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(click.style("Reference data seeded.", fg='green'))
|
click.echo(click.style("Reference data seeded.", fg='green'))
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .vendors import vendors_bp
|
|||||||
from .models import models_bp
|
from .models import models_bp
|
||||||
from .businessunits import businessunits_bp
|
from .businessunits import businessunits_bp
|
||||||
from .locations import locations_bp
|
from .locations import locations_bp
|
||||||
|
from .maplevels import maplevels_bp
|
||||||
|
from .mappositions import mappositions_bp
|
||||||
from .operatingsystems import operatingsystems_bp
|
from .operatingsystems import operatingsystems_bp
|
||||||
from .dashboard import dashboard_bp
|
from .dashboard import dashboard_bp
|
||||||
from .dashboarddefaults import dashboarddefaults_bp
|
from .dashboarddefaults import dashboarddefaults_bp
|
||||||
@@ -34,6 +36,8 @@ __all__ = [
|
|||||||
'models_bp',
|
'models_bp',
|
||||||
'businessunits_bp',
|
'businessunits_bp',
|
||||||
'locations_bp',
|
'locations_bp',
|
||||||
|
'maplevels_bp',
|
||||||
|
'mappositions_bp',
|
||||||
'operatingsystems_bp',
|
'operatingsystems_bp',
|
||||||
'dashboard_bp',
|
'dashboard_bp',
|
||||||
'dashboarddefaults_bp',
|
'dashboarddefaults_bp',
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ def create_asset():
|
|||||||
locationid=data.get('locationid'),
|
locationid=data.get('locationid'),
|
||||||
businessunitid=data.get('businessunitid'),
|
businessunitid=data.get('businessunitid'),
|
||||||
mapx=data.get('mapx'),
|
mapx=data.get('mapx'),
|
||||||
|
levelid=data.get('levelid'),
|
||||||
mapy=data.get('mapy'),
|
mapy=data.get('mapy'),
|
||||||
notes=data.get('notes')
|
notes=data.get('notes')
|
||||||
)
|
)
|
||||||
@@ -517,7 +518,7 @@ def update_asset(asset_id: int):
|
|||||||
# Update allowed fields
|
# Update allowed fields
|
||||||
allowed_fields = [
|
allowed_fields = [
|
||||||
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
'assetnumber', 'name', 'serialnumber', 'assettypeid', 'statusid',
|
||||||
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive'
|
'locationid', 'businessunitid', 'mapx', 'mapy', 'levelid', 'notes', 'isactive'
|
||||||
]
|
]
|
||||||
|
|
||||||
for key in allowed_fields:
|
for key in allowed_fields:
|
||||||
@@ -1114,6 +1115,7 @@ def get_assets_map():
|
|||||||
'displayname': asset.display_name,
|
'displayname': asset.display_name,
|
||||||
'serialnumber': asset.serialnumber,
|
'serialnumber': asset.serialnumber,
|
||||||
'mapx': asset.mapx,
|
'mapx': asset.mapx,
|
||||||
|
'levelid': asset.levelid,
|
||||||
'mapy': asset.mapy,
|
'mapy': asset.mapy,
|
||||||
'assettype': asset.assettype.assettype if asset.assettype else None,
|
'assettype': asset.assettype.assettype if asset.assettype else None,
|
||||||
'assettypeid': asset.assettypeid,
|
'assettypeid': asset.assettypeid,
|
||||||
|
|||||||
350
shopdb/core/api/maplevels.py
Normal file
350
shopdb/core/api/maplevels.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""Buildings and levels: the drawings a marker can be placed on (ADR-017).
|
||||||
|
|
||||||
|
Reads are UNAUTHENTICATED. The printer installer map runs before anyone logs in
|
||||||
|
and needs a blueprint to draw, exactly as the printer install-list and the slide
|
||||||
|
feed already do. A level name, a blueprint path and a pixel size are not
|
||||||
|
secrets - the marker positions drawn on them already render on kiosk pages.
|
||||||
|
|
||||||
|
Writes require admin: adding a level changes where every marker on it appears.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Blueprint, request, current_app
|
||||||
|
from flask_jwt_extended import jwt_required
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import Building, MapLevel, Asset, AuditLog
|
||||||
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.api import send_upload
|
||||||
|
from shopdb.utils.imagesize import image_size
|
||||||
|
|
||||||
|
maplevels_bp = Blueprint('maplevels', __name__)
|
||||||
|
|
||||||
|
# Same set the map blueprint upload already accepts. SVG stays allowed because a
|
||||||
|
# floor plan is vector by nature; it is served through send_upload, which sends
|
||||||
|
# the sandbox headers that stop one executing as script.
|
||||||
|
BLUEPRINT_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||||
|
|
||||||
|
|
||||||
|
def _blueprint_dir():
|
||||||
|
return os.path.join(current_app.instance_path, 'maps')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Read - public
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@maplevels_bp.route('', methods=['GET'])
|
||||||
|
def list_levels():
|
||||||
|
"""Every active level, grouped by building, in display order.
|
||||||
|
|
||||||
|
One call, because every consumer needs the whole list: the map switches
|
||||||
|
between levels, and a hover preview has to resolve an arbitrary asset's
|
||||||
|
level to a blueprint without knowing in advance which one it is.
|
||||||
|
"""
|
||||||
|
buildings = (Building.query.filter_by(isactive=True)
|
||||||
|
.order_by(Building.sortorder, Building.buildingid).all())
|
||||||
|
|
||||||
|
# How many markers sit on each level, in one grouped query rather than one
|
||||||
|
# per level. The admin page needs it to say what a rename or a resize is
|
||||||
|
# about to affect, and it is what makes the delete refusal predictable
|
||||||
|
# instead of a surprise.
|
||||||
|
counts = dict(db.session.query(Asset.levelid, db.func.count(Asset.assetid))
|
||||||
|
.filter(Asset.levelid.isnot(None), Asset.isactive.is_(True))
|
||||||
|
.group_by(Asset.levelid).all())
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for building in buildings:
|
||||||
|
entry = building.to_dict()
|
||||||
|
for level in entry.get('levels', []):
|
||||||
|
level['assetcount'] = counts.get(level['levelid'], 0)
|
||||||
|
payload.append(entry)
|
||||||
|
|
||||||
|
default = MapLevel.default_level()
|
||||||
|
return success_response({
|
||||||
|
'buildings': payload,
|
||||||
|
'defaultlevelid': default.levelid if default else None,
|
||||||
|
'totalplaced': sum(counts.values()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||||
|
def get_level(levelid):
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level or not level.isactive:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
return success_response(level.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||||
|
def serve_blueprint(filename, levelid=None):
|
||||||
|
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||||
|
return send_upload(_blueprint_dir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Write - admin
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@maplevels_bp.route('/buildings', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def create_building():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = (data.get('buildingname') or '').strip()
|
||||||
|
if not name:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'buildingname is required')
|
||||||
|
if Building.query.filter(db.func.lower(Building.buildingname)
|
||||||
|
== name.lower()).first():
|
||||||
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
|
f"Building '{name}' already exists",
|
||||||
|
http_code=409)
|
||||||
|
building = Building(buildingname=name,
|
||||||
|
sortorder=int(data.get('sortorder') or 0))
|
||||||
|
db.session.add(building)
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('created', 'Building', entityid=building.buildingid,
|
||||||
|
entityname=name)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(building.to_dict(), message='Building created',
|
||||||
|
http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/buildings/<int:buildingid>', methods=['PUT', 'PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def update_building(buildingid):
|
||||||
|
"""Rename or reorder a building. Levels move with it; nothing repositions."""
|
||||||
|
building = db.session.get(Building, buildingid)
|
||||||
|
if not building:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||||
|
http_code=404)
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if 'buildingname' in data:
|
||||||
|
name = (data['buildingname'] or '').strip()
|
||||||
|
if not name:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'buildingname cannot be blank')
|
||||||
|
clash = Building.query.filter(
|
||||||
|
db.func.lower(Building.buildingname) == name.lower(),
|
||||||
|
Building.buildingid != buildingid).first()
|
||||||
|
if clash:
|
||||||
|
return error_response(ErrorCodes.CONFLICT,
|
||||||
|
f"Building '{name}' already exists",
|
||||||
|
http_code=409)
|
||||||
|
building.buildingname = name
|
||||||
|
if data.get('sortorder') is not None:
|
||||||
|
building.sortorder = int(data['sortorder'])
|
||||||
|
if 'isactive' in data:
|
||||||
|
building.isactive = bool(data['isactive'])
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('updated', 'Building', entityid=buildingid,
|
||||||
|
entityname=building.buildingname)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(building.to_dict(), message='Building updated')
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def create_level():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = (data.get('levelname') or '').strip()
|
||||||
|
buildingid = data.get('buildingid')
|
||||||
|
if not name or not buildingid:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'buildingname and buildingid are required')
|
||||||
|
if not db.session.get(Building, buildingid):
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
level = MapLevel(
|
||||||
|
buildingid=buildingid,
|
||||||
|
levelname=name,
|
||||||
|
sortorder=int(data.get('sortorder') or 0),
|
||||||
|
blueprintlight=data.get('blueprintlight'),
|
||||||
|
blueprintdark=data.get('blueprintdark'),
|
||||||
|
mapwidth=int(data.get('mapwidth') or 3300),
|
||||||
|
mapheight=int(data.get('mapheight') or 2550),
|
||||||
|
)
|
||||||
|
db.session.add(level)
|
||||||
|
db.session.flush()
|
||||||
|
_apply_default(level, data.get('isdefault'))
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('created', 'MapLevel', entityid=level.levelid,
|
||||||
|
entityname=name)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(level.to_dict(), message='Level created',
|
||||||
|
http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>', methods=['PUT', 'PATCH'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def update_level(levelid):
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
# The dimensions are the coordinate space every marker on this level is
|
||||||
|
# expressed in, so changing them moves every marker relative to the image.
|
||||||
|
# Report it rather than doing it silently; the caller decides whether to run
|
||||||
|
# a transform (POST /api/assets/map/transform).
|
||||||
|
warnings = []
|
||||||
|
for field in ('mapwidth', 'mapheight'):
|
||||||
|
if field in data and int(data[field] or 0) != getattr(level, field):
|
||||||
|
warnings.append(
|
||||||
|
'%s changed from %s to %s; existing positions on this level are '
|
||||||
|
'still in the old coordinate space' % (field,
|
||||||
|
getattr(level, field),
|
||||||
|
data[field]))
|
||||||
|
|
||||||
|
for field in ('levelname', 'blueprintlight', 'blueprintdark'):
|
||||||
|
if field in data:
|
||||||
|
setattr(level, field, data[field])
|
||||||
|
for field in ('sortorder', 'mapwidth', 'mapheight'):
|
||||||
|
if field in data and data[field] is not None:
|
||||||
|
setattr(level, field, int(data[field]))
|
||||||
|
if 'isactive' in data:
|
||||||
|
level.isactive = bool(data['isactive'])
|
||||||
|
_apply_default(level, data.get('isdefault'))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('updated', 'MapLevel', entityid=level.levelid,
|
||||||
|
entityname=level.levelname)
|
||||||
|
db.session.commit()
|
||||||
|
payload = level.to_dict()
|
||||||
|
if warnings:
|
||||||
|
payload['warnings'] = warnings
|
||||||
|
return success_response(payload, message='Level updated')
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>', methods=['DELETE'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def delete_level(levelid):
|
||||||
|
"""Deactivate a level, refusing while assets are still placed on it.
|
||||||
|
|
||||||
|
Deleting the drawing out from under a marker would leave a position in a
|
||||||
|
coordinate space that no longer exists - unrenderable, and indistinguishable
|
||||||
|
from a marker that was never placed.
|
||||||
|
"""
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||||
|
if placed:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
f'{placed} asset(s) are placed on this level. Move them to another '
|
||||||
|
f'level first.', http_code=409)
|
||||||
|
if level.isdefault:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'This is the default level. Make another level the default first.')
|
||||||
|
level.isactive = False
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('deleted', 'MapLevel', entityid=levelid,
|
||||||
|
entityname=level.levelname)
|
||||||
|
db.session.commit()
|
||||||
|
return success_response(message='Level deactivated')
|
||||||
|
|
||||||
|
|
||||||
|
@maplevels_bp.route('/<int:levelid>/blueprint', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_role('admin')
|
||||||
|
def upload_blueprint(levelid):
|
||||||
|
"""Upload this level's blueprint for one theme.
|
||||||
|
|
||||||
|
multipart/form-data: file=<image>, theme=light|dark. The native pixel size
|
||||||
|
is NOT inferred from the image - it is stated on the level, because that is
|
||||||
|
what existing coordinates mean and guessing it would move every marker.
|
||||||
|
"""
|
||||||
|
level = db.session.get(MapLevel, levelid)
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such level',
|
||||||
|
http_code=404)
|
||||||
|
theme = (request.form.get('theme') or '').strip().lower()
|
||||||
|
if theme not in ('light', 'dark'):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'theme must be light or dark')
|
||||||
|
upload = request.files.get('file')
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||||
|
ext = os.path.splitext(upload.filename)[1].lower()
|
||||||
|
if ext not in BLUEPRINT_EXTENSIONS:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
f'Unsupported image type {ext}')
|
||||||
|
|
||||||
|
os.makedirs(_blueprint_dir(), exist_ok=True)
|
||||||
|
filename = secure_filename(f'level-{levelid}-{theme}{ext}')
|
||||||
|
raw = upload.read()
|
||||||
|
upload.seek(0)
|
||||||
|
upload.save(os.path.join(_blueprint_dir(), filename))
|
||||||
|
url = f'/api/maplevels/{levelid}/blueprint/{filename}'
|
||||||
|
setattr(level, f'blueprint{theme}', url)
|
||||||
|
|
||||||
|
# The image's real pixel size, read from its header. What happens next
|
||||||
|
# depends entirely on whether anything is already placed on this level.
|
||||||
|
detectedwidth, detectedheight = image_size(raw)
|
||||||
|
placed = Asset.query.filter_by(levelid=levelid, isactive=True).count()
|
||||||
|
sizenote = None
|
||||||
|
if detectedwidth and detectedheight:
|
||||||
|
matches = (detectedwidth == level.mapwidth
|
||||||
|
and detectedheight == level.mapheight)
|
||||||
|
if matches:
|
||||||
|
sizenote = None
|
||||||
|
elif not placed:
|
||||||
|
# Nothing is placed here yet, so no coordinate can be invalidated:
|
||||||
|
# adopt the image's own size, which is almost certainly what the
|
||||||
|
# operator wanted and saves them typing it.
|
||||||
|
level.mapwidth = detectedwidth
|
||||||
|
level.mapheight = detectedheight
|
||||||
|
sizenote = ('dimensions set from the image: %d x %d'
|
||||||
|
% (detectedwidth, detectedheight))
|
||||||
|
else:
|
||||||
|
# Markers exist in the OLD coordinate space. Silently adopting the
|
||||||
|
# new size would move every one of them relative to the drawing
|
||||||
|
# while looking like a successful upload, so this reports and
|
||||||
|
# changes nothing. Resizing is a transform, not an upload.
|
||||||
|
sizenote = (
|
||||||
|
'this image is %d x %d but the level is set to %d x %d, and %d '
|
||||||
|
'marker(s) are placed in the current space. The dimensions were '
|
||||||
|
'NOT changed: set them with a landmark transform '
|
||||||
|
'(POST /api/mappositions/transform) so the markers move with '
|
||||||
|
'them.' % (detectedwidth, detectedheight, level.mapwidth,
|
||||||
|
level.mapheight, placed))
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
AuditLog.log('updated', 'MapLevel', entityid=levelid,
|
||||||
|
entityname=level.levelname,
|
||||||
|
changes={f'blueprint{theme}': {'new': url}})
|
||||||
|
db.session.commit()
|
||||||
|
payload = {'levelid': levelid, f'blueprint{theme}': url,
|
||||||
|
'mapwidth': level.mapwidth, 'mapheight': level.mapheight,
|
||||||
|
'detectedwidth': detectedwidth, 'detectedheight': detectedheight,
|
||||||
|
'placedassets': placed}
|
||||||
|
if sizenote:
|
||||||
|
payload['sizenote'] = sizenote
|
||||||
|
return success_response(payload, message='Blueprint uploaded')
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_default(level, requested):
|
||||||
|
"""Make this level the default, clearing the flag elsewhere.
|
||||||
|
|
||||||
|
Exactly-one-default is a rule across rows, which no column constraint can
|
||||||
|
express, so it is enforced here - the one place that sets the flag.
|
||||||
|
"""
|
||||||
|
if not requested:
|
||||||
|
return
|
||||||
|
MapLevel.query.filter(MapLevel.levelid != level.levelid).update(
|
||||||
|
{'isdefault': False})
|
||||||
|
level.isdefault = True
|
||||||
403
shopdb/core/api/mappositions.py
Normal file
403
shopdb/core/api/mappositions.py
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
"""Bulk marker positions: transform, place, verify, undo (ADR-017).
|
||||||
|
|
||||||
|
A new blueprint invalidates every marker on a level at once, so the operations
|
||||||
|
here are deliberately bulk. Three rules shape all of them:
|
||||||
|
|
||||||
|
**A transform is derived from landmarks, never from image sizes.** The real case
|
||||||
|
that motivated this is 3300x2550 to 3308x4000, where the second level was added
|
||||||
|
below the first: the correct transform is identity scale with a Y offset, and a
|
||||||
|
scale derived from the dimension ratio would stretch every Y by 1.57 and be wrong
|
||||||
|
everywhere. Dimensions describe the canvas; landmarks describe the drawing.
|
||||||
|
|
||||||
|
**A transform is a guess, so it clears the review state.** The levels were
|
||||||
|
redrawn and machines moved. Nothing in a coordinate says whether the machine it
|
||||||
|
points at is still there, so every transformed marker is unreviewed until a human
|
||||||
|
confirms it.
|
||||||
|
|
||||||
|
**Nothing bulk happens without a snapshot.** Positions have no history, and an
|
||||||
|
operation that rewrites hundreds of rows needs a way back that is not a database
|
||||||
|
restore.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, request
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import (Asset, AuditLog, MapLevel,
|
||||||
|
MapPositionSnapshot, User)
|
||||||
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
|
mappositions_bp = Blueprint('mappositions', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _actor():
|
||||||
|
try:
|
||||||
|
user = db.session.get(User, int(get_jwt_identity()))
|
||||||
|
return user.username if user else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# The transform
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def solve_axis(pairs):
|
||||||
|
"""Least-squares scale and offset for one axis: new = scale * old + offset.
|
||||||
|
|
||||||
|
Two pairs solve it exactly; more are averaged, which is worth having because
|
||||||
|
a landmark picked by eye on a scanned drawing carries a few pixels of error
|
||||||
|
and three points let that cancel instead of accumulate.
|
||||||
|
|
||||||
|
Returns None when the landmarks cannot determine the axis - every old value
|
||||||
|
identical, so the denominator is zero. That is a real user error (two
|
||||||
|
landmarks on the same column) and it must be reported rather than papered
|
||||||
|
over with a scale of 1, which would look like it worked.
|
||||||
|
"""
|
||||||
|
count = len(pairs)
|
||||||
|
if count < 2:
|
||||||
|
return None
|
||||||
|
sumold = sum(old for old, _ in pairs)
|
||||||
|
sumnew = sum(new for _, new in pairs)
|
||||||
|
sumoldsq = sum(old * old for old, _ in pairs)
|
||||||
|
sumcross = sum(old * new for old, new in pairs)
|
||||||
|
denominator = count * sumoldsq - sumold * sumold
|
||||||
|
if abs(denominator) < 1e-9:
|
||||||
|
return None
|
||||||
|
scale = (count * sumcross - sumold * sumnew) / denominator
|
||||||
|
offset = (sumnew - scale * sumold) / count
|
||||||
|
return scale, offset
|
||||||
|
|
||||||
|
|
||||||
|
def derive_transform(landmarks):
|
||||||
|
"""Per-axis transform from [{'fromx','fromy','tox','toy'}, ...].
|
||||||
|
|
||||||
|
Per-axis rather than uniform on purpose. A level added below another changes
|
||||||
|
the canvas height without rescaling anything, so Y gets an offset and X gets
|
||||||
|
neither; forcing one scale onto both axes cannot express that.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
xpairs = [(float(mark['fromx']), float(mark['tox'])) for mark in landmarks]
|
||||||
|
ypairs = [(float(mark['fromy']), float(mark['toy'])) for mark in landmarks]
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None, 'each landmark needs numeric fromx, fromy, tox and toy'
|
||||||
|
|
||||||
|
if len(landmarks) < 2:
|
||||||
|
return None, 'at least two landmarks are required'
|
||||||
|
|
||||||
|
xsolution = solve_axis(xpairs)
|
||||||
|
ysolution = solve_axis(ypairs)
|
||||||
|
if xsolution is None:
|
||||||
|
return None, ('the landmarks do not vary in X, so no horizontal scale '
|
||||||
|
'can be derived - pick points that differ across the drawing')
|
||||||
|
if ysolution is None:
|
||||||
|
return None, ('the landmarks do not vary in Y, so no vertical scale '
|
||||||
|
'can be derived - pick points that differ down the drawing')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'scalex': xsolution[0], 'offsetx': xsolution[1],
|
||||||
|
'scaley': ysolution[0], 'offsety': ysolution[1],
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_transform(asset, transform):
|
||||||
|
return (
|
||||||
|
int(round(asset.mapx * transform['scalex'] + transform['offsetx'])),
|
||||||
|
int(round(asset.mapy * transform['scaley'] + transform['offsety'])),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mappositions_bp.route('/transform', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def transform_positions():
|
||||||
|
"""Move every placed marker on a level by a transform read off landmarks.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
levelid the level whose markers are being moved (required)
|
||||||
|
landmarks [{fromx, fromy, tox, toy}, ...] - two or more points, each the
|
||||||
|
same physical feature on the old drawing and the new one
|
||||||
|
tolevelid optional: write the results to a different level, for splitting
|
||||||
|
one drawing into two
|
||||||
|
assetids optional: restrict to these assets
|
||||||
|
dryrun default TRUE. Nothing is written unless this is explicitly false.
|
||||||
|
|
||||||
|
Dry run returns every marker's old and new position and whether it lands
|
||||||
|
outside the target level, which is the only way to see a bad landmark pair
|
||||||
|
before it has moved 300 markers.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
levelid = data.get('levelid')
|
||||||
|
level = db.session.get(MapLevel, levelid) if levelid else None
|
||||||
|
if not level:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'levelid is required and must exist')
|
||||||
|
|
||||||
|
target = level
|
||||||
|
if data.get('tolevelid'):
|
||||||
|
target = db.session.get(MapLevel, data['tolevelid'])
|
||||||
|
if not target:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such tolevelid',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
transform, problem = derive_transform(data.get('landmarks') or [])
|
||||||
|
if problem:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, problem)
|
||||||
|
|
||||||
|
query = Asset.query.filter(
|
||||||
|
Asset.levelid == level.levelid,
|
||||||
|
Asset.mapx.isnot(None), Asset.mapy.isnot(None),
|
||||||
|
Asset.isactive.is_(True))
|
||||||
|
if data.get('assetids'):
|
||||||
|
query = query.filter(Asset.assetid.in_(data['assetids']))
|
||||||
|
assets = query.order_by(Asset.assetid).all()
|
||||||
|
|
||||||
|
moves = []
|
||||||
|
outofbounds = 0
|
||||||
|
for asset in assets:
|
||||||
|
newx, newy = apply_transform(asset, transform)
|
||||||
|
outside = not (0 <= newx <= target.mapwidth and
|
||||||
|
0 <= newy <= target.mapheight)
|
||||||
|
if outside:
|
||||||
|
outofbounds += 1
|
||||||
|
moves.append({
|
||||||
|
'assetid': asset.assetid,
|
||||||
|
'assetnumber': asset.assetnumber,
|
||||||
|
'name': asset.name,
|
||||||
|
'fromx': asset.mapx, 'fromy': asset.mapy,
|
||||||
|
'tox': newx, 'toy': newy,
|
||||||
|
'outofbounds': outside,
|
||||||
|
})
|
||||||
|
|
||||||
|
# The derived transform is reported back whichever mode this is, because it
|
||||||
|
# is the number a human can sanity-check: a Y scale of 1.57 on a level that
|
||||||
|
# only grew taller is the mistake this endpoint exists to avoid, and it is
|
||||||
|
# obvious in the response and invisible in the result.
|
||||||
|
payload = {
|
||||||
|
'transform': transform,
|
||||||
|
'levelid': level.levelid,
|
||||||
|
'tolevelid': target.levelid,
|
||||||
|
'assetcount': len(moves),
|
||||||
|
'outofboundscount': outofbounds,
|
||||||
|
'moves': moves,
|
||||||
|
'dryrun': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.get('dryrun', True):
|
||||||
|
return success_response(payload)
|
||||||
|
|
||||||
|
snapshot = MapPositionSnapshot.capture(
|
||||||
|
assets,
|
||||||
|
reason='transform of %s (%d markers)' % (level.levelname, len(moves)),
|
||||||
|
levelid=level.levelid, createdby=_actor())
|
||||||
|
|
||||||
|
for asset, move in zip(assets, moves):
|
||||||
|
asset.mapx = move['tox']
|
||||||
|
asset.mapy = move['toy']
|
||||||
|
asset.levelid = target.levelid
|
||||||
|
# Cleared, not preserved: a transformed position is a guess, and the
|
||||||
|
# whole point of the review pass is to tell guesses from confirmations.
|
||||||
|
asset.mapverifiedat = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Asset', entityname='%d marker(s) transformed'
|
||||||
|
% len(moves),
|
||||||
|
changes={'transform': transform,
|
||||||
|
'snapshotid': snapshot.snapshotid})
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
payload['dryrun'] = False
|
||||||
|
payload['snapshotid'] = snapshot.snapshotid
|
||||||
|
return success_response(payload, message='%d marker(s) moved; snapshot %d '
|
||||||
|
'can restore them' % (len(moves),
|
||||||
|
snapshot.snapshotid))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Bulk place and verify
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@mappositions_bp.route('/positions', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def set_positions():
|
||||||
|
"""Set positions for many assets at once.
|
||||||
|
|
||||||
|
Body: {positions: [{assetid, mapx, mapy, levelid}], verified: bool}
|
||||||
|
|
||||||
|
`levelid` is required per position rather than taken from a single body-level
|
||||||
|
value, because a bulk save from the editor can legitimately span levels, and
|
||||||
|
inferring it would be the guess this whole feature exists to remove.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
rows = data.get('positions') or []
|
||||||
|
if not rows:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'positions is required and must not be empty')
|
||||||
|
|
||||||
|
wanted = {}
|
||||||
|
for row in rows:
|
||||||
|
assetid = row.get('assetid')
|
||||||
|
levelid = row.get('levelid')
|
||||||
|
if not assetid or not levelid:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'every position needs an assetid and a levelid; a position '
|
||||||
|
'without a level cannot be rendered')
|
||||||
|
if row.get('mapx') is None or row.get('mapy') is None:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'every position needs mapx and mapy')
|
||||||
|
wanted[int(assetid)] = row
|
||||||
|
|
||||||
|
levelids = {int(row['levelid']) for row in wanted.values()}
|
||||||
|
known = {level.levelid for level in
|
||||||
|
MapLevel.query.filter(MapLevel.levelid.in_(levelids)).all()}
|
||||||
|
missing = levelids - known
|
||||||
|
if missing:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'unknown levelid(s): %s'
|
||||||
|
% ', '.join(str(one) for one in sorted(missing)),
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
assets = Asset.query.filter(Asset.assetid.in_(wanted)).all()
|
||||||
|
found = {asset.assetid for asset in assets}
|
||||||
|
unknown = set(wanted) - found
|
||||||
|
if unknown:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'unknown assetid(s): %s'
|
||||||
|
% ', '.join(str(one) for one in sorted(unknown)),
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
snapshot = MapPositionSnapshot.capture(
|
||||||
|
assets, reason='bulk position set (%d markers)' % len(assets),
|
||||||
|
createdby=_actor())
|
||||||
|
|
||||||
|
verified = _now() if data.get('verified') else None
|
||||||
|
for asset in assets:
|
||||||
|
row = wanted[asset.assetid]
|
||||||
|
asset.mapx = int(row['mapx'])
|
||||||
|
asset.mapy = int(row['mapy'])
|
||||||
|
asset.levelid = int(row['levelid'])
|
||||||
|
# Placing a marker by hand IS the confirmation, so this stamps it. A
|
||||||
|
# caller that is only nudging things about can pass verified=false.
|
||||||
|
if verified or data.get('verified') is not False:
|
||||||
|
asset.mapverifiedat = verified or _now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Asset',
|
||||||
|
entityname='%d marker position(s) set' % len(assets),
|
||||||
|
changes={'snapshotid': snapshot.snapshotid})
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'updated': len(assets),
|
||||||
|
'snapshotid': snapshot.snapshotid},
|
||||||
|
message='%d position(s) saved' % len(assets))
|
||||||
|
|
||||||
|
|
||||||
|
@mappositions_bp.route('/verify', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def verify_positions():
|
||||||
|
"""Mark markers as reviewed against the current drawing without moving them.
|
||||||
|
|
||||||
|
This is the common case in a review pass: the transform put it in the right
|
||||||
|
place and a human agrees. No snapshot, because nothing about the position
|
||||||
|
changes - only the statement that somebody looked.
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
assetids = data.get('assetids') or []
|
||||||
|
if not assetids:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'assetids is required')
|
||||||
|
stamp = None if data.get('unverify') else _now()
|
||||||
|
updated = (Asset.query.filter(Asset.assetid.in_(assetids))
|
||||||
|
.update({'mapverifiedat': stamp}, synchronize_session=False))
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'updated': updated},
|
||||||
|
message='%d marker(s) %s' %
|
||||||
|
(updated, 'unverified' if stamp is None
|
||||||
|
else 'marked reviewed'))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Undo
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@mappositions_bp.route('/snapshots', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.view')
|
||||||
|
def list_snapshots():
|
||||||
|
"""Snapshots, newest first. Metadata only - the positions are large."""
|
||||||
|
snapshots = (MapPositionSnapshot.query
|
||||||
|
.order_by(MapPositionSnapshot.snapshotid.desc())
|
||||||
|
.limit(50).all())
|
||||||
|
return success_response([one.to_dict() for one in snapshots])
|
||||||
|
|
||||||
|
|
||||||
|
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('assets.edit')
|
||||||
|
def restore_snapshot(snapshotid):
|
||||||
|
"""Put the positions in this snapshot back.
|
||||||
|
|
||||||
|
Takes its own snapshot first, so an undo is itself undoable - which matters
|
||||||
|
because the most likely reason to restore is a transform that looked right in
|
||||||
|
the preview and wrong on the drawing, and the second attempt is rarely the
|
||||||
|
last one either.
|
||||||
|
"""
|
||||||
|
snapshot = db.session.get(MapPositionSnapshot, snapshotid)
|
||||||
|
if not snapshot:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such snapshot',
|
||||||
|
http_code=404)
|
||||||
|
|
||||||
|
rows = snapshot.positions
|
||||||
|
if not rows:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'this snapshot holds no positions')
|
||||||
|
|
||||||
|
assetids = [row['assetid'] for row in rows]
|
||||||
|
assets = {asset.assetid: asset for asset in
|
||||||
|
Asset.query.filter(Asset.assetid.in_(assetids)).all()}
|
||||||
|
|
||||||
|
MapPositionSnapshot.capture(
|
||||||
|
list(assets.values()),
|
||||||
|
reason='before restoring snapshot %d' % snapshotid,
|
||||||
|
levelid=snapshot.levelid, createdby=_actor())
|
||||||
|
|
||||||
|
restored = 0
|
||||||
|
skipped = []
|
||||||
|
for row in rows:
|
||||||
|
asset = assets.get(row['assetid'])
|
||||||
|
if asset is None:
|
||||||
|
# The asset was deleted since the snapshot. Say so rather than
|
||||||
|
# failing the whole restore: the other 299 markers still want
|
||||||
|
# putting back.
|
||||||
|
skipped.append(row['assetid'])
|
||||||
|
continue
|
||||||
|
asset.mapx = row.get('mapx')
|
||||||
|
asset.mapy = row.get('mapy')
|
||||||
|
asset.levelid = row.get('levelid')
|
||||||
|
stamp = row.get('mapverifiedat')
|
||||||
|
asset.mapverifiedat = datetime.fromisoformat(stamp) if stamp else None
|
||||||
|
restored += 1
|
||||||
|
|
||||||
|
snapshot.restoredat = _now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
AuditLog.log('updated', 'Asset',
|
||||||
|
entityname='%d marker(s) restored from snapshot %d'
|
||||||
|
% (restored, snapshotid),
|
||||||
|
changes={'snapshotid': snapshotid, 'skipped': skipped})
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
{'restored': restored, 'skippedassetids': skipped},
|
||||||
|
message='%d marker(s) restored%s' %
|
||||||
|
(restored, '; %d asset(s) no longer exist' % len(skipped)
|
||||||
|
if skipped else ''))
|
||||||
@@ -23,6 +23,14 @@ logger = logging.getLogger(__name__)
|
|||||||
search_bp = Blueprint('search', __name__)
|
search_bp = Blueprint('search', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# EVERY searcher below truncates with .limit(). An unordered LIMIT lets the
|
||||||
|
# database return a DIFFERENT subset of the matching rows between two identical
|
||||||
|
# requests - the same search run twice came back with different hits, which reads
|
||||||
|
# as the search being broken rather than as a missing ORDER BY. Each query
|
||||||
|
# therefore ends with a TOTAL order: a display key plus the primary key, so ties
|
||||||
|
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
|
||||||
|
|
||||||
|
|
||||||
def _word_match(query, *columns):
|
def _word_match(query, *columns):
|
||||||
"""SQL clause matching rows that contain EVERY word of the query, each word
|
"""SQL clause matching rows that contain EVERY word of the query, each word
|
||||||
in any of the columns, in any order.
|
in any of the columns, in any order.
|
||||||
@@ -213,7 +221,7 @@ def _search_applications(query, search_term):
|
|||||||
apps = Application.query.filter(
|
apps = Application.query.filter(
|
||||||
Application.isactive == True,
|
Application.isactive == True,
|
||||||
_word_match(query, Application.appname, Application.appdescription)
|
_word_match(query, Application.appname, Application.appdescription)
|
||||||
).limit(10).all()
|
).order_by(Application.appname, Application.appid).limit(10).all()
|
||||||
|
|
||||||
for app in apps:
|
for app in apps:
|
||||||
relevance = 20
|
relevance = 20
|
||||||
@@ -236,16 +244,30 @@ def _search_applications(query, search_term):
|
|||||||
|
|
||||||
|
|
||||||
def _search_knowledgebase(query, search_term):
|
def _search_knowledgebase(query, search_term):
|
||||||
"""Search Knowledge Base by description and keywords."""
|
"""Search Knowledge Base by description and keywords.
|
||||||
|
|
||||||
|
An article whose topic is a RETIRED application is excluded, matching
|
||||||
|
GET /api/knowledgebase. Filtering it out of the plugin's own listing while
|
||||||
|
global search still returned it is not a rule at all: the article was two
|
||||||
|
keystrokes away, and the result printed the retired application's name as its
|
||||||
|
subject, which reads as though it were still in service.
|
||||||
|
|
||||||
|
A null topic still matches. Not every article is about an application.
|
||||||
|
"""
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
_require_enabled('knowledgebase')
|
_require_enabled('knowledgebase')
|
||||||
from plugins.knowledgebase.models import KnowledgeBase
|
from plugins.knowledgebase.models import KnowledgeBase
|
||||||
|
retired = db.session.query(Application.appid).filter(
|
||||||
|
Application.isactive.is_(False))
|
||||||
kb_articles = KnowledgeBase.query.filter(
|
kb_articles = KnowledgeBase.query.filter(
|
||||||
KnowledgeBase.isactive == True,
|
KnowledgeBase.isactive == True,
|
||||||
|
db.or_(KnowledgeBase.appid.is_(None),
|
||||||
|
KnowledgeBase.appid.notin_(retired)),
|
||||||
_word_match(query, KnowledgeBase.shortdescription,
|
_word_match(query, KnowledgeBase.shortdescription,
|
||||||
KnowledgeBase.keywords)
|
KnowledgeBase.keywords)
|
||||||
).limit(20).all()
|
).order_by(KnowledgeBase.clicks.desc(),
|
||||||
|
KnowledgeBase.linkid).limit(20).all()
|
||||||
|
|
||||||
for kb in kb_articles:
|
for kb in kb_articles:
|
||||||
relevance = 10 + (kb.clicks or 0) * 0.1
|
relevance = 10 + (kb.clicks or 0) * 0.1
|
||||||
@@ -339,7 +361,22 @@ def _search_employees(query, search_term):
|
|||||||
|
|
||||||
|
|
||||||
def _search_assets(query, search_term):
|
def _search_assets(query, search_term):
|
||||||
"""Search unified Assets table by number, name, serial, notes."""
|
"""Search unified Assets table by number, name, serial, notes and the two
|
||||||
|
optional identifiers.
|
||||||
|
|
||||||
|
gaugelabreference and maintenancereference are searched for EVERY asset type
|
||||||
|
(ADR-001). Settings lets a site enable either identifier on machines, PCs,
|
||||||
|
printers and network devices, but only the measuring-tools searcher looked at
|
||||||
|
gaugelabreference and nothing looked at maintenancereference at all - so a
|
||||||
|
tag an operator was told to record was one nobody could search by. An
|
||||||
|
identifier that can be entered has to be findable, or it is a write-only
|
||||||
|
field.
|
||||||
|
|
||||||
|
The per-type `identifier_<name>_<assettype>_enabled` toggles are NOT applied
|
||||||
|
here. They govern whether the field is SHOWN on that type; a value already in
|
||||||
|
the row is still the tag written on the physical machine, and matching it is
|
||||||
|
strictly better than returning nothing to someone reading it off a label.
|
||||||
|
"""
|
||||||
results = []
|
results = []
|
||||||
try:
|
try:
|
||||||
assets = Asset.query.join(AssetType).options(
|
assets = Asset.query.join(AssetType).options(
|
||||||
@@ -348,8 +385,9 @@ def _search_assets(query, search_term):
|
|||||||
).filter(
|
).filter(
|
||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Asset.assetnumber, Asset.name,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.notes)
|
Asset.serialnumber, Asset.notes,
|
||||||
).limit(15).all()
|
Asset.gaugelabreference, Asset.maintenancereference)
|
||||||
|
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
@@ -357,6 +395,10 @@ def _search_assets(query, search_term):
|
|||||||
relevance = 100
|
relevance = 100
|
||||||
elif asset.name and query.lower() == asset.name.lower():
|
elif asset.name and query.lower() == asset.name.lower():
|
||||||
relevance = 90
|
relevance = 90
|
||||||
|
elif asset.gaugelabreference and query.lower() == asset.gaugelabreference.lower():
|
||||||
|
relevance = 88
|
||||||
|
elif asset.maintenancereference and query.lower() == asset.maintenancereference.lower():
|
||||||
|
relevance = 86
|
||||||
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
elif asset.serialnumber and query.lower() == asset.serialnumber.lower():
|
||||||
relevance = 85
|
relevance = 85
|
||||||
elif asset.name and query.lower() in asset.name.lower():
|
elif asset.name and query.lower() in asset.name.lower():
|
||||||
@@ -388,7 +430,7 @@ def _search_measuringtools(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Asset.assetnumber, Asset.name,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.gaugelabreference)
|
Asset.serialnumber, Asset.gaugelabreference)
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
|
||||||
|
|
||||||
for asset in assets:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
@@ -408,6 +450,106 @@ def _search_measuringtools(query, search_term):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _search_usbdevices(query, search_term):
|
||||||
|
"""Search USB devices by serial, asset tag and product name.
|
||||||
|
|
||||||
|
A USB device is NOT an asset - it lives in the usb plugin's own table - so
|
||||||
|
the generic asset search cannot see it and these records were unreachable
|
||||||
|
from search entirely. Serial number is the field people actually have in
|
||||||
|
hand: it is what is printed on the stick they are holding.
|
||||||
|
|
||||||
|
currentusername is deliberately NOT searched. It records who holds the
|
||||||
|
device, and making search a way to list what a named person has checked out
|
||||||
|
is a different feature from finding a device.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
try:
|
||||||
|
_require_enabled('usb')
|
||||||
|
from plugins.usb.models import USBDevice
|
||||||
|
devices = USBDevice.query.filter(
|
||||||
|
USBDevice.isactive == True,
|
||||||
|
_word_match(query, USBDevice.serialnumber, USBDevice.assetnumber,
|
||||||
|
USBDevice.label, USBDevice.productname)
|
||||||
|
).order_by(USBDevice.serialnumber,
|
||||||
|
USBDevice.usbdeviceid).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)
|
||||||
|
).order_by(PrintedItem.itemcode,
|
||||||
|
PrintedItem.printeditemid).limit(10).all()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
relevance = 20
|
||||||
|
if query.lower() == (item.itemcode or '').lower():
|
||||||
|
relevance = 100
|
||||||
|
elif query.lower() == (item.gagelabtag or '').lower():
|
||||||
|
relevance = 95
|
||||||
|
elif query.lower() == (item.itemname or '').lower():
|
||||||
|
relevance = 90
|
||||||
|
elif query.lower() in (item.itemname or '').lower():
|
||||||
|
relevance = 50
|
||||||
|
|
||||||
|
subtitle = item.itemcode or item.gagelabtag
|
||||||
|
if item.binlocation:
|
||||||
|
subtitle = f'{subtitle} - {item.binlocation}' if subtitle else item.binlocation
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
'type': 'printed_item',
|
||||||
|
'id': item.printeditemid,
|
||||||
|
'title': item.itemname,
|
||||||
|
'subtitle': subtitle,
|
||||||
|
'url': f'/printedparts/{item.printeditemid}',
|
||||||
|
'relevance': relevance,
|
||||||
|
})
|
||||||
|
except ImportError:
|
||||||
|
pass # printedparts plugin absent or disabled
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Printed item search failed: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _search_customfields(query, search_term):
|
def _search_customfields(query, search_term):
|
||||||
"""Search custom-field VALUES for fields flagged searchable.
|
"""Search custom-field VALUES for fields flagged searchable.
|
||||||
|
|
||||||
@@ -432,7 +574,7 @@ def _search_customfields(query, search_term):
|
|||||||
CustomField.searchable == True,
|
CustomField.searchable == True,
|
||||||
CustomField.isactive == True,
|
CustomField.isactive == True,
|
||||||
_word_match(query, CustomFieldValue.value),
|
_word_match(query, CustomFieldValue.value),
|
||||||
).limit(15).all()
|
).order_by(Asset.assetnumber, CustomField.fieldid).limit(15).all()
|
||||||
|
|
||||||
for asset, field in rows:
|
for asset, field in rows:
|
||||||
result = _get_asset_result(asset, query, relevance=40)
|
result = _get_asset_result(asset, query, relevance=40)
|
||||||
@@ -452,7 +594,8 @@ def _search_by_ip(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Communication.asset).joinedload(Asset.assettype),
|
joinedload(Communication.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Communication.asset).joinedload(Asset.location),
|
joinedload(Communication.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Communication.ipaddress,
|
||||||
|
Communication.communicationid).limit(10).all()
|
||||||
|
|
||||||
seen_assets = set()
|
seen_assets = set()
|
||||||
for comm in comms:
|
for comm in comms:
|
||||||
@@ -512,7 +655,7 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Computer.asset).joinedload(Asset.assettype),
|
joinedload(Computer.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Computer.asset).joinedload(Asset.location),
|
joinedload(Computer.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Computer.hostname, Computer.computerid).limit(10).all()
|
||||||
|
|
||||||
for comp in computers:
|
for comp in computers:
|
||||||
if comp.asset and comp.asset.isactive:
|
if comp.asset and comp.asset.isactive:
|
||||||
@@ -535,7 +678,7 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(Printer.asset).joinedload(Asset.assettype),
|
joinedload(Printer.asset).joinedload(Asset.assettype),
|
||||||
joinedload(Printer.asset).joinedload(Asset.location),
|
joinedload(Printer.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(Printer.hostname, Printer.printerid).limit(10).all()
|
||||||
|
|
||||||
for printer in printers:
|
for printer in printers:
|
||||||
if printer.asset and printer.asset.isactive:
|
if printer.asset and printer.asset.isactive:
|
||||||
@@ -558,7 +701,8 @@ def _search_hostnames(query, search_term):
|
|||||||
).options(
|
).options(
|
||||||
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
joinedload(NetworkDevice.asset).joinedload(Asset.assettype),
|
||||||
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
joinedload(NetworkDevice.asset).joinedload(Asset.location),
|
||||||
).limit(10).all()
|
).order_by(NetworkDevice.hostname,
|
||||||
|
NetworkDevice.networkdeviceid).limit(10).all()
|
||||||
|
|
||||||
for device in devices:
|
for device in devices:
|
||||||
if device.asset and device.asset.isactive:
|
if device.asset and device.asset.isactive:
|
||||||
@@ -642,7 +786,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor, Model.modelnumber,
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
||||||
MachineType.machinetype)
|
MachineType.machinetype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in machine_assets:
|
for asset in machine_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -670,7 +814,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor, Model.modelnumber,
|
_word_match(query, Vendor.vendor, Model.modelnumber,
|
||||||
PrinterType.printertype)
|
PrinterType.printertype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in printer_assets:
|
for asset in printer_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -696,7 +840,7 @@ def _search_vendor_model_type(query, search_term):
|
|||||||
Asset.isactive == True,
|
Asset.isactive == True,
|
||||||
_word_match(query, Vendor.vendor,
|
_word_match(query, Vendor.vendor,
|
||||||
NetworkDeviceType.networkdevicetype)
|
NetworkDeviceType.networkdevicetype)
|
||||||
).limit(10).all()
|
).order_by(Asset.assetnumber, Asset.assetid).limit(10).all()
|
||||||
|
|
||||||
for asset in netdev_assets:
|
for asset in netdev_assets:
|
||||||
results.append(_get_asset_result(asset, query, 30))
|
results.append(_get_asset_result(asset, query, 30))
|
||||||
@@ -886,6 +1030,8 @@ def global_search():
|
|||||||
results.extend(_search_employees(query, search_term))
|
results.extend(_search_employees(query, search_term))
|
||||||
results.extend(_search_assets(query, search_term))
|
results.extend(_search_assets(query, search_term))
|
||||||
results.extend(_search_measuringtools(query, search_term))
|
results.extend(_search_measuringtools(query, search_term))
|
||||||
|
results.extend(_search_usbdevices(query, search_term))
|
||||||
|
results.extend(_search_printeditems(query, search_term))
|
||||||
results.extend(_search_customfields(query, search_term))
|
results.extend(_search_customfields(query, search_term))
|
||||||
results.extend(_search_notifications(query, search_term))
|
results.extend(_search_notifications(query, search_term))
|
||||||
results.extend(_search_hostnames(query, search_term))
|
results.extend(_search_hostnames(query, search_term))
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ SEARCH_DOMAINS = {
|
|||||||
'measuring_tool': 'Measuring Tools',
|
'measuring_tool': 'Measuring Tools',
|
||||||
'notification': 'Notifications',
|
'notification': 'Notifications',
|
||||||
'subnet': 'Subnets',
|
'subnet': 'Subnets',
|
||||||
|
'usb_device': 'USB Devices',
|
||||||
|
'printed_item': 'Printed Items',
|
||||||
}
|
}
|
||||||
|
|
||||||
def _declared_default(key: str) -> dict:
|
def _declared_default(key: str) -> dict:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .model import Model
|
|||||||
from .businessunit import BusinessUnit
|
from .businessunit import BusinessUnit
|
||||||
from .dashboarddefault import DashboardDefault
|
from .dashboarddefault import DashboardDefault
|
||||||
from .location import Location, LocationType, derive_locationcode
|
from .location import Location, LocationType, derive_locationcode
|
||||||
|
from .maplevel import Building, MapLevel
|
||||||
|
from .mapsnapshot import MapPositionSnapshot
|
||||||
from .operatingsystem import OperatingSystem
|
from .operatingsystem import OperatingSystem
|
||||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||||
from .communication import Communication, CommunicationType
|
from .communication import Communication, CommunicationType
|
||||||
@@ -37,6 +39,10 @@ __all__ = [
|
|||||||
'DashboardDefault',
|
'DashboardDefault',
|
||||||
'Location',
|
'Location',
|
||||||
'LocationType',
|
'LocationType',
|
||||||
|
# Map model (ADR-017): which drawing renders an asset, at what size
|
||||||
|
'Building',
|
||||||
|
'MapLevel',
|
||||||
|
'MapPositionSnapshot',
|
||||||
'derive_locationcode',
|
'derive_locationcode',
|
||||||
'OperatingSystem',
|
'OperatingSystem',
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -117,9 +117,24 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
nullable=True
|
nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Floor map position (ADR-001: asset-specific override; nullable)
|
# Floor map position (ADR-001: asset-specific override; nullable).
|
||||||
mapx = db.Column(db.Integer, comment='X coordinate on floor map (ADR-001)')
|
#
|
||||||
mapy = db.Column(db.Integer, comment='Y coordinate on floor map (ADR-001)')
|
# Absolute pixels in the NATIVE COORDINATE SPACE OF ITS LEVEL, not of the
|
||||||
|
# site (ADR-017). levelid says which drawing they are pixels of, and without
|
||||||
|
# it a position cannot be rendered - a marker drawn on the wrong level's
|
||||||
|
# blueprint looks perfectly correct and points at the wrong place, so the UI
|
||||||
|
# shows "level unknown" rather than assuming the default.
|
||||||
|
mapx = db.Column(db.Integer, comment='X coordinate on this level (ADR-017)')
|
||||||
|
mapy = db.Column(db.Integer, comment='Y coordinate on this level (ADR-017)')
|
||||||
|
levelid = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('maplevels.levelid'), nullable=True,
|
||||||
|
index=True,
|
||||||
|
comment='Which drawing mapx/mapy are pixels of (ADR-017)')
|
||||||
|
# When the position was last CONFIRMED against the current drawing. A bulk
|
||||||
|
# transform clears it, because a transform is a starting guess: the levels
|
||||||
|
# were redrawn and machines moved, and nothing in the coordinates says which
|
||||||
|
# markers are now stale. Null means "not yet reviewed on this drawing".
|
||||||
|
mapverifiedat = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
# Notes
|
# Notes
|
||||||
notes = db.Column(db.Text, nullable=True)
|
notes = db.Column(db.Text, nullable=True)
|
||||||
@@ -198,6 +213,10 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
'locationname': related.location.locationname if related.location else None,
|
'locationname': related.location.locationname if related.location else None,
|
||||||
'mapx': related.mapx,
|
'mapx': related.mapx,
|
||||||
'mapy': related.mapy,
|
'mapy': related.mapy,
|
||||||
|
# The level belongs to whichever asset supplied the
|
||||||
|
# coordinates (ADR-017). Inheriting a position without its
|
||||||
|
# level draws it on the borrower's drawing instead.
|
||||||
|
'levelid': related.levelid,
|
||||||
'inheritedfrom': related.assetnumber
|
'inheritedfrom': related.assetnumber
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +269,11 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
result['mapx'] = inherited['mapx']
|
result['mapx'] = inherited['mapx']
|
||||||
if result.get('mapy') is None:
|
if result.get('mapy') is None:
|
||||||
result['mapy'] = inherited['mapy']
|
result['mapy'] = inherited['mapy']
|
||||||
|
# Coordinates and their level move together, always. Copying the
|
||||||
|
# position while leaving levelid as this asset's own is how an
|
||||||
|
# inherited marker lands on the wrong drawing.
|
||||||
|
if result.get('levelid') is None:
|
||||||
|
result['levelid'] = inherited.get('levelid')
|
||||||
|
|
||||||
# Operation/short code of the resolved location (own or inherited).
|
# Operation/short code of the resolved location (own or inherited).
|
||||||
# Derived from the location name's leading token; labels can encode a
|
# Derived from the location name's leading token; labels can encode a
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class Location(BaseModel):
|
|||||||
# chain priority 3.
|
# chain priority 3.
|
||||||
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
mapx = db.Column(db.Integer, comment='Default X coordinate for assets at this location')
|
||||||
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
mapy = db.Column(db.Integer, comment='Default Y coordinate for assets at this location')
|
||||||
|
# Which drawing those coordinates are pixels of (ADR-017). A location is on a
|
||||||
|
# level as much as an asset is, and an asset with no position of its own
|
||||||
|
# inherits BOTH the coordinates and the level from here - inheriting the
|
||||||
|
# coordinates alone would draw them on whatever level the asset claims.
|
||||||
|
levelid = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('maplevels.levelid'), nullable=True, index=True,
|
||||||
|
comment='Which level mapx/mapy are pixels of (ADR-017)')
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
locationtype = db.relationship('LocationType')
|
locationtype = db.relationship('LocationType')
|
||||||
|
|||||||
109
shopdb/core/models/maplevel.py
Normal file
109
shopdb/core/models/maplevel.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Building + MapLevel models: which drawing renders an asset, at what size.
|
||||||
|
|
||||||
|
See ADR-017. A site used to have one floor map, described by four settings, and
|
||||||
|
`assets.mapx`/`mapy` were pixels in that one image. A second level and a likely
|
||||||
|
second building make that a table rather than a setting.
|
||||||
|
|
||||||
|
The alternative - stacking levels on one tall canvas - was rejected because it
|
||||||
|
turns "which level is this on" into `mapy > 2550`: an inference over a magic
|
||||||
|
number that changes whenever the drawing is re-exported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Building(BaseModel):
|
||||||
|
"""A building at this site. Groups levels; holds nothing a level needs.
|
||||||
|
|
||||||
|
Separate from Location deliberately (ADR-017): a Location answers which
|
||||||
|
operation owns an asset, a building groups the drawings it appears on.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'buildings'
|
||||||
|
|
||||||
|
buildingid = db.Column(db.Integer, primary_key=True)
|
||||||
|
buildingname = db.Column(db.String(100), nullable=False, unique=True)
|
||||||
|
# Display order. Buildings have no natural ordering and their names are not
|
||||||
|
# reliably ordinal ('Main', 'Annex', 'Building 2'), so the order is stated.
|
||||||
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
levels = db.relationship(
|
||||||
|
'MapLevel', back_populates='building',
|
||||||
|
order_by='MapLevel.sortorder', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Building {self.buildingname}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = super().to_dict()
|
||||||
|
data['levels'] = [level.to_dict() for level in self.levels
|
||||||
|
if level.isactive]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class MapLevel(BaseModel):
|
||||||
|
"""One drawing: a level of a building, with its own blueprint and size.
|
||||||
|
|
||||||
|
WHY THE DIMENSIONS LIVE HERE. They were site-wide settings, which cannot
|
||||||
|
express a mezzanine drawn at a different scale from the floor below it, and
|
||||||
|
certainly not a second building. `mapx`/`mapy` are absolute pixels in THIS
|
||||||
|
level's coordinate space, so a level without its own dimensions cannot place
|
||||||
|
a marker correctly.
|
||||||
|
|
||||||
|
Name and order are separate columns on purpose: levels are not reliably
|
||||||
|
numbered (basement, ground, mezzanine, roof), and `sortorder` gives
|
||||||
|
adjacency and up/down navigation without pretending the names are ordinal.
|
||||||
|
It also lets a mezzanine be inserted later without renumbering anything.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'maplevels'
|
||||||
|
|
||||||
|
levelid = db.Column(db.Integer, primary_key=True)
|
||||||
|
buildingid = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('buildings.buildingid'), nullable=False,
|
||||||
|
index=True)
|
||||||
|
levelname = db.Column(db.String(100), nullable=False)
|
||||||
|
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Both themes, because the map renders in whichever the viewer is using and
|
||||||
|
# a light-on-white blueprint is unreadable in dark mode. Either may be
|
||||||
|
# blank; the renderer falls back to the other rather than to nothing.
|
||||||
|
blueprintlight = db.Column(db.String(255), nullable=True)
|
||||||
|
blueprintdark = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
# Native pixel size of the blueprint. Positions are absolute pixels in this
|
||||||
|
# space (ADR-017), so these are what a marker's coordinates mean.
|
||||||
|
mapwidth = db.Column(db.Integer, nullable=False, default=3300)
|
||||||
|
mapheight = db.Column(db.Integer, nullable=False, default=2550)
|
||||||
|
|
||||||
|
# Exactly one level carries this. It is where an asset with no level lands,
|
||||||
|
# and what the map opens on. Enforced in the API rather than by a constraint,
|
||||||
|
# because "exactly one" across rows is not a column-level rule.
|
||||||
|
isdefault = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
building = db.relationship('Building', back_populates='levels')
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('buildingid', 'levelname',
|
||||||
|
name='uq_maplevel_building_name'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<MapLevel {self.levelname}>"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
data = super().to_dict()
|
||||||
|
data['buildingname'] = self.building.buildingname if self.building else None
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def default_level(cls):
|
||||||
|
"""The default level, or the lowest-sorted one if none is marked.
|
||||||
|
|
||||||
|
Never returns None on a seeded database: the migration that created this
|
||||||
|
table also created one level from the settings it replaced.
|
||||||
|
"""
|
||||||
|
level = cls.query.filter_by(isdefault=True, isactive=True).first()
|
||||||
|
if level is not None:
|
||||||
|
return level
|
||||||
|
return (cls.query.filter_by(isactive=True)
|
||||||
|
.order_by(cls.sortorder, cls.levelid).first())
|
||||||
76
shopdb/core/models/mapsnapshot.py
Normal file
76
shopdb/core/models/mapsnapshot.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""A saved set of marker positions, so a bulk change can be undone.
|
||||||
|
|
||||||
|
Positions had no history. A landmark transform rewrites every marker on a level
|
||||||
|
in one statement, and without a way back the honest instruction would be "take a
|
||||||
|
database backup first" - which nobody does before clicking a button in a UI, so
|
||||||
|
in practice the feature would either not be used or be used once, badly.
|
||||||
|
|
||||||
|
One row holds the whole set as JSON rather than a row per asset. A snapshot is
|
||||||
|
read back whole or not at all, so restore stays a single statement, and there is
|
||||||
|
no orphan-child case to reason about.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MapPositionSnapshot(BaseModel):
|
||||||
|
__tablename__ = 'mappositionsnapshots'
|
||||||
|
|
||||||
|
snapshotid = db.Column(db.Integer, primary_key=True)
|
||||||
|
# Which level the operation targeted. Nullable because a snapshot may span
|
||||||
|
# levels (moving assets between them), and then no single level owns it.
|
||||||
|
levelid = db.Column(db.Integer, nullable=True)
|
||||||
|
reason = db.Column(db.String(255), nullable=True)
|
||||||
|
assetcount = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
positionsjson = db.Column(db.Text, nullable=False)
|
||||||
|
# Set when this snapshot has been restored, so the history reads as what
|
||||||
|
# happened rather than as a list of identical-looking saves.
|
||||||
|
restoredat = db.Column(db.DateTime, nullable=True)
|
||||||
|
createdby = db.Column(db.String(100), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<MapPositionSnapshot {self.snapshotid} ({self.assetcount})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def positions(self):
|
||||||
|
try:
|
||||||
|
return json.loads(self.positionsjson or '[]')
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
"""Metadata only. The positions themselves are large and nobody browsing
|
||||||
|
a list of snapshots wants them."""
|
||||||
|
data = super().to_dict()
|
||||||
|
data.pop('positionsjson', None)
|
||||||
|
return data
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def capture(cls, assets, reason, levelid=None, createdby=None):
|
||||||
|
"""Record the CURRENT positions of these assets, before they change.
|
||||||
|
|
||||||
|
Includes levelid and mapverifiedat, not just the coordinates: a restore
|
||||||
|
has to put a marker back on the level it was on and with the review state
|
||||||
|
it had, or undo would silently mark reviewed work as unreviewed.
|
||||||
|
"""
|
||||||
|
rows = [{
|
||||||
|
'assetid': asset.assetid,
|
||||||
|
'mapx': asset.mapx,
|
||||||
|
'mapy': asset.mapy,
|
||||||
|
'levelid': asset.levelid,
|
||||||
|
'mapverifiedat': asset.mapverifiedat.isoformat()
|
||||||
|
if asset.mapverifiedat else None,
|
||||||
|
} for asset in assets]
|
||||||
|
snapshot = cls(
|
||||||
|
levelid=levelid,
|
||||||
|
reason=reason,
|
||||||
|
assetcount=len(rows),
|
||||||
|
positionsjson=json.dumps(rows, separators=(',', ':')),
|
||||||
|
createdby=createdby,
|
||||||
|
)
|
||||||
|
db.session.add(snapshot)
|
||||||
|
db.session.flush()
|
||||||
|
return snapshot
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user