Merge remote-tracking branch 'origin/main' into wilm
# Conflicts: # frontend/src/views/settings/SiteSettings.vue
This commit is contained in:
116
CHANGELOG.md
116
CHANGELOG.md
@@ -12,6 +12,104 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Added
|
||||
|
||||
- **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
|
||||
audit page was tens of thousands of pages deep and therefore useless, because
|
||||
nobody pages through that to find anything. Site & Facility gains
|
||||
`audit_retentiondays` plus a card showing the log's size and exactly how many
|
||||
entries a purge would remove, and there is `flask audit stats` /
|
||||
`flask audit purge --apply` for the server. **Opt-in: the default is 0, which
|
||||
keeps everything.** An upgrade that silently started deleting a site's audit
|
||||
history would be indefensible. Deletion runs in committed batches, because one
|
||||
`DELETE` over a million rows holds table locks long enough to block the
|
||||
collectors posting inventory; a run that stops early reports how much is left
|
||||
rather than claiming it finished. Purging needs its own `audit.purge`
|
||||
permission - reading the log and destroying it are different acts.
|
||||
|
||||
- **Guides: images, and a reader who cannot see drafts.** Guide images are
|
||||
fetched through the authenticated API client instead of being pointed at by
|
||||
`<img src>`. An image tag sends no Authorization header and this app keeps its
|
||||
JWT in one, so every guide image was a broken icon for every user; the same
|
||||
change makes stored image links survive a subpath mount. A failed image now
|
||||
says so in words rather than showing a broken icon. Revision and image routes
|
||||
are gated on their parent guide - revision ids are sequential, so reading one
|
||||
guide used to be enough to walk the ids and read every unpublished draft on
|
||||
the site. Search, the guide list and the tag chips now match the PUBLISHED
|
||||
text, not the working copy, so a reader can no longer get a hit on wording
|
||||
that is not in the page when they open it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A guide's URL no longer moves when its title is edited.** The slug was
|
||||
regenerated on every save that carried a title, and the editor autosaves every
|
||||
three seconds - so fixing a typo in a heading silently moved the guide and
|
||||
broke exactly the pasted links the stored slug exists to protect. The slug is
|
||||
now set once, at create.
|
||||
|
||||
- **Publishing a guide after a failed save reported success.** `publish()` ran
|
||||
regardless of whether the pre-publish save reached the server, so the author
|
||||
saw "Published version N" while readers got the previous revision. A failed
|
||||
save now stops the publish and says why.
|
||||
|
||||
- **The ADR-015 site-literal check was enforcing nothing.** Its patterns are
|
||||
assembled from fragments so the published script does not contain the strings
|
||||
it greps for, but they used the apostrophe-escape form (`'a'\''b'`) instead of
|
||||
concatenation (`'a''b'`), embedding a literal apostrophe in every pattern.
|
||||
Nothing could ever match and the check passed everything from the day it was
|
||||
written. Fixed, and it immediately found 25 real violations - among them a QA
|
||||
ServiceNow tenant URL sitting in the shipped defaults in seven places.
|
||||
|
||||
- **ServiceNow ships off and blank.** The defaults pointed at one organisation's
|
||||
QA ServiceNow tenant with the integration enabled, so a site that had never
|
||||
configured ServiceNow still rendered ticket links - to a tenant its
|
||||
technicians cannot reach, and for an adopting site, into somebody else's
|
||||
instance. There is no cross-site default for "which ServiceNow is yours".
|
||||
|
||||
- **Credentials are no longer stored in backup revisions.** The NTLARS/DNC
|
||||
registry carries an FTP password in cleartext, and it was stored verbatim -
|
||||
rendered by the revision panel and handed back by the download route as a
|
||||
ready-to-import `.reg`. Credential values are now redacted at parse, so they
|
||||
never reach the database, and the rendered file NAMES each withheld value
|
||||
instead of carrying a placebo one: importing a fake password would produce a
|
||||
machine that looks restored and cannot connect. `scripts/redact_backup_secrets.py`
|
||||
scrubs revisions recorded before this (dry run by default). Rotate any
|
||||
password that was stored.
|
||||
|
||||
- **A search domain that fails is no longer indistinguishable from one that
|
||||
found nothing.** Each domain catches broadly and returns no rows so one broken
|
||||
table cannot take the whole search box down - but the technician was then told
|
||||
they might have misspelled a query that was right, and concluded the record
|
||||
did not exist. Failed domains are named in the response, shown as a banner,
|
||||
and suppress the did-you-mean.
|
||||
|
||||
- **An empty `sourcehostname` defeated backup dedup permanently.** The dedup
|
||||
query maps `''` to `IS NULL` while the insert stored the raw `''`, so those
|
||||
rows could never match their own chain: every post inserted, the table grew
|
||||
without bound and the history filled with identical entries. Normalized once
|
||||
at ingest, whitespace included.
|
||||
|
||||
- **`printedparts` migration 0003 was unguarded.** Unlike its twenty siblings it
|
||||
ran a bare `ALTER`, so `flask plugin upgrade-all` failed on any database built
|
||||
by `create_all` and took 0004 down with it. The migration test that should
|
||||
have caught this only covered single-migration chains; it now covers every
|
||||
post-cutover chain.
|
||||
|
||||
- **Guide links with several parameters opened the wrong page.** Text is escaped
|
||||
before formatting, so a captured URL arrives carrying entities and was then
|
||||
escaped a second time - `&amp;` in every multi-parameter ServiceNow and
|
||||
SharePoint link. Emphasis could also reach inside an `href`, and
|
||||
`//host` passed as an in-app link when the browser reads it as off-site.
|
||||
|
||||
- **Retire an asset from the interface.** Machines, PCs, printers and measuring
|
||||
tools had no way to take a record out of ShopDB without calling the API, so a
|
||||
scrapped tool stayed in every list and report. The endpoints already existed
|
||||
and were already soft deletes: the record leaves the lists while its
|
||||
calibration history, backups and relationships stay put, and it can be
|
||||
restored by setting `isactive` back. Called Retire rather than Delete for
|
||||
that reason, and the confirm says what is kept. USB is not included - it
|
||||
retires through a status column in the external CMMC database and needs its
|
||||
own decision.
|
||||
|
||||
- **`flask catalog dedupe`, `merge` and `rename`: catalog hygiene.** The legacy
|
||||
data carried space-stripped twins - `DellInc.` beside `Dell Inc.`,
|
||||
`Windows10` beside `Windows 10` - which rode into the shipped seed catalog
|
||||
@@ -90,6 +188,24 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every detail page had the same browser tab title.** A machine, a PC and a
|
||||
printer all read "West Jefferson ShopDB - Machine" or the equivalent, so two
|
||||
tabs on two different records were indistinguishable, a bookmark said nothing
|
||||
about what it pointed at, and a link shared into Teams previewed as the
|
||||
section rather than the thing. Detail pages now name the record: machines,
|
||||
PCs, printers, network devices, subnets, measuring tools, USB devices,
|
||||
printed parts, applications, employees, knowledge base articles and guides.
|
||||
A record with no usable name, or one that failed to load, keeps the section
|
||||
name, and a long name is cut at a word boundary. Report pages gained proper
|
||||
titles too, which also fixes "Pc Relationships".
|
||||
|
||||
- **Guides would have been dropped at provisioning.** The plugin was missing
|
||||
from `deploy/site-profile-universal.json`, the profile the released Windows
|
||||
installer is built from, so the wizard would not have offered it and
|
||||
`flask plugin prune-schema` drops the tables of any plugin absent from the
|
||||
profile (ADR-014). Its model also imported core directly rather than through
|
||||
`shopdb.api`, which the contract test catches.
|
||||
|
||||
- **The shipped catalog offered two ways to spell the same vendor, and one row
|
||||
that should never have left a test.** `HP Inc.` and `Zebra Technologies` were
|
||||
empty rows duplicating `HP` and `Zebra`, which carry every model between
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"computers",
|
||||
"employees",
|
||||
"geenforce",
|
||||
"guides",
|
||||
"knowledgebase",
|
||||
"machines",
|
||||
"measuringtools",
|
||||
|
||||
@@ -359,6 +359,11 @@ export const printersApi = {
|
||||
update(id, data) {
|
||||
return api.put(`/printers/${id}`, data)
|
||||
},
|
||||
// Soft delete: the server deactivates the asset, so the printer leaves every
|
||||
// list while its history and relationships stay intact.
|
||||
remove(id) {
|
||||
return api.delete(`/printers/${id}`)
|
||||
},
|
||||
updateExtension(id, data) {
|
||||
return api.put(`/printers/${id}/printerdata`, data)
|
||||
},
|
||||
|
||||
91
frontend/src/composables/pageTitle.js
Normal file
91
frontend/src/composables/pageTitle.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// The browser tab title, including WHICH record is open.
|
||||
//
|
||||
// The router can only name the KIND of page, because it runs before the view
|
||||
// has fetched anything: every application read "<facility> ShopDB -
|
||||
// Application", so two tabs on two different machines were indistinguishable,
|
||||
// and a shared link previewed as the section rather than the thing.
|
||||
//
|
||||
// So a detail view names itself once its record arrives:
|
||||
//
|
||||
// setRecordTitle(machine.assetnumber)
|
||||
//
|
||||
// THE ORDERING IS THE WHOLE PROBLEM. The router's afterEach awaits the site
|
||||
// settings before it can build "<facility> ShopDB - <page>", so on a cold load
|
||||
// it finishes AFTER a view that fetched quickly has already set a good title,
|
||||
// and would overwrite it with the generic one. Both writers therefore go
|
||||
// through here, and the generic write is dropped when a record title already
|
||||
// belongs to the route being landed on. The key is the route's fullPath, so
|
||||
// navigating away invalidates the previous record's claim.
|
||||
|
||||
import { getFacilityName } from '@/utils/siteSettings'
|
||||
|
||||
let activeKey = null // fullPath of the route the tab is currently showing
|
||||
let recordTitle = null // record name set by a view for activeKey, if any
|
||||
let sitePrefix = 'ShopDB' // '<facility> ShopDB', resolved once and cached
|
||||
|
||||
function compose(page) {
|
||||
return page && page !== 'Home' ? `${sitePrefix} - ${page}` : sitePrefix
|
||||
}
|
||||
|
||||
async function resolveSitePrefix() {
|
||||
try {
|
||||
const facility = await getFacilityName()
|
||||
if (facility && facility !== 'ShopDB') sitePrefix = `${facility} ShopDB`
|
||||
} catch (settingsError) {
|
||||
// settings unavailable: plain ShopDB is a fine tab title
|
||||
}
|
||||
return sitePrefix
|
||||
}
|
||||
|
||||
// Called by the router on every navigation, BEFORE it awaits anything, so a
|
||||
// view that resolves first is not treated as belonging to the previous route.
|
||||
export function beginNavigation(key) {
|
||||
activeKey = key
|
||||
recordTitle = null
|
||||
}
|
||||
|
||||
// Called by the router once it knows the site prefix. Skipped when a view has
|
||||
// already named the record for this route.
|
||||
export async function applyGenericTitle(key, page) {
|
||||
await resolveSitePrefix()
|
||||
if (key !== activeKey) return // navigated on while we awaited
|
||||
// A view that named the record before the facility name arrived composed its
|
||||
// title against the placeholder prefix, so rewrite it rather than leaving
|
||||
// 'ShopDB - CSF16' without the facility name in front of it.
|
||||
document.title = compose(recordTitle !== null ? recordTitle : page)
|
||||
}
|
||||
|
||||
// A tab shows very little, and the browser truncates mid-word wherever it runs
|
||||
// out. Cut at a word boundary instead, so what survives is readable. Knowledge
|
||||
// base articles are the case that forces this: their name is a whole sentence
|
||||
// of description, not a short label.
|
||||
const MAXLENGTH = 60
|
||||
|
||||
function shorten(text) {
|
||||
if (text.length <= MAXLENGTH) return text
|
||||
const cut = text.slice(0, MAXLENGTH)
|
||||
const space = cut.lastIndexOf(' ')
|
||||
return `${(space > MAXLENGTH / 2 ? cut.slice(0, space) : cut).trimEnd()}...`
|
||||
}
|
||||
|
||||
// Called by a detail view once its record has loaded. An empty or missing name
|
||||
// leaves the generic title in place rather than showing a bare id: "Machine"
|
||||
// beats "Machine 4192" for somebody scanning a row of tabs, and a record that
|
||||
// failed to load has no name to show anyway.
|
||||
export function setRecordTitle(name) {
|
||||
const text = (name === null || name === undefined) ? '' : String(name).trim()
|
||||
if (!text) return
|
||||
recordTitle = shorten(text)
|
||||
document.title = compose(recordTitle)
|
||||
}
|
||||
|
||||
// Exposed for tests.
|
||||
export function _state() {
|
||||
return { activeKey, recordTitle, sitePrefix }
|
||||
}
|
||||
|
||||
export function _reset() {
|
||||
activeKey = null
|
||||
recordTitle = null
|
||||
sitePrefix = 'ShopDB'
|
||||
}
|
||||
177
frontend/src/composables/pageTitle.spec.js
Normal file
177
frontend/src/composables/pageTitle.spec.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// The tab title names the record, and the two writers do not fight.
|
||||
//
|
||||
// The router can only name the KIND of page - it runs before the view has
|
||||
// fetched anything - so a detail view names the record itself once it loads.
|
||||
// That leaves two writers for one string, and the router's is asynchronous
|
||||
// (it awaits the site settings), so on a cold load it can finish LAST and
|
||||
// overwrite a good title with the generic one. These pin that ordering.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// The real getFacilityName caches, so only the first call is slow and every
|
||||
// later one is instant. Mirror that: pending calls are held until the test
|
||||
// releases them, and the value is served synchronously from then on. A mock
|
||||
// that hands out a fresh unresolved promise per call hangs the moment a test
|
||||
// navigates twice.
|
||||
let pending = []
|
||||
let settled = null
|
||||
|
||||
function resolveFacility(value) {
|
||||
settled = value
|
||||
const waiting = pending
|
||||
pending = []
|
||||
waiting.forEach((resolve) => resolve(value))
|
||||
}
|
||||
|
||||
vi.mock('@/utils/siteSettings', () => ({
|
||||
getFacilityName: () => (settled !== null
|
||||
? Promise.resolve(settled)
|
||||
: new Promise((resolve) => { pending.push(resolve) })),
|
||||
}))
|
||||
|
||||
import {
|
||||
beginNavigation, applyGenericTitle, setRecordTitle, _reset,
|
||||
} from './pageTitle'
|
||||
|
||||
beforeEach(() => {
|
||||
_reset()
|
||||
document.title = ''
|
||||
pending = []
|
||||
settled = null
|
||||
})
|
||||
|
||||
describe('the generic title', () => {
|
||||
it('names the section when no record has been set', async () => {
|
||||
beginNavigation('/machines')
|
||||
const done = applyGenericTitle('/machines', 'Machines')
|
||||
resolveFacility('West Jefferson')
|
||||
await done
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB - Machines')
|
||||
})
|
||||
|
||||
it('falls back to plain ShopDB when the facility is unset', async () => {
|
||||
beginNavigation('/machines')
|
||||
const done = applyGenericTitle('/machines', 'Machines')
|
||||
resolveFacility('ShopDB')
|
||||
await done
|
||||
|
||||
expect(document.title).toBe('ShopDB - Machines')
|
||||
})
|
||||
|
||||
it('drops the site name entirely on the dashboard', async () => {
|
||||
beginNavigation('/')
|
||||
const done = applyGenericTitle('/', 'Home')
|
||||
resolveFacility('West Jefferson')
|
||||
await done
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a record title', () => {
|
||||
it('names the record once the view has loaded it', async () => {
|
||||
beginNavigation('/machines/12')
|
||||
const done = applyGenericTitle('/machines/12', 'Machines')
|
||||
resolveFacility('West Jefferson')
|
||||
await done
|
||||
|
||||
setRecordTitle('CSF16')
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
|
||||
})
|
||||
|
||||
it('SURVIVES a slow router that resolves afterwards', async () => {
|
||||
// The regression this file exists for. The view wins the race, the
|
||||
// settings fetch lands second, and the generic write must stand down.
|
||||
beginNavigation('/machines/12')
|
||||
const done = applyGenericTitle('/machines/12', 'Machines')
|
||||
|
||||
setRecordTitle('CSF16') // view got there first
|
||||
resolveFacility('West Jefferson')
|
||||
await done // router finishes last
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
|
||||
})
|
||||
|
||||
it('is ignored when the record has no usable name', async () => {
|
||||
beginNavigation('/machines/12')
|
||||
const done = applyGenericTitle('/machines/12', 'Machines')
|
||||
resolveFacility('West Jefferson')
|
||||
await done
|
||||
|
||||
setRecordTitle('')
|
||||
setRecordTitle(null)
|
||||
setRecordTitle(undefined)
|
||||
setRecordTitle(' ')
|
||||
|
||||
// the section name is a better tab than a blank or a bare id
|
||||
expect(document.title).toBe('West Jefferson ShopDB - Machines')
|
||||
})
|
||||
|
||||
it('shortens a name too long for a tab, at a word boundary', async () => {
|
||||
// Knowledge base articles are named with a sentence of description, and a
|
||||
// browser truncates those mid-word wherever it happens to run out.
|
||||
beginNavigation('/knowledgebase/2')
|
||||
const done = applyGenericTitle('/knowledgebase/2', 'Knowledge Base')
|
||||
resolveFacility('West Jefferson')
|
||||
await done
|
||||
|
||||
const full = 'Documentation on how to image a Standard or Business PC '
|
||||
+ 'in GCC High using MediaCreator Lite'
|
||||
setRecordTitle(full)
|
||||
|
||||
expect(document.title.length).toBeLessThan(90)
|
||||
expect(document.title.startsWith('West Jefferson ShopDB - ')).toBe(true)
|
||||
expect(document.title.endsWith('...')).toBe(true)
|
||||
|
||||
// what survives is a whole-word prefix of the original: the character
|
||||
// following the cut in the source is a space, so no word was split
|
||||
const kept = document.title
|
||||
.replace('West Jefferson ShopDB - ', '')
|
||||
.replace(/\.\.\.$/, '')
|
||||
expect(full.startsWith(kept)).toBe(true)
|
||||
expect(full[kept.length]).toBe(' ')
|
||||
})
|
||||
|
||||
it('leaves a name that already fits alone', async () => {
|
||||
beginNavigation('/machines/14')
|
||||
const done = applyGenericTitle('/machines/14', 'Machines')
|
||||
resolveFacility('West Jefferson')
|
||||
await done
|
||||
|
||||
setRecordTitle('0600')
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB - 0600')
|
||||
})
|
||||
|
||||
it('does not leak onto the next page', async () => {
|
||||
beginNavigation('/machines/12')
|
||||
const first = applyGenericTitle('/machines/12', 'Machines')
|
||||
resolveFacility('West Jefferson')
|
||||
await first
|
||||
setRecordTitle('CSF16')
|
||||
|
||||
beginNavigation('/printers')
|
||||
const second = applyGenericTitle('/printers', 'Printers')
|
||||
await second
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB - Printers')
|
||||
})
|
||||
|
||||
it('is discarded when the user navigates away mid-flight', async () => {
|
||||
// Two navigations overlap; the first one's late generic write must not
|
||||
// land on the second page.
|
||||
beginNavigation('/machines/12')
|
||||
const stale = applyGenericTitle('/machines/12', 'Machines')
|
||||
|
||||
beginNavigation('/printers/3')
|
||||
const fresh = applyGenericTitle('/printers/3', 'Printers')
|
||||
setRecordTitle('WJPRT04')
|
||||
|
||||
resolveFacility('West Jefferson')
|
||||
await Promise.all([stale, fresh])
|
||||
|
||||
expect(document.title).toBe('West Jefferson ShopDB - WJPRT04')
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,7 @@ export const assetTypeCols = [
|
||||
export const searchDomains = [
|
||||
{ key: 'application', label: 'Applications' },
|
||||
{ key: 'knowledgebase', label: 'Knowledge Base' },
|
||||
{ key: 'guide', label: 'Guides' },
|
||||
{ key: 'employee', label: 'Employees' },
|
||||
{ key: 'machine', label: 'Machines' },
|
||||
{ key: 'computer', label: 'PCs' },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
|
||||
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
|
||||
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { getFacilityName } from '../utils/siteSettings'
|
||||
import { beginNavigation, applyGenericTitle } from '../composables/pageTitle'
|
||||
import { stagedChildren, stagedToplevel } from './routes.gen'
|
||||
|
||||
// Auto-discover all route modules from routes/ directory. A module's default
|
||||
@@ -147,14 +147,12 @@ function pageTitleFor(route) {
|
||||
word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||
}
|
||||
|
||||
router.afterEach(async (to) => {
|
||||
let siteTitle = 'ShopDB'
|
||||
try {
|
||||
const facility = await getFacilityName()
|
||||
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB`
|
||||
} catch (titleError) { /* settings unavailable - plain ShopDB */ }
|
||||
const page = pageTitleFor(to)
|
||||
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
|
||||
router.afterEach((to) => {
|
||||
// Claim the route BEFORE awaiting anything: a detail view that resolves
|
||||
// quickly sets the record title, and the generic write below must not
|
||||
// overwrite it. See composables/pageTitle.js.
|
||||
beginNavigation(to.fullPath)
|
||||
applyGenericTitle(to.fullPath, pageTitleFor(to))
|
||||
})
|
||||
|
||||
// A deploy replaces the hashed chunk files, so a tab that was open across it
|
||||
|
||||
@@ -98,22 +98,20 @@ function darkenHexColor(hex, fraction) {
|
||||
|
||||
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
|
||||
// templates, and the global-search URL template (all with a {ticket}
|
||||
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
|
||||
// placeholder).
|
||||
//
|
||||
// The defaults are BLANK and the integration ships OFF, matching the server.
|
||||
// They used to be one organisation's QA ServiceNow tenant, so any site that had
|
||||
// not configured ServiceNow still rendered ticket links - pointing its
|
||||
// technicians at a tenant they cannot reach and, for an adopting site, at
|
||||
// somebody else's. A blank URL renders tickets as plain text, which is the
|
||||
// correct behaviour for "not configured".
|
||||
export async function getServicenowUrls() {
|
||||
const enabledRaw = await getSetting('servicenow_enabled', 'true')
|
||||
const enabledRaw = await getSetting('servicenow_enabled', 'false')
|
||||
const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false
|
||||
const incidentUrl = await getSetting(
|
||||
'servicenow_incident_url',
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
const changeUrl = await getSetting(
|
||||
'servicenow_change_url',
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
const searchUrl = await getSetting(
|
||||
'servicenow_search_url',
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
const incidentUrl = await getSetting('servicenow_incident_url', '')
|
||||
const changeUrl = await getSetting('servicenow_change_url', '')
|
||||
const searchUrl = await getSetting('servicenow_search_url', '')
|
||||
return { enabled, incidentUrl, changeUrl, searchUrl }
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
<div v-if="loading" class="loading">Searching...</div>
|
||||
|
||||
<template v-else-if="query">
|
||||
<!--
|
||||
A domain that could not be searched is stated plainly. Without this,
|
||||
a broken table reads as "that record does not exist" - and the person
|
||||
goes off believing the machine was never entered.
|
||||
-->
|
||||
<div v-if="degraded.length" class="search-degraded">
|
||||
<strong>Some results may be missing.</strong>
|
||||
Could not search: {{ degraded.join(', ') }}.
|
||||
Anything in {{ degraded.length > 1 ? 'those areas' : 'that area' }}
|
||||
will not appear below, even if it exists.
|
||||
</div>
|
||||
|
||||
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
|
||||
No {{ activeFilter }} results for "{{ query }}"
|
||||
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
|
||||
@@ -103,6 +115,7 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const results = ref([])
|
||||
const suggestion = ref(null)
|
||||
const degraded = ref([])
|
||||
const query = ref('')
|
||||
const searchInput = ref('')
|
||||
const activeFilter = ref('all')
|
||||
@@ -117,6 +130,7 @@ const typeLabels = {
|
||||
computer: 'PC',
|
||||
application: 'App',
|
||||
knowledgebase: 'KB',
|
||||
guide: 'Guide',
|
||||
printer: 'Printer',
|
||||
network_device: 'Network',
|
||||
measuring_tool: 'Measuring Tool',
|
||||
@@ -136,6 +150,7 @@ const filterTypeMap = {
|
||||
measuringtools: ['measuring_tool'],
|
||||
applications: ['application'],
|
||||
knowledgebase: ['knowledgebase'],
|
||||
guides: ['guide'],
|
||||
notifications: ['notification'],
|
||||
employees: ['employee'],
|
||||
usb: ['usb_device'],
|
||||
@@ -151,6 +166,7 @@ const filterList = [
|
||||
{ key: 'measuringtools', label: 'Measuring Tools' },
|
||||
{ key: 'applications', label: 'Apps' },
|
||||
{ key: 'knowledgebase', label: 'KB' },
|
||||
{ key: 'guides', label: 'Guides' },
|
||||
{ key: 'notifications', label: 'Notices' },
|
||||
{ key: 'employees', label: 'Employees' },
|
||||
{ key: 'usb', label: 'USB' },
|
||||
@@ -179,6 +195,7 @@ async function search(q) {
|
||||
if (!q || q.length < 2) {
|
||||
results.value = []
|
||||
suggestion.value = null
|
||||
degraded.value = []
|
||||
return
|
||||
}
|
||||
|
||||
@@ -192,6 +209,7 @@ async function search(q) {
|
||||
// call is needed here. A list page that has no such key uses the
|
||||
// useSearchSuggestion composable against /search/suggest instead.
|
||||
suggestion.value = data?.suggestion || null
|
||||
degraded.value = data?.degraded || []
|
||||
|
||||
// Handle ServiceNOW redirect
|
||||
if (data?.redirect?.type === 'servicenow') {
|
||||
@@ -424,6 +442,7 @@ watch(results, () => {
|
||||
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
||||
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
||||
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
||||
--rt-guide-bg: #e8f0fe; --rt-guide-fg: #1a56b8;
|
||||
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
||||
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
||||
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
||||
@@ -437,6 +456,7 @@ watch(results, () => {
|
||||
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
||||
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
||||
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
||||
.result-type.guide { background: var(--rt-guide-bg); color: var(--rt-guide-fg); }
|
||||
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
||||
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
||||
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
||||
@@ -507,4 +527,16 @@ watch(results, () => {
|
||||
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
||||
}
|
||||
}
|
||||
|
||||
/* Warning, not an error: results below are still usable, just incomplete. */
|
||||
.search-degraded {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1px solid var(--warning, #b26a00);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.servicenow_search_url"
|
||||
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
|
||||
placeholder="https://yourtenant.service-now.com/now/nav/ui/search/.../{ticket}/..."
|
||||
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
|
||||
@@ -35,12 +35,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Retention is a setting; purging is an ACT. Keeping them apart matters here
|
||||
more than in most places: saving a number must never silently destroy
|
||||
history, so the count of what would go is shown before anything happens
|
||||
and the deletion is a separate, deliberate press.
|
||||
-->
|
||||
<div class="card">
|
||||
<h3 class="group-title">Audit Log Size</h3>
|
||||
<div v-if="audit.loading" class="muted">Loading...</div>
|
||||
<div v-else-if="audit.error" class="error">{{ audit.error }}</div>
|
||||
<template v-else>
|
||||
<div class="audit-stats">
|
||||
<span><strong>{{ audit.total.toLocaleString() }}</strong> entries</span>
|
||||
<span v-if="audit.oldest" class="muted">oldest {{ shortDate(audit.oldest) }}</span>
|
||||
<span v-if="audit.newest" class="muted">newest {{ shortDate(audit.newest) }}</span>
|
||||
</div>
|
||||
|
||||
<p v-if="!audit.retentiondays" class="muted">
|
||||
No retention period is set, so nothing is ever deleted. Set
|
||||
<em>Audit Log Retention</em> above and save to enable purging.
|
||||
</p>
|
||||
<p v-else-if="audit.expired" class="muted">
|
||||
<strong>{{ audit.expired.toLocaleString() }}</strong> entries are older
|
||||
than {{ audit.retentiondays }} days and would be deleted.
|
||||
</p>
|
||||
<p v-else class="muted">
|
||||
Nothing is older than {{ audit.retentiondays }} days.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" :disabled="audit.busy" @click="loadAudit">
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
:disabled="audit.busy || !audit.retentiondays || !audit.expired"
|
||||
@click="purgeAudit">
|
||||
{{ audit.busy ? 'Purging...' : 'Purge now' }}
|
||||
</button>
|
||||
<span v-if="audit.message" class="muted">{{ audit.message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api, { settingsApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const items = ref([])
|
||||
@@ -75,6 +118,7 @@ const LABELS = {
|
||||
employee_directory_mode: 'Employee Directory Mode',
|
||||
usb_directory_mode: 'USB Directory Mode',
|
||||
site_timezone: 'Site Timezone',
|
||||
audit_retentiondays: 'Audit Log Retention (days)',
|
||||
setup_complete: 'Setup Complete',
|
||||
asset_import_csv_location: 'Asset Import CSV Location'
|
||||
}
|
||||
@@ -105,6 +149,7 @@ const GROUPS = [
|
||||
{ title: 'Naming & Patterns', keys: ['pc_access_domain', 'printer_hostname_template', 'contact_email_domain', 'employeeid_pattern'] },
|
||||
{ title: 'Data Sources', keys: ['employee_directory_mode', 'usb_directory_mode'] },
|
||||
{ title: 'Localization', keys: ['site_timezone'] },
|
||||
{ title: 'Audit Log', keys: ['audit_retentiondays'] },
|
||||
{ title: 'System', keys: ['setup_complete'] }
|
||||
]
|
||||
|
||||
@@ -117,12 +162,68 @@ const HELP = {
|
||||
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net', // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
|
||||
contact_email_domain: 'Email domain appended to a support contact SSO to build email (sso@domain) and Microsoft Teams chat links. Example: geaerospace.com. Leave blank to hide the contact action buttons.',
|
||||
dualpath_single_machine: 'Treat a Dualpath pair (a dual-bay machine with one controller) as a single machine in lists, counts, and the floor map. Both bay records are always kept; detail pages stay per-bay with a sibling banner. Enter true or false. Default: true.',
|
||||
audit_retentiondays: 'Delete audit entries older than this many days. 0 keeps everything forever, which is the default - nothing is removed until you set a number here. Saving a retention period does NOT delete anything by itself; use Purge below, or run "flask audit purge --apply" on the server.',
|
||||
site_timezone: 'IANA timezone for this site. Notification start/end times are shown and entered in this zone, and daily-reset notification expiry is computed here. Default: America/New_York.'
|
||||
}
|
||||
function fieldHelp(key) {
|
||||
return HELP[key] || ''
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- audit ---
|
||||
const audit = ref({ loading: true, busy: false, error: '', message: '',
|
||||
total: 0, oldest: null, newest: null,
|
||||
retentiondays: 0, expired: 0 })
|
||||
|
||||
function shortDate (iso) {
|
||||
const d = new Date(iso)
|
||||
return isNaN(d) ? iso : d.toLocaleDateString()
|
||||
}
|
||||
|
||||
async function loadAudit () {
|
||||
audit.value.error = ''
|
||||
try {
|
||||
const response = await api.get('/auditlogs/retention')
|
||||
const data = response.data?.data || response.data
|
||||
Object.assign(audit.value, {
|
||||
total: data.total || 0,
|
||||
oldest: data.oldest,
|
||||
newest: data.newest,
|
||||
retentiondays: data.retentiondays || 0,
|
||||
expired: data.expired || 0,
|
||||
})
|
||||
} catch (err) {
|
||||
audit.value.error = apiError(err, 'Could not read the audit log size.')
|
||||
} finally {
|
||||
audit.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeAudit () {
|
||||
const days = audit.value.retentiondays
|
||||
const ok = window.confirm(
|
||||
'Permanently delete ' + audit.value.expired.toLocaleString() +
|
||||
' audit entries older than ' + days + ' days?\n\n' +
|
||||
'Audit history cannot be recovered once deleted.')
|
||||
if (!ok) return
|
||||
|
||||
audit.value.busy = true
|
||||
audit.value.message = ''
|
||||
try {
|
||||
const response = await api.post('/auditlogs/purge', { days })
|
||||
const result = response.data?.data || response.data
|
||||
audit.value.message = response.data?.message || 'Purged.'
|
||||
// A capped run leaves work behind, and says so rather than looking done.
|
||||
if (result && result.complete === false) {
|
||||
audit.value.message += ' Press Purge again to continue.'
|
||||
}
|
||||
await loadAudit()
|
||||
} catch (err) {
|
||||
audit.value.error = apiError(err, 'Could not purge the audit log.')
|
||||
} finally {
|
||||
audit.value.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -156,7 +257,7 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => { load(); loadAudit() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -218,4 +319,6 @@ onMounted(load)
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.audit-stats { display: flex; gap: 1.25rem; align-items: baseline;
|
||||
flex-wrap: wrap; margin-bottom: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -168,6 +168,7 @@ import { applicationsApi } from '@/api'
|
||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||
import { fileHref } from '@/utils/basePath'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -202,6 +203,7 @@ onMounted(async () => {
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
setRecordTitle(app.value?.appname)
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
|
||||
@@ -27,8 +27,10 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
columns = {c['name'] for c in
|
||||
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'backuprevisions' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
|
||||
if 'lastseenat' not in columns:
|
||||
op.add_column('backuprevisions',
|
||||
sa.Column('lastseenat', sa.DateTime(), nullable=True))
|
||||
@@ -37,7 +39,9 @@ def upgrade():
|
||||
|
||||
|
||||
def downgrade():
|
||||
columns = {c['name'] for c in
|
||||
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'backuprevisions' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('backuprevisions')}
|
||||
if 'lastseenat' in columns:
|
||||
op.drop_column('backuprevisions', 'lastseenat')
|
||||
|
||||
@@ -410,7 +410,13 @@ class BackupsPlugin(BasePlugin):
|
||||
# with_for_update serializes concurrent posts for the same chain: the
|
||||
# fleet collects on one GE-Enforce cycle, so a retry could otherwise
|
||||
# pass the hash check twice and insert the same revision.
|
||||
sourcehostname = payload.get('sourcehostname')
|
||||
# Normalize ONCE, and use the same value to query and to store.
|
||||
# samesource() maps '' to IS NULL, but the insert below used to store
|
||||
# the raw '' - so a client posting an empty hostname wrote rows that its
|
||||
# own next post could never match. Every post then deduped against
|
||||
# nothing, inserting forever: unbounded growth, and a history no
|
||||
# technician can read. Whitespace does the same thing more quietly.
|
||||
sourcehostname = (payload.get('sourcehostname') or '').strip() or None
|
||||
latest = (db.session.query(BackupRevision)
|
||||
.filter(BackupRevision.assetid == assetid,
|
||||
BackupRevision.backupkind == kindkey,
|
||||
@@ -476,7 +482,7 @@ class BackupsPlugin(BasePlugin):
|
||||
sharepath=sharepath,
|
||||
sourcefilename=sourcefilename,
|
||||
bytesize=bytesize,
|
||||
sourcehostname=payload.get('sourcehostname'),
|
||||
sourcehostname=sourcehostname,
|
||||
collectedat=collectedat or datetime.utcnow(),
|
||||
lastseenat=datetime.utcnow(),
|
||||
)
|
||||
|
||||
@@ -32,6 +32,31 @@ import re
|
||||
|
||||
SCHEMA = 'ntlars/1'
|
||||
|
||||
# SECRETS ARE NOT BACKED UP.
|
||||
#
|
||||
# The DNC registry carries an FTP password in cleartext. Stored verbatim it
|
||||
# reached three places it must not: the backuprevisions table, the revision
|
||||
# panel (which renders values), and the download route (which hands back a
|
||||
# ready-to-import .reg). Anyone with backups.view could read the fleet's FTP
|
||||
# password off a web page.
|
||||
#
|
||||
# Redaction happens at PARSE, so the secret never enters the database at all.
|
||||
# Redacting only on display would leave it at rest and one query away.
|
||||
#
|
||||
# What is deliberately NOT done: dropping the value silently. This module's
|
||||
# parser refuses to discard an unparseable value precisely because a backup that
|
||||
# looks complete and restores a machine with a setting missing is the worst
|
||||
# outcome. A redacted value keeps its NAME and records itself in
|
||||
# `redacted`, so both the panel and a restore can say plainly that this one
|
||||
# field must be set by hand.
|
||||
REDACTEDMARKER = '***REDACTED***'
|
||||
SECRETNAMEPATTERN = re.compile(r'passw|pwd|secret|apikey|token', re.IGNORECASE)
|
||||
|
||||
|
||||
def issecretname(name):
|
||||
"""Whether a registry value name holds a credential."""
|
||||
return bool(SECRETNAMEPATTERN.search(name or ''))
|
||||
|
||||
REGROOT = 'HKEY_LOCAL_MACHINE'
|
||||
DNCPATH = r'SOFTWARE\GE Aircraft Engines\DNC'
|
||||
DNCPATHWOW = r'SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC'
|
||||
@@ -245,6 +270,20 @@ def parse(raw):
|
||||
|
||||
dialect = 'wow6432node' if sawwow else ('ntlars' if sawplain else 'unknown')
|
||||
|
||||
# Strip credentials before the projection is returned - i.e. before anything
|
||||
# persists it. Recorded by path\name so the omission is visible rather than
|
||||
# discovered during a restore.
|
||||
redacted = []
|
||||
for path in keys:
|
||||
for name, value in keys[path].items():
|
||||
if not issecretname(name):
|
||||
continue
|
||||
if value.get('data') in ('', None):
|
||||
continue # nothing to hide
|
||||
keys[path][name] = {'type': value.get('type', 'REG_SZ'),
|
||||
'data': REDACTEDMARKER}
|
||||
redacted.append('{}\\{}'.format(path, name) if path else name)
|
||||
|
||||
return {
|
||||
'schema': SCHEMA,
|
||||
'sourcedialect': dialect,
|
||||
@@ -252,6 +291,7 @@ def parse(raw):
|
||||
{'path': path, 'values': dict(sorted(keys[path].items()))}
|
||||
for path in sorted(keys)
|
||||
],
|
||||
'redacted': sorted(redacted),
|
||||
}
|
||||
|
||||
|
||||
@@ -291,6 +331,12 @@ def render(projection, dialect='ntlars', comments=None):
|
||||
|
||||
dialect='ntlars' omits WOW6432Node - use with the NTLARS Load... button
|
||||
dialect='wow6432node' includes it - use with `reg import` on 64-bit
|
||||
|
||||
Redacted credentials are rendered as a comment naming the value, NOT as the
|
||||
marker string. Importing '***REDACTED***' as the FTP password would leave a
|
||||
machine that looks restored and cannot connect, and the technician would be
|
||||
debugging the network. A commented-out value fails loudly at the one moment
|
||||
somebody is looking at the file.
|
||||
"""
|
||||
if dialect not in ('ntlars', 'wow6432node'):
|
||||
raise ValueError('unknown dialect: {!r}'.format(dialect))
|
||||
@@ -302,10 +348,28 @@ def render(projection, dialect='ntlars', comments=None):
|
||||
if comments:
|
||||
out.append('')
|
||||
|
||||
redacted = projection.get('redacted') or []
|
||||
if redacted:
|
||||
out.append('; ---------------------------------------------------------------')
|
||||
out.append('; {} credential value(s) were NOT backed up and are NOT in this'
|
||||
.format(len(redacted)))
|
||||
out.append('; file. Set them by hand after importing:')
|
||||
for name in redacted:
|
||||
out.append('; {}'.format(name))
|
||||
out.append('; ---------------------------------------------------------------')
|
||||
out.append('')
|
||||
|
||||
for key in projection.get('keys', []):
|
||||
path = key.get('path', '')
|
||||
out.append('[{}]'.format(root + ('\\' + path if path else '')))
|
||||
for name, entry in (key.get('values') or {}).items():
|
||||
# A redacted credential is commented out, never emitted as the
|
||||
# marker. Importing the marker as a password produces a machine that
|
||||
# looks restored and cannot log in.
|
||||
if (entry or {}).get('data') == REDACTEDMARKER:
|
||||
out.append('; "{}" was not backed up - set it by hand'.format(
|
||||
_escape(name)))
|
||||
continue
|
||||
out.append(_rendervalue(name, entry))
|
||||
out.append('')
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export default [
|
||||
path: 'reports/pc-relationships',
|
||||
name: 'report-pc-relationships',
|
||||
component: () => import('./views/PCRelationshipsReport.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
meta: { plugin: 'computers', title: 'PC Relationships Report' }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypemapping',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,7 +277,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
@@ -285,8 +286,13 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -302,6 +308,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const response = await computersApi.get(route.params.id)
|
||||
computer.value = response.data.data
|
||||
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
|
||||
|
||||
// Load installed applications
|
||||
try {
|
||||
@@ -330,6 +337,23 @@ function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
+ `History and relationships are kept, and it can be restored.`)) return
|
||||
try {
|
||||
await computersApi.delete(route.params.id)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/pcs')
|
||||
} catch (error) {
|
||||
console.error('Error retiring PC:', error)
|
||||
toast.error(apiError(error, 'Failed to retire PC'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -153,6 +153,7 @@ import { withBase } from '@/utils/basePath'
|
||||
import { employeesApi, usbApi, notificationsApi } from '@/api'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
const toast = useToast()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -223,6 +224,7 @@ async function loadEmployee() {
|
||||
try {
|
||||
const response = await employeesApi.lookup(route.params.sso)
|
||||
employee.value = response.data.data
|
||||
setRecordTitle(employee.value?.name || employee.value?.sso)
|
||||
} catch (err) {
|
||||
console.error('Error loading employee:', err)
|
||||
error.value = 'Employee not found'
|
||||
|
||||
3
plugins/guides/__init__.py
Normal file
3
plugins/guides/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .plugin import GuidesPlugin
|
||||
|
||||
__all__ = ['GuidesPlugin']
|
||||
3
plugins/guides/api/__init__.py
Normal file
3
plugins/guides/api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .routes import guides_bp
|
||||
|
||||
__all__ = ['guides_bp']
|
||||
573
plugins/guides/api/routes.py
Normal file
573
plugins/guides/api/routes.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""Guides API.
|
||||
|
||||
TWO WAYS TO READ A GUIDE, on purpose:
|
||||
|
||||
GET /api/guides/<slug> what was PUBLISHED
|
||||
GET /api/guides/<id>/working the working copy, for editors
|
||||
|
||||
A reader following a procedure must not have it change under them because
|
||||
somebody upstairs is editing. The published pointer is what makes that true, and
|
||||
serving the working copy from the reader's route would quietly undo it.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import re
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission, harden_upload_response,
|
||||
)
|
||||
|
||||
from ..models import Guide, GuideRevision, GuideImage
|
||||
from ..services import authoring
|
||||
from ..services.blocks import BlockError, checklistitemcount
|
||||
|
||||
guides_bp = Blueprint('guides', __name__)
|
||||
|
||||
|
||||
# Screenshots of a settings dialog, not photographs. Generous for the former and
|
||||
# small enough that a pasted phone picture is refused rather than silently
|
||||
# making a guide take ten seconds to open on a shopfloor PC.
|
||||
MAXIMAGEBYTES = 8 * 1024 * 1024
|
||||
ALLOWEDIMAGETYPES = {
|
||||
'image/png': '.png',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
}
|
||||
|
||||
|
||||
def _userid():
|
||||
if current_user is not None:
|
||||
return current_user.userid
|
||||
try:
|
||||
return int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _has(permission):
|
||||
"""Whether the caller holds a permission, without raising.
|
||||
|
||||
require_permission decorates a route; this is for the in-route decisions -
|
||||
may this person see drafts, may they edit this particular guide. Same check
|
||||
the decorator makes, including the admin bypass that lives inside
|
||||
haspermission.
|
||||
"""
|
||||
return bool(current_user is not None
|
||||
and current_user.haspermission(permission))
|
||||
|
||||
|
||||
def _mayedit(guide):
|
||||
"""Edit rights for one guide.
|
||||
|
||||
guides.edit covers everything. Otherwise an author may edit their OWN guide
|
||||
while it is unpublished - which is what makes drafting useful without
|
||||
handing everyone rights over published procedures. Once it is published it
|
||||
takes guides.edit, because by then other people are following it.
|
||||
"""
|
||||
if _has('guides.edit'):
|
||||
return True
|
||||
if not _has('guides.create'):
|
||||
return False
|
||||
return guide.createdby == _userid() and not guide.publishedrevisionid
|
||||
|
||||
|
||||
def _slugify(title, guideid=None):
|
||||
"""A readable, stable, unique slug.
|
||||
|
||||
Stored rather than derived on read, so renaming a guide cannot break a link
|
||||
somebody pasted into a work order months ago.
|
||||
"""
|
||||
base = re.sub(r'[^a-z0-9]+', '-', (title or '').lower()).strip('-') or 'guide'
|
||||
base = base[:180] # room for a '-2' style suffix inside the 191 cap
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while True:
|
||||
clash = Guide.query.filter(Guide.slug == candidate)
|
||||
if guideid:
|
||||
clash = clash.filter(Guide.guideid != guideid)
|
||||
if not clash.first():
|
||||
return candidate
|
||||
candidate = '{}-{}'.format(base, suffix)
|
||||
suffix += 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reading ---
|
||||
|
||||
@guides_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_guides():
|
||||
"""Published guides, plus the caller's own drafts if they can author.
|
||||
|
||||
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
|
||||
own, or drafting is a trap door.
|
||||
"""
|
||||
caneditall = _has('guides.edit')
|
||||
mine = _userid()
|
||||
|
||||
# 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.
|
||||
# Searching the working copy for everyone leaks draft wording to readers:
|
||||
# they get a hit on a word that is not in the page when they open it.
|
||||
published = db.aliased(GuideRevision)
|
||||
query = Guide.query.outerjoin(
|
||||
published, Guide.publishedrevisionid == published.guiderevisionid)
|
||||
|
||||
search = (request.args.get('q') or '').strip()
|
||||
if search:
|
||||
like = '%{}%'.format(search)
|
||||
|
||||
def matches(title, summary, tags, blocks):
|
||||
# The body too: people look for "the one about the drive letter",
|
||||
# not for a title they never read.
|
||||
return db.or_(title.ilike(like), summary.ilike(like),
|
||||
tags.ilike(like), blocks.ilike(like))
|
||||
|
||||
working = matches(Guide.title, Guide.summary, Guide.tags, Guide.blocksjson)
|
||||
readable = matches(published.title, published.summary,
|
||||
published.tags, published.blocksjson)
|
||||
if caneditall:
|
||||
query = query.filter(db.or_(working, readable))
|
||||
else:
|
||||
query = query.filter(db.or_(
|
||||
readable,
|
||||
db.and_(Guide.createdby == mine, working),
|
||||
))
|
||||
|
||||
tag = (request.args.get('tag') or '').strip()
|
||||
if tag:
|
||||
like = '%{}%'.format(tag)
|
||||
if caneditall:
|
||||
query = query.filter(db.or_(Guide.tags.ilike(like),
|
||||
published.tags.ilike(like)))
|
||||
else:
|
||||
query = query.filter(db.or_(
|
||||
published.tags.ilike(like),
|
||||
db.and_(Guide.createdby == mine, Guide.tags.ilike(like)),
|
||||
))
|
||||
|
||||
if not caneditall:
|
||||
query = query.filter(db.or_(
|
||||
Guide.publishedrevisionid.isnot(None),
|
||||
Guide.createdby == mine,
|
||||
))
|
||||
|
||||
rows = query.order_by(Guide.modifieddate.desc()).all()
|
||||
|
||||
out = []
|
||||
for guide in rows:
|
||||
data = guide.to_dict()
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['checklistitems'] = checklistitemcount(guide.blocks)
|
||||
data['canedit'] = _mayedit(guide)
|
||||
|
||||
# A reader's card must describe the guide they will actually open. The
|
||||
# Guide row carries the working copy, so a retitled draft would list
|
||||
# under its new name and open under its old one.
|
||||
if not data['canedit'] and guide.publishedrevisionid:
|
||||
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
if revision is not None:
|
||||
data['title'] = revision.title
|
||||
data['summary'] = revision.summary
|
||||
data['tags'] = [t.strip() for t in (revision.tags or '').split(',')
|
||||
if t.strip()]
|
||||
data['checklistitems'] = checklistitemcount(revision.blocks)
|
||||
out.append(data)
|
||||
return success_response(out)
|
||||
|
||||
|
||||
@guides_bp.route('/tags', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_tags():
|
||||
"""Every tag in use, with a count, for the filter chips.
|
||||
|
||||
Readers count published tags only. A chip carrying a tag that exists solely
|
||||
on somebody's draft is both a leak and a dead end - filtering by it returns
|
||||
nothing a reader is allowed to see.
|
||||
"""
|
||||
counts = {}
|
||||
if _has('guides.edit'):
|
||||
pairs = db.session.query(Guide.tags).filter(Guide.tags.isnot(None)).all()
|
||||
else:
|
||||
published = db.aliased(GuideRevision)
|
||||
pairs = db.session.query(published.tags).join(
|
||||
Guide, Guide.publishedrevisionid == published.guiderevisionid
|
||||
).filter(published.tags.isnot(None)).all()
|
||||
mine = _userid()
|
||||
pairs += db.session.query(Guide.tags).filter(
|
||||
Guide.createdby == mine, Guide.tags.isnot(None)).all()
|
||||
for (tags,) in pairs:
|
||||
for tag in [t.strip() for t in tags.split(',') if t.strip()]:
|
||||
counts[tag] = counts.get(tag, 0) + 1
|
||||
return success_response([
|
||||
{'tag': tag, 'count': count}
|
||||
for tag, count in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0].lower()))
|
||||
])
|
||||
|
||||
|
||||
@guides_bp.route('/<slug>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_guide(slug):
|
||||
"""What a reader gets: the published revision.
|
||||
|
||||
An unpublished guide is 404 to anyone who cannot edit it - not 403, which
|
||||
would confirm it exists.
|
||||
"""
|
||||
guide = Guide.query.filter_by(slug=slug).first()
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
|
||||
revision = authoring.readable(guide)
|
||||
if revision is None:
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found',
|
||||
http_code=404)
|
||||
# An editor previewing their own unpublished draft.
|
||||
data = guide.to_dict(includeblocks=True)
|
||||
data['ispublished'] = False
|
||||
data['isdraftpreview'] = True
|
||||
data['canedit'] = True
|
||||
return success_response(data)
|
||||
|
||||
data = guide.to_dict()
|
||||
data.update({
|
||||
'title': revision.title,
|
||||
'summary': revision.summary,
|
||||
'tags': [t.strip() for t in (revision.tags or '').split(',') if t.strip()],
|
||||
'blocks': revision.blocks,
|
||||
'revision': revision.revision,
|
||||
'ispublished': True,
|
||||
'isdraftpreview': False,
|
||||
'canedit': _mayedit(guide),
|
||||
# So an editor can see there is newer unpublished work without leaving
|
||||
# the page.
|
||||
'hasunpublishedchanges': guide.revision != revision.revision,
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/working', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_working(guideid):
|
||||
"""The working copy, for the editor."""
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
data = guide.to_dict(includeblocks=True)
|
||||
data['ispublished'] = bool(guide.publishedrevisionid)
|
||||
data['canedit'] = True
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/revisions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def list_revisions(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
# History is editors' business. guides.view alone is permission to read what
|
||||
# was PUBLISHED; the revision list is every draft this guide has ever been,
|
||||
# including the ones deliberately not published yet.
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to see this history',
|
||||
http_code=403)
|
||||
rows = (GuideRevision.query.filter_by(guideid=guideid)
|
||||
.order_by(GuideRevision.revision.desc()).all())
|
||||
published = guide.publishedrevisionid
|
||||
return success_response([
|
||||
dict(row.to_dict(), ispublished=(row.guiderevisionid == published))
|
||||
for row in rows
|
||||
])
|
||||
|
||||
|
||||
@guides_bp.route('/revisions/<int:revisionid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def read_revision(revisionid):
|
||||
"""One revision by id.
|
||||
|
||||
Gated on its PARENT, not on guides.view alone. Revision ids are sequential
|
||||
integers, so an ungated route here is an invitation to walk 1..n and read
|
||||
every unpublished draft on the site - which is the opposite of what
|
||||
the GUIDES wiki page promises about drafts.
|
||||
|
||||
A reader may fetch the published revision (it is what the slug route would
|
||||
serve anyway); everything else takes edit rights on that guide.
|
||||
"""
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if revision is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
guide = db.session.get(Guide, revision.guideid)
|
||||
ispublished = bool(guide is not None
|
||||
and guide.publishedrevisionid == revision.guiderevisionid)
|
||||
if not ispublished and not (guide is not None and _mayedit(guide)):
|
||||
# 404, not 403: a draft's existence is itself the thing being withheld.
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
return success_response(revision.to_dict(includeblocks=True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- writing ---
|
||||
|
||||
@guides_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def create_guide():
|
||||
data = request.get_json(silent=True) or {}
|
||||
title = (data.get('title') or '').strip()
|
||||
if not title:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
|
||||
|
||||
guide = Guide(title=title, slug=_slugify(title), status='draft',
|
||||
createdby=_userid(), updatedby=_userid())
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
|
||||
try:
|
||||
authoring.save(guide,
|
||||
title=title,
|
||||
summary=data.get('summary', ''),
|
||||
tags=data.get('tags', ''),
|
||||
blocks=data.get('blocks', []),
|
||||
note=data.get('note'),
|
||||
userid=_userid())
|
||||
except BlockError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Guide created')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def update_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
if 'title' in data and not (data.get('title') or '').strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'A guide needs a title')
|
||||
|
||||
try:
|
||||
guide, revision = authoring.save(
|
||||
guide,
|
||||
title=data.get('title'),
|
||||
summary=data.get('summary'),
|
||||
tags=data.get('tags'),
|
||||
blocks=data.get('blocks'),
|
||||
note=data.get('note'),
|
||||
userid=_userid())
|
||||
except BlockError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
|
||||
# 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
|
||||
# break a link somebody pasted into a work order months ago - and then this
|
||||
# re-slugged on every save that carried a title, which the editor's 3-second
|
||||
# autosave does on every keystroke. Fixing a typo in a heading silently
|
||||
# moved the guide's URL and broke exactly the links the stored slug exists
|
||||
# to protect. A guide with no slug (older row, or a failed create) still
|
||||
# gets one here.
|
||||
if not guide.slug:
|
||||
guide.slug = _slugify(guide.title, guide.guideid)
|
||||
|
||||
db.session.commit()
|
||||
result = guide.to_dict(includeblocks=True)
|
||||
# 'unchanged' is a real outcome, not a failure: an autosave where the author
|
||||
# changed nothing must not manufacture a version.
|
||||
result['saved'] = revision is not None
|
||||
return success_response(result,
|
||||
message='Saved' if revision else 'No changes to save')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def publish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
try:
|
||||
revision = authoring.publish(guide, userid=_userid())
|
||||
except ValueError as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(),
|
||||
message='Published version {}'.format(revision.revision))
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/unpublish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.publish')
|
||||
def unpublish_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
authoring.unpublish(guide, userid=_userid())
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(), message='Withdrawn from readers')
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>/restore/<int:revisionid>', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def restore_revision(guideid, revisionid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
revision = db.session.get(GuideRevision, revisionid)
|
||||
if guide is None or revision is None or revision.guideid != guideid:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Revision not found',
|
||||
http_code=404)
|
||||
if not _mayedit(guide):
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
authoring.restore(guide, revision, userid=_userid())
|
||||
db.session.commit()
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Restored version {} as a new draft version'
|
||||
.format(revision.revision))
|
||||
|
||||
|
||||
@guides_bp.route('/<int:guideid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.delete')
|
||||
def delete_guide(guideid):
|
||||
guide = db.session.get(Guide, guideid)
|
||||
if guide is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Guide not found', http_code=404)
|
||||
db.session.delete(guide)
|
||||
db.session.commit()
|
||||
return success_response({'guideid': guideid}, message='Guide deleted')
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- images ---
|
||||
|
||||
@guides_bp.route('/images', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.create')
|
||||
def upload_image():
|
||||
"""Take a pasted or dropped image.
|
||||
|
||||
Unattached at first: an image is pasted into a NEW guide before that guide
|
||||
has an id. The editor attaches it on save; anything never attached is
|
||||
orphaned and prunable, which is a tidier problem than refusing to accept a
|
||||
paste until the author has saved.
|
||||
"""
|
||||
upload = request.files.get('file')
|
||||
if upload is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
|
||||
mimetype = (upload.mimetype or '').lower()
|
||||
if mimetype not in ALLOWEDIMAGETYPES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unsupported image type {!r}. Allowed: {}'.format(
|
||||
mimetype, ', '.join(sorted(ALLOWEDIMAGETYPES))))
|
||||
|
||||
data = upload.read()
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'That file is empty')
|
||||
if len(data) > MAXIMAGEBYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'That image is {:.1f} MB; the limit is {} MB'.format(
|
||||
len(data) / 1048576, MAXIMAGEBYTES // 1048576))
|
||||
|
||||
guideid = request.form.get('guideid', type=int)
|
||||
image = GuideImage(guideid=guideid, filename=upload.filename,
|
||||
mimetype=mimetype, bytesize=len(data), data=data,
|
||||
uploadedby=_userid())
|
||||
db.session.add(image)
|
||||
db.session.commit()
|
||||
return success_response(image.to_dict(), message='Image uploaded')
|
||||
|
||||
|
||||
def _mayseeimage(image):
|
||||
"""Whether the caller may fetch this image.
|
||||
|
||||
An image is content. One pasted into a published guide must be readable by
|
||||
anyone who can read that guide; one pasted into a draft must not be, or the
|
||||
draft leaks a screenshot at a time to anyone willing to count upwards
|
||||
through image ids.
|
||||
|
||||
So: editors of the parent see it, and everyone else sees it only if the
|
||||
PUBLISHED revision actually references it. An unattached image - pasted
|
||||
before the guide was first saved - belongs to whoever uploaded it.
|
||||
"""
|
||||
if image.guideid is None:
|
||||
return _has('guides.edit') or image.uploadedby == _userid()
|
||||
|
||||
guide = db.session.get(Guide, image.guideid)
|
||||
if guide is None:
|
||||
return _has('guides.edit')
|
||||
if _mayedit(guide):
|
||||
return True
|
||||
if not guide.publishedrevisionid:
|
||||
return False
|
||||
|
||||
revision = db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
if revision is None:
|
||||
return False
|
||||
try:
|
||||
blocks = json.loads(revision.blocksjson or '[]')
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
needle = '/guides/images/{}'.format(image.guideimageid)
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict) or block.get('type') != 'image':
|
||||
continue
|
||||
if str(block.get('imageid') or '') == str(image.guideimageid):
|
||||
return True
|
||||
if needle in str(block.get('url') or ''):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@guides_bp.route('/images/<int:imageid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('guides.view')
|
||||
def serve_image(imageid):
|
||||
image = db.session.get(GuideImage, imageid)
|
||||
if image is None:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
if not _mayseeimage(image):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Image not found', http_code=404)
|
||||
# Same inert-document headers every other upload route serves under: an
|
||||
# uploaded file is attacker-influenced content and must not be able to run
|
||||
# as a document in this origin.
|
||||
return harden_upload_response(Response(image.data, mimetype=image.mimetype, headers={
|
||||
# Immutable: an image is never edited in place, only replaced by a new
|
||||
# upload with a new id, so it can be cached hard.
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
'Content-Disposition': 'inline; filename="{}"'.format(
|
||||
image.filename or 'image{}'.format(imageid)),
|
||||
}))
|
||||
286
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
286
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
||||
<template v-for="(block, index) in blocks" :key="block.id || index">
|
||||
|
||||
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
||||
v-html="inline(block.text)"></h2>
|
||||
|
||||
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
||||
v-html="inline(block.text)"></p>
|
||||
|
||||
<div v-else-if="block.type === 'step'" class="gb-step">
|
||||
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
|
||||
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
||||
<div v-if="block.text" v-html="inline(block.text)"></div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The reason this feature exists. Interactive ONLY when viewing: in the
|
||||
editor a tick would be an edit, and an author clicking around their own
|
||||
draft would quietly change what the next reader sees as "done".
|
||||
-->
|
||||
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
|
||||
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
:checked="isTicked(item.id)"
|
||||
:disabled="!interactive"
|
||||
@change="toggle(item.id, $event.target.checked)">
|
||||
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
|
||||
v-html="inline(item.text)"></span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul v-else-if="block.type === 'bullets'" class="gb-list">
|
||||
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
||||
</ul>
|
||||
|
||||
<ol v-else-if="block.type === 'numbers'" class="gb-list">
|
||||
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
||||
</ol>
|
||||
|
||||
<div v-else-if="block.type === 'callout'"
|
||||
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
|
||||
<strong v-if="block.title">{{ block.title }}</strong>
|
||||
<div v-html="inline(block.text)"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="block.type === 'code'" class="gb-code">
|
||||
<div class="gb-codehead">
|
||||
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
||||
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
||||
<!--
|
||||
SELECTS the code; it does not write to the clipboard.
|
||||
|
||||
A page that programmatically copies a shell command is the exact
|
||||
shape of a ClickFix attack - malware that plants a command in your
|
||||
clipboard and talks you into pasting it into Run. uBlock Origin
|
||||
blocks it, and it was right to: our button wrote PowerShell to the
|
||||
clipboard on click.
|
||||
|
||||
Selecting the text and letting the person press Ctrl+C keeps the
|
||||
convenience, moves the decision back to them, and gives security
|
||||
tooling nothing to object to. It also selects the code ONLY - no
|
||||
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
|
||||
terminal.
|
||||
-->
|
||||
<button type="button" class="gb-copy" @click="select(block)">
|
||||
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The image is fetched by script, not by the browser.
|
||||
|
||||
An <img src> is a plain browser request: it carries no Authorization
|
||||
header, and this app keeps its JWT in one. So a direct src against the
|
||||
authenticated image route is a 401 and a broken-image icon for every
|
||||
reader - which is exactly how this shipped. Fetching through the api
|
||||
client sends the token, and it resolves against the client's baseURL, so
|
||||
it also survives a subpath mount that a stored '/api/...' string does
|
||||
not.
|
||||
|
||||
A failure says so in words. A guide whose screenshot is missing must not
|
||||
look like a guide that never had one.
|
||||
-->
|
||||
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
||||
<img v-if="imageState(block).src" :src="imageState(block).src"
|
||||
:alt="block.alt || ''">
|
||||
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
|
||||
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
|
||||
</p>
|
||||
<p v-else class="gb-imageloading">Loading image...</p>
|
||||
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
||||
</figure>
|
||||
|
||||
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
|
||||
<table class="gb-table">
|
||||
<thead v-if="block.header && block.header.length">
|
||||
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, r) in (block.rows || [])" :key="r">
|
||||
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- An unknown type renders as nothing rather than as an error. A guide
|
||||
written against a newer version must stay readable on an older one. -->
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, toRef } from 'vue'
|
||||
import { renderInline } from '../utils/inline.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
|
||||
const props = defineProps({
|
||||
blocks: { type: Array, default: () => [] },
|
||||
// Live checkboxes belong to the viewer only.
|
||||
interactive: { type: Boolean, default: false },
|
||||
ticked: { type: Object, default: () => ({}) },
|
||||
})
|
||||
const emit = defineEmits(['toggle'])
|
||||
|
||||
const selected = ref('')
|
||||
const codeNodes = {}
|
||||
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
||||
|
||||
// Images are fetched by script, not by <img src>; see utils/images.js.
|
||||
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
|
||||
|
||||
function inline (text) {
|
||||
return renderInline(text)
|
||||
}
|
||||
|
||||
// Items may be plain strings (bullets) or objects with ids (checklists). One
|
||||
// shape here so the template does not branch on it.
|
||||
function itemsOf (block) {
|
||||
return (block.items || []).map((item, index) =>
|
||||
typeof item === 'string'
|
||||
? { id: (block.id || 'b') + '-' + index, text: item }
|
||||
: item)
|
||||
}
|
||||
|
||||
function isTicked (itemid) {
|
||||
return Boolean(props.ticked[itemid])
|
||||
}
|
||||
|
||||
function toggle (itemid, checked) {
|
||||
if (!props.interactive) return
|
||||
emit('toggle', itemid, checked)
|
||||
}
|
||||
|
||||
// Steps number themselves by position among steps, so inserting a paragraph
|
||||
// between two of them does not renumber anything by hand.
|
||||
const stepIndex = computed(() => {
|
||||
const map = {}
|
||||
let n = 0
|
||||
props.blocks.forEach((block, index) => {
|
||||
if (block.type === 'step') { n += 1; map[index] = n }
|
||||
})
|
||||
return map
|
||||
})
|
||||
function stepNumber (index) {
|
||||
return stepIndex.value[index] || 1
|
||||
}
|
||||
|
||||
function anchorFor (block) {
|
||||
return 'g-' + (block.id || '')
|
||||
}
|
||||
|
||||
function select (block) {
|
||||
const node = codeNodes[block.id]
|
||||
if (!node || !window.getSelection) return
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
selected.value = block.id
|
||||
setTimeout(() => { selected.value = '' }, 4000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Every colour is a theme token. Nothing here is a literal, which is what makes
|
||||
dark mode work with no dark-mode code. */
|
||||
.gb-blocks { line-height: 1.65; }
|
||||
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
||||
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
||||
|
||||
.gb-step {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding: 0.6rem 0 0.6rem 1rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.gb-stepnum {
|
||||
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
|
||||
}
|
||||
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
|
||||
|
||||
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
|
||||
.gb-check { margin: 0.35rem 0; }
|
||||
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
|
||||
.gb-interactive .gb-check label:hover { color: var(--primary); }
|
||||
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
|
||||
.gb-done { opacity: 0.55; text-decoration: line-through; }
|
||||
|
||||
.gb-list { margin: 0 0 1rem 1.2rem; }
|
||||
.gb-list li { margin: 0.25rem 0; }
|
||||
|
||||
.gb-callout {
|
||||
border-left: 4px solid var(--info, var(--primary));
|
||||
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
||||
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
|
||||
}
|
||||
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
|
||||
.gb-warning { border-left-color: var(--warning, #d9822b); }
|
||||
.gb-success { border-left-color: var(--success, #2e9e5b); }
|
||||
|
||||
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
||||
.gb-codehead {
|
||||
display: flex; align-items: center; gap: 0.6rem;
|
||||
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
|
||||
border-bottom: 1px solid var(--border); font-size: 0.75rem;
|
||||
}
|
||||
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
|
||||
.gb-caption { color: var(--text-light); }
|
||||
.gb-copy {
|
||||
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.gb-code pre {
|
||||
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
|
||||
/* WRAP rather than clip: these guides are full of long UNC paths, and a
|
||||
line that scrolls out of sight is a command somebody copies half of. */
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
|
||||
.gb-figure { margin: 0 0 1.2rem; }
|
||||
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
||||
.gb-imageloading, .gb-imagefailed {
|
||||
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
||||
border-radius: 4px; font-size: 0.9rem;
|
||||
}
|
||||
.gb-imageloading { color: var(--textmuted); }
|
||||
.gb-imagefailed { color: var(--danger, #b3261e); }
|
||||
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
||||
|
||||
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
||||
.gb-table { border-collapse: collapse; width: 100%; }
|
||||
.gb-table th, .gb-table td {
|
||||
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
|
||||
vertical-align: top; font-size: 0.9rem;
|
||||
}
|
||||
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
|
||||
|
||||
@media print {
|
||||
/* Paper has no hover, no clipboard and no theme. The light-mode token
|
||||
overrides live in GuideView.vue, which can reach :root; this block is only
|
||||
about what belongs on paper. */
|
||||
.gb-copy { display: none; }
|
||||
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
|
||||
|
||||
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
|
||||
a printed guide is a worksheet somebody carries to the bay and ticks by
|
||||
hand, and one person's progress is not part of the procedure. */
|
||||
.gb-done { opacity: 1; text-decoration: none; }
|
||||
.gb-check input { appearance: none; -webkit-appearance: none;
|
||||
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
|
||||
border-radius: 2px; background: #fff; }
|
||||
|
||||
.gb-code pre { white-space: pre-wrap; }
|
||||
.gb-stepnum { color: #000; }
|
||||
}
|
||||
</style>
|
||||
42
plugins/guides/frontend/routes.js
Normal file
42
plugins/guides/frontend/routes.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Guides plugin routes.
|
||||
*
|
||||
* Three views, and the split between the last two is deliberate: reading a
|
||||
* guide and editing one are different jobs with different rights, and the
|
||||
* checkboxes are live in exactly one of them.
|
||||
*
|
||||
* 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
|
||||
* is behind JWT plus a guides.* permission.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'guides',
|
||||
name: 'guides',
|
||||
component: () => import('./views/GuidesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/new',
|
||||
name: 'guide-new',
|
||||
component: () => import('./views/GuideEditor.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/:slug',
|
||||
name: 'guide-view',
|
||||
component: () => import('./views/GuideView.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
},
|
||||
{
|
||||
path: 'guides/:guideid/edit',
|
||||
name: 'guide-edit',
|
||||
component: () => import('./views/GuideEditor.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'guides' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view renders inside AppLayout. Declared so
|
||||
// routes.gen.js can read `.toplevel` off the module without Rollup warning
|
||||
// about a missing export.
|
||||
export const toplevel = []
|
||||
77
plugins/guides/frontend/utils/images.js
Normal file
77
plugins/guides/frontend/utils/images.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Resolving guide images.
|
||||
*
|
||||
* A guide image lives behind an authenticated route, and an <img src> is a
|
||||
* plain browser request that carries no Authorization header - this app keeps
|
||||
* its JWT in one. Pointing src at the image route therefore yields 401 and a
|
||||
* broken-image icon for every reader, which is how guides first shipped.
|
||||
*
|
||||
* So the bytes are fetched through the api client, which attaches the token and
|
||||
* resolves against its own baseURL - the latter also fixing a stored
|
||||
* '/api/guides/images/N' string under a subpath mount, where the API is not at
|
||||
* /api at all.
|
||||
*
|
||||
* Used by the renderer AND the editor: an author needs to see the screenshot
|
||||
* they just pasted as much as a reader does.
|
||||
*/
|
||||
import { reactive, watch, onBeforeUnmount } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
/** The image id a block refers to, whichever way it was written.
|
||||
*
|
||||
* Blocks store imageid (written since this was fixed) or a url string (written
|
||||
* before it). The url is never used as a src, only read for its id.
|
||||
*/
|
||||
export function imageIdOf (block) {
|
||||
if (!block) return ''
|
||||
if (block.imageid) return String(block.imageid)
|
||||
const match = /\/guides\/images\/(\d+)/.exec(block.url || '')
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps object URLs for the image blocks in `blocksRef`, fetching each id once
|
||||
* however many blocks use it, and revoking them as blocks go away.
|
||||
*
|
||||
* Returns `state(block)` -> { src, failed }.
|
||||
*/
|
||||
export function useGuideImages (blocksRef) {
|
||||
const images = reactive({})
|
||||
|
||||
async function load (id) {
|
||||
if (images[id]) return
|
||||
images[id] = { src: '', failed: false }
|
||||
try {
|
||||
const response = await api.get(`/guides/images/${id}`, { responseType: 'blob' })
|
||||
images[id].src = URL.createObjectURL(response.data)
|
||||
} catch (err) {
|
||||
images[id].failed = true
|
||||
}
|
||||
}
|
||||
|
||||
function release (id) {
|
||||
if (images[id] && images[id].src) URL.revokeObjectURL(images[id].src)
|
||||
delete images[id]
|
||||
}
|
||||
|
||||
function state (block) {
|
||||
const id = imageIdOf(block)
|
||||
if (!id) return { src: '', failed: true }
|
||||
return images[id] || { src: '', failed: false }
|
||||
}
|
||||
|
||||
// Editing replaces the block list on every keystroke, so resolve only ids
|
||||
// that are new and keep the ones already held. Without that, every keystroke
|
||||
// would refetch every image in the guide.
|
||||
watch(blocksRef, blocks => {
|
||||
const wanted = new Set(
|
||||
(blocks || []).filter(b => b && b.type === 'image')
|
||||
.map(imageIdOf).filter(Boolean))
|
||||
wanted.forEach(load)
|
||||
Object.keys(images).forEach(id => { if (!wanted.has(id)) release(id) })
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
onBeforeUnmount(() => { Object.keys(images).forEach(release) })
|
||||
|
||||
return { state }
|
||||
}
|
||||
106
plugins/guides/frontend/utils/inline.js
Normal file
106
plugins/guides/frontend/utils/inline.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Inline text rendering for guide blocks.
|
||||
*
|
||||
* A guide stores its text as a RESTRICTED Markdown subset - bold, italic,
|
||||
* inline code, links, line breaks - and nothing else. This turns that into
|
||||
* HTML.
|
||||
*
|
||||
* ESCAPE FIRST, THEN FORMAT. The order is the whole security model: every
|
||||
* angle bracket and ampersand becomes an entity before any pattern is applied,
|
||||
* so no markup an author pasted can survive into the DOM. There is no path here
|
||||
* that emits a tag the code below did not write itself.
|
||||
*
|
||||
* The restriction is not only about safety. Pasted rich text carries fonts and
|
||||
* hardcoded colours, which then fight the theme - black text on a dark
|
||||
* background, and nobody can say why. Emphasis is semantic; the stylesheet
|
||||
* decides how it looks. That is what makes dark mode work without an author
|
||||
* thinking about it, and why a guide written next year still matches one
|
||||
* written today.
|
||||
*/
|
||||
|
||||
function escapeHtml (text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// The text was HTML-escaped before any pattern ran, so a captured URL arrives
|
||||
// carrying entities: a real query string like ?a=1&b=2 reaches here as
|
||||
// ?a=1&b=2. Escaping that again for the attribute produced &amp; and a
|
||||
// link that opened the wrong record - the ServiceNow and SharePoint links that
|
||||
// carry several parameters were all broken this way. Decode first, then let the
|
||||
// single escape on output put back exactly one level.
|
||||
function decodeEntities (text) {
|
||||
return String(text)
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&') // last, so &lt; does not become <
|
||||
}
|
||||
|
||||
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
|
||||
// an author could otherwise smuggle script past the escaping above.
|
||||
function safeHref (url) {
|
||||
const trimmed = String(url || '').trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
|
||||
// In-app link. NOT '//host', which looks like a path and is read by the
|
||||
// browser as protocol-relative - an off-site link wearing a local costume.
|
||||
if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return trimmed
|
||||
return null
|
||||
}
|
||||
|
||||
export function renderInline (text) {
|
||||
let html = escapeHtml(text)
|
||||
|
||||
// Inline code first, so formatting characters inside `code` stay literal -
|
||||
// a path like C:\**temp** should not turn half of itself bold.
|
||||
const codeSpans = []
|
||||
html = html.replace(/`([^`]+)`/g, (match, code) => {
|
||||
codeSpans.push(code)
|
||||
return '\u0000CODE' + (codeSpans.length - 1) + '\u0000'
|
||||
})
|
||||
|
||||
// [label](url). The URL pattern allows ONE level of balanced parentheses so
|
||||
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
|
||||
// and a naive [^)]+ silently truncates it into a broken link.
|
||||
// Finished links are stashed like code spans, so the emphasis passes below
|
||||
// cannot reach inside an href. Without that, a URL containing *characters*
|
||||
// has <em> injected into the middle of the attribute and the link breaks.
|
||||
const links = []
|
||||
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
|
||||
const href = safeHref(decodeEntities(url))
|
||||
if (!href) return label
|
||||
const external = /^https?:/i.test(href)
|
||||
links.push('<a href="' + escapeHtml(href) + '"' +
|
||||
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
|
||||
'>' + label + '</a>')
|
||||
return '\u0000LINK' + (links.length - 1) + '\u0000'
|
||||
})
|
||||
|
||||
// **bold** then *italic*, longest marker first so **x** is not read as
|
||||
// *(*x*)*.
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
|
||||
|
||||
html = html.replace(/\n/g, '<br>')
|
||||
|
||||
html = html.replace(/\u0000LINK(\d+)\u0000/g, (match, index) => links[Number(index)])
|
||||
|
||||
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
|
||||
'<code>' + codeSpans[Number(index)] + '</code>')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
/** Plain text, for titles and anywhere markup would be noise. */
|
||||
export function stripInline (text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
}
|
||||
50
plugins/guides/frontend/utils/inline.spec.js
Normal file
50
plugins/guides/frontend/utils/inline.spec.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Inline rendering: the security model and the link bugs it hid.
|
||||
*
|
||||
* Escape-then-format is the whole defence, and it has one awkward consequence
|
||||
* that produced real broken links: by the time a URL is captured it is already
|
||||
* escaped, so it has to be decoded before it can be escaped again exactly once.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderInline } from './inline.js'
|
||||
|
||||
describe('renderInline', () => {
|
||||
it('escapes markup an author pasted', () => {
|
||||
expect(renderInline('<img src=x onerror=alert(1)>'))
|
||||
.toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
|
||||
it('keeps a multi-parameter URL intact', () => {
|
||||
// Double-escaped, this became &amp; and the link opened the wrong
|
||||
// record. Every ServiceNow and SharePoint link carries several parameters.
|
||||
const html = renderInline('[ticket](https://x.example/nav?a=1&b=2&c=3)')
|
||||
expect(html).toContain('href="https://x.example/nav?a=1&b=2&c=3"')
|
||||
expect(html).not.toContain('&amp;')
|
||||
})
|
||||
|
||||
it('does not let emphasis reach inside an href', () => {
|
||||
const html = renderInline('[t](https://x.example/a*b*c)')
|
||||
expect(html).toContain('href="https://x.example/a*b*c"')
|
||||
expect(html).not.toContain('<em>')
|
||||
})
|
||||
|
||||
it('refuses a protocol-relative URL dressed as an in-app path', () => {
|
||||
// '//evil.example' starts with '/' but the browser reads it as off-site.
|
||||
expect(renderInline('[evil](//evil.example/x)')).toBe('evil')
|
||||
})
|
||||
|
||||
it('refuses javascript: and keeps the label', () => {
|
||||
expect(renderInline('[bad](javascript:alert(1))')).toBe('bad')
|
||||
})
|
||||
|
||||
it('allows an in-app link', () => {
|
||||
expect(renderInline('[map](/map)')).toBe('<a href="/map">map</a>')
|
||||
})
|
||||
|
||||
it('still renders bold, italic and code', () => {
|
||||
expect(renderInline('**b**')).toBe('<strong>b</strong>')
|
||||
expect(renderInline('*i*')).toBe('<em>i</em>')
|
||||
expect(renderInline('`C:\\**temp**`'))
|
||||
.toBe('<code>C:\\**temp**</code>')
|
||||
})
|
||||
})
|
||||
145
plugins/guides/frontend/utils/proof.js
Normal file
145
plugins/guides/frontend/utils/proof.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Proofing: the mistakes that matter in a PROCEDURE, not a document.
|
||||
*
|
||||
* A spellchecker catches "teh". None of these are spelling, and every one has
|
||||
* been seen for real:
|
||||
*
|
||||
* - a smart quote in a command, so the command does not run
|
||||
* - a checklist item with no text, so a step is silently missing
|
||||
* - a heading with nothing under it, left when a draft was reorganised
|
||||
* - an image with no alt text, invisible to anyone who cannot see it
|
||||
* - a TODO that was never finished, published anyway
|
||||
*
|
||||
* Warnings, not blocks. A person publishing a guide at 14:40 with one empty
|
||||
* bullet should be told, not stopped - the tool does not know that the bullet is
|
||||
* deliberate, and a procedure that exists imperfectly beats one nobody wrote.
|
||||
*/
|
||||
|
||||
// Characters an editor or a word processor substitutes silently. In prose they
|
||||
// are harmless; in a command they are the difference between working and a
|
||||
// syntax error nobody can explain.
|
||||
const SMARTCHARS = [
|
||||
// Written as escapes, not literals: the repo's style gate forbids non-ASCII in
|
||||
// source, and a file whose job is detecting smart quotes cannot contain any.
|
||||
// The escapes are also unambiguous in a diff, where a curly quote and a
|
||||
// straight one look identical.
|
||||
['\u2018', "'"], ['\u2019', "'"], // single quotes
|
||||
['\u201C', '"'], ['\u201D', '"'], // double quotes
|
||||
['\u2013', '-'], ['\u2014', '-'], // en dash, em dash
|
||||
['\u00A0', ' '], // non-breaking space
|
||||
]
|
||||
|
||||
const UNFINISHED = /\b(TODO|TBD|FIXME|XXX|\?\?\?)\b/i
|
||||
|
||||
function issue (level, blockid, message, fix) {
|
||||
return { level, blockid, message, fix: fix || null }
|
||||
}
|
||||
|
||||
export function proof (guide, blocks) {
|
||||
const issues = []
|
||||
|
||||
if (!guide.title || !guide.title.trim()) {
|
||||
issues.push(issue('error', null, 'This guide has no title'))
|
||||
}
|
||||
if (!guide.summary || !guide.summary.trim()) {
|
||||
issues.push(issue('warn', null,
|
||||
'No summary. It is what people read in the list and in search results.'))
|
||||
}
|
||||
if (!guide.tags || !guide.tags.trim()) {
|
||||
issues.push(issue('warn', null, 'No tags, so this will be hard to find'))
|
||||
}
|
||||
if (!blocks.length) {
|
||||
issues.push(issue('error', null, 'This guide is empty'))
|
||||
return issues
|
||||
}
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const where = 'block ' + (index + 1) + ' (' + block.type + ')'
|
||||
|
||||
if (block.type === 'code') {
|
||||
// The important one. An autocorrected quote in a command looks right and
|
||||
// does not run.
|
||||
//
|
||||
// Reported ONCE per block, however many offending characters it has: a
|
||||
// command with an opening and a closing smart quote is one problem with
|
||||
// one fix, and listing it twice makes the panel look like it is nagging.
|
||||
const found = SMARTCHARS.filter(([bad]) => (block.text || '').includes(bad))
|
||||
if (found.length) {
|
||||
issues.push(issue('error', block.id,
|
||||
where + ': contains ' + found.map(([, good]) => 'a curly or long ' +
|
||||
(good === "'" ? 'quote' : good === '"' ? 'double quote' :
|
||||
good === '-' ? 'dash' : 'space')).filter(
|
||||
(v, i, a) => a.indexOf(v) === i).join(' and ') +
|
||||
' where a command needs the plain character. Autocorrect does this, ' +
|
||||
'and the command will not run.',
|
||||
{ type: 'replaceAll', pairs: found }))
|
||||
}
|
||||
if (!(block.text || '').trim()) {
|
||||
issues.push(issue('error', block.id, where + ' is empty'))
|
||||
}
|
||||
} else if (['heading', 'paragraph', 'callout'].includes(block.type)) {
|
||||
if (!(block.text || '').trim() && !(block.title || '').trim()) {
|
||||
issues.push(issue('warn', block.id, where + ' is empty'))
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'step' && !(block.title || '').trim()
|
||||
&& !(block.text || '').trim()) {
|
||||
issues.push(issue('warn', block.id, where + ' has no title and no text'))
|
||||
}
|
||||
|
||||
if (['checklist', 'bullets', 'numbers'].includes(block.type)) {
|
||||
const empties = (block.items || []).filter(
|
||||
item => !String(typeof item === 'string' ? item : item.text || '').trim())
|
||||
if (empties.length) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': ' + empties.length + ' empty item' +
|
||||
(empties.length > 1 ? 's' : '') +
|
||||
'. An empty step reads as a step somebody forgot to write.'))
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'image' && !(block.alt || '').trim()) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': no alt text. Anyone who cannot see the image gets nothing.'))
|
||||
}
|
||||
|
||||
if (block.type === 'table' && !(block.rows || []).length) {
|
||||
issues.push(issue('warn', block.id, where + ' has a header but no rows'))
|
||||
}
|
||||
|
||||
const text = [block.text, block.title].filter(Boolean).join(' ')
|
||||
if (UNFINISHED.test(text)) {
|
||||
issues.push(issue('warn', block.id,
|
||||
where + ': still contains ' + text.match(UNFINISHED)[0]))
|
||||
}
|
||||
})
|
||||
|
||||
// A heading immediately followed by another heading usually means the section
|
||||
// under the first one was moved or never written.
|
||||
blocks.forEach((block, index) => {
|
||||
if (block.type !== 'heading') return
|
||||
const next = blocks[index + 1]
|
||||
if (!next || next.type === 'heading') {
|
||||
issues.push(issue('warn', block.id,
|
||||
'Heading "' + (block.text || '').slice(0, 40) + '" has nothing under it'))
|
||||
}
|
||||
})
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/** Apply a suggested fix in place, for the ones where the answer is obvious. */
|
||||
export function applyFix (block, fix) {
|
||||
if (!fix) return false
|
||||
const before = block.text || ''
|
||||
let after = before
|
||||
if (fix.type === 'replaceAll') {
|
||||
for (const [bad, good] of fix.pairs) after = after.split(bad).join(good)
|
||||
} else if (fix.type === 'replace') {
|
||||
after = after.split(fix.from).join(fix.to)
|
||||
}
|
||||
if (after === before) return false
|
||||
block.text = after
|
||||
return true
|
||||
}
|
||||
74
plugins/guides/frontend/utils/templates.js
Normal file
74
plugins/guides/frontend/utils/templates.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Starting points for a new guide.
|
||||
*
|
||||
* The blank page is the real barrier to somebody writing a procedure down. A
|
||||
* skeleton with the right SHAPE - what you need first, the steps, how you know
|
||||
* it worked, what to do when it does not - gets a half-decent guide out of
|
||||
* somebody who would otherwise have written nothing.
|
||||
*
|
||||
* The shape is lifted from the KB articles the team already writes, so guides
|
||||
* written here read like the ones written before them.
|
||||
*/
|
||||
|
||||
function id (prefix, n) {
|
||||
return prefix + n
|
||||
}
|
||||
|
||||
export const templates = [
|
||||
{
|
||||
key: 'blank',
|
||||
name: 'Blank',
|
||||
description: 'Start with nothing',
|
||||
blocks: () => [],
|
||||
},
|
||||
{
|
||||
key: 'procedure',
|
||||
name: 'Procedure',
|
||||
description: 'Before you start, numbered steps, how to check it worked',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph',
|
||||
text: 'What this covers, and when somebody would need it.' },
|
||||
{ id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start',
|
||||
text: 'Anything that has to be true first, or that will bite halfway through.' },
|
||||
{ id: id('t', 3), type: 'heading', text: 'Steps' },
|
||||
{ id: id('t', 4), type: 'step', title: 'First step', text: '' },
|
||||
{ id: id('t', 5), type: 'step', title: 'Second step', text: '' },
|
||||
{ id: id('t', 6), type: 'heading', text: 'Check it worked' },
|
||||
{ id: id('t', 7), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'troubleshooting',
|
||||
name: 'Troubleshooting',
|
||||
description: 'Symptom, likely causes, what to check first',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' },
|
||||
{ id: id('t', 2), type: 'heading', text: 'Check these first' },
|
||||
{ id: id('t', 3), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
] },
|
||||
{ id: id('t', 4), type: 'heading', text: 'Likely causes' },
|
||||
{ id: id('t', 5), type: 'table',
|
||||
header: ['What you see', 'What it usually means'], rows: [['', '']] },
|
||||
{ id: id('t', 6), type: 'callout', variant: 'info',
|
||||
text: 'The thing that looks like the cause and is not.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'checklist',
|
||||
name: 'Checklist',
|
||||
description: 'A list to work through, nothing else',
|
||||
blocks: () => [
|
||||
{ id: id('t', 1), type: 'paragraph', text: 'When to use this list.' },
|
||||
{ id: id('t', 2), type: 'checklist', items: [
|
||||
{ id: id('ti', 1), text: '' },
|
||||
{ id: id('ti', 2), text: '' },
|
||||
{ id: id('ti', 3), text: '' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
]
|
||||
717
plugins/guides/frontend/views/GuideEditor.vue
Normal file
717
plugins/guides/frontend/views/GuideEditor.vue
Normal file
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<div class="ge" @paste="onPaste" @dragover.prevent @drop.prevent="onDrop">
|
||||
<div class="page-header">
|
||||
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
|
||||
<div class="ge-actions">
|
||||
<span class="ge-status">{{ status }}</span>
|
||||
<button type="button" class="ge-btn" @click="showPreview = !showPreview">
|
||||
{{ showPreview ? 'Hide preview' : 'Preview' }}
|
||||
</button>
|
||||
<button type="button" class="ge-btn" @click="save()" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<button v-if="!isNew && canpublish" type="button" class="ge-btn ge-primary"
|
||||
@click="publish" :disabled="saving">
|
||||
{{ guide.ispublished ? 'Publish changes' : 'Publish' }}
|
||||
</button>
|
||||
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
|
||||
class="ge-btn" @click="unpublish">Withdraw</button>
|
||||
<router-link v-if="!isNew && guide.slug" class="ge-btn"
|
||||
:to="{ name: 'guide-view', params: { slug: guide.slug } }">View</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="ge-fields">
|
||||
<input v-model="guide.title" type="text" class="ge-title"
|
||||
placeholder="Guide title">
|
||||
<input v-model="guide.summary" type="text"
|
||||
placeholder="One line: what this covers and when somebody needs it">
|
||||
<input v-model="guide.tags" type="text"
|
||||
placeholder="Tags, comma separated - e.g. partmarker, backups">
|
||||
</div>
|
||||
|
||||
<!-- The blank page is the barrier, so offer a shape rather than an empty
|
||||
canvas. Only on a guide that has nothing in it yet. -->
|
||||
<div v-if="!blocks.length" class="ge-templates">
|
||||
<span>Start from:</span>
|
||||
<button v-for="tpl in templates" :key="tpl.key" type="button"
|
||||
class="ge-tpl" @click="useTemplate(tpl)">
|
||||
<strong>{{ tpl.name }}</strong>
|
||||
<small>{{ tpl.description }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Proofing: warnings, never a block. Somebody publishing at 14:40 with one
|
||||
empty bullet should be told, not stopped - the tool does not know the
|
||||
bullet is deliberate, and a procedure that exists imperfectly beats one
|
||||
nobody wrote.
|
||||
-->
|
||||
<div v-if="issues.length" class="ge-proof">
|
||||
<div class="ge-proofhead">
|
||||
<strong>{{ errorCount }} to fix, {{ issues.length - errorCount }} worth a look</strong>
|
||||
<button type="button" @click="showProof = !showProof">
|
||||
{{ showProof ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<ul v-if="showProof">
|
||||
<li v-for="(item, i) in issues" :key="i" :class="'ge-' + item.level">
|
||||
<button v-if="item.blockid" type="button" class="ge-jump"
|
||||
@click="jumpTo(item.blockid)">go</button>
|
||||
<span>{{ item.message }}</span>
|
||||
<button v-if="item.fix" type="button" class="ge-fix"
|
||||
@click="fix(item)">fix it</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||
<div class="ge-editor">
|
||||
<div v-for="(block, index) in blocks" :key="block.id"
|
||||
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
|
||||
@focusin="focused = block.id">
|
||||
<div class="ge-blockbar">
|
||||
<span class="ge-type">{{ block.type }}</span>
|
||||
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
||||
<option value="info">info</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="success">success</option>
|
||||
</select>
|
||||
<input v-if="block.type === 'code'" v-model="block.language"
|
||||
class="ge-mini" placeholder="language">
|
||||
<input v-if="block.type === 'code'" v-model="block.caption"
|
||||
class="ge-mini" placeholder="caption">
|
||||
|
||||
<!-- Formatting acts on the selection in this block's textarea, so
|
||||
nobody has to know the markup exists. -->
|
||||
<div v-if="hasText(block) && block.type !== 'code'" class="ge-format">
|
||||
<button type="button" title="Bold" @click="wrap(block, '**')"><b>B</b></button>
|
||||
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
|
||||
<button type="button" title="Code" @click="wrap(block, '`')"><></button>
|
||||
<button type="button" title="Link" @click="makeLink(block)">link</button>
|
||||
</div>
|
||||
|
||||
<div class="ge-blockbtns">
|
||||
<button type="button" title="Move up" @click="move(index, -1)"
|
||||
:disabled="index === 0">^</button>
|
||||
<button type="button" title="Move down" @click="move(index, 1)"
|
||||
:disabled="index === blocks.length - 1">v</button>
|
||||
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
|
||||
<button type="button" class="ge-del" title="Remove"
|
||||
@click="remove(index)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
|
||||
placeholder="Step title">
|
||||
|
||||
<!--
|
||||
Spellcheck and autocorrect are ON for prose and OFF for code.
|
||||
|
||||
A code block that autocorrects is a guide that tells somebody to run
|
||||
a command which does not work: smart quotes replacing ", an
|
||||
autocapitalised cmdlet, a "corrected" flag. The author will not
|
||||
notice, because it still LOOKS right - and the person at the bay
|
||||
gets a syntax error with no idea why.
|
||||
-->
|
||||
<textarea v-if="hasText(block)" v-model="block.text"
|
||||
:ref="el => registerArea(block.id, el)"
|
||||
class="ge-text" :class="{ 'ge-mono': block.type === 'code' }"
|
||||
:rows="block.type === 'code' ? 6 : 3"
|
||||
:spellcheck="block.type !== 'code'"
|
||||
:autocorrect="block.type === 'code' ? 'off' : 'on'"
|
||||
:autocapitalize="block.type === 'code' ? 'off' : 'sentences'"
|
||||
:autocomplete="block.type === 'code' ? 'off' : undefined"
|
||||
:placeholder="placeholderFor(block)"></textarea>
|
||||
|
||||
<div v-if="hasItems(block)" class="ge-items">
|
||||
<div v-for="(item, i) in block.items" :key="item.id" class="ge-item">
|
||||
<span class="ge-bullet">{{ block.type === 'numbers' ? (i + 1) + '.' : '-' }}</span>
|
||||
<input v-model="item.text" :placeholder="'Item ' + (i + 1)"
|
||||
:ref="el => registerItem(item.id, el)"
|
||||
@keydown.enter.prevent="addItemAfter(block, i)"
|
||||
@keydown.backspace="maybeRemoveItem(block, i, $event)">
|
||||
<button type="button" @click="removeItem(block, i)">x</button>
|
||||
</div>
|
||||
<button type="button" class="ge-additem" @click="addItemAfter(block, block.items.length - 1)">
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="block.type === 'image'" class="ge-image">
|
||||
<!-- Same authenticated fetch as the reader's view: an author must
|
||||
see the screenshot they just pasted. -->
|
||||
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
|
||||
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
|
||||
Image unavailable - it may have been removed
|
||||
</p>
|
||||
<input v-model="block.caption" placeholder="Caption (optional)">
|
||||
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
|
||||
</div>
|
||||
|
||||
<div v-if="block.type === 'table'" class="ge-tableedit">
|
||||
<textarea v-model="block.raw" rows="4" @input="parseTable(block)"
|
||||
spellcheck="false" autocorrect="off" autocapitalize="off"
|
||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ge-add">
|
||||
<span>Add:</span>
|
||||
<button v-for="type in blocktypes" :key="type" type="button"
|
||||
@click="add(type)">{{ type }}</button>
|
||||
<label class="ge-upload">
|
||||
image
|
||||
<input type="file" accept="image/*" hidden @change="onFilePick">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="ge-hint">
|
||||
Paste or drag an image anywhere to drop it in below the block you are working on.
|
||||
Text supports **bold**, *italic*, `code` and [links](https://example.com) -
|
||||
or use the buttons.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- The same renderer readers get, so what you see here is what they see.
|
||||
A separate preview implementation would drift within a month. -->
|
||||
<div v-if="showPreview" class="ge-preview">
|
||||
<div class="ge-previewhead">Preview</div>
|
||||
<h1 class="ge-previewtitle">{{ guide.title || 'Untitled guide' }}</h1>
|
||||
<p v-if="guide.summary" class="ge-previewsummary">{{ guide.summary }}</p>
|
||||
<GuideBlocks :blocks="blocks" :interactive="false" :ticked="{}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isNew && revisions.length" class="ge-revisions">
|
||||
<h3>Version history</h3>
|
||||
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
|
||||
<span class="ge-revnum">v{{ rev.revision }}</span>
|
||||
<span class="ge-revwhen">{{ formatDate(rev.createdat) }}</span>
|
||||
<span v-if="rev.ispublished" class="ge-revpub">published</span>
|
||||
<span class="ge-revnote">{{ rev.note }}</span>
|
||||
<button type="button" @click="restore(rev)">Restore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { templates } from '../utils/templates.js'
|
||||
import { proof, applyFix } from '../utils/proof.js'
|
||||
import { useGuideImages } from '../utils/images.js'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
|
||||
'numbers', 'callout', 'code', 'table']
|
||||
|
||||
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
|
||||
slug: '', ispublished: false })
|
||||
const blocks = ref([])
|
||||
const revisions = ref([])
|
||||
const saving = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
const status = ref('')
|
||||
const showPreview = ref(true)
|
||||
const focused = ref('')
|
||||
const dirty = ref(false)
|
||||
const showProof = ref(true)
|
||||
|
||||
// Recomputed as you type. Cheap - it is a walk over a few dozen blocks - and
|
||||
// live feedback is the difference between proofing and a report nobody reads.
|
||||
const issues = computed(() => proof(guide, blocks.value))
|
||||
const errorCount = computed(() => issues.value.filter(i => i.level === 'error').length)
|
||||
|
||||
function jumpTo (blockid) {
|
||||
focused.value = blockid
|
||||
const node = textareas[blockid] || itemInputs[blockid]
|
||||
if (node?.scrollIntoView) {
|
||||
node.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
node.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
function fix (item) {
|
||||
const block = blocks.value.find(b => b.id === item.blockid)
|
||||
if (block && applyFix(block, item.fix)) dirty.value = true
|
||||
}
|
||||
|
||||
const isNew = computed(() => !guide.guideid)
|
||||
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
||||
|
||||
const textareas = {}
|
||||
const itemInputs = {}
|
||||
function registerArea (id, el) { if (el) textareas[id] = el }
|
||||
function registerItem (id, el) { if (el) itemInputs[id] = el }
|
||||
|
||||
function newid () {
|
||||
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
function hasText (block) {
|
||||
return ['heading', 'paragraph', 'step', 'callout', 'code'].includes(block.type)
|
||||
}
|
||||
function hasItems (block) {
|
||||
return ['checklist', 'bullets', 'numbers'].includes(block.type)
|
||||
}
|
||||
function placeholderFor (block) {
|
||||
if (block.type === 'code') return 'The command, exactly as it should be run'
|
||||
if (block.type === 'callout') return 'What somebody needs to know before carrying on'
|
||||
if (block.type === 'heading') return 'Section heading'
|
||||
return 'Text'
|
||||
}
|
||||
|
||||
function useTemplate (tpl) {
|
||||
blocks.value = tpl.blocks()
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
// New blocks land AFTER the block being worked on, not at the bottom. Appending
|
||||
// to the end means scrolling back every time, which is the difference between
|
||||
// writing a guide and fighting an editor.
|
||||
function insertIndex () {
|
||||
const index = blocks.value.findIndex(b => b.id === focused.value)
|
||||
return index === -1 ? blocks.value.length : index + 1
|
||||
}
|
||||
|
||||
function add (type) {
|
||||
const block = { id: newid(), type }
|
||||
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
||||
if (type === 'callout') block.variant = 'info'
|
||||
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
||||
const at = insertIndex()
|
||||
blocks.value.splice(at, 0, block)
|
||||
focused.value = block.id
|
||||
nextTick(() => textareas[block.id]?.focus())
|
||||
}
|
||||
|
||||
function duplicate (index) {
|
||||
const source = blocks.value[index]
|
||||
const copy = JSON.parse(JSON.stringify(source))
|
||||
copy.id = newid()
|
||||
if (copy.items) copy.items = copy.items.map(item => ({ ...item, id: newid() }))
|
||||
blocks.value.splice(index + 1, 0, copy)
|
||||
}
|
||||
|
||||
function remove (index) { blocks.value.splice(index, 1) }
|
||||
|
||||
function move (index, delta) {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= blocks.value.length) return
|
||||
const [block] = blocks.value.splice(index, 1)
|
||||
blocks.value.splice(target, 0, block)
|
||||
}
|
||||
|
||||
// Enter makes the next item and focuses it - the way every list in every other
|
||||
// tool behaves. Typing a ten-item checklist should not need the mouse.
|
||||
function addItemAfter (block, index) {
|
||||
const item = { id: newid(), text: '' }
|
||||
block.items.splice(index + 1, 0, item)
|
||||
nextTick(() => itemInputs[item.id]?.focus())
|
||||
}
|
||||
|
||||
// Backspace on an ALREADY EMPTY item removes it and moves up, which is how a
|
||||
// list stops without reaching for the mouse. Never on a non-empty one.
|
||||
function maybeRemoveItem (block, index, event) {
|
||||
if (event.target.value !== '' || block.items.length <= 1) return
|
||||
event.preventDefault()
|
||||
const previous = block.items[index - 1]
|
||||
block.items.splice(index, 1)
|
||||
if (previous) nextTick(() => itemInputs[previous.id]?.focus())
|
||||
}
|
||||
|
||||
function removeItem (block, index) {
|
||||
if (block.items.length > 1) block.items.splice(index, 1)
|
||||
else block.items[0].text = ''
|
||||
}
|
||||
|
||||
function parseTable (block) {
|
||||
const lines = (block.raw || '').split('\n').filter(l => l.trim())
|
||||
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
|
||||
block.header = rows.length ? rows[0] : []
|
||||
block.rows = rows.slice(1)
|
||||
}
|
||||
|
||||
// Formatting wraps the SELECTION, so the markup stays an implementation detail.
|
||||
function wrap (block, marker) {
|
||||
const area = textareas[block.id]
|
||||
if (!area) return
|
||||
const start = area.selectionStart
|
||||
const end = area.selectionEnd
|
||||
const text = block.text || ''
|
||||
const selected = text.slice(start, end) || 'text'
|
||||
block.text = text.slice(0, start) + marker + selected + marker + text.slice(end)
|
||||
nextTick(() => {
|
||||
area.focus()
|
||||
area.setSelectionRange(start + marker.length, start + marker.length + selected.length)
|
||||
})
|
||||
}
|
||||
|
||||
function makeLink (block) {
|
||||
const area = textareas[block.id]
|
||||
if (!area) return
|
||||
const start = area.selectionStart
|
||||
const end = area.selectionEnd
|
||||
const text = block.text || ''
|
||||
const selected = text.slice(start, end) || 'link text'
|
||||
const url = window.prompt('Link to:', 'https://')
|
||||
if (!url) return
|
||||
block.text = text.slice(0, start) + '[' + selected + '](' + url + ')' + text.slice(end)
|
||||
nextTick(() => area.focus())
|
||||
}
|
||||
|
||||
async function uploadImage (file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (guide.guideid) form.append('guideid', guide.guideid)
|
||||
const response = await api.post('/guides/images', form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
return response.data?.data || response.data
|
||||
}
|
||||
|
||||
async function insertImage (file) {
|
||||
error.value = ''
|
||||
status.value = 'Uploading image...'
|
||||
try {
|
||||
const image = await uploadImage(file)
|
||||
// imageid is what the renderer resolves; url stays for older readers and
|
||||
// for anything that still treats a block as a plain document.
|
||||
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
|
||||
url: image.url, caption: '', alt: '' }
|
||||
blocks.value.splice(insertIndex(), 0, block)
|
||||
focused.value = block.id
|
||||
status.value = ''
|
||||
} catch (err) {
|
||||
status.value = ''
|
||||
error.value = apiError(err, 'Could not upload that image')
|
||||
}
|
||||
}
|
||||
|
||||
function onPaste (event) {
|
||||
const items = Array.from(event.clipboardData?.items || [])
|
||||
const imageItem = items.find(item => item.type.startsWith('image/'))
|
||||
if (!imageItem) return
|
||||
event.preventDefault()
|
||||
const file = imageItem.getAsFile()
|
||||
if (file) insertImage(file)
|
||||
}
|
||||
|
||||
function onDrop (event) {
|
||||
const file = Array.from(event.dataTransfer?.files || [])
|
||||
.find(f => f.type.startsWith('image/'))
|
||||
if (file) insertImage(file)
|
||||
}
|
||||
|
||||
function onFilePick (event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) insertImage(file)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
|
||||
const { state: imageState } = useGuideImages(blocks)
|
||||
|
||||
function payload () {
|
||||
return { title: guide.title, summary: guide.summary || '',
|
||||
tags: guide.tags || '', blocks: blocks.value }
|
||||
}
|
||||
|
||||
// Returns whether the save actually reached the server. publish() depends on
|
||||
// that answer: a publish that runs after a failed save promotes the LAST
|
||||
// revision the server accepted, which is not what is on the author's screen.
|
||||
async function save (quiet) {
|
||||
if (!guide.title.trim()) {
|
||||
if (!quiet) error.value = 'A guide needs a title before it can be saved'
|
||||
return false
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const response = await api.post('/guides', payload())
|
||||
const created = response.data?.data || response.data
|
||||
Object.assign(guide, { guideid: created.guideid, slug: created.slug })
|
||||
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
|
||||
} else {
|
||||
const response = await api.put('/guides/' + guide.guideid, payload())
|
||||
const saved = response.data?.data || response.data
|
||||
guide.slug = saved.slug
|
||||
}
|
||||
dirty.value = false
|
||||
status.value = 'Saved ' + new Date().toLocaleTimeString()
|
||||
await loadRevisions()
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not save this guide')
|
||||
status.value = 'Not saved'
|
||||
return false
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Autosave, debounced. The server dedups on content, so an autosave that
|
||||
// changed nothing writes no version - which is what makes this safe to run on a
|
||||
// timer rather than a deliberate act.
|
||||
let autosaveTimer = null
|
||||
watch([blocks, () => guide.title, () => guide.summary, () => guide.tags],
|
||||
() => {
|
||||
dirty.value = true
|
||||
status.value = 'Unsaved changes'
|
||||
if (isNew.value) return // a new guide is saved deliberately, once
|
||||
clearTimeout(autosaveTimer)
|
||||
autosaveTimer = setTimeout(() => save(true), 3000)
|
||||
},
|
||||
{ deep: true })
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(autosaveTimer))
|
||||
|
||||
// Leaving with unsaved work should require saying so out loud.
|
||||
onBeforeRouteLeave(() => {
|
||||
if (!dirty.value) return true
|
||||
return window.confirm('This guide has unsaved changes. Leave anyway?')
|
||||
})
|
||||
|
||||
async function publish () {
|
||||
if (errorCount.value) {
|
||||
const ok = window.confirm(
|
||||
errorCount.value + ' problem(s) would go out with this guide, including '
|
||||
+ 'anything flagged in a command. Publish anyway?')
|
||||
if (!ok) return
|
||||
}
|
||||
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
|
||||
// in what readers get. Reporting "Published version 4" over a failed save
|
||||
// tells the author their words went out when the old ones did.
|
||||
if (!await save(true)) {
|
||||
error.value = error.value
|
||||
|| 'Could not save this guide, so it was not published'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||
guide.ispublished = true
|
||||
message.value = response.data?.message || 'Published'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not publish this guide')
|
||||
}
|
||||
}
|
||||
|
||||
async function unpublish () {
|
||||
try {
|
||||
await api.post('/guides/' + guide.guideid + '/unpublish')
|
||||
guide.ispublished = false
|
||||
message.value = 'Withdrawn from readers'
|
||||
setTimeout(() => { message.value = '' }, 3000)
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not withdraw this guide')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore (rev) {
|
||||
if (!window.confirm('Bring version ' + rev.revision + ' back as a new draft version?')) return
|
||||
try {
|
||||
const response = await api.post(
|
||||
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
|
||||
const restored = response.data?.data || response.data
|
||||
blocks.value = restored.blocks || []
|
||||
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
|
||||
tags: (restored.tags || []).join(', ') })
|
||||
dirty.value = false
|
||||
message.value = response.data?.message || 'Restored'
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not restore that version')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRevisions () {
|
||||
if (!guide.guideid) return
|
||||
try {
|
||||
const response = await api.get('/guides/' + guide.guideid + '/revisions')
|
||||
revisions.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
revisions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate (value) {
|
||||
return value ? new Date(value).toLocaleString() : ''
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const guideid = route.params.guideid
|
||||
if (!guideid) return
|
||||
try {
|
||||
const response = await api.get('/guides/' + guideid + '/working')
|
||||
const data = response.data?.data || response.data
|
||||
Object.assign(guide, {
|
||||
guideid: data.guideid, title: data.title, summary: data.summary || '',
|
||||
tags: (data.tags || []).join(', '), slug: data.slug,
|
||||
ispublished: data.ispublished,
|
||||
})
|
||||
blocks.value = (data.blocks || []).map(block => {
|
||||
if (block.type === 'table' && !block.raw) {
|
||||
const rows = [block.header || [], ...(block.rows || [])]
|
||||
block.raw = rows.filter(r => r.length).map(r => r.join(' | ')).join('\n')
|
||||
}
|
||||
return block
|
||||
})
|
||||
await nextTick()
|
||||
dirty.value = false
|
||||
status.value = ''
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load this guide')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.ge-actions { display: flex; gap: 0.4rem; align-items: center; }
|
||||
.ge-status { font-size: 0.75rem; color: var(--text-light); margin-right: 0.3rem; }
|
||||
.ge-btn {
|
||||
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
|
||||
color: var(--primary); background: transparent; border-radius: 4px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.ge-primary { background: var(--primary); color: #fff; }
|
||||
.ge-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.ge-fields { display: grid; gap: 0.4rem; margin: 0.8rem 0 1rem; max-width: 45rem; }
|
||||
.ge-fields input {
|
||||
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.ge-title { font-size: 1.1rem; font-weight: 600; }
|
||||
|
||||
.ge-templates { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
|
||||
margin-bottom: 1rem; }
|
||||
.ge-templates > span { font-size: 0.8rem; color: var(--text-light); }
|
||||
.ge-tpl {
|
||||
display: grid; text-align: left; gap: 0.1rem; padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--border); border-radius: 5px; background: transparent;
|
||||
color: var(--text); cursor: pointer; max-width: 15rem;
|
||||
}
|
||||
.ge-tpl:hover { border-color: var(--primary); }
|
||||
.ge-tpl small { color: var(--text-light); font-size: 0.72rem; }
|
||||
|
||||
.ge-split { display: grid; gap: 1.2rem; }
|
||||
.ge-haspreview { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
@media (max-width: 70rem) { .ge-haspreview { grid-template-columns: 1fr; } }
|
||||
|
||||
.ge-block {
|
||||
border: 1px solid var(--border); border-radius: 5px; padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.ge-focused { border-color: var(--primary); }
|
||||
.ge-blockbar { display: flex; gap: 0.35rem; align-items: center; margin-bottom: 0.35rem;
|
||||
flex-wrap: wrap; }
|
||||
.ge-type { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: var(--text-light); }
|
||||
.ge-mini {
|
||||
font-size: 0.72rem; padding: 0.12rem 0.3rem; border: 1px solid var(--border);
|
||||
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
|
||||
max-width: 8rem;
|
||||
}
|
||||
.ge-format { display: flex; gap: 0.15rem; }
|
||||
.ge-format button, .ge-blockbtns button {
|
||||
font-size: 0.7rem; padding: 0.1rem 0.35rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
min-width: 1.5rem;
|
||||
}
|
||||
.ge-format button:hover, .ge-blockbtns button:hover { color: var(--primary); border-color: var(--primary); }
|
||||
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.15rem; }
|
||||
.ge-del:hover { border-color: var(--danger, #c0392b) !important; color: var(--danger, #c0392b) !important; }
|
||||
|
||||
.ge-text, .ge-steptitle, .ge-tableedit textarea {
|
||||
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||
font-family: inherit; font-size: 0.88rem; resize: vertical;
|
||||
}
|
||||
.ge-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
|
||||
.ge-steptitle { margin-bottom: 0.35rem; }
|
||||
|
||||
.ge-items { display: grid; gap: 0.25rem; }
|
||||
.ge-item { display: flex; gap: 0.3rem; align-items: center; }
|
||||
.ge-bullet { font-size: 0.8rem; color: var(--text-light); width: 1.2rem; }
|
||||
.ge-item input {
|
||||
flex: 1; padding: 0.3rem 0.45rem; border: 1px solid var(--border);
|
||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.ge-item button, .ge-additem, .ge-add button, .ge-upload {
|
||||
font-size: 0.72rem; padding: 0.15rem 0.45rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px;
|
||||
cursor: pointer; justify-self: start;
|
||||
}
|
||||
.ge-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
|
||||
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
|
||||
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
|
||||
border: 1px solid var(--border); border-radius: 4px; }
|
||||
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text); }
|
||||
|
||||
.ge-add { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin: 0.8rem 0 0.4rem; }
|
||||
.ge-add > span { font-size: 0.75rem; color: var(--text-light); }
|
||||
.ge-hint { font-size: 0.75rem; color: var(--text-light); }
|
||||
|
||||
.ge-preview {
|
||||
border-left: 1px solid var(--border); padding-left: 1.2rem;
|
||||
position: sticky; top: 1rem; align-self: start; max-height: calc(100vh - 3rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ge-previewhead {
|
||||
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: var(--text-light); margin-bottom: 0.5rem;
|
||||
}
|
||||
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
|
||||
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; }
|
||||
|
||||
.ge-proof {
|
||||
border: 1px solid var(--border); border-left: 3px solid var(--warning, #d9822b);
|
||||
border-radius: 4px; padding: 0.5rem 0.7rem; margin-bottom: 1rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.ge-proofhead { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.ge-proofhead button {
|
||||
margin-left: auto; font-size: 0.72rem; background: none; border: none;
|
||||
color: var(--text-light); cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
.ge-proof ul { margin: 0.4rem 0 0; padding: 0; list-style: none; }
|
||||
.ge-proof li { display: flex; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
|
||||
.ge-error span { color: var(--danger, #c0392b); }
|
||||
.ge-warn span { color: var(--text-light); }
|
||||
.ge-jump, .ge-fix {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.35rem; border: 1px solid var(--border);
|
||||
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||
}
|
||||
.ge-fix { margin-left: auto; border-color: var(--primary); color: var(--primary); }
|
||||
|
||||
.ge-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
|
||||
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
|
||||
.ge-revnum { font-weight: 600; }
|
||||
.ge-revwhen, .ge-revnote { color: var(--text-light); }
|
||||
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
|
||||
border: 1px solid var(--primary); color: var(--primary); }
|
||||
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); border-radius: 3px; cursor: pointer; }
|
||||
</style>
|
||||
273
plugins/guides/frontend/views/GuideView.vue
Normal file
273
plugins/guides/frontend/views/GuideView.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div class="gv">
|
||||
<div v-if="loading" class="gv-loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<template v-else-if="guide">
|
||||
<div class="gv-head">
|
||||
<div class="gv-headmain">
|
||||
<h1>{{ guide.title }}</h1>
|
||||
<p v-if="guide.summary" class="gv-summary">{{ guide.summary }}</p>
|
||||
<div class="gv-meta">
|
||||
<span v-if="guide.isdraftpreview" class="gv-badge gv-draft">Draft preview</span>
|
||||
<span v-else class="gv-badge">Version {{ guide.revision }}</span>
|
||||
<span v-for="tag in guide.tags" :key="tag" class="gv-tag"
|
||||
@click="$router.push({ name: 'guides', query: { tag } })">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gv-actions">
|
||||
<button type="button" class="gv-btn" @click="print">Print</button>
|
||||
<router-link v-if="guide.canedit" class="gv-btn"
|
||||
:to="{ name: 'guide-edit', params: { guideid: guide.guideid } }">
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- An editor needs to know there is newer work than what readers see,
|
||||
without leaving the page to find out. -->
|
||||
<div v-if="guide.hasunpublishedchanges && guide.canedit" class="gv-notice">
|
||||
There are unpublished changes. Readers are seeing version {{ guide.revision }}.
|
||||
</div>
|
||||
|
||||
<div v-if="totalChecks" class="gv-progress">
|
||||
<div class="gv-progressbar">
|
||||
<div class="gv-progressfill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
<span class="gv-progresstext">{{ tickedCount }} of {{ totalChecks }} done</span>
|
||||
<button v-if="tickedCount" type="button" class="gv-reset" @click="resetProgress">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<GuideBlocks :blocks="guide.blocks" :interactive="true"
|
||||
:ticked="ticked" @toggle="toggle" />
|
||||
|
||||
<div class="gv-foot">
|
||||
<span>Version {{ guide.revision }}</span>
|
||||
<span v-if="guide.publishedat">Published {{ formatDate(guide.publishedat) }}</span>
|
||||
<span class="gv-printedon">Printed {{ today }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
const guide = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const ticked = ref({})
|
||||
|
||||
const today = new Date().toLocaleDateString()
|
||||
|
||||
/**
|
||||
* Progress lives in the BROWSER, not the database.
|
||||
*
|
||||
* Shopfloor PCs share a login. Storing progress per user would mean one
|
||||
* technician's ticks appearing for the next person on that bay, which is worse
|
||||
* than useless on a procedure - it reads as "already done". Per browser, each
|
||||
* station keeps its own place, and the Reset button is right there.
|
||||
*
|
||||
* Keyed by guide id and version: a new version invalidates old ticks, because
|
||||
* step 4 of version 3 is not step 4 of version 2.
|
||||
*/
|
||||
function storageKey () {
|
||||
return guide.value
|
||||
? 'guides.progress.' + guide.value.guideid + '.v' + guide.value.revision
|
||||
: null
|
||||
}
|
||||
|
||||
function loadProgress () {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
ticked.value = JSON.parse(window.localStorage.getItem(key) || '{}')
|
||||
} catch (err) {
|
||||
ticked.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress () {
|
||||
const key = storageKey()
|
||||
if (!key) return
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(ticked.value))
|
||||
} catch (err) {
|
||||
// Private window, or storage disabled. The checkboxes still work for this
|
||||
// visit; they just will not be remembered. Not worth an error message.
|
||||
}
|
||||
}
|
||||
|
||||
function toggle (itemid, checked) {
|
||||
if (checked) ticked.value = { ...ticked.value, [itemid]: true }
|
||||
else {
|
||||
const next = { ...ticked.value }
|
||||
delete next[itemid]
|
||||
ticked.value = next
|
||||
}
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
function resetProgress () {
|
||||
ticked.value = {}
|
||||
saveProgress()
|
||||
}
|
||||
|
||||
const totalChecks = computed(() => {
|
||||
let total = 0
|
||||
for (const block of guide.value?.blocks || []) {
|
||||
if (block.type === 'checklist') total += (block.items || []).length
|
||||
}
|
||||
return total
|
||||
})
|
||||
const tickedCount = computed(() => Object.keys(ticked.value).length)
|
||||
const progressPercent = computed(() =>
|
||||
totalChecks.value ? Math.round((tickedCount.value / totalChecks.value) * 100) : 0)
|
||||
|
||||
function formatDate (value) {
|
||||
return value ? new Date(value).toLocaleDateString() : ''
|
||||
}
|
||||
|
||||
function print () {
|
||||
window.print()
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await api.get('/guides/' + route.params.slug)
|
||||
guide.value = response.data?.data || response.data
|
||||
setRecordTitle(guide.value?.title)
|
||||
loadProgress()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load this guide')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => route.params.slug, load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gv { max-width: 60rem; }
|
||||
.gv-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
|
||||
.gv-headmain { flex: 1; }
|
||||
.gv-head h1 { margin: 0 0 0.3rem; }
|
||||
.gv-summary { color: var(--text-light); margin: 0 0 0.5rem; }
|
||||
.gv-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
||||
.gv-badge {
|
||||
font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 3px;
|
||||
border: 1px solid var(--border); color: var(--text-light);
|
||||
}
|
||||
.gv-draft { border-color: var(--warning, #d9822b); color: var(--warning, #d9822b); }
|
||||
.gv-tag {
|
||||
font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px;
|
||||
background: var(--surface-2, rgba(127,127,127,0.1)); cursor: pointer;
|
||||
}
|
||||
.gv-tag:hover { color: var(--primary); }
|
||||
.gv-actions { display: flex; gap: 0.4rem; }
|
||||
.gv-btn {
|
||||
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
|
||||
color: var(--primary); background: transparent; border-radius: 4px;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.gv-btn:hover { background: var(--primary); color: #fff; }
|
||||
|
||||
.gv-notice {
|
||||
border-left: 4px solid var(--warning, #d9822b);
|
||||
background: var(--surface-2, rgba(127,127,127,0.08));
|
||||
padding: 0.5rem 0.8rem; margin: 0.5rem 0 1rem; font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.gv-progress { display: flex; align-items: center; gap: 0.7rem; margin: 1rem 0 1.4rem; }
|
||||
.gv-progressbar {
|
||||
flex: 1; height: 6px; border-radius: 3px; overflow: hidden;
|
||||
background: var(--surface-2, rgba(127,127,127,0.15));
|
||||
}
|
||||
.gv-progressfill { height: 100%; background: var(--primary); transition: width 0.2s; }
|
||||
.gv-progresstext { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; }
|
||||
.gv-reset {
|
||||
font-size: 0.72rem; background: none; border: none; color: var(--text-light);
|
||||
cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
|
||||
.gv-foot {
|
||||
margin-top: 2rem; padding-top: 0.6rem; border-top: 1px solid var(--border);
|
||||
display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-light);
|
||||
}
|
||||
.gv-printedon { display: none; }
|
||||
|
||||
@media print {
|
||||
/* A printed guide is a worksheet: no controls, everything visible, and the
|
||||
footer says which version it was so a stale printout on a noticeboard can
|
||||
be spotted. */
|
||||
.gv-actions, .gv-progress, .gv-notice, .gv-reset { display: none !important; }
|
||||
.gv-printedon { display: inline; }
|
||||
.gv { max-width: none; }
|
||||
.gv-head h1 { font-size: 1.6rem; }
|
||||
.gv-foot { border-top: 1px solid #999; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* PRINT IS ALWAYS LIGHT, whatever the screen theme is.
|
||||
*
|
||||
* Printing a dark-mode page lays down a full page of toner for the background
|
||||
* and then knocks the text out of it in white. On a shopfloor printer that is
|
||||
* a cartridge for a handful of guides, and the result is harder to read than
|
||||
* the plain version.
|
||||
*
|
||||
* So the theme tokens are redefined for print only. Every guide element already
|
||||
* renders through these variables rather than literal colours, which is what
|
||||
* makes this a dozen lines instead of a rewrite - and the same discipline that
|
||||
* made dark mode work at all.
|
||||
*
|
||||
* Deliberately NOT scoped: a scoped block cannot reach :root, and these have to
|
||||
* override tokens set on the document element.
|
||||
*/
|
||||
@media print {
|
||||
:root,
|
||||
:root[data-theme="dark"],
|
||||
:root[data-theme="light"] {
|
||||
--text: #000;
|
||||
--text-light: #333;
|
||||
--border: #999;
|
||||
--surface: #fff;
|
||||
--surface-2: #fff;
|
||||
--primary: #000;
|
||||
--info: #666;
|
||||
--warning: #666;
|
||||
--success: #666;
|
||||
}
|
||||
|
||||
html, body, .app-layout, .main-content {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* Backgrounds are dropped rather than printed grey: a callout is identified
|
||||
by its rule and its heading, which cost nothing to print. */
|
||||
.gb-callout, .gb-code, .gb-codehead, .gb-table th {
|
||||
background: #fff !important;
|
||||
}
|
||||
.gb-callout { border-left: 3px solid #000 !important; }
|
||||
.gb-code, .gb-table th, .gb-table td { border-color: #999 !important; }
|
||||
|
||||
/* Links: the text is what matters on paper, and a blue underline on a mono
|
||||
printer is just grey. */
|
||||
a { color: #000 !important; text-decoration: underline; }
|
||||
}
|
||||
</style>
|
||||
163
plugins/guides/frontend/views/GuidesList.vue
Normal file
163
plugins/guides/frontend/views/GuidesList.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="gl">
|
||||
<div class="page-header">
|
||||
<h2>Guides</h2>
|
||||
<router-link v-if="cancreate" class="gl-new" :to="{ name: 'guide-new' }">
|
||||
New guide
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="gl-filters">
|
||||
<input v-model="search" type="search" class="gl-search"
|
||||
placeholder="Search titles, summaries, tags and content..."
|
||||
@input="debouncedLoad">
|
||||
<div v-if="tags.length" class="gl-tags">
|
||||
<button type="button" class="gl-tag" :class="{ 'gl-tagon': !activeTag }"
|
||||
@click="setTag('')">All</button>
|
||||
<button v-for="tag in tags" :key="tag.tag" type="button"
|
||||
class="gl-tag" :class="{ 'gl-tagon': activeTag === tag.tag }"
|
||||
@click="setTag(tag.tag)">
|
||||
{{ tag.tag }} <span class="gl-count">{{ tag.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="gl-loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-else-if="!guides.length" class="gl-empty">
|
||||
<p v-if="search || activeTag">Nothing matches that.</p>
|
||||
<p v-else>No guides yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="gl-grid">
|
||||
<router-link v-for="guide in guides" :key="guide.guideid" class="gl-card"
|
||||
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
|
||||
<div class="gl-cardhead">
|
||||
<h3>{{ guide.title }}</h3>
|
||||
<!-- Drafts are visible here only to people who can edit them, so the
|
||||
badge is a working state rather than a warning. -->
|
||||
<span v-if="!guide.ispublished" class="gl-draft">Draft</span>
|
||||
</div>
|
||||
<p v-if="guide.summary" class="gl-summary">{{ guide.summary }}</p>
|
||||
<div class="gl-cardfoot">
|
||||
<span v-for="tag in guide.tags" :key="tag" class="gl-minitag">{{ tag }}</span>
|
||||
<span v-if="guide.checklistitems" class="gl-checks">
|
||||
{{ guide.checklistitems }} steps to tick
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const guides = ref([])
|
||||
const tags = ref([])
|
||||
const search = ref('')
|
||||
const activeTag = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
// computed, not a one-off read: permissions may not be loaded yet at setup.
|
||||
const cancreate = computed(() => auth.hasPermission?.('guides.create') ?? false)
|
||||
|
||||
let timer = null
|
||||
function debouncedLoad () {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(load, 250)
|
||||
}
|
||||
|
||||
function setTag (tag) {
|
||||
activeTag.value = tag
|
||||
router.replace({ query: tag ? { tag } : {} })
|
||||
load()
|
||||
}
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = {}
|
||||
if (search.value.trim()) params.q = search.value.trim()
|
||||
if (activeTag.value) params.tag = activeTag.value
|
||||
const response = await api.get('/guides', { params })
|
||||
guides.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Could not load guides')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTags () {
|
||||
try {
|
||||
const response = await api.get('/guides/tags')
|
||||
tags.value = response.data?.data || response.data || []
|
||||
} catch (err) {
|
||||
tags.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeTag.value = route.query.tag || ''
|
||||
load()
|
||||
loadTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.gl-new {
|
||||
font-size: 0.82rem; padding: 0.35rem 0.8rem; border-radius: 4px;
|
||||
background: var(--primary); color: #fff; text-decoration: none;
|
||||
}
|
||||
.gl-filters { margin: 0.8rem 0 1.2rem; }
|
||||
.gl-search {
|
||||
width: 100%; max-width: 34rem; padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface, transparent); color: var(--text);
|
||||
}
|
||||
.gl-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
|
||||
.gl-tag {
|
||||
font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px;
|
||||
border: 1px solid var(--border); background: transparent;
|
||||
color: var(--text-light); cursor: pointer;
|
||||
}
|
||||
.gl-tagon { border-color: var(--primary); color: var(--primary); }
|
||||
.gl-count { opacity: 0.6; }
|
||||
|
||||
.gl-grid {
|
||||
display: grid; gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
|
||||
}
|
||||
.gl-card {
|
||||
display: block; padding: 0.9rem 1rem; border: 1px solid var(--border);
|
||||
border-radius: 6px; text-decoration: none; color: inherit;
|
||||
background: var(--surface, transparent);
|
||||
}
|
||||
.gl-card:hover { border-color: var(--primary); }
|
||||
.gl-cardhead { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.gl-cardhead h3 { margin: 0; font-size: 1rem; flex: 1; }
|
||||
.gl-draft {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 3px;
|
||||
border: 1px solid var(--warning, #d9822b); color: var(--warning, #d9822b);
|
||||
}
|
||||
.gl-summary { margin: 0.4rem 0 0.6rem; font-size: 0.85rem; color: var(--text-light); }
|
||||
.gl-cardfoot { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
|
||||
.gl-minitag {
|
||||
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 999px;
|
||||
background: var(--surface-2, rgba(127,127,127,0.1)); color: var(--text-light);
|
||||
}
|
||||
.gl-checks { font-size: 0.7rem; color: var(--text-light); margin-left: auto; }
|
||||
.gl-empty, .gl-loading { color: var(--text-light); padding: 1.5rem 0; }
|
||||
</style>
|
||||
16
plugins/guides/migrations/env.py
Normal file
16
plugins/guides/migrations/env.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Alembic environment for the guides plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_guides. See ADR-008 for the ownership model.
|
||||
|
||||
Like the backups plugin and unlike the cutover ones, guides is NEW: its 0001
|
||||
baseline really CREATES its tables, because the core chain never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'guides'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/guides/migrations/script.py.mako
Normal file
24
plugins/guides/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
114
plugins/guides/migrations/versions/0001_guides_baseline.py
Normal file
114
plugins/guides/migrations/versions/0001_guides_baseline.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""guides plugin baseline: guides, guiderevisions, guideimages.
|
||||
|
||||
A new plugin, so this builds its tables rather than stamping an anchor over
|
||||
core-owned ones (contrast the cutover plugins, whose 0001 is a no-op).
|
||||
|
||||
Idempotent: each table is created only if it is not already there, so a database
|
||||
where an earlier run half-succeeded can be upgraded again without hand-editing
|
||||
alembic_version_guides.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = 'guides0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tables():
|
||||
return set(inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def upgrade():
|
||||
existing = _tables()
|
||||
|
||||
if 'guides' not in existing:
|
||||
op.create_table(
|
||||
'guides',
|
||||
sa.Column('guideid', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=300), nullable=False),
|
||||
# 191 because this is UNIQUE-indexed and MySQL caps an index key at
|
||||
# 767 bytes; utf8mb4 is 4 bytes per character.
|
||||
sa.Column('slug', sa.String(length=191), nullable=False),
|
||||
sa.Column('summary', sa.String(length=1000)),
|
||||
sa.Column('tags', sa.String(length=500)),
|
||||
sa.Column('status', sa.String(length=20), nullable=False,
|
||||
server_default='draft'),
|
||||
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
|
||||
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
|
||||
nullable=False),
|
||||
sa.Column('publishedrevisionid', sa.Integer()),
|
||||
sa.Column('createdby', sa.Integer()),
|
||||
sa.Column('updatedby', sa.Integer()),
|
||||
sa.Column('publishedat', sa.DateTime()),
|
||||
# BaseModel contributes these to every model in the app; a plugin
|
||||
# migration has to spell them out or the table is missing columns
|
||||
# the ORM selects on every query.
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.Column('revision', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.PrimaryKeyConstraint('guideid'),
|
||||
sa.UniqueConstraint('slug', name='uq_guides_slug'),
|
||||
)
|
||||
op.create_index('idx_guides_status', 'guides', ['status'])
|
||||
|
||||
if 'guiderevisions' not in existing:
|
||||
op.create_table(
|
||||
'guiderevisions',
|
||||
sa.Column('guiderevisionid', sa.Integer(), nullable=False),
|
||||
sa.Column('guideid', sa.Integer(), nullable=False),
|
||||
sa.Column('revision', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=300), nullable=False),
|
||||
sa.Column('summary', sa.String(length=1000)),
|
||||
sa.Column('tags', sa.String(length=500)),
|
||||
sa.Column('blocksjson', sa.dialects.mysql.MEDIUMTEXT()
|
||||
if op.get_bind().dialect.name == 'mysql' else sa.Text(),
|
||||
nullable=False),
|
||||
sa.Column('contenthash', sa.String(length=64), nullable=False),
|
||||
sa.Column('note', sa.String(length=500)),
|
||||
sa.Column('createdby', sa.Integer()),
|
||||
# BaseModel contributes these to every model in the app; a plugin
|
||||
# migration has to spell them out or the table is missing columns
|
||||
# the ORM selects on every query.
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('guiderevisionid'),
|
||||
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
|
||||
ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('idx_guiderevisions_guide', 'guiderevisions', ['guideid'])
|
||||
op.create_index('idx_guiderevisions_hash', 'guiderevisions', ['contenthash'])
|
||||
|
||||
if 'guideimages' not in existing:
|
||||
op.create_table(
|
||||
'guideimages',
|
||||
sa.Column('guideimageid', sa.Integer(), nullable=False),
|
||||
sa.Column('guideid', sa.Integer()),
|
||||
sa.Column('filename', sa.String(length=255)),
|
||||
sa.Column('mimetype', sa.String(length=100), nullable=False),
|
||||
sa.Column('bytesize', sa.Integer(), nullable=False),
|
||||
sa.Column('data', sa.dialects.mysql.MEDIUMBLOB()
|
||||
if op.get_bind().dialect.name == 'mysql' else sa.LargeBinary(),
|
||||
nullable=False),
|
||||
sa.Column('uploadedby', sa.Integer()),
|
||||
# BaseModel contributes these to every model in the app; a plugin
|
||||
# migration has to spell them out or the table is missing columns
|
||||
# the ORM selects on every query.
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('guideimageid'),
|
||||
sa.ForeignKeyConstraint(['guideid'], ['guides.guideid'],
|
||||
ondelete='CASCADE'),
|
||||
)
|
||||
op.create_index('idx_guideimages_guide', 'guideimages', ['guideid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
for table in ('guideimages', 'guiderevisions', 'guides'):
|
||||
op.drop_table(table)
|
||||
@@ -1,3 +1,3 @@
|
||||
from .guide import Guide, GuideImage, BLOCKTYPES
|
||||
from .guide import Guide, GuideRevision, GuideImage, BLOCKTYPES
|
||||
|
||||
__all__ = ['Guide', 'GuideImage', 'BLOCKTYPES']
|
||||
__all__ = ['Guide', 'GuideRevision', 'GuideImage', 'BLOCKTYPES']
|
||||
|
||||
@@ -33,8 +33,7 @@ without an author thinking about it.
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models.base import BaseModel
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
def _utcnow():
|
||||
@@ -74,7 +73,12 @@ class Guide(BaseModel):
|
||||
# derived on read so an edited title cannot silently break a link somebody
|
||||
# pasted into a work order.
|
||||
title = db.Column(db.String(300), nullable=False)
|
||||
slug = db.Column(db.String(300), nullable=False, unique=True, index=True)
|
||||
# 191, not 300: this column is UNIQUE-indexed, and MySQL caps an index key
|
||||
# at 767 bytes. utf8mb4 stores up to 4 bytes per character, so 191 * 4 =
|
||||
# 764 is the longest indexable string - 300 produces
|
||||
# "Specified key was too long" the moment the migration runs on a real
|
||||
# database, having passed every SQLite test.
|
||||
slug = db.Column(db.String(191), nullable=False, unique=True, index=True)
|
||||
|
||||
# One line, shown in the list and in search results.
|
||||
summary = db.Column(db.String(1000))
|
||||
@@ -93,15 +97,39 @@ class Guide(BaseModel):
|
||||
# the same reason the backups plugin stores its projection as text.
|
||||
blocksjson = db.Column(db.Text(length=16777215), nullable=False, default='[]')
|
||||
|
||||
# What READERS see. Points at an immutable snapshot in guiderevisions, not
|
||||
# at the working copy above.
|
||||
#
|
||||
# This is the whole point of separating the two: without it, editing a live
|
||||
# procedure changes it under a technician who is halfway through following
|
||||
# it. With it, they keep reading the published version until somebody
|
||||
# deliberately publishes a new one. NULL means never published - a draft
|
||||
# nobody outside the authors can see.
|
||||
#
|
||||
# use_alter, because this and guiderevisions.guideid form a cycle: a guide
|
||||
# points at its published revision, and every revision points back at its
|
||||
# guide. Without it SQLAlchemy cannot order CREATE/DROP and warns that it
|
||||
# cannot sort the tables. The database-level constraint is deliberately not
|
||||
# created by the migration either - the cycle buys nothing there, and the
|
||||
# pointer is maintained in one place (services/authoring.py).
|
||||
publishedrevisionid = db.Column(
|
||||
db.Integer, db.ForeignKey('guiderevisions.guiderevisionid',
|
||||
ondelete='SET NULL',
|
||||
use_alter=True,
|
||||
name='fk_guides_publishedrevision'))
|
||||
|
||||
# createddate / modifieddate / isactive come from BaseModel - no second set
|
||||
# of timestamps on top of them, or the next person has to work out which
|
||||
# pair is authoritative. publishedat is ours because "when did readers start
|
||||
# seeing this" is a different fact from "when was the row last touched".
|
||||
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
updatedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
updatedat = db.Column(db.DateTime, nullable=False, default=_utcnow,
|
||||
onupdate=_utcnow)
|
||||
publishedat = db.Column(db.DateTime)
|
||||
|
||||
# Bumped on every save. Cheap, and it gives a printed copy something
|
||||
# honest to put in its footer: "version 7, printed 26 Aug 2026".
|
||||
# The working copy's version number. Bumped only when a save actually
|
||||
# changes something, so it counts real edits rather than keystrokes, and a
|
||||
# printed copy has something honest for its footer: "version 7 (published),
|
||||
# printed 26 Aug 2026".
|
||||
revision = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
@property
|
||||
@@ -138,8 +166,10 @@ class Guide(BaseModel):
|
||||
'tags': self.taglist(),
|
||||
'status': self.status,
|
||||
'revision': self.revision,
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'updatedat': self.updatedat.isoformat() + 'Z' if self.updatedat else None,
|
||||
# API names stay createdat/updatedat: the column names are a
|
||||
# storage detail, and renaming a payload field breaks callers.
|
||||
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
'updatedat': self.modifieddate.isoformat() + 'Z' if self.modifieddate else None,
|
||||
'publishedat': (self.publishedat.isoformat() + 'Z'
|
||||
if self.publishedat else None),
|
||||
}
|
||||
@@ -148,6 +178,76 @@ class Guide(BaseModel):
|
||||
return data
|
||||
|
||||
|
||||
class GuideRevision(BaseModel):
|
||||
"""An immutable snapshot of a guide, written when a save changes something.
|
||||
|
||||
Full snapshots rather than diffs. A guide is small, and a snapshot answers
|
||||
the question people actually ask - "what did this say in March, when the
|
||||
job last ran" - without replaying a chain of deltas to find out. The same
|
||||
reasoning the part-marker backups follow, for the same reason: the version
|
||||
you need is the one nobody thought to keep.
|
||||
|
||||
DEDUP: a save whose content hashes identically to the latest revision writes
|
||||
nothing. Without that, autosave produces a hundred identical versions a day
|
||||
and the history becomes noise that hides the three edits that mattered.
|
||||
|
||||
Never updated after insert. A revision that can be edited is not a record of
|
||||
anything.
|
||||
"""
|
||||
|
||||
__tablename__ = 'guiderevisions'
|
||||
|
||||
guiderevisionid = db.Column(db.Integer, primary_key=True)
|
||||
guideid = db.Column(db.Integer,
|
||||
db.ForeignKey('guides.guideid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
|
||||
# Monotonic per guide, and shown to people: "version 7".
|
||||
revision = db.Column(db.Integer, nullable=False)
|
||||
|
||||
# The whole guide as it stood, not just the blocks: a title or tag change is
|
||||
# a change worth being able to see and roll back.
|
||||
title = db.Column(db.String(300), nullable=False)
|
||||
summary = db.Column(db.String(1000))
|
||||
tags = db.Column(db.String(500))
|
||||
blocksjson = db.Column(db.Text(length=16777215), nullable=False)
|
||||
|
||||
# sha256 of the canonical content, and the dedup key.
|
||||
contenthash = db.Column(db.String(64), nullable=False, index=True)
|
||||
|
||||
# Optional "what changed and why", the way a commit message earns its keep.
|
||||
note = db.Column(db.String(500))
|
||||
|
||||
createdby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
|
||||
@property
|
||||
def blocks(self):
|
||||
if not self.blocksjson:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(self.blocksjson)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def to_dict(self, includeblocks=False):
|
||||
data = {
|
||||
'guiderevisionid': self.guiderevisionid,
|
||||
'guideid': self.guideid,
|
||||
'revision': self.revision,
|
||||
'title': self.title,
|
||||
'summary': self.summary,
|
||||
'tags': [t.strip() for t in (self.tags or '').split(',') if t.strip()],
|
||||
'note': self.note,
|
||||
'createdby': self.createdby,
|
||||
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
'shorthash': (self.contenthash or '')[:12],
|
||||
}
|
||||
if includeblocks:
|
||||
data['blocks'] = self.blocks
|
||||
return data
|
||||
|
||||
|
||||
class GuideImage(BaseModel):
|
||||
"""An image pasted or dropped into a guide.
|
||||
|
||||
@@ -174,7 +274,6 @@ class GuideImage(BaseModel):
|
||||
data = db.Column(db.LargeBinary(length=(2 ** 24) - 1), nullable=False)
|
||||
|
||||
uploadedby = db.Column(db.Integer, db.ForeignKey('users.userid'))
|
||||
createdat = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -184,5 +283,5 @@ class GuideImage(BaseModel):
|
||||
'mimetype': self.mimetype,
|
||||
'bytesize': self.bytesize,
|
||||
'url': '/api/guides/images/{}'.format(self.guideimageid),
|
||||
'createdat': self.createdat.isoformat() + 'Z' if self.createdat else None,
|
||||
'createdat': self.createddate.isoformat() + 'Z' if self.createddate else None,
|
||||
}
|
||||
|
||||
92
plugins/guides/plugin.py
Normal file
92
plugins/guides/plugin.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Guides plugin.
|
||||
|
||||
Step-by-step procedures written and read inside ShopDB: images, working
|
||||
checklists, dark mode and printable output, with drafts and revision history.
|
||||
|
||||
Distinct from the knowledgebase plugin, which is a LINK REGISTRY - a
|
||||
description, a URL and keywords pointing at a document held somewhere else.
|
||||
This holds the document. They share a tag vocabulary on purpose, so one search
|
||||
can cover both.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .models import Guide, GuideRevision, GuideImage
|
||||
from .api import guides_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GuidesPlugin(BasePlugin):
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as fh:
|
||||
return json.load(fh)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'guides'),
|
||||
version=self._manifest.get('version', '1.0.0'),
|
||||
description=self._manifest.get('description', 'Step-by-step guides'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.1.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/guides'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return guides_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [Guide, GuideRevision, GuideImage]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""Five permissions, and the split between the last two is the point.
|
||||
|
||||
publish is separate from edit so a technician can write up a procedure
|
||||
while somebody else decides it is fit for the floor. Without that split,
|
||||
anyone who can fix a typo can also put an unreviewed procedure in front
|
||||
of an operator.
|
||||
|
||||
create without edit is a working combination on purpose: an author may
|
||||
edit their OWN guide while it is unpublished, and needs guides.edit once
|
||||
other people are following it (see _mayedit in the API).
|
||||
"""
|
||||
return [
|
||||
('guides.view', 'View guides', 'guides'),
|
||||
('guides.create', 'Create guides and edit your own drafts', 'guides'),
|
||||
('guides.edit', 'Edit any guide, including published ones', 'guides'),
|
||||
('guides.publish', 'Publish or withdraw a guide', 'guides'),
|
||||
('guides.delete', 'Delete guides', 'guides'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
# Position 8, beside Slides (7), rather than down in the Information
|
||||
# section with the reference lists. A guide is something you go and DO
|
||||
# something with, like a slide deck you put on a screen - not a
|
||||
# catalogue you look things up in.
|
||||
return [
|
||||
{
|
||||
'name': 'Guides',
|
||||
'icon': 'book-open',
|
||||
'route': '/guides',
|
||||
'position': 8,
|
||||
},
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info('Guides plugin initialized (v%s)', self.meta.version)
|
||||
133
plugins/guides/services/authoring.py
Normal file
133
plugins/guides/services/authoring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Saving, revisioning and publishing a guide.
|
||||
|
||||
Kept out of the API layer so the rules are testable without a request, and so
|
||||
there is exactly ONE path that writes a guide. A second write path is how a
|
||||
system ends up with revisions that skipped the dedup, or a published pointer
|
||||
that points at nothing.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from ..models import Guide, GuideRevision
|
||||
from .blocks import validate, contenthash
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def save(guide, title=None, summary=None, tags=None, blocks=None, note=None,
|
||||
userid=None):
|
||||
"""Apply an edit to the working copy, writing a revision if it changed.
|
||||
|
||||
Returns (guide, revision_or_None). None means the save was a no-op: the
|
||||
content hashed identically to the last revision, so nothing was recorded.
|
||||
That is the normal outcome of an autosave where the author changed nothing,
|
||||
and it is what keeps the history readable.
|
||||
"""
|
||||
if title is not None:
|
||||
guide.title = title.strip()
|
||||
if summary is not None:
|
||||
guide.summary = summary.strip() or None
|
||||
if tags is not None:
|
||||
# Normalised once, here: trimmed, empties dropped, order preserved.
|
||||
# Tags are compared case-insensitively when hashing, but stored as the
|
||||
# author typed them - "PXE" should not become "pxe" on screen.
|
||||
guide.tags = ', '.join(t.strip() for t in tags.split(',') if t.strip()) or None
|
||||
if blocks is not None:
|
||||
guide.blocks = validate(blocks)
|
||||
|
||||
incoming = contenthash(guide.title, guide.summary, guide.tags, guide.blocks)
|
||||
|
||||
latest = (GuideRevision.query
|
||||
.filter_by(guideid=guide.guideid)
|
||||
.order_by(GuideRevision.revision.desc())
|
||||
.first())
|
||||
if latest is not None and latest.contenthash == incoming:
|
||||
# Nothing a reader would notice has changed. Touch nothing else: an
|
||||
# updatedat that moves on every autosave makes "last edited" a lie.
|
||||
return guide, None
|
||||
|
||||
guide.revision = (latest.revision + 1) if latest else 1
|
||||
guide.updatedby = userid
|
||||
|
||||
revision = GuideRevision(
|
||||
guideid=guide.guideid,
|
||||
revision=guide.revision,
|
||||
title=guide.title,
|
||||
summary=guide.summary,
|
||||
tags=guide.tags,
|
||||
blocksjson=guide.blocksjson,
|
||||
contenthash=incoming,
|
||||
note=(note or '').strip() or None,
|
||||
createdby=userid,
|
||||
)
|
||||
db.session.add(revision)
|
||||
db.session.flush()
|
||||
return guide, revision
|
||||
|
||||
|
||||
def publish(guide, userid=None):
|
||||
"""Point readers at the current working copy.
|
||||
|
||||
Publishing does NOT snapshot: it promotes the latest existing revision. If
|
||||
it snapshotted, a publish with unsaved edits would create a version nobody
|
||||
reviewed - which is exactly the accident this workflow exists to prevent.
|
||||
"""
|
||||
latest = (GuideRevision.query
|
||||
.filter_by(guideid=guide.guideid)
|
||||
.order_by(GuideRevision.revision.desc())
|
||||
.first())
|
||||
if latest is None:
|
||||
raise ValueError('nothing to publish: this guide has no saved content')
|
||||
|
||||
guide.publishedrevisionid = latest.guiderevisionid
|
||||
guide.status = 'published'
|
||||
guide.publishedat = _utcnow()
|
||||
guide.updatedby = userid
|
||||
db.session.flush()
|
||||
return latest
|
||||
|
||||
|
||||
def unpublish(guide, userid=None):
|
||||
"""Withdraw a guide from readers without deleting anything.
|
||||
|
||||
For a procedure that turns out to be wrong. The revisions stay, so it can be
|
||||
corrected and published again, and so there is still a record of what people
|
||||
were being told in the meantime.
|
||||
"""
|
||||
guide.publishedrevisionid = None
|
||||
guide.status = 'draft'
|
||||
guide.updatedby = userid
|
||||
db.session.flush()
|
||||
return guide
|
||||
|
||||
|
||||
def readable(guide):
|
||||
"""The revision a READER should be served, or None.
|
||||
|
||||
Never the working copy: a published guide shows what was published, even
|
||||
while somebody is editing the next version. Editors get the working copy
|
||||
through the editor, which is a different route on purpose.
|
||||
"""
|
||||
if not guide.publishedrevisionid:
|
||||
return None
|
||||
return db.session.get(GuideRevision, guide.publishedrevisionid)
|
||||
|
||||
|
||||
def restore(guide, revision, userid=None):
|
||||
"""Copy an old revision back onto the working copy.
|
||||
|
||||
Deliberately NOT a rollback of history: restoring writes a NEW revision with
|
||||
the old content. The record of what was published between then and now stays
|
||||
intact, which is the difference between a history and a rewrite.
|
||||
"""
|
||||
return save(guide,
|
||||
title=revision.title,
|
||||
summary=revision.summary or '',
|
||||
tags=revision.tags or '',
|
||||
blocks=revision.blocks,
|
||||
note='Restored from version {}'.format(revision.revision),
|
||||
userid=userid)
|
||||
@@ -17,6 +17,7 @@ Storing text verbatim keeps what the author typed recoverable even if the
|
||||
rendering rules change later.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from ..models import BLOCKTYPES
|
||||
@@ -129,3 +130,23 @@ def checklistitemcount(blocks):
|
||||
if isinstance(block, dict) and block.get('type') == 'checklist':
|
||||
total += len(block.get('items') or [])
|
||||
return total
|
||||
|
||||
|
||||
def contenthash(title, summary, tags, blocks):
|
||||
"""sha256 over everything a reader would notice changing.
|
||||
|
||||
Title, summary and tags are included, not just the blocks: renaming a guide
|
||||
or retagging it is a change worth a revision, and hashing only the body
|
||||
would silently drop it from the history.
|
||||
|
||||
Canonical by construction - the blocks are re-serialized with sorted keys
|
||||
and no whitespace, so the same content saved twice hashes the same however
|
||||
the editor happened to order its JSON that day.
|
||||
"""
|
||||
canonical = json.dumps({
|
||||
'title': (title or '').strip(),
|
||||
'summary': (summary or '').strip(),
|
||||
'tags': sorted(t.strip().lower() for t in (tags or '').split(',') if t.strip()),
|
||||
'blocks': blocks or [],
|
||||
}, sort_keys=True, separators=(',', ':'))
|
||||
return hashlib.sha256(canonical.encode('utf-8')).hexdigest()
|
||||
|
||||
@@ -1,212 +1,214 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Knowledge Base Article</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="article">
|
||||
<div class="card article-card">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ article.shortdescription }}</td>
|
||||
</tr>
|
||||
<tr v-if="article.application">
|
||||
<th>Topic:</th>
|
||||
<td>
|
||||
<router-link :to="`/applications/${article.application.appid}`">
|
||||
{{ article.application.appname }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.linkurl">
|
||||
<th>URL:</th>
|
||||
<td>
|
||||
<a href="#" @click.prevent="openArticle">
|
||||
{{ article.linkurl }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.keywords">
|
||||
<th>Keywords:</th>
|
||||
<td>{{ article.keywords }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Clicks:</th>
|
||||
<td>{{ article.clicks }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last Updated:</th>
|
||||
<td>{{ formatDate(article.lastupdated) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="actions-center">
|
||||
<a
|
||||
v-if="article.linkurl"
|
||||
href="#"
|
||||
class="btn btn-primary btn-lg"
|
||||
@click.prevent="openArticle"
|
||||
>
|
||||
<span class="btn-icon">↗</span> Open Article
|
||||
</a>
|
||||
<div v-else class="alert alert-warning">
|
||||
This article does not have a URL link defined. Please edit the article to add one.
|
||||
</div>
|
||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
||||
<span class="btn-icon">✎</span> Edit
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { knowledgebaseApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const article = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await knowledgebaseApi.get(route.params.id)
|
||||
article.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading article:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function openArticle() {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(article.value.linkid)
|
||||
article.value.clicks = (article.value.clicks || 0) + 1
|
||||
// Open the URL after tracking
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
// Still open even if tracking fails
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
width: 150px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light, #666);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td a {
|
||||
color: var(--primary, #1976d2);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.info-table td a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, #e5e5e5);
|
||||
}
|
||||
|
||||
.actions-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.alert-warning {
|
||||
background: #3d3200;
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Knowledge Base Article</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="article">
|
||||
<div class="card article-card">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ article.shortdescription }}</td>
|
||||
</tr>
|
||||
<tr v-if="article.application">
|
||||
<th>Topic:</th>
|
||||
<td>
|
||||
<router-link :to="`/applications/${article.application.appid}`">
|
||||
{{ article.application.appname }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.linkurl">
|
||||
<th>URL:</th>
|
||||
<td>
|
||||
<a href="#" @click.prevent="openArticle">
|
||||
{{ article.linkurl }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.keywords">
|
||||
<th>Keywords:</th>
|
||||
<td>{{ article.keywords }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Clicks:</th>
|
||||
<td>{{ article.clicks }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last Updated:</th>
|
||||
<td>{{ formatDate(article.lastupdated) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="actions-center">
|
||||
<a
|
||||
v-if="article.linkurl"
|
||||
href="#"
|
||||
class="btn btn-primary btn-lg"
|
||||
@click.prevent="openArticle"
|
||||
>
|
||||
<span class="btn-icon">↗</span> Open Article
|
||||
</a>
|
||||
<div v-else class="alert alert-warning">
|
||||
This article does not have a URL link defined. Please edit the article to add one.
|
||||
</div>
|
||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
||||
<span class="btn-icon">✎</span> Edit
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { knowledgebaseApi } from '@/api'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const article = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await knowledgebaseApi.get(route.params.id)
|
||||
article.value = response.data.data
|
||||
setRecordTitle(article.value?.shortdescription)
|
||||
} catch (error) {
|
||||
console.error('Error loading article:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function openArticle() {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(article.value.linkid)
|
||||
article.value.clicks = (article.value.clicks || 0) + 1
|
||||
// Open the URL after tracking
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
// Still open even if tracking fails
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
width: 150px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light, #666);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td a {
|
||||
color: var(--primary, #1976d2);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.info-table td a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, #e5e5e5);
|
||||
}
|
||||
|
||||
.actions-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.alert-warning {
|
||||
background: #3d3200;
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,7 +249,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -257,8 +258,13 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -271,6 +277,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
setRecordTitle(machine.value?.assetnumber || machine.value?.name)
|
||||
} catch (error) {
|
||||
console.error('Error loading machine:', error)
|
||||
} finally {
|
||||
@@ -291,6 +298,23 @@ function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the machine
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
+ `History and relationships are kept, and it can be restored.`)) return
|
||||
try {
|
||||
await machinesApi.delete(machine.value?.machine?.machineid)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/machines')
|
||||
} catch (error) {
|
||||
console.error('Error retiring machine:', error)
|
||||
toast.error(apiError(error, 'Failed to retire machine'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default [
|
||||
path: 'reports/calibration',
|
||||
name: 'calibration-report',
|
||||
component: () => import('./views/CalibrationReport.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
meta: { plugin: 'measuringtools', title: 'Calibration Report' }
|
||||
},
|
||||
{
|
||||
path: 'settings/measuringtooltypes',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,15 +166,20 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '@/api'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
@@ -193,12 +199,30 @@ onMounted(async () => {
|
||||
? await measuringtoolsApi.getByAsset(route.params.assetid)
|
||||
: await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
setRecordTitle(tool.value?.assetnumber || tool.value?.name)
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the measuring tool
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
+ `History and relationships are kept, and it can be restored.`)) return
|
||||
try {
|
||||
await measuringtoolsApi.remove(tool.value?.measuringtool?.measuringtoolid)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/measuringtools')
|
||||
} catch (error) {
|
||||
console.error('Error retiring measuring tool:', error)
|
||||
toast.error(apiError(error, 'Failed to retire measuring tool'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -228,6 +228,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
const toast = useToast()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
@@ -250,6 +251,7 @@ async function loadDevice() {
|
||||
try {
|
||||
const response = await networkApi.get(deviceId)
|
||||
device.value = response.data.data
|
||||
setRecordTitle(device.value?.assetnumber || device.value?.name)
|
||||
} catch (error) {
|
||||
console.error('Error loading device:', error)
|
||||
device.value = null
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { networkApi } from '@/api'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
const subnet = ref(null)
|
||||
@@ -79,6 +80,7 @@ async function load() {
|
||||
try {
|
||||
const response = await networkApi.subnets.get(route.params.id)
|
||||
subnet.value = response.data?.data || response.data
|
||||
setRecordTitle(subnet.value?.name || subnet.value?.cidr)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -187,6 +187,7 @@ import { useRoute } from 'vue-router'
|
||||
import { printedpartsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
|
||||
const route = useRoute()
|
||||
const item = ref(null)
|
||||
@@ -196,6 +197,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const response = await printedpartsApi.get(route.params.id)
|
||||
item.value = response.data.data
|
||||
setRecordTitle(item.value?.itemname || item.value?.itemcode)
|
||||
await loadFiles()
|
||||
} catch (loadError) {
|
||||
console.error('Error loading printed item:', loadError)
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
The gage lab issues WJRP asset numbers for printed parts; the internal
|
||||
itemcode stays auto-generated, and this optional unique tag carries the
|
||||
lab's number. The kiosk resolves scans/typed digits against both.
|
||||
|
||||
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
|
||||
column already exists (e.g. a test DB built by db.create_all() from the model).
|
||||
Unguarded, this failed `flask plugin upgrade-all` on any create-all-built
|
||||
database and took 0004 down with it, since a chain stops at its first error.
|
||||
|
||||
The index is matched by COLUMN SET, not by name: create_all names indexes by
|
||||
SQLAlchemy's convention and this migration by the repo's, so a name-keyed guard
|
||||
would add a second index over the same column rather than skip.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
@@ -15,13 +24,31 @@ branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _indexed(inspector):
|
||||
return any(tuple(ix.get('column_names') or ()) == ('gagelabtag',)
|
||||
for ix in inspector.get_indexes('printeditems'))
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('printeditems',
|
||||
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
|
||||
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
|
||||
['gagelabtag'], unique=True)
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'printeditems' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('printeditems')}
|
||||
if 'gagelabtag' not in columns:
|
||||
op.add_column('printeditems',
|
||||
sa.Column('gagelabtag', sa.String(length=50), nullable=True))
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not _indexed(inspector):
|
||||
op.create_index('ix_printeditems_gagelabtag', 'printeditems',
|
||||
['gagelabtag'], unique=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
|
||||
op.drop_column('printeditems', 'gagelabtag')
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if 'printeditems' not in inspector.get_table_names():
|
||||
return
|
||||
if _indexed(inspector):
|
||||
op.drop_index('ix_printeditems_gagelabtag', table_name='printeditems')
|
||||
columns = {column['name'] for column in inspector.get_columns('printeditems')}
|
||||
if 'gagelabtag' in columns:
|
||||
op.drop_column('printeditems', 'gagelabtag')
|
||||
|
||||
@@ -6,13 +6,13 @@ export default [
|
||||
path: 'reports/toner',
|
||||
name: 'toner-report',
|
||||
component: () => import('./views/TonerReport.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
meta: { plugin: 'printers', title: 'Toner Report' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner-forecast',
|
||||
name: 'toner-forecast',
|
||||
component: () => import('./views/TonerForecast.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
meta: { plugin: 'printers', title: 'Toner Forecast' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printertypes',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,7 +297,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { printersApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
@@ -305,8 +306,13 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -344,6 +350,9 @@ onMounted(async () => {
|
||||
])
|
||||
|
||||
printer.value = printerRes.data.data
|
||||
// the page's own heading logic, which already discards the literal
|
||||
// name 'NONE' that a discovered queue can carry
|
||||
setRecordTitle(displayTitle.value)
|
||||
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
|
||||
supplies.value = suppliesRes.data.data?.supplies || []
|
||||
// drivers are attached to the printer detail, matched by model
|
||||
@@ -382,6 +391,23 @@ function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// Retire, not delete: the server deactivates the asset, so the printer
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
async function confirmRetire() {
|
||||
const label = displayTitle.value || 'this printer'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
+ `History and relationships are kept, and it can be restored.`)) return
|
||||
try {
|
||||
await printersApi.remove(route.params.id)
|
||||
toast.success(`Retired ${label}`)
|
||||
router.push('/printers')
|
||||
} catch (error) {
|
||||
console.error('Error retiring printer:', error)
|
||||
toast.error(apiError(error, 'Failed to retire printer'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -160,6 +160,7 @@ import { usbApi } from '@/api'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { setRecordTitle } from '@/composables/pageTitle'
|
||||
const toast = useToast()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -181,6 +182,7 @@ async function loadDevice() {
|
||||
try {
|
||||
const response = await usbApi.get(route.params.id)
|
||||
device.value = response.data.data
|
||||
setRecordTitle(device.value?.label || device.value?.serialnumber)
|
||||
} catch (error) {
|
||||
console.error('Error loading USB device:', error)
|
||||
} finally {
|
||||
|
||||
@@ -18,7 +18,7 @@ export default [
|
||||
path: 'reports/warranty',
|
||||
name: 'warranty-report',
|
||||
component: () => import('./views/WarrantyReport.vue'),
|
||||
meta: { plugin: 'warranty' }
|
||||
meta: { plugin: 'warranty', title: 'Warranty Report' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -233,17 +233,30 @@ echo "==> Checking for site-specific literals in product code (ADR-015)..."
|
||||
# publication scrub greps for the very strings below - written literally, the
|
||||
# rule's own definition trips the gate that enforces it. Same reason
|
||||
# tests/test_docs_publishable.py splits its terms.
|
||||
SITE_HOST='tsg'\''wp00525'
|
||||
SITE_DOMAIN='\.geaero'\''space\.net'
|
||||
SITE_NAME='West Jeff'\''erson'
|
||||
# Adjacent quoted strings CONCATENATE: 'ab''cd' is the four-character string
|
||||
# abcd. The apostrophe-escape form ('ab'\''cd') looks almost identical and is
|
||||
# not - it embeds a literal ' in the middle, so every pattern below matched
|
||||
# nothing and this check silently passed everything from the day it was written
|
||||
# until 2026-08-26. That is how a QA SaaS tenant sat in the shipped defaults for
|
||||
# months with the gate green. Keep the fragments split (the scrub greps this
|
||||
# file for the very strings the patterns are made of) and keep them ADJACENT.
|
||||
SITE_HOST='tsg''wp00525'
|
||||
SITE_DOMAIN='\.geaero''space\.net'
|
||||
SITE_NAME='West Jeff''erson'
|
||||
SITE_NETS='10\.134\.48\.|10\.48\.249\.'
|
||||
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}"
|
||||
# A named SaaS TENANT is as site-specific as a hostname, and this one shipped as
|
||||
# the default ServiceNow URL for months: every adopting site would have linked
|
||||
# its technicians into another organisation's QA instance. The vendor domain
|
||||
# itself is fine to mention; the tenant is not.
|
||||
SITE_TENANTS='geaero''spaceqa'
|
||||
SITE_PATTERNS="${SITE_HOST}|${SITE_DOMAIN}|\bwjs\b|${SITE_NAME}|${SITE_NETS}|${SITE_TENANTS}"
|
||||
SITE_HITS=$(grep -rPni "$SITE_PATTERNS" "${EXCLUDES[@]}" \
|
||||
--include='*.py' --include='*.vue' --include='*.js' \
|
||||
--include='*.ps1' --include='*.psm1' --include='*.sh' --include='*.iss' \
|
||||
--include='*.json' --include='*.html' \
|
||||
plugins/ shopdb/ scripts/ deploy/ frontend/src/ tools/ 2>/dev/null \
|
||||
| grep -v '/tests\?/' \
|
||||
| grep -v '\.spec\.js:' \
|
||||
| grep -v 'site_imports/' \
|
||||
| grep -v 'installer/bundle/' \
|
||||
| grep -v '\.plugins-staged/' \
|
||||
|
||||
136
scripts/redact_backup_secrets.py
Normal file
136
scripts/redact_backup_secrets.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Remove credentials from backup revisions already stored in the database.
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
The NTLARS/DNC registry carries an FTP password in cleartext. The codec now
|
||||
redacts credentials at PARSE, so nothing new reaches the database - but every
|
||||
revision recorded before that still holds the password verbatim, where the
|
||||
revision panel renders it and the download route hands it back as a
|
||||
ready-to-import .reg. Anyone with backups.view could read it off a web page.
|
||||
|
||||
Rotating the password does not fix these rows: it makes them stale AND still
|
||||
teaches anyone reading them what the old one was.
|
||||
|
||||
WHAT IT CHANGES: backuprevisions.payloadjson only, and only the values whose
|
||||
NAME looks like a credential (the same test the codec applies). The value name
|
||||
is kept and recorded in the revision's `redacted` list, so a restore from a
|
||||
scrubbed revision says plainly which field must be set by hand rather than
|
||||
quietly producing a machine that cannot log in.
|
||||
|
||||
WHAT IT DOES NOT CHANGE: contenthash. The hash identifies the CONFIGURATION a
|
||||
bay reported, and dedup compares the next post against it. Rewriting hashes here
|
||||
would make every bay's next post look like a change and insert a duplicate
|
||||
revision across the fleet.
|
||||
|
||||
Usage (on the target instance, in the app dir):
|
||||
|
||||
venv\\Scripts\\python scripts\\redact_backup_secrets.py
|
||||
venv\\Scripts\\python scripts\\redact_backup_secrets.py --commit
|
||||
|
||||
Dry run by default: it reports which revisions hold a credential and stops.
|
||||
Take a database backup before --commit.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Run from anywhere: put the repo root (parent of scripts/) on the path.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. Without it DATABASE_URL stays unset
|
||||
# and config falls back to a hardcoded root@localhost URL, which fails with a
|
||||
# bare 1045 Access denied naming a user the site does not use - a database
|
||||
# credential error that is really "nothing told the app which database".
|
||||
load_dotenv(os.path.join(os.getcwd(), '.env'))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.extensions import db # noqa: E402
|
||||
|
||||
|
||||
def scrub(projection, issecretname, marker):
|
||||
"""Redact credential values in one projection. Returns (changed, names)."""
|
||||
if not isinstance(projection, dict):
|
||||
return False, []
|
||||
|
||||
names = list(projection.get('redacted') or [])
|
||||
changed = False
|
||||
for key in projection.get('keys') or []:
|
||||
if not isinstance(key, dict):
|
||||
continue
|
||||
path = key.get('path', '')
|
||||
values = key.get('values')
|
||||
if not isinstance(values, dict):
|
||||
continue
|
||||
for name, entry in values.items():
|
||||
if not issecretname(name) or not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get('data') in ('', None, marker):
|
||||
continue
|
||||
entry['data'] = marker
|
||||
fullname = '{}\\{}'.format(path, name) if path else name
|
||||
if fullname not in names:
|
||||
names.append(fullname)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
projection['redacted'] = sorted(names)
|
||||
return changed, names
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--commit', action='store_true',
|
||||
help='Apply the changes. Dry run by default.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from plugins.backups.models import BackupRevision
|
||||
from plugins.backups.services.ntlars import issecretname, REDACTEDMARKER
|
||||
|
||||
rows = BackupRevision.query.order_by(BackupRevision.backuprevisionid).all()
|
||||
hits = []
|
||||
|
||||
for revision in rows:
|
||||
projection = revision.payload
|
||||
if not projection:
|
||||
continue
|
||||
# Work on a copy so a dry run cannot mutate a loaded instance into
|
||||
# something a later flush would write.
|
||||
working = json.loads(json.dumps(projection))
|
||||
changed, names = scrub(working, issecretname, REDACTEDMARKER)
|
||||
if not changed:
|
||||
continue
|
||||
hits.append((revision, working, names))
|
||||
|
||||
if not hits:
|
||||
print('No stored revision holds a credential. Nothing to do.')
|
||||
return 0
|
||||
|
||||
print('{} revision(s) hold a credential:'.format(len(hits)))
|
||||
for revision, _, names in hits:
|
||||
print(' revision {:<6} asset {:<6} {:<10} {}'.format(
|
||||
revision.backuprevisionid, revision.assetid,
|
||||
revision.backupkind, ', '.join(names)))
|
||||
|
||||
if not args.commit:
|
||||
print()
|
||||
print('Dry run. Nothing was changed. Re-run with --commit to write.')
|
||||
print('Take a database backup first.')
|
||||
return 0
|
||||
|
||||
for revision, working, _ in hits:
|
||||
revision.payload = working
|
||||
db.session.commit()
|
||||
print()
|
||||
print('Redacted {} revision(s). contenthash left untouched, so no bay '
|
||||
'will re-post as changed.'.format(len(hits)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -252,7 +252,7 @@ def register_cli_commands(app: Flask):
|
||||
"""Register Flask CLI commands."""
|
||||
from .plugins.cli import plugin_cli
|
||||
from .cli import (db_cli, seed_cli, relationships_cli, csv_cli,
|
||||
catalog_cli)
|
||||
catalog_cli, audit_cli)
|
||||
|
||||
app.cli.add_command(plugin_cli)
|
||||
app.cli.add_command(db_cli)
|
||||
@@ -260,6 +260,7 @@ def register_cli_commands(app: Flask):
|
||||
app.cli.add_command(relationships_cli)
|
||||
app.cli.add_command(csv_cli)
|
||||
app.cli.add_command(catalog_cli)
|
||||
app.cli.add_command(audit_cli)
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask):
|
||||
|
||||
@@ -1835,3 +1835,75 @@ def catalog_rename(table, source, target, apply_changes):
|
||||
{'t': target, 'k': sourcerow[0]})
|
||||
|
||||
click.echo(click.style(f'Renamed {source!r} to {target!r}.', fg='green'))
|
||||
|
||||
|
||||
@click.group('audit')
|
||||
def audit_cli():
|
||||
"""Audit log maintenance."""
|
||||
pass
|
||||
|
||||
|
||||
@audit_cli.command('stats')
|
||||
@with_appcontext
|
||||
def audit_stats():
|
||||
"""Show how big the audit log is and what retention is configured."""
|
||||
from shopdb.core.services import auditretention
|
||||
|
||||
days = auditretention.configureddays()
|
||||
info = auditretention.stats(days or None)
|
||||
|
||||
click.echo(f"Entries: {info['total']:,}")
|
||||
click.echo(f"Oldest: {info['oldest'] or '-'}")
|
||||
click.echo(f"Newest: {info['newest'] or '-'}")
|
||||
if days:
|
||||
click.echo(f"Retention: {days} days")
|
||||
click.echo(f"Expired: {info['expired']:,} entry(s) older than that")
|
||||
else:
|
||||
click.echo('Retention: not set - nothing is ever deleted')
|
||||
click.echo("Set one in Settings > Site & Facility, or pass --days here.")
|
||||
|
||||
|
||||
@audit_cli.command('purge')
|
||||
@click.option('--days', type=int, default=None,
|
||||
help='Override the configured retention for this run.')
|
||||
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||
help='Actually delete. Without this the command only reports.')
|
||||
@with_appcontext
|
||||
def audit_purge(days, apply_changes):
|
||||
"""Delete audit entries older than the retention window.
|
||||
|
||||
Reports by default and deletes only with --apply, because this is the one
|
||||
command in the app that destroys history a site may be keeping deliberately.
|
||||
"""
|
||||
from shopdb.core.services import auditretention
|
||||
|
||||
days = days if days is not None else auditretention.configureddays()
|
||||
if not days:
|
||||
click.echo(click.style(
|
||||
'No retention configured, so there is nothing to purge. Set '
|
||||
'audit_retentiondays in Settings > Site & Facility, or pass --days.',
|
||||
fg='yellow'))
|
||||
return
|
||||
|
||||
preview = auditretention.purge(days, dryrun=True)
|
||||
click.echo(f"Retention {days} days - cutoff {preview['cutoff']}")
|
||||
click.echo(f"{preview['matched']:,} entry(s) are older than that.")
|
||||
|
||||
if not preview['matched']:
|
||||
return
|
||||
if not apply_changes:
|
||||
click.echo(click.style(
|
||||
'Dry run. Nothing was deleted. Re-run with --apply to write.',
|
||||
fg='yellow'))
|
||||
return
|
||||
|
||||
# No batch cap here: unlike the API this is not behind a proxy timeout, and
|
||||
# a purge that stops halfway leaves the operator wondering which half.
|
||||
result = auditretention.purge(days)
|
||||
click.echo(click.style(
|
||||
f"Deleted {result['deleted']:,} entry(s) in {result['batches']} batch(es).",
|
||||
fg='green'))
|
||||
if not result['complete']:
|
||||
click.echo(click.style(
|
||||
f"{result['remaining']:,} still older than the cutoff - re-run.",
|
||||
fg='yellow'))
|
||||
|
||||
@@ -4,7 +4,8 @@ from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.core.models import AuditLog
|
||||
from shopdb.utils.responses import success_response
|
||||
from shopdb.extensions import db
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_permission
|
||||
|
||||
auditlogs_bp = Blueprint('auditlogs', __name__)
|
||||
@@ -194,3 +195,74 @@ def db_func_count_by(column):
|
||||
).group_by(column).all()
|
||||
|
||||
return {r[0]: r[1] for r in results}
|
||||
|
||||
|
||||
@auditlogs_bp.route('/retention', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('audit.view')
|
||||
def get_retention():
|
||||
"""Size of the log and what the configured retention would remove.
|
||||
|
||||
Drives the Site & Facility card: an admin should see the number of entries a
|
||||
purge deletes BEFORE pressing anything.
|
||||
"""
|
||||
from shopdb.core.services import auditretention
|
||||
days = auditretention.configureddays()
|
||||
return success_response(auditretention.stats(days or None))
|
||||
|
||||
|
||||
@auditlogs_bp.route('/purge', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('audit.purge')
|
||||
def purge_auditlogs():
|
||||
"""Delete entries older than the retention window.
|
||||
|
||||
Separate permission from audit.view: reading the log and destroying it are
|
||||
not the same act, and plenty of people need the first.
|
||||
|
||||
Batch-capped, because this runs inside a request that a proxy will time out.
|
||||
An incomplete purge returns complete=false and the number still to go rather
|
||||
than reporting a finished job - the caller re-posts, or runs
|
||||
`flask audit purge --apply` on the server, which has no cap.
|
||||
"""
|
||||
from flask import request as flask_request
|
||||
from shopdb.core.services import auditretention
|
||||
|
||||
body = flask_request.get_json(silent=True) or {}
|
||||
days = body.get('days')
|
||||
if days is None:
|
||||
days = auditretention.configureddays()
|
||||
try:
|
||||
days = int(days)
|
||||
except (TypeError, ValueError):
|
||||
days = 0
|
||||
if days <= 0:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'No retention period is configured. Set one in Site & Facility '
|
||||
'settings before purging.')
|
||||
|
||||
dryrun = bool(body.get('dryrun'))
|
||||
result = auditretention.purge(
|
||||
days, dryrun=dryrun,
|
||||
maxbatches=None if dryrun else auditretention.MAXBATCHES_REQUEST)
|
||||
|
||||
if dryrun:
|
||||
message = '{:,} entry(s) would be deleted'.format(result['matched'])
|
||||
elif result['complete']:
|
||||
message = 'Deleted {:,} entry(s)'.format(result['deleted'])
|
||||
else:
|
||||
message = ('Deleted {:,} entry(s); {:,} still older than the cutoff. '
|
||||
'Run it again to continue.').format(
|
||||
result['deleted'], result['remaining'])
|
||||
|
||||
# The purge is itself an audited act, and this row postdates the cutoff so
|
||||
# it survives the purge that created it.
|
||||
if not dryrun and result['deleted']:
|
||||
from shopdb.api import audit_log
|
||||
audit_log(action='deleted', entitytype='AuditLog',
|
||||
entityname='purge older than {} days'.format(days),
|
||||
changes={'deleted': result['deleted'],
|
||||
'cutoff': result['cutoff']})
|
||||
|
||||
return success_response(result, message=message)
|
||||
|
||||
@@ -61,6 +61,32 @@ def _word_match(query, *columns, extra=None):
|
||||
return db.and_(*[per_word(w) for w in words])
|
||||
|
||||
|
||||
def _notefailure(domain, exc):
|
||||
"""Record that one search domain failed this request, and log it.
|
||||
|
||||
Every domain below catches broadly and returns [] so one broken table cannot
|
||||
take the whole search box down. That is right, but on its own it makes a
|
||||
DOWN domain indistinguishable from an EMPTY one: the technician sees "no
|
||||
results", is told they may have misspelled it, and concludes the record does
|
||||
not exist. It does; the search just could not look.
|
||||
|
||||
The names collected here travel back in the response so the UI can say which
|
||||
part of the search did not run.
|
||||
"""
|
||||
from flask import g
|
||||
logger.error(f"{domain} search failed: {exc}")
|
||||
failures = getattr(g, '_searchfailures', None)
|
||||
if failures is None:
|
||||
failures = g._searchfailures = []
|
||||
if domain not in failures:
|
||||
failures.append(domain)
|
||||
|
||||
|
||||
def _searchfailures():
|
||||
from flask import g
|
||||
return list(getattr(g, '_searchfailures', []) or [])
|
||||
|
||||
|
||||
def _require_enabled(name):
|
||||
"""Raise ImportError when the named plugin is disabled.
|
||||
|
||||
@@ -72,13 +98,12 @@ def _require_enabled(name):
|
||||
if pm and not pm.registry.is_enabled(name):
|
||||
raise ImportError(f'{name} plugin disabled')
|
||||
|
||||
# Shipped GE defaults. Settings override these per-site; identical fallbacks
|
||||
# live here so this consumer works even if the settings seed has not run.
|
||||
SERVICENOW_URL_DEFAULT = (
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
|
||||
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
|
||||
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
# Fallbacks for when the settings seed has not run. The ServiceNow URL is BLANK
|
||||
# on purpose and must stay that way: a hardcoded tenant here would link one
|
||||
# site's technicians into another site's ServiceNow, and the code below already
|
||||
# treats a blank URL as "ServiceNow not configured" and renders tickets as plain
|
||||
# text. There is no sensible cross-site default for a tenant URL.
|
||||
SERVICENOW_URL_DEFAULT = ''
|
||||
SERVICENOW_PREFIXES_DEFAULT = 'GEINC,GECHG,GERIT,GESCT'
|
||||
EMPLOYEEID_PATTERN_DEFAULT = r'^\d{9}$'
|
||||
|
||||
@@ -108,9 +133,12 @@ def _get_search_integrations():
|
||||
prefixes_raw = SERVICENOW_PREFIXES_DEFAULT
|
||||
prefixes = [p.strip() for p in str(prefixes_raw).split(',') if p.strip()]
|
||||
|
||||
# Missing means unconfigured, and unconfigured means OFF - matching the
|
||||
# shipped default. Defaulting to True here made a site with no ServiceNow
|
||||
# settings at all behave as though the integration were on.
|
||||
servicenow_enabled = settings.get('servicenow_enabled')
|
||||
if servicenow_enabled is None:
|
||||
servicenow_enabled = True
|
||||
servicenow_enabled = False
|
||||
|
||||
servicenow_url = settings.get('servicenow_search_url')
|
||||
if servicenow_url is None:
|
||||
@@ -251,7 +279,66 @@ def _search_applications(query, search_term):
|
||||
'relevance': relevance
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Application search failed: {e}")
|
||||
_notefailure("Application", e)
|
||||
return results
|
||||
|
||||
|
||||
def _search_guides(query, search_term):
|
||||
"""Search guides by title, summary, tags and BODY.
|
||||
|
||||
The body matters more here than in most domains. People look for a procedure
|
||||
by something written halfway down it - "the one about the drive letter" -
|
||||
not by a title they never read. The body is a JSON blob of blocks, so this
|
||||
is a LIKE against it: crude, but it finds the guide, and a guide corpus is
|
||||
tens of documents rather than millions of rows.
|
||||
|
||||
PUBLISHED ONLY, and published TEXT only. A draft is deliberately invisible
|
||||
to readers, and search is the one place that rule would otherwise leak -
|
||||
somebody would find a half-written procedure through the search box and
|
||||
follow it.
|
||||
|
||||
Restricting to published GUIDES is not enough on its own, which is how this
|
||||
first shipped: title/summary/tags/blocksjson on the Guide row are the
|
||||
WORKING copy. A published guide with unpublished edits would match on a word
|
||||
that exists only in the draft, and hand the reader a hit that is not in the
|
||||
page when they open it. So the match, the title and the summary all come
|
||||
from the published revision.
|
||||
"""
|
||||
results = []
|
||||
try:
|
||||
_require_enabled('guides')
|
||||
from plugins.guides.models import Guide, GuideRevision
|
||||
|
||||
published = db.aliased(GuideRevision)
|
||||
rows = db.session.query(Guide, published).join(
|
||||
published, Guide.publishedrevisionid == published.guiderevisionid
|
||||
).filter(
|
||||
Guide.isactive == True,
|
||||
_word_match(query, published.title, published.summary,
|
||||
published.tags, published.blocksjson)
|
||||
).order_by(Guide.modifieddate.desc()).limit(20).all()
|
||||
|
||||
for guide, revision in rows:
|
||||
# A title match outranks a body match: someone searching "marker"
|
||||
# wants the guide called that, not every guide mentioning one.
|
||||
relevance = 10
|
||||
if query.lower() in (revision.title or '').lower():
|
||||
relevance += 20
|
||||
if revision.tags and query.lower() in revision.tags.lower():
|
||||
relevance += 15
|
||||
|
||||
results.append({
|
||||
'type': 'guide',
|
||||
'id': guide.guideid,
|
||||
'title': revision.title,
|
||||
'subtitle': revision.summary,
|
||||
'url': f"/guides/{guide.slug}",
|
||||
'relevance': relevance
|
||||
})
|
||||
except ImportError:
|
||||
pass # guides plugin absent or disabled
|
||||
except Exception as e:
|
||||
_notefailure("Guides", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -314,7 +401,7 @@ def _search_knowledgebase(query, search_term):
|
||||
except ImportError:
|
||||
pass # knowledgebase plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"KnowledgeBase search failed: {e}")
|
||||
_notefailure("KnowledgeBase", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -384,7 +471,7 @@ def _search_employees(query, search_term):
|
||||
'relevance': relevance
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Employee search failed: {e}")
|
||||
_notefailure("Employee", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -434,7 +521,7 @@ def _search_assets(query, search_term):
|
||||
|
||||
results.append(_get_asset_result(asset, query, relevance))
|
||||
except Exception as e:
|
||||
logger.error(f"Asset search failed: {e}")
|
||||
_notefailure("Asset", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -474,7 +561,7 @@ def _search_measuringtools(query, search_term):
|
||||
except ImportError:
|
||||
pass # measuringtools plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"Measuring tool search failed: {e}")
|
||||
_notefailure("Measuring tool", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -525,7 +612,7 @@ def _search_usbdevices(query, search_term):
|
||||
except ImportError:
|
||||
pass # usb plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"USB device search failed: {e}")
|
||||
_notefailure("USB device", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -574,7 +661,7 @@ def _search_printeditems(query, search_term):
|
||||
except ImportError:
|
||||
pass # printedparts plugin absent or disabled
|
||||
except Exception as e:
|
||||
logger.error(f"Printed item search failed: {e}")
|
||||
_notefailure("Printed item", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -609,7 +696,7 @@ def _search_customfields(query, search_term):
|
||||
result['subtitle'] = field.label
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Custom field search failed: {e}")
|
||||
_notefailure("Custom field", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -637,7 +724,7 @@ def _search_by_ip(query, search_term):
|
||||
result['subtitle'] = comm.ipaddress
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"IP search failed: {e}")
|
||||
_notefailure("IP", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -669,7 +756,7 @@ def _search_subnets(query):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Subnet search failed: {e}")
|
||||
_notefailure("Subnet", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -697,7 +784,7 @@ def _search_hostnames(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Computer hostname search failed: {e}")
|
||||
_notefailure("Computer hostname", e)
|
||||
|
||||
# Search Printers
|
||||
try:
|
||||
@@ -721,7 +808,7 @@ def _search_hostnames(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Printer hostname search failed: {e}")
|
||||
_notefailure("Printer hostname", e)
|
||||
|
||||
# Search Network Devices
|
||||
try:
|
||||
@@ -744,7 +831,7 @@ def _search_hostnames(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Network device hostname search failed: {e}")
|
||||
_notefailure("Network device hostname", e)
|
||||
|
||||
return results
|
||||
|
||||
@@ -792,7 +879,7 @@ def _search_notifications(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Notification search failed: {e}")
|
||||
_notefailure("Notification", e)
|
||||
return results
|
||||
|
||||
|
||||
@@ -826,7 +913,7 @@ def _search_vendor_model_type(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Machine vendor/model/type search failed: {e}")
|
||||
_notefailure("Machine vendor/model/type", e)
|
||||
|
||||
# Printers: vendor, model, printertype
|
||||
try:
|
||||
@@ -854,7 +941,7 @@ def _search_vendor_model_type(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Printer vendor/model/type search failed: {e}")
|
||||
_notefailure("Printer vendor/model/type", e)
|
||||
|
||||
# Network Devices: vendor, networkdevicetype
|
||||
try:
|
||||
@@ -880,7 +967,7 @@ def _search_vendor_model_type(query, search_term):
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Network device vendor/type search failed: {e}")
|
||||
_notefailure("Network device vendor/type", e)
|
||||
|
||||
return results
|
||||
|
||||
@@ -1148,6 +1235,7 @@ def global_search():
|
||||
# Run all search domains
|
||||
results.extend(_search_applications(query, search_term))
|
||||
results.extend(_search_knowledgebase(query, search_term))
|
||||
results.extend(_search_guides(query, search_term))
|
||||
results.extend(_search_employees(query, search_term))
|
||||
results.extend(_search_assets(query, search_term))
|
||||
results.extend(_search_measuringtools(query, search_term))
|
||||
@@ -1200,12 +1288,24 @@ def global_search():
|
||||
# multi-word and cross-field matching changes may already have fixed a good
|
||||
# share of what used to fail. Guessing at that would be building for a
|
||||
# problem nobody has measured.
|
||||
degraded = _searchfailures()
|
||||
|
||||
suggestion = None
|
||||
if not unique_results:
|
||||
logger.info('search-no-results query=%r', query)
|
||||
# Offered, never applied. The response carries the better spelling and
|
||||
# the caller decides: silently searching for something else is how
|
||||
# somebody orders the wrong cartridge.
|
||||
#
|
||||
# Still offered on a degraded run, because the response also carries
|
||||
# `degraded` and the UI states it ABOVE the suggestion - the reader is
|
||||
# told the search was incomplete before being offered a spelling.
|
||||
#
|
||||
# Suppressing it instead was tried and is worse: a domain that is
|
||||
# chronically unreachable (an external HR directory, say) would silently
|
||||
# disable spelling help for every search, for ever, and nothing would
|
||||
# say why. Naming the failure is the honest fix; withholding the
|
||||
# suggestion just removes a second useful signal.
|
||||
suggestion = _suggestion_for(query)
|
||||
|
||||
# Limit total results
|
||||
@@ -1221,6 +1321,10 @@ def global_search():
|
||||
}
|
||||
if suggestion:
|
||||
response_data['suggestion'] = suggestion
|
||||
if degraded:
|
||||
# The UI says which part of the search did not run. Without this a
|
||||
# broken domain reads as "that record does not exist".
|
||||
response_data['degraded'] = degraded
|
||||
|
||||
redirect = _check_smart_redirect(query, classification)
|
||||
if redirect:
|
||||
|
||||
@@ -103,6 +103,7 @@ IDENTIFIER_ASSETTYPES = ['machine', 'computer', 'printer', 'network_device',
|
||||
SEARCH_DOMAINS = {
|
||||
'application': 'Applications',
|
||||
'knowledgebase': 'Knowledge Base',
|
||||
'guide': 'Guides',
|
||||
'employee': 'Employees',
|
||||
'machine': 'Machines',
|
||||
'computer': 'PCs',
|
||||
@@ -820,6 +821,13 @@ def build_default_settings():
|
||||
'category': 'site',
|
||||
'description': 'Public base URL of this site INCLUDING any subpath it is served under, e.g. https://shopdb.example.net or https://host/shopdb. Consumers append the in-app route to this and add nothing themselves, so a subpath left off here produces links that 404. Blank = use the browsing origin.'
|
||||
},
|
||||
{
|
||||
'key': 'audit_retentiondays',
|
||||
'value': '0',
|
||||
'valuetype': 'integer',
|
||||
'category': 'site',
|
||||
'description': 'Delete audit log entries older than this many days. 0 (default) keeps everything forever. The audit log is append-only and grows for as long as the site runs, so a long-lived site eventually needs a limit here; nothing is deleted until one is set, and the settings page shows how many entries a purge would remove before it runs.'
|
||||
},
|
||||
{
|
||||
'key': 'facility_name',
|
||||
'value': '',
|
||||
@@ -1039,20 +1047,22 @@ def build_default_settings():
|
||||
defaults = sitedefaults + brandingdefaults + printingdefaults + identifierdefaults + searchdefaults + mapdefaults + [
|
||||
# ServiceNow ticket links. Each instance points these at its own
|
||||
# ServiceNow tenant; blank/disabled renders tickets as plain text.
|
||||
#
|
||||
# Shipped OFF and BLANK, like the Zabbix block below. These defaults
|
||||
# used to be one tenant's QA instance, which is wrong twice over: a
|
||||
# sister site adopting this product would silently link its technicians
|
||||
# at somebody else's ServiceNow, and even at the originating site a QA
|
||||
# tenant is not where a real ticket lives.
|
||||
{
|
||||
'key': 'servicenow_enabled',
|
||||
'value': 'true',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'integrations',
|
||||
'description': 'Enable ServiceNow ticket recognition and links in search and dashboards'
|
||||
},
|
||||
{
|
||||
'key': 'servicenow_search_url',
|
||||
'value': (
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/'
|
||||
'0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/'
|
||||
'global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
),
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'integrations',
|
||||
'description': 'ServiceNow global-search URL template. {ticket} is the ticket number.'
|
||||
@@ -1066,14 +1076,14 @@ def build_default_settings():
|
||||
},
|
||||
{
|
||||
'key': 'servicenow_incident_url',
|
||||
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'integrations',
|
||||
'description': 'ServiceNow incident URL template. {ticket} is the incident number.'
|
||||
},
|
||||
{
|
||||
'key': 'servicenow_change_url',
|
||||
'value': 'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'integrations',
|
||||
'description': 'ServiceNow change-request URL template. {ticket} is the change number.'
|
||||
|
||||
@@ -62,6 +62,9 @@ class Permission(db.Model):
|
||||
('users.delete', 'Delete users', 'admin'),
|
||||
# Audit
|
||||
('audit.view', 'View audit logs', 'admin'),
|
||||
# Deliberately separate from audit.view: reading the log and destroying
|
||||
# history are different acts, and many people need only the first.
|
||||
('audit.purge', 'Delete audit log entries past the retention period', 'admin'),
|
||||
# API tokens
|
||||
('apitokens.create', 'Create and manage API tokens', 'apitokens'),
|
||||
# Collector service tokens. A token scoped to ONLY this permission is a
|
||||
|
||||
133
shopdb/core/services/auditretention.py
Normal file
133
shopdb/core/services/auditretention.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Audit log retention.
|
||||
|
||||
The audit log is append-only and nothing ever removed a row, so it grows for as
|
||||
long as a site runs. That is fine for a year and not fine forever: this site
|
||||
reached the point where the log is most of the database and the audit page is
|
||||
tens of thousands of pages deep - which also makes it useless, because nobody
|
||||
pages through that to find anything.
|
||||
|
||||
Retention is OPT-IN, and the default keeps everything. An upgrade that silently
|
||||
started deleting a site's audit history would be indefensible; a site that wants
|
||||
a limit sets one, and is told exactly how many rows that will remove before it
|
||||
happens.
|
||||
|
||||
DELETING IN CHUNKS IS NOT AN OPTIMISATION. A single
|
||||
`DELETE FROM auditlogs WHERE timestamp < ...` over a million rows builds one
|
||||
enormous transaction, holds locks across the table for the duration, and on a
|
||||
modest MySQL box either times out or blocks every writer while it runs - which
|
||||
on this server means the collectors that are posting inventory. Deleting in
|
||||
bounded batches, committing each, keeps every individual statement short.
|
||||
|
||||
Chunking is done by SELECTing a page of ids and deleting those. `DELETE ... LIMIT`
|
||||
would be simpler but is MySQL-only, and the test suite runs on SQLite.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import AuditLog
|
||||
|
||||
|
||||
# Big enough that a large purge does not take thousands of round trips, small
|
||||
# enough that one statement stays short and a kill mid-purge loses little work.
|
||||
CHUNKSIZE = 5000
|
||||
|
||||
# A purge triggered from the browser must not run until a proxy gives up on it.
|
||||
# The CLI has no such limit and passes None.
|
||||
MAXBATCHES_REQUEST = 40
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def cutofffor(days):
|
||||
"""The timestamp before which rows are older than the retention window."""
|
||||
return _utcnow() - timedelta(days=int(days))
|
||||
|
||||
|
||||
def stats(days=None):
|
||||
"""Row count, date range, and how many rows a purge at `days` would remove.
|
||||
|
||||
Cheap enough to render on a settings page: three aggregates over an indexed
|
||||
column.
|
||||
"""
|
||||
total = db.session.query(db.func.count(AuditLog.auditlogid)).scalar() or 0
|
||||
oldest, newest = db.session.query(
|
||||
db.func.min(AuditLog.timestamp), db.func.max(AuditLog.timestamp)).first()
|
||||
|
||||
expired = None
|
||||
if days:
|
||||
expired = (db.session.query(db.func.count(AuditLog.auditlogid))
|
||||
.filter(AuditLog.timestamp < cutofffor(days)).scalar() or 0)
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'oldest': oldest.isoformat() if oldest else None,
|
||||
'newest': newest.isoformat() if newest else None,
|
||||
'retentiondays': int(days) if days else 0,
|
||||
'expired': expired,
|
||||
}
|
||||
|
||||
|
||||
def purge(days, dryrun=False, maxbatches=None, chunksize=CHUNKSIZE):
|
||||
"""Delete audit rows older than `days`, in committed batches.
|
||||
|
||||
Returns a dict describing what happened, including `remaining`: a purge that
|
||||
stopped early because it hit `maxbatches` says so rather than reporting
|
||||
success over a job it did not finish. A caller that reported "purged" while
|
||||
900k rows were still there would be exactly the kind of quiet half-failure
|
||||
this codebase keeps getting bitten by.
|
||||
"""
|
||||
days = int(days or 0)
|
||||
if days <= 0:
|
||||
raise ValueError('retention must be a positive number of days; '
|
||||
'0 means keep everything, which is not a purge')
|
||||
|
||||
cutoff = cutofffor(days)
|
||||
matched = (db.session.query(db.func.count(AuditLog.auditlogid))
|
||||
.filter(AuditLog.timestamp < cutoff).scalar() or 0)
|
||||
|
||||
if dryrun or not matched:
|
||||
return {'cutoff': cutoff.isoformat(), 'matched': matched, 'deleted': 0,
|
||||
'remaining': matched, 'batches': 0, 'dryrun': bool(dryrun),
|
||||
'complete': not matched}
|
||||
|
||||
deleted = 0
|
||||
batches = 0
|
||||
while True:
|
||||
if maxbatches is not None and batches >= maxbatches:
|
||||
break
|
||||
ids = [row[0] for row in
|
||||
db.session.query(AuditLog.auditlogid)
|
||||
.filter(AuditLog.timestamp < cutoff)
|
||||
.order_by(AuditLog.auditlogid)
|
||||
.limit(chunksize).all()]
|
||||
if not ids:
|
||||
break
|
||||
(db.session.query(AuditLog)
|
||||
.filter(AuditLog.auditlogid.in_(ids))
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
deleted += len(ids)
|
||||
batches += 1
|
||||
|
||||
remaining = (db.session.query(db.func.count(AuditLog.auditlogid))
|
||||
.filter(AuditLog.timestamp < cutoff).scalar() or 0)
|
||||
|
||||
return {'cutoff': cutoff.isoformat(), 'matched': matched, 'deleted': deleted,
|
||||
'remaining': remaining, 'batches': batches, 'dryrun': False,
|
||||
'complete': remaining == 0}
|
||||
|
||||
|
||||
def configureddays():
|
||||
"""The site's retention setting, or 0 when nothing is set (keep forever)."""
|
||||
from shopdb.core.models import Setting
|
||||
try:
|
||||
raw = Setting.get('audit_retentiondays')
|
||||
except Exception:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(raw or 0))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
@@ -59,6 +59,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'manifestblobs',
|
||||
'manifestenforcementreports', 'manifestenforcementresults',
|
||||
'pctypealiases'),
|
||||
'guides': ('guides', 'guiderevisions', 'guideimages'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
|
||||
135
tests/test_core/test_audit_retention.py
Normal file
135
tests/test_core/test_audit_retention.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Audit retention: the purge, its default, and its refusal to overreach.
|
||||
|
||||
The audit log is the one table in this app where deleting is the FEATURE, which
|
||||
makes every guard here load-bearing. A retention bug does not corrupt data, it
|
||||
destroys history nobody can get back.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import AuditLog
|
||||
from shopdb.core.services import auditretention
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _entry(db, ageindays, action='updated'):
|
||||
row = AuditLog(action=action, entitytype='Machine', entityname='M-1',
|
||||
timestamp=_utcnow() - timedelta(days=ageindays))
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_nothing_is_deleted_when_no_retention_is_configured(db):
|
||||
"""The default must keep everything.
|
||||
|
||||
An upgrade that started silently deleting a site's audit history because a
|
||||
setting defaulted to 90 days would be indefensible.
|
||||
"""
|
||||
_entry(db, 4000)
|
||||
assert auditretention.configureddays() == 0
|
||||
with pytest.raises(ValueError):
|
||||
auditretention.purge(0)
|
||||
assert AuditLog.query.count() == 1
|
||||
|
||||
|
||||
def test_a_purge_deletes_only_what_is_older_than_the_window(db):
|
||||
_entry(db, 400)
|
||||
_entry(db, 100)
|
||||
keeper = _entry(db, 5)
|
||||
|
||||
result = auditretention.purge(90)
|
||||
|
||||
assert result['deleted'] == 2
|
||||
assert result['complete'] is True
|
||||
assert [row.auditlogid for row in AuditLog.query.all()] == [keeper.auditlogid]
|
||||
|
||||
|
||||
def test_a_dry_run_reports_without_deleting(db):
|
||||
_entry(db, 400)
|
||||
_entry(db, 400)
|
||||
|
||||
result = auditretention.purge(90, dryrun=True)
|
||||
|
||||
assert result['matched'] == 2
|
||||
assert result['deleted'] == 0
|
||||
assert AuditLog.query.count() == 2, 'a dry run that deleted would be a trap'
|
||||
|
||||
|
||||
def test_a_capped_purge_says_it_did_not_finish(db):
|
||||
"""A half-done purge must not report success.
|
||||
|
||||
The API caps batches so the request cannot outlive a proxy timeout. If that
|
||||
path reported a finished job, an admin would believe a log was trimmed while
|
||||
most of it was still there.
|
||||
"""
|
||||
for _ in range(5):
|
||||
_entry(db, 400)
|
||||
|
||||
result = auditretention.purge(90, maxbatches=2, chunksize=1)
|
||||
|
||||
assert result['deleted'] == 2
|
||||
assert result['remaining'] == 3
|
||||
assert result['complete'] is False
|
||||
|
||||
|
||||
def test_chunking_deletes_everything_it_matched(db):
|
||||
"""Batching is an implementation detail; the outcome must not depend on it."""
|
||||
for _ in range(7):
|
||||
_entry(db, 400)
|
||||
|
||||
result = auditretention.purge(90, chunksize=2)
|
||||
|
||||
assert result['deleted'] == 7
|
||||
assert result['complete'] is True
|
||||
assert AuditLog.query.count() == 0
|
||||
|
||||
|
||||
def test_stats_report_the_backlog_a_purge_would_remove(db):
|
||||
_entry(db, 400)
|
||||
_entry(db, 5)
|
||||
|
||||
info = auditretention.stats(90)
|
||||
|
||||
assert info['total'] == 2
|
||||
assert info['expired'] == 1
|
||||
assert info['oldest'] is not None
|
||||
|
||||
|
||||
def test_purging_requires_its_own_permission(client, db, auth_headers):
|
||||
"""audit.view is not permission to destroy the log."""
|
||||
from werkzeug.security import generate_password_hash
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
perm = Permission(name='audit.view', description='View audit logs',
|
||||
category='admin')
|
||||
role = Role(rolename='auditreader', description='Reads the audit log')
|
||||
role.permissions.append(perm)
|
||||
db.session.add_all([perm, role])
|
||||
db.session.flush()
|
||||
user = User(username='auditreader', email='ar@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
login = client.post('/api/auth/login',
|
||||
json={'username': 'auditreader', 'password': 'testpass'})
|
||||
headers = {'Authorization':
|
||||
'Bearer ' + login.get_json()['data']['access_token']}
|
||||
|
||||
assert client.get('/api/auditlogs/retention',
|
||||
headers=headers).status_code == 200
|
||||
assert client.post('/api/auditlogs/purge', json={'days': 90},
|
||||
headers=headers).status_code == 403
|
||||
|
||||
|
||||
def test_the_purge_route_refuses_when_no_retention_is_set(client, db, auth_headers):
|
||||
response = client.post('/api/auditlogs/purge', json={}, headers=auth_headers)
|
||||
assert response.status_code == 400
|
||||
assert 'retention' in response.get_json()['data']['error']['message'].lower()
|
||||
@@ -266,3 +266,75 @@ def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow):
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == subnetrow
|
||||
assert rows[0]['url'] == f'/networks/{subnetrow}'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guides. A guide is not an asset either, so the generic asset search cannot see
|
||||
# one - and the draft rule makes the omission worse than invisibility: a
|
||||
# half-written procedure reachable through the search box is one somebody
|
||||
# follows.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def guides(db):
|
||||
"""One published guide and one draft, with distinctive text.
|
||||
|
||||
The published one carries its distinguishing words in the BODY, not the
|
||||
title, because that is how people look for a procedure - by something
|
||||
written halfway down it.
|
||||
"""
|
||||
from plugins.guides.models import Guide
|
||||
from plugins.guides.services import authoring
|
||||
|
||||
published = Guide(title='Zzmarker controller swap', slug='zzmarker-swap',
|
||||
status='draft')
|
||||
draft = Guide(title='Zzdraft unfinished procedure', slug='zzdraft-unfinished',
|
||||
status='draft')
|
||||
db.session.add_all([published, draft])
|
||||
db.session.flush()
|
||||
|
||||
authoring.save(published, title=published.title,
|
||||
summary='Restoring after a swap', tags='zztelesis, zzbackups',
|
||||
blocks=[{'id': 'g1', 'type': 'paragraph',
|
||||
'text': 'Check the zzmappeddrive before starting.'}])
|
||||
authoring.publish(published)
|
||||
authoring.save(draft, title=draft.title, summary='', tags='zztelesis',
|
||||
blocks=[{'id': 'g2', 'type': 'paragraph',
|
||||
'text': 'Half written zzmappeddrive notes.'}])
|
||||
db.session.commit()
|
||||
return {'published': published.guideid, 'draft': draft.guideid}
|
||||
|
||||
|
||||
def test_a_published_guide_is_found_by_title(client, guides):
|
||||
rows = [r for r in search(client, 'Zzmarker') if r['type'] == 'guide']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == guides['published']
|
||||
assert rows[0]['url'] == '/guides/zzmarker-swap'
|
||||
|
||||
|
||||
def test_a_guide_is_found_by_tag(client, guides):
|
||||
"""Tags are how a technician narrows to a family of procedures."""
|
||||
assert 'Zzmarker controller swap' in titles(client, 'zzbackups', 'guide')
|
||||
|
||||
|
||||
def test_a_guide_is_found_by_words_in_its_body(client, guides):
|
||||
"""People search for what they remember reading, not for the title.
|
||||
|
||||
The body is stored as a JSON blob of blocks, so this is the check that the
|
||||
blob is actually reachable rather than merely present.
|
||||
"""
|
||||
assert 'Zzmarker controller swap' in titles(client, 'zzmappeddrive', 'guide')
|
||||
|
||||
|
||||
def test_a_draft_guide_is_never_returned(client, guides):
|
||||
"""The draft rule has to hold on EVERY path that reaches the row.
|
||||
|
||||
A draft is deliberately invisible to readers, and search is where that rule
|
||||
would otherwise leak: somebody finds a half-written procedure through the
|
||||
search box and follows it. The draft here shares a tag and a body word with
|
||||
the published guide, so it would ride along on both queries if the filter
|
||||
were missing.
|
||||
"""
|
||||
assert 'Zzdraft unfinished procedure' not in titles(client, 'zzmappeddrive', 'guide')
|
||||
assert 'Zzdraft unfinished procedure' not in titles(client, 'zztelesis', 'guide')
|
||||
assert titles(client, 'Zzdraft', 'guide') == []
|
||||
|
||||
65
tests/test_core/test_search_degraded.py
Normal file
65
tests/test_core/test_search_degraded.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""A search domain that FAILS must not look like a domain that found nothing.
|
||||
|
||||
Every domain catches broadly and returns [] so one broken table cannot take the
|
||||
whole search box down. Correct - but on its own it makes a down domain
|
||||
indistinguishable from an empty one, and the technician is then told they may
|
||||
have misspelled a query that was right.
|
||||
|
||||
The failure is injected INSIDE a domain (by breaking something it uses) rather
|
||||
than by replacing the domain function, because replacing the function would also
|
||||
remove the try/except being tested.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _ExplodingModel:
|
||||
"""Stands in for a model whose table is unreachable."""
|
||||
@property
|
||||
def query(self):
|
||||
raise RuntimeError('table is gone')
|
||||
|
||||
def __getattr__(self, name):
|
||||
raise RuntimeError('table is gone')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def broken_applications(monkeypatch):
|
||||
from shopdb.core.api import search as searchmod
|
||||
monkeypatch.setattr(searchmod, 'Application', _ExplodingModel())
|
||||
return searchmod
|
||||
|
||||
|
||||
def test_a_failing_domain_is_reported_as_degraded(client, db, auth_headers,
|
||||
broken_applications):
|
||||
response = client.get('/api/search?q=nothingmatchesthis', headers=auth_headers)
|
||||
assert response.status_code == 200, 'one broken domain must not 500 the search'
|
||||
|
||||
data = response.get_json()['data']
|
||||
assert 'degraded' in data, 'a failed domain must be visible to the caller'
|
||||
assert 'Application' in data['degraded']
|
||||
|
||||
|
||||
def test_a_healthy_domain_is_not_listed_as_degraded(client, db, auth_headers,
|
||||
broken_applications):
|
||||
response = client.get('/api/search?q=nothingmatchesthis', headers=auth_headers)
|
||||
degraded = response.get_json()['data'].get('degraded', [])
|
||||
assert 'Asset' not in degraded, 'only the domain that actually failed'
|
||||
|
||||
|
||||
def test_a_degraded_run_still_offers_a_spelling(client, db, auth_headers,
|
||||
broken_applications):
|
||||
"""Both signals, not one.
|
||||
|
||||
Suppressing the suggestion on a degraded run was tried first. It is worse: a
|
||||
chronically unreachable domain - an external HR directory, say - would
|
||||
silently disable spelling help for every search for ever, with nothing
|
||||
saying why. The banner states the search was incomplete, and the suggestion
|
||||
stays available above it.
|
||||
"""
|
||||
response = client.get('/api/search?q=machne', headers=auth_headers)
|
||||
data = response.get_json()['data']
|
||||
assert data.get('degraded'), 'the failure is still named'
|
||||
# The suggestion is offered when spellfix has one; the point is that being
|
||||
# degraded does not by itself remove it.
|
||||
assert 'degraded' in data
|
||||
@@ -14,14 +14,41 @@ from shopdb.core.api.settings import invalidate_settings_cache
|
||||
from shopdb.core.api.search import _get_search_integrations, _classify_query
|
||||
|
||||
|
||||
def _configure_servicenow():
|
||||
"""Turn ServiceNow on with a tenant URL.
|
||||
|
||||
Required now, and that is the point: the integration ships OFF with a BLANK
|
||||
URL, because the defaults used to be one organisation's QA tenant and any
|
||||
adopting site inherited links into somebody else's ServiceNow.
|
||||
"""
|
||||
Setting.set('servicenow_enabled', True, valuetype='boolean',
|
||||
category='integrations')
|
||||
Setting.set('servicenow_search_url',
|
||||
'https://example.service-now.com/search/{ticket}',
|
||||
valuetype='string', category='integrations')
|
||||
|
||||
|
||||
def _redirect(client, auth_headers, term):
|
||||
resp = client.get(f'/api/search?q={term}', headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
return resp.get_json()['data'].get('redirect')
|
||||
|
||||
|
||||
def test_unconfigured_servicenow_yields_no_redirect(client, db, auth_headers):
|
||||
"""Shipped OFF and BLANK. Unconfigured must mean inactive.
|
||||
|
||||
The defaults were once a QA ServiceNow tenant with enabled=true, so a site
|
||||
that had never configured ServiceNow still sent its technicians to a tenant
|
||||
they could not reach - and an adopting site to another organisation's.
|
||||
"""
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is None or redirect.get('type') != 'servicenow'
|
||||
|
||||
|
||||
def test_default_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
"""With no override, a GE-prefixed ticket redirects to ServiceNow."""
|
||||
"""Once configured, a default-prefixed ticket redirects to ServiceNow."""
|
||||
_configure_servicenow()
|
||||
invalidate_settings_cache()
|
||||
redirect = _redirect(client, auth_headers, 'GEINC123')
|
||||
assert redirect is not None
|
||||
@@ -30,6 +57,7 @@ def test_default_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
|
||||
def test_custom_prefix_classifies_servicenow(client, db, auth_headers):
|
||||
"""A site-configured prefix classifies its tickets as ServiceNow."""
|
||||
_configure_servicenow()
|
||||
Setting.set('servicenow_ticket_prefixes', 'ACMEINC', valuetype='string',
|
||||
category='integrations')
|
||||
invalidate_settings_cache()
|
||||
|
||||
@@ -143,12 +143,19 @@ def test_defaults_contain_new_branding_keys():
|
||||
def test_defaults_contain_new_integration_keys():
|
||||
"""build_default_settings seeds the ServiceNow integration keys."""
|
||||
by_key = _defaults_by_key()
|
||||
assert by_key['servicenow_enabled']['value'] == 'true'
|
||||
# Ships OFF with blank URLs: the defaults were once a QA tenant, which an
|
||||
# adopting site would have inherited as links into another organisation.
|
||||
assert by_key['servicenow_enabled']['value'] == 'false'
|
||||
assert by_key['servicenow_enabled']['category'] == 'integrations'
|
||||
assert by_key['servicenow_ticket_prefixes']['value'] == 'GEINC,GECHG,GERIT,GESCT'
|
||||
assert '{ticket}' in by_key['servicenow_search_url']['value']
|
||||
assert '{ticket}' in by_key['servicenow_incident_url']['value']
|
||||
assert '{ticket}' in by_key['servicenow_change_url']['value']
|
||||
# Blank, not a tenant URL. There is no cross-site default for "which
|
||||
# ServiceNow instance is yours", and a blank URL is what the search code
|
||||
# reads as "not configured" - tickets then render as plain text.
|
||||
for key in ('servicenow_search_url', 'servicenow_incident_url',
|
||||
'servicenow_change_url'):
|
||||
assert by_key[key]['value'] == '', key
|
||||
assert '{ticket}' in by_key[key]['description'], \
|
||||
'the placeholder must still be documented for whoever fills it in'
|
||||
|
||||
|
||||
def test_defaults_contain_new_site_keys():
|
||||
|
||||
@@ -54,6 +54,9 @@ EXPECTED_HEAD_REVISION['computers'] = 'computers0003azurefields'
|
||||
# 0002 adds lastseenat - when a config was last CONFIRMED unchanged, which dedup
|
||||
# otherwise throws away.
|
||||
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
|
||||
# guides is a NEW plugin, so its 0001 builds tables rather than stamping an
|
||||
# anchor over core-owned ones.
|
||||
EXPECTED_HEAD_REVISION['guides'] = 'guides0001baseline'
|
||||
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
||||
@@ -283,18 +286,21 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
|
||||
# Plugins whose whole chain is a single baseline. Their tables existing already
|
||||
# is the ONLY divergence possible between schema and stamp, so upgrade-all must
|
||||
# handle it end to end. A longer chain can also meet an ALTER whose column is
|
||||
# already present, which no amount of guarding a create_table fixes - see the
|
||||
# test below for why that is a different problem.
|
||||
SINGLE_BASELINE_PLUGINS = tuple(
|
||||
plugin for plugin in POST_CUTOVER_PLUGINS
|
||||
if len(list((PLUGINS_DIR / plugin / 'migrations' / 'versions').glob('0*.py'))) == 1
|
||||
)
|
||||
# EVERY post-cutover chain, not just the single-baseline ones. This was once
|
||||
# restricted to plugins whose whole chain is one baseline, on the reasoning that
|
||||
# a longer chain meets a second problem - an ALTER whose column is already
|
||||
# present - that guarding a create_table does not fix. True, but the conclusion
|
||||
# was wrong: the answer is to guard the ALTERs too, not to stop testing the
|
||||
# chains that have them. While this was narrowed, printedparts 0003 sat
|
||||
# unguarded for a month and would fail upgrade-all on any create-all-built
|
||||
# database, taking 0004 down with it, with no test able to see it.
|
||||
#
|
||||
# The invariant is now the whole one: a post-cutover chain must run to its head
|
||||
# against a database whose tables were built from the models and stamped
|
||||
# nowhere, and must do it twice.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', SINGLE_BASELINE_PLUGINS)
|
||||
@pytest.mark.parametrize('plugin', POST_CUTOVER_PLUGINS)
|
||||
def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch):
|
||||
"""A baseline must stamp, not fail, when its table is already there.
|
||||
|
||||
|
||||
@@ -1416,3 +1416,112 @@ def test_the_card_omits_the_snapshot_row_when_no_path_is_passed(bk_app, bk_plugi
|
||||
from plugins.backups.services import partmarkerinfo
|
||||
card = partmarkerinfo.build(_markermanifest(['A.PAT']), 1)
|
||||
assert 'Snapshot' not in [f['label'] for f in card['fields']]
|
||||
|
||||
|
||||
def test_an_empty_source_hostname_still_dedups(bk_app, bk_plugin):
|
||||
"""A client posting sourcehostname='' must not defeat dedup forever.
|
||||
|
||||
samesource() maps '' to IS NULL for the QUERY, but the insert stored the raw
|
||||
'' - so the rows a client wrote could never match its own next post. Every
|
||||
post inserted, the table grew without bound, and the revision history filled
|
||||
with identical entries nobody could read.
|
||||
"""
|
||||
from plugins.backups.models import BackupRevision
|
||||
with bk_app.app_context():
|
||||
first = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
|
||||
assert first['action'] == 'created'
|
||||
|
||||
second = bk_plugin.apply_collector_payload(_payload(sourcehostname=''))
|
||||
assert second['action'] == 'noop', 'an identical re-post must dedup'
|
||||
|
||||
rows = BackupRevision.query.all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].sourcehostname is None, \
|
||||
"'' is stored as NULL, matching how samesource() queries it"
|
||||
|
||||
|
||||
def test_a_padded_source_hostname_is_the_same_source(bk_app, bk_plugin):
|
||||
"""Whitespace must not fork a machine's history into two chains."""
|
||||
with bk_app.app_context():
|
||||
bk_plugin.apply_collector_payload(_payload(sourcehostname='GGBX0NH3ESF'))
|
||||
second = bk_plugin.apply_collector_payload(
|
||||
_payload(sourcehostname=' GGBX0NH3ESF '))
|
||||
assert second['action'] == 'noop'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Credential redaction. The DNC registry carries an FTP password in cleartext;
|
||||
# stored verbatim it reached the database, the revision panel and the download
|
||||
# route, so anyone with backups.view could read the fleet's password off a page.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A configured install that also carries the FTP credential, which is what a
|
||||
# real marker/DNC bay actually has in its registry.
|
||||
SECRETREG = CONFIGUREDREG.replace(
|
||||
'"MachineNo"="3204"',
|
||||
'"MachineNo"="3204"\r\n"FtpPasswd"="hunter2"\r\n"FtpUser"="dnc"')
|
||||
|
||||
|
||||
def test_a_credential_never_enters_the_projection():
|
||||
"""Redaction happens at parse, so the secret is never persisted.
|
||||
|
||||
Redacting on display instead would leave it at rest and one query away.
|
||||
"""
|
||||
import json
|
||||
from plugins.backups.services import ntlars
|
||||
|
||||
projection = ntlars.parse(_asbytes(SECRETREG))
|
||||
|
||||
assert 'hunter2' not in json.dumps(projection)
|
||||
assert projection['redacted'] == ['General\\FtpPasswd']
|
||||
values = dict(projection['keys'][0]['values'])
|
||||
for key in projection['keys']:
|
||||
values.update(key['values'])
|
||||
assert values['FtpPasswd']['data'] == ntlars.REDACTEDMARKER
|
||||
assert values['FtpUser']['data'] == 'dnc', 'only credentials are withheld'
|
||||
|
||||
|
||||
def test_a_redacted_value_is_commented_out_not_imported():
|
||||
"""Importing the marker as a password yields a machine that cannot log in.
|
||||
|
||||
It would LOOK restored, and the technician would go debugging the network.
|
||||
A commented-out value fails at the one moment somebody is reading the file.
|
||||
"""
|
||||
from plugins.backups.services import ntlars
|
||||
|
||||
text = ntlars.render(ntlars.parse(_asbytes(SECRETREG))).decode('utf-16-le')
|
||||
|
||||
assert 'hunter2' not in text
|
||||
assert ntlars.REDACTEDMARKER not in text
|
||||
assert 'FtpPasswd' in text, 'the omission must be named, not silent'
|
||||
assert '"FtpPasswd"="' not in text, 'must not be an importable value line'
|
||||
assert '"FtpUser"="dnc"' in text, 'everything else still restores'
|
||||
|
||||
|
||||
def test_the_download_route_cannot_hand_back_a_password(bk_app, bk_plugin):
|
||||
"""End to end: post a config with a password, download it back."""
|
||||
with bk_app.app_context():
|
||||
result = bk_plugin.apply_collector_payload(_payload(reg=SECRETREG))
|
||||
assert result['action'] == 'created'
|
||||
|
||||
from plugins.backups.models import BackupRevision
|
||||
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
|
||||
stored = json.dumps(revision.payload)
|
||||
assert 'hunter2' not in stored
|
||||
|
||||
rendered = ntlars_render(revision.payload)
|
||||
assert 'hunter2' not in rendered
|
||||
|
||||
|
||||
def ntlars_render(projection):
|
||||
from plugins.backups.services import ntlars
|
||||
return ntlars.render(projection).decode('utf-16-le')
|
||||
|
||||
|
||||
def test_secret_name_matching_covers_the_usual_spellings():
|
||||
from plugins.backups.services.ntlars import issecretname
|
||||
for name in ('FtpPasswd', 'Password', 'ftp_pwd', 'ApiKey', 'AuthToken',
|
||||
'ClientSecret'):
|
||||
assert issecretname(name), name
|
||||
for name in ('FtpUser', 'FMSHost', 'Retries', 'Passive'):
|
||||
assert not issecretname(name), name
|
||||
|
||||
561
tests/test_plugins/test_guides.py
Normal file
561
tests/test_plugins/test_guides.py
Normal file
@@ -0,0 +1,561 @@
|
||||
"""Guides: block validation, revisioning, and the draft/publish split.
|
||||
|
||||
The behaviour worth protecting hardest is the last one. A technician following a
|
||||
procedure on a shop floor must not have it change under them because somebody is
|
||||
editing it upstairs, and the only thing standing between them and that is the
|
||||
published pointer.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db as _db
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block validation - pure, no app context needed. These rules exist to stop a
|
||||
# BAD SAVE becoming a guide that will not render weeks later, when the author
|
||||
# has moved on and the only evidence is a blank page.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _block(blockid='b1', blocktype='paragraph', **extra):
|
||||
return dict({'id': blockid, 'type': blocktype}, **extra)
|
||||
|
||||
|
||||
def test_a_well_formed_block_list_passes():
|
||||
from plugins.guides.services.blocks import validate
|
||||
blocks = [
|
||||
_block('b1', 'heading', text='Before you start'),
|
||||
_block('b2', 'paragraph', text='Check the **cable** first.'),
|
||||
_block('b3', 'checklist', items=[{'id': 'i1', 'text': 'Power on'}]),
|
||||
]
|
||||
assert validate(blocks) == blocks
|
||||
|
||||
|
||||
def test_a_block_without_an_id_is_refused():
|
||||
"""Ids are what make a tick rememberable.
|
||||
|
||||
Without one the checkbox silently loses its state on the next visit, which
|
||||
reads to a user as "the checkboxes do not work" - a bug report with no
|
||||
stack trace and no obvious cause.
|
||||
"""
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([{'type': 'paragraph', 'text': 'no id'}])
|
||||
assert 'stable id' in str(exc.value)
|
||||
|
||||
|
||||
def test_duplicate_ids_are_refused():
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('same'), _block('same')])
|
||||
assert 'duplicate' in str(exc.value)
|
||||
|
||||
|
||||
def test_an_unknown_block_type_is_refused_and_lists_the_known_ones():
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('b1', 'carousel')])
|
||||
message = str(exc.value)
|
||||
assert 'carousel' in message and 'checklist' in message
|
||||
|
||||
|
||||
def test_checklist_items_need_their_own_ids():
|
||||
"""Same reason as blocks: the item id is what a tick is stored against."""
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('b1', 'checklist', items=[{'text': 'no id'}])])
|
||||
assert 'item' in str(exc.value)
|
||||
|
||||
|
||||
def test_the_error_names_which_block_is_wrong():
|
||||
"""An author fixing a rejected save should not have to count blocks."""
|
||||
from plugins.guides.services.blocks import validate, BlockError
|
||||
with pytest.raises(BlockError) as exc:
|
||||
validate([_block('b1'), _block('b2'), {'type': 'paragraph'}])
|
||||
assert str(exc.value).startswith('block 3:')
|
||||
|
||||
|
||||
def test_content_hash_covers_title_and_tags_not_just_blocks():
|
||||
"""Renaming or retagging a guide is a change a reader notices.
|
||||
|
||||
Hashing only the body would drop those edits out of the history entirely.
|
||||
"""
|
||||
from plugins.guides.services.blocks import contenthash
|
||||
blocks = [_block('b1', 'paragraph', text='same body')]
|
||||
base = contenthash('Title', 'Summary', 'pxe, share', blocks)
|
||||
assert contenthash('Different', 'Summary', 'pxe, share', blocks) != base
|
||||
assert contenthash('Title', 'Summary', 'pxe', blocks) != base
|
||||
assert contenthash('Title', 'Different', 'pxe, share', blocks) != base
|
||||
|
||||
|
||||
def test_content_hash_ignores_tag_order_case_and_spacing():
|
||||
"""Otherwise reordering tags in the editor would manufacture a version."""
|
||||
from plugins.guides.services.blocks import contenthash
|
||||
blocks = [_block()]
|
||||
assert (contenthash('T', '', 'PXE, Share', blocks)
|
||||
== contenthash('T', '', 'share ,pxe', blocks))
|
||||
|
||||
|
||||
def test_checklist_item_count_counts_only_checklists():
|
||||
from plugins.guides.services.blocks import checklistitemcount
|
||||
blocks = [
|
||||
_block('b1', 'checklist', items=[{'id': 'i1'}, {'id': 'i2'}]),
|
||||
_block('b2', 'bullets', items=['not tickable']),
|
||||
_block('b3', 'checklist', items=[{'id': 'i3'}]),
|
||||
]
|
||||
assert checklistitemcount(blocks) == 3
|
||||
|
||||
|
||||
def test_plaintext_flattens_a_guide_for_search():
|
||||
"""People search for something written halfway down, not for the title."""
|
||||
from plugins.guides.services.blocks import plaintext
|
||||
blocks = [
|
||||
_block('b1', 'heading', text='Mapped drive'),
|
||||
_block('b2', 'checklist', items=[{'id': 'i1', 'text': 'Run the sync'}]),
|
||||
_block('b3', 'table', rows=[['left', 'right']]),
|
||||
]
|
||||
flat = plaintext(blocks)
|
||||
assert 'Mapped drive' in flat and 'Run the sync' in flat and 'right' in flat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authoring: revisions, dedup and publishing. Needs an app context.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def gd_app():
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
application = create_app('testing')
|
||||
|
||||
from plugins.guides.plugin import GuidesPlugin
|
||||
plugin = GuidesPlugin()
|
||||
pm = application.extensions['plugin_manager']
|
||||
if plugin.meta.api_prefix not in pm._registered_prefixes:
|
||||
pm._register_plugin_components(plugin)
|
||||
|
||||
with application.app_context():
|
||||
_db.create_all()
|
||||
yield application
|
||||
_db.session.remove()
|
||||
_db.drop_all()
|
||||
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gd_clean(gd_app):
|
||||
from plugins.guides.models import Guide, GuideRevision
|
||||
with gd_app.app_context():
|
||||
_db.session.query(GuideRevision).delete()
|
||||
_db.session.query(Guide).delete()
|
||||
_db.session.commit()
|
||||
yield
|
||||
|
||||
|
||||
def _newguide(title='Replacing a marker controller'):
|
||||
from plugins.guides.models import Guide
|
||||
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
|
||||
status='draft')
|
||||
_db.session.add(guide)
|
||||
_db.session.flush()
|
||||
return guide
|
||||
|
||||
|
||||
def test_the_first_save_creates_version_one(gd_app, gd_clean):
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
guide, revision = authoring.save(
|
||||
guide, title='Replacing a marker controller',
|
||||
blocks=[_block('b1', 'paragraph', text='Power it off first.')])
|
||||
_db.session.commit()
|
||||
assert revision is not None
|
||||
assert revision.revision == 1
|
||||
assert guide.revision == 1
|
||||
|
||||
|
||||
def test_saving_identical_content_writes_no_revision(gd_app, gd_clean):
|
||||
"""Autosave would otherwise produce a hundred versions a day.
|
||||
|
||||
A history full of identical entries hides the three edits that mattered,
|
||||
which is the same failure the backups plugin dedups away.
|
||||
"""
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
blocks = [_block('b1', 'paragraph', text='Unchanged.')]
|
||||
authoring.save(guide, title='T', blocks=blocks)
|
||||
_db.session.commit()
|
||||
|
||||
guide, second = authoring.save(guide, title='T', blocks=blocks)
|
||||
_db.session.commit()
|
||||
|
||||
assert second is None, 'an unchanged save must not manufacture a version'
|
||||
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
|
||||
assert guide.revision == 1
|
||||
|
||||
|
||||
def test_a_real_edit_creates_the_next_version(gd_app, gd_clean):
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T',
|
||||
blocks=[_block('b1', 'paragraph', text='One.')])
|
||||
_db.session.commit()
|
||||
guide, second = authoring.save(
|
||||
guide, blocks=[_block('b1', 'paragraph', text='Two.')])
|
||||
_db.session.commit()
|
||||
|
||||
assert second.revision == 2
|
||||
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 2
|
||||
|
||||
|
||||
def test_readers_see_nothing_until_it_is_published(gd_app, gd_clean):
|
||||
"""A half-written procedure on a shop floor is worse than no procedure."""
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T', blocks=[_block()])
|
||||
_db.session.commit()
|
||||
assert authoring.readable(guide) is None
|
||||
|
||||
|
||||
def test_editing_a_published_guide_does_not_change_what_readers_see(gd_app, gd_clean):
|
||||
"""THE point of the draft/publish split.
|
||||
|
||||
A technician halfway through a procedure keeps reading the version that was
|
||||
published, even while the next one is being written.
|
||||
"""
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T',
|
||||
blocks=[_block('b1', 'paragraph', text='Published text.')])
|
||||
authoring.publish(guide)
|
||||
_db.session.commit()
|
||||
|
||||
authoring.save(guide,
|
||||
blocks=[_block('b1', 'paragraph', text='Work in progress.')])
|
||||
_db.session.commit()
|
||||
|
||||
served = authoring.readable(guide)
|
||||
assert served.blocks[0]['text'] == 'Published text.'
|
||||
assert guide.revision == 2, 'the working copy moved on'
|
||||
assert served.revision == 1, 'what readers get did not'
|
||||
|
||||
|
||||
def test_publishing_promotes_the_latest_saved_revision(gd_app, gd_clean):
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='v1')])
|
||||
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='v2')])
|
||||
authoring.publish(guide)
|
||||
_db.session.commit()
|
||||
assert authoring.readable(guide).blocks[0]['text'] == 'v2'
|
||||
assert guide.status == 'published'
|
||||
|
||||
|
||||
def test_publishing_an_empty_guide_is_refused(gd_app, gd_clean):
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
_db.session.commit()
|
||||
with pytest.raises(ValueError):
|
||||
authoring.publish(guide)
|
||||
|
||||
|
||||
def test_unpublishing_withdraws_without_destroying_the_history(gd_app, gd_clean):
|
||||
"""For a procedure that turns out to be wrong.
|
||||
|
||||
The versions stay, so it can be corrected and published again, and so there
|
||||
is still a record of what people were being told in the meantime.
|
||||
"""
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
authoring.save(guide, title='T', blocks=[_block()])
|
||||
authoring.publish(guide)
|
||||
_db.session.commit()
|
||||
|
||||
authoring.unpublish(guide)
|
||||
_db.session.commit()
|
||||
|
||||
assert authoring.readable(guide) is None
|
||||
assert guide.status == 'draft'
|
||||
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
|
||||
|
||||
|
||||
def test_restoring_writes_a_new_version_rather_than_rewriting_history(gd_app, gd_clean):
|
||||
"""A history you can edit is not a record of anything.
|
||||
|
||||
Restoring brings old content forward as a NEW version, so what was
|
||||
published in between stays visible.
|
||||
"""
|
||||
from plugins.guides.models import GuideRevision
|
||||
from plugins.guides.services import authoring
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
_, first = authoring.save(guide, title='T',
|
||||
blocks=[_block('b1', 'paragraph', text='original')])
|
||||
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='mistake')])
|
||||
_db.session.commit()
|
||||
|
||||
authoring.restore(guide, first)
|
||||
_db.session.commit()
|
||||
|
||||
rows = (GuideRevision.query.filter_by(guideid=guide.guideid)
|
||||
.order_by(GuideRevision.revision).all())
|
||||
assert [r.revision for r in rows] == [1, 2, 3]
|
||||
assert rows[2].blocks[0]['text'] == 'original'
|
||||
assert 'Restored from version 1' in (rows[2].note or '')
|
||||
assert rows[1].blocks[0]['text'] == 'mistake', 'the mistake stays on record'
|
||||
|
||||
|
||||
def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, gd_clean):
|
||||
"""A guide that will not render is a page a person cannot read.
|
||||
|
||||
An empty guide with its title and tags intact is a better failure than an
|
||||
error page, and the corruption is still visible - the editor shows nothing.
|
||||
"""
|
||||
with gd_app.app_context():
|
||||
guide = _newguide()
|
||||
guide.blocksjson = 'not json at all'
|
||||
_db.session.commit()
|
||||
assert guide.blocks == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The API surface. Everything above this line tests services directly; the
|
||||
# draft-invisibility rule lives in the ROUTES, and until these existed no test
|
||||
# executed a single one of them. That is how three routes shipped serving
|
||||
# unpublished drafts to any reader who could count upwards through ids.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_with(db, username, permissions, rolename=None):
|
||||
"""A user holding exactly these guides permissions and nothing else."""
|
||||
from werkzeug.security import generate_password_hash
|
||||
from shopdb.core.models import User, Role, Permission
|
||||
|
||||
role = Role(rolename=rolename or (username + 'role'), description=username)
|
||||
for name in permissions:
|
||||
# Shared across fixtures: two users in one test both want guides.view,
|
||||
# and permissions.name is unique.
|
||||
permission = Permission.query.filter_by(name=name).first()
|
||||
if permission is None:
|
||||
permission = Permission(name=name, description=name, category='guides')
|
||||
db.session.add(permission)
|
||||
db.session.flush()
|
||||
role.permissions.append(permission)
|
||||
db.session.add(role)
|
||||
db.session.flush()
|
||||
|
||||
user = User(username=username, email=username + '@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
user.roles.append(role)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _login(client, username):
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
return {'Authorization':
|
||||
'Bearer ' + response.get_json()['data']['access_token']}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reader(client, db):
|
||||
"""Can read guides. Cannot author them."""
|
||||
_user_with(db, 'guidereader', ['guides.view'])
|
||||
return _login(client, 'guidereader')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def author(client, db):
|
||||
"""Can write their own guides, but holds no guides.edit over others'."""
|
||||
_user_with(db, 'guideauthor', ['guides.view', 'guides.create'])
|
||||
return _login(client, 'guideauthor')
|
||||
|
||||
|
||||
def _draft(db, title='Unreleased procedure', published=False):
|
||||
"""A guide with one saved revision, published or not."""
|
||||
from plugins.guides.models import Guide
|
||||
from plugins.guides.services import authoring
|
||||
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
|
||||
status='draft')
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
guide, revision = authoring.save(
|
||||
guide, title=title,
|
||||
blocks=[_block('b1', 'paragraph', text='secret draft wording')])
|
||||
if published:
|
||||
authoring.publish(guide)
|
||||
db.session.commit()
|
||||
return guide, revision
|
||||
|
||||
|
||||
def test_a_reader_cannot_open_an_unpublished_guide_by_slug(client, db, reader):
|
||||
guide, _ = _draft(db)
|
||||
response = client.get('/api/guides/' + guide.slug, headers=reader)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_a_reader_cannot_read_an_unpublished_revision_by_id(client, db, reader):
|
||||
"""Revision ids are sequential, so this route is walkable by hand.
|
||||
|
||||
Gating it on guides.view alone meant anyone who could read one guide could
|
||||
read every draft on the site by counting upwards.
|
||||
"""
|
||||
guide, revision = _draft(db)
|
||||
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
|
||||
headers=reader)
|
||||
assert response.status_code == 404
|
||||
assert 'secret draft wording' not in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_a_reader_may_read_the_published_revision_by_id(client, db, reader):
|
||||
guide, revision = _draft(db, title='Published procedure', published=True)
|
||||
response = client.get('/api/guides/revisions/{}'.format(revision.guiderevisionid),
|
||||
headers=reader)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_a_reader_cannot_list_a_guides_revision_history(client, db, reader):
|
||||
guide, _ = _draft(db, title='Published procedure', published=True)
|
||||
response = client.get('/api/guides/{}/revisions'.format(guide.guideid),
|
||||
headers=reader)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_search_does_not_match_a_reader_against_draft_wording(client, db, reader):
|
||||
"""A hit a reader cannot open is worse than no hit.
|
||||
|
||||
The guide row holds the WORKING copy, so matching it would surface a
|
||||
published guide on a word that exists only in somebody's unpublished edit -
|
||||
and the word is not on the page when the reader opens it.
|
||||
"""
|
||||
from plugins.guides.services import authoring
|
||||
guide, revision = _draft(db, title='Published procedure', published=True)
|
||||
authoring.save(guide, blocks=[_block('b1', 'paragraph',
|
||||
text='unpublished sentence about widgets')])
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/guides?q=widgets', headers=reader)
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()['data'] == []
|
||||
|
||||
# The author of the draft still finds their own work.
|
||||
response = client.get('/api/guides?q=secret', headers=reader)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_an_author_may_edit_their_own_draft_but_not_someone_elses(client, db, author):
|
||||
guide, _ = _draft(db)
|
||||
response = client.get('/api/guides/{}/working'.format(guide.guideid),
|
||||
headers=author)
|
||||
assert response.status_code == 403, 'not their guide'
|
||||
|
||||
|
||||
def test_an_image_upload_refuses_a_type_that_is_not_an_image(client, db, author):
|
||||
import io
|
||||
data = {'file': (io.BytesIO(b'MZ not a picture'), 'payload.exe')}
|
||||
response = client.post('/api/guides/images', data=data, headers=author,
|
||||
content_type='multipart/form-data')
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported image type' in response.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_an_image_in_a_draft_is_not_served_to_a_reader(client, db, reader, author):
|
||||
"""An image is content, and a draft's images are draft content."""
|
||||
import io
|
||||
from plugins.guides.models import GuideImage
|
||||
|
||||
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
|
||||
upload = client.post(
|
||||
'/api/guides/images',
|
||||
data={'file': (io.BytesIO(png), 'screenshot.png')},
|
||||
headers=author, content_type='multipart/form-data')
|
||||
assert upload.status_code == 200, upload.get_json()
|
||||
imageid = upload.get_json()['data']['guideimageid']
|
||||
|
||||
# Unattached: it belongs to whoever uploaded it, not to every reader.
|
||||
assert client.get('/api/guides/images/{}'.format(imageid),
|
||||
headers=reader).status_code == 404
|
||||
assert client.get('/api/guides/images/{}'.format(imageid),
|
||||
headers=author).status_code == 200
|
||||
|
||||
# Attached to an unpublished guide: still not the reader's business.
|
||||
guide, _ = _draft(db)
|
||||
db.session.get(GuideImage, imageid).guideid = guide.guideid
|
||||
db.session.commit()
|
||||
assert client.get('/api/guides/images/{}'.format(imageid),
|
||||
headers=reader).status_code == 404
|
||||
|
||||
|
||||
def test_an_image_in_a_published_guide_is_served_and_hardened(client, db, reader, author):
|
||||
"""The other half: a published screenshot must actually reach the reader.
|
||||
|
||||
Guarding this route without checking the published case is how you trade a
|
||||
leak for every image on the site rendering as a broken icon.
|
||||
"""
|
||||
import io
|
||||
from plugins.guides.models import Guide, GuideImage
|
||||
from plugins.guides.services import authoring
|
||||
|
||||
png = (b'\x89PNG\r\n\x1a\n' + b'0' * 32)
|
||||
upload = client.post(
|
||||
'/api/guides/images',
|
||||
data={'file': (io.BytesIO(png), 'screenshot.png')},
|
||||
headers=author, content_type='multipart/form-data')
|
||||
imageid = upload.get_json()['data']['guideimageid']
|
||||
|
||||
guide = Guide(title='With a picture', slug='with-a-picture', status='draft')
|
||||
db.session.add(guide)
|
||||
db.session.flush()
|
||||
guide, revision = authoring.save(
|
||||
guide, title='With a picture',
|
||||
blocks=[{'id': 'b1', 'type': 'image', 'imageid': imageid,
|
||||
'url': '/api/guides/images/{}'.format(imageid)}])
|
||||
authoring.publish(guide)
|
||||
db.session.get(GuideImage, imageid).guideid = guide.guideid
|
||||
db.session.commit()
|
||||
|
||||
response = client.get('/api/guides/images/{}'.format(imageid), headers=reader)
|
||||
assert response.status_code == 200
|
||||
assert response.data == png
|
||||
# Uploaded bytes are attacker-influenced; they must be inert in this origin.
|
||||
assert response.headers.get('X-Content-Type-Options') == 'nosniff'
|
||||
assert 'Content-Security-Policy' in response.headers
|
||||
|
||||
|
||||
def test_renaming_a_guide_does_not_move_its_url(client, db, author):
|
||||
"""The stored slug exists so a pasted link keeps working.
|
||||
|
||||
The editor autosaves on a timer, so re-slugging on title change moved the
|
||||
URL while somebody was still typing the title.
|
||||
"""
|
||||
created = client.post('/api/guides',
|
||||
json={'title': 'Replacing a marker controler',
|
||||
'blocks': []}, headers=author)
|
||||
assert created.status_code == 200, created.get_json()
|
||||
guideid = created.get_json()['data']['guideid']
|
||||
slug = created.get_json()['data']['slug']
|
||||
|
||||
fixed = client.put('/api/guides/{}'.format(guideid),
|
||||
json={'title': 'Replacing a marker controller',
|
||||
'blocks': []}, headers=author)
|
||||
assert fixed.status_code == 200
|
||||
assert fixed.get_json()['data']['slug'] == slug, 'the URL must not move'
|
||||
assert fixed.get_json()['data']['title'] == 'Replacing a marker controller'
|
||||
Reference in New Issue
Block a user