Merge remote-tracking branch 'origin/main' into wilm

# Conflicts:
#	shopdb/__init__.py
This commit is contained in:
benjamin.baker
2026-08-28 09:30:35 -04:00
112 changed files with 5502 additions and 604 deletions

View File

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

View File

@@ -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',

View File

@@ -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()
} }
}, },

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

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

View File

@@ -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)

View File

@@ -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;
} }

View File

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

View File

@@ -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.

View File

@@ -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;

View File

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

View File

@@ -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',
}, },
] ]

View File

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

View File

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

View File

@@ -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.

View File

@@ -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',
}, },
] ]

View File

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

View File

@@ -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; }

View File

@@ -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',

View File

@@ -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, '`')">&lt;&gt;</button> <button type="button" title="Code" @click="wrap(block, '`')">&lt;&gt;</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 |&#10;Header | Header&#10;Cell | Cell"></textarea> placeholder="One row per line, cells separated by |&#10;Header | Header&#10;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>

View File

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

View File

@@ -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)

View File

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

View 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

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

View File

@@ -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."""

View File

@@ -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',
}, },

View File

@@ -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.

View File

@@ -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',
}, },
] ]

View File

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

View File

@@ -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',
}, },
] ]

View File

@@ -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."""

View File

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

View File

@@ -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',
}, },
] ]

View File

@@ -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.

View File

@@ -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,
}, },
] ]

View File

@@ -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."""

View File

@@ -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',
}, },
] ]

View File

@@ -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.

View File

@@ -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' },

View File

@@ -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',
}, },
] ]

View File

@@ -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):

View File

@@ -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'],
}, },
] ]

View File

@@ -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'],
}, },
] ]

View File

@@ -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']}),
}

View File

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

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

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

View File

@@ -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',

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

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

View File

@@ -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',
}, },
] ]

View File

@@ -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."""

View File

@@ -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({})

View File

@@ -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',
}, },
] ]

View File

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

View File

@@ -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',

View File

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

View File

@@ -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."""

View File

@@ -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."""

View File

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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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(

View File

@@ -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)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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).

View File

@@ -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 = [

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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':

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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'] == []

View File

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

View 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

View File

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

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

View File

@@ -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']

View File

@@ -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}')

View File

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

View File

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

View File

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

View File

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

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

View 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