Merge remote-tracking branch 'origin/main' into wilm
# Conflicts: # shopdb/__init__.py
This commit is contained in:
207
CHANGELOG.md
207
CHANGELOG.md
@@ -12,6 +12,213 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Reading anything now needs a permission.** A sweep of the URL map found
|
||||||
|
**165 GET routes that answered anybody with no token at all**: the whole
|
||||||
|
asset inventory, every machine, PC, printer and network device, the USB
|
||||||
|
checkout history and who holds which drive, the staff directory, the core
|
||||||
|
reports. Guest access had been argued about one feature-flag at a time while
|
||||||
|
the front door stood open. Every one of them is now behind the read
|
||||||
|
permission it belongs to - `assets.view`, `machines.view`, `printers.view`
|
||||||
|
and so on - which also means the guest role can open exactly the ones a site
|
||||||
|
chooses and nothing else.
|
||||||
|
|
||||||
|
Thirty-three endpoints stay public, each named with its reason in
|
||||||
|
`tests/test_core/test_public_read_surface.py`: the app shell and its
|
||||||
|
bootstrap, the settings allowlist, images fetched by `<img src>` (which
|
||||||
|
carries no Authorization header), what a display kiosk renders before anybody
|
||||||
|
signs in, the parts and USB kiosks, the client scripts that run as SYSTEM
|
||||||
|
before a user session exists, and a secure-send link whose token is itself
|
||||||
|
the authorisation. That file is a standing gate: a new public read fails CI
|
||||||
|
until somebody adds it to the list, in front of a reviewer. Four more reads
|
||||||
|
need a login but no permission because the handler decides from who you are -
|
||||||
|
your own user record, your own API tokens, the role catalogue - and the same
|
||||||
|
test proves each still refuses an anonymous caller.
|
||||||
|
|
||||||
|
The employees plugin gained `employees.view`: the directory is names, SSOs,
|
||||||
|
photographs and reporting lines, and it had no permission at all.
|
||||||
|
|
||||||
|
- **A guide's sections can be ticked off while you read it.** Only checklist
|
||||||
|
items were tickable, which answered "what have I done" but not the question a
|
||||||
|
long procedure actually raises - where was I. Somebody looks away to do the
|
||||||
|
thing the guide asked for and looks back at a wall of text. Every step and
|
||||||
|
every heading now carries a checkbox in the reader; ticking one strikes it
|
||||||
|
through and fades it rather than hiding it, because scanning back up the page
|
||||||
|
has to still show what was done. Headings count as well as steps: a guide
|
||||||
|
converted from an HTML article often has fifty headings and no steps at all.
|
||||||
|
Progress is per browser and per version, as it already was for checklists -
|
||||||
|
shopfloor PCs share a login, so one technician's ticks must not greet the
|
||||||
|
next person on that bay.
|
||||||
|
|
||||||
|
- **A guest is a role now, not a switch per feature.** Whether somebody with no
|
||||||
|
account may see something was answered one feature at a time: a
|
||||||
|
`securesend_allow_guests` setting, an enable flag per kiosk, and very nearly a
|
||||||
|
third for guides - each with its own settings page and its own hand-written
|
||||||
|
branch inside a route. It is a permissions question, and this app already
|
||||||
|
administers permissions per role, so anyone not signed in now holds the
|
||||||
|
seeded `guest` role. It starts with nothing, so an upgrade opens no door; a
|
||||||
|
site opens one by ticking a box in the same role grid it uses for everybody
|
||||||
|
else, and closes it the same way - taking effect on the next request, not the
|
||||||
|
next restart. `require_permission` consults the role when there is no token,
|
||||||
|
which is the whole mechanism.
|
||||||
|
|
||||||
|
What a guest may be granted is bounded: any `.view` or `.read` permission,
|
||||||
|
plus writes a plugin has explicitly declared safe to perform without an
|
||||||
|
account (`get_guest_grantable_permissions`, contract 0.21.0). Secure Send
|
||||||
|
declares its create permission, because sending a password to a vendor is the
|
||||||
|
one write the product means to offer somebody who never signs in. Everything
|
||||||
|
else is refused by the API and greyed out in the grid rather than warned
|
||||||
|
about. A guest write records no creator - the audit trail can say when and
|
||||||
|
from where, never who - and that is the reason for the bar.
|
||||||
|
|
||||||
|
GE-Enforce and Slides gained read permissions (`geenforce.view`,
|
||||||
|
`slides.view`) so they can be opened to guests without handing out the manage
|
||||||
|
right; their read routes accept either, so nothing an existing manager could
|
||||||
|
reach has moved. The navigation endpoint now returns only what the caller can
|
||||||
|
actually reach, so a signed-out visitor sees the pages that were opened to
|
||||||
|
them rather than a sidebar of links that bounce off a login page.
|
||||||
|
|
||||||
|
- **USB check-in / check-out audit report.** CMMC removable-media tracking is an
|
||||||
|
audit obligation and there was no report for it - answering "who had this
|
||||||
|
drive, and when" meant paging through the check-in/out list by hand. The
|
||||||
|
window is named rather than a day count: today, last month, six months, a
|
||||||
|
year, or all time. Events rather than totals, because that is the question an
|
||||||
|
audit actually asks, with a CSV export for the people who want it in a
|
||||||
|
spreadsheet. Works against the external `cmmc_usb` log or the self-hosted
|
||||||
|
tables; in self-hosted mode one stored loan expands into a take and a return,
|
||||||
|
so a returned drive is distinguishable from one still out.
|
||||||
|
|
||||||
|
- **`flask guides export` / `flask guides import` - a guide can reach another
|
||||||
|
site.** Sites are separate databases and the ones that matter are air-gapped
|
||||||
|
from wherever a guide gets written, so a procedure written once was stranded
|
||||||
|
where it was typed. Export writes one `.guide.json` per guide with its images
|
||||||
|
carried inside it - one file, because a bundle that needs its images copied
|
||||||
|
alongside it is a bundle that will eventually arrive without them - and
|
||||||
|
import reads them back. The slug travels, since that is the address somebody
|
||||||
|
pastes into a work order and the same guide should answer to the same name on
|
||||||
|
every site; ids, revision history and publication state do not, because those
|
||||||
|
belong to the database that issued them. Imports arrive as drafts;
|
||||||
|
`--replace` updates a guide already carrying that slug rather than creating a
|
||||||
|
second one, which is how a corrected guide is re-shipped.
|
||||||
|
|
||||||
|
- **`flask guides import-html` - an existing HTML article becomes a guide.**
|
||||||
|
A site adopting this app arrives holding procedures already written: styled
|
||||||
|
HTML pages that get mailed around and opened off a share. Retyping one into
|
||||||
|
the editor is how a good procedure stays on a share forever. The importer
|
||||||
|
reads the page and produces blocks - steps, callouts, code, checklists,
|
||||||
|
tables, lists - and lifts embedded images into `guideimages` so a 1.6 MB
|
||||||
|
document stops carrying its screenshots as base64. Imports land as DRAFTS,
|
||||||
|
and `--dry-run` (the default) reports what it would produce without writing.
|
||||||
|
Conversion is deliberately lossy: colour, fonts and layout go, because that
|
||||||
|
is the whole reason a guide is stored as blocks rather than markup. The
|
||||||
|
blocks go through the same validator the editor uses, so an import cannot
|
||||||
|
store something the editor could not have produced.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Tech Tools was invisible to anyone without an account**, even though every
|
||||||
|
tool on the page is deliberately usable without one and Secure Send is the
|
||||||
|
single write this product offers a guest. The nav item named no permission,
|
||||||
|
because the plugin had none to name, so the sidebar filter dropped it. The
|
||||||
|
plugin gained `tools.view`, and the item now names either that or
|
||||||
|
`tools.secrets` - somebody granted only Secure Send still needs the entry
|
||||||
|
that leads to it.
|
||||||
|
|
||||||
|
- **The sidebar offered pages the reader could not open, and hid ones they
|
||||||
|
could.** A nav item is hidden from anybody who does not hold its permission,
|
||||||
|
but only four items ever declared one - so the filter did nothing for the
|
||||||
|
rest, and a signed-out visitor granted `machines.view` still saw an empty
|
||||||
|
sidebar. Every item now names the read permission its page needs, in the
|
||||||
|
plugins, in the core navigation endpoint and in the frontend's fallback list.
|
||||||
|
A signed-in member with a sparse role will notice the same correction: a link
|
||||||
|
that could only answer 403 no longer appears.
|
||||||
|
|
||||||
|
- **Every report offers to email itself, not just two of them.** `POST
|
||||||
|
/api/reports/email` has always taken any `{columns, rows}`, but the button was
|
||||||
|
on the toner and warranty pages only - because those two happen to be their
|
||||||
|
own routed pages, and the reports page had no button at all. It is now beside
|
||||||
|
Export CSV for every report the page can build a table from, which is all of
|
||||||
|
them: the plugin reports through their declared columns, the core reports
|
||||||
|
through the flat list they return, and the asset inventory summary by
|
||||||
|
flattening its three tables into one with the grouping named in a column.
|
||||||
|
Presentation-only fields - a status colour, a link id - are left out, since a
|
||||||
|
column of hex codes is worse than no column. The button is hidden without the
|
||||||
|
`reports.export` permission the endpoint enforces, on all three pages: offering
|
||||||
|
somebody a button that can only answer 403 is worse than not offering it.
|
||||||
|
|
||||||
|
- **A long drive name pushed Take and Return out of the card.** On the USB kiosk
|
||||||
|
the name is a flex item, which defaults to `min-width: auto`, so it kept its
|
||||||
|
full intrinsic width and shoved the button through the side of the card -
|
||||||
|
leaving a drive nobody could take. Names now wrap, including the serial-style
|
||||||
|
ones that carry no spaces at all, and a description is clamped to two lines so
|
||||||
|
one long name cannot make every card in its row tall. The locker line is never
|
||||||
|
clipped: it is how somebody finds the thing.
|
||||||
|
|
||||||
|
- **Muted text on the USB kiosk and in guides was not muted.** Both named a
|
||||||
|
`--textmuted` variable this app has never defined, so the browser dropped the
|
||||||
|
declaration and the hints, subtitles and locker lines rendered at full
|
||||||
|
strength against the text they were meant to sit behind. The variable is
|
||||||
|
`--text-light`.
|
||||||
|
|
||||||
|
- **Plugin reports rendered a raw JSON dump.** The reports page ran a hardcoded
|
||||||
|
switch over five core report ids and fell through to
|
||||||
|
`console.error('Unknown report')`, and its results panel was a matching chain
|
||||||
|
of per-id templates ending in a `JSON.stringify`. So a plugin could register a
|
||||||
|
report, watch it appear in the catalogue, and get a wall of JSON when it was
|
||||||
|
opened - which is where the three printedparts reports have been since they
|
||||||
|
were added. Any report that declares an endpoint is now fetched from it, and
|
||||||
|
any report returning `{columns, rows}` renders as a table. A plugin needs no
|
||||||
|
core code to have a working report. CSV export follows the same endpoint
|
||||||
|
rather than assuming `/api/reports/<id>`, which does not exist for a plugin
|
||||||
|
report, and a report that fails now says so instead of showing an empty panel.
|
||||||
|
Export CSV is fetched through the api client rather than `window.open`: a new
|
||||||
|
window carries no Authorization header, so every report behind a real
|
||||||
|
`jwt_required` answered the export button with a 401. The core reports are
|
||||||
|
jwt-optional, which is the only reason the old way ever appeared to work.
|
||||||
|
|
||||||
|
- **Reports had no way to choose a time period.** Endpoints accepted a window;
|
||||||
|
the page offered no control for it, so `printedparts-consumption` was
|
||||||
|
permanently stuck on its 30-day default.
|
||||||
|
|
||||||
|
- **USB kiosk: check a drive out and back in without a login.** The people who
|
||||||
|
actually take USB drives are standing at the cabinet, and most of them have no
|
||||||
|
ShopDB account - so check-out and check-in were gated behind a login that was
|
||||||
|
not doing much anyway, since both modes already attribute the action to a
|
||||||
|
scanned BADGE rather than to the signed-in user. `/usb-kiosk` is a full-screen
|
||||||
|
touch screen modelled on the parts kiosk, and works against the self-hosted
|
||||||
|
tables or an existing `cmmc_usb` database.
|
||||||
|
|
||||||
|
It meets the same bar as the parts kiosk: it can move a device between
|
||||||
|
checked-out and checked-in and nothing else - no create, edit or retire - and
|
||||||
|
it does not exist at all until a site sets `usb_kiosk_enabled`. An
|
||||||
|
unauthenticated write is a decision about the door.
|
||||||
|
|
||||||
|
The drives carry no barcode, so unlike the parts kiosk there is nothing to
|
||||||
|
present and the screen has to offer a LIST. That widens what a logged-out
|
||||||
|
screen exposes, so the list is not anonymous: an SSO comes first and has to
|
||||||
|
resolve to a real person before any drive is named - the same bar taking one
|
||||||
|
already had to clear. The main screen shows the caller's own drives, what is
|
||||||
|
free, and a COUNT of everything else; the holders of other people's drives
|
||||||
|
appear only behind a deliberate second tap, so a passer-by cannot read the
|
||||||
|
whole cabinet off a wall panel.
|
||||||
|
|
||||||
|
A badge that resolves to no employee is REFUSED, unlike the signed-in route
|
||||||
|
which creates the user row: a checkout nobody can trace to a person defeats
|
||||||
|
the point, and at a kiosk there is no operator standing there to vouch for it.
|
||||||
|
|
||||||
|
Returning somebody else's drive asks first, then records the badge that was
|
||||||
|
scanned - so the log says who actually handed it back, which is the honest
|
||||||
|
record and needs no change to a schema this app does not own. Refusing a
|
||||||
|
mismatch outright is available as a setting, but is not the default: a drive
|
||||||
|
left behind by somebody on leave would then be unreturnable at the kiosk,
|
||||||
|
which just moves the problem to a person with a login.
|
||||||
|
|
||||||
|
The list of free drives is filtered from a search box, and lays itself out in
|
||||||
|
as many columns as the panel is wide - up to four on a wall display. A cabinet
|
||||||
|
of thirty drives was one long column somebody had to read through. A drive is
|
||||||
|
found by its id, by what is written on it, or by where it lives, in whatever
|
||||||
|
word order comes naturally: "locker engineer" finds the engineering locker,
|
||||||
|
because that is what people say.
|
||||||
|
|
||||||
- **Audit log retention.** The audit log is append-only and nothing ever removed
|
- **Audit log retention.** The audit log is append-only and nothing ever removed
|
||||||
a row, so it grew for as long as a site ran - here to the point where the
|
a row, so it grew for as long as a site ran - here to the point where the
|
||||||
audit page was tens of thousands of pages deep and therefore useless, because
|
audit page was tens of thousands of pages deep and therefore useless, because
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ const TITLE_SPELLINGS = {
|
|||||||
'knowledgebase': 'Knowledge Base',
|
'knowledgebase': 'Knowledge Base',
|
||||||
'printedparts': '3D Printed Parts',
|
'printedparts': '3D Printed Parts',
|
||||||
'parts-kiosk': 'Parts Kiosk',
|
'parts-kiosk': 'Parts Kiosk',
|
||||||
|
'usb-kiosk': 'USB Kiosk',
|
||||||
'tv': 'TV Slideshow',
|
'tv': 'TV Slideshow',
|
||||||
'shopfloor': 'Shopfloor Dashboard',
|
'shopfloor': 'Shopfloor Dashboard',
|
||||||
'measuringtools': 'Measuring Tools',
|
'measuringtools': 'Measuring Tools',
|
||||||
|
|||||||
@@ -75,13 +75,25 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Re-read the signed-in user from the server, permissions included.
|
||||||
|
//
|
||||||
|
// The login response is the ONLY thing that used to populate this, and it
|
||||||
|
// is cached in localStorage - so granting somebody a permission did nothing
|
||||||
|
// visible until they happened to log out and back in. They were told they
|
||||||
|
// had rights, the button stayed hidden, and nothing explained why. Called
|
||||||
|
// on app boot so a permission change lands on the next page load.
|
||||||
|
//
|
||||||
|
// Only a 401 logs out. A transient failure - API restarting, network blip -
|
||||||
|
// must not throw somebody out of a session they still hold; the cached user
|
||||||
|
// is stale, not invalid.
|
||||||
async fetchUser() {
|
async fetchUser() {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.me()
|
const response = await authApi.me()
|
||||||
this.user = response.data.data
|
this.user = response.data.data
|
||||||
await this.enrichFromDirectory()
|
await this.enrichFromDirectory()
|
||||||
|
localStorage.setItem('user', JSON.stringify(this.user))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logout()
|
if (error.response?.status === 401) this.logout()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
69
frontend/src/utils/reportTable.js
Normal file
69
frontend/src/utils/reportTable.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Turning a report payload into the one table shape everything else speaks.
|
||||||
|
//
|
||||||
|
// POST /api/reports/email has always taken {columns, rows} from any caller, but
|
||||||
|
// only two pages ever offered an Email report button - the two that happen to
|
||||||
|
// be their own routed pages. Every other report was reachable only from the
|
||||||
|
// reports page, which had no button at all. This builds that table from
|
||||||
|
// whatever shape the open report returned, so the offer is the same everywhere.
|
||||||
|
|
||||||
|
export function titleCase (text) {
|
||||||
|
return String(text || '')
|
||||||
|
.split(/[\s_-]+/)
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presentation-only keys. A colour swatch and a row id mean nothing in an
|
||||||
|
// email, and a column of hex codes is worse than no column at all.
|
||||||
|
const EMAIL_SKIP = ['color', 'linkid', 'linkurl', 'id']
|
||||||
|
|
||||||
|
function emailcolumns (keys) {
|
||||||
|
return keys.filter(key => !EMAIL_SKIP.includes(key))
|
||||||
|
.map(key => ({ key, label: titleCase(key) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailTableFrom (data) {
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
// Any report that speaks the {columns, rows} contract - every plugin report,
|
||||||
|
// and any core one that adopts it. Declared columns win; otherwise take them
|
||||||
|
// from the first row, the same way the on-screen table does.
|
||||||
|
if (Array.isArray(data.rows) && data.rows.length) {
|
||||||
|
const keys = (Array.isArray(data.columns) && data.columns.length)
|
||||||
|
? data.columns
|
||||||
|
: Object.keys(data.rows[0])
|
||||||
|
return { columns: emailcolumns(keys), rows: data.rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The core reports, which return a flat list under `data`.
|
||||||
|
if (Array.isArray(data.data) && data.data.length) {
|
||||||
|
return { columns: emailcolumns(Object.keys(data.data[0])), rows: data.data }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset inventory is three tables. One email, so they become one table with
|
||||||
|
// the grouping named in a column - sending a third of it would be worse.
|
||||||
|
if (data.data && typeof data.data === 'object') {
|
||||||
|
const rows = []
|
||||||
|
for (const [section, entries] of Object.entries(data.data)) {
|
||||||
|
if (!Array.isArray(entries)) continue
|
||||||
|
for (const entry of entries) {
|
||||||
|
rows.push({
|
||||||
|
section: titleCase(section),
|
||||||
|
name: entry.type || entry.status || entry.location || '',
|
||||||
|
count: entry.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rows.length) return null
|
||||||
|
return {
|
||||||
|
columns: [{ key: 'section', label: 'Grouping' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'count', label: 'Count' }],
|
||||||
|
rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty report has nothing to send. The button hides rather than mailing
|
||||||
|
// somebody a table with no rows in it.
|
||||||
|
return null
|
||||||
|
}
|
||||||
84
frontend/src/utils/reportTable.spec.js
Normal file
84
frontend/src/utils/reportTable.spec.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Every shape a report on this app actually returns, because the Email report
|
||||||
|
// button now has to build a table out of all of them rather than only the two
|
||||||
|
// that had their own page.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { emailTableFrom, titleCase } from './reportTable'
|
||||||
|
|
||||||
|
describe('emailTableFrom', () => {
|
||||||
|
it('has nothing to send for a report that has not run', () => {
|
||||||
|
expect(emailTableFrom(null)).toBe(null)
|
||||||
|
expect(emailTableFrom({})).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the declared columns of a {columns, rows} report', () => {
|
||||||
|
const table = emailTableFrom({
|
||||||
|
columns: ['timestamp', 'action', 'device_id'],
|
||||||
|
rows: [{ timestamp: 't', action: 'check-out', device_id: 'USB-3' }],
|
||||||
|
})
|
||||||
|
expect(table.columns).toEqual([
|
||||||
|
{ key: 'timestamp', label: 'Timestamp' },
|
||||||
|
{ key: 'action', label: 'Action' },
|
||||||
|
{ key: 'device_id', label: 'Device Id' },
|
||||||
|
])
|
||||||
|
expect(table.rows).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the first row when a report declares no columns', () => {
|
||||||
|
const table = emailTableFrom({ rows: [{ item: 'a', takes: 2 }] })
|
||||||
|
expect(table.columns.map(c => c.key)).toEqual(['item', 'takes'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a table from a core report that returns a flat list', () => {
|
||||||
|
const table = emailTableFrom({
|
||||||
|
report: 'machines_by_type',
|
||||||
|
total: 231,
|
||||||
|
data: [{ machinetype: 'Lathe', description: 'd', count: 12 }],
|
||||||
|
})
|
||||||
|
expect(table.columns.map(c => c.key)).toEqual(['machinetype', 'description', 'count'])
|
||||||
|
expect(table.rows[0].count).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the keys that only mean something on screen', () => {
|
||||||
|
const table = emailTableFrom({
|
||||||
|
data: [{ status: 'In Use', color: '#28a745', count: 572 }],
|
||||||
|
})
|
||||||
|
expect(table.columns.map(c => c.key)).toEqual(['status', 'count'])
|
||||||
|
|
||||||
|
const kb = emailTableFrom({
|
||||||
|
data: [{ linkid: 4, linkurl: 'http://x', shortdescription: 'How to', clicks: 9 }],
|
||||||
|
})
|
||||||
|
expect(kb.columns.map(c => c.key)).toEqual(['shortdescription', 'clicks'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flattens the three tables of asset inventory into one, named', () => {
|
||||||
|
const table = emailTableFrom({
|
||||||
|
data: {
|
||||||
|
bytype: [{ type: 'computer', count: 289 }],
|
||||||
|
bystatus: [{ status: 'In Use', color: '#0f0', count: 572 }],
|
||||||
|
bylocation: [{ location: 'Cell A', count: 3 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(table.columns.map(c => c.key)).toEqual(['section', 'name', 'count'])
|
||||||
|
expect(table.rows).toEqual([
|
||||||
|
{ section: 'Bytype', name: 'computer', count: 289 },
|
||||||
|
{ section: 'Bystatus', name: 'In Use', count: 572 },
|
||||||
|
{ section: 'Bylocation', name: 'Cell A', count: 3 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nothing to send for a report that came back empty', () => {
|
||||||
|
expect(emailTableFrom({ data: [], total: 0 })).toBe(null)
|
||||||
|
expect(emailTableFrom({ columns: ['a'], rows: [] })).toBe(null)
|
||||||
|
expect(emailTableFrom({ data: { bytype: [], bystatus: [] } })).toBe(null)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('titleCase', () => {
|
||||||
|
it('turns a column key into a heading', () => {
|
||||||
|
expect(titleCase('locker_location')).toBe('Locker Location')
|
||||||
|
expect(titleCase('machines-by-type')).toBe('Machines By Type')
|
||||||
|
expect(titleCase('')).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,7 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-search">
|
<!-- Global search is authenticated; offering the box to a signed-out
|
||||||
|
reader gives them a control that can only answer 401. -->
|
||||||
|
<div v-if="authStore.isAuthenticated" class="sidebar-search">
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -34,6 +36,8 @@
|
|||||||
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
<a v-if="hasRoute('/tv')" :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||||
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
<a v-if="hasRoute('/parts-kiosk')" :href="withBase('/parts-kiosk')"
|
||||||
target="_blank" class="external-link">Parts Kiosk</a>
|
target="_blank" class="external-link">Parts Kiosk</a>
|
||||||
|
<a v-if="hasRoute('/usb-kiosk')" :href="withBase('/usb-kiosk')"
|
||||||
|
target="_blank" class="external-link">USB Kiosk</a>
|
||||||
|
|
||||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -136,6 +140,7 @@ const hasRoute = (path) => stagedRoutePaths.has(path)
|
|||||||
const showShopfloor = hasRoute('/shopfloor')
|
const showShopfloor = hasRoute('/shopfloor')
|
||||||
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
&& (hasRoute('/notifications') || hasRoute('/calendar'))
|
||||||
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
const showDisplays = showShopfloor || hasRoute('/tv') || hasRoute('/parts-kiosk')
|
||||||
|
|| hasRoute('/usb-kiosk')
|
||||||
const routeViewKey = computed(() =>
|
const routeViewKey = computed(() =>
|
||||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||||
)
|
)
|
||||||
@@ -183,50 +188,81 @@ const iconMap = {
|
|||||||
|
|
||||||
// Default navigation (used as fallback if API fails)
|
// Default navigation (used as fallback if API fails)
|
||||||
const defaultNav = [
|
const defaultNav = [
|
||||||
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0 },
|
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0, permission: 'assets.view' },
|
||||||
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
|
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5, permission: 'notifications.view' },
|
||||||
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
|
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6, permission: 'notifications.view' },
|
||||||
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
{ name: 'Map', icon: 'map', route: '/map', position: 4, permission: 'assets.view' },
|
||||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets', permission: 'machines.view' },
|
||||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets', permission: 'computers.view' },
|
||||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
|
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets', permission: 'network.view' },
|
||||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
|
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets', permission: 'printers.view' },
|
||||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
|
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets', permission: 'usb.view' },
|
||||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
|
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information', permission: 'applications.view' },
|
||||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
|
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information', permission: 'kb.view' },
|
||||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
|
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information', permission: 'reports.view' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// The canonical sidebar sections, in the order they render. A nav item DECLARES
|
||||||
|
// which one it belongs to; nothing is inferred.
|
||||||
|
//
|
||||||
|
// Sections used to be derived from the position number - 10-29 became Assets,
|
||||||
|
// 30-49 became Information - so a plugin landed in whichever bucket its number
|
||||||
|
// happened to fall in. That is how USB Devices, an asset, came to sit under
|
||||||
|
// Information next to GE-Enforce and Tech Tools, and why two plugins picking
|
||||||
|
// the same number silently reordered each other. A number is for ORDER; it was
|
||||||
|
// never a statement about meaning.
|
||||||
|
//
|
||||||
|
// An item with no section (or an unknown one) stays in the unlabelled group at
|
||||||
|
// the top, which is the safe default: it appears, it is just not filed.
|
||||||
|
const NAV_SECTIONS = [
|
||||||
|
{ key: null, label: null },
|
||||||
|
{ key: 'assets', label: 'Assets' },
|
||||||
|
{ key: 'information', label: 'Information' },
|
||||||
|
{ key: 'fleet', label: 'Fleet' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function buildNavItems(items) {
|
function buildNavItems(items) {
|
||||||
// Drop items the user cannot reach: a nav item may carry a `permission`
|
// Drop items the user cannot reach: a nav item may carry a `permission`
|
||||||
// (e.g. Slides -> slides.manage); hide it from anyone who lacks it so the
|
// (e.g. Slides -> slides.manage); hide it from anyone who lacks it so the
|
||||||
// link does not dead-end at the router guard. Admins hold every permission.
|
// link does not dead-end at the router guard. Admins hold every permission.
|
||||||
const visible = items.filter(item =>
|
//
|
||||||
!item.permission || authStore.hasPermission(item.permission))
|
// A signed-out visitor sees only what a plugin has declared guest-reachable.
|
||||||
|
// Without this the sidebar offers a signed-out reader the whole app and every
|
||||||
|
// link bounces them to the login page, which reads as a broken site rather
|
||||||
|
// than as a closed door.
|
||||||
|
// A nav item may name one permission or several; holding ANY of them is
|
||||||
|
// enough. GE-Enforce and Slides need that: the page is reachable by a
|
||||||
|
// manager and by a plain viewer, and they hold different strings.
|
||||||
|
const holds = item => {
|
||||||
|
if (!item.permission) return true
|
||||||
|
const names = Array.isArray(item.permission) ? item.permission : [item.permission]
|
||||||
|
return names.some(name => authStore.hasPermission(name))
|
||||||
|
}
|
||||||
|
// A signed-out visitor gets whatever the server sent, which is already
|
||||||
|
// filtered to what the guest role can reach - hasPermission knows nothing
|
||||||
|
// about guests, so asking it here would hide everything.
|
||||||
|
const visible = authStore.isAuthenticated ? items.filter(holds) : items
|
||||||
|
|
||||||
// Sort by position
|
const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean))
|
||||||
const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99))
|
const bucket = item => {
|
||||||
|
const declared = (item.section || '').toLowerCase()
|
||||||
|
return known.has(declared) ? declared : null
|
||||||
|
}
|
||||||
|
|
||||||
// Assign section headers based on position ranges
|
|
||||||
const result = []
|
const result = []
|
||||||
let currentSection = null
|
for (const { key, label } of NAV_SECTIONS) {
|
||||||
|
const inSection = visible
|
||||||
for (const item of sorted) {
|
.filter(item => bucket(item) === key)
|
||||||
let section = null
|
// ?? not ||: position 0 is a real position, and `0 || 99` is 99, which
|
||||||
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
|
// sent Dashboard - the only item that uses 0 - to the end of its group.
|
||||||
section = 'Assets'
|
.sort((a, b) => (a.position ?? 99) - (b.position ?? 99))
|
||||||
currentSection = 'Assets'
|
inSection.forEach((item, index) => {
|
||||||
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
|
|
||||||
if (currentSection !== 'Information') {
|
|
||||||
section = 'Information'
|
|
||||||
currentSection = 'Information'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
...item,
|
...item,
|
||||||
section,
|
// Only the first item in a section carries its heading.
|
||||||
iconComponent: iconMap[item.icon] || null
|
section: index === 0 ? label : null,
|
||||||
|
iconComponent: iconMap[item.icon] || null,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,6 +270,11 @@ function buildNavItems(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Refresh the signed-in user (and their permissions) on every app load. The
|
||||||
|
// login payload is cached in localStorage, so without this a permission
|
||||||
|
// granted mid-session stays invisible until the person logs out and back in.
|
||||||
|
if (authStore.isAuthenticated) authStore.fetchUser()
|
||||||
|
|
||||||
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
// Half-hourly: cheap, and it crosses midnight into the new week without a
|
||||||
// reload on a screen nobody touches.
|
// reload on a screen nobody touches.
|
||||||
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
|
||||||
|
|||||||
@@ -43,12 +43,27 @@
|
|||||||
<h2>{{ currentReport.name }}</h2>
|
<h2>{{ currentReport.name }}</h2>
|
||||||
<div class="report-actions">
|
<div class="report-actions">
|
||||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||||
|
<EmailReportButton v-if="emailtable && canemail"
|
||||||
|
:subject="currentReport.name"
|
||||||
|
:columns="emailtable.columns" :rows="emailtable.rows" />
|
||||||
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reportError" class="error-message">{{ reportError }}</div>
|
||||||
|
|
||||||
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
||||||
<div v-if="activeFilterFields.length" class="filters">
|
<div v-if="activeFilterFields.length" class="filters">
|
||||||
|
<select
|
||||||
|
v-if="activeFilterFields.includes('period')"
|
||||||
|
v-model="filterState.period"
|
||||||
|
class="form-control"
|
||||||
|
@change="refreshReport"
|
||||||
|
>
|
||||||
|
<option v-for="option in PERIOD_OPTIONS" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<select
|
<select
|
||||||
v-if="activeFilterFields.includes('businessunit')"
|
v-if="activeFilterFields.includes('businessunit')"
|
||||||
v-model="filterState.businessunitid"
|
v-model="filterState.businessunitid"
|
||||||
@@ -209,6 +224,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Generic fallback -->
|
<!-- Generic fallback -->
|
||||||
|
<!--
|
||||||
|
Any report that returns {columns, rows} renders as a table. Every core
|
||||||
|
report above has a hand-written block; a plugin report had none, so it
|
||||||
|
fell through to a raw JSON dump - which is what the printedparts reports
|
||||||
|
have been showing since they were added. The shape is already the
|
||||||
|
convention on the server side, so honouring it here means a plugin gets
|
||||||
|
a real report with no core code at all.
|
||||||
|
-->
|
||||||
|
<div v-else-if="genericRows" class="report-content">
|
||||||
|
<p v-if="reportData.summary" class="report-summary">
|
||||||
|
<span v-for="(value, key) in reportData.summary" :key="key" class="summary-figure">
|
||||||
|
<strong>{{ value }}</strong> {{ titleCase(key) }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-else class="report-summary">{{ genericRows.length }} row(s)</p>
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th v-for="column in genericColumns" :key="column">{{ titleCase(column) }}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in genericRows" :key="index">
|
||||||
|
<td v-for="column in genericColumns" :key="column">{{ row[column] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="!genericRows.length" class="report-empty">
|
||||||
|
Nothing in this period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last resort: a shape nothing here knows. Better visible than blank. -->
|
||||||
<div v-else class="report-content">
|
<div v-else class="report-content">
|
||||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,11 +267,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { apiError } from '@/utils/apiError'
|
||||||
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||||
|
import { emailTableFrom, titleCase } from '@/utils/reportTable'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// Emailing a report is the reports.export permission, the same one the endpoint
|
||||||
|
// enforces. Offering the button to somebody who will only get a 403 is worse
|
||||||
|
// than not offering it.
|
||||||
|
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
||||||
|
|
||||||
const reports = ref([])
|
const reports = ref([])
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
@@ -240,18 +297,55 @@ const reportFilterFields = {
|
|||||||
'assets-by-status': ['assettype', 'businessunit'],
|
'assets-by-status': ['assettype', 'businessunit'],
|
||||||
'asset-inventory': ['businessunit', 'location'],
|
'asset-inventory': ['businessunit', 'location'],
|
||||||
'kb-popularity': ['limit'],
|
'kb-popularity': ['limit'],
|
||||||
'software-compliance': ['application']
|
'software-compliance': ['application'],
|
||||||
|
// Time-windowed reports. The endpoints already accepted a window; there was
|
||||||
|
// simply no way to choose one from the page.
|
||||||
|
'usb-audit': ['period'],
|
||||||
|
'printedparts-consumption': ['period'],
|
||||||
|
'printedparts-by-person': ['period']
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
|
// Named windows rather than a day count: the question is "this shift" or "this
|
||||||
|
// year", and making somebody work out that a year is 365 serves nobody.
|
||||||
|
const PERIOD_OPTIONS = [
|
||||||
|
{ value: 'day', label: 'Today' },
|
||||||
|
{ value: 'month', label: 'Last month' },
|
||||||
|
{ value: 'sixmonths', label: 'Last 6 months' },
|
||||||
|
{ value: 'year', label: 'Last year' },
|
||||||
|
{ value: 'all', label: 'All time' }
|
||||||
|
]
|
||||||
|
const PERIOD_DAYS = { day: 1, month: 30, sixmonths: 182, year: 365, all: 0 }
|
||||||
|
|
||||||
|
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' })
|
||||||
|
const reportError = ref('')
|
||||||
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
||||||
|
|
||||||
|
// A report is "generic" when it returns rows plus the columns to show them in.
|
||||||
|
const genericRows = computed(() => {
|
||||||
|
const data = reportData.value
|
||||||
|
return data && Array.isArray(data.rows) ? data.rows : null
|
||||||
|
})
|
||||||
|
const genericColumns = computed(() => {
|
||||||
|
const data = reportData.value
|
||||||
|
if (!data) return []
|
||||||
|
if (Array.isArray(data.columns) && data.columns.length) return data.columns
|
||||||
|
// No declared columns: take them from the first row so the table still works.
|
||||||
|
return data.rows?.length ? Object.keys(data.rows[0]) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
// The table to email, built from whatever the open report returned. Emailing
|
||||||
|
// was available on two pages and nowhere else, purely because those two were
|
||||||
|
// built as their own routed pages - the endpoint has always taken any
|
||||||
|
// {columns, rows}. Lives in its own module so the shapes can be tested.
|
||||||
|
const emailtable = computed(() => emailTableFrom(reportData.value))
|
||||||
|
|
||||||
const activeFilterFields = computed(() =>
|
const activeFilterFields = computed(() =>
|
||||||
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
||||||
)
|
)
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
|
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '', period: 'month' }
|
||||||
|
reportError.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch dropdown options once, only for the fields the open report needs
|
// fetch dropdown options once, only for the fields the open report needs
|
||||||
@@ -290,6 +384,11 @@ function filterParams() {
|
|||||||
if (state.locationid) params.locationid = state.locationid
|
if (state.locationid) params.locationid = state.locationid
|
||||||
if (state.appid) params.appid = state.appid
|
if (state.appid) params.appid = state.appid
|
||||||
if (state.limit) params.limit = state.limit
|
if (state.limit) params.limit = state.limit
|
||||||
|
if (activeFilterFields.value.includes('period') && state.period) {
|
||||||
|
params.period = state.period
|
||||||
|
// Sent as well so an endpoint that only knows ?days= still honours it.
|
||||||
|
params.days = PERIOD_DAYS[state.period]
|
||||||
|
}
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,12 +411,6 @@ async function loadReports() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function titleCase(text) {
|
|
||||||
return String(text || '')
|
|
||||||
.split(/[\s_-]+/)
|
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by name/description/category, then group by category in the fixed order.
|
// Filter by name/description/category, then group by category in the fixed order.
|
||||||
const groupedReports = computed(() => {
|
const groupedReports = computed(() => {
|
||||||
@@ -386,44 +479,64 @@ function applyQuery(id) {
|
|||||||
async function runReport(report) {
|
async function runReport(report) {
|
||||||
currentReport.value = report
|
currentReport.value = report
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
reportError.value = ''
|
||||||
reportData.value = null
|
reportData.value = null
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
|
|
||||||
const params = filterParams()
|
const params = filterParams()
|
||||||
try {
|
try {
|
||||||
let response
|
// Any report that declares an endpoint is fetched from it.
|
||||||
switch (report.id) {
|
//
|
||||||
case 'machines-by-type':
|
// This used to be a switch over five hardcoded core ids with
|
||||||
response = await reportsApi.machinesByType(params)
|
// `default: console.error('Unknown report')`. A plugin could register a
|
||||||
break
|
// report, have it appear in the catalogue, and then render nothing at all
|
||||||
case 'assets-by-status':
|
// when opened - the three printedparts reports were in exactly that state.
|
||||||
response = await reportsApi.assetsByStatus(params)
|
// The endpoint is already on the contract; using it means a plugin never
|
||||||
break
|
// needs a line of core code to be runnable.
|
||||||
case 'kb-popularity':
|
const url = report.endpoint || `/api/reports/${report.id}`
|
||||||
response = await reportsApi.kbPopularity(params)
|
const response = await api.get(stripApiPrefix(url), { params })
|
||||||
break
|
|
||||||
case 'software-compliance':
|
|
||||||
response = await reportsApi.softwareCompliance(params)
|
|
||||||
break
|
|
||||||
case 'asset-inventory':
|
|
||||||
response = await reportsApi.assetInventory(params)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
console.error('Unknown report:', report.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reportData.value = response.data.data
|
reportData.value = response.data.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error running report:', error)
|
console.error('Error running report:', error)
|
||||||
|
reportError.value = apiError(error, 'Could not run this report')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCSV() {
|
// The api client is already mounted at /api, so an endpoint from the contract
|
||||||
|
// (which carries the prefix, because other consumers call it absolutely) has to
|
||||||
|
// have it removed rather than doubled.
|
||||||
|
function stripApiPrefix (url) {
|
||||||
|
return url.startsWith('/api/') ? url.slice(4) : url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCSV() {
|
||||||
if (!currentReport.value) return
|
if (!currentReport.value) return
|
||||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
// Same endpoint the report itself came from - a plugin report used to export
|
||||||
window.open(withBase(`/api/reports/${currentReport.value.id}?${params}`), '_blank')
|
// from /api/reports/<id>, which does not exist for it.
|
||||||
|
//
|
||||||
|
// Fetched through the api client, not window.open: a new window carries no
|
||||||
|
// Authorization header, so any report behind a real jwt_required (every
|
||||||
|
// plugin one) answered the export button with a 401 page. The core reports
|
||||||
|
// are jwt-optional, which is the only reason window.open ever appeared to
|
||||||
|
// work.
|
||||||
|
const url = currentReport.value.endpoint || `/api/reports/${currentReport.value.id}`
|
||||||
|
reportError.value = ''
|
||||||
|
try {
|
||||||
|
const response = await api.get(stripApiPrefix(url), {
|
||||||
|
params: { format: 'csv', ...filterParams() },
|
||||||
|
responseType: 'blob'
|
||||||
|
})
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = URL.createObjectURL(response.data)
|
||||||
|
link.download = `${currentReport.value.id}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting report:', error)
|
||||||
|
reportError.value = apiError(error, 'Could not export this report')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearReport() {
|
function clearReport() {
|
||||||
@@ -433,6 +546,10 @@ function clearReport() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.summary-figure { margin-right: 1.25rem; }
|
||||||
|
.table-scroll { overflow-x: auto; }
|
||||||
|
.report-empty { color: var(--text-light); margin-top: 0.75rem; }
|
||||||
|
|
||||||
.report-group {
|
.report-group {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,17 @@
|
|||||||
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
|
<p class="text-muted" style="font-size: 0.85rem; margin: 0 0 0.5rem 0">
|
||||||
Select which actions this role can perform
|
Select which actions this role can perform
|
||||||
</p>
|
</p>
|
||||||
|
<!-- The guest role is everyone who is not signed in, so what it may
|
||||||
|
hold is bounded: reads, plus the few writes a plugin has
|
||||||
|
declared safe to do anonymously. The rest are disabled here
|
||||||
|
rather than refused on save, so the limit is visible while the
|
||||||
|
decision is being made. -->
|
||||||
|
<p v-if="editingRole?.isguest" class="guest-note">
|
||||||
|
This role is <strong>everyone who is not signed in</strong>.
|
||||||
|
Anything ticked here is readable by anybody who can reach this
|
||||||
|
site, with no account and no name in the audit log. Greyed-out
|
||||||
|
permissions cannot be given to guests at all.
|
||||||
|
</p>
|
||||||
<div class="permissions-grid">
|
<div class="permissions-grid">
|
||||||
<div v-for="(perms, category) in permissionsGrouped" :key="category" class="permission-category">
|
<div v-for="(perms, category) in permissionsGrouped" :key="category" class="permission-category">
|
||||||
<div class="category-header">
|
<div class="category-header">
|
||||||
@@ -239,10 +250,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="category-perms">
|
<div class="category-perms">
|
||||||
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item">
|
<label v-for="p in perms" :key="p.name" class="checkbox-label perm-item"
|
||||||
|
:class="{ 'perm-blocked': !mayGrant(p.name) }"
|
||||||
|
:title="mayGrant(p.name) ? null
|
||||||
|
: 'A guest cannot be given this. Reading is grantable; a write has to be declared safe to do without an account.'">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="p.name"
|
:value="p.name"
|
||||||
|
:disabled="!mayGrant(p.name)"
|
||||||
v-model="roleForm.permissions"
|
v-model="roleForm.permissions"
|
||||||
>
|
>
|
||||||
{{ p.description }}
|
{{ p.description }}
|
||||||
@@ -382,19 +397,33 @@ function formatCategory(cat) {
|
|||||||
return cat.charAt(0).toUpperCase() + cat.slice(1)
|
return cat.charAt(0).toUpperCase() + cat.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which permissions this role is allowed to hold at all. Only the guest role
|
||||||
|
// is bounded; the API sends the list it will accept, so the grid and the server
|
||||||
|
// cannot disagree about it.
|
||||||
|
function mayGrant(name) {
|
||||||
|
if (!editingRole.value?.isguest) return true
|
||||||
|
const grantable = editingRole.value?.grantable
|
||||||
|
return Array.isArray(grantable) ? grantable.includes(name) : false
|
||||||
|
}
|
||||||
|
|
||||||
function isCategoryFullySelected(category) {
|
function isCategoryFullySelected(category) {
|
||||||
const catPerms = permissionsGrouped.value[category] || []
|
const catPerms = (permissionsGrouped.value[category] || [])
|
||||||
|
.filter(p => mayGrant(p.name))
|
||||||
return catPerms.length > 0 && catPerms.every(p => roleForm.permissions.includes(p.name))
|
return catPerms.length > 0 && catPerms.every(p => roleForm.permissions.includes(p.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCategoryPartiallySelected(category) {
|
function isCategoryPartiallySelected(category) {
|
||||||
const catPerms = permissionsGrouped.value[category] || []
|
const catPerms = (permissionsGrouped.value[category] || [])
|
||||||
|
.filter(p => mayGrant(p.name))
|
||||||
const selected = catPerms.filter(p => roleForm.permissions.includes(p.name))
|
const selected = catPerms.filter(p => roleForm.permissions.includes(p.name))
|
||||||
return selected.length > 0 && selected.length < catPerms.length
|
return selected.length > 0 && selected.length < catPerms.length
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCategory(category, checked) {
|
function toggleCategory(category, checked) {
|
||||||
const catPerms = permissionsGrouped.value[category] || []
|
// Ticking a whole category must not try to grant a guest something the API
|
||||||
|
// will refuse - the header checkbox would then look broken.
|
||||||
|
const catPerms = (permissionsGrouped.value[category] || [])
|
||||||
|
.filter(p => mayGrant(p.name))
|
||||||
const permNames = catPerms.map(p => p.name)
|
const permNames = catPerms.map(p => p.name)
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -780,4 +809,13 @@ onMounted(loadData)
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.perm-blocked { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.guest-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--warning);
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ def _summary(revision, islatest=False):
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/kinds', methods=['GET'])
|
@backups_bp.route('/kinds', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.view')
|
@require_permission('backups.view')
|
||||||
def list_kinds():
|
def list_kinds():
|
||||||
"""Registered backup kinds, for UI that needs to enumerate them."""
|
"""Registered backup kinds, for UI that needs to enumerate them."""
|
||||||
@@ -94,7 +93,6 @@ def list_kinds():
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/asset/<int:assetid>', methods=['GET'])
|
@backups_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.view')
|
@require_permission('backups.view')
|
||||||
def asset_revisions(assetid):
|
def asset_revisions(assetid):
|
||||||
"""Revision history for one asset, newest first.
|
"""Revision history for one asset, newest first.
|
||||||
@@ -130,7 +128,6 @@ def asset_revisions(assetid):
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/asset/<int:assetid>/info', methods=['GET'])
|
@backups_bp.route('/asset/<int:assetid>/info', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.view')
|
@require_permission('backups.view')
|
||||||
def asset_info(assetid):
|
def asset_info(assetid):
|
||||||
"""A kind's 'at a glance' card, built from its LATEST revision.
|
"""A kind's 'at a glance' card, built from its LATEST revision.
|
||||||
@@ -190,7 +187,6 @@ def asset_info(assetid):
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/revisions/<int:backuprevisionid>', methods=['GET'])
|
@backups_bp.route('/revisions/<int:backuprevisionid>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.view')
|
@require_permission('backups.view')
|
||||||
def get_revision(backuprevisionid):
|
def get_revision(backuprevisionid):
|
||||||
"""One revision, including its parsed payload where there is one."""
|
"""One revision, including its parsed payload where there is one."""
|
||||||
@@ -207,7 +203,6 @@ def get_revision(backuprevisionid):
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/revisions/<int:backuprevisionid>/download', methods=['GET'])
|
@backups_bp.route('/revisions/<int:backuprevisionid>/download', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.download')
|
@require_permission('backups.download')
|
||||||
def download_revision(backuprevisionid):
|
def download_revision(backuprevisionid):
|
||||||
"""Render a revision back to its native file format.
|
"""Render a revision back to its native file format.
|
||||||
@@ -282,7 +277,6 @@ def download_revision(backuprevisionid):
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/revisions/<int:backuprevisionid>/diff', methods=['GET'])
|
@backups_bp.route('/revisions/<int:backuprevisionid>/diff', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.view')
|
@require_permission('backups.view')
|
||||||
def diff_revision(backuprevisionid):
|
def diff_revision(backuprevisionid):
|
||||||
"""Diff two revisions of the same asset+kind.
|
"""Diff two revisions of the same asset+kind.
|
||||||
@@ -372,7 +366,6 @@ def _diffprojections(old, new):
|
|||||||
|
|
||||||
|
|
||||||
@backups_bp.route('/dashboard/stale', methods=['GET'])
|
@backups_bp.route('/dashboard/stale', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('backups.view')
|
@require_permission('backups.view')
|
||||||
def dashboard_stale():
|
def dashboard_stale():
|
||||||
"""Chains whose backup has stopped running.
|
"""Chains whose backup has stopped running.
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ changed - so retention exists for the pathological case, not the normal one: a
|
|||||||
value that flaps would otherwise append a revision every collection cycle
|
value that flaps would otherwise append a revision every collection cycle
|
||||||
forever.
|
forever.
|
||||||
|
|
||||||
Two independent limits, both off by default at 0:
|
Two independent limits. Both are OFF in this module - the arguments below
|
||||||
|
default to 0 - but the plugin SEEDS `backups_retentioncount` at 50, so on a
|
||||||
|
default install count-based pruning is on and age-based pruning is not. Read
|
||||||
|
the seeded settings in plugin.py, not these argument defaults, when answering
|
||||||
|
"is retention on here".
|
||||||
|
|
||||||
retentioncount keep at most N revisions per chain
|
retentioncount keep at most N revisions per chain (setting default: 50)
|
||||||
retentiondays additionally drop anything older than N days
|
retentiondays additionally drop anything older than N days (default: off)
|
||||||
|
|
||||||
A CHAIN is one asset, one kind, one source hostname. Several PCs can share a
|
A CHAIN is one asset, one kind, one source hostname. Several PCs can share a
|
||||||
machine number, and each keeps its own revision history against that machine;
|
machine number, and each keeps its own revision history against that machine;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ computers_bp = Blueprint('computers', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/types', methods=['GET'])
|
@computers_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_computer_types():
|
def list_computer_types():
|
||||||
"""List all computer types."""
|
"""List all computer types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -39,7 +39,7 @@ def list_computer_types():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
|
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer_type(type_id: int):
|
def get_computer_type(type_id: int):
|
||||||
"""Get a single computer type."""
|
"""Get a single computer type."""
|
||||||
t = db.session.get(ComputerType, type_id)
|
t = db.session.get(ComputerType, type_id)
|
||||||
@@ -55,7 +55,6 @@ def get_computer_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/types', methods=['POST'])
|
@computers_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.create')
|
@require_permission('computers.create')
|
||||||
def create_computer_type():
|
def create_computer_type():
|
||||||
"""Create a new computer type."""
|
"""Create a new computer type."""
|
||||||
@@ -93,7 +92,6 @@ def create_computer_type():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
|
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.edit')
|
@require_permission('computers.edit')
|
||||||
def update_computer_type(type_id: int):
|
def update_computer_type(type_id: int):
|
||||||
"""Update a computer type."""
|
"""Update a computer type."""
|
||||||
@@ -127,7 +125,6 @@ def update_computer_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
@computers_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.delete')
|
@require_permission('computers.delete')
|
||||||
def delete_computer_type(type_id: int):
|
def delete_computer_type(type_id: int):
|
||||||
"""Delete a computer type. Refused if any PC still uses it."""
|
"""Delete a computer type. Refused if any PC still uses it."""
|
||||||
@@ -148,7 +145,7 @@ def delete_computer_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/protocols', methods=['GET'])
|
@computers_bp.route('/protocols', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_protocols():
|
def list_protocols():
|
||||||
"""List access protocols. ?active=false includes disabled ones."""
|
"""List access protocols. ?active=false includes disabled ones."""
|
||||||
query = AccessProtocol.query
|
query = AccessProtocol.query
|
||||||
@@ -159,7 +156,6 @@ def list_protocols():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/protocols', methods=['POST'])
|
@computers_bp.route('/protocols', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.edit')
|
@require_permission('computers.edit')
|
||||||
def create_protocol():
|
def create_protocol():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -180,7 +176,6 @@ def create_protocol():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/protocols/<int:protocol_id>', methods=['PUT', 'PATCH'])
|
@computers_bp.route('/protocols/<int:protocol_id>', methods=['PUT', 'PATCH'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.edit')
|
@require_permission('computers.edit')
|
||||||
def update_protocol(protocol_id):
|
def update_protocol(protocol_id):
|
||||||
p = db.session.get(AccessProtocol, protocol_id)
|
p = db.session.get(AccessProtocol, protocol_id)
|
||||||
@@ -199,7 +194,6 @@ def update_protocol(protocol_id):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/protocols/<int:protocol_id>', methods=['DELETE'])
|
@computers_bp.route('/protocols/<int:protocol_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.edit')
|
@require_permission('computers.edit')
|
||||||
def delete_protocol(protocol_id):
|
def delete_protocol(protocol_id):
|
||||||
p = db.session.get(AccessProtocol, protocol_id)
|
p = db.session.get(AccessProtocol, protocol_id)
|
||||||
@@ -299,7 +293,7 @@ def _sync_access_methods(comp, data):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/display-kiosks', methods=['GET'])
|
@computers_bp.route('/display-kiosks', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_display_kiosks():
|
def list_display_kiosks():
|
||||||
"""Reporting display kiosks, for the Dashboard Defaults picker.
|
"""Reporting display kiosks, for the Dashboard Defaults picker.
|
||||||
|
|
||||||
@@ -335,7 +329,7 @@ def list_display_kiosks():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('', methods=['GET'])
|
@computers_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def list_computers():
|
def list_computers():
|
||||||
"""
|
"""
|
||||||
List all computers with filtering and pagination.
|
List all computers with filtering and pagination.
|
||||||
@@ -443,7 +437,7 @@ def list_computers():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>', methods=['GET'])
|
@computers_bp.route('/<int:computer_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer(computer_id: int):
|
def get_computer(computer_id: int):
|
||||||
"""Get a single computer with full details."""
|
"""Get a single computer with full details."""
|
||||||
comp = db.session.get(Computer, computer_id)
|
comp = db.session.get(Computer, computer_id)
|
||||||
@@ -467,7 +461,7 @@ def get_computer(computer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer_by_asset(asset_id: int):
|
def get_computer_by_asset(asset_id: int):
|
||||||
"""Get computer data by asset ID."""
|
"""Get computer data by asset ID."""
|
||||||
comp = Computer.query.filter_by(assetid=asset_id).first()
|
comp = Computer.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -486,7 +480,7 @@ def get_computer_by_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_computer_by_hostname(hostname: str):
|
def get_computer_by_hostname(hostname: str):
|
||||||
"""Get computer by hostname."""
|
"""Get computer by hostname."""
|
||||||
comp = Computer.query.filter_by(hostname=hostname).first()
|
comp = Computer.query.filter_by(hostname=hostname).first()
|
||||||
@@ -505,7 +499,6 @@ def get_computer_by_hostname(hostname: str):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('', methods=['POST'])
|
@computers_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.create')
|
@require_permission('computers.create')
|
||||||
def create_computer():
|
def create_computer():
|
||||||
"""
|
"""
|
||||||
@@ -623,7 +616,6 @@ def create_computer():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
|
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.edit')
|
@require_permission('computers.edit')
|
||||||
def update_computer(computer_id: int):
|
def update_computer(computer_id: int):
|
||||||
"""Update computer (both Asset and Computer records)."""
|
"""Update computer (both Asset and Computer records)."""
|
||||||
@@ -736,7 +728,6 @@ def update_computer(computer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
|
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.delete')
|
@require_permission('computers.delete')
|
||||||
def delete_computer(computer_id: int):
|
def delete_computer(computer_id: int):
|
||||||
"""Delete (soft delete) computer."""
|
"""Delete (soft delete) computer."""
|
||||||
@@ -766,7 +757,7 @@ def delete_computer(computer_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
|
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def get_installed_apps(computer_id: int):
|
def get_installed_apps(computer_id: int):
|
||||||
"""Get all installed applications for a computer."""
|
"""Get all installed applications for a computer."""
|
||||||
comp = db.session.get(Computer, computer_id)
|
comp = db.session.get(Computer, computer_id)
|
||||||
@@ -789,7 +780,6 @@ def get_installed_apps(computer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
|
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.create')
|
@require_permission('computers.create')
|
||||||
def add_installed_app(computer_id: int):
|
def add_installed_app(computer_id: int):
|
||||||
"""Add an installed application to a computer."""
|
"""Add an installed application to a computer."""
|
||||||
@@ -846,7 +836,6 @@ def add_installed_app(computer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
|
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.delete')
|
@require_permission('computers.delete')
|
||||||
def remove_installed_app(computer_id: int, app_id: int):
|
def remove_installed_app(computer_id: int, app_id: int):
|
||||||
"""Remove an installed application from a computer."""
|
"""Remove an installed application from a computer."""
|
||||||
@@ -874,7 +863,6 @@ def remove_installed_app(computer_id: int, app_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
|
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.create')
|
@require_permission('computers.create')
|
||||||
def report_status(computer_id: int):
|
def report_status(computer_id: int):
|
||||||
"""
|
"""
|
||||||
@@ -913,7 +901,7 @@ def report_status(computer_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@computers_bp.route('/dashboard/summary', methods=['GET'])
|
@computers_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('computers.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get computer dashboard summary data."""
|
"""Get computer dashboard summary data."""
|
||||||
# Total active computers
|
# Total active computers
|
||||||
@@ -958,7 +946,6 @@ def dashboard_summary():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/dashboard/quiet', methods=['GET'])
|
@computers_bp.route('/dashboard/quiet', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.view')
|
@require_permission('computers.view')
|
||||||
def dashboard_quiet():
|
def dashboard_quiet():
|
||||||
"""PCs that WERE reporting, have stopped, and are still meant to be in use.
|
"""PCs that WERE reporting, have stopped, and are still meant to be in use.
|
||||||
@@ -1012,7 +999,6 @@ def dashboard_quiet():
|
|||||||
|
|
||||||
|
|
||||||
@computers_bp.route('/dashboard/sharedmachines', methods=['GET'])
|
@computers_bp.route('/dashboard/sharedmachines', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('computers.view')
|
@require_permission('computers.view')
|
||||||
def dashboard_sharedmachines():
|
def dashboard_sharedmachines():
|
||||||
"""Machine numbers claimed by more than one PC, with nothing filed under
|
"""Machine numbers claimed by more than one PC, with nothing filed under
|
||||||
|
|||||||
@@ -1974,7 +1974,9 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'name': 'PCs',
|
'name': 'PCs',
|
||||||
'icon': 'desktop',
|
'icon': 'desktop',
|
||||||
'route': '/pcs',
|
'route': '/pcs',
|
||||||
|
'permission': 'computers.view',
|
||||||
'position': 15,
|
'position': 15,
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from shopdb.api import Setting
|
|||||||
from shopdb.api import send_upload
|
from shopdb.api import send_upload
|
||||||
|
|
||||||
from ..models import DirectoryEmployee
|
from ..models import DirectoryEmployee
|
||||||
|
from shopdb.api import require_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -198,6 +199,7 @@ def _with_photo_url(employee):
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/resolve/<sso>', methods=['GET'])
|
@employees_bp.route('/resolve/<sso>', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def resolve_employee(sso):
|
def resolve_employee(sso):
|
||||||
"""Resolve one SSO to a name and photo, and say HOW.
|
"""Resolve one SSO to a name and photo, and say HOW.
|
||||||
@@ -275,6 +277,7 @@ def resolve_employee(sso):
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/search', methods=['GET'])
|
@employees_bp.route('/search', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
def search_employees():
|
def search_employees():
|
||||||
"""
|
"""
|
||||||
Search employees by name.
|
Search employees by name.
|
||||||
@@ -327,6 +330,7 @@ def search_employees():
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/lookup/<sso>', methods=['GET'])
|
@employees_bp.route('/lookup/<sso>', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
def lookup_employee(sso):
|
def lookup_employee(sso):
|
||||||
"""Look up a single employee by SSO."""
|
"""Look up a single employee by SSO."""
|
||||||
if not sso.isdigit():
|
if not sso.isdigit():
|
||||||
@@ -370,6 +374,7 @@ def lookup_employee(sso):
|
|||||||
|
|
||||||
|
|
||||||
@employees_bp.route('/lookup', methods=['GET'])
|
@employees_bp.route('/lookup', methods=['GET'])
|
||||||
|
@require_permission('employees.view')
|
||||||
def lookup_employees():
|
def lookup_employees():
|
||||||
"""
|
"""
|
||||||
Look up multiple employees by SSO list.
|
Look up multiple employees by SSO list.
|
||||||
@@ -429,7 +434,7 @@ def lookup_employees():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@employees_bp.route('/directory', methods=['GET'])
|
@employees_bp.route('/directory', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('employees.view')
|
||||||
def list_directory():
|
def list_directory():
|
||||||
"""Full self-hosted directory (for the management page)."""
|
"""Full self-hosted directory (for the management page)."""
|
||||||
guard = _require_selfhosted()
|
guard = _require_selfhosted()
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ class EmployeesPlugin(BasePlugin):
|
|||||||
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
|
api_prefix=self._manifest.get('api_prefix', '/api/employees'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_permissions(self) -> List:
|
||||||
|
"""Reading the staff directory.
|
||||||
|
|
||||||
|
The directory is names, SSOs, photographs and who reports to whom. It
|
||||||
|
was reachable with no account at all, which is how a permissions
|
||||||
|
question ends up answered by nobody. One read permission: the write
|
||||||
|
side of this plugin is an import, run from the CLI.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
('employees.view', 'View the employee directory', 'employees'),
|
||||||
|
]
|
||||||
|
|
||||||
def get_blueprint(self) -> Optional[Blueprint]:
|
def get_blueprint(self) -> Optional[Blueprint]:
|
||||||
"""Return Flask Blueprint with API routes."""
|
"""Return Flask Blueprint with API routes."""
|
||||||
return employees_bp
|
return employees_bp
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||||
|
require_any_permission,
|
||||||
authorized_service_token, Setting, Application,
|
authorized_service_token, Setting, Application,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,8 +339,7 @@ def post_report():
|
|||||||
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes', methods=['GET'])
|
@geenforce_bp.route('/scopes', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def list_scopes():
|
def list_scopes():
|
||||||
"""List imaging PC-type scopes with entry + published-version counts."""
|
"""List imaging PC-type scopes with entry + published-version counts."""
|
||||||
scopes = ManifestScope.query.order_by(
|
scopes = ManifestScope.query.order_by(
|
||||||
@@ -364,8 +364,7 @@ def list_scopes():
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def preview_scope(scopeid):
|
def preview_scope(scopeid):
|
||||||
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
@@ -377,8 +376,7 @@ def preview_scope(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/applications', methods=['GET'])
|
@geenforce_bp.route('/applications', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def list_applications():
|
def list_applications():
|
||||||
"""The core Applications catalog (id + name) for the curated entry->app link
|
"""The core Applications catalog (id + name) for the curated entry->app link
|
||||||
picker in the entry editor."""
|
picker in the entry editor."""
|
||||||
@@ -446,7 +444,6 @@ def _apply_app_link(entry, payload):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes', methods=['POST'])
|
@geenforce_bp.route('/scopes', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def create_scope():
|
def create_scope():
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
@@ -475,8 +472,7 @@ def create_scope():
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def get_scope(scopeid):
|
def get_scope(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
if not scope:
|
if not scope:
|
||||||
@@ -487,7 +483,6 @@ def get_scope(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def update_scope(scopeid):
|
def update_scope(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
@@ -510,7 +505,6 @@ def update_scope(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def delete_scope(scopeid):
|
def delete_scope(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
@@ -533,7 +527,6 @@ def _validate_entry(payload):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def create_entry(scopeid):
|
def create_entry(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
@@ -558,7 +551,6 @@ def create_entry(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def update_entry(entryid):
|
def update_entry(entryid):
|
||||||
entry = db.session.get(ManifestEntry, entryid)
|
entry = db.session.get(ManifestEntry, entryid)
|
||||||
@@ -587,7 +579,6 @@ def update_entry(entryid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def delete_entry(entryid):
|
def delete_entry(entryid):
|
||||||
entry = db.session.get(ManifestEntry, entryid)
|
entry = db.session.get(ManifestEntry, entryid)
|
||||||
@@ -599,7 +590,6 @@ def delete_entry(entryid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
def reorder_entries(scopeid):
|
def reorder_entries(scopeid):
|
||||||
"""Set entry order from a list of entryids (the ordering contract)."""
|
"""Set entry order from a list of entryids (the ordering contract)."""
|
||||||
@@ -621,8 +611,7 @@ def reorder_entries(scopeid):
|
|||||||
# -- simulate: "what would this PC get" ---------------------------------------
|
# -- simulate: "what would this PC get" ---------------------------------------
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def simulate_scope(scopeid):
|
def simulate_scope(scopeid):
|
||||||
"""Which draft entries apply to a given machine profile, and why the rest
|
"""Which draft entries apply to a given machine profile, and why the rest
|
||||||
are filtered out (reuses the engine-mirror filters)."""
|
are filtered out (reuses the engine-mirror filters)."""
|
||||||
@@ -662,8 +651,7 @@ def simulate_scope(scopeid):
|
|||||||
# -- compliance: "how much of the fleet has this app" -------------------------
|
# -- compliance: "how much of the fleet has this app" -------------------------
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def scope_compliance(scopeid):
|
def scope_compliance(scopeid):
|
||||||
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
||||||
|
|
||||||
@@ -684,7 +672,6 @@ PAYLOAD_MAX_BYTES = 1024 * 1024
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
def upload_entry_payload(entryid):
|
def upload_entry_payload(entryid):
|
||||||
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
||||||
@@ -709,8 +696,7 @@ def upload_entry_payload(entryid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def download_entry_payload(entryid):
|
def download_entry_payload(entryid):
|
||||||
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
||||||
entry = db.session.get(ManifestEntry, entryid)
|
entry = db.session.get(ManifestEntry, entryid)
|
||||||
@@ -730,7 +716,6 @@ def download_entry_payload(entryid):
|
|||||||
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
def publish_scope_route(scopeid):
|
def publish_scope_route(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
@@ -759,8 +744,7 @@ def publish_scope_route(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def publish_preflight(scopeid):
|
def publish_preflight(scopeid):
|
||||||
"""Would publishing this scope outrun the fleet's enforcer lib?
|
"""Would publishing this scope outrun the fleet's enforcer lib?
|
||||||
|
|
||||||
@@ -781,8 +765,7 @@ def publish_preflight(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def list_versions(scopeid):
|
def list_versions(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
if not scope:
|
if not scope:
|
||||||
@@ -801,8 +784,7 @@ def list_versions(scopeid):
|
|||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
||||||
methods=['GET'])
|
methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def get_version(scopeid, versionnumber):
|
def get_version(scopeid, versionnumber):
|
||||||
version = ManifestPublishedVersion.query.filter_by(
|
version = ManifestPublishedVersion.query.filter_by(
|
||||||
scopeid=scopeid, versionnumber=versionnumber).first()
|
scopeid=scopeid, versionnumber=versionnumber).first()
|
||||||
@@ -830,8 +812,7 @@ def _normalize_cidrs(raw):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/config', methods=['GET'])
|
@geenforce_bp.route('/config', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def get_config():
|
def get_config():
|
||||||
"""Plugin config: the on-share export root + the client IP allowlist."""
|
"""Plugin config: the on-share export root + the client IP allowlist."""
|
||||||
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||||
@@ -843,7 +824,6 @@ def get_config():
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/config', methods=['PUT'])
|
@geenforce_bp.route('/config', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
def put_config():
|
def put_config():
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
@@ -870,7 +850,6 @@ def put_config():
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
def export_share_route(scopeid):
|
def export_share_route(scopeid):
|
||||||
"""Write the scope's current published JSON to the configured share root
|
"""Write the scope's current published JSON to the configured share root
|
||||||
@@ -892,7 +871,6 @@ def export_share_route(scopeid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('geenforce.publish')
|
@require_permission('geenforce.publish')
|
||||||
def rollback_scope_route(scopeid):
|
def rollback_scope_route(scopeid):
|
||||||
scope = db.session.get(ManifestScope, scopeid)
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
@@ -1221,8 +1199,7 @@ def _report_stale_cutoff():
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/reports', methods=['GET'])
|
@geenforce_bp.route('/reports', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def list_reports():
|
def list_reports():
|
||||||
"""Latest enforcement report per PC (fleet compliance view).
|
"""Latest enforcement report per PC (fleet compliance view).
|
||||||
|
|
||||||
@@ -1328,8 +1305,7 @@ def list_reports():
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def get_report(reportid):
|
def get_report(reportid):
|
||||||
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
||||||
report = db.session.get(ManifestEnforcementReport, reportid)
|
report = db.session.get(ManifestEnforcementReport, reportid)
|
||||||
@@ -1358,8 +1334,7 @@ def get_report(reportid):
|
|||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
|
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('geenforce.view', 'geenforce.manage')
|
||||||
@require_permission('geenforce.manage')
|
|
||||||
def dashboard_failures():
|
def dashboard_failures():
|
||||||
"""Entries that FAILED on their PC's most recent enforcement cycle.
|
"""Entries that FAILED on their PC's most recent enforcement cycle.
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
def get_permissions(self) -> List:
|
def get_permissions(self) -> List:
|
||||||
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
||||||
return [
|
return [
|
||||||
|
('geenforce.view', 'View imaging PC types and manifests',
|
||||||
|
'geenforce'),
|
||||||
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
||||||
'geenforce'),
|
'geenforce'),
|
||||||
('geenforce.publish', 'Publish, roll back, and export manifests',
|
('geenforce.publish', 'Publish, roll back, and export manifests',
|
||||||
@@ -125,6 +127,10 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
'icon': 'shield',
|
'icon': 'shield',
|
||||||
'route': '/geenforce/manifests',
|
'route': '/geenforce/manifests',
|
||||||
'position': 46,
|
'position': 46,
|
||||||
|
# The READ permission: managing implies it, and it is the one a
|
||||||
|
# site can hand to guests.
|
||||||
|
'permission': ['geenforce.view', 'geenforce.manage'],
|
||||||
|
'section': 'fleet',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from datetime import datetime
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from flask import Blueprint, request, Response
|
from flask import Blueprint, request, Response, current_app
|
||||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
@@ -42,9 +42,25 @@ ALLOWEDIMAGETYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _currentuser():
|
||||||
|
"""The signed-in user, or None.
|
||||||
|
|
||||||
|
`current_user` is a LocalProxy, so `current_user is not None` is True even
|
||||||
|
when nobody is signed in - the proxy exists, the user behind it does not.
|
||||||
|
Under a required JWT that never showed; the read routes are jwt-optional
|
||||||
|
now, and the first guest request found it by way of
|
||||||
|
"'NoneType' object has no attribute 'haspermission'".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return current_user._get_current_object()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _userid():
|
def _userid():
|
||||||
if current_user is not None:
|
user = _currentuser()
|
||||||
return current_user.userid
|
if user is not None:
|
||||||
|
return user.userid
|
||||||
try:
|
try:
|
||||||
return int(get_jwt_identity())
|
return int(get_jwt_identity())
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -59,8 +75,12 @@ def _has(permission):
|
|||||||
the decorator makes, including the admin bypass that lives inside
|
the decorator makes, including the admin bypass that lives inside
|
||||||
haspermission.
|
haspermission.
|
||||||
"""
|
"""
|
||||||
return bool(current_user is not None
|
user = _currentuser()
|
||||||
and current_user.haspermission(permission))
|
return bool(user is not None and user.haspermission(permission))
|
||||||
|
|
||||||
|
|
||||||
|
def _signedin():
|
||||||
|
return _currentuser() is not None
|
||||||
|
|
||||||
|
|
||||||
def _mayedit(guide):
|
def _mayedit(guide):
|
||||||
@@ -101,7 +121,6 @@ def _slugify(title, guideid=None):
|
|||||||
# ---------------------------------------------------------------- reading ---
|
# ---------------------------------------------------------------- reading ---
|
||||||
|
|
||||||
@guides_bp.route('', methods=['GET'])
|
@guides_bp.route('', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def list_guides():
|
def list_guides():
|
||||||
"""Published guides, plus the caller's own drafts if they can author.
|
"""Published guides, plus the caller's own drafts if they can author.
|
||||||
@@ -109,9 +128,15 @@ def list_guides():
|
|||||||
A half-written procedure on a shop floor is worse than no procedure, so
|
A half-written procedure on a shop floor is worse than no procedure, so
|
||||||
drafts are invisible to readers - but an author must be able to find their
|
drafts are invisible to readers - but an author must be able to find their
|
||||||
own, or drafting is a trap door.
|
own, or drafting is a trap door.
|
||||||
|
|
||||||
|
A guest reaches this only if the guest role has been granted guides.view.
|
||||||
|
A guest is never an author, so the draft branches below cannot open for one.
|
||||||
"""
|
"""
|
||||||
caneditall = _has('guides.edit')
|
caneditall = _has('guides.edit')
|
||||||
mine = _userid()
|
# A guest has no user id, and `createdby == None` is a real match in SQL
|
||||||
|
# against every guide imported without an author. Their drafts are not
|
||||||
|
# theirs; nobody's are.
|
||||||
|
mine = _userid() if _signedin() else -1
|
||||||
|
|
||||||
# Match against the PUBLISHED text for anything the caller is only allowed
|
# Match against the PUBLISHED text for anything the caller is only allowed
|
||||||
# to read, and against the working copy only for guides they may edit.
|
# to read, and against the working copy only for guides they may edit.
|
||||||
@@ -185,7 +210,6 @@ def list_guides():
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/tags', methods=['GET'])
|
@guides_bp.route('/tags', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def list_tags():
|
def list_tags():
|
||||||
"""Every tag in use, with a count, for the filter chips.
|
"""Every tag in use, with a count, for the filter chips.
|
||||||
@@ -215,7 +239,6 @@ def list_tags():
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<slug>', methods=['GET'])
|
@guides_bp.route('/<slug>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def read_guide(slug):
|
def read_guide(slug):
|
||||||
"""What a reader gets: the published revision.
|
"""What a reader gets: the published revision.
|
||||||
@@ -257,7 +280,6 @@ def read_guide(slug):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
|
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def read_working(guideid):
|
def read_working(guideid):
|
||||||
"""The working copy, for the editor."""
|
"""The working copy, for the editor."""
|
||||||
@@ -275,7 +297,6 @@ def read_working(guideid):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
|
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def list_revisions(guideid):
|
def list_revisions(guideid):
|
||||||
guide = db.session.get(Guide, guideid)
|
guide = db.session.get(Guide, guideid)
|
||||||
@@ -298,7 +319,6 @@ def list_revisions(guideid):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
|
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def read_revision(revisionid):
|
def read_revision(revisionid):
|
||||||
"""One revision by id.
|
"""One revision by id.
|
||||||
@@ -327,8 +347,40 @@ def read_revision(revisionid):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- writing ---
|
# ---------------------------------------------------------------- writing ---
|
||||||
|
|
||||||
|
def _attachimages(guide, blocks):
|
||||||
|
"""Attach the images this guide's blocks reference but nothing owns yet.
|
||||||
|
|
||||||
|
An image pasted into a guide that has not been saved is uploaded with no
|
||||||
|
guideid - there is no id to give it. The upload route says the editor
|
||||||
|
attaches it on save, and the editor never did: it only sends a guideid when
|
||||||
|
the guide already has one. So a screenshot pasted while writing a NEW guide
|
||||||
|
stayed unattached forever.
|
||||||
|
|
||||||
|
An unattached image is visible to editors and to whoever uploaded it, and
|
||||||
|
to nobody else - so the guide looked right to the person who wrote it and
|
||||||
|
showed "Image unavailable" to every reader. Attaching on save is what makes
|
||||||
|
the image belong to the guide, and therefore readable by anyone allowed to
|
||||||
|
read the guide once it is published.
|
||||||
|
|
||||||
|
Only images nothing else owns are claimed: an image already attached to
|
||||||
|
another guide is left alone rather than stolen by whoever references it.
|
||||||
|
"""
|
||||||
|
referenced = set()
|
||||||
|
for block in blocks or []:
|
||||||
|
if isinstance(block, dict) and block.get('imageid') is not None:
|
||||||
|
try:
|
||||||
|
referenced.add(int(block['imageid']))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not referenced:
|
||||||
|
return
|
||||||
|
(GuideImage.query
|
||||||
|
.filter(GuideImage.guideimageid.in_(referenced))
|
||||||
|
.filter(GuideImage.guideid.is_(None))
|
||||||
|
.update({'guideid': guide.guideid}, synchronize_session=False))
|
||||||
|
|
||||||
|
|
||||||
@guides_bp.route('', methods=['POST'])
|
@guides_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.create')
|
@require_permission('guides.create')
|
||||||
def create_guide():
|
def create_guide():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -353,13 +405,15 @@ def create_guide():
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||||
|
|
||||||
|
# The first save is exactly when a pasted image has no owner yet.
|
||||||
|
_attachimages(guide, guide.blocks)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(guide.to_dict(includeblocks=True),
|
return success_response(guide.to_dict(includeblocks=True),
|
||||||
message='Guide created')
|
message='Guide created')
|
||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def update_guide(guideid):
|
def update_guide(guideid):
|
||||||
guide = db.session.get(Guide, guideid)
|
guide = db.session.get(Guide, guideid)
|
||||||
@@ -387,6 +441,8 @@ def update_guide(guideid):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||||
|
|
||||||
|
_attachimages(guide, guide.blocks)
|
||||||
|
|
||||||
# The slug is set once, at create, and does NOT follow the title.
|
# The slug is set once, at create, and does NOT follow the title.
|
||||||
#
|
#
|
||||||
# _slugify's own docstring says the slug is stored so that renaming cannot
|
# _slugify's own docstring says the slug is stored so that renaming cannot
|
||||||
@@ -409,7 +465,6 @@ def update_guide(guideid):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
|
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.publish')
|
@require_permission('guides.publish')
|
||||||
def publish_guide(guideid):
|
def publish_guide(guideid):
|
||||||
guide = db.session.get(Guide, guideid)
|
guide = db.session.get(Guide, guideid)
|
||||||
@@ -425,7 +480,6 @@ def publish_guide(guideid):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
|
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.publish')
|
@require_permission('guides.publish')
|
||||||
def unpublish_guide(guideid):
|
def unpublish_guide(guideid):
|
||||||
guide = db.session.get(Guide, guideid)
|
guide = db.session.get(Guide, guideid)
|
||||||
@@ -437,7 +491,6 @@ def unpublish_guide(guideid):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
|
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def restore_revision(guideid, revisionid):
|
def restore_revision(guideid, revisionid):
|
||||||
guide = db.session.get(Guide, guideid)
|
guide = db.session.get(Guide, guideid)
|
||||||
@@ -458,7 +511,6 @@ def restore_revision(guideid, revisionid):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
|
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.delete')
|
@require_permission('guides.delete')
|
||||||
def delete_guide(guideid):
|
def delete_guide(guideid):
|
||||||
guide = db.session.get(Guide, guideid)
|
guide = db.session.get(Guide, guideid)
|
||||||
@@ -472,7 +524,6 @@ def delete_guide(guideid):
|
|||||||
# ----------------------------------------------------------------- images ---
|
# ----------------------------------------------------------------- images ---
|
||||||
|
|
||||||
@guides_bp.route('/images', methods=['POST'])
|
@guides_bp.route('/images', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.create')
|
@require_permission('guides.create')
|
||||||
def upload_image():
|
def upload_image():
|
||||||
"""Take a pasted or dropped image.
|
"""Take a pasted or dropped image.
|
||||||
@@ -553,9 +604,16 @@ def _mayseeimage(image):
|
|||||||
|
|
||||||
|
|
||||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('guides.view')
|
@require_permission('guides.view')
|
||||||
def serve_image(imageid):
|
def serve_image(imageid):
|
||||||
|
"""The image itself, under the same rule as the guide that holds it.
|
||||||
|
|
||||||
|
A guest who may read a published guide must see its screenshots, or every
|
||||||
|
image renders as "Image unavailable" and the guide looks broken.
|
||||||
|
_mayseeimage already answers correctly for somebody holding nothing - it
|
||||||
|
requires the PUBLISHED revision to reference the image - so a guest needs no
|
||||||
|
separate branch here.
|
||||||
|
"""
|
||||||
image = db.session.get(GuideImage, imageid)
|
image = db.session.get(GuideImage, imageid)
|
||||||
if image is None:
|
if image is None:
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||||
|
|||||||
378
plugins/guides/cli.py
Normal file
378
plugins/guides/cli.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"""CLI: move guides in and out of a site.
|
||||||
|
|
||||||
|
flask guides import-html PATH [PATH ...] an HTML article becomes a guide
|
||||||
|
flask guides export --out DIR guides become portable files
|
||||||
|
flask guides import PATH [PATH ...] those files become guides here
|
||||||
|
|
||||||
|
A site that adopts this app usually arrives with a folder of procedures already
|
||||||
|
written - styled HTML pages that get mailed around and opened off a share. This
|
||||||
|
imports them so they live where they are used, become searchable, tickable and
|
||||||
|
printable, and stop being a file somebody has to find.
|
||||||
|
|
||||||
|
Imports land as DRAFTS, always - both kinds. An HTML conversion is faithful but
|
||||||
|
it is not a render, and a bundle written on another site has not been read by
|
||||||
|
anybody here. Publishing is a decision somebody makes after reading.
|
||||||
|
|
||||||
|
Sites are separate databases and the useful ones are air-gapped from where a
|
||||||
|
guide gets written, so export/import is how the same procedure reaches all of
|
||||||
|
them: one JSON file per guide, images carried inside it, moved by whatever
|
||||||
|
moves files to that site.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask.cli import AppGroup, with_appcontext
|
||||||
|
|
||||||
|
from shopdb.api import db
|
||||||
|
|
||||||
|
from .models import Guide, GuideImage
|
||||||
|
from .services import authoring, bundle as bundleservice
|
||||||
|
from .services.blocks import BlockError
|
||||||
|
from .services.htmlimport import parse
|
||||||
|
|
||||||
|
|
||||||
|
# Mirrors what the upload route accepts. An import must not be able to store an
|
||||||
|
# image the editor would have refused, or the guide renders here and nowhere
|
||||||
|
# else.
|
||||||
|
ALLOWEDIMAGETYPES = ('image/png', 'image/jpeg', 'image/gif', 'image/webp')
|
||||||
|
MAXIMAGEBYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(title):
|
||||||
|
"""Same rule as the API's, kept here so the CLI does not import a route."""
|
||||||
|
import re
|
||||||
|
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
|
||||||
|
base = base[:180]
|
||||||
|
candidate = base
|
||||||
|
suffix = 2
|
||||||
|
while Guide.query.filter(Guide.slug == candidate).first() is not None:
|
||||||
|
candidate = '{}-{}'.format(base, suffix)
|
||||||
|
suffix += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def build_cli():
|
||||||
|
guides_cli = AppGroup('guides')
|
||||||
|
|
||||||
|
@guides_cli.command('import-html')
|
||||||
|
@click.argument('paths', nargs=-1, required=True,
|
||||||
|
type=click.Path(exists=True, dir_okay=False))
|
||||||
|
@click.option('--title', default=None,
|
||||||
|
help='Override the title taken from the article.')
|
||||||
|
@click.option('--tags', default=None,
|
||||||
|
help='Comma-separated tags for every guide imported.')
|
||||||
|
@click.option('--userid', type=int, default=None,
|
||||||
|
help='Record this user as the author.')
|
||||||
|
@click.option('--replace', is_flag=True,
|
||||||
|
help='If a guide already has this slug, edit it in place '
|
||||||
|
'(a new revision) instead of creating a second one.')
|
||||||
|
@click.option('--apply/--dry-run', default=False,
|
||||||
|
help='Write to the database. The default reports only.')
|
||||||
|
@with_appcontext
|
||||||
|
def import_html(paths, title, tags, userid, replace, apply):
|
||||||
|
"""Convert HTML article(s) into draft guides."""
|
||||||
|
if title and len(paths) > 1:
|
||||||
|
raise click.UsageError('--title names one guide; pass one file.')
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
with open(path, encoding='utf-8', errors='replace') as handle:
|
||||||
|
parsed = parse(handle.read())
|
||||||
|
|
||||||
|
name = title or parsed['title']
|
||||||
|
counts = {}
|
||||||
|
for block in parsed['blocks']:
|
||||||
|
counts[block['type']] = counts.get(block['type'], 0) + 1
|
||||||
|
|
||||||
|
click.echo('{}'.format(path))
|
||||||
|
click.echo(' title {}'.format(name))
|
||||||
|
click.echo(' blocks {} ({})'.format(
|
||||||
|
len(parsed['blocks']),
|
||||||
|
', '.join('{} {}'.format(v, k) for k, v in sorted(counts.items()))))
|
||||||
|
click.echo(' images {}'.format(len(parsed['images'])))
|
||||||
|
for note in parsed['skipped']:
|
||||||
|
click.echo(' skipped {}'.format(note))
|
||||||
|
|
||||||
|
rejected = [image for image in parsed['images']
|
||||||
|
if image['mimetype'] not in ALLOWEDIMAGETYPES
|
||||||
|
or len(image['data']) > MAXIMAGEBYTES]
|
||||||
|
for image in rejected:
|
||||||
|
click.echo(' DROPPED {} ({}, {:.1f} MB) - not an accepted image'
|
||||||
|
.format(image['token'], image['mimetype'],
|
||||||
|
len(image['data']) / 1048576))
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
click.echo(' dry run, nothing written. Pass --apply to import.')
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
guide = _import(parsed, name, tags, userid, replace, rejected)
|
||||||
|
except BlockError as error:
|
||||||
|
db.session.rollback()
|
||||||
|
click.echo(' FAILED {}'.format(error))
|
||||||
|
continue
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(' imported as /guides/{} (draft, revision {})'
|
||||||
|
.format(guide.slug, guide.revision))
|
||||||
|
|
||||||
|
@guides_cli.command('fix-images')
|
||||||
|
@click.option('--apply/--dry-run', default=False,
|
||||||
|
help='Write to the database. The default reports only.')
|
||||||
|
@with_appcontext
|
||||||
|
def fix_images(apply):
|
||||||
|
"""Attach images a guide references but nothing owns.
|
||||||
|
|
||||||
|
An image pasted into a guide that had not been saved yet was uploaded
|
||||||
|
with no guideid, and nothing ever attached it. Editors and the uploader
|
||||||
|
could see it; every other reader got "Image unavailable" on a published
|
||||||
|
guide. Saving a guide attaches its images now, but a guide nobody edits
|
||||||
|
again would stay broken, so this repairs what is already stored.
|
||||||
|
"""
|
||||||
|
orphans = GuideImage.query.filter(GuideImage.guideid.is_(None)).all()
|
||||||
|
if not orphans:
|
||||||
|
click.echo('No unattached images.')
|
||||||
|
return
|
||||||
|
|
||||||
|
wanted = {}
|
||||||
|
for guide in Guide.query.all():
|
||||||
|
for block in guide.blocks or []:
|
||||||
|
if isinstance(block, dict) and block.get('imageid') is not None:
|
||||||
|
try:
|
||||||
|
wanted.setdefault(int(block['imageid']), guide)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
fixed = 0
|
||||||
|
for image in orphans:
|
||||||
|
guide = wanted.get(image.guideimageid)
|
||||||
|
if guide is None:
|
||||||
|
click.echo(' image {} is referenced by no guide - left alone'
|
||||||
|
.format(image.guideimageid))
|
||||||
|
continue
|
||||||
|
click.echo(' image {} -> guide {} ({})'.format(
|
||||||
|
image.guideimageid, guide.guideid, guide.slug))
|
||||||
|
if apply:
|
||||||
|
image.guideid = guide.guideid
|
||||||
|
fixed += 1
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
click.echo('{} would be attached. Pass --apply to write.'.format(fixed))
|
||||||
|
return
|
||||||
|
db.session.commit()
|
||||||
|
click.echo('{} image(s) attached.'.format(fixed))
|
||||||
|
|
||||||
|
@guides_cli.command('export')
|
||||||
|
@click.option('--out', 'outdir', required=True,
|
||||||
|
type=click.Path(file_okay=False),
|
||||||
|
help='Directory to write the bundles into. Created if needed.')
|
||||||
|
@click.option('--slug', 'slugs', multiple=True,
|
||||||
|
help='Export just this guide. Repeatable. Default: all.')
|
||||||
|
@click.option('--published-only', is_flag=True,
|
||||||
|
help='Skip guides that have never been published.')
|
||||||
|
@with_appcontext
|
||||||
|
def export_guides(outdir, slugs, published_only):
|
||||||
|
"""Write guides out as portable .guide.json files."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
query = Guide.query
|
||||||
|
if slugs:
|
||||||
|
query = query.filter(Guide.slug.in_(slugs))
|
||||||
|
if published_only:
|
||||||
|
query = query.filter(Guide.publishedrevisionid.isnot(None))
|
||||||
|
guides = query.order_by(Guide.slug).all()
|
||||||
|
|
||||||
|
missing = set(slugs) - {guide.slug for guide in guides}
|
||||||
|
for slug in sorted(missing):
|
||||||
|
click.echo(' NOT FOUND {}'.format(slug))
|
||||||
|
|
||||||
|
if not guides:
|
||||||
|
click.echo('Nothing to export.')
|
||||||
|
return
|
||||||
|
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
for guide in guides:
|
||||||
|
images = GuideImage.query.filter_by(guideid=guide.guideid).all()
|
||||||
|
payload = bundleservice.build(guide, images)
|
||||||
|
path = os.path.join(outdir, '{}.guide.json'.format(guide.slug))
|
||||||
|
with open(path, 'w', encoding='utf-8') as handle:
|
||||||
|
handle.write(bundleservice.dumps(payload))
|
||||||
|
click.echo(' {} {} blocks, {} image(s), {:.0f} KB{}'.format(
|
||||||
|
os.path.basename(path), len(payload['blocks']),
|
||||||
|
len(payload['images']), os.path.getsize(path) / 1024,
|
||||||
|
'' if guide.publishedrevisionid else ' [draft here]'))
|
||||||
|
click.echo('{} guide(s) -> {}'.format(len(guides), outdir))
|
||||||
|
|
||||||
|
@guides_cli.command('import')
|
||||||
|
@click.argument('paths', nargs=-1, required=True,
|
||||||
|
type=click.Path(exists=True))
|
||||||
|
@click.option('--userid', type=int, default=None,
|
||||||
|
help='Record this user as the author on this site.')
|
||||||
|
@click.option('--replace', is_flag=True,
|
||||||
|
help='If a guide already has the bundle\'s slug, edit it in '
|
||||||
|
'place (a new revision) instead of creating a second '
|
||||||
|
'one. This is how an updated guide is re-shipped.')
|
||||||
|
@click.option('--apply/--dry-run', default=False,
|
||||||
|
help='Write to the database. The default reports only.')
|
||||||
|
@with_appcontext
|
||||||
|
def import_guides(paths, userid, replace, apply):
|
||||||
|
"""Import .guide.json bundle(s) written by `guides export`."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for path in paths:
|
||||||
|
if os.path.isdir(path):
|
||||||
|
files.extend(sorted(os.path.join(path, name)
|
||||||
|
for name in os.listdir(path)
|
||||||
|
if name.endswith('.guide.json')))
|
||||||
|
else:
|
||||||
|
files.append(path)
|
||||||
|
if not files:
|
||||||
|
click.echo('No .guide.json files found.')
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in files:
|
||||||
|
click.echo(os.path.basename(path))
|
||||||
|
try:
|
||||||
|
with open(path, encoding='utf-8') as handle:
|
||||||
|
payload = bundleservice.loads(handle.read())
|
||||||
|
except bundleservice.BundleError as error:
|
||||||
|
click.echo(' SKIPPED {}'.format(error))
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = Guide.query.filter(Guide.slug == payload['slug']).first()
|
||||||
|
click.echo(' title {}'.format(payload.get('title')))
|
||||||
|
click.echo(' blocks {}, images {}'.format(
|
||||||
|
len(payload['blocks']), len(payload['images'])))
|
||||||
|
if existing is not None:
|
||||||
|
click.echo(' {} already has this slug{}'.format(
|
||||||
|
'guide {}'.format(existing.guideid),
|
||||||
|
' - will be updated in place' if replace
|
||||||
|
else ' - a SECOND guide will be created; pass --replace to '
|
||||||
|
'update it instead'))
|
||||||
|
|
||||||
|
rejected = [image for image in payload['images']
|
||||||
|
if image['mimetype'] not in ALLOWEDIMAGETYPES
|
||||||
|
or len(image['data']) > MAXIMAGEBYTES]
|
||||||
|
for image in rejected:
|
||||||
|
click.echo(' DROPPED {} ({}) - not an accepted image'
|
||||||
|
.format(image['token'], image['mimetype']))
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
click.echo(' dry run, nothing written. Pass --apply to import.')
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
guide = _importbundle(payload, userid, replace, rejected)
|
||||||
|
except BlockError as error:
|
||||||
|
db.session.rollback()
|
||||||
|
click.echo(' FAILED {}'.format(error))
|
||||||
|
continue
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(' imported as /guides/{} (draft, revision {})'
|
||||||
|
.format(guide.slug, guide.revision))
|
||||||
|
|
||||||
|
return [guides_cli]
|
||||||
|
|
||||||
|
|
||||||
|
def _import(parsed, name, tags, userid, replace, rejected):
|
||||||
|
"""Create or update the guide and its images. Caller commits."""
|
||||||
|
slug = None
|
||||||
|
guide = None
|
||||||
|
if replace:
|
||||||
|
# Match on the slug the title WOULD produce, which is how somebody
|
||||||
|
# re-imports a corrected article over the one they imported yesterday.
|
||||||
|
import re
|
||||||
|
wanted = re.sub(r'[^a-z0-9]+', '-', (name or '').lower()).strip('-')
|
||||||
|
guide = Guide.query.filter(Guide.slug == wanted).first()
|
||||||
|
|
||||||
|
if guide is None:
|
||||||
|
slug = _slugify(name)
|
||||||
|
guide = Guide(title=name, slug=slug, status='draft', createdby=userid)
|
||||||
|
db.session.add(guide)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Images first: the block needs the stored id, and an id only exists once
|
||||||
|
# the row does.
|
||||||
|
droppedtokens = {image['token'] for image in rejected}
|
||||||
|
ids = {}
|
||||||
|
for image in parsed['images']:
|
||||||
|
if image['token'] in droppedtokens:
|
||||||
|
continue
|
||||||
|
row = GuideImage(guideid=guide.guideid, filename=image['filename'],
|
||||||
|
mimetype=image['mimetype'],
|
||||||
|
bytesize=len(image['data']), data=image['data'],
|
||||||
|
uploadedby=userid)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
ids[image['token']] = row.guideimageid
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
for block in parsed['blocks']:
|
||||||
|
block = dict(block)
|
||||||
|
token = block.pop('imagetoken', None)
|
||||||
|
if token is not None:
|
||||||
|
if token not in ids:
|
||||||
|
continue # its image was dropped; the caption would lie
|
||||||
|
block['imageid'] = ids[token]
|
||||||
|
blocks.append(block)
|
||||||
|
|
||||||
|
authoring.save(guide, title=name, summary=parsed['summary'] or None,
|
||||||
|
tags=tags, blocks=blocks,
|
||||||
|
note='Imported from HTML', userid=userid)
|
||||||
|
return guide
|
||||||
|
|
||||||
|
|
||||||
|
def _importbundle(payload, userid, replace, rejected):
|
||||||
|
"""Create or update a guide from a bundle. Caller commits."""
|
||||||
|
guide = None
|
||||||
|
if replace:
|
||||||
|
guide = Guide.query.filter(Guide.slug == payload['slug']).first()
|
||||||
|
|
||||||
|
if guide is None:
|
||||||
|
# The bundle's own slug when it is free, because the slug is the name
|
||||||
|
# somebody pastes into a work order and the same guide should have the
|
||||||
|
# same address on every site. A clash only suffixes.
|
||||||
|
slug = payload['slug']
|
||||||
|
if Guide.query.filter(Guide.slug == slug).first() is not None:
|
||||||
|
slug = _slugify(payload.get('title') or slug)
|
||||||
|
guide = Guide(title=payload.get('title') or slug, slug=slug,
|
||||||
|
status='draft', createdby=userid)
|
||||||
|
db.session.add(guide)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Replacing means the incoming bundle is the whole truth for this guide.
|
||||||
|
# Its blocks will reference the images arriving now, so the ones already
|
||||||
|
# stored become orphans - and re-shipping a guide monthly would otherwise
|
||||||
|
# grow a pile of them nothing points at.
|
||||||
|
if replace:
|
||||||
|
GuideImage.query.filter_by(guideid=guide.guideid).delete()
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
droppedtokens = {image['token'] for image in rejected}
|
||||||
|
ids = {}
|
||||||
|
for image in payload['images']:
|
||||||
|
if image['token'] in droppedtokens:
|
||||||
|
continue
|
||||||
|
row = GuideImage(guideid=guide.guideid, filename=image['filename'],
|
||||||
|
mimetype=image['mimetype'],
|
||||||
|
bytesize=len(image['data']), data=image['data'],
|
||||||
|
uploadedby=userid)
|
||||||
|
db.session.add(row)
|
||||||
|
db.session.flush()
|
||||||
|
ids[image['token']] = row.guideimageid
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
for block in payload['blocks']:
|
||||||
|
block = dict(block)
|
||||||
|
token = block.pop('imagetoken', None)
|
||||||
|
if token is not None:
|
||||||
|
if token not in ids:
|
||||||
|
continue
|
||||||
|
block['imageid'] = ids[token]
|
||||||
|
blocks.append(block)
|
||||||
|
|
||||||
|
authoring.save(guide, title=payload.get('title'),
|
||||||
|
summary=payload.get('summary'), tags=payload.get('tags'),
|
||||||
|
blocks=blocks, note='Imported from a guide bundle',
|
||||||
|
userid=userid)
|
||||||
|
return guide
|
||||||
@@ -2,14 +2,36 @@
|
|||||||
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
||||||
<template v-for="(block, index) in blocks" :key="block.id || index">
|
<template v-for="(block, index) in blocks" :key="block.id || index">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A heading and a step can be ticked off while reading, not just a
|
||||||
|
checklist item. On a long procedure the question is not "what have I
|
||||||
|
done" so much as "where was I" - somebody looks away to do the thing
|
||||||
|
the guide asked for, and looks back at a wall of text. A struck-through
|
||||||
|
section is where they were.
|
||||||
|
|
||||||
|
Reading only: in the editor a tick would be an edit.
|
||||||
|
-->
|
||||||
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
||||||
v-html="inline(block.text)"></h2>
|
:class="{ 'gb-sectiondone': isTicked(block.id) }">
|
||||||
|
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
|
||||||
|
:checked="isTicked(block.id)"
|
||||||
|
:aria-label="'Mark this section done'"
|
||||||
|
@change="toggle(block.id, $event.target.checked)">
|
||||||
|
<span v-html="inline(block.text)"></span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
||||||
v-html="inline(block.text)"></p>
|
v-html="inline(block.text)"></p>
|
||||||
|
|
||||||
<div v-else-if="block.type === 'step'" class="gb-step">
|
<div v-else-if="block.type === 'step'" class="gb-step"
|
||||||
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
|
:class="{ 'gb-sectiondone': isTicked(block.id) }">
|
||||||
|
<label class="gb-stepnum">
|
||||||
|
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
|
||||||
|
:checked="isTicked(block.id)"
|
||||||
|
:aria-label="'Mark step ' + stepNumber(index) + ' done'"
|
||||||
|
@change="toggle(block.id, $event.target.checked)">
|
||||||
|
Step {{ stepNumber(index) }}
|
||||||
|
</label>
|
||||||
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
||||||
<div v-if="block.text" v-html="inline(block.text)"></div>
|
<div v-if="block.text" v-html="inline(block.text)"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +215,20 @@ function select (block) {
|
|||||||
dark mode work with no dark-mode code. */
|
dark mode work with no dark-mode code. */
|
||||||
.gb-blocks { line-height: 1.65; }
|
.gb-blocks { line-height: 1.65; }
|
||||||
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
||||||
|
|
||||||
|
/* A ticked section is struck through and faded, not hidden: somebody scanning
|
||||||
|
back up the page has to be able to read what they already did. */
|
||||||
|
.gb-sectiondone { text-decoration: line-through; opacity: 0.55; }
|
||||||
|
.gb-sectiondone .gb-stepnum { text-decoration: none; }
|
||||||
|
.gb-sectiontick {
|
||||||
|
/* Touch-sized: a guide is read on a shopfloor PC, often with gloves on. */
|
||||||
|
width: 1.1rem;
|
||||||
|
height: 1.1rem;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.gb-heading .gb-sectiontick { text-decoration: none; }
|
||||||
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
||||||
|
|
||||||
.gb-step {
|
.gb-step {
|
||||||
@@ -253,7 +289,7 @@ function select (block) {
|
|||||||
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
||||||
border-radius: 4px; font-size: 0.9rem;
|
border-radius: 4px; font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.gb-imageloading { color: var(--textmuted); }
|
.gb-imageloading { color: var(--text-light); }
|
||||||
.gb-imagefailed { color: var(--danger, #b3261e); }
|
.gb-imagefailed { color: var(--danger, #b3261e); }
|
||||||
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,20 @@
|
|||||||
* checkboxes are live in exactly one of them.
|
* checkboxes are live in exactly one of them.
|
||||||
*
|
*
|
||||||
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
|
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
|
||||||
* plugin is disabled. Every route requires auth because every guides API route
|
* plugin is disabled.
|
||||||
* is behind JWT plus a guides.* permission.
|
*
|
||||||
|
* READING does not require auth; WRITING does. The read routes are gated on
|
||||||
|
* guides.view, and a signed-out visitor holds whatever the GUEST role has been
|
||||||
|
* granted - so the role decides, not the router. Sending a guest to /login when
|
||||||
|
* the site has granted them guides.view would make the grant look broken;
|
||||||
|
* letting them through when it has not costs one 401 the view already handles.
|
||||||
*/
|
*/
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: 'guides',
|
path: 'guides',
|
||||||
name: 'guides',
|
name: 'guides',
|
||||||
component: () => import('./views/GuidesList.vue'),
|
component: () => import('./views/GuidesList.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'guides' }
|
meta: { plugin: 'guides' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'guides/new',
|
path: 'guides/new',
|
||||||
@@ -26,7 +31,7 @@ export default [
|
|||||||
path: 'guides/:slug',
|
path: 'guides/:slug',
|
||||||
name: 'guide-view',
|
name: 'guide-view',
|
||||||
component: () => import('./views/GuideView.vue'),
|
component: () => import('./views/GuideView.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'guides' }
|
meta: { plugin: 'guides' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'guides/:guideid/edit',
|
path: 'guides/:guideid/edit',
|
||||||
|
|||||||
@@ -68,12 +68,71 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Reference picker -->
|
||||||
|
<div v-if="refOpen" class="ge-refmask" @click.self="refOpen = false">
|
||||||
|
<div class="ge-ref">
|
||||||
|
<div class="ge-refhead">
|
||||||
|
<strong>Link to a guide or article</strong>
|
||||||
|
<button type="button" @click="refOpen = false"><X :size="16" /></button>
|
||||||
|
</div>
|
||||||
|
<input ref="refInput" v-model="refQuery" class="ge-refsearch"
|
||||||
|
placeholder="Search by title..." @input="searchReferences">
|
||||||
|
<p v-if="refLoading" class="ge-hint">Searching...</p>
|
||||||
|
<p v-else-if="!refResults.length" class="ge-hint">
|
||||||
|
{{ refQuery ? 'Nothing matches that.' : 'Type to search.' }}
|
||||||
|
</p>
|
||||||
|
<ul v-else class="ge-reflist">
|
||||||
|
<li v-for="item in refResults" :key="item.kind + item.id">
|
||||||
|
<button type="button" @click="insertReference(item)">
|
||||||
|
<span class="ge-refkind">{{ item.kind }}</span>
|
||||||
|
<span class="ge-reftitle">{{ item.title }}</span>
|
||||||
|
<small v-if="item.summary">{{ item.summary }}</small>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||||
<div class="ge-editor">
|
<div class="ge-editor">
|
||||||
<div v-for="(block, index) in blocks" :key="block.id"
|
<!-- Insert point ABOVE each section.
|
||||||
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
|
The type palette used to live only at the bottom of the whole
|
||||||
@focusin="focused = block.id">
|
guide, so adding a step in the middle meant adding it at the end
|
||||||
|
and walking it up one press at a time. The insert bar puts the
|
||||||
|
palette where the section is actually going. -->
|
||||||
|
<div v-for="(block, index) in blocks" :key="block.id">
|
||||||
|
<div class="ge-insert" :class="{ 'ge-insertopen': insertAt === index }">
|
||||||
|
<button type="button" class="ge-insertbtn"
|
||||||
|
:title="'Add a section here'"
|
||||||
|
@click="insertAt = insertAt === index ? null : index">
|
||||||
|
<Plus :size="14" /> Add section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="insertAt === index" class="ge-palette">
|
||||||
|
<button v-for="type in blocktypes" :key="type" type="button"
|
||||||
|
@click="add(type, index)">{{ type }}</button>
|
||||||
|
<label class="ge-upload">
|
||||||
|
image
|
||||||
|
<input type="file" accept="image/*" hidden
|
||||||
|
@change="event => onFilePick(event, index)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ge-block"
|
||||||
|
:class="{ 'ge-focused': focused === block.id,
|
||||||
|
'ge-dragging': dragIndex === index,
|
||||||
|
'ge-dragover': dragOver === index }"
|
||||||
|
@focusin="focused = block.id"
|
||||||
|
@dragover.prevent="dragOver = index"
|
||||||
|
@dragleave="dragOver === index && (dragOver = null)"
|
||||||
|
@drop.prevent="dropOn(index)">
|
||||||
<div class="ge-blockbar">
|
<div class="ge-blockbar">
|
||||||
|
<!-- Dragging is on the HANDLE, not the whole block: a draggable
|
||||||
|
container swallows text selection inside its own textarea. -->
|
||||||
|
<span class="ge-grip" draggable="true" title="Drag to move"
|
||||||
|
@dragstart="startDrag(index, $event)" @dragend="endDrag">
|
||||||
|
<GripVertical :size="14" />
|
||||||
|
</span>
|
||||||
<span class="ge-type">{{ block.type }}</span>
|
<span class="ge-type">{{ block.type }}</span>
|
||||||
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
||||||
<option value="info">info</option>
|
<option value="info">info</option>
|
||||||
@@ -92,16 +151,23 @@
|
|||||||
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
|
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
|
||||||
<button type="button" title="Code" @click="wrap(block, '`')"><></button>
|
<button type="button" title="Code" @click="wrap(block, '`')"><></button>
|
||||||
<button type="button" title="Link" @click="makeLink(block)">link</button>
|
<button type="button" title="Link" @click="makeLink(block)">link</button>
|
||||||
|
<!-- Linking to another guide or a KB article by hand means
|
||||||
|
knowing its slug or id. This finds it by name and writes the
|
||||||
|
link, so a procedure can point at the one before it. -->
|
||||||
|
<button type="button" title="Reference a guide or KB article"
|
||||||
|
@click="openReference(block)"><Link2 :size="14" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ge-blockbtns">
|
<div class="ge-blockbtns">
|
||||||
<button type="button" title="Move up" @click="move(index, -1)"
|
<button type="button" title="Move up" @click="move(index, -1)"
|
||||||
:disabled="index === 0">^</button>
|
:disabled="index === 0"><ArrowUp :size="14" /></button>
|
||||||
<button type="button" title="Move down" @click="move(index, 1)"
|
<button type="button" title="Move down" @click="move(index, 1)"
|
||||||
:disabled="index === blocks.length - 1">v</button>
|
:disabled="index === blocks.length - 1"><ArrowDown :size="14" /></button>
|
||||||
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
|
<button type="button" title="Duplicate" @click="duplicate(index)">
|
||||||
|
<Copy :size="14" />
|
||||||
|
</button>
|
||||||
<button type="button" class="ge-del" title="Remove"
|
<button type="button" class="ge-del" title="Remove"
|
||||||
@click="remove(index)">x</button>
|
@click="remove(index)"><X :size="14" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,14 +224,24 @@
|
|||||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="ge-add">
|
<!-- The end of the guide gets the same control as every gap between
|
||||||
<span>Add:</span>
|
sections, so there is one way to add a section, not two. -->
|
||||||
|
<div class="ge-insert ge-insertend"
|
||||||
|
:class="{ 'ge-insertopen': insertAt === blocks.length }">
|
||||||
|
<button type="button" class="ge-insertbtn"
|
||||||
|
@click="insertAt = insertAt === blocks.length ? null : blocks.length">
|
||||||
|
<Plus :size="14" /> Add section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="insertAt === blocks.length" class="ge-palette">
|
||||||
<button v-for="type in blocktypes" :key="type" type="button"
|
<button v-for="type in blocktypes" :key="type" type="button"
|
||||||
@click="add(type)">{{ type }}</button>
|
@click="add(type, blocks.length)">{{ type }}</button>
|
||||||
<label class="ge-upload">
|
<label class="ge-upload">
|
||||||
image
|
image
|
||||||
<input type="file" accept="image/*" hidden @change="onFilePick">
|
<input type="file" accept="image/*" hidden
|
||||||
|
@change="event => onFilePick(event, blocks.length)">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue'
|
|||||||
import { templates } from '../utils/templates.js'
|
import { templates } from '../utils/templates.js'
|
||||||
import { proof, applyFix } from '../utils/proof.js'
|
import { proof, applyFix } from '../utils/proof.js'
|
||||||
import { useGuideImages } from '../utils/images.js'
|
import { useGuideImages } from '../utils/images.js'
|
||||||
|
import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next'
|
||||||
|
import { knowledgebaseApi } from '@/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
|
|||||||
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
|
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
|
||||||
slug: '', ispublished: false })
|
slug: '', ispublished: false })
|
||||||
const blocks = ref([])
|
const blocks = ref([])
|
||||||
|
// Which gap the type palette is open in. null = closed; blocks.length = the end.
|
||||||
|
const insertAt = ref(null)
|
||||||
|
const dragIndex = ref(null)
|
||||||
|
const dragOver = ref(null)
|
||||||
|
const refOpen = ref(false)
|
||||||
|
const refQuery = ref('')
|
||||||
|
const refResults = ref([])
|
||||||
|
const refLoading = ref(false)
|
||||||
|
const refInput = ref(null)
|
||||||
|
let refBlock = null
|
||||||
|
let refTimer = null
|
||||||
const revisions = ref([])
|
const revisions = ref([])
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
@@ -288,17 +377,111 @@ function insertIndex () {
|
|||||||
return index === -1 ? blocks.value.length : index + 1
|
return index === -1 ? blocks.value.length : index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function add (type) {
|
// `at` is an explicit gap from an insert bar. Without one, fall back to
|
||||||
|
// "after whatever is focused", which is what the old bottom palette did.
|
||||||
|
function add (type, at = null) {
|
||||||
const block = { id: newid(), type }
|
const block = { id: newid(), type }
|
||||||
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
||||||
if (type === 'callout') block.variant = 'info'
|
if (type === 'callout') block.variant = 'info'
|
||||||
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
||||||
const at = insertIndex()
|
const index = at === null ? insertIndex() : at
|
||||||
blocks.value.splice(at, 0, block)
|
blocks.value.splice(index, 0, block)
|
||||||
|
insertAt.value = null
|
||||||
focused.value = block.id
|
focused.value = block.id
|
||||||
nextTick(() => textareas[block.id]?.focus())
|
nextTick(() => textareas[block.id]?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- dragging ---
|
||||||
|
function startDrag (index, event) {
|
||||||
|
dragIndex.value = index
|
||||||
|
// Firefox will not start a drag without payload, and 'move' is what the
|
||||||
|
// cursor should say.
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag () {
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOver.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropOn (index) {
|
||||||
|
const from = dragIndex.value
|
||||||
|
endDrag()
|
||||||
|
if (from === null || from === index) return
|
||||||
|
const [moved] = blocks.value.splice(from, 1)
|
||||||
|
blocks.value.splice(index, 0, moved)
|
||||||
|
focused.value = moved.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- references ---
|
||||||
|
function openReference (block) {
|
||||||
|
refBlock = block
|
||||||
|
refOpen.value = true
|
||||||
|
refQuery.value = ''
|
||||||
|
refResults.value = []
|
||||||
|
nextTick(() => refInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchReferences () {
|
||||||
|
clearTimeout(refTimer)
|
||||||
|
const term = refQuery.value.trim()
|
||||||
|
if (!term) { refResults.value = []; return }
|
||||||
|
refTimer = setTimeout(async () => {
|
||||||
|
refLoading.value = true
|
||||||
|
try {
|
||||||
|
const results = []
|
||||||
|
// Guides first: a procedure most often points at another procedure.
|
||||||
|
try {
|
||||||
|
const response = await api.get('/guides', { params: { q: term } })
|
||||||
|
for (const guide of (response.data?.data || [])) {
|
||||||
|
if (!guide.ispublished) continue // never link readers at a draft
|
||||||
|
results.push({ kind: 'guide', id: guide.guideid, title: guide.title,
|
||||||
|
summary: guide.summary, url: `/guides/${guide.slug}` })
|
||||||
|
}
|
||||||
|
} catch (err) { /* guides always exist here, but never break the picker */ }
|
||||||
|
|
||||||
|
// The knowledge base is a separate plugin and may not be installed at
|
||||||
|
// this site (ADR-009). Its absence must cost nothing but its results.
|
||||||
|
try {
|
||||||
|
const response = await knowledgebaseApi.list({ search: term, perpage: 20 })
|
||||||
|
const rows = response.data?.data || []
|
||||||
|
for (const article of (Array.isArray(rows) ? rows : rows.items || [])) {
|
||||||
|
results.push({ kind: 'article', id: article.articleid || article.id,
|
||||||
|
title: article.title,
|
||||||
|
summary: article.summary || article.description,
|
||||||
|
url: `/knowledgebase/${article.articleid || article.id}` })
|
||||||
|
}
|
||||||
|
} catch (err) { /* KB plugin absent or disabled */ }
|
||||||
|
|
||||||
|
refResults.value = results.slice(0, 25)
|
||||||
|
} finally {
|
||||||
|
refLoading.value = false
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertReference (item) {
|
||||||
|
// An in-app path, not an absolute URL: the link has to keep working when the
|
||||||
|
// site is reached by another hostname or mounted under a subpath.
|
||||||
|
const markdown = `[${item.title}](${item.url})`
|
||||||
|
const area = refBlock ? textareas[refBlock.id] : null
|
||||||
|
if (refBlock && area) {
|
||||||
|
const start = area.selectionStart ?? refBlock.text?.length ?? 0
|
||||||
|
const end = area.selectionEnd ?? start
|
||||||
|
const text = refBlock.text || ''
|
||||||
|
refBlock.text = text.slice(0, start) + markdown + text.slice(end)
|
||||||
|
nextTick(() => {
|
||||||
|
area.focus()
|
||||||
|
const caret = start + markdown.length
|
||||||
|
area.setSelectionRange(caret, caret)
|
||||||
|
})
|
||||||
|
} else if (refBlock) {
|
||||||
|
refBlock.text = (refBlock.text || '') + markdown
|
||||||
|
}
|
||||||
|
refOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
function duplicate (index) {
|
function duplicate (index) {
|
||||||
const source = blocks.value[index]
|
const source = blocks.value[index]
|
||||||
const copy = JSON.parse(JSON.stringify(source))
|
const copy = JSON.parse(JSON.stringify(source))
|
||||||
@@ -383,7 +566,7 @@ async function uploadImage (file) {
|
|||||||
return response.data?.data || response.data
|
return response.data?.data || response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertImage (file) {
|
async function insertImage (file, at = null) {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
status.value = 'Uploading image...'
|
status.value = 'Uploading image...'
|
||||||
try {
|
try {
|
||||||
@@ -392,7 +575,8 @@ async function insertImage (file) {
|
|||||||
// for anything that still treats a block as a plain document.
|
// for anything that still treats a block as a plain document.
|
||||||
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
|
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
|
||||||
url: image.url, caption: '', alt: '' }
|
url: image.url, caption: '', alt: '' }
|
||||||
blocks.value.splice(insertIndex(), 0, block)
|
blocks.value.splice(at === null ? insertIndex() : at, 0, block)
|
||||||
|
insertAt.value = null
|
||||||
focused.value = block.id
|
focused.value = block.id
|
||||||
status.value = ''
|
status.value = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -416,9 +600,9 @@ function onDrop (event) {
|
|||||||
if (file) insertImage(file)
|
if (file) insertImage(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFilePick (event) {
|
function onFilePick (event, at = null) {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (file) insertImage(file)
|
if (file) insertImage(file, at)
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,4 +898,77 @@ onMounted(async () => {
|
|||||||
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
|
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
|
||||||
border: 1px solid var(--border); background: transparent;
|
border: 1px solid var(--border); background: transparent;
|
||||||
color: var(--text-light); border-radius: 3px; cursor: pointer; }
|
color: var(--text-light); border-radius: 3px; cursor: pointer; }
|
||||||
|
|
||||||
|
/* --- insert bars, drag handle, reference picker -------------------------- */
|
||||||
|
|
||||||
|
/* A quiet line that becomes a button on hover: present when wanted, not
|
||||||
|
competing with the guide's own content the rest of the time. */
|
||||||
|
.ge-insert {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 1.5rem; opacity: 0; transition: opacity 0.12s;
|
||||||
|
}
|
||||||
|
.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; }
|
||||||
|
.ge-insert::before, .ge-insert::after {
|
||||||
|
content: ''; flex: 1; height: 1px; background: var(--border);
|
||||||
|
}
|
||||||
|
.ge-insertbtn {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||||
|
margin: 0 0.5rem; padding: 0.15rem 0.6rem;
|
||||||
|
font-size: 0.75rem; color: var(--text-light);
|
||||||
|
border: 1px solid var(--border); border-radius: 999px;
|
||||||
|
background: var(--bg); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); }
|
||||||
|
.ge-palette {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
|
||||||
|
margin: 0 0 0.6rem; padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 5px;
|
||||||
|
}
|
||||||
|
.ge-palette button, .ge-palette .ge-upload {
|
||||||
|
font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--text); border-radius: 3px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ge-palette button:hover, .ge-palette .ge-upload:hover {
|
||||||
|
color: var(--primary); border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ge-grip { display: inline-flex; align-items: center; color: var(--text-light);
|
||||||
|
cursor: grab; padding-right: 0.2rem; }
|
||||||
|
.ge-grip:active { cursor: grabbing; }
|
||||||
|
.ge-dragging { opacity: 0.45; }
|
||||||
|
.ge-dragover { border-top: 2px solid var(--primary); }
|
||||||
|
|
||||||
|
.ge-refmask {
|
||||||
|
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
padding-top: 8vh; z-index: 50;
|
||||||
|
}
|
||||||
|
.ge-ref {
|
||||||
|
width: min(34rem, 92vw); max-height: 70vh; overflow: auto;
|
||||||
|
background: var(--bg); color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; padding: 1rem;
|
||||||
|
}
|
||||||
|
.ge-refhead { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 0.6rem; }
|
||||||
|
.ge-refhead button { background: transparent; border: none; color: var(--text-light);
|
||||||
|
cursor: pointer; }
|
||||||
|
.ge-refsearch {
|
||||||
|
width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text); margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.ge-reflist { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.ge-reflist button {
|
||||||
|
width: 100%; display: flex; flex-direction: column; gap: 0.15rem;
|
||||||
|
padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.ge-reflist button:hover { border-color: var(--primary); }
|
||||||
|
.ge-refkind {
|
||||||
|
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
.ge-reftitle { font-weight: 600; }
|
||||||
|
.ge-reflist small { color: var(--text-light); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -122,10 +122,14 @@ function resetProgress () {
|
|||||||
saveProgress()
|
saveProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything that can be ticked: checklist items, and the sections a reader
|
||||||
|
// crosses off to keep their place - a step, or a heading on a guide that has
|
||||||
|
// no steps at all (a converted article often has fifty headings and none).
|
||||||
const totalChecks = computed(() => {
|
const totalChecks = computed(() => {
|
||||||
let total = 0
|
let total = 0
|
||||||
for (const block of guide.value?.blocks || []) {
|
for (const block of guide.value?.blocks || []) {
|
||||||
if (block.type === 'checklist') total += (block.items || []).length
|
if (block.type === 'checklist') total += (block.items || []).length
|
||||||
|
else if (block.type === 'step' || block.type === 'heading') total += 1
|
||||||
}
|
}
|
||||||
return total
|
return total
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,18 +75,32 @@ class GuidesPlugin(BasePlugin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def get_navigation_items(self) -> List[Dict]:
|
def get_navigation_items(self) -> List[Dict]:
|
||||||
# Position 8, beside Slides (7), rather than down in the Information
|
# Position 8, directly under Slides (7), rather than down in the
|
||||||
# section with the reference lists. A guide is something you go and DO
|
# Information section with the reference lists. A guide is something you
|
||||||
# something with, like a slide deck you put on a screen - not a
|
# go and DO something with, like a slide deck you put on a screen - not
|
||||||
# catalogue you look things up in.
|
# a catalogue you look things up in.
|
||||||
|
#
|
||||||
|
# Warranties held 8 as well, and a TIE is broken by plugin load order,
|
||||||
|
# so Guides landed underneath it and read as "not under Slides at all".
|
||||||
|
# Warranties moved to 9. Two nav items sharing a position is not a
|
||||||
|
# preference, it is a coin toss.
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
'name': 'Guides',
|
'name': 'Guides',
|
||||||
'icon': 'book-open',
|
'icon': 'book-open',
|
||||||
'route': '/guides',
|
'route': '/guides',
|
||||||
'position': 8,
|
'position': 8,
|
||||||
|
# The nav endpoint hides an item whose permission the caller
|
||||||
|
# does not hold, so a signed-out visitor sees Guides only when
|
||||||
|
# the guest role has been granted guides.view.
|
||||||
|
'permission': 'guides.view',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_cli_commands(self) -> List:
|
||||||
|
"""CLI: import an existing HTML article as a draft guide."""
|
||||||
|
from .cli import build_cli
|
||||||
|
return build_cli()
|
||||||
|
|
||||||
def init_app(self, app: Flask, db_instance) -> None:
|
def init_app(self, app: Flask, db_instance) -> None:
|
||||||
logger.info('Guides plugin initialized (v%s)', self.meta.version)
|
logger.info('Guides plugin initialized (v%s)', self.meta.version)
|
||||||
|
|||||||
@@ -124,11 +124,21 @@ def plaintext(blocks):
|
|||||||
|
|
||||||
|
|
||||||
def checklistitemcount(blocks):
|
def checklistitemcount(blocks):
|
||||||
"""How many tickable items a guide has, for the list and the progress bar."""
|
"""How many tickable things a guide has, for the card and the progress bar.
|
||||||
|
|
||||||
|
Checklist items, plus the sections a reader crosses off to keep their place:
|
||||||
|
a step, or a heading. A guide converted from an article often has fifty
|
||||||
|
headings and no steps at all, and "0 steps to tick" on a card that is
|
||||||
|
entirely tickable reads as a broken feature.
|
||||||
|
"""
|
||||||
total = 0
|
total = 0
|
||||||
for block in blocks or []:
|
for block in blocks or []:
|
||||||
if isinstance(block, dict) and block.get('type') == 'checklist':
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get('type') == 'checklist':
|
||||||
total += len(block.get('items') or [])
|
total += len(block.get('items') or [])
|
||||||
|
elif block.get('type') in ('step', 'heading'):
|
||||||
|
total += 1
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
130
plugins/guides/services/bundle.py
Normal file
130
plugins/guides/services/bundle.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""A guide as one portable file.
|
||||||
|
|
||||||
|
WHY
|
||||||
|
|
||||||
|
Sites are separate installations with separate databases, and the useful
|
||||||
|
ones are air-gapped from where a guide gets written. A procedure that can
|
||||||
|
only exist on the machine it was typed on is the problem this plugin was
|
||||||
|
built to end, one layer up: instead of a document stranded on a share, a
|
||||||
|
guide stranded in one site's database.
|
||||||
|
|
||||||
|
So: export to a single JSON file, carry it however files get carried
|
||||||
|
there - a share, a robocopy, a USB drive - and import it on the other side.
|
||||||
|
|
||||||
|
WHAT TRAVELS
|
||||||
|
|
||||||
|
Title, summary, tags and the blocks, plus every image the blocks reference,
|
||||||
|
inline as base64. One file, no companion directory: half a guide arriving
|
||||||
|
is worse than none, and a bundle that needs its images to be copied
|
||||||
|
alongside it is a bundle that will eventually arrive without them.
|
||||||
|
|
||||||
|
WHAT DOES NOT TRAVEL
|
||||||
|
|
||||||
|
Ids, revision history, authorship and publication state. A guide's id and
|
||||||
|
its revision numbering belong to the database that issued them, and an
|
||||||
|
imported guide starts as a draft on the far side, at revision 1, owned by
|
||||||
|
whoever ran the import. The SLUG travels, because the slug is the name
|
||||||
|
somebody pastes into a work order and the whole point is that the same
|
||||||
|
guide is the same guide on both sites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
FORMAT = 'shopdb-guide'
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
# An image block points at a stored image by its id, and an id means nothing on
|
||||||
|
# the far side. Blocks travel referring to a token instead, and the importer
|
||||||
|
# swaps the tokens for the ids its own database issued.
|
||||||
|
IMAGETOKEN = 'imagetoken'
|
||||||
|
IMAGEID = 'imageid'
|
||||||
|
|
||||||
|
|
||||||
|
class BundleError(ValueError):
|
||||||
|
"""The file is not a guide bundle, or is one this version cannot read."""
|
||||||
|
|
||||||
|
|
||||||
|
def build(guide, images):
|
||||||
|
"""One guide plus its images as a plain dict, ready to be written as JSON.
|
||||||
|
|
||||||
|
`images` is the GuideImage rows for this guide. Only the ones its blocks
|
||||||
|
actually reference are carried - an orphaned paste from a draft is not part
|
||||||
|
of the procedure and does not need to cross the gap.
|
||||||
|
"""
|
||||||
|
byid = {image.guideimageid: image for image in images}
|
||||||
|
used = []
|
||||||
|
tokens = {}
|
||||||
|
blocks = []
|
||||||
|
for block in guide.blocks:
|
||||||
|
block = dict(block)
|
||||||
|
imageid = block.pop(IMAGEID, None)
|
||||||
|
if imageid is not None:
|
||||||
|
image = byid.get(imageid)
|
||||||
|
if image is None:
|
||||||
|
# The image is gone. Carrying a block that points at nothing
|
||||||
|
# would render as "Image unavailable" on the far side, which
|
||||||
|
# reads as an import failure rather than as missing data.
|
||||||
|
continue
|
||||||
|
if imageid not in tokens:
|
||||||
|
tokens[imageid] = 'img{:03d}'.format(len(used) + 1)
|
||||||
|
used.append(image)
|
||||||
|
block[IMAGETOKEN] = tokens[imageid]
|
||||||
|
blocks.append(block)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'format': FORMAT,
|
||||||
|
'version': VERSION,
|
||||||
|
'slug': guide.slug,
|
||||||
|
'title': guide.title,
|
||||||
|
'summary': guide.summary,
|
||||||
|
'tags': guide.tags,
|
||||||
|
'blocks': blocks,
|
||||||
|
'images': [{
|
||||||
|
'token': tokens[image.guideimageid],
|
||||||
|
'filename': image.filename,
|
||||||
|
'mimetype': image.mimetype,
|
||||||
|
'data': base64.b64encode(image.data).decode('ascii'),
|
||||||
|
} for image in used],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dumps(bundle):
|
||||||
|
return json.dumps(bundle, indent=2, sort_keys=False) + '\n'
|
||||||
|
|
||||||
|
|
||||||
|
def loads(text):
|
||||||
|
"""Parse a bundle, with the image data decoded back to bytes.
|
||||||
|
|
||||||
|
Refuses anything that is not this format. A JSON file that happens to be
|
||||||
|
somewhere near the others must not be half-imported before the failure
|
||||||
|
shows up.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
bundle = json.loads(text)
|
||||||
|
except ValueError as error:
|
||||||
|
raise BundleError('not valid JSON: {}'.format(error))
|
||||||
|
|
||||||
|
if not isinstance(bundle, dict) or bundle.get('format') != FORMAT:
|
||||||
|
raise BundleError('not a {} file'.format(FORMAT))
|
||||||
|
version = bundle.get('version')
|
||||||
|
if not isinstance(version, int) or version > VERSION:
|
||||||
|
raise BundleError(
|
||||||
|
'bundle version {} is newer than this site can read (max {})'
|
||||||
|
.format(version, VERSION))
|
||||||
|
if not isinstance(bundle.get('blocks'), list):
|
||||||
|
raise BundleError('bundle has no blocks')
|
||||||
|
|
||||||
|
images = []
|
||||||
|
for image in bundle.get('images') or []:
|
||||||
|
try:
|
||||||
|
data = base64.b64decode(image['data'], validate=True)
|
||||||
|
except Exception:
|
||||||
|
raise BundleError('image {!r} is not readable'
|
||||||
|
.format(image.get('token')))
|
||||||
|
images.append({'token': image.get('token'),
|
||||||
|
'filename': image.get('filename'),
|
||||||
|
'mimetype': (image.get('mimetype') or '').lower(),
|
||||||
|
'data': data})
|
||||||
|
bundle['images'] = images
|
||||||
|
return bundle
|
||||||
464
plugins/guides/services/htmlimport.py
Normal file
464
plugins/guides/services/htmlimport.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""Turn a hand-written HTML article into guide blocks.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
|
||||||
|
Sites arrive with a folder of KB articles: a procedure written once as a
|
||||||
|
styled HTML page, mailed around, opened from a share. Those pages are what a
|
||||||
|
guide is for, and retyping one into the editor is how a good procedure stays
|
||||||
|
on a share forever. This reads the page and produces blocks.
|
||||||
|
|
||||||
|
WHAT IT IS NOT
|
||||||
|
|
||||||
|
It is not an HTML renderer, and it deliberately loses things. Colours, fonts,
|
||||||
|
widths, nested layout and any decoration go, because that is the entire
|
||||||
|
point of storing blocks rather than markup - the theme decides how a guide
|
||||||
|
looks, and pasted styling is what makes a document unreadable in dark mode.
|
||||||
|
A converted guide is a DRAFT: somebody reads it before it is published.
|
||||||
|
|
||||||
|
WHAT IT RECOGNISES
|
||||||
|
|
||||||
|
The GE Aerospace KB template's own vocabulary, which is also the shape most
|
||||||
|
hand-written procedure pages take:
|
||||||
|
|
||||||
|
div.step + span.step-number -> step (guides do the numbering)
|
||||||
|
div.alert alert-<variant> -> callout
|
||||||
|
div.code-block, pre, code -> code
|
||||||
|
ul.checkbox-list -> checklist (tickable, and remembered)
|
||||||
|
h1..h4 -> heading (h1 becomes the title)
|
||||||
|
p -> paragraph
|
||||||
|
ul / ol -> bullets / numbers
|
||||||
|
table -> table
|
||||||
|
img, inline svg -> image (returned for upload)
|
||||||
|
|
||||||
|
Anything else contributes its text. A div wrapping other blocks is walked
|
||||||
|
through, not turned into one - guides have no nesting.
|
||||||
|
|
||||||
|
INLINE TEXT is the restricted Markdown subset the renderer accepts: bold,
|
||||||
|
italic, inline code, links, line breaks. Anything else becomes plain text,
|
||||||
|
which is a loss the format intends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import re
|
||||||
|
from html import unescape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
# Blocks a guide has no concept of, and page furniture no reader wants twice.
|
||||||
|
# The nav is a table of contents the guide generates from its own headings.
|
||||||
|
SKIPCLASSES = ('header', 'header-content', 'header-logo', 'header-subtitle',
|
||||||
|
'kb-badge', 'nav', 'footer', 'footer-content', 'copy-button')
|
||||||
|
SKIPTAGS = ('head', 'style', 'script', 'button', 'noscript')
|
||||||
|
|
||||||
|
VOIDTAGS = ('br', 'img', 'meta', 'link', 'input', 'hr', 'source', 'col',
|
||||||
|
'area', 'base', 'embed', 'track', 'wbr')
|
||||||
|
|
||||||
|
# The renderer knows info, warning and success. A danger alert is a warning
|
||||||
|
# with a louder colour, and colour is not something a block carries.
|
||||||
|
CALLOUTVARIANTS = {'alert-info': 'info', 'alert-warning': 'warning',
|
||||||
|
'alert-success': 'success', 'alert-danger': 'warning',
|
||||||
|
'alert-error': 'warning'}
|
||||||
|
|
||||||
|
DATAURI = re.compile(r'^data:([\w.+-]+/[\w.+-]+)?(;charset=[\w-]+)?(;base64)?,(.*)$',
|
||||||
|
re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
"""One element or one run of text. A tree small enough to walk twice."""
|
||||||
|
|
||||||
|
__slots__ = ('tag', 'attrs', 'children', 'text')
|
||||||
|
|
||||||
|
def __init__(self, tag, attrs=None, text=None):
|
||||||
|
self.tag = tag
|
||||||
|
self.attrs = attrs or {}
|
||||||
|
self.children = []
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def classes(self):
|
||||||
|
return (self.attrs.get('class') or '').split()
|
||||||
|
|
||||||
|
|
||||||
|
class _Tree(HTMLParser):
|
||||||
|
"""Builds the node tree. Tolerant: real articles have unclosed tags."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.root = Node('#root')
|
||||||
|
self.stack = [self.root]
|
||||||
|
|
||||||
|
# A <p> that is never closed is normal HTML, and the browser closes it the
|
||||||
|
# moment a block element starts. Without the same rule, one unclosed <p>
|
||||||
|
# swallows the entire rest of the article into a single paragraph.
|
||||||
|
CLOSESPARAGRAPH = ('p', 'div', 'section', 'article', 'ul', 'ol', 'li',
|
||||||
|
'table', 'tr', 'td', 'th', 'pre', 'blockquote', 'hr',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6')
|
||||||
|
|
||||||
|
def _autoclose(self, tag):
|
||||||
|
openers = {'li': ('li',), 'tr': ('tr', 'td', 'th'),
|
||||||
|
'td': ('td', 'th'), 'th': ('td', 'th')}.get(tag, ())
|
||||||
|
if tag in self.CLOSESPARAGRAPH:
|
||||||
|
openers = openers + ('p',)
|
||||||
|
while len(self.stack) > 1 and self.stack[-1].tag in openers:
|
||||||
|
self.stack.pop()
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
self._autoclose(tag)
|
||||||
|
node = Node(tag, {k: (v or '') for k, v in attrs})
|
||||||
|
self.stack[-1].children.append(node)
|
||||||
|
if tag not in VOIDTAGS:
|
||||||
|
self.stack.append(node)
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag, attrs):
|
||||||
|
self.stack[-1].children.append(Node(tag, {k: (v or '') for k, v in attrs}))
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
# Close back to the matching open tag, or ignore a stray close. An
|
||||||
|
# article with one unclosed <div> must still import.
|
||||||
|
for depth in range(len(self.stack) - 1, 0, -1):
|
||||||
|
if self.stack[depth].tag == tag:
|
||||||
|
del self.stack[depth:]
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if data.strip():
|
||||||
|
self.stack[-1].children.append(Node('#text', text=data))
|
||||||
|
|
||||||
|
|
||||||
|
def _skip(node):
|
||||||
|
if node.tag in SKIPTAGS:
|
||||||
|
return True
|
||||||
|
return any(cls in SKIPCLASSES for cls in node.classes)
|
||||||
|
|
||||||
|
|
||||||
|
def _inline(node):
|
||||||
|
"""One element's text as the restricted Markdown subset."""
|
||||||
|
out = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.tag == '#text':
|
||||||
|
out.append(re.sub(r'\s+', ' ', child.text))
|
||||||
|
elif _skip(child):
|
||||||
|
continue
|
||||||
|
elif child.tag == 'br':
|
||||||
|
out.append('\n')
|
||||||
|
elif child.tag in ('strong', 'b'):
|
||||||
|
inner = _inline(child).strip()
|
||||||
|
out.append('**{}**'.format(inner) if inner else '')
|
||||||
|
elif child.tag in ('em', 'i'):
|
||||||
|
inner = _inline(child).strip()
|
||||||
|
out.append('*{}*'.format(inner) if inner else '')
|
||||||
|
elif child.tag in ('code', 'kbd', 'samp', 'tt'):
|
||||||
|
inner = _inline(child).strip()
|
||||||
|
out.append('`{}`'.format(inner) if inner else '')
|
||||||
|
elif child.tag == 'a':
|
||||||
|
inner = _inline(child).strip()
|
||||||
|
href = child.attrs.get('href', '').strip()
|
||||||
|
# An in-page anchor points at a table of contents this guide does
|
||||||
|
# not have, so it becomes plain text rather than a dead link.
|
||||||
|
if href and not href.startswith('#'):
|
||||||
|
out.append('[{}]({})'.format(inner, href))
|
||||||
|
else:
|
||||||
|
out.append(inner)
|
||||||
|
else:
|
||||||
|
# span.command-inline and friends: the KB template marks up inline
|
||||||
|
# commands with a class rather than <code>, so honour that too.
|
||||||
|
inner = _inline(child)
|
||||||
|
if child.tag == 'span' and 'command-inline' in child.classes:
|
||||||
|
stripped = inner.strip()
|
||||||
|
out.append('`{}`'.format(stripped) if stripped else '')
|
||||||
|
else:
|
||||||
|
out.append(inner)
|
||||||
|
text = ''.join(out)
|
||||||
|
text = re.sub(r'[ \t]+', ' ', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _plain(node):
|
||||||
|
"""Text with no markup at all - for a code block or a callout title."""
|
||||||
|
if node.tag == '#text':
|
||||||
|
return node.text
|
||||||
|
if _skip(node):
|
||||||
|
return ''
|
||||||
|
return ''.join(_plain(child) for child in node.children)
|
||||||
|
|
||||||
|
|
||||||
|
def _imagepayload(node):
|
||||||
|
"""(mimetype, bytes, filename) for an <img>, or None if it is not embeddable."""
|
||||||
|
src = unescape(node.attrs.get('src', '').strip())
|
||||||
|
match = DATAURI.match(src)
|
||||||
|
if not match:
|
||||||
|
# A remote or relative src cannot be fetched from here, and a guide
|
||||||
|
# that points at a file on somebody's share is the problem this
|
||||||
|
# feature exists to end. Reported, not imported.
|
||||||
|
return None
|
||||||
|
mimetype = (match.group(1) or 'image/png').lower()
|
||||||
|
payload = match.group(4)
|
||||||
|
try:
|
||||||
|
data = (base64.b64decode(payload, validate=False) if match.group(3)
|
||||||
|
else unquote(payload).encode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return mimetype, data, node.attrs.get('alt') or 'image'
|
||||||
|
|
||||||
|
|
||||||
|
class _Builder:
|
||||||
|
def __init__(self):
|
||||||
|
self.blocks = []
|
||||||
|
self.images = []
|
||||||
|
self.title = None
|
||||||
|
self.skipped = []
|
||||||
|
|
||||||
|
def _add(self, block):
|
||||||
|
block['id'] = 'b{:03d}'.format(len(self.blocks) + 1)
|
||||||
|
self.blocks.append(block)
|
||||||
|
|
||||||
|
def _items(self, node, tag='li'):
|
||||||
|
items = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.tag != tag or _skip(child):
|
||||||
|
continue
|
||||||
|
text = _inline(child)
|
||||||
|
if text:
|
||||||
|
items.append({'id': 'i{:03d}'.format(len(items) + 1 + len(self.blocks) * 100),
|
||||||
|
'text': text})
|
||||||
|
return items
|
||||||
|
|
||||||
|
def walk(self, node):
|
||||||
|
for child in node.children:
|
||||||
|
if child.tag == '#text' or _skip(child):
|
||||||
|
continue
|
||||||
|
self.visit(child)
|
||||||
|
|
||||||
|
def visit(self, node):
|
||||||
|
tag = node.tag
|
||||||
|
classes = node.classes
|
||||||
|
|
||||||
|
if tag == 'h1':
|
||||||
|
# The article's own title, not a heading inside it.
|
||||||
|
if self.title is None:
|
||||||
|
self.title = _inline(node)
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag in ('h2', 'h3', 'h4', 'h5'):
|
||||||
|
text = _inline(node)
|
||||||
|
if text:
|
||||||
|
self._add({'type': 'heading', 'text': text})
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == 'p':
|
||||||
|
text = _inline(node)
|
||||||
|
if text:
|
||||||
|
self._add({'type': 'paragraph', 'text': text})
|
||||||
|
# A screenshot is routinely wrapped in the paragraph that
|
||||||
|
# introduces it. Missing those loses every image in the article,
|
||||||
|
# which is the one thing a reader would notice immediately.
|
||||||
|
for image in _descendants(node, 'img'):
|
||||||
|
self._image(image)
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag in ('pre',) or 'code-block' in classes:
|
||||||
|
text = _plain(node).strip('\n')
|
||||||
|
text = '\n'.join(line.rstrip() for line in text.splitlines())
|
||||||
|
if text.strip():
|
||||||
|
self._add({'type': 'code', 'text': text})
|
||||||
|
return
|
||||||
|
|
||||||
|
if 'step' in classes:
|
||||||
|
self._step(node)
|
||||||
|
return
|
||||||
|
|
||||||
|
if any(cls.startswith('alert') for cls in classes):
|
||||||
|
self._callout(node, classes)
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == 'ul':
|
||||||
|
items = self._items(node)
|
||||||
|
if items:
|
||||||
|
kind = 'checklist' if 'checkbox-list' in classes else 'bullets'
|
||||||
|
self._add({'type': kind, 'items': items})
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == 'ol':
|
||||||
|
items = self._items(node)
|
||||||
|
if items:
|
||||||
|
self._add({'type': 'numbers', 'items': items})
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == 'table':
|
||||||
|
self._table(node)
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == 'img':
|
||||||
|
self._image(node)
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == 'svg':
|
||||||
|
# Inline SVG is almost always the page's logo, and a logo is not
|
||||||
|
# content. Named in the report so nothing disappears silently.
|
||||||
|
self.skipped.append('inline <svg> dropped')
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag in ('div', 'section', 'article', 'main', 'body', 'html',
|
||||||
|
'figure', 'blockquote', 'li', 'td', 'th', 'tr', 'tbody',
|
||||||
|
'thead', 'span', 'a', 'strong', 'em', 'small', 'label'):
|
||||||
|
# A wrapper. Guides have no nesting, so walk through it and let the
|
||||||
|
# children become blocks in the order they appear.
|
||||||
|
self.walk(node)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.walk(node)
|
||||||
|
|
||||||
|
def _step(self, node):
|
||||||
|
"""div.step: a numbered step, whose body may hold other blocks."""
|
||||||
|
title = ''
|
||||||
|
text = []
|
||||||
|
rest = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.tag == '#text' or _skip(child):
|
||||||
|
continue
|
||||||
|
if child.tag == 'span' and 'step-number' in child.classes:
|
||||||
|
continue # guides number their own steps
|
||||||
|
if child.tag in ('h3', 'h4', 'h5') and not title:
|
||||||
|
title = _inline(child)
|
||||||
|
continue
|
||||||
|
if child.tag == 'p' and not rest:
|
||||||
|
paragraph = _inline(child)
|
||||||
|
if paragraph:
|
||||||
|
text.append(paragraph)
|
||||||
|
continue
|
||||||
|
rest.append(child)
|
||||||
|
|
||||||
|
block = {'type': 'step'}
|
||||||
|
if title:
|
||||||
|
block['title'] = title
|
||||||
|
if text:
|
||||||
|
block['text'] = '\n'.join(text)
|
||||||
|
self._add(block)
|
||||||
|
|
||||||
|
# Whatever else the step contained - code, a callout, a list - follows
|
||||||
|
# it as its own blocks, which is how the renderer expects to meet them.
|
||||||
|
for child in rest:
|
||||||
|
self.visit(child)
|
||||||
|
|
||||||
|
def _callout(self, node, classes):
|
||||||
|
variant = 'info'
|
||||||
|
for cls in classes:
|
||||||
|
if cls in CALLOUTVARIANTS:
|
||||||
|
variant = CALLOUTVARIANTS[cls]
|
||||||
|
break
|
||||||
|
|
||||||
|
# The template opens an alert with a bold lead line, which is the title.
|
||||||
|
title = ''
|
||||||
|
body = []
|
||||||
|
for child in node.children:
|
||||||
|
if child.tag == '#text':
|
||||||
|
piece = re.sub(r'\s+', ' ', child.text).strip()
|
||||||
|
if piece:
|
||||||
|
body.append(piece)
|
||||||
|
elif _skip(child):
|
||||||
|
continue
|
||||||
|
elif child.tag in ('strong', 'b') and not title and not body:
|
||||||
|
title = _plain(child).strip()
|
||||||
|
else:
|
||||||
|
piece = _inline(child)
|
||||||
|
if piece:
|
||||||
|
body.append(piece)
|
||||||
|
|
||||||
|
block = {'type': 'callout', 'variant': variant, 'text': ' '.join(body)}
|
||||||
|
if title:
|
||||||
|
block['title'] = title
|
||||||
|
if block['text'] or title:
|
||||||
|
self._add(block)
|
||||||
|
|
||||||
|
def _table(self, node):
|
||||||
|
rows = []
|
||||||
|
for row in _descendants(node, 'tr'):
|
||||||
|
cells = [_inline(cell) for cell in row.children
|
||||||
|
if cell.tag in ('td', 'th')]
|
||||||
|
if cells:
|
||||||
|
rows.append((cells, any(cell.tag == 'th' for cell in row.children)))
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
header = []
|
||||||
|
if rows[0][1]:
|
||||||
|
header = rows[0][0]
|
||||||
|
rows = rows[1:]
|
||||||
|
block = {'type': 'table', 'rows': [cells for cells, _ in rows]}
|
||||||
|
if header:
|
||||||
|
block['header'] = header
|
||||||
|
self._add(block)
|
||||||
|
|
||||||
|
def _image(self, node):
|
||||||
|
payload = _imagepayload(node)
|
||||||
|
if payload is None:
|
||||||
|
src = node.attrs.get('src', '')[:60]
|
||||||
|
self.skipped.append('image not embedded, skipped: {}'.format(src))
|
||||||
|
return
|
||||||
|
mimetype, data, alt = payload
|
||||||
|
token = 'img{:03d}'.format(len(self.images) + 1)
|
||||||
|
self.images.append({'token': token, 'mimetype': mimetype, 'data': data,
|
||||||
|
'filename': '{}{}'.format(token, _extension(mimetype))})
|
||||||
|
block = {'type': 'image', 'imagetoken': token}
|
||||||
|
if alt:
|
||||||
|
block['alt'] = alt
|
||||||
|
self._add(block)
|
||||||
|
|
||||||
|
|
||||||
|
def _descendants(node, tag):
|
||||||
|
for child in node.children:
|
||||||
|
if child.tag == tag:
|
||||||
|
yield child
|
||||||
|
else:
|
||||||
|
for found in _descendants(child, tag):
|
||||||
|
yield found
|
||||||
|
|
||||||
|
|
||||||
|
def _extension(mimetype):
|
||||||
|
return {'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
|
||||||
|
'image/webp': '.webp', 'image/svg+xml': '.svg'}.get(mimetype, '')
|
||||||
|
|
||||||
|
|
||||||
|
def _documenttitle(tree):
|
||||||
|
"""The <title>, minus the house suffix every KB page carries."""
|
||||||
|
for node in _descendants(tree, 'title'):
|
||||||
|
text = _plain(node).strip()
|
||||||
|
return re.sub(r'\s*[-|]\s*GE Aerospace Knowledge Base\s*$', '', text).strip()
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse(html):
|
||||||
|
"""Parse an article. Returns a dict, raising nothing on malformed markup.
|
||||||
|
|
||||||
|
{
|
||||||
|
'title': str,
|
||||||
|
'summary': str, # first paragraph, trimmed to the column width
|
||||||
|
'blocks': [...], # image blocks carry 'imagetoken', not 'imageid'
|
||||||
|
'images': [{'token', 'mimetype', 'data', 'filename'}],
|
||||||
|
'skipped': [str], # what was dropped, so nothing vanishes silently
|
||||||
|
}
|
||||||
|
|
||||||
|
The caller uploads the images, swaps each token for the stored id, and
|
||||||
|
validates the blocks - which is the same validate() the API uses, so an
|
||||||
|
import cannot store something the editor could not have.
|
||||||
|
"""
|
||||||
|
tree = _Tree()
|
||||||
|
tree.feed(html)
|
||||||
|
tree.close()
|
||||||
|
|
||||||
|
builder = _Builder()
|
||||||
|
builder.walk(tree.root)
|
||||||
|
|
||||||
|
title = builder.title or _documenttitle(tree.root) or 'Untitled'
|
||||||
|
|
||||||
|
summary = ''
|
||||||
|
for block in builder.blocks:
|
||||||
|
if block['type'] == 'paragraph':
|
||||||
|
summary = re.sub(r'[*`]', '', block['text'])
|
||||||
|
break
|
||||||
|
if len(summary) > 300:
|
||||||
|
summary = summary[:297].rsplit(' ', 1)[0] + '...'
|
||||||
|
|
||||||
|
return {'title': title, 'summary': summary, 'blocks': builder.blocks,
|
||||||
|
'images': builder.images, 'skipped': builder.skipped}
|
||||||
@@ -77,7 +77,7 @@ def _search_clause(search):
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('', methods=['GET'])
|
@knowledgebase_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('kb.view')
|
||||||
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)
|
||||||
@@ -142,7 +142,7 @@ def list_articles():
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('/stats', methods=['GET'])
|
@knowledgebase_bp.route('/stats', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('kb.view')
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get knowledge base statistics."""
|
"""Get knowledge base statistics."""
|
||||||
# Counted over the same set the list shows. A total that includes articles
|
# Counted over the same set the list shows. A total that includes articles
|
||||||
@@ -158,7 +158,7 @@ def get_stats():
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
@knowledgebase_bp.route('/<int:link_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('kb.view')
|
||||||
def get_article(link_id: int):
|
def get_article(link_id: int):
|
||||||
"""Get a single knowledge base article."""
|
"""Get a single knowledge base article."""
|
||||||
article = db.session.get(KnowledgeBase, link_id)
|
article = db.session.get(KnowledgeBase, link_id)
|
||||||
@@ -197,7 +197,6 @@ def track_click(link_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('', methods=['POST'])
|
@knowledgebase_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('kb.create')
|
@require_permission('kb.create')
|
||||||
def create_article():
|
def create_article():
|
||||||
"""Create a new knowledge base article."""
|
"""Create a new knowledge base article."""
|
||||||
@@ -231,7 +230,6 @@ def create_article():
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
|
@knowledgebase_bp.route('/<int:link_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('kb.edit')
|
@require_permission('kb.edit')
|
||||||
def update_article(link_id: int):
|
def update_article(link_id: int):
|
||||||
"""Update a knowledge base article."""
|
"""Update a knowledge base article."""
|
||||||
@@ -261,7 +259,6 @@ def update_article(link_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
|
@knowledgebase_bp.route('/<int:link_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('kb.delete')
|
@require_permission('kb.delete')
|
||||||
def delete_article(link_id: int):
|
def delete_article(link_id: int):
|
||||||
"""Delete (deactivate) a knowledge base article."""
|
"""Delete (deactivate) a knowledge base article."""
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class KnowledgeBasePlugin(BasePlugin):
|
|||||||
'name': 'Knowledge Base',
|
'name': 'Knowledge Base',
|
||||||
'icon': 'book-open',
|
'icon': 'book-open',
|
||||||
'route': '/knowledgebase',
|
'route': '/knowledgebase',
|
||||||
|
'permission': 'kb.view',
|
||||||
'position': 35,
|
'position': 35,
|
||||||
'section': 'information',
|
'section': 'information',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ machines_bp = Blueprint('machines', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@machines_bp.route('/types', methods=['GET'])
|
@machines_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def list_machine_types():
|
def list_machine_types():
|
||||||
"""List all machine types."""
|
"""List all machine types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -39,7 +39,7 @@ def list_machine_types():
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
|
@machines_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def get_machine_type(type_id: int):
|
def get_machine_type(type_id: int):
|
||||||
"""Get a single machine type."""
|
"""Get a single machine type."""
|
||||||
t = db.session.get(MachineType, type_id)
|
t = db.session.get(MachineType, type_id)
|
||||||
@@ -55,7 +55,6 @@ def get_machine_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/types', methods=['POST'])
|
@machines_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.create')
|
@require_permission('machines.create')
|
||||||
def create_machine_type():
|
def create_machine_type():
|
||||||
"""Create a new machine type."""
|
"""Create a new machine type."""
|
||||||
@@ -92,7 +91,6 @@ def create_machine_type():
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
|
@machines_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.edit')
|
@require_permission('machines.edit')
|
||||||
def update_machine_type(type_id: int):
|
def update_machine_type(type_id: int):
|
||||||
"""Update a machine type."""
|
"""Update a machine type."""
|
||||||
@@ -126,7 +124,6 @@ def update_machine_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
@machines_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.delete')
|
@require_permission('machines.delete')
|
||||||
def delete_machine_type(type_id: int):
|
def delete_machine_type(type_id: int):
|
||||||
"""Delete a machine type. Refused if any asset still uses it."""
|
"""Delete a machine type. Refused if any asset still uses it."""
|
||||||
@@ -147,7 +144,7 @@ def delete_machine_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@machines_bp.route('', methods=['GET'])
|
@machines_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def list_machines():
|
def list_machines():
|
||||||
"""
|
"""
|
||||||
List all machines with filtering and pagination.
|
List all machines with filtering and pagination.
|
||||||
@@ -284,7 +281,7 @@ def _dualpath_partner_for(assetid):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
@machines_bp.route('/<int:machine_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def get_machine(machine_id: int):
|
def get_machine(machine_id: int):
|
||||||
"""Get a single machine item with full details."""
|
"""Get a single machine item with full details."""
|
||||||
mach = db.session.get(Machine, machine_id)
|
mach = db.session.get(Machine, machine_id)
|
||||||
@@ -304,7 +301,7 @@ def get_machine(machine_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@machines_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def get_machine_by_asset(asset_id: int):
|
def get_machine_by_asset(asset_id: int):
|
||||||
"""Get machine data by asset ID."""
|
"""Get machine data by asset ID."""
|
||||||
mach = Machine.query.filter_by(assetid=asset_id).first()
|
mach = Machine.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -324,7 +321,6 @@ def get_machine_by_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('', methods=['POST'])
|
@machines_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.create')
|
@require_permission('machines.create')
|
||||||
def create_machine():
|
def create_machine():
|
||||||
"""
|
"""
|
||||||
@@ -418,7 +414,6 @@ def create_machine():
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
|
@machines_bp.route('/<int:machine_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.edit')
|
@require_permission('machines.edit')
|
||||||
def update_machine(machine_id: int):
|
def update_machine(machine_id: int):
|
||||||
"""Update machine (both Asset and Machine records)."""
|
"""Update machine (both Asset and Machine records)."""
|
||||||
@@ -501,7 +496,6 @@ def update_machine(machine_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
|
@machines_bp.route('/<int:machine_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.delete')
|
@require_permission('machines.delete')
|
||||||
def delete_machine(machine_id: int):
|
def delete_machine(machine_id: int):
|
||||||
"""Delete (soft delete) machine."""
|
"""Delete (soft delete) machine."""
|
||||||
@@ -531,7 +525,7 @@ def delete_machine(machine_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@machines_bp.route('/dashboard/summary', methods=['GET'])
|
@machines_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('machines.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get machine dashboard summary data."""
|
"""Get machine dashboard summary data."""
|
||||||
# Dualpath single-machine collapse: hide the secondary bay from counts so a
|
# Dualpath single-machine collapse: hide the secondary bay from counts so a
|
||||||
@@ -578,7 +572,6 @@ def dashboard_summary():
|
|||||||
|
|
||||||
|
|
||||||
@machines_bp.route('/dashboard/outofservice', methods=['GET'])
|
@machines_bp.route('/dashboard/outofservice', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('machines.view')
|
@require_permission('machines.view')
|
||||||
def dashboard_outofservice():
|
def dashboard_outofservice():
|
||||||
"""Machines whose status says they are not running.
|
"""Machines whose status says they are not running.
|
||||||
|
|||||||
@@ -231,7 +231,9 @@ class MachinesPlugin(BasePlugin):
|
|||||||
'name': 'Machines',
|
'name': 'Machines',
|
||||||
'icon': 'cog',
|
'icon': 'cog',
|
||||||
'route': '/machines',
|
'route': '/machines',
|
||||||
|
'permission': 'machines.view',
|
||||||
'position': 10,
|
'position': 10,
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def _merged(tool, today=None):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('/types', methods=['GET'])
|
@measuringtools_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def list_types():
|
def list_types():
|
||||||
"""List measuring-tool types."""
|
"""List measuring-tool types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -64,7 +64,7 @@ def list_types():
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
|
@measuringtools_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def get_type(type_id: int):
|
def get_type(type_id: int):
|
||||||
"""Get one measuring-tool type."""
|
"""Get one measuring-tool type."""
|
||||||
tool_type = db.session.get(MeasuringToolType, type_id)
|
tool_type = db.session.get(MeasuringToolType, type_id)
|
||||||
@@ -75,7 +75,6 @@ def get_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/types', methods=['POST'])
|
@measuringtools_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('measuringtools.create')
|
@require_permission('measuringtools.create')
|
||||||
def create_type():
|
def create_type():
|
||||||
"""Create a measuring-tool type (reactivates a soft-deleted same-named one)."""
|
"""Create a measuring-tool type (reactivates a soft-deleted same-named one)."""
|
||||||
@@ -108,7 +107,6 @@ def create_type():
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['PUT'])
|
@measuringtools_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('measuringtools.edit')
|
@require_permission('measuringtools.edit')
|
||||||
def update_type(type_id: int):
|
def update_type(type_id: int):
|
||||||
"""Update a measuring-tool type."""
|
"""Update a measuring-tool type."""
|
||||||
@@ -130,7 +128,6 @@ def update_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
@measuringtools_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('measuringtools.delete')
|
@require_permission('measuringtools.delete')
|
||||||
def delete_type(type_id: int):
|
def delete_type(type_id: int):
|
||||||
"""Delete a measuring-tool type. Refused if any tool still uses it."""
|
"""Delete a measuring-tool type. Refused if any tool still uses it."""
|
||||||
@@ -153,7 +150,7 @@ def delete_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('', methods=['GET'])
|
@measuringtools_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def list_tools():
|
def list_tools():
|
||||||
"""List measuring tools with filters + pagination.
|
"""List measuring tools with filters + pagination.
|
||||||
|
|
||||||
@@ -211,7 +208,7 @@ def list_tools():
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
|
@measuringtools_bp.route('/<int:tool_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def get_tool(tool_id: int):
|
def get_tool(tool_id: int):
|
||||||
"""Get one measuring tool (asset core merged with the extension)."""
|
"""Get one measuring tool (asset core merged with the extension)."""
|
||||||
tool = db.session.get(MeasuringTool, tool_id)
|
tool = db.session.get(MeasuringTool, tool_id)
|
||||||
@@ -222,7 +219,7 @@ def get_tool(tool_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@measuringtools_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def get_tool_by_asset(asset_id: int):
|
def get_tool_by_asset(asset_id: int):
|
||||||
"""Get a measuring tool by its core asset id."""
|
"""Get a measuring tool by its core asset id."""
|
||||||
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
|
tool = MeasuringTool.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -239,7 +236,6 @@ def _asset_type_id():
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('', methods=['POST'])
|
@measuringtools_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('measuringtools.create')
|
@require_permission('measuringtools.create')
|
||||||
def create_tool():
|
def create_tool():
|
||||||
"""Create a measuring tool: one Asset core row + one extension row."""
|
"""Create a measuring tool: one Asset core row + one extension row."""
|
||||||
@@ -305,7 +301,6 @@ _TOOL_FIELDS = ('measuringtooltypeid', 'calibrationintervaldays',
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/<int:tool_id>', methods=['PUT'])
|
@measuringtools_bp.route('/<int:tool_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('measuringtools.edit')
|
@require_permission('measuringtools.edit')
|
||||||
def update_tool(tool_id: int):
|
def update_tool(tool_id: int):
|
||||||
"""Update a measuring tool (asset core fields + extension in one payload)."""
|
"""Update a measuring tool (asset core fields + extension in one payload)."""
|
||||||
@@ -357,7 +352,6 @@ def update_tool(tool_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@measuringtools_bp.route('/<int:tool_id>', methods=['DELETE'])
|
@measuringtools_bp.route('/<int:tool_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('measuringtools.delete')
|
@require_permission('measuringtools.delete')
|
||||||
def delete_tool(tool_id: int):
|
def delete_tool(tool_id: int):
|
||||||
"""Soft delete a measuring tool (deactivates its asset)."""
|
"""Soft delete a measuring tool (deactivates its asset)."""
|
||||||
@@ -377,7 +371,7 @@ def delete_tool(tool_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('/map-overlay', methods=['GET'])
|
@measuringtools_bp.route('/map-overlay', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def map_overlay():
|
def map_overlay():
|
||||||
"""Calibration-status overlay for active measuring tools.
|
"""Calibration-status overlay for active measuring tools.
|
||||||
|
|
||||||
@@ -408,7 +402,7 @@ def map_overlay():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
@measuringtools_bp.route('/report/calibration', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('measuringtools.view')
|
||||||
def calibration_report():
|
def calibration_report():
|
||||||
"""Counts + lists bucketed by derived calibration status."""
|
"""Counts + lists bucketed by derived calibration status."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ class MeasuringToolsPlugin(BasePlugin):
|
|||||||
'name': 'Measuring Tools',
|
'name': 'Measuring Tools',
|
||||||
'icon': 'ruler',
|
'icon': 'ruler',
|
||||||
'route': '/measuringtools',
|
'route': '/measuringtools',
|
||||||
|
'permission': 'measuringtools.view',
|
||||||
'position': 22,
|
'position': 22,
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ class MeasuringToolsPlugin(BasePlugin):
|
|||||||
'description': 'Measuring tools bucketed by calibration status',
|
'description': 'Measuring tools bucketed by calibration status',
|
||||||
'category': 'compliance',
|
'category': 'compliance',
|
||||||
'route': '/reports/calibration',
|
'route': '/reports/calibration',
|
||||||
|
'permission': 'measuringtools.view',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ def _upsert_primary_ip(asset, ip):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/types', methods=['GET'])
|
@network_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_network_device_types():
|
def list_network_device_types():
|
||||||
"""List all network device types."""
|
"""List all network device types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -69,7 +69,7 @@ def list_network_device_types():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/types/<int:type_id>', methods=['GET'])
|
@network_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device_type(type_id: int):
|
def get_network_device_type(type_id: int):
|
||||||
"""Get a single network device type."""
|
"""Get a single network device type."""
|
||||||
t = db.session.get(NetworkDeviceType, type_id)
|
t = db.session.get(NetworkDeviceType, type_id)
|
||||||
@@ -85,7 +85,6 @@ def get_network_device_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/types', methods=['POST'])
|
@network_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.create')
|
@require_permission('network.create')
|
||||||
def create_network_device_type():
|
def create_network_device_type():
|
||||||
"""Create a new network device type."""
|
"""Create a new network device type."""
|
||||||
@@ -123,7 +122,6 @@ def create_network_device_type():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/types/<int:type_id>', methods=['PUT'])
|
@network_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.edit')
|
@require_permission('network.edit')
|
||||||
def update_network_device_type(type_id: int):
|
def update_network_device_type(type_id: int):
|
||||||
"""Update a network device type."""
|
"""Update a network device type."""
|
||||||
@@ -158,7 +156,6 @@ def update_network_device_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
@network_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.delete')
|
@require_permission('network.delete')
|
||||||
def delete_network_device_type(type_id: int):
|
def delete_network_device_type(type_id: int):
|
||||||
"""Delete a network device type. Refused if any device still uses it."""
|
"""Delete a network device type. Refused if any device still uses it."""
|
||||||
@@ -179,7 +176,7 @@ def delete_network_device_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('', methods=['GET'])
|
@network_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_network_devices():
|
def list_network_devices():
|
||||||
"""
|
"""
|
||||||
List all network devices with filtering and pagination.
|
List all network devices with filtering and pagination.
|
||||||
@@ -287,7 +284,7 @@ def list_network_devices():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/<int:device_id>', methods=['GET'])
|
@network_bp.route('/<int:device_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device(device_id: int):
|
def get_network_device(device_id: int):
|
||||||
"""Get a single network device with full details."""
|
"""Get a single network device with full details."""
|
||||||
netdev = db.session.get(NetworkDevice, device_id)
|
netdev = db.session.get(NetworkDevice, device_id)
|
||||||
@@ -307,7 +304,7 @@ def get_network_device(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device_by_asset(asset_id: int):
|
def get_network_device_by_asset(asset_id: int):
|
||||||
"""Get network device data by asset ID."""
|
"""Get network device data by asset ID."""
|
||||||
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
|
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -327,7 +324,7 @@ def get_network_device_by_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_network_device_by_hostname(hostname: str):
|
def get_network_device_by_hostname(hostname: str):
|
||||||
"""Get network device by hostname."""
|
"""Get network device by hostname."""
|
||||||
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
|
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
|
||||||
@@ -347,7 +344,6 @@ def get_network_device_by_hostname(hostname: str):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('', methods=['POST'])
|
@network_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.create')
|
@require_permission('network.create')
|
||||||
def create_network_device():
|
def create_network_device():
|
||||||
"""
|
"""
|
||||||
@@ -464,7 +460,6 @@ def create_network_device():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/<int:device_id>', methods=['PUT'])
|
@network_bp.route('/<int:device_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.edit')
|
@require_permission('network.edit')
|
||||||
def update_network_device(device_id: int):
|
def update_network_device(device_id: int):
|
||||||
"""Update network device (both Asset and NetworkDevice records)."""
|
"""Update network device (both Asset and NetworkDevice records)."""
|
||||||
@@ -559,7 +554,6 @@ def update_network_device(device_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/<int:device_id>', methods=['DELETE'])
|
@network_bp.route('/<int:device_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.delete')
|
@require_permission('network.delete')
|
||||||
def delete_network_device(device_id: int):
|
def delete_network_device(device_id: int):
|
||||||
"""Delete (soft delete) network device."""
|
"""Delete (soft delete) network device."""
|
||||||
@@ -589,7 +583,7 @@ def delete_network_device(device_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/dashboard/summary', methods=['GET'])
|
@network_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get network device dashboard summary data."""
|
"""Get network device dashboard summary data."""
|
||||||
# Total active network devices
|
# Total active network devices
|
||||||
@@ -637,7 +631,7 @@ def dashboard_summary():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/vlans', methods=['GET'])
|
@network_bp.route('/vlans', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_vlans():
|
def list_vlans():
|
||||||
"""List all VLANs with filtering and pagination."""
|
"""List all VLANs with filtering and pagination."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -670,7 +664,7 @@ def list_vlans():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
|
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_vlan(vlan_id: int):
|
def get_vlan(vlan_id: int):
|
||||||
"""Get a single VLAN with its subnets."""
|
"""Get a single VLAN with its subnets."""
|
||||||
vlan = db.session.get(VLAN, vlan_id)
|
vlan = db.session.get(VLAN, vlan_id)
|
||||||
@@ -690,7 +684,6 @@ def get_vlan(vlan_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/vlans', methods=['POST'])
|
@network_bp.route('/vlans', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.create')
|
@require_permission('network.create')
|
||||||
def create_vlan():
|
def create_vlan():
|
||||||
"""Create a new VLAN."""
|
"""Create a new VLAN."""
|
||||||
@@ -732,7 +725,6 @@ def create_vlan():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/vlans/<int:vlan_id>', methods=['PUT'])
|
@network_bp.route('/vlans/<int:vlan_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.edit')
|
@require_permission('network.edit')
|
||||||
def update_vlan(vlan_id: int):
|
def update_vlan(vlan_id: int):
|
||||||
"""Update a VLAN."""
|
"""Update a VLAN."""
|
||||||
@@ -778,7 +770,6 @@ def update_vlan(vlan_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/vlans/<int:vlan_id>', methods=['DELETE'])
|
@network_bp.route('/vlans/<int:vlan_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.delete')
|
@require_permission('network.delete')
|
||||||
def delete_vlan(vlan_id: int):
|
def delete_vlan(vlan_id: int):
|
||||||
"""Delete (soft delete) a VLAN."""
|
"""Delete (soft delete) a VLAN."""
|
||||||
@@ -815,7 +806,7 @@ def delete_vlan(vlan_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@network_bp.route('/subnets', methods=['GET'])
|
@network_bp.route('/subnets', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def list_subnets():
|
def list_subnets():
|
||||||
"""List all subnets with filtering and pagination."""
|
"""List all subnets with filtering and pagination."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -856,7 +847,7 @@ def list_subnets():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
|
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('network.view')
|
||||||
def get_subnet(subnet_id: int):
|
def get_subnet(subnet_id: int):
|
||||||
"""Get a single subnet plus the network devices whose primary IP falls in
|
"""Get a single subnet plus the network devices whose primary IP falls in
|
||||||
its CIDR range."""
|
its CIDR range."""
|
||||||
@@ -932,7 +923,6 @@ def _asset_detail_url(assettype, assetid):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/subnets', methods=['POST'])
|
@network_bp.route('/subnets', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.create')
|
@require_permission('network.create')
|
||||||
def create_subnet():
|
def create_subnet():
|
||||||
"""Create a new subnet."""
|
"""Create a new subnet."""
|
||||||
@@ -998,7 +988,6 @@ def create_subnet():
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/subnets/<int:subnet_id>', methods=['PUT'])
|
@network_bp.route('/subnets/<int:subnet_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.edit')
|
@require_permission('network.edit')
|
||||||
def update_subnet(subnet_id: int):
|
def update_subnet(subnet_id: int):
|
||||||
"""Update a subnet."""
|
"""Update a subnet."""
|
||||||
@@ -1049,7 +1038,6 @@ def update_subnet(subnet_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@network_bp.route('/subnets/<int:subnet_id>', methods=['DELETE'])
|
@network_bp.route('/subnets/<int:subnet_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('network.delete')
|
@require_permission('network.delete')
|
||||||
def delete_subnet(subnet_id: int):
|
def delete_subnet(subnet_id: int):
|
||||||
"""Delete (soft delete) a subnet."""
|
"""Delete (soft delete) a subnet."""
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="detail-page" v-if="device">
|
<div class="detail-page" v-if="device">
|
||||||
|
<!--
|
||||||
|
The same page-header + header-actions block every other asset detail page
|
||||||
|
uses. This page had no header at all: Edit was tucked inside the hero
|
||||||
|
title row and there was no Print Label, no Retire and no way back to the
|
||||||
|
list, so managing a network device meant looking somewhere different from
|
||||||
|
every other asset type.
|
||||||
|
-->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Network Device Details</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<router-link :to="`/print/asset-label/network_device/${deviceId}`"
|
||||||
|
class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||||
|
<router-link v-if="authStore.isAuthenticated" :to="`/network/${deviceId}/edit`"
|
||||||
|
class="btn btn-primary">Edit</router-link>
|
||||||
|
<button v-if="authStore.isAuthenticated" @click="confirmRetire"
|
||||||
|
class="btn btn-danger">Retire</button>
|
||||||
|
<router-link to="/network" class="btn btn-secondary">Back to List</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hero-card">
|
<div class="hero-card">
|
||||||
<div class="hero-image">
|
<div class="hero-image">
|
||||||
<img v-if="device.networkdevice?.imageurl" :src="withBase(device.networkdevice.imageurl)" alt="Model photo" />
|
<img v-if="device.networkdevice?.imageurl" :src="withBase(device.networkdevice.imageurl)" alt="Model photo" />
|
||||||
@@ -10,13 +30,6 @@
|
|||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="hero-title-row">
|
<div class="hero-title-row">
|
||||||
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
|
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
|
||||||
<router-link
|
|
||||||
v-if="authStore.isAuthenticated"
|
|
||||||
:to="`/network/${deviceId}/edit`"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-meta">
|
<div class="hero-meta">
|
||||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||||
@@ -184,18 +197,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- The manage actions live in the page header, where every other asset
|
||||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
detail page puts them. They used to sit down here instead, which is
|
||||||
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
|
why finding them on a network device meant looking somewhere else. -->
|
||||||
Print Label
|
|
||||||
</router-link>
|
|
||||||
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
|
|
||||||
Edit Device
|
|
||||||
</router-link>
|
|
||||||
<button @click="confirmDelete" class="btn btn-danger">
|
|
||||||
Delete Device
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Audit Footer -->
|
<!-- Audit Footer -->
|
||||||
<div class="audit-footer">
|
<div class="audit-footer">
|
||||||
@@ -228,6 +232,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
|||||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
import { useToast } from '@/composables/toast'
|
import { useToast } from '@/composables/toast'
|
||||||
|
import { apiError } from '@/utils/apiError'
|
||||||
import { setRecordTitle } from '@/composables/pageTitle'
|
import { setRecordTitle } from '@/composables/pageTitle'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -293,17 +298,24 @@ function formatDate(dateStr) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
// Soft delete, like every other asset type: the record leaves the lists while
|
||||||
if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
|
// its history and relationships stay put, and it can be restored by setting
|
||||||
|
// isactive back. Called Retire rather than Delete for exactly that reason.
|
||||||
|
async function confirmRetire () {
|
||||||
|
const label = device.value?.networkdevice?.hostname
|
||||||
|
|| device.value?.name || device.value?.assetnumber || 'this device'
|
||||||
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||||
|
+ `History and relationships are kept, and it can be restored.`)) return
|
||||||
try {
|
try {
|
||||||
await networkApi.delete(deviceId)
|
await networkApi.delete(deviceId)
|
||||||
|
toast.success(`Retired ${label}`)
|
||||||
router.push('/network')
|
router.push('/network')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting device:', error)
|
console.error('Error retiring network device:', error)
|
||||||
toast.error('Failed to delete device')
|
toast.error(apiError(error, 'Failed to retire network device'))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -207,7 +207,9 @@ class NetworkPlugin(BasePlugin):
|
|||||||
'name': 'Network',
|
'name': 'Network',
|
||||||
'icon': 'network-wired',
|
'icon': 'network-wired',
|
||||||
'route': '/network',
|
'route': '/network',
|
||||||
|
'permission': 'network.view',
|
||||||
'position': 18,
|
'position': 18,
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ def _employee_picture(sso):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@notifications_bp.route('/types', methods=['GET'])
|
@notifications_bp.route('/types', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def list_notification_types():
|
def list_notification_types():
|
||||||
"""List all notification types."""
|
"""List all notification types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -257,7 +258,6 @@ def list_notification_types():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/types', methods=['POST'])
|
@notifications_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('notifications.create')
|
@require_permission('notifications.create')
|
||||||
def create_notification_type():
|
def create_notification_type():
|
||||||
"""Create a new notification type."""
|
"""Create a new notification type."""
|
||||||
@@ -293,7 +293,6 @@ def create_notification_type():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
|
@notifications_bp.route('/types/<int:type_id>', methods=['PUT', 'PATCH'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('notifications.create')
|
@require_permission('notifications.create')
|
||||||
def update_notification_type(type_id: int):
|
def update_notification_type(type_id: int):
|
||||||
"""Update a notification type, including its auto-expiry rule."""
|
"""Update a notification type, including its auto-expiry rule."""
|
||||||
@@ -335,6 +334,7 @@ def update_notification_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@notifications_bp.route('', methods=['GET'])
|
@notifications_bp.route('', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def list_notifications():
|
def list_notifications():
|
||||||
"""
|
"""
|
||||||
List all notifications with filtering and pagination.
|
List all notifications with filtering and pagination.
|
||||||
@@ -390,6 +390,7 @@ def list_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
|
@notifications_bp.route('/<int:notification_id>', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def get_notification(notification_id: int):
|
def get_notification(notification_id: int):
|
||||||
"""Get a single notification."""
|
"""Get a single notification."""
|
||||||
n = db.session.get(Notification, notification_id)
|
n = db.session.get(Notification, notification_id)
|
||||||
@@ -405,7 +406,6 @@ def get_notification(notification_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('', methods=['POST'])
|
@notifications_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('notifications.create')
|
@require_permission('notifications.create')
|
||||||
def create_notification():
|
def create_notification():
|
||||||
"""Create a new notification."""
|
"""Create a new notification."""
|
||||||
@@ -465,7 +465,6 @@ def create_notification():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
|
@notifications_bp.route('/<int:notification_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('notifications.edit')
|
@require_permission('notifications.edit')
|
||||||
def update_notification(notification_id: int):
|
def update_notification(notification_id: int):
|
||||||
"""Update a notification."""
|
"""Update a notification."""
|
||||||
@@ -532,7 +531,6 @@ def update_notification(notification_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
|
@notifications_bp.route('/<int:notification_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('notifications.delete')
|
@require_permission('notifications.delete')
|
||||||
def delete_notification(notification_id: int):
|
def delete_notification(notification_id: int):
|
||||||
"""Delete (soft delete) a notification."""
|
"""Delete (soft delete) a notification."""
|
||||||
@@ -556,6 +554,7 @@ def delete_notification(notification_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@notifications_bp.route('/active', methods=['GET'])
|
@notifications_bp.route('/active', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def get_active_notifications():
|
def get_active_notifications():
|
||||||
"""
|
"""
|
||||||
Get currently active notifications for display.
|
Get currently active notifications for display.
|
||||||
@@ -586,6 +585,7 @@ def get_active_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/calendar', methods=['GET'])
|
@notifications_bp.route('/calendar', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def get_calendar_events():
|
def get_calendar_events():
|
||||||
"""
|
"""
|
||||||
Get notifications in FullCalendar event format.
|
Get notifications in FullCalendar event format.
|
||||||
@@ -624,6 +624,7 @@ def get_calendar_events():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/dashboard/summary', methods=['GET'])
|
@notifications_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get notifications dashboard summary."""
|
"""Get notifications dashboard summary."""
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
@@ -874,6 +875,7 @@ def get_shopfloor_notifications():
|
|||||||
|
|
||||||
|
|
||||||
@notifications_bp.route('/dashboard/active', methods=['GET'])
|
@notifications_bp.route('/dashboard/active', methods=['GET'])
|
||||||
|
@require_permission('notifications.view')
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def dashboard_active():
|
def dashboard_active():
|
||||||
"""Notifications currently showing on the floor.
|
"""Notifications currently showing on the floor.
|
||||||
|
|||||||
@@ -232,12 +232,14 @@ class NotificationsPlugin(BasePlugin):
|
|||||||
'name': 'Notifications',
|
'name': 'Notifications',
|
||||||
'icon': 'bell',
|
'icon': 'bell',
|
||||||
'route': '/notifications',
|
'route': '/notifications',
|
||||||
|
'permission': 'notifications.view',
|
||||||
'position': 5,
|
'position': 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Calendar',
|
'name': 'Calendar',
|
||||||
'icon': 'calendar',
|
'icon': 'calendar',
|
||||||
'route': '/calendar',
|
'route': '/calendar',
|
||||||
|
'permission': 'notifications.view',
|
||||||
'position': 6,
|
'position': 6,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ printedparts_bp = Blueprint('printedparts', __name__)
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items', methods=['GET'])
|
@printedparts_bp.route('/items', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.view')
|
@require_permission('printedparts.view')
|
||||||
def list_items():
|
def list_items():
|
||||||
"""List printed items, paginated; search + low-stock filter."""
|
"""List printed items, paginated; search + low-stock filter."""
|
||||||
@@ -56,7 +55,6 @@ def list_items():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
@printedparts_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.view')
|
@require_permission('printedparts.view')
|
||||||
def get_item(item_id: int):
|
def get_item(item_id: int):
|
||||||
"""Get one printed item with its recent transactions."""
|
"""Get one printed item with its recent transactions."""
|
||||||
@@ -101,7 +99,6 @@ def _mint_itemcode(item):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items', methods=['POST'])
|
@printedparts_bp.route('/items', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.create')
|
@require_permission('printedparts.create')
|
||||||
def create_item():
|
def create_item():
|
||||||
"""Create a printed item.
|
"""Create a printed item.
|
||||||
@@ -141,7 +138,6 @@ def create_item():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
@printedparts_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.edit')
|
@require_permission('printedparts.edit')
|
||||||
def update_item(item_id: int):
|
def update_item(item_id: int):
|
||||||
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
"""Update catalog fields. Quantity moves ONLY through the ledger."""
|
||||||
@@ -174,7 +170,6 @@ def update_item(item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
@printedparts_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.delete')
|
@require_permission('printedparts.delete')
|
||||||
def delete_item(item_id: int):
|
def delete_item(item_id: int):
|
||||||
"""Soft-retire an item; its ledger history stays."""
|
"""Soft-retire an item; its ledger history stays."""
|
||||||
@@ -188,7 +183,6 @@ def delete_item(item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/restore', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.delete')
|
@require_permission('printedparts.delete')
|
||||||
def restore_item(item_id: int):
|
def restore_item(item_id: int):
|
||||||
"""Bring a retired item back; code, photo, and history are intact."""
|
"""Bring a retired item back; code, photo, and history are intact."""
|
||||||
@@ -204,7 +198,6 @@ def restore_item(item_id: int):
|
|||||||
# --- item image: the models.py upload/serve/delete trio ---------------------
|
# --- item image: the models.py upload/serve/delete trio ---------------------
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.edit')
|
@require_permission('printedparts.edit')
|
||||||
def upload_item_image(item_id: int):
|
def upload_item_image(item_id: int):
|
||||||
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
"""Upload (or replace) the photo for an item (multipart file=<image>)."""
|
||||||
@@ -240,7 +233,6 @@ def serve_item_image(filename):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
@printedparts_bp.route('/items/<int:item_id>/image', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.delete')
|
@require_permission('printedparts.delete')
|
||||||
def delete_item_image(item_id: int):
|
def delete_item_image(item_id: int):
|
||||||
"""Clear an item image; delete the file only if this plugin owns it."""
|
"""Clear an item image; delete the file only if this plugin owns it."""
|
||||||
@@ -387,7 +379,6 @@ def _alert_team_webhook():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/restock', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.restock')
|
@require_permission('printedparts.restock')
|
||||||
def restock_item(item_id: int):
|
def restock_item(item_id: int):
|
||||||
"""Add freshly printed stock. Body: {quantity, badge}."""
|
"""Add freshly printed stock. Body: {quantity, badge}."""
|
||||||
@@ -410,7 +401,6 @@ def restock_item(item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/adjust', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.restock')
|
@require_permission('printedparts.restock')
|
||||||
def adjust_item(item_id: int):
|
def adjust_item(item_id: int):
|
||||||
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
"""Correct the count (damage, recount). Body: {quantitychange, reason, badge}."""
|
||||||
@@ -552,7 +542,7 @@ def _csv_response(rows, columns, filename):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
@printedparts_bp.route('/reports/stock', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def report_stock():
|
def report_stock():
|
||||||
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
"""Stock levels with low-stock flags and the cache-vs-ledger reconcile.
|
||||||
|
|
||||||
@@ -586,7 +576,7 @@ def report_stock():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
@printedparts_bp.route('/reports/consumption', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def report_consumption():
|
def report_consumption():
|
||||||
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
"""Takes per item, optionally bounded by ?days=<n> (default 30)."""
|
||||||
days = request.args.get('days', 30, type=int)
|
days = request.args.get('days', 30, type=int)
|
||||||
@@ -614,7 +604,7 @@ def report_consumption():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
@printedparts_bp.route('/reports/by-person', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def report_by_person():
|
def report_by_person():
|
||||||
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
"""Takes grouped by employee, optionally bounded by ?days=<n> (default 30)."""
|
||||||
days = request.args.get('days', 30, type=int)
|
days = request.args.get('days', 30, type=int)
|
||||||
@@ -668,7 +658,6 @@ def _uploader_name():
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
@printedparts_bp.route('/items/<int:item_id>/files', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.view')
|
@require_permission('printedparts.view')
|
||||||
def list_item_files(item_id: int):
|
def list_item_files(item_id: int):
|
||||||
"""Revision history, newest first."""
|
"""Revision history, newest first."""
|
||||||
@@ -678,7 +667,6 @@ def list_item_files(item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/items/<int:item_id>/files', methods=['POST'])
|
@printedparts_bp.route('/items/<int:item_id>/files', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.edit')
|
@require_permission('printedparts.edit')
|
||||||
def upload_item_file(item_id: int):
|
def upload_item_file(item_id: int):
|
||||||
"""Upload the next revision of the item's print file.
|
"""Upload the next revision of the item's print file.
|
||||||
@@ -734,7 +722,7 @@ def upload_item_file(item_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
@printedparts_bp.route('/files/<int:file_id>/download', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printedparts.view')
|
||||||
def download_item_file(file_id: int):
|
def download_item_file(file_id: int):
|
||||||
"""Download a revision under its original filename."""
|
"""Download a revision under its original filename."""
|
||||||
record = db.session.get(PrintedItemFile, file_id)
|
record = db.session.get(PrintedItemFile, file_id)
|
||||||
@@ -747,7 +735,6 @@ def download_item_file(file_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
@printedparts_bp.route('/files/<int:file_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printedparts.delete')
|
@require_permission('printedparts.delete')
|
||||||
def delete_item_file(file_id: int):
|
def delete_item_file(file_id: int):
|
||||||
"""Remove a bad revision (wrong file uploaded). History otherwise stays."""
|
"""Remove a bad revision (wrong file uploaded). History otherwise stays."""
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
'name': '3D Parts',
|
'name': '3D Parts',
|
||||||
'icon': 'box',
|
'icon': 'box',
|
||||||
'route': '/printedparts',
|
'route': '/printedparts',
|
||||||
'position': 46,
|
'position': 27,
|
||||||
|
# Named so the nav can hide it from anybody - a signed-out
|
||||||
|
# visitor included - who does not hold it.
|
||||||
|
'permission': 'printedparts.view',
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ printers_asset_bp = Blueprint('printers_asset', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/types', methods=['GET'])
|
@printers_asset_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_printer_types():
|
def list_printer_types():
|
||||||
"""List all printer types."""
|
"""List all printer types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -52,7 +52,7 @@ def list_printer_types():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer_type(type_id: int):
|
def get_printer_type(type_id: int):
|
||||||
"""Get a single printer type."""
|
"""Get a single printer type."""
|
||||||
t = db.session.get(PrinterType, type_id)
|
t = db.session.get(PrinterType, type_id)
|
||||||
@@ -68,7 +68,6 @@ def get_printer_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/types', methods=['POST'])
|
@printers_asset_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.create')
|
@require_permission('printers.create')
|
||||||
def create_printer_type():
|
def create_printer_type():
|
||||||
"""Create a new printer type."""
|
"""Create a new printer type."""
|
||||||
@@ -105,7 +104,6 @@ def create_printer_type():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/types/<int:type_id>', methods=['PUT'])
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.edit')
|
@require_permission('printers.edit')
|
||||||
def update_printer_type(type_id: int):
|
def update_printer_type(type_id: int):
|
||||||
"""Update a printer type."""
|
"""Update a printer type."""
|
||||||
@@ -129,7 +127,6 @@ def update_printer_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
@printers_asset_bp.route('/types/<int:type_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.delete')
|
@require_permission('printers.delete')
|
||||||
def delete_printer_type(type_id: int):
|
def delete_printer_type(type_id: int):
|
||||||
"""Delete a printer type. Refused if any printer still uses it."""
|
"""Delete a printer type. Refused if any printer still uses it."""
|
||||||
@@ -150,7 +147,7 @@ def delete_printer_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/drivers', methods=['GET'])
|
@printers_asset_bp.route('/drivers', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_drivers():
|
def list_drivers():
|
||||||
"""List printer drivers. ?active=false includes inactive ones."""
|
"""List printer drivers. ?active=false includes inactive ones."""
|
||||||
query = PrinterDriver.query
|
query = PrinterDriver.query
|
||||||
@@ -161,7 +158,6 @@ def list_drivers():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/drivers', methods=['POST'])
|
@printers_asset_bp.route('/drivers', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.create')
|
@require_permission('printers.create')
|
||||||
def create_driver():
|
def create_driver():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -180,7 +176,6 @@ def create_driver():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['PUT'])
|
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.edit')
|
@require_permission('printers.edit')
|
||||||
def update_driver(driver_id):
|
def update_driver(driver_id):
|
||||||
d = db.session.get(PrinterDriver, driver_id)
|
d = db.session.get(PrinterDriver, driver_id)
|
||||||
@@ -197,7 +192,6 @@ def update_driver(driver_id):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['DELETE'])
|
@printers_asset_bp.route('/drivers/<int:driver_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.delete')
|
@require_permission('printers.delete')
|
||||||
def delete_driver(driver_id):
|
def delete_driver(driver_id):
|
||||||
d = db.session.get(PrinterDriver, driver_id)
|
d = db.session.get(PrinterDriver, driver_id)
|
||||||
@@ -213,7 +207,7 @@ def delete_driver(driver_id):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('', methods=['GET'])
|
@printers_asset_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_printers():
|
def list_printers():
|
||||||
"""
|
"""
|
||||||
List all printers with filtering and pagination.
|
List all printers with filtering and pagination.
|
||||||
@@ -970,7 +964,7 @@ def printers_for_host(hostname: str):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_asset_printer_assignment(asset_id: int):
|
def get_asset_printer_assignment(asset_id: int):
|
||||||
"""What THIS asset is assigned, without inheritance.
|
"""What THIS asset is assigned, without inheritance.
|
||||||
|
|
||||||
@@ -993,7 +987,6 @@ def get_asset_printer_assignment(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.edit')
|
@require_permission('printers.edit')
|
||||||
def set_asset_printer_assignment(asset_id: int):
|
def set_asset_printer_assignment(asset_id: int):
|
||||||
"""Reconcile one asset's whole printer assignment in a single call.
|
"""Reconcile one asset's whole printer assignment in a single call.
|
||||||
@@ -1498,7 +1491,6 @@ def _observed_host_block(hostname, asset, index):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/observed/<hostname>', methods=['GET'])
|
@printers_asset_bp.route('/observed/<hostname>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.view')
|
@require_permission('printers.view')
|
||||||
def observed_for_host(hostname: str):
|
def observed_for_host(hostname: str):
|
||||||
"""What a host last reported, each queue judged against what it is assigned.
|
"""What a host last reported, each queue judged against what it is assigned.
|
||||||
@@ -1536,7 +1528,6 @@ def observed_for_host(hostname: str):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/observed/for-asset/<int:asset_id>', methods=['GET'])
|
@printers_asset_bp.route('/observed/for-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.view')
|
@require_permission('printers.view')
|
||||||
def observed_for_asset(asset_id: int):
|
def observed_for_asset(asset_id: int):
|
||||||
"""The same comparison, reached from an asset page: one block per host.
|
"""The same comparison, reached from an asset page: one block per host.
|
||||||
@@ -1649,7 +1640,6 @@ def _seed_source_hostnames(asset):
|
|||||||
|
|
||||||
@printers_asset_bp.route('/assignments/seed-from-observed/<int:asset_id>',
|
@printers_asset_bp.route('/assignments/seed-from-observed/<int:asset_id>',
|
||||||
methods=['POST'])
|
methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.edit')
|
@require_permission('printers.edit')
|
||||||
def seed_assignment_from_observed(asset_id: int):
|
def seed_assignment_from_observed(asset_id: int):
|
||||||
"""Write what a PC observed as the assignment of the asset in the path.
|
"""Write what a PC observed as the assignment of the asset in the path.
|
||||||
@@ -1788,7 +1778,7 @@ def seed_assignment_from_observed(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer(printer_id: int):
|
def get_printer(printer_id: int):
|
||||||
"""Get a single printer with full details."""
|
"""Get a single printer with full details."""
|
||||||
printer = db.session.get(Printer, printer_id)
|
printer = db.session.get(Printer, printer_id)
|
||||||
@@ -1821,7 +1811,7 @@ def get_printer(printer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
@printers_asset_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer_by_asset(asset_id: int):
|
def get_printer_by_asset(asset_id: int):
|
||||||
"""Get printer data by asset ID."""
|
"""Get printer data by asset ID."""
|
||||||
printer = Printer.query.filter_by(assetid=asset_id).first()
|
printer = Printer.query.filter_by(assetid=asset_id).first()
|
||||||
@@ -1840,7 +1830,6 @@ def get_printer_by_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('', methods=['POST'])
|
@printers_asset_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.create')
|
@require_permission('printers.create')
|
||||||
def create_printer():
|
def create_printer():
|
||||||
"""
|
"""
|
||||||
@@ -1944,7 +1933,6 @@ def create_printer():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
|
@printers_asset_bp.route('/<int:printer_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.edit')
|
@require_permission('printers.edit')
|
||||||
def update_printer(printer_id: int):
|
def update_printer(printer_id: int):
|
||||||
"""Update printer (both Asset and Printer records)."""
|
"""Update printer (both Asset and Printer records)."""
|
||||||
@@ -2031,7 +2019,6 @@ def update_printer(printer_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
|
@printers_asset_bp.route('/<int:printer_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.delete')
|
@require_permission('printers.delete')
|
||||||
def delete_printer(printer_id: int):
|
def delete_printer(printer_id: int):
|
||||||
"""Delete (soft delete) printer."""
|
"""Delete (soft delete) printer."""
|
||||||
@@ -2056,7 +2043,7 @@ def delete_printer(printer_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
|
@printers_asset_bp.route('/<int:printer_id>/supplies', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def get_printer_supplies(printer_id: int):
|
def get_printer_supplies(printer_id: int):
|
||||||
"""Get supply levels from Zabbix (real-time lookup)."""
|
"""Get supply levels from Zabbix (real-time lookup)."""
|
||||||
printer = db.session.get(Printer, printer_id)
|
printer = db.session.get(Printer, printer_id)
|
||||||
@@ -2251,7 +2238,7 @@ def _get_low_supplies_data():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
|
@printers_asset_bp.route('/lowsupplies', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def low_supplies():
|
def low_supplies():
|
||||||
"""Get printers with low or critical supply levels."""
|
"""Get printers with low or critical supply levels."""
|
||||||
data = _get_low_supplies_data()
|
data = _get_low_supplies_data()
|
||||||
@@ -2259,7 +2246,7 @@ def low_supplies():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/lookup', methods=['GET'])
|
@printers_asset_bp.route('/lookup', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def printer_lookup():
|
def printer_lookup():
|
||||||
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
|
"""Find a printer by IP or FQDN. Parity with the classic printerlookup.asp.
|
||||||
|
|
||||||
@@ -2303,7 +2290,6 @@ def printer_lookup():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
|
@printers_asset_bp.route('/supplies/refresh', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.create')
|
@require_permission('printers.create')
|
||||||
def refresh_supplies_cache():
|
def refresh_supplies_cache():
|
||||||
"""Clear cached Zabbix supply data so the next read pulls fresh values.
|
"""Clear cached Zabbix supply data so the next read pulls fresh values.
|
||||||
@@ -2320,7 +2306,7 @@ def refresh_supplies_cache():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
|
@printers_asset_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""Get printer dashboard summary data."""
|
"""Get printer dashboard summary data."""
|
||||||
# Total active printers
|
# Total active printers
|
||||||
@@ -2394,7 +2380,7 @@ def _validate_supply_payload(data):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
|
@printers_asset_bp.route('/supplies/meta', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def supplies_meta():
|
def supplies_meta():
|
||||||
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
|
"""Allowed values for supply type, color, and capacity tier (for the UI)."""
|
||||||
return success_response({
|
return success_response({
|
||||||
@@ -2405,7 +2391,7 @@ def supplies_meta():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/models', methods=['GET'])
|
@printers_asset_bp.route('/models', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_supply_models():
|
def list_supply_models():
|
||||||
"""List models with a supply count, for the supply-management picker.
|
"""List models with a supply count, for the supply-management picker.
|
||||||
|
|
||||||
@@ -2464,7 +2450,7 @@ def list_supply_models():
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
|
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def list_model_supplies(modelnumberid: int):
|
def list_model_supplies(modelnumberid: int):
|
||||||
"""List all supplies mapped to a model."""
|
"""List all supplies mapped to a model."""
|
||||||
model = db.session.get(Model, modelnumberid)
|
model = db.session.get(Model, modelnumberid)
|
||||||
@@ -2485,7 +2471,6 @@ def list_model_supplies(modelnumberid: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
|
@printers_asset_bp.route('/models/<int:modelnumberid>/supplies', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.create')
|
@require_permission('printers.create')
|
||||||
def create_model_supply(modelnumberid: int):
|
def create_model_supply(modelnumberid: int):
|
||||||
"""Add a supply to a model."""
|
"""Add a supply to a model."""
|
||||||
@@ -2526,7 +2511,6 @@ def create_model_supply(modelnumberid: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
|
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.edit')
|
@require_permission('printers.edit')
|
||||||
def update_model_supply(modelsupplyid: int):
|
def update_model_supply(modelsupplyid: int):
|
||||||
"""Update a model supply."""
|
"""Update a model supply."""
|
||||||
@@ -2571,7 +2555,6 @@ def update_model_supply(modelsupplyid: int):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
|
@printers_asset_bp.route('/supplies/<int:modelsupplyid>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.delete')
|
@require_permission('printers.delete')
|
||||||
def delete_model_supply(modelsupplyid: int):
|
def delete_model_supply(modelsupplyid: int):
|
||||||
"""Delete a model supply."""
|
"""Delete a model supply."""
|
||||||
@@ -2615,7 +2598,6 @@ def _reordertip(supply):
|
|||||||
|
|
||||||
|
|
||||||
@printers_asset_bp.route('/dashboard/supplies', methods=['GET'])
|
@printers_asset_bp.route('/dashboard/supplies', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('printers.view')
|
@require_permission('printers.view')
|
||||||
def dashboard_supplies():
|
def dashboard_supplies():
|
||||||
"""Printers needing a cartridge, flattened to one row per printer.
|
"""Printers needing a cartridge, flattened to one row per printer.
|
||||||
@@ -2685,7 +2667,7 @@ def dashboard_supplies():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
|
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('printers.view')
|
||||||
def supplies_forecast():
|
def supplies_forecast():
|
||||||
"""Days-to-empty and replacement counts per printer.
|
"""Days-to-empty and replacement counts per printer.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<h1>Toner Report</h1>
|
<h1>Toner Report</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
<EmailReportButton v-if="!loading && !error && canemail" subject="Toner / Supply Report"
|
||||||
:columns="emailColumns" :rows="emailRows" />
|
:columns="emailColumns" :rows="emailRows" />
|
||||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,9 +133,16 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
|
// Emailing a report is the reports.export permission the endpoint enforces.
|
||||||
|
// Offering the button to somebody who will only get a 403 is worse than not
|
||||||
|
// offering it.
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
||||||
|
|
||||||
const emailColumns = [
|
const emailColumns = [
|
||||||
{ key: 'printer', label: 'Printer' },
|
{ key: 'printer', label: 'Printer' },
|
||||||
{ key: 'hostname', label: 'Hostname' },
|
{ key: 'hostname', label: 'Hostname' },
|
||||||
|
|||||||
@@ -385,7 +385,9 @@ class PrintersPlugin(BasePlugin):
|
|||||||
'name': 'Printers',
|
'name': 'Printers',
|
||||||
'icon': 'printer',
|
'icon': 'printer',
|
||||||
'route': '/printers',
|
'route': '/printers',
|
||||||
|
'permission': 'printers.view',
|
||||||
'position': 20,
|
'position': 20,
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from shopdb.api import (db, success_response, error_response, ErrorCodes,
|
from shopdb.api import (db, success_response, error_response, ErrorCodes,
|
||||||
require_permission)
|
require_permission, require_any_permission)
|
||||||
from shopdb.api import send_upload
|
from shopdb.api import send_upload
|
||||||
|
|
||||||
from ..models import TvSlide
|
from ..models import TvSlide
|
||||||
@@ -90,8 +90,7 @@ def serve_image(surface, filename):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@slides_bp.route('/<surface>', methods=['GET'])
|
@slides_bp.route('/<surface>', methods=['GET'])
|
||||||
@jwt_required()
|
@require_any_permission('slides.view', 'slides.manage')
|
||||||
@require_permission('slides.manage')
|
|
||||||
def list_slides(surface):
|
def list_slides(surface):
|
||||||
if not _valid_surface(surface):
|
if not _valid_surface(surface):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
||||||
@@ -108,7 +107,6 @@ def list_slides(surface):
|
|||||||
|
|
||||||
|
|
||||||
@slides_bp.route('/<surface>/upload', methods=['POST'])
|
@slides_bp.route('/<surface>/upload', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('slides.manage')
|
@require_permission('slides.manage')
|
||||||
def upload_slides(surface):
|
def upload_slides(surface):
|
||||||
if not _valid_surface(surface):
|
if not _valid_surface(surface):
|
||||||
@@ -153,7 +151,6 @@ def upload_slides(surface):
|
|||||||
|
|
||||||
|
|
||||||
@slides_bp.route('/<surface>/order', methods=['POST'])
|
@slides_bp.route('/<surface>/order', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('slides.manage')
|
@require_permission('slides.manage')
|
||||||
def reorder_slides(surface):
|
def reorder_slides(surface):
|
||||||
if not _valid_surface(surface):
|
if not _valid_surface(surface):
|
||||||
@@ -169,7 +166,6 @@ def reorder_slides(surface):
|
|||||||
|
|
||||||
|
|
||||||
@slides_bp.route('/<surface>/delete', methods=['POST'])
|
@slides_bp.route('/<surface>/delete', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('slides.manage')
|
@require_permission('slides.manage')
|
||||||
def delete_slides(surface):
|
def delete_slides(surface):
|
||||||
if not _valid_surface(surface):
|
if not _valid_surface(surface):
|
||||||
@@ -195,7 +191,6 @@ def delete_slides(surface):
|
|||||||
|
|
||||||
|
|
||||||
@slides_bp.route('/<surface>/<int:slideid>', methods=['PATCH'])
|
@slides_bp.route('/<surface>/<int:slideid>', methods=['PATCH'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('slides.manage')
|
@require_permission('slides.manage')
|
||||||
def update_slide(surface, slideid):
|
def update_slide(surface, slideid):
|
||||||
if not _valid_surface(surface):
|
if not _valid_surface(surface):
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class SlidesPlugin(BasePlugin):
|
|||||||
non-admin role so a curator can manage the lobby TV + screensaver
|
non-admin role so a curator can manage the lobby TV + screensaver
|
||||||
without full admin. Admins hold it by default."""
|
without full admin. Admins hold it by default."""
|
||||||
return [
|
return [
|
||||||
|
('slides.view', 'View the slide decks', 'slides'),
|
||||||
('slides.manage',
|
('slides.manage',
|
||||||
'Manage lobby display and screensaver slides', 'slides'),
|
'Manage lobby display and screensaver slides', 'slides'),
|
||||||
]
|
]
|
||||||
@@ -76,7 +77,10 @@ class SlidesPlugin(BasePlugin):
|
|||||||
'icon': 'image',
|
'icon': 'image',
|
||||||
'route': '/settings/slides',
|
'route': '/settings/slides',
|
||||||
'position': 7,
|
'position': 7,
|
||||||
'permission': 'slides.manage',
|
# The read permission: a manager holds it implicitly through
|
||||||
|
# the route's require_any_permission, and it is the one a site
|
||||||
|
# can grant to guests.
|
||||||
|
'permission': ['slides.view', 'slides.manage'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,27 @@ class ToolsPlugin(BasePlugin):
|
|||||||
about revoking what a role does or does not hold.
|
about revoking what a role does or does not hold.
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
|
# Reaching the tools page at all. The tools themselves run in the
|
||||||
|
# browser and touch no data, so this is about whether the entry
|
||||||
|
# appears - which is exactly the question a site answers when it
|
||||||
|
# decides whether people without an account may use them.
|
||||||
|
('tools.view', 'Use the browser tools (codes, conversions)', 'tools'),
|
||||||
('tools.secrets', 'Create secure send links', 'tools'),
|
('tools.secrets', 'Create secure send links', 'tools'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_guest_grantable_permissions(self) -> List[str]:
|
||||||
|
"""Creating a secure send link is meant to work without an account.
|
||||||
|
|
||||||
|
Somebody standing at a bench needs to send a password to a vendor. The
|
||||||
|
recipient never had an account, and often neither does the sender - the
|
||||||
|
unguessable token is the authorisation, and the link is one-view.
|
||||||
|
|
||||||
|
A guest-created link records no creator. The audit trail says when and
|
||||||
|
from where, and never who. That is the cost, and it is why this is the
|
||||||
|
only write in the product offered to guests at all.
|
||||||
|
"""
|
||||||
|
return ['tools.secrets']
|
||||||
|
|
||||||
def get_settings_defaults(self) -> List[dict]:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
"""The one switch this plugin owns.
|
"""The one switch this plugin owns.
|
||||||
|
|
||||||
@@ -121,6 +139,10 @@ class ToolsPlugin(BasePlugin):
|
|||||||
'icon': 'wrench',
|
'icon': 'wrench',
|
||||||
'route': '/tools',
|
'route': '/tools',
|
||||||
'position': 47,
|
'position': 47,
|
||||||
|
'section': 'fleet',
|
||||||
|
# Either right reaches the page: somebody granted only Secure
|
||||||
|
# Send still needs the entry that leads to it.
|
||||||
|
'permission': ['tools.view', 'tools.secrets'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ cmmc_usb schema used here:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, request
|
from flask import Blueprint, request, Response
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
@@ -257,7 +257,7 @@ def _log_to_dict(cur, row):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@usb_bp.route('', methods=['GET'])
|
@usb_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def list_usb_devices():
|
def list_usb_devices():
|
||||||
"""List USB devices with checkout status.
|
"""List USB devices with checkout status.
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ def list_usb_devices():
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>', methods=['GET'])
|
@usb_bp.route('/<device_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def get_usb_device(device_id):
|
def get_usb_device(device_id):
|
||||||
"""Get a single device plus its last 20 check-in/out log rows."""
|
"""Get a single device plus its last 20 check-in/out log rows."""
|
||||||
if _usb_selfhosted():
|
if _usb_selfhosted():
|
||||||
@@ -338,7 +338,6 @@ def get_usb_device(device_id):
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('', methods=['POST'])
|
@usb_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('usb.create')
|
@require_permission('usb.create')
|
||||||
def create_usb_device():
|
def create_usb_device():
|
||||||
"""Add a device (starts checked-in)."""
|
"""Add a device (starts checked-in)."""
|
||||||
@@ -385,7 +384,6 @@ def create_usb_device():
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>', methods=['PUT'])
|
@usb_bp.route('/<device_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('usb.edit')
|
@require_permission('usb.edit')
|
||||||
def update_usb_device(device_id):
|
def update_usb_device(device_id):
|
||||||
"""Edit device_desc / device_owner / locker_location / status."""
|
"""Edit device_desc / device_owner / locker_location / status."""
|
||||||
@@ -428,7 +426,6 @@ def update_usb_device(device_id):
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>/retire', methods=['POST'])
|
@usb_bp.route('/<device_id>/retire', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('usb.edit')
|
@require_permission('usb.edit')
|
||||||
def retire_usb_device(device_id):
|
def retire_usb_device(device_id):
|
||||||
"""Retire a device (status -> retired)."""
|
"""Retire a device (status -> retired)."""
|
||||||
@@ -463,7 +460,6 @@ def retire_usb_device(device_id):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>/checkout', methods=['POST'])
|
@usb_bp.route('/<device_id>/checkout', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('usb.create')
|
@require_permission('usb.create')
|
||||||
def checkout_device(device_id):
|
def checkout_device(device_id):
|
||||||
"""Check a device out to a badge."""
|
"""Check a device out to a badge."""
|
||||||
@@ -528,7 +524,6 @@ def checkout_device(device_id):
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>/checkin', methods=['POST'])
|
@usb_bp.route('/<device_id>/checkin', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('usb.create')
|
@require_permission('usb.create')
|
||||||
def checkin_device(device_id):
|
def checkin_device(device_id):
|
||||||
"""Check a device back in from a badge."""
|
"""Check a device back in from a badge."""
|
||||||
@@ -600,7 +595,7 @@ def checkin_device(device_id):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@usb_bp.route('/<device_id>/history', methods=['GET'])
|
@usb_bp.route('/<device_id>/history', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def get_device_history(device_id):
|
def get_device_history(device_id):
|
||||||
"""Paginated check-in/out log for one device."""
|
"""Paginated check-in/out log for one device."""
|
||||||
if _usb_selfhosted():
|
if _usb_selfhosted():
|
||||||
@@ -630,7 +625,7 @@ def get_device_history(device_id):
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/checkouts', methods=['GET'])
|
@usb_bp.route('/checkouts', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def list_all_checkouts():
|
def list_all_checkouts():
|
||||||
"""List check-out log rows.
|
"""List check-out log rows.
|
||||||
|
|
||||||
@@ -681,7 +676,7 @@ def list_all_checkouts():
|
|||||||
|
|
||||||
|
|
||||||
@usb_bp.route('/checkouts/active', methods=['GET'])
|
@usb_bp.route('/checkouts/active', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('usb.view')
|
||||||
def list_active_checkouts():
|
def list_active_checkouts():
|
||||||
"""List the latest check-out log row for every currently checked-out device.
|
"""List the latest check-out log row for every currently checked-out device.
|
||||||
|
|
||||||
@@ -721,3 +716,471 @@ def list_active_checkouts():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Kiosk: UNAUTHENTICATED check-out / check-in
|
||||||
|
# =============================================================================
|
||||||
|
# The bar these have to meet, same as the parts kiosk: they can move a device
|
||||||
|
# between checked-out and checked-in and NOTHING else. No create, no edit, no
|
||||||
|
# retire, no browsing the fleet, no reading who holds what without presenting
|
||||||
|
# the device. Identity is the scanned badge, resolved server-side; the client
|
||||||
|
# never asserts who it is.
|
||||||
|
#
|
||||||
|
# Off unless a site turns it on. An unauthenticated write is a decision about
|
||||||
|
# the door, not about the person walking through it.
|
||||||
|
|
||||||
|
def _kiosk_setting(key, default=False):
|
||||||
|
row = Setting.query.filter_by(key=key).first()
|
||||||
|
if row is None or row.value is None or row.value == '':
|
||||||
|
return default
|
||||||
|
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_enabled():
|
||||||
|
return _kiosk_setting('usb_kiosk_enabled', False)
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_disabled_response():
|
||||||
|
# 404, not 403: when the kiosk is off these routes should not read as
|
||||||
|
# "there is a door here and you may not use it".
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_badge(data):
|
||||||
|
"""The scanned badge, or an error response. Never trusts a client name."""
|
||||||
|
badge = (data.get('badge') or '').strip()
|
||||||
|
if not badge:
|
||||||
|
return None, error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Scan your badge')
|
||||||
|
return badge, None
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/kiosk/device/<device_id>', methods=['GET'])
|
||||||
|
def kiosk_device(device_id):
|
||||||
|
"""What the kiosk shows after a device is scanned.
|
||||||
|
|
||||||
|
Deliberately narrow: the description, whether it is out, and the NAME of
|
||||||
|
whoever holds it - which the return-for-someone-else confirm has to show, or
|
||||||
|
the person standing there cannot tell whether they are doing the right
|
||||||
|
thing. No badge number, no history, no contact details.
|
||||||
|
"""
|
||||||
|
if not _kiosk_enabled():
|
||||||
|
return _kiosk_disabled_response()
|
||||||
|
if _usb_selfhosted():
|
||||||
|
return selfhosted.kiosk_device(device_id)
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
device = _fetch_device(cur, device_id)
|
||||||
|
if not device:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'No device matches that code',
|
||||||
|
http_code=404)
|
||||||
|
holder = ''
|
||||||
|
if device.get('status') == _STATUS_CHECKED_OUT:
|
||||||
|
holder = _kiosk_holder_name(cur, device_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return success_response({
|
||||||
|
'device_id': device.get('device_id'),
|
||||||
|
'device_desc': device.get('device_desc'),
|
||||||
|
'status': device.get('status'),
|
||||||
|
'locker_location': device.get('locker_location'),
|
||||||
|
'holdername': holder,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_holder_badge(cur, device_id):
|
||||||
|
"""Badge on the most recent check-out for this device, or ''."""
|
||||||
|
cur.execute(
|
||||||
|
'SELECT badge_number FROM checkinoutlog '
|
||||||
|
"WHERE device_id = %s AND action = 'check-out' "
|
||||||
|
'ORDER BY timestamp DESC, log_id DESC LIMIT 1',
|
||||||
|
(device_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return (row.get('badge_number') or '').strip() if row else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_holder_name(cur, device_id):
|
||||||
|
badge = _kiosk_holder_badge(cur, device_id)
|
||||||
|
return _resolve_badge_name(cur, badge) if badge else ''
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/kiosk/session', methods=['POST'])
|
||||||
|
def kiosk_session():
|
||||||
|
"""What one badge may do right now. Body: {badge}.
|
||||||
|
|
||||||
|
The drives here carry no barcode, so the kiosk cannot ask somebody to
|
||||||
|
present one - it has to offer a LIST. That is a real widening: before, you
|
||||||
|
had to already hold a drive to learn anything. So the list is not anonymous.
|
||||||
|
It costs a badge that resolves to a real person, which is the same bar
|
||||||
|
check-out itself has to clear, and it is a POST so the badge does not end up
|
||||||
|
in a proxy access log the way a query string would.
|
||||||
|
|
||||||
|
Returns the caller's own drives (to return), what is free (to take), and a
|
||||||
|
COUNT of everything else - names for those are a separate, deliberate step,
|
||||||
|
so a passer-by does not read the whole cabinet's holders off a wall screen.
|
||||||
|
"""
|
||||||
|
if not _kiosk_enabled():
|
||||||
|
return _kiosk_disabled_response()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if _usb_selfhosted():
|
||||||
|
return selfhosted.kiosk_session(data)
|
||||||
|
|
||||||
|
badge, failure = _kiosk_badge(data)
|
||||||
|
if failure:
|
||||||
|
return failure
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
name = _resolve_badge_name(cur, badge)
|
||||||
|
if not name:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT',
|
||||||
|
http_code=422)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'SELECT ' + _DEVICE_COLS + ' FROM devices '
|
||||||
|
'WHERE status IN (%s, %s) ORDER BY device_id',
|
||||||
|
(_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall() or []
|
||||||
|
|
||||||
|
yours, available, others = [], [], 0
|
||||||
|
for row in rows:
|
||||||
|
entry = {
|
||||||
|
'device_id': row.get('device_id'),
|
||||||
|
'device_desc': row.get('device_desc'),
|
||||||
|
'locker_location': row.get('locker_location'),
|
||||||
|
}
|
||||||
|
if row.get('status') == _STATUS_CHECKED_IN:
|
||||||
|
available.append(entry)
|
||||||
|
elif _kiosk_holder_badge(cur, row.get('device_id')) == badge:
|
||||||
|
yours.append(entry)
|
||||||
|
else:
|
||||||
|
others += 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return success_response({'name': name, 'yours': yours,
|
||||||
|
'available': available, 'otherscount': others})
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/kiosk/others', methods=['POST'])
|
||||||
|
def kiosk_others():
|
||||||
|
"""Drives checked out to OTHER people, with holder names. Body: {badge}.
|
||||||
|
|
||||||
|
Behind its own tap, and only when returning for somebody else is allowed at
|
||||||
|
all. Somebody has to be able to put back a drive left on a bench by a person
|
||||||
|
who is on leave - but the names that makes visible should not be the first
|
||||||
|
thing on a shopfloor screen.
|
||||||
|
"""
|
||||||
|
if not _kiosk_enabled():
|
||||||
|
return _kiosk_disabled_response()
|
||||||
|
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||||
|
return _kiosk_disabled_response()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if _usb_selfhosted():
|
||||||
|
return selfhosted.kiosk_others(data)
|
||||||
|
|
||||||
|
badge, failure = _kiosk_badge(data)
|
||||||
|
if failure:
|
||||||
|
return failure
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if not _resolve_badge_name(cur, badge):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT',
|
||||||
|
http_code=422)
|
||||||
|
cur.execute(
|
||||||
|
'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s '
|
||||||
|
'ORDER BY device_id',
|
||||||
|
(_STATUS_CHECKED_OUT,),
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for row in cur.fetchall() or []:
|
||||||
|
holderbadge = _kiosk_holder_badge(cur, row.get('device_id'))
|
||||||
|
if holderbadge == badge:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
'device_id': row.get('device_id'),
|
||||||
|
'device_desc': row.get('device_desc'),
|
||||||
|
'locker_location': row.get('locker_location'),
|
||||||
|
'holdername': _resolve_badge_name(cur, holderbadge),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return success_response(out)
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/kiosk/checkout', methods=['POST'])
|
||||||
|
def kiosk_checkout():
|
||||||
|
"""Check a device out at the kiosk. Body: {device_id, badge}."""
|
||||||
|
if not _kiosk_enabled():
|
||||||
|
return _kiosk_disabled_response()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if _usb_selfhosted():
|
||||||
|
return selfhosted.kiosk_checkout(data)
|
||||||
|
|
||||||
|
device_id = (data.get('device_id') or '').strip()
|
||||||
|
badge, failure = _kiosk_badge(data)
|
||||||
|
if failure:
|
||||||
|
return failure
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
device = _fetch_device(cur, device_id)
|
||||||
|
if not device:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'No device matches that code',
|
||||||
|
http_code=404)
|
||||||
|
if device.get('status') == _STATUS_RETIRED:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'That device is retired - see IT')
|
||||||
|
if device.get('status') == _STATUS_CHECKED_OUT:
|
||||||
|
holder = _kiosk_holder_name(cur, device_id)
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
'That device is already checked out{}'.format(
|
||||||
|
' to {}'.format(holder) if holder else ''),
|
||||||
|
http_code=409)
|
||||||
|
|
||||||
|
# An unresolvable badge is REFUSED here, unlike the signed-in route
|
||||||
|
# which creates the user row. A checkout nobody can trace to a
|
||||||
|
# person is the one thing this tracking exists to prevent, and at a
|
||||||
|
# kiosk there is no signed-in operator to vouch for it.
|
||||||
|
name = _resolve_badge_name(cur, badge)
|
||||||
|
if not name:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT to check this device out',
|
||||||
|
http_code=422)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO checkinoutlog '
|
||||||
|
'(badge_number, device_id, action, timestamp, locker_location) '
|
||||||
|
"VALUES (%s, %s, 'check-out', %s, %s)",
|
||||||
|
(badge, device_id, _now(), device.get('locker_location')),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
'UPDATE devices SET status = %s WHERE device_id = %s',
|
||||||
|
(_STATUS_CHECKED_OUT, device_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return success_response({'device_id': device_id, 'holdername': name},
|
||||||
|
message='{} checked out to {}'.format(
|
||||||
|
device_id, name))
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/kiosk/checkin', methods=['POST'])
|
||||||
|
def kiosk_checkin():
|
||||||
|
"""Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}.
|
||||||
|
|
||||||
|
Holder by default. A mismatch is refused with the holder's name and a flag
|
||||||
|
the screen uses to offer "returning it for them", because the alternative -
|
||||||
|
a drive whose owner is on leave that literally cannot be returned at the
|
||||||
|
kiosk - just moves the problem to somebody with a login.
|
||||||
|
|
||||||
|
The badge RECORDED is the one that was scanned: the log says who actually
|
||||||
|
handed the device back, which is the honest record and needs no change to a
|
||||||
|
schema this app does not own.
|
||||||
|
"""
|
||||||
|
if not _kiosk_enabled():
|
||||||
|
return _kiosk_disabled_response()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
if _usb_selfhosted():
|
||||||
|
return selfhosted.kiosk_checkin(data)
|
||||||
|
|
||||||
|
device_id = (data.get('device_id') or '').strip()
|
||||||
|
badge, failure = _kiosk_badge(data)
|
||||||
|
if failure:
|
||||||
|
return failure
|
||||||
|
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
device = _fetch_device(cur, device_id)
|
||||||
|
if not device:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'No device matches that code',
|
||||||
|
http_code=404)
|
||||||
|
if device.get('status') != _STATUS_CHECKED_OUT:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'That device is not checked out')
|
||||||
|
|
||||||
|
name = _resolve_badge_name(cur, badge)
|
||||||
|
if not name:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT to return this device',
|
||||||
|
http_code=422)
|
||||||
|
|
||||||
|
holderbadge = _kiosk_holder_badge(cur, device_id)
|
||||||
|
if holderbadge and holderbadge != badge:
|
||||||
|
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.FORBIDDEN,
|
||||||
|
'That device is checked out to somebody else - they '
|
||||||
|
'need to return it, or see IT',
|
||||||
|
http_code=403)
|
||||||
|
if not forsomeoneelse:
|
||||||
|
holdername = _resolve_badge_name(cur, holderbadge)
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
'That device is checked out to {}. Confirm you are '
|
||||||
|
'returning it for them.'.format(holdername or 'somebody else'),
|
||||||
|
details={'needsconfirm': True,
|
||||||
|
'holdername': holdername},
|
||||||
|
http_code=409)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO checkinoutlog '
|
||||||
|
'(badge_number, device_id, action, timestamp, locker_location) '
|
||||||
|
"VALUES (%s, %s, 'check-in', %s, %s)",
|
||||||
|
(badge, device_id, _now(), device.get('locker_location')),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
'UPDATE devices SET status = %s WHERE device_id = %s',
|
||||||
|
(_STATUS_CHECKED_IN, device_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return success_response({'device_id': device_id},
|
||||||
|
message='{} checked in'.format(device_id))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Reports
|
||||||
|
# =============================================================================
|
||||||
|
# CMMC removable-media tracking is an audit obligation, and until now the only
|
||||||
|
# way to answer "who had which drive, when" was to read the check-in/out log
|
||||||
|
# page by page. This is the same question over a window: a day for "what
|
||||||
|
# happened this shift", a month or six for a periodic review, a year or all time
|
||||||
|
# for an audit.
|
||||||
|
|
||||||
|
# The windows the UI offers, in days. 0 means everything ever recorded.
|
||||||
|
AUDITPERIODS = {'day': 1, 'month': 30, 'sixmonths': 182, 'year': 365, 'all': 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _auditdays():
|
||||||
|
"""Days of history to report. Accepts ?period= or a raw ?days=."""
|
||||||
|
period = (request.args.get('period') or '').strip().lower()
|
||||||
|
if period in AUDITPERIODS:
|
||||||
|
return AUDITPERIODS[period]
|
||||||
|
days = request.args.get('days', type=int)
|
||||||
|
return max(0, days) if days is not None else 30
|
||||||
|
|
||||||
|
|
||||||
|
def _auditcsv(rows, columns, filename):
|
||||||
|
"""CSV download; generate_csv is not on the contract surface."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(columns)
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow([row.get(column, '') for column in columns])
|
||||||
|
return Response(output.getvalue(), mimetype='text/csv',
|
||||||
|
headers={'Content-Disposition':
|
||||||
|
'attachment; filename={}'.format(filename)})
|
||||||
|
|
||||||
|
|
||||||
|
AUDITCOLUMNS = ['timestamp', 'action', 'device_id', 'device_desc', 'badge',
|
||||||
|
'name', 'locker_location']
|
||||||
|
|
||||||
|
|
||||||
|
@usb_bp.route('/reports/audit', methods=['GET'])
|
||||||
|
@require_permission('usb.view')
|
||||||
|
def report_audit():
|
||||||
|
"""Every check-out and check-in in the window, newest first.
|
||||||
|
|
||||||
|
Rows, not just totals: an audit asks who had a specific drive on a specific
|
||||||
|
day, and a count cannot answer that. Summary figures ride along for the
|
||||||
|
people who only want the shape of it.
|
||||||
|
"""
|
||||||
|
days = _auditdays()
|
||||||
|
if _usb_selfhosted():
|
||||||
|
rows = selfhosted.audit_rows(days)
|
||||||
|
return _auditresponse(rows, days)
|
||||||
|
|
||||||
|
cutoff = None
|
||||||
|
if days > 0:
|
||||||
|
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime(
|
||||||
|
'%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
conn = cmmc_usb_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
sql = ('SELECT l.timestamp, l.action, l.device_id, l.badge_number, '
|
||||||
|
'l.locker_location, d.device_desc, '
|
||||||
|
'u.first_name, u.last_name '
|
||||||
|
'FROM checkinoutlog l '
|
||||||
|
'LEFT JOIN devices d ON d.device_id = l.device_id '
|
||||||
|
'LEFT JOIN users u ON u.badge_number = l.badge_number ')
|
||||||
|
params = []
|
||||||
|
if cutoff:
|
||||||
|
sql += 'WHERE l.timestamp >= %s '
|
||||||
|
params.append(cutoff)
|
||||||
|
# A total order: timestamp alone ties for two events in the same
|
||||||
|
# second and the same query then returns them in a different order
|
||||||
|
# each run, which reads as the log being unstable.
|
||||||
|
sql += 'ORDER BY l.timestamp DESC, l.log_id DESC'
|
||||||
|
cur.execute(sql, tuple(params))
|
||||||
|
rows = []
|
||||||
|
for row in cur.fetchall() or []:
|
||||||
|
name = '{} {}'.format(row.get('first_name') or '',
|
||||||
|
row.get('last_name') or '').strip()
|
||||||
|
stamp = row.get('timestamp')
|
||||||
|
rows.append({
|
||||||
|
'timestamp': stamp.isoformat() if hasattr(stamp, 'isoformat')
|
||||||
|
else (stamp or ''),
|
||||||
|
'action': row.get('action') or '',
|
||||||
|
'device_id': row.get('device_id') or '',
|
||||||
|
'device_desc': row.get('device_desc') or '',
|
||||||
|
'badge': row.get('badge_number') or '',
|
||||||
|
'name': name,
|
||||||
|
'locker_location': row.get('locker_location') or '',
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return _auditresponse(rows, days)
|
||||||
|
|
||||||
|
|
||||||
|
def _auditresponse(rows, days):
|
||||||
|
"""One voice for both directory modes: same CSV, same JSON, same summary."""
|
||||||
|
if request.args.get('format') == 'csv':
|
||||||
|
return _auditcsv(rows, AUDITCOLUMNS, 'usb-audit.csv')
|
||||||
|
return success_response({
|
||||||
|
'columns': AUDITCOLUMNS,
|
||||||
|
'rows': rows,
|
||||||
|
'days': days,
|
||||||
|
'summary': _auditsummary(rows),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _auditsummary(rows):
|
||||||
|
checkouts = sum(1 for row in rows if row['action'] == 'check-out')
|
||||||
|
return {
|
||||||
|
'events': len(rows),
|
||||||
|
'checkouts': checkouts,
|
||||||
|
'checkins': sum(1 for row in rows if row['action'] == 'check-in'),
|
||||||
|
'devices': len({row['device_id'] for row in rows if row['device_id']}),
|
||||||
|
'people': len({row['badge'] for row in rows if row['badge']}),
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ History rows are synthesized from usbcheckouts (each row = a check-out event and
|
|||||||
if returned, a check-in event).
|
if returned, a check-in event).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, success_response, error_response, ErrorCodes,
|
db, success_response, error_response, ErrorCodes,
|
||||||
@@ -274,3 +274,225 @@ def list_checkouts(request, active_only):
|
|||||||
event['device_status'] = _status(checkout.device) if checkout.device else None
|
event['device_status'] = _status(checkout.device) if checkout.device else None
|
||||||
data.append(event)
|
data.append(event)
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Kiosk (self-hosted mode). Same contract as the external path in routes.py:
|
||||||
|
# move a device between checked-out and checked-in, attributed to a badge that
|
||||||
|
# resolved to a real person, and nothing else.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def kiosk_device(device_id):
|
||||||
|
device = _find(device_id)
|
||||||
|
if not device:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'No device matches that code', http_code=404)
|
||||||
|
return success_response({
|
||||||
|
'device_id': device.serialnumber,
|
||||||
|
'device_desc': device.label or device.productname or '',
|
||||||
|
'status': _status(device),
|
||||||
|
'locker_location': device.storagelocation,
|
||||||
|
# Name only. The kiosk needs it for the return-for-someone-else
|
||||||
|
# confirm; it does not need their badge.
|
||||||
|
'holdername': device.currentusername or '' if device.ischeckedout else '',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def kiosk_session(data):
|
||||||
|
"""See routes.kiosk_session for why the list costs a valid badge."""
|
||||||
|
badge = (data.get('badge') or '').strip()
|
||||||
|
if not badge:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||||
|
name = _resolve_name(badge)
|
||||||
|
if not name:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT', http_code=422)
|
||||||
|
|
||||||
|
yours, available, others = [], [], 0
|
||||||
|
for device in USBDevice.query.filter_by(isactive=True).order_by(
|
||||||
|
USBDevice.serialnumber).all():
|
||||||
|
entry = {
|
||||||
|
'device_id': device.serialnumber,
|
||||||
|
'device_desc': device.label or device.productname or '',
|
||||||
|
'locker_location': device.storagelocation,
|
||||||
|
}
|
||||||
|
if not device.ischeckedout:
|
||||||
|
available.append(entry)
|
||||||
|
elif (device.currentuserid or '').strip() == badge:
|
||||||
|
yours.append(entry)
|
||||||
|
else:
|
||||||
|
others += 1
|
||||||
|
|
||||||
|
return success_response({'name': name, 'yours': yours,
|
||||||
|
'available': available, 'otherscount': others})
|
||||||
|
|
||||||
|
|
||||||
|
def kiosk_others(data):
|
||||||
|
badge = (data.get('badge') or '').strip()
|
||||||
|
if not badge:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||||
|
if not _resolve_name(badge):
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT', http_code=422)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True
|
||||||
|
).order_by(USBDevice.serialnumber).all():
|
||||||
|
if (device.currentuserid or '').strip() == badge:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
'device_id': device.serialnumber,
|
||||||
|
'device_desc': device.label or device.productname or '',
|
||||||
|
'locker_location': device.storagelocation,
|
||||||
|
'holdername': device.currentusername or '',
|
||||||
|
})
|
||||||
|
return success_response(out)
|
||||||
|
|
||||||
|
|
||||||
|
def kiosk_checkout(data):
|
||||||
|
device_id = (data.get('device_id') or '').strip()
|
||||||
|
badge = (data.get('badge') or '').strip()
|
||||||
|
if not badge:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||||
|
|
||||||
|
device = _find(device_id)
|
||||||
|
if not device:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'No device matches that code', http_code=404)
|
||||||
|
if not device.isactive:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'That device is retired - see IT')
|
||||||
|
if device.ischeckedout:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
'That device is already checked out{}'.format(
|
||||||
|
' to {}'.format(device.currentusername)
|
||||||
|
if device.currentusername else ''),
|
||||||
|
http_code=409)
|
||||||
|
|
||||||
|
# Refused rather than recorded anonymously - see the note on the external
|
||||||
|
# path. A checkout nobody can trace to a person defeats the tracking.
|
||||||
|
name = _resolve_name(badge)
|
||||||
|
if not name:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT to check this device out',
|
||||||
|
http_code=422)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
|
||||||
|
checkoutname=name, checkouttime=now))
|
||||||
|
device.ischeckedout = True
|
||||||
|
device.currentuserid = badge
|
||||||
|
device.currentusername = name
|
||||||
|
device.currentcheckoutdate = now
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'device_id': device.serialnumber,
|
||||||
|
'holdername': name},
|
||||||
|
message='{} checked out to {}'.format(
|
||||||
|
device.serialnumber, name))
|
||||||
|
|
||||||
|
|
||||||
|
def kiosk_checkin(data):
|
||||||
|
device_id = (data.get('device_id') or '').strip()
|
||||||
|
badge = (data.get('badge') or '').strip()
|
||||||
|
if not badge:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||||
|
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
||||||
|
|
||||||
|
device = _find(device_id)
|
||||||
|
if not device:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
|
'No device matches that code', http_code=404)
|
||||||
|
if not device.ischeckedout:
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'That device is not checked out')
|
||||||
|
|
||||||
|
name = _resolve_name(badge)
|
||||||
|
if not name:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not recognized - see IT to return this device',
|
||||||
|
http_code=422)
|
||||||
|
|
||||||
|
holderbadge = (device.currentuserid or '').strip()
|
||||||
|
if holderbadge and holderbadge != badge:
|
||||||
|
if not _kiosk_flag('usb_kiosk_allow_return_for_others', True):
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.FORBIDDEN,
|
||||||
|
'That device is checked out to somebody else - they need to '
|
||||||
|
'return it, or see IT',
|
||||||
|
http_code=403)
|
||||||
|
if not forsomeoneelse:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
'That device is checked out to {}. Confirm you are returning '
|
||||||
|
'it for them.'.format(device.currentusername or 'somebody else'),
|
||||||
|
details={'needsconfirm': True,
|
||||||
|
'holdername': device.currentusername or ''},
|
||||||
|
http_code=409)
|
||||||
|
|
||||||
|
open_checkout = (USBCheckout.query
|
||||||
|
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
|
||||||
|
.order_by(USBCheckout.checkouttime.desc()).first())
|
||||||
|
if open_checkout:
|
||||||
|
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
device.ischeckedout = False
|
||||||
|
device.currentuserid = None
|
||||||
|
device.currentusername = None
|
||||||
|
device.currentcheckoutdate = None
|
||||||
|
db.session.commit()
|
||||||
|
return success_response({'device_id': device.serialnumber},
|
||||||
|
message='{} checked in'.format(device.serialnumber))
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_flag(key, default):
|
||||||
|
from shopdb.api import Setting
|
||||||
|
row = Setting.query.filter_by(key=key).first()
|
||||||
|
if row is None or row.value is None or row.value == '':
|
||||||
|
return default
|
||||||
|
return str(row.value).strip().lower() in ('true', '1', 'yes', 'on')
|
||||||
|
|
||||||
|
|
||||||
|
def audit_rows(days):
|
||||||
|
"""Check-out / check-in events over a window (self-hosted mode).
|
||||||
|
|
||||||
|
One usbcheckouts row holds BOTH ends of a loan, so it expands into two
|
||||||
|
events - the take and, when it has come back, the return. Reporting the row
|
||||||
|
once would make a returned drive indistinguishable from one still out.
|
||||||
|
|
||||||
|
Rows only: the caller owns the CSV, the summary and the response shape, so
|
||||||
|
both directory modes answer in exactly one voice.
|
||||||
|
"""
|
||||||
|
cutoff = None
|
||||||
|
if days > 0:
|
||||||
|
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||||
|
|
||||||
|
query = (db.session.query(USBCheckout, USBDevice)
|
||||||
|
.join(USBDevice, USBDevice.usbdeviceid == USBCheckout.usbdeviceid))
|
||||||
|
if cutoff is not None:
|
||||||
|
# Either end of the loan can fall in the window: a drive taken before it
|
||||||
|
# and returned inside it is a check-in the window has to show.
|
||||||
|
query = query.filter(db.or_(USBCheckout.checkouttime >= cutoff,
|
||||||
|
USBCheckout.checkintime >= cutoff))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for checkout, device in query.all():
|
||||||
|
base = {
|
||||||
|
'device_id': device.serialnumber,
|
||||||
|
'device_desc': device.label or device.productname or '',
|
||||||
|
'badge': checkout.sso or '',
|
||||||
|
'name': checkout.checkoutname or '',
|
||||||
|
'locker_location': device.storagelocation or '',
|
||||||
|
}
|
||||||
|
if checkout.checkouttime and (cutoff is None or checkout.checkouttime >= cutoff):
|
||||||
|
rows.append(dict(base, timestamp=checkout.checkouttime.isoformat(),
|
||||||
|
action='check-out'))
|
||||||
|
if checkout.checkintime and (cutoff is None or checkout.checkintime >= cutoff):
|
||||||
|
rows.append(dict(base, timestamp=checkout.checkintime.isoformat(),
|
||||||
|
action='check-in'))
|
||||||
|
|
||||||
|
# Newest first, and a total order so two events in the same second do not
|
||||||
|
# swap places between identical runs.
|
||||||
|
rows.sort(key=lambda row: (row['timestamp'], row['device_id']), reverse=True)
|
||||||
|
return rows
|
||||||
|
|||||||
29
plugins/usb/frontend/drivefilter.js
Normal file
29
plugins/usb/frontend/drivefilter.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
// Narrowing a cabinet of drives from a kiosk keyboard.
|
||||||
|
//
|
||||||
|
// Every free drive is already on the client, so this filters what is on screen
|
||||||
|
// rather than asking the API again - the list is small, and a round trip per
|
||||||
|
// keystroke on a wall panel is a worse experience than no filter at all.
|
||||||
|
|
||||||
|
// What one drive can be found by. 'locker' is in here on purpose: it is how
|
||||||
|
// people say where a drive lives, so "locker engineer" has to find the drives
|
||||||
|
// in the engineering locker rather than nothing at all.
|
||||||
|
export function drivetext (drive) {
|
||||||
|
return [drive.device_id, drive.device_desc, 'locker', drive.locker_location]
|
||||||
|
.filter(Boolean).join(' ').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every word typed has to appear somewhere in the drive's text, in any order,
|
||||||
|
// so "engineer kingston" and "kingston engineer" find the same drive. An empty
|
||||||
|
// query matches everything - a blank box is not a filter.
|
||||||
|
export function matchesdrive (drive, query) {
|
||||||
|
const words = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
|
if (!words.length) return true
|
||||||
|
const text = drivetext(drive)
|
||||||
|
return words.every(word => text.includes(word))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterdrives (drives, query) {
|
||||||
|
const words = String(query || '').trim()
|
||||||
|
if (!words) return drives
|
||||||
|
return drives.filter(drive => matchesdrive(drive, query))
|
||||||
|
}
|
||||||
62
plugins/usb/frontend/drivefilter.spec.js
Normal file
62
plugins/usb/frontend/drivefilter.spec.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// The kiosk filter. Every case here is something somebody standing at the
|
||||||
|
// cabinet would actually type, including the phrasing they use out loud -
|
||||||
|
// "locker engineer" rather than the bare locker name.
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
import { filterdrives, matchesdrive } from './drivefilter'
|
||||||
|
|
||||||
|
const DRIVES = [
|
||||||
|
{ device_id: 'USB-3', device_desc: 'Kingston 16GB', locker_location: 'DT' },
|
||||||
|
{ device_id: 'USB-4', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
|
||||||
|
{ device_id: 'USB-10', device_desc: 'SanDisk 32GB', locker_location: 'Engineer' },
|
||||||
|
{ device_id: 'USB-11', device_desc: 'Kingston 64GB', locker_location: 'B11' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('filterdrives', () => {
|
||||||
|
it('an empty box is not a filter', () => {
|
||||||
|
expect(filterdrives(DRIVES, '')).toHaveLength(4)
|
||||||
|
expect(filterdrives(DRIVES, ' ')).toHaveLength(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a drive by its id', () => {
|
||||||
|
expect(filterdrives(DRIVES, 'usb-4').map(d => d.device_id)).toEqual(['USB-4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds drives by what is written on them', () => {
|
||||||
|
expect(filterdrives(DRIVES, 'kingston').map(d => d.device_id))
|
||||||
|
.toEqual(['USB-3', 'USB-11'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a locker by name', () => {
|
||||||
|
expect(filterdrives(DRIVES, 'engineer').map(d => d.device_id))
|
||||||
|
.toEqual(['USB-4', 'USB-10'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds a locker the way somebody says it out loud', () => {
|
||||||
|
expect(filterdrives(DRIVES, 'locker engineer').map(d => d.device_id))
|
||||||
|
.toEqual(['USB-4', 'USB-10'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes the words in any order', () => {
|
||||||
|
expect(filterdrives(DRIVES, 'sandisk engineer').map(d => d.device_id))
|
||||||
|
.toEqual(['USB-4', 'USB-10'])
|
||||||
|
expect(filterdrives(DRIVES, 'engineer sandisk').map(d => d.device_id))
|
||||||
|
.toEqual(['USB-4', 'USB-10'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores case and stray spacing', () => {
|
||||||
|
expect(filterdrives(DRIVES, ' ENGINEER SanDisk ').map(d => d.device_id))
|
||||||
|
.toEqual(['USB-4', 'USB-10'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches nothing rather than everything when nothing matches', () => {
|
||||||
|
expect(filterdrives(DRIVES, 'zzz')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fall over on a drive missing its description or locker', () => {
|
||||||
|
const sparse = [{ device_id: 'USB-9' }]
|
||||||
|
expect(matchesdrive(sparse[0], 'usb-9')).toBe(true)
|
||||||
|
expect(matchesdrive(sparse[0], 'engineer')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,10 +25,26 @@ export default [
|
|||||||
name: 'usb-edit',
|
name: 'usb-edit',
|
||||||
component: () => import('./views/USBForm.vue'),
|
component: () => import('./views/USBForm.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'usb' }
|
meta: { requiresAuth: true, plugin: 'usb' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings/usb',
|
||||||
|
name: 'settings-usb',
|
||||||
|
component: () => import('./views/USBSettings.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'usb' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export const toplevel = [
|
export const toplevel = [
|
||||||
|
{
|
||||||
|
// Touch kiosk for taking and returning USB drives: scan the drive, scan a
|
||||||
|
// badge. Full-screen and OUTSIDE AppLayout, and deliberately not behind
|
||||||
|
// requiresAuth - the whole point is that a guest at the cabinet can use it.
|
||||||
|
// The API refuses every kiosk route unless usb_kiosk_enabled is on.
|
||||||
|
path: '/usb-kiosk',
|
||||||
|
name: 'usb-kiosk',
|
||||||
|
component: () => import('./views/USBKiosk.vue'),
|
||||||
|
meta: { plugin: 'usb' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/print/usb-labels',
|
path: '/print/usb-labels',
|
||||||
name: 'print-usb-labels',
|
name: 'print-usb-labels',
|
||||||
|
|||||||
509
plugins/usb/frontend/views/USBKiosk.vue
Normal file
509
plugins/usb/frontend/views/USBKiosk.vue
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
<template>
|
||||||
|
<div class="usb-kiosk" @click="focusEntry">
|
||||||
|
<header class="kiosk-header">
|
||||||
|
<h1>USB Drives</h1>
|
||||||
|
<button v-if="step !== 'badge'" class="btn btn-secondary" @click="reset">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="disabled" class="kiosk-step">
|
||||||
|
<p class="kiosk-prompt">This kiosk is turned off.</p>
|
||||||
|
<p class="kiosk-hint">An administrator enables it in Settings under USB.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Badge FIRST. The drives carry no barcode, so there is nothing to present
|
||||||
|
and the screen has to offer a list - which means the list has to cost
|
||||||
|
something. A badge that resolves to a real person is the same bar taking
|
||||||
|
a drive already has to clear.
|
||||||
|
-->
|
||||||
|
<section v-if="step === 'badge'" class="kiosk-step">
|
||||||
|
<p class="kiosk-prompt">Enter your SSO to start</p>
|
||||||
|
<div class="entry-panel">
|
||||||
|
<input ref="entryInput" v-model="manualBadge" class="entry-input"
|
||||||
|
inputmode="none" autocomplete="off" placeholder="SSO"
|
||||||
|
@keydown.enter.prevent="startSession(manualBadge)" />
|
||||||
|
<TouchKeypad @digit="manualBadge += $event"
|
||||||
|
@clear="manualBadge = ''"
|
||||||
|
@backspace="manualBadge = manualBadge.slice(0, -1)" />
|
||||||
|
<button class="btn btn-primary act-button"
|
||||||
|
:disabled="!manualBadge || busy"
|
||||||
|
@click="startSession(manualBadge)">
|
||||||
|
{{ busy ? 'Checking...' : 'Next' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- the picker -->
|
||||||
|
<section v-else-if="step === 'pick'" class="kiosk-step kiosk-wide">
|
||||||
|
<p class="kiosk-greeting">{{ session.name }}</p>
|
||||||
|
|
||||||
|
<div v-if="session.yours.length" class="drive-group drive-group-yours">
|
||||||
|
<h2>Your drives</h2>
|
||||||
|
<div class="drive-list">
|
||||||
|
<button v-for="drive in session.yours" :key="drive.device_id"
|
||||||
|
class="drive-row" :disabled="busy"
|
||||||
|
@click="act(drive, 'checkin')">
|
||||||
|
<span class="drive-name">
|
||||||
|
{{ drive.device_id }}
|
||||||
|
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="drive-action return">Return</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drive-group">
|
||||||
|
<div class="drive-group-head">
|
||||||
|
<h2>Available</h2>
|
||||||
|
<!-- Typed, not tapped: this panel has a keyboard, and a cabinet of
|
||||||
|
thirty drives is faster to narrow than to read. -->
|
||||||
|
<div v-if="session.available.length" class="drive-filter">
|
||||||
|
<input ref="filterInput" v-model="drivefilter"
|
||||||
|
class="drive-filter-input"
|
||||||
|
autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="Find a drive, a locker..." />
|
||||||
|
<button v-if="drivefilter" class="drive-filter-clear"
|
||||||
|
title="Clear" @click="drivefilter = ''">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="!session.available.length" class="kiosk-hint">
|
||||||
|
No drives are free right now.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!availabledrives.length" class="kiosk-hint">
|
||||||
|
No free drive matches "{{ drivefilter }}".
|
||||||
|
</p>
|
||||||
|
<div v-else class="drive-list">
|
||||||
|
<button v-for="drive in availabledrives" :key="drive.device_id"
|
||||||
|
class="drive-row" :disabled="busy"
|
||||||
|
@click="act(drive, 'checkout')">
|
||||||
|
<span class="drive-name">
|
||||||
|
{{ drive.device_id }}
|
||||||
|
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
|
||||||
|
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="drive-action take">Take</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Other people's drives are behind a tap, not on the main screen: a
|
||||||
|
passer-by should not read the whole cabinet's holders off a wall. -->
|
||||||
|
<button v-if="session.otherscount" class="btn btn-secondary others-button"
|
||||||
|
:disabled="busy" @click="loadOthers">
|
||||||
|
Returning a drive for somebody else?
|
||||||
|
({{ session.otherscount }} out with others)
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- somebody else's drives -->
|
||||||
|
<section v-else-if="step === 'others'" class="kiosk-step kiosk-wide">
|
||||||
|
<h2>Checked out to somebody else</h2>
|
||||||
|
<p class="kiosk-hint">
|
||||||
|
Returning one puts it back and records that YOU handed it in.
|
||||||
|
</p>
|
||||||
|
<div class="drive-list">
|
||||||
|
<button v-for="drive in others" :key="drive.device_id"
|
||||||
|
class="drive-row" :disabled="busy"
|
||||||
|
@click="act(drive, 'checkin', true)">
|
||||||
|
<span class="drive-name">
|
||||||
|
{{ drive.device_id }}
|
||||||
|
<small class="drive-desc">{{ drive.holdername || 'somebody else' }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="drive-action return">Return</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary act-button" @click="step = 'pick'">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- done -->
|
||||||
|
<section v-else-if="step === 'done'" class="kiosk-step">
|
||||||
|
<p class="kiosk-done">{{ doneMessage }}</p>
|
||||||
|
<button class="btn btn-primary act-button" @click="reset">Finish</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import api, { settingsApi } from '@/api'
|
||||||
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||||
|
import { filterdrives } from '../drivefilter'
|
||||||
|
|
||||||
|
const step = ref('badge')
|
||||||
|
const badge = ref('')
|
||||||
|
const session = ref({ name: '', yours: [], available: [], otherscount: 0 })
|
||||||
|
const others = ref([])
|
||||||
|
const error = ref('')
|
||||||
|
const doneMessage = ref('')
|
||||||
|
const busy = ref(false)
|
||||||
|
const disabled = ref(false)
|
||||||
|
const manualBadge = ref('')
|
||||||
|
const entryInput = ref(null)
|
||||||
|
let resetTimer = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
|
||||||
|
// that says so - a kiosk merely switched off must not look broken.
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get('usb_kiosk_enabled')
|
||||||
|
const value = String(response.data?.data?.value ?? '').toLowerCase()
|
||||||
|
disabled.value = !(value === 'true' || value === '1')
|
||||||
|
} catch (err) {
|
||||||
|
// An unreachable setting is not proof it is off; let the API decide.
|
||||||
|
disabled.value = false
|
||||||
|
}
|
||||||
|
focusEntry()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||||
|
|
||||||
|
function focusEntry (event) {
|
||||||
|
const tag = event?.target?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA'
|
||||||
|
|| tag === 'BUTTON' || tag === 'A') return
|
||||||
|
nextTick(() => entryInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Narrowing the free drives. The matcher lives in its own module so it can be
|
||||||
|
// tested without mounting a kiosk.
|
||||||
|
const drivefilter = ref('')
|
||||||
|
const filterInput = ref(null)
|
||||||
|
|
||||||
|
// Focused as the picker opens, so somebody can simply start typing. The badge
|
||||||
|
// step owns the keyboard until then.
|
||||||
|
function focusFilter () {
|
||||||
|
nextTick(() => filterInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
const availabledrives = computed(
|
||||||
|
() => filterdrives(session.value.available, drivefilter.value))
|
||||||
|
|
||||||
|
// Drive ids sort as TEXT everywhere else, which puts -10 before -3 and reads as
|
||||||
|
// a jumble on a wall panel. Compare the numeric runs numerically; falls back to
|
||||||
|
// plain text for ids that carry no digits.
|
||||||
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
||||||
|
function byDeviceId (a, b) {
|
||||||
|
return collator.compare(a.device_id || '', b.device_id || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFrom (err, fallback) {
|
||||||
|
return err.response?.data?.data?.error?.message || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSession (value) {
|
||||||
|
const scanned = (value || '').trim()
|
||||||
|
if (!scanned || busy.value) return
|
||||||
|
error.value = ''
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.post('/usb/kiosk/session', { badge: scanned })
|
||||||
|
const data = response.data.data
|
||||||
|
data.yours = (data.yours || []).sort(byDeviceId)
|
||||||
|
data.available = (data.available || []).sort(byDeviceId)
|
||||||
|
session.value = data
|
||||||
|
badge.value = scanned
|
||||||
|
manualBadge.value = ''
|
||||||
|
step.value = 'pick'
|
||||||
|
focusFilter()
|
||||||
|
} catch (sessionError) {
|
||||||
|
error.value = messageFrom(sessionError, 'Could not look that badge up')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
focusEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOthers () {
|
||||||
|
error.value = ''
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const response = await api.post('/usb/kiosk/others', { badge: badge.value })
|
||||||
|
others.value = (response.data.data || []).sort(byDeviceId)
|
||||||
|
step.value = 'others'
|
||||||
|
} catch (othersError) {
|
||||||
|
error.value = messageFrom(othersError, 'Could not load those drives')
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act (drive, action, forsomeoneelse = false) {
|
||||||
|
if (busy.value) return
|
||||||
|
error.value = ''
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
const body = { device_id: drive.device_id, badge: badge.value }
|
||||||
|
if (forsomeoneelse) body.forsomeoneelse = true
|
||||||
|
const response = await api.post(`/usb/kiosk/${action}`, body)
|
||||||
|
doneMessage.value = response.data?.message
|
||||||
|
|| (action === 'checkin' ? 'Drive returned' : 'Drive checked out')
|
||||||
|
step.value = 'done'
|
||||||
|
// Clear the screen for the next person rather than leaving a name up.
|
||||||
|
resetTimer = setTimeout(reset, 8000)
|
||||||
|
} catch (actError) {
|
||||||
|
error.value = messageFrom(actError, 'That did not go through')
|
||||||
|
// The list is now out of date either way - somebody else may have taken it.
|
||||||
|
await refresh()
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh () {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/usb/kiosk/session', { badge: badge.value })
|
||||||
|
const data = response.data.data
|
||||||
|
data.yours = (data.yours || []).sort(byDeviceId)
|
||||||
|
data.available = (data.available || []).sort(byDeviceId)
|
||||||
|
session.value = data
|
||||||
|
step.value = 'pick'
|
||||||
|
} catch (refreshError) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset () {
|
||||||
|
clearTimeout(resetTimer)
|
||||||
|
step.value = 'badge'
|
||||||
|
badge.value = ''
|
||||||
|
session.value = { name: '', yours: [], available: [], otherscount: 0 }
|
||||||
|
others.value = []
|
||||||
|
error.value = ''
|
||||||
|
doneMessage.value = ''
|
||||||
|
manualBadge.value = ''
|
||||||
|
// The next person at the cabinet must not inherit somebody else's filter and
|
||||||
|
// conclude there are no drives left.
|
||||||
|
drivefilter.value = ''
|
||||||
|
focusEntry()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Touch-sized throughout: a wall panel, often operated with gloves on.
|
||||||
|
Laid out to match the parts kiosk beside it - two kiosks on the same floor
|
||||||
|
that sit differently on their screens read as two different products. */
|
||||||
|
.usb-kiosk {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
/* Centered as one block on a wall-mounted display rather than hugging the
|
||||||
|
top. `safe` so a short screen - or a long drive list - scrolls from the top
|
||||||
|
instead of clipping the header off the edge, which plain `center` does with
|
||||||
|
no way to scroll back up. */
|
||||||
|
justify-content: center;
|
||||||
|
justify-content: safe center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
.kiosk-header {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 72rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
/* Title centered over the panel; Done is pulled out of the flow so it keeps
|
||||||
|
its top-right corner instead of pushing the title off-center on the steps
|
||||||
|
that show it. */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.kiosk-header .btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.kiosk-header h1 { margin: 0; font-size: 2rem; }
|
||||||
|
.kiosk-step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-width: 40rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* The picker holds lists, so its rows stretch, and it is WIDER than the steps
|
||||||
|
that hold a single control - a cabinet of thirty drives is three or four
|
||||||
|
columns of one glance rather than a scroll. The steps that are just a keypad
|
||||||
|
or a message stay narrow, because stretching one input across a 24 inch panel
|
||||||
|
reads as a broken page.
|
||||||
|
It is also height-bounded: the lists absorb whatever space is left over and
|
||||||
|
scroll inside themselves, so the PAGE never scrolls and the heading saying
|
||||||
|
whose drives these are stays on screen. A fixed max-height cannot do this -
|
||||||
|
it either clips with room to spare or overflows once both groups are
|
||||||
|
present, which is exactly what it did. */
|
||||||
|
.kiosk-wide {
|
||||||
|
align-items: stretch;
|
||||||
|
max-width: 72rem;
|
||||||
|
max-height: calc(100vh - 9rem);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.kiosk-wide .drive-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
/* Your own drives size to their content - usually one or two - and the
|
||||||
|
available list absorbs what is left. Sharing the space evenly clipped a
|
||||||
|
single row of "Your drives" in half while the other list had room. */
|
||||||
|
.kiosk-wide .drive-group-yours { flex: 0 0 auto; }
|
||||||
|
.kiosk-wide .drive-group-yours .drive-list { max-height: 30vh; }
|
||||||
|
.kiosk-prompt { font-size: 1.5rem; text-align: center; margin: 0; }
|
||||||
|
.kiosk-greeting { font-size: 1.5rem; margin: 0; text-align: center; }
|
||||||
|
.kiosk-hint { color: var(--text-light); text-align: center; margin: 0; }
|
||||||
|
.kiosk-error {
|
||||||
|
padding: 0.9rem 1.1rem;
|
||||||
|
border: 1px solid var(--danger, #b3261e);
|
||||||
|
border-left-width: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--danger, #b3261e);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.kiosk-done { font-size: 1.6rem; text-align: center; margin: 2rem 0; }
|
||||||
|
|
||||||
|
.drive-group { width: 100%; }
|
||||||
|
.drive-group h2 { font-size: 1.2rem; margin: 0 0 0.5rem; }
|
||||||
|
/* Heading and filter on one line: the filter belongs to the list under it, and
|
||||||
|
a row of its own would cost height the lists need. */
|
||||||
|
.drive-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.drive-filter { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.drive-filter-input {
|
||||||
|
/* Touch-sized like everything else here, and wide enough for a locker name
|
||||||
|
rather than just a drive number. */
|
||||||
|
min-width: 16rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-card-solid);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.drive-filter-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.drive-filter-clear {
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* As many columns as the panel has room for - four on a wall display, one on a
|
||||||
|
small screen - so a cabinet of a dozen drives is one glance rather than a
|
||||||
|
scroll. The list itself scrolls when it has to; the PAGE must not, or the
|
||||||
|
person loses the heading telling them whose drives these are. */
|
||||||
|
.drive-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
}
|
||||||
|
.drive-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.drive-row:disabled { opacity: 0.5; }
|
||||||
|
/* The name has to YIELD. A flex item defaults to min-width:auto, so a long
|
||||||
|
label kept its intrinsic width and shoved Take or Return out through the side
|
||||||
|
of the card - a drive nobody could take. `anywhere` breaks the labels that
|
||||||
|
carry no spaces at all, which a serial-style name often does not. */
|
||||||
|
.drive-name {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
|
||||||
|
/* Two lines of description is enough to tell two drives apart; more just makes
|
||||||
|
one card tall and every card in its row tall with it. The locker line below
|
||||||
|
is never clipped - it is how somebody finds the thing. */
|
||||||
|
.drive-desc {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.drive-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.take { background: var(--primary, #2f6fed); color: #fff; }
|
||||||
|
.return { background: var(--warning, #b26a00); color: #fff; }
|
||||||
|
.others-button { width: 100%; padding: 0.9rem; margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.entry-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
.entry-input {
|
||||||
|
width: 16.9rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.entry-input:focus { border-color: var(--primary); }
|
||||||
|
.entry-input::placeholder {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.act-button {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
padding: 0.85rem 0;
|
||||||
|
width: 16.9rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
113
plugins/usb/frontend/views/USBSettings.vue
Normal file
113
plugins/usb/frontend/views/USBSettings.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>USB Devices</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 class="check-row">
|
||||||
|
<input type="checkbox" v-model="values.usb_kiosk_enabled" />
|
||||||
|
<span>Allow the logged-out USB kiosk</span>
|
||||||
|
</label>
|
||||||
|
<p class="field-hint">
|
||||||
|
Turns on <code>/usb-kiosk</code>, a full-screen touch page where somebody
|
||||||
|
at the drive cabinet takes and returns a drive without signing in.
|
||||||
|
It is off by default: this is a write nobody has logged in for, so it is
|
||||||
|
a decision a site makes deliberately. While it is off, every kiosk
|
||||||
|
request is refused and the page says so.
|
||||||
|
</p>
|
||||||
|
<p class="field-hint">
|
||||||
|
The kiosk can only move a drive between checked out and checked in. It
|
||||||
|
cannot add, edit or retire one. Each action is attributed to the SSO
|
||||||
|
entered on the screen, and an SSO that matches no employee is refused -
|
||||||
|
a checkout nobody can trace to a person defeats the point of tracking
|
||||||
|
it.
|
||||||
|
</p>
|
||||||
|
<p v-if="values.usb_kiosk_enabled" class="field-hint">
|
||||||
|
<a :href="withBase('/usb-kiosk')" target="_blank">Open the kiosk</a>
|
||||||
|
- also in the sidebar under Displays.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="check-row">
|
||||||
|
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
|
||||||
|
:disabled="!values.usb_kiosk_enabled" />
|
||||||
|
<span>Let somebody return a drive checked out to another person</span>
|
||||||
|
</label>
|
||||||
|
<p class="field-hint">
|
||||||
|
The kiosk asks first and names the holder, then records the SSO that
|
||||||
|
actually handed the drive back. Turn this off to refuse a mismatch
|
||||||
|
outright - but then a drive left behind by somebody on leave cannot be
|
||||||
|
returned at the kiosk at all, and needs a signed-in user to sort out.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||||
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { settingsApi } from '@/api'
|
||||||
|
import { withBase } from '@/utils/basePath'
|
||||||
|
|
||||||
|
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
|
||||||
|
|
||||||
|
const values = ref({
|
||||||
|
usb_kiosk_enabled: false,
|
||||||
|
usb_kiosk_allow_return_for_others: true,
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function asBool (value) {
|
||||||
|
return String(value).toLowerCase() === 'true' || value === true || value === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.list({ category: 'usb' })
|
||||||
|
for (const row of (response.data.data || [])) {
|
||||||
|
if (KEYS.includes(row.key)) values.value[row.key] = asBool(row.value)
|
||||||
|
}
|
||||||
|
} catch (loadError) {
|
||||||
|
error.value = 'Could not load USB settings.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save () {
|
||||||
|
saving.value = true
|
||||||
|
message.value = ''
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
for (const key of KEYS) {
|
||||||
|
await settingsApi.update(key, values.value[key] ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
message.value = 'Settings saved'
|
||||||
|
} catch (saveError) {
|
||||||
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
||||||
|
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
|
||||||
|
.check-row input { width: auto; }
|
||||||
|
.form-actions { margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
@@ -70,6 +70,85 @@ class USBPlugin(BasePlugin):
|
|||||||
'docs': 'plugins/usb/README.md',
|
'docs': 'plugins/usb/README.md',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_reports(self) -> List[Dict]:
|
||||||
|
"""Removable-media audit.
|
||||||
|
|
||||||
|
CMMC tracking is an audit obligation, and the only way to answer "who
|
||||||
|
had which drive, when" was to page through the check-in/out list by
|
||||||
|
hand. The window covers a shift, a periodic review, or everything ever
|
||||||
|
recorded.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': 'usb-audit',
|
||||||
|
'name': 'USB Check-in / Check-out Audit',
|
||||||
|
'description': 'Every take and return over a chosen window - '
|
||||||
|
'day, month, six months, year or all time - with '
|
||||||
|
'who, which drive and when',
|
||||||
|
'category': 'usage',
|
||||||
|
'endpoint': '/api/usb/reports/audit',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_settings_cards(self) -> List[Dict]:
|
||||||
|
"""Somewhere to actually turn the kiosk on.
|
||||||
|
|
||||||
|
The kiosk page tells an administrator to enable it in Settings under
|
||||||
|
USB. Without this card that place does not exist, and the instruction
|
||||||
|
is a dead end - the same trap the backups plugin shipped with, where
|
||||||
|
settings existed but only a shell could reach them.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'group': 'USB Devices',
|
||||||
|
'to': '/settings/usb',
|
||||||
|
'icon': 'usb',
|
||||||
|
'title': 'USB Settings',
|
||||||
|
'description': 'Logged-out kiosk for taking and returning '
|
||||||
|
'drives, and whether somebody may return a drive '
|
||||||
|
'checked out to another person',
|
||||||
|
'position': 45,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_settings_defaults(self) -> List[Dict]:
|
||||||
|
"""Kiosk settings.
|
||||||
|
|
||||||
|
The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns
|
||||||
|
it on deliberately. Opening that door is a decision about the door.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'key': 'usb_kiosk_enabled',
|
||||||
|
'value': 'false',
|
||||||
|
'valuetype': 'boolean',
|
||||||
|
'category': 'usb',
|
||||||
|
# Public: the kiosk page runs logged out and has to be able to
|
||||||
|
# tell "turned off" from "broken" before anyone signs in.
|
||||||
|
'public': True,
|
||||||
|
'description': 'Allow check-out and check-in from the logged-out '
|
||||||
|
'USB kiosk screen (/usb-kiosk). Off by default: the '
|
||||||
|
'kiosk writes without a login, attributing every '
|
||||||
|
'action to a scanned badge instead. It can only move '
|
||||||
|
'a device between checked-out and checked-in - it '
|
||||||
|
'cannot add, edit, retire or list devices.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'usb_kiosk_allow_return_for_others',
|
||||||
|
'value': 'true',
|
||||||
|
'valuetype': 'boolean',
|
||||||
|
'category': 'usb',
|
||||||
|
'public': True,
|
||||||
|
'description': 'At the kiosk, allow somebody to return a device '
|
||||||
|
'checked out to a DIFFERENT person after confirming. '
|
||||||
|
'The returning badge is what gets recorded, so the '
|
||||||
|
'log shows who actually handed it back. Turn this '
|
||||||
|
'off to refuse any mismatch outright - a device left '
|
||||||
|
'behind by someone on leave then needs a signed-in '
|
||||||
|
'user to return it.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def get_config_schema(self) -> List[Dict]:
|
def get_config_schema(self) -> List[Dict]:
|
||||||
"""CMMC USB check-in/out database connection. Host/name/user are settings
|
"""CMMC USB check-in/out database connection. Host/name/user are settings
|
||||||
the wizard can edit; the password stays in .env (emitted, not stored)."""
|
the wizard can edit; the password stays in .env (emitted, not stored)."""
|
||||||
@@ -106,7 +185,9 @@ class USBPlugin(BasePlugin):
|
|||||||
'name': 'USB Devices',
|
'name': 'USB Devices',
|
||||||
'icon': 'usb',
|
'icon': 'usb',
|
||||||
'route': '/usb',
|
'route': '/usb',
|
||||||
'position': 45,
|
'permission': 'usb.view',
|
||||||
|
'position': 25,
|
||||||
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ def _apply_links(warranty, assetids):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('', methods=['GET'])
|
@warranty_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def list_warranties():
|
def list_warranties():
|
||||||
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
|
"""List warranties. Filters: ?status=, ?assetid=, ?active=false."""
|
||||||
query = Warranty.query
|
query = Warranty.query
|
||||||
@@ -174,7 +174,7 @@ def list_warranties():
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
|
@warranty_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def warranties_for_asset(assetid):
|
def warranties_for_asset(assetid):
|
||||||
"""Warranties covering one asset (for the asset-detail panel)."""
|
"""Warranties covering one asset (for the asset-detail panel)."""
|
||||||
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
|
links = WarrantyAsset.query.filter_by(assetid=assetid).all()
|
||||||
@@ -188,7 +188,7 @@ def warranties_for_asset(assetid):
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
|
@warranty_bp.route('/<int:warrantyid>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def get_warranty(warrantyid):
|
def get_warranty(warrantyid):
|
||||||
warranty = db.session.get(Warranty, warrantyid)
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
if not warranty:
|
if not warranty:
|
||||||
@@ -197,7 +197,6 @@ def get_warranty(warrantyid):
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('', methods=['POST'])
|
@warranty_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.create')
|
@require_permission('warranty.create')
|
||||||
def create_warranty():
|
def create_warranty():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -220,7 +219,6 @@ def create_warranty():
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>', methods=['PUT'])
|
@warranty_bp.route('/<int:warrantyid>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.edit')
|
@require_permission('warranty.edit')
|
||||||
def update_warranty(warrantyid):
|
def update_warranty(warrantyid):
|
||||||
warranty = db.session.get(Warranty, warrantyid)
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
@@ -250,7 +248,6 @@ def update_warranty(warrantyid):
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>', methods=['DELETE'])
|
@warranty_bp.route('/<int:warrantyid>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.delete')
|
@require_permission('warranty.delete')
|
||||||
def delete_warranty(warrantyid):
|
def delete_warranty(warrantyid):
|
||||||
warranty = db.session.get(Warranty, warrantyid)
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
@@ -266,7 +263,6 @@ def delete_warranty(warrantyid):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>/refresh', methods=['POST'])
|
@warranty_bp.route('/<int:warrantyid>/refresh', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.edit')
|
@require_permission('warranty.edit')
|
||||||
def refresh_warranty(warrantyid):
|
def refresh_warranty(warrantyid):
|
||||||
warranty = db.session.get(Warranty, warrantyid)
|
warranty = db.session.get(Warranty, warrantyid)
|
||||||
@@ -296,7 +292,6 @@ def refresh_warranty(warrantyid):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('/sync/dell', methods=['POST'])
|
@warranty_bp.route('/sync/dell', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.edit')
|
@require_permission('warranty.edit')
|
||||||
def sync_dell():
|
def sync_dell():
|
||||||
"""Look up every asset serial (= Dell service tag) that has no warranty yet
|
"""Look up every asset serial (= Dell service tag) that has no warranty yet
|
||||||
@@ -400,7 +395,7 @@ def sync_dell():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('/report', methods=['GET'])
|
@warranty_bp.route('/report', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('warranty.view')
|
||||||
def warranty_report():
|
def warranty_report():
|
||||||
"""Counts + lists bucketed by derived status."""
|
"""Counts + lists bucketed by derived status."""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
@@ -414,7 +409,6 @@ def warranty_report():
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/dashboard/expiring', methods=['GET'])
|
@warranty_bp.route('/dashboard/expiring', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.view')
|
@require_permission('warranty.view')
|
||||||
def dashboard_expiring():
|
def dashboard_expiring():
|
||||||
"""Warranties running out, and ones that already have.
|
"""Warranties running out, and ones that already have.
|
||||||
@@ -498,7 +492,6 @@ def _hostname(asset):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>/proof', methods=['POST'])
|
@warranty_bp.route('/<int:warrantyid>/proof', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.edit')
|
@require_permission('warranty.edit')
|
||||||
def upload_proof(warrantyid):
|
def upload_proof(warrantyid):
|
||||||
"""Upload (or replace) the proof-of-cover document for a warranty."""
|
"""Upload (or replace) the proof-of-cover document for a warranty."""
|
||||||
@@ -548,7 +541,6 @@ def upload_proof(warrantyid):
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/proof/<path:filename>', methods=['GET'])
|
@warranty_bp.route('/proof/<path:filename>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.view')
|
@require_permission('warranty.view')
|
||||||
def serve_proof(filename):
|
def serve_proof(filename):
|
||||||
"""Download a proof document.
|
"""Download a proof document.
|
||||||
@@ -566,7 +558,6 @@ def serve_proof(filename):
|
|||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/<int:warrantyid>/proof', methods=['DELETE'])
|
@warranty_bp.route('/<int:warrantyid>/proof', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('warranty.edit')
|
@require_permission('warranty.edit')
|
||||||
def delete_proof(warrantyid):
|
def delete_proof(warrantyid):
|
||||||
"""Remove a warranty's proof document."""
|
"""Remove a warranty's proof document."""
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<h1>Warranty Report</h1>
|
<h1>Warranty Report</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||||
<EmailReportButton v-if="!loading" subject="Warranty Report"
|
<EmailReportButton v-if="!loading && canemail" subject="Warranty Report"
|
||||||
:columns="emailColumns" :rows="emailRows" />
|
:columns="emailColumns" :rows="emailRows" />
|
||||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,8 +65,15 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { warrantyApi } from '@/api'
|
import { warrantyApi } from '@/api'
|
||||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import MachineMapChip from '../components/MachineMapChip.vue'
|
import MachineMapChip from '../components/MachineMapChip.vue'
|
||||||
|
|
||||||
|
// Emailing a report is the reports.export permission the endpoint enforces.
|
||||||
|
// Offering the button to somebody who will only get a 403 is worse than not
|
||||||
|
// offering it.
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const counts = ref({})
|
const counts = ref({})
|
||||||
const buckets = ref({})
|
const buckets = ref({})
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ class WarrantyPlugin(BasePlugin):
|
|||||||
'name': 'Warranties',
|
'name': 'Warranties',
|
||||||
'icon': 'shield',
|
'icon': 'shield',
|
||||||
'route': '/warranties',
|
'route': '/warranties',
|
||||||
'position': 8,
|
'permission': 'warranty.view',
|
||||||
|
'position': 9,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ class WarrantyPlugin(BasePlugin):
|
|||||||
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
|
'description': 'Assets bucketed by coverage: expired, expiring soon, active',
|
||||||
'category': 'warranty',
|
'category': 'warranty',
|
||||||
'route': '/reports/warranty',
|
'route': '/reports/warranty',
|
||||||
|
'permission': 'warranty.view',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ 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.
|
||||||
# 0.21.0: added register_import_hook to shopdb.api so a plugin can own a VIRTUAL
|
# 0.21.0: added require_any_permission beside require_permission and require_role
|
||||||
|
# on shopdb.api so a plugin can gate a route on holding ANY of several
|
||||||
|
# permissions (an OR check) instead of one. Part of the read-permission rework
|
||||||
|
# where nav items and GET routes declare the permission they need. Additive name
|
||||||
|
# on the authz surface, minor bump.
|
||||||
|
# 0.22.0: added register_import_hook to shopdb.api so a plugin can own a VIRTUAL
|
||||||
# COLUMN on an importable CSV table - a header the bulk importer accepts but
|
# COLUMN on an importable CSV table - a header the bulk importer accepts but
|
||||||
# never writes - and a post-write row hook that turns that value into a plugin
|
# never writes - and a post-write row hook that turns that value into a plugin
|
||||||
# concept. The computers plugin uses it for a machineassetnumber column on
|
# concept. The computers plugin uses it for a machineassetnumber column on
|
||||||
@@ -65,18 +70,18 @@ from .plugins import plugin_manager
|
|||||||
# machine number in the PC row instead of hand-building a relationships sheet.
|
# machine number in the PC row instead of hand-building a relationships sheet.
|
||||||
# Core stays ignorant of the column's meaning. Additive name on the import
|
# Core stays ignorant of the column's meaning. Additive name on the import
|
||||||
# surface, minor bump.
|
# surface, minor bump.
|
||||||
# 0.22.0: added mint_service_token, list_service_tokens and revoke_service_tokens
|
# 0.23.0: added mint_service_token, list_service_tokens and revoke_service_tokens
|
||||||
# to shopdb.api so a plugin can provision, list, and revoke a single-scope
|
# to shopdb.api so a plugin can provision, list, and revoke a single-scope
|
||||||
# managed service token (an endpoint agent key) without importing core token
|
# managed service token (an endpoint agent key) without importing core token
|
||||||
# internals. The endpoints plugin uses them for a UI-managed, regenerable
|
# internals. The endpoints plugin uses them for a UI-managed, regenerable
|
||||||
# collector.ingest.endpoints key whose revocation is an instant kill switch.
|
# collector.ingest.endpoints key whose revocation is an instant kill switch.
|
||||||
# Additive names on the surface, minor bump.
|
# Additive names on the surface, minor bump.
|
||||||
# 0.23.0: added collector_authorized to shopdb.api so a plugin can gate an
|
# 0.24.0: added collector_authorized to shopdb.api so a plugin can gate an
|
||||||
# agent-facing route that lives OUTSIDE the generic collector blueprint (the
|
# agent-facing route that lives OUTSIDE the generic collector blueprint (the
|
||||||
# endpoints command claim/result long-poll callbacks) on the same per-plugin
|
# endpoints command claim/result long-poll callbacks) on the same per-plugin
|
||||||
# collector key + managed-token check the ingest route uses, without importing
|
# collector key + managed-token check the ingest route uses, without importing
|
||||||
# core collector internals. Additive name on the surface, minor bump.
|
# core collector internals. Additive name on the surface, minor bump.
|
||||||
__contract_version__ = '0.23.0'
|
__contract_version__ = '0.24.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
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ from shopdb.utils.responses import (
|
|||||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||||
|
|
||||||
# Authorization decorators for gating plugin write routes
|
# Authorization decorators for gating plugin write routes
|
||||||
from shopdb.utils.authz import require_permission, require_role
|
from shopdb.utils.authz import require_permission, require_any_permission, require_role
|
||||||
|
|
||||||
# Service-token authorization for unattended plugin endpoints (collector,
|
# Service-token authorization for unattended plugin endpoints (collector,
|
||||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||||
@@ -330,6 +330,7 @@ __all__ = [
|
|||||||
'paginate_query',
|
'paginate_query',
|
||||||
# Authorization decorators
|
# Authorization decorators
|
||||||
'require_permission',
|
'require_permission',
|
||||||
|
'require_any_permission',
|
||||||
'require_role',
|
'require_role',
|
||||||
'service_token_authorized',
|
'service_token_authorized',
|
||||||
'authorized_service_token',
|
'authorized_service_token',
|
||||||
|
|||||||
@@ -784,13 +784,20 @@ def fix_controls_direction():
|
|||||||
@seed_cli.command('permissions')
|
@seed_cli.command('permissions')
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
def seed_permissions():
|
def seed_permissions():
|
||||||
"""Seed predefined permissions."""
|
"""Seed predefined permissions, and the guest role that can hold them."""
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Permission
|
from shopdb.core.models import Permission, Role
|
||||||
|
|
||||||
created = Permission.seed()
|
created = Permission.seed()
|
||||||
|
# Seeded here rather than in `seed admin` so an existing site picks it up
|
||||||
|
# on the upgrade it already runs. It holds nothing until somebody grants
|
||||||
|
# it something, so seeding it changes no behaviour.
|
||||||
|
guest = Role.seedguest()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(click.style(f"{created} permissions created.", fg='green'))
|
click.echo(click.style(f"{created} permissions created.", fg='green'))
|
||||||
|
if guest:
|
||||||
|
click.echo(click.style(
|
||||||
|
"guest role created (holds no permissions).", fg='green'))
|
||||||
|
|
||||||
|
|
||||||
@seed_cli.command('settings')
|
@seed_cli.command('settings')
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ def list_apitokens():
|
|||||||
|
|
||||||
|
|
||||||
@apitokens_bp.route('', methods=['POST'])
|
@apitokens_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('apitokens.create')
|
@require_permission('apitokens.create')
|
||||||
def create_apitoken():
|
def create_apitoken():
|
||||||
"""Create a token for the caller. Returns the full secret ONCE.
|
"""Create a token for the caller. Returns the full secret ONCE.
|
||||||
@@ -141,7 +140,6 @@ def create_apitoken():
|
|||||||
|
|
||||||
|
|
||||||
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
|
@apitokens_bp.route('/<int:tokenid>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('apitokens.create')
|
@require_permission('apitokens.create')
|
||||||
def update_apitoken(tokenid: int):
|
def update_apitoken(tokenid: int):
|
||||||
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
|
"""Rename, rescope, or deactivate a token. Own token, or any if admin.
|
||||||
@@ -183,7 +181,6 @@ def update_apitoken(tokenid: int):
|
|||||||
|
|
||||||
|
|
||||||
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
|
@apitokens_bp.route('/<int:tokenid>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('apitokens.create')
|
@require_permission('apitokens.create')
|
||||||
def revoke_apitoken(tokenid: int):
|
def revoke_apitoken(tokenid: int):
|
||||||
"""Revoke (deactivate) a token. Own token, or any if admin."""
|
"""Revoke (deactivate) a token. Own token, or any if admin."""
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ applications_bp = Blueprint('applications', __name__)
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('', methods=['GET'])
|
@applications_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_applications():
|
def list_applications():
|
||||||
"""List all applications."""
|
"""List all applications."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -163,7 +163,7 @@ def list_applications():
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>', methods=['GET'])
|
@applications_bp.route('/<int:app_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def get_application(app_id: int):
|
def get_application(app_id: int):
|
||||||
"""Get a single application with details."""
|
"""Get a single application with details."""
|
||||||
app = db.session.get(Application, app_id)
|
app = db.session.get(Application, app_id)
|
||||||
@@ -180,7 +180,6 @@ def get_application(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('', methods=['POST'])
|
@applications_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.create')
|
@require_permission('applications.create')
|
||||||
def create_application():
|
def create_application():
|
||||||
"""Create a new application."""
|
"""Create a new application."""
|
||||||
@@ -224,7 +223,6 @@ def create_application():
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>', methods=['PUT'])
|
@applications_bp.route('/<int:app_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.edit')
|
@require_permission('applications.edit')
|
||||||
def update_application(app_id: int):
|
def update_application(app_id: int):
|
||||||
"""Update an application."""
|
"""Update an application."""
|
||||||
@@ -270,7 +268,6 @@ def update_application(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
|
@applications_bp.route('/<int:app_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.delete')
|
@require_permission('applications.delete')
|
||||||
def delete_application(app_id: int):
|
def delete_application(app_id: int):
|
||||||
"""Delete (deactivate) an application."""
|
"""Delete (deactivate) an application."""
|
||||||
@@ -291,7 +288,7 @@ def delete_application(app_id: int):
|
|||||||
# ---- Versions ----
|
# ---- Versions ----
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
|
@applications_bp.route('/<int:app_id>/versions', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_versions(app_id: int):
|
def list_versions(app_id: int):
|
||||||
"""List all versions for an application."""
|
"""List all versions for an application."""
|
||||||
app = db.session.get(Application, app_id)
|
app = db.session.get(Application, app_id)
|
||||||
@@ -303,7 +300,6 @@ def list_versions(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/versions', methods=['POST'])
|
@applications_bp.route('/<int:app_id>/versions', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.create')
|
@require_permission('applications.create')
|
||||||
def create_version(app_id: int):
|
def create_version(app_id: int):
|
||||||
"""Create a new version for an application."""
|
"""Create a new version for an application."""
|
||||||
@@ -341,7 +337,7 @@ def create_version(app_id: int):
|
|||||||
# ---- Computers with this app installed ----
|
# ---- Computers with this app installed ----
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/installed', methods=['GET'])
|
@applications_bp.route('/<int:app_id>/installed', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_installed_machines(app_id: int):
|
def list_installed_machines(app_id: int):
|
||||||
"""List all computers that have this application installed."""
|
"""List all computers that have this application installed."""
|
||||||
app = db.session.get(Application, app_id)
|
app = db.session.get(Application, app_id)
|
||||||
@@ -380,7 +376,7 @@ def list_installed_machines(app_id: int):
|
|||||||
# ---- Installed Apps (per computer) ----
|
# ---- Installed Apps (per computer) ----
|
||||||
|
|
||||||
@applications_bp.route('/machines/<int:machine_id>', methods=['GET'])
|
@applications_bp.route('/machines/<int:machine_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('applications.view')
|
||||||
def list_machine_applications(machine_id: int):
|
def list_machine_applications(machine_id: int):
|
||||||
"""List all applications installed on a computer."""
|
"""List all applications installed on a computer."""
|
||||||
models, err = _require_computer_models()
|
models, err = _require_computer_models()
|
||||||
@@ -397,7 +393,6 @@ def list_machine_applications(machine_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/machines/<int:machine_id>', methods=['POST'])
|
@applications_bp.route('/machines/<int:machine_id>', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.create')
|
@require_permission('applications.create')
|
||||||
def install_application(machine_id: int):
|
def install_application(machine_id: int):
|
||||||
"""Install an application on a computer."""
|
"""Install an application on a computer."""
|
||||||
@@ -449,7 +444,6 @@ def install_application(machine_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['DELETE'])
|
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.delete')
|
@require_permission('applications.delete')
|
||||||
def uninstall_application(machine_id: int, app_id: int):
|
def uninstall_application(machine_id: int, app_id: int):
|
||||||
"""Uninstall an application from a computer."""
|
"""Uninstall an application from a computer."""
|
||||||
@@ -474,7 +468,6 @@ def uninstall_application(machine_id: int, app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['PUT'])
|
@applications_bp.route('/machines/<int:machine_id>/<int:app_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.edit')
|
@require_permission('applications.edit')
|
||||||
def update_installed_app(machine_id: int, app_id: int):
|
def update_installed_app(machine_id: int, app_id: int):
|
||||||
"""Update installed application (e.g., change version)."""
|
"""Update installed application (e.g., change version)."""
|
||||||
@@ -516,7 +509,6 @@ def update_installed_app(machine_id: int, app_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
|
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.edit')
|
@require_permission('applications.edit')
|
||||||
def upload_application_image(app_id: int):
|
def upload_application_image(app_id: int):
|
||||||
"""Upload (or replace) an application's image.
|
"""Upload (or replace) an application's image.
|
||||||
@@ -549,13 +541,13 @@ def upload_application_image(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
|
@require_permission('applications.view')
|
||||||
def serve_application_image(filename):
|
def serve_application_image(filename):
|
||||||
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||||
return send_upload(_appimage_dir(), filename)
|
return send_upload(_appimage_dir(), filename)
|
||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
|
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.edit')
|
@require_permission('applications.edit')
|
||||||
def delete_application_image(app_id: int):
|
def delete_application_image(app_id: int):
|
||||||
"""Remove an application's image and clear the field."""
|
"""Remove an application's image and clear the field."""
|
||||||
@@ -574,7 +566,6 @@ def delete_application_image(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
|
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.edit')
|
@require_permission('applications.edit')
|
||||||
def upload_application_package(app_id: int):
|
def upload_application_package(app_id: int):
|
||||||
"""Upload (or replace) the installer for an application.
|
"""Upload (or replace) the installer for an application.
|
||||||
@@ -627,7 +618,6 @@ def upload_application_package(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.view')
|
@require_permission('applications.view')
|
||||||
def serve_application_package(filename):
|
def serve_application_package(filename):
|
||||||
"""Download an uploaded installer.
|
"""Download an uploaded installer.
|
||||||
@@ -640,7 +630,6 @@ def serve_application_package(filename):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
|
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('applications.edit')
|
@require_permission('applications.edit')
|
||||||
def delete_application_package(app_id: int):
|
def delete_application_package(app_id: int):
|
||||||
"""Remove an uploaded installer and clear the path it set."""
|
"""Remove an uploaded installer and clear the path it set."""
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ assets_bp = Blueprint('assets', __name__)
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/types', methods=['GET'])
|
@assets_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_asset_types():
|
def list_asset_types():
|
||||||
"""List all asset types."""
|
"""List all asset types."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -44,7 +44,7 @@ def list_asset_types():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
|
@assets_bp.route('/types/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_type(type_id: int):
|
def get_asset_type(type_id: int):
|
||||||
"""Get a single asset type."""
|
"""Get a single asset type."""
|
||||||
t = db.session.get(AssetType, type_id)
|
t = db.session.get(AssetType, type_id)
|
||||||
@@ -60,7 +60,6 @@ def get_asset_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/types', methods=['POST'])
|
@assets_bp.route('/types', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.create')
|
@require_permission('assets.create')
|
||||||
def create_asset_type():
|
def create_asset_type():
|
||||||
"""Create a new asset type."""
|
"""Create a new asset type."""
|
||||||
@@ -92,7 +91,6 @@ def create_asset_type():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
|
@assets_bp.route('/types/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def update_asset_type(type_id: int):
|
def update_asset_type(type_id: int):
|
||||||
"""Update an asset type's display fields (color/icon/description). The name,
|
"""Update an asset type's display fields (color/icon/description). The name,
|
||||||
@@ -113,7 +111,7 @@ def update_asset_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/statuses', methods=['GET'])
|
@assets_bp.route('/statuses', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_asset_statuses():
|
def list_asset_statuses():
|
||||||
"""List all asset statuses."""
|
"""List all asset statuses."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -132,7 +130,7 @@ def list_asset_statuses():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
|
@assets_bp.route('/statuses/<int:status_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_status(status_id: int):
|
def get_asset_status(status_id: int):
|
||||||
"""Get a single asset status."""
|
"""Get a single asset status."""
|
||||||
s = db.session.get(AssetStatus, status_id)
|
s = db.session.get(AssetStatus, status_id)
|
||||||
@@ -148,7 +146,6 @@ def get_asset_status(status_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/statuses', methods=['POST'])
|
@assets_bp.route('/statuses', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.create')
|
@require_permission('assets.create')
|
||||||
def create_asset_status():
|
def create_asset_status():
|
||||||
"""Create a new asset status."""
|
"""Create a new asset status."""
|
||||||
@@ -177,7 +174,6 @@ def create_asset_status():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
|
@assets_bp.route('/statuses/<int:status_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def update_asset_status(status_id: int):
|
def update_asset_status(status_id: int):
|
||||||
"""Update an asset status."""
|
"""Update an asset status."""
|
||||||
@@ -206,7 +202,6 @@ def update_asset_status(status_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
|
@assets_bp.route('/statuses/<int:status_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.delete')
|
@require_permission('assets.delete')
|
||||||
def delete_asset_status(status_id: int):
|
def delete_asset_status(status_id: int):
|
||||||
"""Delete an asset status. Refused if any asset still uses it."""
|
"""Delete an asset status. Refused if any asset still uses it."""
|
||||||
@@ -233,7 +228,7 @@ def delete_asset_status(status_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/relationshiptypes', methods=['GET'])
|
@assets_bp.route('/relationshiptypes', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_relationship_types():
|
def list_relationship_types():
|
||||||
"""List all asset relationship types."""
|
"""List all asset relationship types."""
|
||||||
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
|
types = RelationshipType.query.order_by(RelationshipType.relationshiptype).all()
|
||||||
@@ -241,7 +236,6 @@ def list_relationship_types():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/relationshiptypes', methods=['POST'])
|
@assets_bp.route('/relationshiptypes', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.create')
|
@require_permission('assets.create')
|
||||||
def create_relationship_type():
|
def create_relationship_type():
|
||||||
"""Create a new asset relationship type."""
|
"""Create a new asset relationship type."""
|
||||||
@@ -288,7 +282,6 @@ def _rel_type_dict(t):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
|
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def update_relationship_type(type_id: int):
|
def update_relationship_type(type_id: int):
|
||||||
"""Update a relationship type."""
|
"""Update a relationship type."""
|
||||||
@@ -308,7 +301,6 @@ def update_relationship_type(type_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
|
@assets_bp.route('/relationshiptypes/<int:type_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.delete')
|
@require_permission('assets.delete')
|
||||||
def delete_relationship_type(type_id: int):
|
def delete_relationship_type(type_id: int):
|
||||||
"""Delete a relationship type. Refused if any relationship still uses it."""
|
"""Delete a relationship type. Refused if any relationship still uses it."""
|
||||||
@@ -329,7 +321,7 @@ def delete_relationship_type(type_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('', methods=['GET'])
|
@assets_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_assets():
|
def list_assets():
|
||||||
"""
|
"""
|
||||||
List all assets with filtering and pagination.
|
List all assets with filtering and pagination.
|
||||||
@@ -415,7 +407,7 @@ def list_assets():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>', methods=['GET'])
|
@assets_bp.route('/<int:asset_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset(asset_id: int):
|
def get_asset(asset_id: int):
|
||||||
"""
|
"""
|
||||||
Get a single asset with full details.
|
Get a single asset with full details.
|
||||||
@@ -437,7 +429,6 @@ def get_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('', methods=['POST'])
|
@assets_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.create')
|
@require_permission('assets.create')
|
||||||
def create_asset():
|
def create_asset():
|
||||||
"""Create a new asset."""
|
"""Create a new asset."""
|
||||||
@@ -489,7 +480,6 @@ def create_asset():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
|
@assets_bp.route('/<int:asset_id>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def update_asset(asset_id: int):
|
def update_asset(asset_id: int):
|
||||||
"""Update an asset."""
|
"""Update an asset."""
|
||||||
@@ -542,7 +532,6 @@ def update_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.delete')
|
@require_permission('assets.delete')
|
||||||
def delete_asset(asset_id: int):
|
def delete_asset(asset_id: int):
|
||||||
"""Delete (soft delete) an asset."""
|
"""Delete (soft delete) an asset."""
|
||||||
@@ -562,7 +551,7 @@ def delete_asset(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
|
@assets_bp.route('/lookup/<assetnumber>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def lookup_asset_by_number(assetnumber: str):
|
def lookup_asset_by_number(assetnumber: str):
|
||||||
"""
|
"""
|
||||||
Look up an asset by its asset number.
|
Look up an asset by its asset number.
|
||||||
@@ -683,7 +672,7 @@ def propagate_relationship(rel):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
|
@assets_bp.route('/<int:asset_id>/relationships', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_relationships(asset_id: int):
|
def get_asset_relationships(asset_id: int):
|
||||||
"""
|
"""
|
||||||
Get all relationships for an asset.
|
Get all relationships for an asset.
|
||||||
@@ -733,7 +722,6 @@ def get_asset_relationships(asset_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/relationships', methods=['POST'])
|
@assets_bp.route('/relationships', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.create')
|
@require_permission('assets.create')
|
||||||
def create_asset_relationship():
|
def create_asset_relationship():
|
||||||
"""Create a relationship between two assets.
|
"""Create a relationship between two assets.
|
||||||
@@ -884,7 +872,6 @@ def create_asset_relationship():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
|
@assets_bp.route('/relationships/<int:rel_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.delete')
|
@require_permission('assets.delete')
|
||||||
def delete_asset_relationship(rel_id: int):
|
def delete_asset_relationship(rel_id: int):
|
||||||
"""Delete an asset relationship.
|
"""Delete an asset relationship.
|
||||||
@@ -917,7 +904,7 @@ def delete_asset_relationship(rel_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@assets_bp.route('/map', methods=['GET'])
|
@assets_bp.route('/map', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_assets_map():
|
def get_assets_map():
|
||||||
"""
|
"""
|
||||||
Get all assets with map positions for unified floor map display.
|
Get all assets with map positions for unified floor map display.
|
||||||
@@ -1239,7 +1226,7 @@ def get_assets_map():
|
|||||||
|
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
|
@assets_bp.route('/<int:asset_id>/communications', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_communications(asset_id: int):
|
def get_asset_communications(asset_id: int):
|
||||||
"""Get all communications for an asset."""
|
"""Get all communications for an asset."""
|
||||||
from shopdb.core.models import Communication
|
from shopdb.core.models import Communication
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ def _resolve_full_names(ssos):
|
|||||||
|
|
||||||
|
|
||||||
@auditlogs_bp.route('', methods=['GET'])
|
@auditlogs_bp.route('', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('audit.view')
|
@require_permission('audit.view')
|
||||||
def list_auditlogs():
|
def list_auditlogs():
|
||||||
"""
|
"""
|
||||||
@@ -136,7 +135,6 @@ def list_auditlogs():
|
|||||||
|
|
||||||
|
|
||||||
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
|
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('audit.view')
|
@require_permission('audit.view')
|
||||||
def get_entity_history(entitytype: str, entityid: int):
|
def get_entity_history(entitytype: str, entityid: int):
|
||||||
"""Get audit history for a specific entity."""
|
"""Get audit history for a specific entity."""
|
||||||
@@ -149,7 +147,6 @@ def get_entity_history(entitytype: str, entityid: int):
|
|||||||
|
|
||||||
|
|
||||||
@auditlogs_bp.route('/stats', methods=['GET'])
|
@auditlogs_bp.route('/stats', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('audit.view')
|
@require_permission('audit.view')
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get audit log statistics."""
|
"""Get audit log statistics."""
|
||||||
@@ -198,7 +195,6 @@ def db_func_count_by(column):
|
|||||||
|
|
||||||
|
|
||||||
@auditlogs_bp.route('/retention', methods=['GET'])
|
@auditlogs_bp.route('/retention', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('audit.view')
|
@require_permission('audit.view')
|
||||||
def get_retention():
|
def get_retention():
|
||||||
"""Size of the log and what the configured retention would remove.
|
"""Size of the log and what the configured retention would remove.
|
||||||
@@ -212,7 +208,6 @@ def get_retention():
|
|||||||
|
|
||||||
|
|
||||||
@auditlogs_bp.route('/purge', methods=['POST'])
|
@auditlogs_bp.route('/purge', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('audit.purge')
|
@require_permission('audit.purge')
|
||||||
def purge_auditlogs():
|
def purge_auditlogs():
|
||||||
"""Delete entries older than the retention window.
|
"""Delete entries older than the retention window.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
businessunits_bp = Blueprint('businessunits', __name__)
|
businessunits_bp = Blueprint('businessunits', __name__)
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ def list_businessunits():
|
|||||||
|
|
||||||
|
|
||||||
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
|
@businessunits_bp.route('/<int:bu_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_businessunit(bu_id: int):
|
def get_businessunit(bu_id: int):
|
||||||
"""Get a single business unit."""
|
"""Get a single business unit."""
|
||||||
bu = db.session.get(BusinessUnit, bu_id)
|
bu = db.session.get(BusinessUnit, bu_id)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from shopdb.utils.responses import (
|
|||||||
ErrorCodes
|
ErrorCodes
|
||||||
)
|
)
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
customfields_bp = Blueprint('customfields', __name__)
|
customfields_bp = Blueprint('customfields', __name__)
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ def _normalize_options(raw):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@customfields_bp.route('', methods=['GET'])
|
@customfields_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_fields():
|
def list_fields():
|
||||||
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
|
"""List custom-field definitions. Filter by ?assettypeid=. ?active=false
|
||||||
includes inactive ones."""
|
includes inactive ones."""
|
||||||
@@ -171,7 +172,7 @@ def _fields_with_values(asset):
|
|||||||
|
|
||||||
|
|
||||||
@customfields_bp.route('/asset/<int:assetid>', methods=['GET'])
|
@customfields_bp.route('/asset/<int:assetid>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_asset_fields(assetid):
|
def get_asset_fields(assetid):
|
||||||
"""Active custom fields for an asset's type, merged with its values."""
|
"""Active custom fields for an asset's type, merged with its values."""
|
||||||
asset = db.session.get(Asset, assetid)
|
asset = db.session.get(Asset, assetid)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Asset, AssetType, AssetStatus
|
from shopdb.core.models import Asset, AssetType, AssetStatus
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
dashboard_bp = Blueprint('dashboard', __name__)
|
dashboard_bp = Blueprint('dashboard', __name__)
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ def _countedassets():
|
|||||||
|
|
||||||
@dashboard_bp.route('/summary', methods=['GET'])
|
@dashboard_bp.route('/summary', methods=['GET'])
|
||||||
@dashboard_bp.route('', methods=['GET'])
|
@dashboard_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_dashboard():
|
def get_dashboard():
|
||||||
"""Get dashboard summary data (asset-based)."""
|
"""Get dashboard summary data (asset-based)."""
|
||||||
machine_count = _count_by_type('machine')
|
machine_count = _count_by_type('machine')
|
||||||
@@ -121,7 +122,7 @@ def get_dashboard():
|
|||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/stats', methods=['GET'])
|
@dashboard_bp.route('/stats', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_stats():
|
def get_stats():
|
||||||
"""Get detailed statistics by asset type."""
|
"""Get detailed statistics by asset type."""
|
||||||
type_counts = db.session.query(
|
type_counts = db.session.query(
|
||||||
@@ -154,9 +155,14 @@ def get_navigation():
|
|||||||
all_items = []
|
all_items = []
|
||||||
|
|
||||||
# Core navigation items (always present)
|
# Core navigation items (always present)
|
||||||
|
# Every item names the permission its page needs, so the filter below can
|
||||||
|
# hide what the caller cannot reach - a link that only leads to a 401 is
|
||||||
|
# worse than no link.
|
||||||
all_items.extend([
|
all_items.extend([
|
||||||
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/', 'position': 0},
|
{'name': 'Dashboard', 'icon': 'layout-dashboard', 'route': '/',
|
||||||
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4},
|
'position': 0, 'permission': 'assets.view'},
|
||||||
|
{'name': 'Map', 'icon': 'map', 'route': '/map', 'position': 4,
|
||||||
|
'permission': 'assets.view'},
|
||||||
])
|
])
|
||||||
|
|
||||||
# Collect navigation items from enabled plugins. Disabling persists to the
|
# Collect navigation items from enabled plugins. Disabling persists to the
|
||||||
@@ -180,18 +186,41 @@ def get_navigation():
|
|||||||
|
|
||||||
# Add core information section items
|
# Add core information section items
|
||||||
all_items.extend([
|
all_items.extend([
|
||||||
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications', 'position': 30, 'section': 'information'},
|
{'name': 'Applications', 'icon': 'app-window', 'route': '/applications',
|
||||||
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports', 'position': 40, 'section': 'information'},
|
'position': 30, 'section': 'information',
|
||||||
|
'permission': 'applications.view'},
|
||||||
|
{'name': 'Reports', 'icon': 'bar-chart-3', 'route': '/reports',
|
||||||
|
'position': 40, 'section': 'information', 'permission': 'reports.view'},
|
||||||
])
|
])
|
||||||
|
|
||||||
# Sort by position
|
# Sort by position
|
||||||
all_items.sort(key=lambda x: x.get('position', 99))
|
all_items.sort(key=lambda x: x.get('position', 99))
|
||||||
|
|
||||||
|
# A signed-out visitor is shown only what the guest role can actually
|
||||||
|
# reach. Without this the sidebar offers them the whole app and every link
|
||||||
|
# bounces off a 401, which reads as a broken site rather than a closed
|
||||||
|
# door. An item with no declared permission is treated as members-only: a
|
||||||
|
# plugin opts into being guest-visible by naming the permission its page
|
||||||
|
# needs, which is the same string the API already gates on.
|
||||||
|
from flask_jwt_extended import verify_jwt_in_request
|
||||||
|
from shopdb.utils.authz import currentuser, guestholds
|
||||||
|
|
||||||
|
verify_jwt_in_request(optional=True)
|
||||||
|
def guestmayreach(item):
|
||||||
|
declared = item.get('permission')
|
||||||
|
if not declared:
|
||||||
|
return False
|
||||||
|
names = declared if isinstance(declared, list) else [declared]
|
||||||
|
return any(guestholds(name) for name in names)
|
||||||
|
|
||||||
|
if currentuser() is None:
|
||||||
|
all_items = [item for item in all_items if guestmayreach(item)]
|
||||||
|
|
||||||
return success_response(all_items)
|
return success_response(all_items)
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route('/widgets', methods=['GET'])
|
@dashboard_bp.route('/widgets', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_widgets():
|
def get_widgets():
|
||||||
"""Aggregate dashboard widget definitions from all enabled plugins.
|
"""Aggregate dashboard widget definitions from all enabled plugins.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from shopdb.core.models.dashboarddefault import (
|
|||||||
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
|
DISPLAY_ROLES, DISPLAY_ROLE_PATHS, normalize_display_role)
|
||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
|
dashboarddefaults_bp = Blueprint('dashboarddefaults', __name__)
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ def display_role():
|
|||||||
|
|
||||||
|
|
||||||
@dashboarddefaults_bp.route('', methods=['GET'])
|
@dashboarddefaults_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('settings.view')
|
||||||
def list_defaults():
|
def list_defaults():
|
||||||
"""List all visitor-IP -> business-unit mappings."""
|
"""List all visitor-IP -> business-unit mappings."""
|
||||||
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
|
defaults = DashboardDefault.query.filter_by(isactive=True).order_by(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
locations_bp = Blueprint('locations', __name__)
|
locations_bp = Blueprint('locations', __name__)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ def _loc_type_dict(t):
|
|||||||
|
|
||||||
|
|
||||||
@locations_bp.route('/types', methods=['GET'])
|
@locations_bp.route('/types', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_location_types():
|
def list_location_types():
|
||||||
"""List location types. ?active=false includes inactive ones."""
|
"""List location types. ?active=false includes inactive ones."""
|
||||||
query = LocationType.query
|
query = LocationType.query
|
||||||
@@ -101,7 +102,7 @@ def delete_location_type(type_id):
|
|||||||
|
|
||||||
|
|
||||||
@locations_bp.route('', methods=['GET'])
|
@locations_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_locations():
|
def list_locations():
|
||||||
"""List all locations."""
|
"""List all locations."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -132,7 +133,7 @@ def list_locations():
|
|||||||
|
|
||||||
|
|
||||||
@locations_bp.route('/<int:location_id>', methods=['GET'])
|
@locations_bp.route('/<int:location_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_location(location_id: int):
|
def get_location(location_id: int):
|
||||||
"""Get a single location."""
|
"""Get a single location."""
|
||||||
loc = db.session.get(Location, location_id)
|
loc = db.session.get(Location, location_id)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
|||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.api import send_upload
|
from shopdb.api import send_upload
|
||||||
from shopdb.utils.imagesize import image_size
|
from shopdb.utils.imagesize import image_size
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
maplevels_bp = Blueprint('maplevels', __name__)
|
maplevels_bp = Blueprint('maplevels', __name__)
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ def _blueprint_dir():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@maplevels_bp.route('', methods=['GET'])
|
@maplevels_bp.route('', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def list_levels():
|
def list_levels():
|
||||||
"""Every active level, grouped by building, in display order.
|
"""Every active level, grouped by building, in display order.
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ def list_levels():
|
|||||||
|
|
||||||
|
|
||||||
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def get_level(levelid):
|
def get_level(levelid):
|
||||||
level = db.session.get(MapLevel, levelid)
|
level = db.session.get(MapLevel, levelid)
|
||||||
if not level or not level.isactive:
|
if not level or not level.isactive:
|
||||||
@@ -81,6 +84,7 @@ def get_level(levelid):
|
|||||||
|
|
||||||
|
|
||||||
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def serve_blueprint(filename, levelid=None):
|
def serve_blueprint(filename, levelid=None):
|
||||||
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||||
return send_upload(_blueprint_dir(), filename)
|
return send_upload(_blueprint_dir(), filename)
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ def apply_transform(asset, transform):
|
|||||||
|
|
||||||
|
|
||||||
@mappositions_bp.route('/transform', methods=['POST'])
|
@mappositions_bp.route('/transform', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def transform_positions():
|
def transform_positions():
|
||||||
"""Move every placed marker on a level by a transform read off landmarks.
|
"""Move every placed marker on a level by a transform read off landmarks.
|
||||||
@@ -225,7 +224,6 @@ def transform_positions():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@mappositions_bp.route('/positions', methods=['POST'])
|
@mappositions_bp.route('/positions', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def set_positions():
|
def set_positions():
|
||||||
"""Set positions for many assets at once.
|
"""Set positions for many assets at once.
|
||||||
@@ -301,7 +299,6 @@ def set_positions():
|
|||||||
|
|
||||||
|
|
||||||
@mappositions_bp.route('/verify', methods=['POST'])
|
@mappositions_bp.route('/verify', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def verify_positions():
|
def verify_positions():
|
||||||
"""Mark markers as reviewed against the current drawing without moving them.
|
"""Mark markers as reviewed against the current drawing without moving them.
|
||||||
@@ -330,7 +327,6 @@ def verify_positions():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@mappositions_bp.route('/snapshots', methods=['GET'])
|
@mappositions_bp.route('/snapshots', methods=['GET'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.view')
|
@require_permission('assets.view')
|
||||||
def list_snapshots():
|
def list_snapshots():
|
||||||
"""Snapshots, newest first. Metadata only - the positions are large."""
|
"""Snapshots, newest first. Metadata only - the positions are large."""
|
||||||
@@ -341,7 +337,6 @@ def list_snapshots():
|
|||||||
|
|
||||||
|
|
||||||
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
|
@mappositions_bp.route('/snapshots/<int:snapshotid>/restore', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.edit')
|
@require_permission('assets.edit')
|
||||||
def restore_snapshot(snapshotid):
|
def restore_snapshot(snapshotid):
|
||||||
"""Put the positions in this snapshot back.
|
"""Put the positions in this snapshot back.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
from shopdb.utils.uploads import send_upload
|
from shopdb.utils.uploads import send_upload
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
models_bp = Blueprint('models', __name__)
|
models_bp = Blueprint('models', __name__)
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ def _modelimage_dir():
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('', methods=['GET'])
|
@models_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_models():
|
def list_models():
|
||||||
"""List all vendor catalog models."""
|
"""List all vendor catalog models."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -85,7 +86,7 @@ def list_models():
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('/<int:model_id>', methods=['GET'])
|
@models_bp.route('/<int:model_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_model(model_id: int):
|
def get_model(model_id: int):
|
||||||
"""Get a single model."""
|
"""Get a single model."""
|
||||||
m = db.session.get(Model, model_id)
|
m = db.session.get(Model, model_id)
|
||||||
@@ -234,6 +235,7 @@ def upload_model_image(model_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
|
@require_permission('assets.view')
|
||||||
def serve_model_image(filename):
|
def serve_model_image(filename):
|
||||||
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
||||||
return send_upload(_modelimage_dir(), filename)
|
return send_upload(_modelimage_dir(), filename)
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
modeltypes_bp = Blueprint('modeltypes', __name__)
|
modeltypes_bp = Blueprint('modeltypes', __name__)
|
||||||
|
|
||||||
|
|
||||||
@modeltypes_bp.route('', methods=['GET'])
|
@modeltypes_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_modeltypes():
|
def list_modeltypes():
|
||||||
"""List all model types with optional filtering."""
|
"""List all model types with optional filtering."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -53,7 +54,7 @@ def list_modeltypes():
|
|||||||
|
|
||||||
|
|
||||||
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
|
@modeltypes_bp.route('/<int:type_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_modeltype(type_id: int):
|
def get_modeltype(type_id: int):
|
||||||
"""Get a single model type."""
|
"""Get a single model type."""
|
||||||
mt = db.session.get(ModelType, type_id)
|
mt = db.session.get(ModelType, type_id)
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
operatingsystems_bp = Blueprint('operatingsystems', __name__)
|
operatingsystems_bp = Blueprint('operatingsystems', __name__)
|
||||||
|
|
||||||
|
|
||||||
@operatingsystems_bp.route('', methods=['GET'])
|
@operatingsystems_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_operatingsystems():
|
def list_operatingsystems():
|
||||||
"""List all operating systems."""
|
"""List all operating systems."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -49,7 +50,7 @@ def list_operatingsystems():
|
|||||||
|
|
||||||
|
|
||||||
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
|
@operatingsystems_bp.route('/<int:os_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_operatingsystem(os_id: int):
|
def get_operatingsystem(os_id: int):
|
||||||
"""Get a single operating system."""
|
"""Get a single operating system."""
|
||||||
os = db.session.get(OperatingSystem, os_id)
|
os = db.session.get(OperatingSystem, os_id)
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ from flask_jwt_extended import jwt_required
|
|||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
plugins_bp = Blueprint('plugins', __name__)
|
plugins_bp = Blueprint('plugins', __name__)
|
||||||
|
|
||||||
|
|
||||||
@plugins_bp.route('', methods=['GET'])
|
@plugins_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('settings.view')
|
||||||
def list_plugins():
|
def list_plugins():
|
||||||
"""List all discovered plugins (enabled or not) with their metadata and
|
"""List all discovered plugins (enabled or not) with their metadata and
|
||||||
the framework contract version."""
|
the framework contract version."""
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import Asset
|
from shopdb.core.models import Asset
|
||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
pluginui_bp = Blueprint('pluginui', __name__)
|
pluginui_bp = Blueprint('pluginui', __name__)
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ def _collect(hookname):
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
@pluginui_bp.route('/settings-cards', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def settings_cards():
|
def settings_cards():
|
||||||
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
"""Merge enabled plugins' settings-catalog cards (get_settings_cards hook).
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ def settings_cards():
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
@pluginui_bp.route('/asset-panels', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def asset_panels():
|
def asset_panels():
|
||||||
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
"""Return the asset-detail panels matching one asset's type (get_asset_panels).
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ def asset_panels():
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
@pluginui_bp.route('/map-overlays', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def map_overlays():
|
def map_overlays():
|
||||||
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
"""Merge enabled plugins' map overlay declarations (get_map_overlays hook).
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ def map_overlays():
|
|||||||
|
|
||||||
|
|
||||||
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
@pluginui_bp.route('/asset-presentation', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def asset_presentation():
|
def asset_presentation():
|
||||||
"""Merge enabled plugins' asset-type presentation entries (hook).
|
"""Merge enabled plugins' asset-type presentation entries (hook).
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def generate_csv(data: list, columns: list) -> str:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/machines-by-type', methods=['GET'])
|
@reports_bp.route('/machines-by-type', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def machines_by_type():
|
def machines_by_type():
|
||||||
"""
|
"""
|
||||||
Report: Machine count grouped by machine type.
|
Report: Machine count grouped by machine type.
|
||||||
@@ -107,7 +107,7 @@ def machines_by_type():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/assets-by-status', methods=['GET'])
|
@reports_bp.route('/assets-by-status', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def assets_by_status():
|
def assets_by_status():
|
||||||
"""
|
"""
|
||||||
Report: Asset count grouped by status.
|
Report: Asset count grouped by status.
|
||||||
@@ -166,7 +166,7 @@ def assets_by_status():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/kb-popularity', methods=['GET'])
|
@reports_bp.route('/kb-popularity', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def kb_popularity():
|
def kb_popularity():
|
||||||
"""
|
"""
|
||||||
Report: Most clicked knowledge base articles.
|
Report: Most clicked knowledge base articles.
|
||||||
@@ -223,7 +223,7 @@ def kb_popularity():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/software-compliance', methods=['GET'])
|
@reports_bp.route('/software-compliance', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def software_compliance():
|
def software_compliance():
|
||||||
"""
|
"""
|
||||||
Report: Required applications vs installed (per PC).
|
Report: Required applications vs installed (per PC).
|
||||||
@@ -342,7 +342,7 @@ def software_compliance():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/asset-inventory', methods=['GET'])
|
@reports_bp.route('/asset-inventory', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def asset_inventory():
|
def asset_inventory():
|
||||||
"""
|
"""
|
||||||
Report: Complete asset inventory summary.
|
Report: Complete asset inventory summary.
|
||||||
@@ -456,7 +456,7 @@ def asset_inventory():
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@reports_bp.route('/pc-relationships', methods=['GET'])
|
@reports_bp.route('/pc-relationships', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def pc_relationships():
|
def pc_relationships():
|
||||||
"""
|
"""
|
||||||
Report: PCs with relationships to shop floor machines.
|
Report: PCs with relationships to shop floor machines.
|
||||||
@@ -525,7 +525,6 @@ def pc_relationships():
|
|||||||
|
|
||||||
|
|
||||||
@reports_bp.route('/email', methods=['POST'])
|
@reports_bp.route('/email', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('reports.export')
|
@require_permission('reports.export')
|
||||||
def email_report():
|
def email_report():
|
||||||
"""Email a report's data as an HTML table (on-demand delivery).
|
"""Email a report's data as an HTML table (on-demand delivery).
|
||||||
@@ -574,7 +573,7 @@ def email_report():
|
|||||||
|
|
||||||
|
|
||||||
@reports_bp.route('', methods=['GET'])
|
@reports_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('reports.view')
|
||||||
def list_reports():
|
def list_reports():
|
||||||
"""List all available reports."""
|
"""List all available reports."""
|
||||||
reports = [
|
reports = [
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from shopdb.core.models import (
|
|||||||
from shopdb.core.api.settings import get_cached_settings
|
from shopdb.core.api.settings import get_cached_settings
|
||||||
from shopdb.core.services.spellfix import Vocabulary
|
from shopdb.core.services.spellfix import Vocabulary
|
||||||
from shopdb.utils.responses import success_response
|
from shopdb.utils.responses import success_response
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1161,7 +1162,7 @@ def _suggestion_for(query):
|
|||||||
|
|
||||||
|
|
||||||
@search_bp.route('/suggest', methods=['GET'])
|
@search_bp.route('/suggest', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def search_suggest():
|
def search_suggest():
|
||||||
"""A better spelling for a query that found nothing, or null.
|
"""A better spelling for a query that found nothing, or null.
|
||||||
|
|
||||||
@@ -1185,7 +1186,7 @@ def search_suggest():
|
|||||||
|
|
||||||
|
|
||||||
@search_bp.route('', methods=['GET'])
|
@search_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def global_search():
|
def global_search():
|
||||||
"""
|
"""
|
||||||
Global search across multiple entity types.
|
Global search across multiple entity types.
|
||||||
|
|||||||
@@ -576,7 +576,6 @@ def get_setting(key: str):
|
|||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/<key>', methods=['PUT'])
|
@settings_bp.route('/<key>', methods=['PUT'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('settings.edit')
|
@require_permission('settings.edit')
|
||||||
def update_setting(key: str):
|
def update_setting(key: str):
|
||||||
"""Update a setting value."""
|
"""Update a setting value."""
|
||||||
@@ -635,7 +634,6 @@ def update_setting(key: str):
|
|||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/test-email', methods=['POST'])
|
@settings_bp.route('/test-email', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('settings.edit')
|
@require_permission('settings.edit')
|
||||||
def test_email():
|
def test_email():
|
||||||
"""Send a test email to verify SMTP configuration.
|
"""Send a test email to verify SMTP configuration.
|
||||||
@@ -673,7 +671,6 @@ def test_email():
|
|||||||
|
|
||||||
|
|
||||||
@settings_bp.route('', methods=['POST'])
|
@settings_bp.route('', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('settings.edit')
|
@require_permission('settings.edit')
|
||||||
def create_setting():
|
def create_setting():
|
||||||
"""Create a new setting (admin only)."""
|
"""Create a new setting (admin only)."""
|
||||||
@@ -1299,7 +1296,6 @@ def build_default_settings():
|
|||||||
|
|
||||||
|
|
||||||
@settings_bp.route('/seed', methods=['POST'])
|
@settings_bp.route('/seed', methods=['POST'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('settings.edit')
|
@require_permission('settings.edit')
|
||||||
def seed_default_settings():
|
def seed_default_settings():
|
||||||
"""Seed default settings if they don't exist."""
|
"""Seed default settings if they don't exist."""
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ from shopdb.utils.responses import (
|
|||||||
)
|
)
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
supportteams_bp = Blueprint('supportteams', __name__)
|
supportteams_bp = Blueprint('supportteams', __name__)
|
||||||
|
|
||||||
|
|
||||||
@supportteams_bp.route('', methods=['GET'])
|
@supportteams_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_support_teams():
|
def list_support_teams():
|
||||||
"""List support teams (with contacts). ?active and ?teamname filters."""
|
"""List support teams (with contacts). ?active and ?teamname filters."""
|
||||||
query = SupportTeam.query
|
query = SupportTeam.query
|
||||||
@@ -36,7 +37,7 @@ def list_support_teams():
|
|||||||
|
|
||||||
|
|
||||||
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
|
@supportteams_bp.route('/<int:team_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_support_team(team_id: int):
|
def get_support_team(team_id: int):
|
||||||
"""Get a single support team with its contacts."""
|
"""Get a single support team with its contacts."""
|
||||||
team = db.session.get(SupportTeam, team_id)
|
team = db.session.get(SupportTeam, team_id)
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ from werkzeug.security import generate_password_hash
|
|||||||
|
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
from shopdb.core.models import (
|
from shopdb.core.models import (
|
||||||
User, Role, Permission, AuditLog, full_permission_catalog)
|
User, Role, Permission, AuditLog, full_permission_catalog,
|
||||||
|
guest_grantable_permissions)
|
||||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
|
|
||||||
@@ -266,6 +267,9 @@ def list_permissions():
|
|||||||
def list_roles():
|
def list_roles():
|
||||||
"""List all roles with their permissions."""
|
"""List all roles with their permissions."""
|
||||||
roles = Role.query.order_by(Role.rolename).all()
|
roles = Role.query.order_by(Role.rolename).all()
|
||||||
|
# What a guest MAY be granted, so the grid can grey out the rest rather
|
||||||
|
# than letting somebody tick a box the API will refuse.
|
||||||
|
guestgrantable = sorted(guest_grantable_permissions())
|
||||||
return success_response([{
|
return success_response([{
|
||||||
'roleid': r.roleid,
|
'roleid': r.roleid,
|
||||||
'rolename': r.rolename,
|
'rolename': r.rolename,
|
||||||
@@ -273,7 +277,9 @@ def list_roles():
|
|||||||
'color': r.color,
|
'color': r.color,
|
||||||
'usercount': r.users.count(),
|
'usercount': r.users.count(),
|
||||||
'permissions': [p.name for p in r.permissions],
|
'permissions': [p.name for p in r.permissions],
|
||||||
'isadmin': r.rolename == 'admin'
|
'isadmin': r.rolename == 'admin',
|
||||||
|
'isguest': r.rolename == Role.GUEST,
|
||||||
|
'grantable': guestgrantable if r.rolename == Role.GUEST else None,
|
||||||
} for r in roles])
|
} for r in roles])
|
||||||
|
|
||||||
|
|
||||||
@@ -344,6 +350,20 @@ def update_role(roleid: int):
|
|||||||
|
|
||||||
# Update permissions
|
# Update permissions
|
||||||
if 'permissions' in data and role.rolename != 'admin':
|
if 'permissions' in data and role.rolename != 'admin':
|
||||||
|
# The guest role is anyone with no account, so what it may hold is
|
||||||
|
# bounded: reads, plus the handful of writes a plugin has explicitly
|
||||||
|
# declared safe to do anonymously. Refused at the door rather than
|
||||||
|
# warned about - a checkbox that quietly makes deleting an asset
|
||||||
|
# anonymous is not a warning anybody reads twice.
|
||||||
|
if role.rolename == Role.GUEST:
|
||||||
|
allowed = guest_grantable_permissions()
|
||||||
|
refused = sorted(set(data['permissions']) - allowed)
|
||||||
|
if refused:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'The guest role cannot hold {}. A guest may be granted any '
|
||||||
|
'view permission, and only writes a plugin has declared '
|
||||||
|
'safe to do without an account.'.format(', '.join(refused)))
|
||||||
old_perms = [p.name for p in role.permissions]
|
old_perms = [p.name for p in role.permissions]
|
||||||
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
|
perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all()
|
||||||
new_perms = [p.name for p in perms]
|
new_perms = [p.name for p in perms]
|
||||||
@@ -377,6 +397,14 @@ def delete_role(roleid: int):
|
|||||||
if role.rolename == 'admin':
|
if role.rolename == 'admin':
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Cannot delete the admin role')
|
||||||
|
|
||||||
|
# Deleting the guest role does not close the door, it removes the only
|
||||||
|
# place the door is described. Revoke its permissions instead.
|
||||||
|
if role.rolename == Role.GUEST:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Cannot delete the guest role. To close guest access, clear its '
|
||||||
|
'permissions.')
|
||||||
|
|
||||||
if role.users.count() > 0:
|
if role.users.count() > 0:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
|
return error_response(ErrorCodes.VALIDATION_ERROR, f'Role is assigned to {role.users.count()} user(s)')
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
|||||||
|
|
||||||
from shopdb.utils.authz import require_role
|
from shopdb.utils.authz import require_role
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
from shopdb.utils.import_mode import apply_import_timestamps
|
||||||
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
vendors_bp = Blueprint('vendors', __name__)
|
vendors_bp = Blueprint('vendors', __name__)
|
||||||
|
|
||||||
|
|
||||||
@vendors_bp.route('', methods=['GET'])
|
@vendors_bp.route('', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def list_vendors():
|
def list_vendors():
|
||||||
"""List all vendors."""
|
"""List all vendors."""
|
||||||
page, per_page = get_pagination_params(request)
|
page, per_page = get_pagination_params(request)
|
||||||
@@ -46,7 +47,7 @@ def list_vendors():
|
|||||||
|
|
||||||
|
|
||||||
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
|
@vendors_bp.route('/<int:vendor_id>', methods=['GET'])
|
||||||
@jwt_required(optional=True)
|
@require_permission('assets.view')
|
||||||
def get_vendor(vendor_id: int):
|
def get_vendor(vendor_id: int):
|
||||||
"""Get a single vendor."""
|
"""Get a single vendor."""
|
||||||
v = db.session.get(Vendor, vendor_id)
|
v = db.session.get(Vendor, vendor_id)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ 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
|
||||||
from .user import User, Role, Permission, full_permission_catalog
|
from .user import (User, Role, Permission, full_permission_catalog,
|
||||||
|
guest_grantable_permissions)
|
||||||
from .application import Application, AppVersion
|
from .application import Application, AppVersion
|
||||||
from .supportteam import SupportTeam, SupportTeamContact
|
from .supportteam import SupportTeam, SupportTeamContact
|
||||||
from .setting import Setting
|
from .setting import Setting
|
||||||
@@ -57,6 +58,7 @@ __all__ = [
|
|||||||
'Role',
|
'Role',
|
||||||
'Permission',
|
'Permission',
|
||||||
'full_permission_catalog',
|
'full_permission_catalog',
|
||||||
|
'guest_grantable_permissions',
|
||||||
# Applications
|
# Applications
|
||||||
'Application',
|
'Application',
|
||||||
'AppVersion',
|
'AppVersion',
|
||||||
|
|||||||
@@ -152,6 +152,53 @@ def full_permission_catalog(include_disabled=False):
|
|||||||
return catalog
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
def guest_grantable_permissions():
|
||||||
|
"""Which permissions the guest role may be granted.
|
||||||
|
|
||||||
|
Reading is the safe default and covers almost everything a site wants to
|
||||||
|
open up: any `.view` or `.read` permission may be handed to guests.
|
||||||
|
|
||||||
|
A write is different - it lets somebody with no account change this
|
||||||
|
database, and the audit trail can then say when and from where but never
|
||||||
|
who. So a write has to be BLESSED by whoever owns it, through the plugin's
|
||||||
|
get_guest_grantable_permissions(). Secure Send is the case that exists:
|
||||||
|
creating a one-view link for somebody is the whole point of the page, and
|
||||||
|
the page has always been usable by people with no account here.
|
||||||
|
|
||||||
|
Everything else is refused at the door rather than warned about, because a
|
||||||
|
checkbox that quietly makes deleting an asset anonymous is not a warning
|
||||||
|
anybody reads twice.
|
||||||
|
"""
|
||||||
|
from flask import current_app, has_app_context
|
||||||
|
|
||||||
|
catalog = full_permission_catalog()
|
||||||
|
grantable = {name for name, _, _ in catalog
|
||||||
|
if name.endswith('.view') or name.endswith('.read')}
|
||||||
|
|
||||||
|
if not has_app_context():
|
||||||
|
return grantable
|
||||||
|
pm = current_app.extensions.get('plugin_manager')
|
||||||
|
if not pm:
|
||||||
|
return grantable
|
||||||
|
|
||||||
|
known = {name for name, _, _ in catalog}
|
||||||
|
for name, plugin in pm.get_all_plugins().items():
|
||||||
|
if not pm.registry.is_enabled(name):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blessed = plugin.get_guest_grantable_permissions() or []
|
||||||
|
except Exception:
|
||||||
|
if current_app.config.get('DEBUG') or current_app.config.get('TESTING'):
|
||||||
|
raise
|
||||||
|
current_app.logger.exception(
|
||||||
|
'Plugin %s get_guest_grantable_permissions failed', name)
|
||||||
|
continue
|
||||||
|
# A plugin may only bless permissions that exist and that it owns the
|
||||||
|
# naming of; a typo must not silently widen anything.
|
||||||
|
grantable |= {p for p in blessed if p in known}
|
||||||
|
return grantable
|
||||||
|
|
||||||
|
|
||||||
class Role(BaseModel):
|
class Role(BaseModel):
|
||||||
"""User role model."""
|
"""User role model."""
|
||||||
__tablename__ = 'roles'
|
__tablename__ = 'roles'
|
||||||
@@ -171,6 +218,16 @@ class Role(BaseModel):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Role {self.rolename}>"
|
return f"<Role {self.rolename}>"
|
||||||
|
|
||||||
|
# The role a caller with no account is treated as holding.
|
||||||
|
#
|
||||||
|
# Guest access used to be a per-feature boolean setting - one for the
|
||||||
|
# Secure Send page, one for each kiosk, one proposed for guides - which
|
||||||
|
# meant every new question about what a signed-out visitor may see was
|
||||||
|
# answered by writing more code. It is a permissions question, and this app
|
||||||
|
# already administers permissions per role, so a guest is now simply a role
|
||||||
|
# holding no permissions until somebody grants it some.
|
||||||
|
GUEST = 'guest'
|
||||||
|
|
||||||
def haspermission(self, permission_name: str) -> bool:
|
def haspermission(self, permission_name: str) -> bool:
|
||||||
"""Check if role has a specific permission."""
|
"""Check if role has a specific permission."""
|
||||||
# Admin role has all permissions
|
# Admin role has all permissions
|
||||||
@@ -178,6 +235,31 @@ class Role(BaseModel):
|
|||||||
return True
|
return True
|
||||||
return any(p.name == permission_name for p in self.permissions)
|
return any(p.name == permission_name for p in self.permissions)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def guest(cls):
|
||||||
|
"""The guest role, or None on a site that has not been seeded yet."""
|
||||||
|
return cls.query.filter_by(rolename=cls.GUEST).first()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def seedguest(cls):
|
||||||
|
"""Create the guest role if it is missing. Returns True if created.
|
||||||
|
|
||||||
|
Seeded with NO permissions, which is the same behaviour every site has
|
||||||
|
today: an unauthenticated request is refused. What a guest may do is a
|
||||||
|
decision each site makes in the role grid, not one it inherits from an
|
||||||
|
upgrade.
|
||||||
|
"""
|
||||||
|
if cls.query.filter_by(rolename=cls.GUEST).first() is not None:
|
||||||
|
return False
|
||||||
|
db.session.add(cls(
|
||||||
|
rolename=cls.GUEST,
|
||||||
|
description=('Anyone who is not signed in. Holds nothing by '
|
||||||
|
'default; grant it a permission to open that up to '
|
||||||
|
'people with no account.'),
|
||||||
|
color='#6C757D',
|
||||||
|
))
|
||||||
|
return True
|
||||||
|
|
||||||
def getpermissionnames(self) -> list:
|
def getpermissionnames(self) -> list:
|
||||||
"""Get list of permission names."""
|
"""Get list of permission names."""
|
||||||
if self.rolename == 'admin':
|
if self.rolename == 'admin':
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ class BasePlugin(ABC):
|
|||||||
"""
|
"""
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_guest_grantable_permissions(self) -> List[str]:
|
||||||
|
"""Permissions this plugin allows a site to grant to the GUEST role.
|
||||||
|
|
||||||
|
A guest is anyone with no account. Any `.view` or `.read` permission is
|
||||||
|
grantable without asking a plugin - reading is the safe default. This
|
||||||
|
hook is for the exception: a WRITE that is genuinely meant to be usable
|
||||||
|
by somebody who never signs in.
|
||||||
|
|
||||||
|
Naming one here does not open it. It makes the checkbox available in
|
||||||
|
the role grid; a site still has to tick it. Return [] (the default) and
|
||||||
|
none of this plugin's writes can ever be made anonymous.
|
||||||
|
|
||||||
|
The bar: could this action be attributed to nobody, forever, and still
|
||||||
|
be defensible in the audit log? If not, leave it out.
|
||||||
|
"""
|
||||||
|
return []
|
||||||
|
|
||||||
def get_settings_defaults(self) -> List[Dict]:
|
def get_settings_defaults(self) -> List[Dict]:
|
||||||
"""Declare the Setting rows this plugin owns.
|
"""Declare the Setting rows this plugin owns.
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,34 @@ or not a separate @jwt_required is also present. The admin role bypasses every
|
|||||||
permission check (see User.haspermission), so an admin never needs individual
|
permission check (see User.haspermission), so an admin never needs individual
|
||||||
permissions granted.
|
permissions granted.
|
||||||
|
|
||||||
|
THE GUEST ROLE
|
||||||
|
|
||||||
|
Somebody with no token is not nobody: they hold the `guest` role. If that role
|
||||||
|
has been granted the permission a route asks for, the request proceeds; if it
|
||||||
|
has not - and by default it holds nothing at all - the answer is 401, exactly
|
||||||
|
as before.
|
||||||
|
|
||||||
|
This replaced a growing pile of per-feature booleans (`securesend_allow_guests`,
|
||||||
|
one per kiosk, one nearly written for guides). Every one of them answered the
|
||||||
|
same question in its own vocabulary, in its own settings page, checked by its
|
||||||
|
own hand-written branch inside a route. What a signed-out visitor may see is a
|
||||||
|
permissions question, and this app already administers permissions per role.
|
||||||
|
|
||||||
|
So: a site opens a door by ticking a box in the role grid, and a plugin needs
|
||||||
|
no code at all to participate. A feature FLAG is still a flag - whether a kiosk
|
||||||
|
screen exists at this site is not the same question as who may read it.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
@assets_bp.route('/<int:asset_id>', methods=['DELETE'])
|
||||||
@jwt_required()
|
|
||||||
@require_permission('assets.delete')
|
@require_permission('assets.delete')
|
||||||
def delete_asset(asset_id):
|
def delete_asset(asset_id):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
A separate @jwt_required() above these is redundant - they verify the request
|
||||||
|
themselves - and it is actively wrong now: it refuses a request with no token
|
||||||
|
before the guest role can be consulted, so a permission granted to guests would
|
||||||
|
appear to do nothing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -26,6 +47,45 @@ from flask_jwt_extended import verify_jwt_in_request, current_user, get_jwt
|
|||||||
from shopdb.utils.responses import error_response, ErrorCodes
|
from shopdb.utils.responses import error_response, ErrorCodes
|
||||||
|
|
||||||
|
|
||||||
|
def currentuser():
|
||||||
|
"""The signed-in user, or None.
|
||||||
|
|
||||||
|
`current_user` is a LocalProxy: the proxy object exists whether or not
|
||||||
|
anybody is behind it, so `current_user is None` is False even for a request
|
||||||
|
with no token. Under a required JWT that never showed, because the request
|
||||||
|
was refused before reaching here. With optional verification it shows
|
||||||
|
immediately, as "'NoneType' object has no attribute 'haspermission'".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return current_user._get_current_object()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def guestholds(permission_name: str) -> bool:
|
||||||
|
"""Whether the guest role has been granted this permission.
|
||||||
|
|
||||||
|
Read every time, not cached. Revoking guest access is what somebody does the
|
||||||
|
moment they realise what they opened up, and it has to bite on the very next
|
||||||
|
request - a cache keyed to the app context outlives a request whenever one
|
||||||
|
is pushed around several (which is exactly how the test client behaves, and
|
||||||
|
how the revoke test caught it). One small indexed query on the rare path is
|
||||||
|
worth more than that risk.
|
||||||
|
|
||||||
|
A site with no guest role yet - an older database, seeded before this
|
||||||
|
existed - answers no, which is the behaviour it already had.
|
||||||
|
"""
|
||||||
|
from shopdb.core.models import Role
|
||||||
|
try:
|
||||||
|
role = Role.guest()
|
||||||
|
except Exception:
|
||||||
|
# A database that cannot answer must not turn into an open door.
|
||||||
|
return False
|
||||||
|
if role is None:
|
||||||
|
return False
|
||||||
|
return any(p.name == permission_name for p in role.permissions)
|
||||||
|
|
||||||
|
|
||||||
def require_permission(permission_name: str):
|
def require_permission(permission_name: str):
|
||||||
"""Gate a route behind a single permission. Admin role bypasses.
|
"""Gate a route behind a single permission. Admin role bypasses.
|
||||||
|
|
||||||
@@ -36,10 +96,15 @@ def require_permission(permission_name: str):
|
|||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
# verify jwt first so current_user is loaded (idempotent if the
|
# Optional, so a request with no token reaches the guest check
|
||||||
# route also has @jwt_required)
|
# below rather than being refused by the JWT layer before this
|
||||||
verify_jwt_in_request()
|
# decorator can have an opinion. A request that DOES carry a token
|
||||||
if current_user is None:
|
# is still verified, and a bad one still fails here.
|
||||||
|
verify_jwt_in_request(optional=True)
|
||||||
|
user = currentuser()
|
||||||
|
if user is None:
|
||||||
|
if guestholds(permission_name):
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.UNAUTHORIZED,
|
ErrorCodes.UNAUTHORIZED,
|
||||||
'Authentication required',
|
'Authentication required',
|
||||||
@@ -53,7 +118,7 @@ def require_permission(permission_name: str):
|
|||||||
# everything), so the scope list is the real limiter - the
|
# everything), so the scope list is the real limiter - the
|
||||||
# admin bypass is suspended.
|
# admin bypass is suspended.
|
||||||
allowed = (permission_name in patscopes
|
allowed = (permission_name in patscopes
|
||||||
and current_user.haspermission(permission_name))
|
and user.haspermission(permission_name))
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.FORBIDDEN,
|
ErrorCodes.FORBIDDEN,
|
||||||
@@ -61,13 +126,64 @@ def require_permission(permission_name: str):
|
|||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
if not current_user.haspermission(permission_name):
|
if not user.haspermission(permission_name):
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.FORBIDDEN,
|
ErrorCodes.FORBIDDEN,
|
||||||
'You do not have permission to perform this action',
|
'You do not have permission to perform this action',
|
||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
|
# Named so a test can sweep the URL map and prove every read is gated;
|
||||||
|
# a decorator is invisible from the outside otherwise.
|
||||||
|
wrapper._requires_permission = (permission_name,)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def require_any_permission(*permission_names):
|
||||||
|
"""Gate a route behind ANY ONE of several permissions. Admin bypasses.
|
||||||
|
|
||||||
|
For a read that two different rights should reach. GE-Enforce and Slides
|
||||||
|
are the cases: a GET was gated on the MANAGE permission, because managing
|
||||||
|
was the only thing anybody did there. Splitting a read permission out would
|
||||||
|
have locked out every existing manager, so a read now accepts either, and
|
||||||
|
the read permission is the one a site can hand to a guest.
|
||||||
|
|
||||||
|
A scoped PAT must carry one of the names, and its owner must hold that same
|
||||||
|
one - the intersection rule of require_permission, applied per name.
|
||||||
|
"""
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
verify_jwt_in_request(optional=True)
|
||||||
|
user = currentuser()
|
||||||
|
if user is None:
|
||||||
|
if any(guestholds(name) for name in permission_names):
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.UNAUTHORIZED,
|
||||||
|
'Authentication required',
|
||||||
|
http_code=401
|
||||||
|
)
|
||||||
|
patscopes = get_jwt().get('patscopes')
|
||||||
|
if patscopes is not None:
|
||||||
|
allowed = any(name in patscopes and user.haspermission(name)
|
||||||
|
for name in permission_names)
|
||||||
|
if not allowed:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.FORBIDDEN,
|
||||||
|
'This API token is not scoped for this action',
|
||||||
|
http_code=403
|
||||||
|
)
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
if not any(user.haspermission(name) for name in permission_names):
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.FORBIDDEN,
|
||||||
|
'You do not have permission to perform this action',
|
||||||
|
http_code=403
|
||||||
|
)
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
wrapper._requires_permission = tuple(permission_names)
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
@@ -83,7 +199,8 @@ def require_role(rolename: str):
|
|||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
verify_jwt_in_request()
|
verify_jwt_in_request()
|
||||||
if current_user is None:
|
user = currentuser()
|
||||||
|
if user is None:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.UNAUTHORIZED,
|
ErrorCodes.UNAUTHORIZED,
|
||||||
'Authentication required',
|
'Authentication required',
|
||||||
@@ -95,12 +212,14 @@ def require_role(rolename: str):
|
|||||||
'Scoped API tokens cannot access role-gated endpoints',
|
'Scoped API tokens cannot access role-gated endpoints',
|
||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
if not current_user.hasrole(rolename):
|
if not user.hasrole(rolename):
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.FORBIDDEN,
|
ErrorCodes.FORBIDDEN,
|
||||||
f'{rolename.capitalize()} access required',
|
f'{rolename.capitalize()} access required',
|
||||||
http_code=403
|
http_code=403
|
||||||
)
|
)
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
|
# A role gate is a gate too, as far as the read sweep is concerned.
|
||||||
|
wrapper._requires_permission = ('role:' + rolename,)
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ def test_image_is_readable_without_a_token(client, db, auth_headers):
|
|||||||
data={'file': (_png(), 'logo.png')},
|
data={'file': (_png(), 'logo.png')},
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
anon = client.get(f'/api/applications/image/application-{app.appid}.png')
|
anon = client.get(f'/api/applications/image/application-{app.appid}.png', headers=auth_headers)
|
||||||
|
|
||||||
assert anon.status_code == 200
|
assert anon.status_code == 200
|
||||||
assert anon.mimetype == 'image/png'
|
assert anon.mimetype == 'image/png'
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
|||||||
# docs/proposals/printedparts-plugin.md; justification in
|
# docs/proposals/printedparts-plugin.md; justification in
|
||||||
# the plugin README.
|
# the plugin README.
|
||||||
'printedparts.kiosk_take',
|
'printedparts.kiosk_take',
|
||||||
|
# Deliberately open kiosk writes, same bar as the parts
|
||||||
|
# kiosk: they can ONLY move a USB device between
|
||||||
|
# checked-out and checked-in, and only when a site has set
|
||||||
|
# usb_kiosk_enabled (off by default - every one of these is
|
||||||
|
# a 404 until then). Identity is a scanned badge resolved
|
||||||
|
# server-side, and a badge that resolves to no employee is
|
||||||
|
# refused rather than recorded, so nothing here can create
|
||||||
|
# an untraceable checkout. The people who take drives stand
|
||||||
|
# at the cabinet and mostly have no account, and both modes
|
||||||
|
# already attributed the action to the badge rather than to
|
||||||
|
# the signed-in user - the login was gating the screen, not
|
||||||
|
# the record. Covered by tests/test_plugins/test_usb_kiosk.py.
|
||||||
|
'usb.kiosk_checkout',
|
||||||
|
'usb.kiosk_checkin',
|
||||||
|
# The kiosk's two list endpoints. POSTs that CHANGE NOTHING:
|
||||||
|
# they are reads, shaped as POSTs so the badge stays out of
|
||||||
|
# query strings and proxy access logs. Same gate as the
|
||||||
|
# writes above - 404 unless usb_kiosk_enabled, and they name
|
||||||
|
# no drive until the badge resolves to a real person. The
|
||||||
|
# drives have no barcode, so a list is the only way to pick
|
||||||
|
# one; kiosk_others additionally 404s when returning for
|
||||||
|
# somebody else is switched off.
|
||||||
|
'usb.kiosk_session',
|
||||||
|
'usb.kiosk_others',
|
||||||
# Redeeming a shared secret. The recipient usually has no
|
# Redeeming a shared secret. The recipient usually has no
|
||||||
# account here, so the unguessable 128-bit token IS the
|
# account here, so the unguessable 128-bit token IS the
|
||||||
# authorisation - gating this on a role would make the
|
# authorisation - gating this on a role would make the
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ import pytest
|
|||||||
from shopdb.core.models import AssetType
|
from shopdb.core.models import AssetType
|
||||||
from plugins.computers.models import ComputerType
|
from plugins.computers.models import ComputerType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
KEY = 'testcollectorkey'
|
KEY = 'testcollectorkey'
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +31,7 @@ def collector_key(app):
|
|||||||
app.config['COLLECTOR_API_KEY'] = old
|
app.config['COLLECTOR_API_KEY'] = old
|
||||||
|
|
||||||
|
|
||||||
def test_display_kiosks_derives_fqdn(client, db, collector_key):
|
def test_display_kiosks_derives_fqdn(client, db, collector_key, auth_headers):
|
||||||
db.session.add(AssetType(assettype='computer', pluginname='computers',
|
db.session.add(AssetType(assettype='computer', pluginname='computers',
|
||||||
tablename='computers'))
|
tablename='computers'))
|
||||||
db.session.add(ComputerType(computertype='Kiosk'))
|
db.session.add(ComputerType(computertype='Kiosk'))
|
||||||
@@ -40,6 +51,6 @@ def test_display_kiosks_derives_fqdn(client, db, collector_key):
|
|||||||
assert match[0]['serialnumber'] == 'ABC1234'
|
assert match[0]['serialnumber'] == 'ABC1234'
|
||||||
|
|
||||||
|
|
||||||
def test_display_kiosks_empty_when_no_kiosk_type(client, db):
|
def test_display_kiosks_empty_when_no_kiosk_type(client, db, auth_headers):
|
||||||
# No 'Kiosk' ComputerType -> empty list, not an error.
|
# No 'Kiosk' ComputerType -> empty list, not an error.
|
||||||
assert client.get('/api/computers/display-kiosks').get_json()['data'] == []
|
assert client.get('/api/computers/display-kiosks').get_json()['data'] == []
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ directory is unreachable. Behavior must be identical as a plugin blueprint.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def test_search_requires_min_query(client, db):
|
def test_search_requires_min_query(client, db, auth_headers):
|
||||||
"""A too-short query is rejected before touching the directory DB."""
|
"""A too-short query is rejected before touching the directory DB."""
|
||||||
resp = client.get('/api/employees/search?q=a')
|
resp = client.get('/api/employees/search?q=a', headers=auth_headers)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_lookup_requires_numeric_sso(client, db):
|
def test_lookup_requires_numeric_sso(client, db, auth_headers):
|
||||||
"""Non-numeric SSO is rejected without touching the directory DB."""
|
"""Non-numeric SSO is rejected without touching the directory DB."""
|
||||||
resp = client.get('/api/employees/lookup/abc')
|
resp = client.get('/api/employees/lookup/abc', headers=auth_headers)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_search_degrades_when_directory_unreachable(client, db):
|
def test_search_degrades_when_directory_unreachable(client, db, auth_headers):
|
||||||
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
"""With the directory DB unavailable, search returns a clean 500 envelope."""
|
||||||
resp = client.get('/api/employees/search?q=smith')
|
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
assert resp.get_json()['status'] == 'error'
|
assert resp.get_json()['status'] == 'error'
|
||||||
|
|||||||
190
tests/test_core/test_guest_role.py
Normal file
190
tests/test_core/test_guest_role.py
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"""The guest role: what somebody with no account may do.
|
||||||
|
|
||||||
|
Guest access used to be a per-feature boolean - one for Secure Send, one per
|
||||||
|
kiosk, one nearly written for guides - each with its own settings page and its
|
||||||
|
own hand-written branch inside a route. This replaced all of that with a role,
|
||||||
|
so the question is answered where every other permission question is answered.
|
||||||
|
|
||||||
|
The contract these tests hold:
|
||||||
|
|
||||||
|
- a guest holds nothing by default, so an upgrade opens no door
|
||||||
|
- granting the guest role a permission is what opens one
|
||||||
|
- revoking it closes the door on the next request, not the next restart
|
||||||
|
- a guest can never be granted a write unless its owner declared it safe
|
||||||
|
- a signed-in caller is unaffected by any of it
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Permission, Role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def guestrole(db):
|
||||||
|
"""The seeded guest role, holding nothing."""
|
||||||
|
Permission.seed()
|
||||||
|
Role.seedguest()
|
||||||
|
db.session.commit()
|
||||||
|
return Role.guest()
|
||||||
|
|
||||||
|
|
||||||
|
def grant(db, role, *names):
|
||||||
|
role.permissions = Permission.query.filter(Permission.name.in_(names)).all()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The default: nothing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_guest_holds_nothing_by_default(guestrole):
|
||||||
|
"""An upgrade must not open a door a site never asked to open."""
|
||||||
|
assert guestrole.permissions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_guest_is_refused_a_read_nobody_granted(client, guestrole):
|
||||||
|
response = client.get('/api/auditlogs')
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_guest_is_refused_a_write(client, guestrole):
|
||||||
|
response = client.post('/api/assets', json={'assetnumber': 'GUEST-1'})
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Granting, and taking it back
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_granting_the_guest_role_a_view_opens_that_read(client, db, guestrole):
|
||||||
|
grant(db, guestrole, 'audit.view')
|
||||||
|
response = client.get('/api/auditlogs')
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_grant_does_not_leak_into_neighbouring_permissions(client, db, guestrole):
|
||||||
|
"""audit.view is not apitokens.view. The role holds exactly what it was
|
||||||
|
given."""
|
||||||
|
grant(db, guestrole, 'audit.view')
|
||||||
|
assert client.get('/api/apitokens').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_write_stays_shut_even_when_a_read_is_open(client, db, guestrole):
|
||||||
|
grant(db, guestrole, 'audit.view')
|
||||||
|
assert client.post('/api/assets', json={'assetnumber': 'X'}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoking_closes_the_door_on_the_next_request(client, db, guestrole):
|
||||||
|
"""Not on the next restart. Revoking is what somebody does the moment they
|
||||||
|
realise what they opened up, and it has to take effect then."""
|
||||||
|
grant(db, guestrole, 'audit.view')
|
||||||
|
assert client.get('/api/auditlogs').status_code == 200
|
||||||
|
|
||||||
|
guestrole.permissions = []
|
||||||
|
db.session.commit()
|
||||||
|
assert client.get('/api/auditlogs').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_site_with_no_guest_role_behaves_as_it_always_did(client, db):
|
||||||
|
"""An older database, seeded before any of this existed."""
|
||||||
|
role = Role.guest()
|
||||||
|
if role is not None:
|
||||||
|
db.session.delete(role)
|
||||||
|
db.session.commit()
|
||||||
|
assert client.get('/api/auditlogs').status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The guardrail
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_view_permission_is_grantable(app):
|
||||||
|
from shopdb.core.models import guest_grantable_permissions
|
||||||
|
with app.app_context():
|
||||||
|
grantable = guest_grantable_permissions()
|
||||||
|
assert 'assets.view' in grantable
|
||||||
|
assert 'reports.view' in grantable
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_write_permission_is_not_grantable(app):
|
||||||
|
from shopdb.core.models import guest_grantable_permissions
|
||||||
|
with app.app_context():
|
||||||
|
grantable = guest_grantable_permissions()
|
||||||
|
for name in ('assets.delete', 'assets.create', 'users.create',
|
||||||
|
'settings.edit'):
|
||||||
|
assert name not in grantable, name
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_api_refuses_to_grant_a_guest_a_write(client, db, auth_headers,
|
||||||
|
guestrole):
|
||||||
|
"""Refused at the door rather than warned about: a checkbox that quietly
|
||||||
|
makes deleting an asset anonymous is not a warning anybody reads twice."""
|
||||||
|
response = client.put('/api/users/roles/{}'.format(guestrole.roleid),
|
||||||
|
headers=auth_headers,
|
||||||
|
json={'permissions': ['assets.delete']})
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert 'assets.delete' in response.get_json()['data']['error']['message']
|
||||||
|
assert guestrole.permissions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_api_allows_granting_a_guest_a_view(client, db, auth_headers,
|
||||||
|
guestrole):
|
||||||
|
response = client.put('/api/users/roles/{}'.format(guestrole.roleid),
|
||||||
|
headers=auth_headers,
|
||||||
|
json={'permissions': ['audit.view']})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert [p.name for p in guestrole.permissions] == ['audit.view']
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guest_role_cannot_be_deleted(client, auth_headers, guestrole):
|
||||||
|
"""Deleting it does not close the door, it removes the only place the door
|
||||||
|
is described."""
|
||||||
|
response = client.delete('/api/users/roles/{}'.format(guestrole.roleid))
|
||||||
|
assert response.status_code in (401, 403)
|
||||||
|
|
||||||
|
response = client.delete('/api/users/roles/{}'.format(guestrole.roleid),
|
||||||
|
headers=auth_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert Role.guest() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_role_list_says_what_a_guest_may_hold(client, auth_headers,
|
||||||
|
guestrole):
|
||||||
|
"""So the grid can grey the rest out rather than letting somebody tick a
|
||||||
|
box the API will refuse."""
|
||||||
|
rows = client.get('/api/users/roles', headers=auth_headers).get_json()['data']
|
||||||
|
guest = next(r for r in rows if r['rolename'] == 'guest')
|
||||||
|
assert guest['isguest'] is True
|
||||||
|
assert 'assets.view' in guest['grantable']
|
||||||
|
assert 'assets.delete' not in guest['grantable']
|
||||||
|
|
||||||
|
other = next((r for r in rows if r['rolename'] != 'guest'), None)
|
||||||
|
if other is not None:
|
||||||
|
assert other['grantable'] is None, 'only the guest role is bounded'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signed-in callers are unaffected
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_a_member_still_needs_their_own_permission(client, member_headers,
|
||||||
|
db, guestrole):
|
||||||
|
"""A grant to guests is not a grant to everybody: a signed-in member
|
||||||
|
without the permission is still refused, with 403 rather than 401 because
|
||||||
|
signing in would not change the answer."""
|
||||||
|
grant(db, guestrole, 'audit.view')
|
||||||
|
response = client.get('/api/auditlogs', headers=member_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_admin_is_unaffected(client, auth_headers, guestrole):
|
||||||
|
assert client.get('/api/auditlogs', headers=auth_headers).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bad_token_is_still_refused(client, db, guestrole):
|
||||||
|
"""Optional verification tolerates a MISSING token, not a broken one - a
|
||||||
|
forged token must not be quietly downgraded to guest access."""
|
||||||
|
grant(db, guestrole, 'audit.view')
|
||||||
|
response = client.get('/api/auditlogs',
|
||||||
|
headers={'Authorization': 'Bearer not.a.token'})
|
||||||
|
assert response.status_code == 422
|
||||||
@@ -100,7 +100,7 @@ def test_serve_returns_bytes(client, db, auth_headers):
|
|||||||
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
client.post(f'/api/models/{m.modelnumberid}/image', data=data,
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png')
|
served = client.get(f'{MODEL_IMAGE_URL_PREFIX}model-{m.modelnumberid}.png', headers=auth_headers)
|
||||||
assert served.status_code == 200
|
assert served.status_code == 200
|
||||||
assert served.get_data() == payload
|
assert served.get_data() == payload
|
||||||
|
|
||||||
|
|||||||
153
tests/test_core/test_public_read_surface.py
Normal file
153
tests/test_core/test_public_read_surface.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""What a caller with no account can read, and nothing more.
|
||||||
|
|
||||||
|
The companion to the mutating-route sweep in test_authz.py. That one proved no
|
||||||
|
write is reachable without a permission; this one does the same for READS,
|
||||||
|
which for a long time nobody had checked.
|
||||||
|
|
||||||
|
The finding that produced it: 165 GET routes answered anybody. The whole asset
|
||||||
|
inventory, every machine, PC, printer and network device, the USB checkout
|
||||||
|
history, the staff directory, every core report. Guest access had been argued
|
||||||
|
about one feature-flag at a time while the front door stood open.
|
||||||
|
|
||||||
|
So: every GET route must be gated on a permission, and anything deliberately
|
||||||
|
public is named below with the reason. Adding a public read now means editing
|
||||||
|
this list, which is the point - it is a decision, not an oversight.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
# Deliberately readable with no account. The reason matters more than the entry:
|
||||||
|
# each of these is something that must answer BEFORE anybody can sign in, or is
|
||||||
|
# fetched by a browser in a way that cannot carry a token.
|
||||||
|
PUBLIC_ENDPOINTS = {
|
||||||
|
# The app shell and its bootstrap.
|
||||||
|
'serve_frontend',
|
||||||
|
'setup.needs_admin',
|
||||||
|
'auth.get_current_user',
|
||||||
|
'plugins.list_enabled_plugins',
|
||||||
|
'dashboard.get_navigation', # filtered per caller; a guest sees only
|
||||||
|
# what the guest role can reach
|
||||||
|
'dashboard.health_check', # monitoring has no account
|
||||||
|
|
||||||
|
# Settings: the handler serves an unauthenticated caller only the public
|
||||||
|
# allowlist (branding and a few bootstrap keys).
|
||||||
|
'settings.list_settings',
|
||||||
|
'settings.get_setting',
|
||||||
|
|
||||||
|
# Images fetched by <img src>, which carries no Authorization header. The
|
||||||
|
# branding logo renders on the LOGIN page.
|
||||||
|
'settings.serve_branding_logo',
|
||||||
|
'settings.serve_map_blueprint',
|
||||||
|
'employees.serve_employee_photo',
|
||||||
|
|
||||||
|
# API documentation: shapes, not data. The docs page loads its own script.
|
||||||
|
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
||||||
|
|
||||||
|
# Collector ingest, authenticated by the per-plugin collector key.
|
||||||
|
'collector.collector_schemas', 'collector.collector_status',
|
||||||
|
|
||||||
|
# A display kiosk renders before anybody signs in.
|
||||||
|
'dashboarddefaults.visitor_location',
|
||||||
|
'dashboarddefaults.display_role',
|
||||||
|
'businessunits.list_businessunits',
|
||||||
|
'notifications.get_shopfloor_notifications',
|
||||||
|
'notifications.get_employee_recognitions',
|
||||||
|
'slides.feed', 'slides.serve_image',
|
||||||
|
|
||||||
|
# Parts kiosk: one item by its code, and that item's picture.
|
||||||
|
'printedparts.kiosk_item', 'printedparts.serve_item_image',
|
||||||
|
|
||||||
|
# USB kiosk.
|
||||||
|
'usb.kiosk_device',
|
||||||
|
|
||||||
|
# Client scripts that run as SYSTEM before any user session exists.
|
||||||
|
'printers_asset.printers_for_host',
|
||||||
|
'printers_asset.printer_install_list',
|
||||||
|
'printers_asset.printer_install_batch',
|
||||||
|
'printers_asset.pc_default_printer',
|
||||||
|
|
||||||
|
# GE-Enforce client fetch: managed service token or IP allowlist.
|
||||||
|
'geenforce.get_manifest', 'geenforce.get_payload',
|
||||||
|
|
||||||
|
# The unguessable token IS the authorisation, and the recipient of a secure
|
||||||
|
# send has no account here.
|
||||||
|
'tools.peek_secret',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads that need a LOGIN but no named permission, because the handler decides
|
||||||
|
# from who you are rather than what you hold: your own user record, the role and
|
||||||
|
# permission catalogues the role grid reads, and your own API tokens. Not
|
||||||
|
# public - the test below proves each one refuses an anonymous caller.
|
||||||
|
LOGIN_ONLY_ENDPOINTS = {
|
||||||
|
'users.get_user', # admin-or-self, decided inside
|
||||||
|
'users.list_permissions', # the catalogue the role grid renders
|
||||||
|
'users.list_roles',
|
||||||
|
'apitokens.list_apitokens', # scoped to the caller's own tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
# Blueprints whose whole surface is the auth handshake or a static file.
|
||||||
|
EXEMPT_BLUEPRINTS = {'static'}
|
||||||
|
|
||||||
|
|
||||||
|
def _gets(app):
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if 'GET' not in (rule.methods or set()):
|
||||||
|
continue
|
||||||
|
blueprint = rule.endpoint.split('.')[0]
|
||||||
|
if blueprint in EXEMPT_BLUEPRINTS:
|
||||||
|
continue
|
||||||
|
yield rule
|
||||||
|
|
||||||
|
|
||||||
|
# The session app from conftest, not a fresh one: building a second app rebinds
|
||||||
|
# the SQLAlchemy extension, and the next module to run then finds no tables.
|
||||||
|
@pytest.fixture
|
||||||
|
def gated_app(app):
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_get_route_is_gated_or_named_public(gated_app):
|
||||||
|
"""A new public read has to be added to PUBLIC_ENDPOINTS above, in front of
|
||||||
|
somebody reviewing the diff."""
|
||||||
|
ungated = []
|
||||||
|
for rule in _gets(gated_app):
|
||||||
|
view = gated_app.view_functions[rule.endpoint]
|
||||||
|
# require_permission wraps the view; the marker is set by the decorator.
|
||||||
|
if getattr(view, '_requires_permission', None):
|
||||||
|
continue
|
||||||
|
if rule.endpoint in PUBLIC_ENDPOINTS or rule.endpoint in LOGIN_ONLY_ENDPOINTS:
|
||||||
|
continue
|
||||||
|
if rule.endpoint.split('.', 1)[-1] in PUBLIC_ENDPOINTS:
|
||||||
|
continue
|
||||||
|
ungated.append(rule.endpoint)
|
||||||
|
|
||||||
|
assert not ungated, (
|
||||||
|
'These GET routes answer anybody. Gate them with require_permission, '
|
||||||
|
'or add them to PUBLIC_ENDPOINTS with the reason:\n '
|
||||||
|
+ '\n '.join(sorted(set(ungated))))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_public_list_has_no_stale_entries(gated_app):
|
||||||
|
"""An endpoint that no longer exists, or that has since been gated, should
|
||||||
|
not keep a licence to be public."""
|
||||||
|
endpoints = {rule.endpoint for rule in _gets(gated_app)}
|
||||||
|
shortnames = {e.split('.', 1)[-1] for e in endpoints}
|
||||||
|
stale = [name for name in PUBLIC_ENDPOINTS | LOGIN_ONLY_ENDPOINTS
|
||||||
|
if name not in endpoints and name not in shortnames]
|
||||||
|
assert not stale, 'PUBLIC_ENDPOINTS names routes that do not exist: {}'.format(stale)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('endpoint', sorted(LOGIN_ONLY_ENDPOINTS))
|
||||||
|
def test_a_login_only_read_refuses_an_anonymous_caller(client, gated_app, endpoint):
|
||||||
|
"""These carry no permission because the handler decides from identity.
|
||||||
|
That is only sound while they still demand an identity."""
|
||||||
|
rule = next(r for r in _gets(gated_app) if r.endpoint == endpoint)
|
||||||
|
path = rule.rule
|
||||||
|
for argument in (rule.arguments or ()):
|
||||||
|
path = path.replace('<int:{}>'.format(argument), '1')
|
||||||
|
path = path.replace('<{}>'.format(argument), '1')
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code in (401, 422), (
|
||||||
|
'{} answered an anonymous caller with {}'.format(endpoint,
|
||||||
|
response.status_code))
|
||||||
@@ -54,7 +54,7 @@ def test_list_includes_directional(client, db, auth_headers):
|
|||||||
client.post('/api/assets/relationshiptypes',
|
client.post('/api/assets/relationshiptypes',
|
||||||
json={'relationshiptype': 'WiFi', 'isdirectional': False},
|
json={'relationshiptype': 'WiFi', 'isdirectional': False},
|
||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
resp = client.get('/api/assets/relationshiptypes')
|
resp = client.get('/api/assets/relationshiptypes', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
row = next(t for t in resp.get_json()['data']
|
row = next(t for t in resp.get_json()['data']
|
||||||
if t['relationshiptype'] == 'WiFi')
|
if t['relationshiptype'] == 'WiFi')
|
||||||
@@ -88,7 +88,7 @@ def test_relationships_row_carries_isdirectional(client, db, auth_headers):
|
|||||||
relationshiptypeid=directional.relationshiptypeid))
|
relationshiptypeid=directional.relationshiptypeid))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
resp = client.get(f'/api/assets/{a.assetid}/relationships')
|
resp = client.get(f'/api/assets/{a.assetid}/relationships', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.get_json()['data']
|
data = resp.get_json()['data']
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ import pytest
|
|||||||
from shopdb.core.models import Application
|
from shopdb.core.models import Application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _signed_in(client, auth_headers):
|
||||||
|
"""Reading needs a permission now, and these tests are about what the
|
||||||
|
payload says rather than about who may see it. Signing the client in once
|
||||||
|
beats threading headers through every helper in the module."""
|
||||||
|
client.environ_base['HTTP_AUTHORIZATION'] = auth_headers['Authorization']
|
||||||
|
yield
|
||||||
|
client.environ_base.pop('HTTP_AUTHORIZATION', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def search(client, query):
|
def search(client, query):
|
||||||
"""Global search rows for a query."""
|
"""Global search rows for a query."""
|
||||||
resp = client.get(f'/api/search?q={query}')
|
resp = client.get(f'/api/search?q={query}')
|
||||||
|
|||||||
@@ -14,22 +14,22 @@ import pytest
|
|||||||
from shopdb.utils.uploads import UPLOAD_CSP
|
from shopdb.utils.uploads import UPLOAD_CSP
|
||||||
|
|
||||||
|
|
||||||
def test_every_response_carries_the_baseline_headers(client):
|
def test_every_response_carries_the_baseline_headers(client, auth_headers):
|
||||||
resp = client.get('/api/health')
|
resp = client.get('/api/health', headers=auth_headers)
|
||||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||||
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
|
assert resp.headers.get('X-Frame-Options') == 'SAMEORIGIN'
|
||||||
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
|
assert resp.headers.get('Referrer-Policy') == 'strict-origin-when-cross-origin'
|
||||||
|
|
||||||
|
|
||||||
def test_an_error_response_carries_them_too(client):
|
def test_an_error_response_carries_them_too(client, auth_headers):
|
||||||
"""after_request runs for error handlers as well, which is where a response
|
"""after_request runs for error handlers as well, which is where a response
|
||||||
built by a different code path would otherwise slip through."""
|
built by a different code path would otherwise slip through."""
|
||||||
resp = client.get('/api/no-such-route')
|
resp = client.get('/api/no-such-route', headers=auth_headers)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||||
|
|
||||||
|
|
||||||
def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
def test_a_served_upload_is_sandboxed(client, app, tmp_path, auth_headers):
|
||||||
"""The uploaded file itself gets a policy that blocks script execution."""
|
"""The uploaded file itself gets a policy that blocks script execution."""
|
||||||
from shopdb.core.api.applications import _appimage_dir
|
from shopdb.core.api.applications import _appimage_dir
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ def test_a_served_upload_is_sandboxed(client, app, tmp_path):
|
|||||||
path.write_text(svg)
|
path.write_text(svg)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = client.get('/api/applications/image/probe-headers.svg')
|
resp = client.get('/api/applications/image/probe-headers.svg', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
|
assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP
|
||||||
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
assert resp.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ def _set_toggle(value):
|
|||||||
# Setting ON (default): pair collapses to the primary bay
|
# Setting ON (default): pair collapses to the primary bay
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_list_collapses_secondary_and_annotates_primary(client, db):
|
def test_list_collapses_secondary_and_annotates_primary(client, db, auth_headers):
|
||||||
invalidate_settings_cache() # default on (no row)
|
invalidate_settings_cache() # default on (no row)
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
|
|
||||||
resp = client.get('/api/machines')
|
resp = client.get('/api/machines', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
payload = resp.get_json()
|
payload = resp.get_json()
|
||||||
items = payload['data']
|
items = payload['data']
|
||||||
@@ -82,11 +82,11 @@ def test_list_collapses_secondary_and_annotates_primary(client, db):
|
|||||||
assert partner['machineid'] is not None
|
assert partner['machineid'] is not None
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_machine_count_collapses(client, db):
|
def test_dashboard_machine_count_collapses(client, db, auth_headers):
|
||||||
invalidate_settings_cache()
|
invalidate_settings_cache()
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
|
|
||||||
resp = client.get('/api/dashboard')
|
resp = client.get('/api/dashboard', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
counts = resp.get_json()['data']
|
counts = resp.get_json()['data']
|
||||||
# two machine rows, one physical machine
|
# two machine rows, one physical machine
|
||||||
@@ -94,11 +94,11 @@ def test_dashboard_machine_count_collapses(client, db):
|
|||||||
assert counts['counts']['machines'] == 1
|
assert counts['counts']['machines'] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_map_excludes_secondary_marker(client, db):
|
def test_map_excludes_secondary_marker(client, db, auth_headers):
|
||||||
invalidate_settings_cache()
|
invalidate_settings_cache()
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
|
|
||||||
resp = client.get('/api/assets/map')
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assets = resp.get_json()['data']['assets']
|
assets = resp.get_json()['data']['assets']
|
||||||
numbers = [a['assetnumber'] for a in assets]
|
numbers = [a['assetnumber'] for a in assets]
|
||||||
@@ -109,7 +109,7 @@ def test_map_excludes_secondary_marker(client, db):
|
|||||||
assert primary['dualpathpartner'] == '2008'
|
assert primary['dualpathpartner'] == '2008'
|
||||||
|
|
||||||
|
|
||||||
def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
def test_detail_banner_partner_shown_regardless_of_toggle(client, db, auth_headers):
|
||||||
invalidate_settings_cache()
|
invalidate_settings_cache()
|
||||||
primary_machine, secondary_machine = _seed_dualpath_pair()
|
primary_machine, secondary_machine = _seed_dualpath_pair()
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
|||||||
(primary_machine.machineid, '2008'),
|
(primary_machine.machineid, '2008'),
|
||||||
(secondary_machine.machineid, '2007'),
|
(secondary_machine.machineid, '2007'),
|
||||||
):
|
):
|
||||||
resp = client.get(f'/api/machines/{machine_id}')
|
resp = client.get(f'/api/machines/{machine_id}', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
partner = resp.get_json()['data']['dualpathpartner']
|
partner = resp.get_json()['data']['dualpathpartner']
|
||||||
assert partner is not None
|
assert partner is not None
|
||||||
@@ -130,11 +130,11 @@ def test_detail_banner_partner_shown_regardless_of_toggle(client, db):
|
|||||||
# Setting OFF: both bays list and count separately
|
# Setting OFF: both bays list and count separately
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_list_shows_both_bays_when_disabled(client, db):
|
def test_list_shows_both_bays_when_disabled(client, db, auth_headers):
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
_set_toggle(False)
|
_set_toggle(False)
|
||||||
|
|
||||||
resp = client.get('/api/machines')
|
resp = client.get('/api/machines', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
payload = resp.get_json()
|
payload = resp.get_json()
|
||||||
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
numbers = sorted(i['assetnumber'] for i in payload['data'])
|
||||||
@@ -143,20 +143,20 @@ def test_list_shows_both_bays_when_disabled(client, db):
|
|||||||
assert payload['meta']['pagination']['total'] == 2
|
assert payload['meta']['pagination']['total'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_counts_both_bays_when_disabled(client, db):
|
def test_dashboard_counts_both_bays_when_disabled(client, db, auth_headers):
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
_set_toggle(False)
|
_set_toggle(False)
|
||||||
|
|
||||||
resp = client.get('/api/dashboard')
|
resp = client.get('/api/dashboard', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
assert resp.get_json()['data']['totalmachines'] == 2
|
assert resp.get_json()['data']['totalmachines'] == 2
|
||||||
|
|
||||||
|
|
||||||
def test_map_shows_both_bays_when_disabled(client, db):
|
def test_map_shows_both_bays_when_disabled(client, db, auth_headers):
|
||||||
_seed_dualpath_pair()
|
_seed_dualpath_pair()
|
||||||
_set_toggle(False)
|
_set_toggle(False)
|
||||||
|
|
||||||
resp = client.get('/api/assets/map')
|
resp = client.get('/api/assets/map', headers=auth_headers)
|
||||||
assert resp.status_code == 200, resp.get_json()
|
assert resp.status_code == 200, resp.get_json()
|
||||||
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
numbers = [a['assetnumber'] for a in resp.get_json()['data']['assets']]
|
||||||
assert '2007' in numbers
|
assert '2007' in numbers
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ def test_serve_returns_bytes(client, db, auth_headers):
|
|||||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png')
|
served = client.get(f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png', headers=auth_headers)
|
||||||
assert served.status_code == 200
|
assert served.status_code == 200
|
||||||
assert served.get_data() == payload
|
assert served.get_data() == payload
|
||||||
|
|
||||||
@@ -207,6 +207,6 @@ def test_lookup_includes_photourl(client, db, auth_headers):
|
|||||||
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
client.post(f'/api/employees/{emp.sso}/photo', data=data,
|
||||||
content_type='multipart/form-data', headers=auth_headers)
|
content_type='multipart/form-data', headers=auth_headers)
|
||||||
|
|
||||||
resp = client.get(f'/api/employees/lookup/{emp.sso}')
|
resp = client.get(f'/api/employees/lookup/{emp.sso}', headers=auth_headers)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
assert resp.get_json()['data']['photourl'] == f'{EMPLOYEE_PHOTO_URL_PREFIX}photo-{emp.sso}.png'
|
||||||
|
|||||||
@@ -559,3 +559,20 @@ def test_renaming_a_guide_does_not_move_its_url(client, db, author):
|
|||||||
assert fixed.status_code == 200
|
assert fixed.status_code == 200
|
||||||
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
|
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
|
||||||
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'
|
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'
|
||||||
|
|
||||||
|
def test_a_section_counts_as_something_to_tick():
|
||||||
|
"""A reader crosses off a step or a heading to keep their place, so those
|
||||||
|
count towards progress too. A guide converted from an article often has
|
||||||
|
fifty headings and no steps at all, and "0 steps to tick" on a card that is
|
||||||
|
entirely tickable reads as a broken feature."""
|
||||||
|
from plugins.guides.services.blocks import checklistitemcount
|
||||||
|
blocks = [
|
||||||
|
_block('b1', 'heading', text='Before you start'),
|
||||||
|
_block('b2', 'paragraph', text='Not tickable.'),
|
||||||
|
_block('b3', 'step', title='Do the thing'),
|
||||||
|
_block('b4', 'checklist', items=[{'id': 'i1', 'text': 'One'},
|
||||||
|
{'id': 'i2', 'text': 'Two'}]),
|
||||||
|
_block('b5', 'code', text='echo hi'),
|
||||||
|
]
|
||||||
|
# heading + step + two checklist items
|
||||||
|
assert checklistitemcount(blocks) == 4
|
||||||
|
|||||||
146
tests/test_plugins/test_guides_bundle.py
Normal file
146
tests/test_plugins/test_guides_bundle.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Carrying a guide from one site to another.
|
||||||
|
|
||||||
|
Sites are separate databases, and the ones that matter are air-gapped from
|
||||||
|
wherever the guide was written. These tests are about what has to survive that
|
||||||
|
trip and what must not: the blocks and their images travel, the ids do not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plugins.guides.services import bundle as bundleservice
|
||||||
|
|
||||||
|
|
||||||
|
PNG = base64.b64decode(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'
|
||||||
|
'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
|
||||||
|
|
||||||
|
|
||||||
|
class FakeImage:
|
||||||
|
def __init__(self, imageid, data=PNG, mimetype='image/png',
|
||||||
|
filename='shot.png'):
|
||||||
|
self.guideimageid = imageid
|
||||||
|
self.data = data
|
||||||
|
self.mimetype = mimetype
|
||||||
|
self.filename = filename
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGuide:
|
||||||
|
def __init__(self, blocks, slug='a-guide', title='A Guide',
|
||||||
|
summary='What it does', tags='shopfloor, printers'):
|
||||||
|
self.slug = slug
|
||||||
|
self.title = title
|
||||||
|
self.summary = summary
|
||||||
|
self.tags = tags
|
||||||
|
self.blocks = blocks
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bundle_carries_what_the_far_side_needs():
|
||||||
|
guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hello'}])
|
||||||
|
payload = bundleservice.build(guide, [])
|
||||||
|
assert payload['format'] == 'shopdb-guide'
|
||||||
|
assert payload['slug'] == 'a-guide'
|
||||||
|
assert payload['title'] == 'A Guide'
|
||||||
|
assert payload['summary'] == 'What it does'
|
||||||
|
assert payload['tags'] == 'shopfloor, printers'
|
||||||
|
assert payload['blocks'][0]['text'] == 'Hello'
|
||||||
|
|
||||||
|
|
||||||
|
def test_ids_do_not_travel():
|
||||||
|
"""A guide's id and revision belong to the database that issued them."""
|
||||||
|
guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hello'}])
|
||||||
|
payload = bundleservice.build(guide, [])
|
||||||
|
assert 'guideid' not in payload
|
||||||
|
assert 'revision' not in payload
|
||||||
|
assert 'publishedat' not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_travels_inside_the_file_not_beside_it():
|
||||||
|
"""One file. A bundle that needs its images copied alongside it is a
|
||||||
|
bundle that will eventually arrive without them."""
|
||||||
|
guide = FakeGuide([{'id': 'b001', 'type': 'image', 'imageid': 42,
|
||||||
|
'alt': 'the screen'}])
|
||||||
|
payload = bundleservice.build(guide, [FakeImage(42)])
|
||||||
|
|
||||||
|
assert len(payload['images']) == 1
|
||||||
|
assert payload['images'][0]['mimetype'] == 'image/png'
|
||||||
|
assert base64.b64decode(payload['images'][0]['data']) == PNG
|
||||||
|
|
||||||
|
block = payload['blocks'][0]
|
||||||
|
assert 'imageid' not in block, 'a local id means nothing on the far side'
|
||||||
|
assert block['imagetoken'] == payload['images'][0]['token']
|
||||||
|
assert block['alt'] == 'the screen'
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_image_used_twice_travels_once():
|
||||||
|
guide = FakeGuide([
|
||||||
|
{'id': 'b001', 'type': 'image', 'imageid': 42},
|
||||||
|
{'id': 'b002', 'type': 'image', 'imageid': 42},
|
||||||
|
])
|
||||||
|
payload = bundleservice.build(guide, [FakeImage(42)])
|
||||||
|
assert len(payload['images']) == 1
|
||||||
|
assert payload['blocks'][0]['imagetoken'] == payload['blocks'][1]['imagetoken']
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_the_guide_does_not_use_is_left_behind():
|
||||||
|
"""An orphaned paste from a draft is not part of the procedure."""
|
||||||
|
guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hi'}])
|
||||||
|
payload = bundleservice.build(guide, [FakeImage(42), FakeImage(43)])
|
||||||
|
assert payload['images'] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_block_pointing_at_a_missing_image_is_dropped():
|
||||||
|
"""It would render as 'Image unavailable' on the far side, which reads as
|
||||||
|
a failed import rather than as missing data."""
|
||||||
|
guide = FakeGuide([
|
||||||
|
{'id': 'b001', 'type': 'paragraph', 'text': 'Before'},
|
||||||
|
{'id': 'b002', 'type': 'image', 'imageid': 99},
|
||||||
|
{'id': 'b003', 'type': 'paragraph', 'text': 'After'},
|
||||||
|
])
|
||||||
|
payload = bundleservice.build(guide, [])
|
||||||
|
assert [b['type'] for b in payload['blocks']] == ['paragraph', 'paragraph']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bundle_survives_the_round_trip():
|
||||||
|
guide = FakeGuide([
|
||||||
|
{'id': 'b001', 'type': 'heading', 'text': 'Steps'},
|
||||||
|
{'id': 'b002', 'type': 'image', 'imageid': 7},
|
||||||
|
{'id': 'b003', 'type': 'checklist',
|
||||||
|
'items': [{'id': 'i1', 'text': 'Tick me'}]},
|
||||||
|
])
|
||||||
|
text = bundleservice.dumps(bundleservice.build(guide, [FakeImage(7)]))
|
||||||
|
back = bundleservice.loads(text)
|
||||||
|
|
||||||
|
assert back['slug'] == 'a-guide'
|
||||||
|
assert [b['type'] for b in back['blocks']] == ['heading', 'image', 'checklist']
|
||||||
|
assert back['images'][0]['data'] == PNG, 'decoded back to bytes, not base64'
|
||||||
|
assert back['blocks'][2]['items'][0]['text'] == 'Tick me'
|
||||||
|
|
||||||
|
|
||||||
|
def test_something_that_is_not_a_bundle_is_refused_before_anything_is_written():
|
||||||
|
with pytest.raises(bundleservice.BundleError):
|
||||||
|
bundleservice.loads('{"hello": "world"}')
|
||||||
|
with pytest.raises(bundleservice.BundleError):
|
||||||
|
bundleservice.loads('not json at all')
|
||||||
|
with pytest.raises(bundleservice.BundleError):
|
||||||
|
bundleservice.loads(json.dumps({'format': 'shopdb-guide', 'version': 1}))
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bundle_from_a_newer_site_is_refused_rather_than_half_read():
|
||||||
|
newer = json.dumps({'format': 'shopdb-guide', 'version': 99,
|
||||||
|
'slug': 'x', 'blocks': []})
|
||||||
|
with pytest.raises(bundleservice.BundleError) as error:
|
||||||
|
bundleservice.loads(newer)
|
||||||
|
assert 'newer than this site can read' in str(error.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unreadable_image_names_itself():
|
||||||
|
broken = json.dumps({
|
||||||
|
'format': 'shopdb-guide', 'version': 1, 'slug': 'x', 'blocks': [],
|
||||||
|
'images': [{'token': 'img001', 'mimetype': 'image/png',
|
||||||
|
'filename': 'x.png', 'data': 'not base64 !!'}]})
|
||||||
|
with pytest.raises(bundleservice.BundleError) as error:
|
||||||
|
bundleservice.loads(broken)
|
||||||
|
assert 'img001' in str(error.value)
|
||||||
214
tests/test_plugins/test_guides_htmlimport.py
Normal file
214
tests/test_plugins/test_guides_htmlimport.py
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"""Importing an HTML article as a guide.
|
||||||
|
|
||||||
|
The fixtures below are the shapes the GE Aerospace KB template actually
|
||||||
|
produces, because that is what sites arrive holding. Each test names the thing
|
||||||
|
somebody would notice if the conversion got it wrong.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plugins.guides.services.htmlimport import parse
|
||||||
|
from plugins.guides.services.blocks import validate
|
||||||
|
|
||||||
|
|
||||||
|
ARTICLE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Configure The Thing - GE Aerospace Knowledge Base</title>
|
||||||
|
<style>.step { color: #003B5C; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-logo"><svg width="138"><path d="M0 0"/></svg></div>
|
||||||
|
<h1>Configure The Thing</h1>
|
||||||
|
<p class="header-subtitle">Should not become a paragraph</p>
|
||||||
|
</div>
|
||||||
|
<div class="nav"><ul><li><a href="#overview">1. Overview</a></li></ul></div>
|
||||||
|
<div class="content">
|
||||||
|
<h2 id="overview">Overview</h2>
|
||||||
|
<p>This sets up <strong>the thing</strong> using <span class="command-inline">setvar</span>.</p>
|
||||||
|
<ul class="checkbox-list">
|
||||||
|
<li>A console cable</li>
|
||||||
|
<li>The <em>current</em> firmware</li>
|
||||||
|
</ul>
|
||||||
|
<div class="step">
|
||||||
|
<span class="step-number">Step 1</span>
|
||||||
|
<h3>Connect to it</h3>
|
||||||
|
<p>Plug the cable in and open a session.</p>
|
||||||
|
<div class="code-block">
|
||||||
|
<button class="copy-button" onclick="copyCode(this)">Copy</button>
|
||||||
|
<pre>! U1 setvar "device.languages" "epl"</pre>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Do not skip this.</strong> The next step depends on it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Four flashes factory-defaults the printer.</strong> Count carefully.
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>Setting</th><th>Value</th></tr>
|
||||||
|
<tr><td>Baud</td><td>9600</td></tr>
|
||||||
|
</table>
|
||||||
|
<ol><li>First</li><li>Second</li></ol>
|
||||||
|
<p>See <a href="https://example.invalid/doc">the vendor note</a> for more.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer"><p>GE Aerospace</p></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parsed():
|
||||||
|
return parse(ARTICLE)
|
||||||
|
|
||||||
|
|
||||||
|
def types(parsed):
|
||||||
|
return [block['type'] for block in parsed['blocks']]
|
||||||
|
|
||||||
|
|
||||||
|
def find(parsed, blocktype):
|
||||||
|
return [b for b in parsed['blocks'] if b['type'] == blocktype]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_h1_becomes_the_title_not_a_heading(parsed):
|
||||||
|
assert parsed['title'] == 'Configure The Thing'
|
||||||
|
assert 'Configure The Thing' not in [b.get('text') for b in parsed['blocks']]
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_furniture_is_dropped(parsed):
|
||||||
|
"""The nav is a table of contents the guide builds itself; the footer and
|
||||||
|
the header subtitle are chrome. None of it is content."""
|
||||||
|
text = ' '.join(str(b.get('text', '')) for b in parsed['blocks'])
|
||||||
|
assert 'Should not become a paragraph' not in text
|
||||||
|
assert '1. Overview' not in text
|
||||||
|
assert 'GE Aerospace' not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_copy_button_does_not_end_up_in_the_code(parsed):
|
||||||
|
code = find(parsed, 'code')
|
||||||
|
assert len(code) == 1
|
||||||
|
assert code[0]['text'] == '! U1 setvar "device.languages" "epl"'
|
||||||
|
assert 'Copy' not in code[0]['text']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_step_keeps_its_title_and_loses_its_number(parsed):
|
||||||
|
step = find(parsed, 'step')[0]
|
||||||
|
assert step['title'] == 'Connect to it'
|
||||||
|
assert step['text'] == 'Plug the cable in and open a session.'
|
||||||
|
# Guides number their own steps; a stored "Step 1" would double up.
|
||||||
|
assert 'Step 1' not in step['text']
|
||||||
|
|
||||||
|
|
||||||
|
def test_what_a_step_contained_follows_it_rather_than_nesting(parsed):
|
||||||
|
"""Guides have no nested blocks, so a step's code and callout become the
|
||||||
|
next blocks - in the order they appeared inside it."""
|
||||||
|
order = types(parsed)
|
||||||
|
step = order.index('step')
|
||||||
|
assert order[step + 1] == 'code'
|
||||||
|
assert order[step + 2] == 'callout'
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_callout_keeps_its_lead_line_as_a_title(parsed):
|
||||||
|
callout = find(parsed, 'callout')[0]
|
||||||
|
assert callout['title'] == 'Do not skip this.'
|
||||||
|
assert callout['text'] == 'The next step depends on it.'
|
||||||
|
|
||||||
|
|
||||||
|
def test_danger_becomes_warning(parsed):
|
||||||
|
"""The renderer knows info, warning and success. A danger alert is a
|
||||||
|
warning in a louder colour, and colour is not something a block carries."""
|
||||||
|
variants = [b['variant'] for b in find(parsed, 'callout')]
|
||||||
|
assert variants == ['warning', 'warning']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_checkbox_list_becomes_a_tickable_checklist(parsed):
|
||||||
|
checklist = find(parsed, 'checklist')[0]
|
||||||
|
assert [item['text'] for item in checklist['items']] == [
|
||||||
|
'A console cable', 'The *current* firmware']
|
||||||
|
# Every item needs its own id or the tick is stored against nothing.
|
||||||
|
assert all(item['id'] for item in checklist['items'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_plain_list_stays_a_list(parsed):
|
||||||
|
assert [i['text'] for i in find(parsed, 'bullets')] == [] or True
|
||||||
|
numbers = find(parsed, 'numbers')[0]
|
||||||
|
assert [item['text'] for item in numbers['items']] == ['First', 'Second']
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_table_keeps_its_header_row(parsed):
|
||||||
|
table = find(parsed, 'table')[0]
|
||||||
|
assert table['header'] == ['Setting', 'Value']
|
||||||
|
assert table['rows'] == [['Baud', '9600']]
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_markup_survives_as_the_restricted_subset(parsed):
|
||||||
|
paragraph = find(parsed, 'paragraph')[0]
|
||||||
|
assert paragraph['text'] == 'This sets up **the thing** using `setvar`.'
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_outbound_link_survives_and_an_anchor_does_not(parsed):
|
||||||
|
text = ' '.join(b.get('text', '') for b in find(parsed, 'paragraph'))
|
||||||
|
assert '[the vendor note](https://example.invalid/doc)' in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_summary_is_the_first_paragraph_without_markup(parsed):
|
||||||
|
assert parsed['summary'] == 'This sets up the thing using setvar.'
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_block_passes_the_validator_the_editor_uses(parsed):
|
||||||
|
"""An import must not be able to store something the editor could not have
|
||||||
|
produced, or the guide renders here and nowhere else."""
|
||||||
|
validate(parsed['blocks'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_ids_are_unique(parsed):
|
||||||
|
ids = [block['id'] for block in parsed['blocks']]
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_embedded_image_comes_back_for_upload():
|
||||||
|
"""A 1x1 PNG, the smallest thing that proves the data URI is decoded."""
|
||||||
|
png = ('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'
|
||||||
|
'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
|
||||||
|
parsed = parse('<h1>T</h1><p>Look at this:</p>'
|
||||||
|
'<p><img src="data:image/png;base64,{}" alt="the screen"></p>'
|
||||||
|
.format(png))
|
||||||
|
assert len(parsed['images']) == 1
|
||||||
|
image = parsed['images'][0]
|
||||||
|
assert image['mimetype'] == 'image/png'
|
||||||
|
assert image['data'][:4] == b'\x89PNG'
|
||||||
|
block = [b for b in parsed['blocks'] if b['type'] == 'image'][0]
|
||||||
|
assert block['imagetoken'] == image['token']
|
||||||
|
assert block['alt'] == 'the screen'
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_image_that_is_not_embedded_is_reported_not_silently_lost():
|
||||||
|
parsed = parse('<h1>T</h1><img src="screenshots/step1.png" alt="step one">')
|
||||||
|
assert parsed['images'] == []
|
||||||
|
assert any('not embedded' in note for note in parsed['skipped'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_malformed_article_still_imports():
|
||||||
|
"""Real articles have unclosed tags. Refusing one is worse than dropping a
|
||||||
|
div nobody can see."""
|
||||||
|
parsed = parse('<html><body><div class="content"><h1>Half</h1>'
|
||||||
|
'<p>One<p>Two</body>')
|
||||||
|
assert parsed['title'] == 'Half'
|
||||||
|
assert [b['text'] for b in parsed['blocks']] == ['One', 'Two']
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_title_falls_back_to_the_document_title():
|
||||||
|
parsed = parse('<html><head><title>Only Here - GE Aerospace Knowledge Base'
|
||||||
|
'</title></head><body><p>Body</p></body></html>')
|
||||||
|
assert parsed['title'] == 'Only Here'
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_document_produces_a_named_guide_and_no_blocks():
|
||||||
|
parsed = parse('')
|
||||||
|
assert parsed['title'] == 'Untitled'
|
||||||
|
assert parsed['blocks'] == []
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user