Catalog seeding, blank-unique fix, slides rotation, console repair
This commit is contained in:
@@ -201,6 +201,7 @@ var
|
|||||||
// the check takes a few seconds, runs hidden, and gives no sign of life.
|
// the check takes a few seconds, runs hidden, and gives no sign of life.
|
||||||
CheckingPage: TOutputProgressWizardPage;
|
CheckingPage: TOutputProgressWizardPage;
|
||||||
PluginPage: TInputOptionWizardPage;
|
PluginPage: TInputOptionWizardPage;
|
||||||
|
CatalogPage: TInputOptionWizardPage;
|
||||||
PluginNames: TArrayOfString;
|
PluginNames: TArrayOfString;
|
||||||
PluginPageReady: Boolean;
|
PluginPageReady: Boolean;
|
||||||
DbPageReady: Boolean;
|
DbPageReady: Boolean;
|
||||||
@@ -397,7 +398,21 @@ begin
|
|||||||
if Trim(PluginNames[I]) <> '' then
|
if Trim(PluginNames[I]) <> '' then
|
||||||
PluginPage.Add(PluginLabel(Trim(PluginNames[I])));
|
PluginPage.Add(PluginLabel(Trim(PluginNames[I])));
|
||||||
|
|
||||||
DbChoicePage := CreateInputOptionPage(PluginPage.ID,
|
// Offered, not forced. A site that machines nothing inherits 21 machine types
|
||||||
|
// it will never use, and a catalog nobody wanted is just clutter in every
|
||||||
|
// dropdown. Ticked by default because the alternative is retyping a vendor and
|
||||||
|
// model list another site already built.
|
||||||
|
CatalogPage := CreateInputOptionPage(PluginPage.ID,
|
||||||
|
'Starter data', 'Load the shared equipment catalog?',
|
||||||
|
'Vendors, model numbers and the type lists, as built up across the other ' +
|
||||||
|
'sites. It only ADDS - nothing existing is changed or removed - and it can ' +
|
||||||
|
'be loaded later with "flask seed catalog". No assets, locations or people ' +
|
||||||
|
'are included; this is catalogue information only.',
|
||||||
|
False, False);
|
||||||
|
CatalogPage.Add('Load the shared vendor and model catalog');
|
||||||
|
CatalogPage.Values[0] := True;
|
||||||
|
|
||||||
|
DbChoicePage := CreateInputOptionPage(CatalogPage.ID,
|
||||||
'Database', 'Where should ShopDB-Flask store its data?',
|
'Database', 'Where should ShopDB-Flask store its data?',
|
||||||
'Most sites already run MySQL for the existing shopdb application. If so, ' +
|
'Most sites already run MySQL for the existing shopdb application. If so, ' +
|
||||||
'choose the second option - installing a second server would collide on ' +
|
'choose the second option - installing a second server would collide on ' +
|
||||||
@@ -1177,6 +1192,8 @@ begin
|
|||||||
' -OnFailure never' +
|
' -OnFailure never' +
|
||||||
' -ClientIpSource ' + ClientIpSourceArg +
|
' -ClientIpSource ' + ClientIpSourceArg +
|
||||||
' -SitePlugins "' + SelectedPlugins + '"';
|
' -SitePlugins "' + SelectedPlugins + '"';
|
||||||
|
if CatalogPage.Values[0] then
|
||||||
|
Common := Common + ' -SeedCatalog';
|
||||||
|
|
||||||
// Subpath deployment: an IIS Application under the existing site instead of a
|
// Subpath deployment: an IIS Application under the existing site instead of a
|
||||||
// site of its own. Appended here because Pascal has no conditional expression.
|
// site of its own. Appended here because Pascal has no conditional expression.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
|
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
|
||||||
'check','sessions','plugins','add-plugin','verify','uninstall')]
|
'check','repair','sessions','plugins','add-plugin','verify','uninstall')]
|
||||||
[string] $Command = 'menu',
|
[string] $Command = 'menu',
|
||||||
[string] $Path = '',
|
[string] $Path = '',
|
||||||
# Machine-readable output for 'check'. The people running this are expected to
|
# Machine-readable output for 'check'. The people running this are expected to
|
||||||
@@ -577,11 +577,101 @@ function Invoke-Check {
|
|||||||
$env:FLASK_APP = 'shopdb'
|
$env:FLASK_APP = 'shopdb'
|
||||||
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
|
try { & $flask db-utils preflight 2>&1 | ForEach-Object { Say " $_" } }
|
||||||
finally { Pop-Location }
|
finally { Pop-Location }
|
||||||
|
|
||||||
|
# Surface an unfinished install HERE, where somebody is already looking,
|
||||||
|
# rather than leaving them to infer it from 500s on unrelated pages.
|
||||||
|
$state = Test-Provisioned
|
||||||
|
if ($state.SchemaCurrent -eq $false -or $state.Seeded -eq $false) {
|
||||||
|
Say ''
|
||||||
|
Say ' THIS SERVER IS NOT FULLY PROVISIONED' 'Red'
|
||||||
|
foreach ($detail in $state.Detail) { Say (" - {0}" -f $detail) 'Red' }
|
||||||
|
Say ' Most pages will answer 500 until this is fixed. Run:' 'Yellow'
|
||||||
|
Say ' shopdb-admin.ps1 repair' 'White'
|
||||||
|
}
|
||||||
|
|
||||||
Say ''
|
Say ''
|
||||||
Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
|
Say ' For help from an AI assistant, paste the output of:' 'DarkGray'
|
||||||
Say ' shopdb-admin.ps1 check -Json' 'White'
|
Say ' shopdb-admin.ps1 check -Json' 'White'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-Provisioned {
|
||||||
|
"""Is the schema current and the reference data seeded?"""
|
||||||
|
# A server whose migrations or seeds never completed does not fail politely:
|
||||||
|
# it answers 500 on most pages and 404 on settings that were never created,
|
||||||
|
# which reads as a broken application rather than an unfinished install. One
|
||||||
|
# site was stood up that way and spent a morning being debugged as a bug.
|
||||||
|
$result = @{ SchemaCurrent = $true; Seeded = $true; Detail = @() }
|
||||||
|
|
||||||
|
$out = Invoke-Flask @('db','current')
|
||||||
|
$current = ($out | Out-String)
|
||||||
|
$head = (Invoke-Flask @('db','heads') | Out-String)
|
||||||
|
if ($script:LastFlaskExit -ne 0) {
|
||||||
|
$result.SchemaCurrent = $false
|
||||||
|
$result.Detail += 'could not read the schema version'
|
||||||
|
} elseif ($current -notmatch '\(head\)' -and $head.Trim()) {
|
||||||
|
# `db current` appends "(head)" when the database is at the newest
|
||||||
|
# revision. Its absence means migrations are outstanding.
|
||||||
|
$result.SchemaCurrent = $false
|
||||||
|
$result.Detail += 'database schema is behind the application'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sentinel seeds. permissions, settings and the reference data are each
|
||||||
|
# created by a `flask seed` command, and their absence is what produced the
|
||||||
|
# 404s on settings keys at a site whose install never finished.
|
||||||
|
$seedOut = (Invoke-Flask @('db-utils','seed-state') | Out-String)
|
||||||
|
if ($seedOut -match 'No such command') {
|
||||||
|
# An older build with no seed-state. Report UNKNOWN rather than healthy:
|
||||||
|
# claiming a clean bill of health from a check that did not run is how a
|
||||||
|
# broken server passes inspection.
|
||||||
|
$result.Seeded = $null
|
||||||
|
$result.Detail += 'seed state could not be checked (older build)'
|
||||||
|
} elseif ($script:LastFlaskExit -ne 0 -or $seedOut -match 'MISSING') {
|
||||||
|
$result.Seeded = $false
|
||||||
|
$result.Detail += 'seed data is missing (permissions, settings or reference data)'
|
||||||
|
}
|
||||||
|
return $result
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Repair {
|
||||||
|
Head 'Repair provisioning'
|
||||||
|
Say ' Brings the database up to the application: migrations, plugin'
|
||||||
|
Say ' migrations, and the seed data. Every step is idempotent, so running'
|
||||||
|
Say ' this on a healthy server changes nothing.'
|
||||||
|
Say ''
|
||||||
|
|
||||||
|
$steps = @(
|
||||||
|
@{ Name = 'core schema'; Args = @('db','upgrade') },
|
||||||
|
@{ Name = 'plugin schemas'; Args = @('plugin','upgrade-all') },
|
||||||
|
@{ Name = 'permissions'; Args = @('seed','permissions') },
|
||||||
|
@{ Name = 'settings'; Args = @('seed','settings') },
|
||||||
|
@{ Name = 'reference data'; Args = @('seed','reference-data') }
|
||||||
|
)
|
||||||
|
$failed = @()
|
||||||
|
foreach ($step in $steps) {
|
||||||
|
Say (" {0} ..." -f $step.Name) 'White'
|
||||||
|
$out = Invoke-Flask $step.Args
|
||||||
|
if ($script:LastFlaskExit -ne 0) {
|
||||||
|
$failed += $step.Name
|
||||||
|
Say (" FAILED (exit {0})" -f $script:LastFlaskExit) 'Red'
|
||||||
|
$out | Select-Object -Last 6 | ForEach-Object { Say " $_" 'Red' }
|
||||||
|
} else {
|
||||||
|
Say ' done' 'Green'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
Say ''
|
||||||
|
Say (" {0} step(s) failed: {1}" -f $failed.Count, ($failed -join ', ')) 'Red'
|
||||||
|
Say ' Nothing further was skipped - each step ran independently.' 'Red'
|
||||||
|
Say ' Send the output above, or run: shopdb-admin.ps1 check -Json' 'Red'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Say ''
|
||||||
|
Say ' Provisioning complete. Restarting the application.' 'Green'
|
||||||
|
Restart-App
|
||||||
|
}
|
||||||
|
|
||||||
function Show-Sessions {
|
function Show-Sessions {
|
||||||
Head 'Worker processes'
|
Head 'Worker processes'
|
||||||
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
|
$w = Get-CimInstance Win32_Process -Filter "Name='w3wp.exe'" -EA SilentlyContinue
|
||||||
@@ -856,6 +946,7 @@ switch ($Command) {
|
|||||||
'open' { Open-Site }
|
'open' { Open-Site }
|
||||||
'backup' { Backup-Db $Path }
|
'backup' { Backup-Db $Path }
|
||||||
'check' { Invoke-Check }
|
'check' { Invoke-Check }
|
||||||
|
'repair' { Invoke-Repair }
|
||||||
'sessions' { Show-Sessions }
|
'sessions' { Show-Sessions }
|
||||||
'plugins' { Show-Plugins }
|
'plugins' { Show-Plugins }
|
||||||
'add-plugin'{ Add-Plugin $Path }
|
'add-plugin'{ Add-Plugin $Path }
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ param(
|
|||||||
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
|
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
|
||||||
# visitor-location lookup and per-host login rate limiting all break quietly.
|
# visitor-location lookup and per-host login rate limiting all break quietly.
|
||||||
[ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
|
[ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
|
||||||
|
# Load the shared vendor/model catalog during stage 3. Offered by the
|
||||||
|
# wizard rather than assumed: a site that machines nothing does not want
|
||||||
|
# 21 machine types cluttering its dropdowns.
|
||||||
|
[switch] $SeedCatalog,
|
||||||
# Required before this installer will alter an installation it did not
|
# Required before this installer will alter an installation it did not
|
||||||
# create. Everything here is built for a greenfield server: it makes its own
|
# create. Everything here is built for a greenfield server: it makes its own
|
||||||
# Python, its own venv and its own IIS objects, and its upgrade path assumes
|
# Python, its own venv and its own IIS objects, and its upgrade path assumes
|
||||||
@@ -1747,6 +1751,14 @@ build to get a matching pair, then send the install log to support.
|
|||||||
Invoke-Native $Flask @('seed',$seed) "seed $seed"
|
Invoke-Native $Flask @('seed',$seed) "seed $seed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Additive and idempotent, so a re-run adds only what is genuinely new
|
||||||
|
# and never overwrites a description somebody corrected here.
|
||||||
|
if ($SeedCatalog) {
|
||||||
|
Write-Log 'flask seed catalog (shared vendor and model catalog)'
|
||||||
|
$catalogOut = Invoke-Native $Flask @('seed','catalog') 'seed catalog' -OkExit 0,1
|
||||||
|
$catalogOut | Select-Object -Last 12 | ForEach-Object { Write-Log " $_" }
|
||||||
|
}
|
||||||
|
|
||||||
# Plugin registry starts empty on a fresh box. apply-profile resolves the
|
# Plugin registry starts empty on a fresh box. apply-profile resolves the
|
||||||
# declared set's hard-dependency closure and installs + enables it in
|
# declared set's hard-dependency closure and installs + enables it in
|
||||||
# dependency order, idempotently (ADR-013). It fails loudly if the profile
|
# dependency order, idempotently (ADR-013). It fails loudly if the profile
|
||||||
|
|||||||
@@ -205,30 +205,41 @@
|
|||||||
<!-- Map Position (optional) -->
|
<!-- Map Position (optional) -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Map Position (Optional)</legend>
|
<legend>Map Position (Optional)</legend>
|
||||||
<div class="form-row">
|
<!--
|
||||||
<div class="form-group">
|
Picked on the floor plan, the same way machines, PCs and printers do
|
||||||
<label for="mapx">Map X Position</label>
|
it. This form used to ask for the X and Y as raw numbers, which meant
|
||||||
<input
|
reading coordinates off another screen and typing them in.
|
||||||
id="mapx"
|
-->
|
||||||
v-model.number="form.mapx"
|
<div class="form-group">
|
||||||
type="number"
|
<label>Map Location</label>
|
||||||
class="form-control"
|
<div class="map-location-control">
|
||||||
min="0"
|
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||||
/>
|
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||||
</div>
|
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||||
<div class="form-group">
|
</div>
|
||||||
<label for="mapy">Map Y Position</label>
|
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||||
<input
|
Set Location on Map
|
||||||
id="mapy"
|
</button>
|
||||||
v-model.number="form.mapy"
|
|
||||||
type="number"
|
|
||||||
class="form-control"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Map Picker Modal -->
|
||||||
|
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||||
|
<div class="map-modal-content">
|
||||||
|
<ShopFloorMap
|
||||||
|
:pickerMode="true"
|
||||||
|
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||||
|
:theme="currentTheme"
|
||||||
|
@positionPicked="handlePositionPicked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Notes</legend>
|
<legend>Notes</legend>
|
||||||
@@ -274,11 +285,16 @@ import {
|
|||||||
businessunitsApi
|
businessunitsApi
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
|
import Modal from '@/components/Modal.vue'
|
||||||
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
|
|
||||||
const { isEnabled } = useIdentifierFlags()
|
const { isEnabled } = useIdentifierFlags()
|
||||||
|
|
||||||
|
const showMapPicker = ref(false)
|
||||||
|
const tempMapPosition = ref(null)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -483,6 +499,24 @@ function cancel() {
|
|||||||
router.push('/network')
|
router.push('/network')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function handlePositionPicked(position) {
|
||||||
|
tempMapPosition.value = position
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPosition() {
|
||||||
|
if (tempMapPosition.value) {
|
||||||
|
form.value.mapx = tempMapPosition.value.left
|
||||||
|
form.value.mapy = tempMapPosition.value.top
|
||||||
|
}
|
||||||
|
showMapPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMapPosition() {
|
||||||
|
form.value.mapx = null
|
||||||
|
form.value.mapy = null
|
||||||
|
tempMapPosition.value = null
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<!-- The tag prefix is shown as a fixed addon so operators type only
|
<!-- The tag prefix is shown as a fixed addon so operators type only
|
||||||
the number part off the label. -->
|
the number part off the label. -->
|
||||||
<div class="entry-input-group">
|
<div class="entry-input-group">
|
||||||
<span class="entry-prefix">{{ LABEL_PREFIX }}</span>
|
<span v-if="LABEL_PREFIX" class="entry-prefix">{{ LABEL_PREFIX }}</span>
|
||||||
<input ref="entryInput" v-model="manualCode" class="entry-input has-prefix"
|
<input ref="entryInput" v-model="manualCode" class="entry-input has-prefix"
|
||||||
inputmode="none" autocomplete="off" placeholder="number"
|
inputmode="none" autocomplete="off" placeholder="number"
|
||||||
@keydown.enter.prevent="lookupItem(manualCode)" />
|
@keydown.enter.prevent="lookupItem(manualCode)" />
|
||||||
@@ -92,13 +92,16 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { printedpartsApi } from '@/api'
|
import { printedpartsApi, settingsApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||||
|
|
||||||
// Fixed tag prefix shown before the label number at the kiosk. Change here if
|
// Leading text on the physical labels, shown before the number box so operators
|
||||||
// the gage lab tags use a different leading string.
|
// type only the digits. It was hardcoded to 'WJ', which is West Jefferson's tag
|
||||||
const LABEL_PREFIX = 'WJ'
|
// format and nobody else's - on another site's kiosk it told operators to expect
|
||||||
|
// letters that are not on their labels. Display only: the lookup sends whatever
|
||||||
|
// was typed, unprefixed.
|
||||||
|
const LABEL_PREFIX = ref('')
|
||||||
|
|
||||||
const step = ref('item')
|
const step = ref('item')
|
||||||
const item = ref(null)
|
const item = ref(null)
|
||||||
@@ -113,7 +116,20 @@ const scannedRevision = ref(null)
|
|||||||
const entryInput = ref(null)
|
const entryInput = ref(null)
|
||||||
let resetTimer = null
|
let resetTimer = null
|
||||||
|
|
||||||
onMounted(focusEntry)
|
onMounted(async () => {
|
||||||
|
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||||
|
// label format is a property of the site's physical tags, not something that
|
||||||
|
// changes during a shift.
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get('printedparts_label_prefix')
|
||||||
|
LABEL_PREFIX.value = (response.data?.data?.value || '').trim()
|
||||||
|
} catch (err) {
|
||||||
|
// Never block the kiosk over a cosmetic hint. An unreachable or unseeded
|
||||||
|
// setting simply means no prefix is shown.
|
||||||
|
LABEL_PREFIX.value = ''
|
||||||
|
}
|
||||||
|
focusEntry()
|
||||||
|
})
|
||||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||||
|
|
||||||
function focusEntry(event) {
|
function focusEntry(event) {
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kiosk label prefix</label>
|
||||||
|
<input v-model="values.printedparts_label_prefix" type="text"
|
||||||
|
class="form-control" maxlength="8" placeholder="none" />
|
||||||
|
<p class="field-hint">
|
||||||
|
Leading text on this site's physical labels, shown at the kiosk before the
|
||||||
|
number box so operators type only the digits. Leave it empty to show no
|
||||||
|
prefix. This is about the printed tags, not the item codes above.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Default low-stock threshold</label>
|
<label>Default low-stock threshold</label>
|
||||||
<input v-model.number="values.printedparts_default_threshold"
|
<input v-model.number="values.printedparts_default_threshold"
|
||||||
@@ -103,6 +114,7 @@ import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
|||||||
|
|
||||||
const KEYS = [
|
const KEYS = [
|
||||||
'printedparts_code_prefix',
|
'printedparts_code_prefix',
|
||||||
|
'printedparts_label_prefix',
|
||||||
'printedparts_default_threshold',
|
'printedparts_default_threshold',
|
||||||
'printedparts_unknown_badge',
|
'printedparts_unknown_badge',
|
||||||
'printedparts_alert_email',
|
'printedparts_alert_email',
|
||||||
@@ -113,6 +125,7 @@ const KEYS = [
|
|||||||
|
|
||||||
const values = ref({
|
const values = ref({
|
||||||
printedparts_code_prefix: '3DP',
|
printedparts_code_prefix: '3DP',
|
||||||
|
printedparts_label_prefix: '',
|
||||||
printedparts_default_threshold: 5,
|
printedparts_default_threshold: 5,
|
||||||
printedparts_unknown_badge: 'deny',
|
printedparts_unknown_badge: 'deny',
|
||||||
printedparts_alert_email: '',
|
printedparts_alert_email: '',
|
||||||
|
|||||||
@@ -126,6 +126,10 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
defaults = [
|
defaults = [
|
||||||
('printedparts_code_prefix', '3DP', 'string',
|
('printedparts_code_prefix', '3DP', 'string',
|
||||||
'Prefix for generated item codes'),
|
'Prefix for generated item codes'),
|
||||||
|
('printedparts_label_prefix', '', 'string',
|
||||||
|
'Leading text on the physical gage-lab labels, shown at the kiosk '
|
||||||
|
'before the number box so operators type only the digits. Empty '
|
||||||
|
'shows no prefix. Site-specific: West Jefferson labels read WJ'),
|
||||||
('printedparts_default_threshold', '5', 'integer',
|
('printedparts_default_threshold', '5', 'integer',
|
||||||
'Default low-stock threshold for new items'),
|
'Default low-stock threshold for new items'),
|
||||||
('printedparts_unknown_badge', 'deny', 'string',
|
('printedparts_unknown_badge', 'deny', 'string',
|
||||||
|
|||||||
@@ -1,191 +1,207 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="tv-dashboard">
|
<div class="tv-dashboard">
|
||||||
<div class="slideshow-container">
|
<div class="slideshow-container">
|
||||||
<div
|
<div
|
||||||
v-for="(slide, idx) in slides"
|
v-for="(slide, idx) in slides"
|
||||||
:key="slide.filename"
|
:key="slide.filename"
|
||||||
class="slide"
|
class="slide"
|
||||||
:class="{ active: idx === currentSlide }"
|
:class="{ active: idx === currentSlide }"
|
||||||
>
|
>
|
||||||
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
|
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-message">
|
<div v-if="error" class="error-message">
|
||||||
<h2>Display Error</h2>
|
<h2>Display Error</h2>
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!slides.length && !error" class="error-message">
|
<div v-if="!slides.length && !error" class="error-message">
|
||||||
<h2>No Slides</h2>
|
<h2>No Slides</h2>
|
||||||
<p>No slides configured for display</p>
|
<p>No slides configured for display</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress-bar" :style="progressStyle"></div>
|
<div class="progress-bar" :style="progressStyle"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
|
|
||||||
// Which slide surface this display renders: 'lobby' (default) or 'shopfloor'
|
// Which slide surface this display renders: 'lobby' (default) or 'shopfloor'
|
||||||
// (the screensaver). Set by the route meta (/screensaver) or a ?surface= query.
|
// (the screensaver). Set by the route meta (/screensaver) or a ?surface= query.
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const VALID_SURFACES = ['lobby', 'shopfloor']
|
const VALID_SURFACES = ['lobby', 'shopfloor']
|
||||||
const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface)
|
const surface = (VALID_SURFACES.includes(route.meta?.surface) && route.meta.surface)
|
||||||
|| (VALID_SURFACES.includes(route.query?.surface) && route.query.surface)
|
|| (VALID_SURFACES.includes(route.query?.surface) && route.query.surface)
|
||||||
|| 'lobby'
|
|| 'lobby'
|
||||||
|
|
||||||
const INTERVAL = 10 // seconds between slides
|
const INTERVAL = 10 // seconds between slides
|
||||||
|
|
||||||
const slides = ref([])
|
const slides = ref([])
|
||||||
const basePath = ref('/static/slides/')
|
const basePath = ref('/static/slides/')
|
||||||
const currentSlide = ref(0)
|
const currentSlide = ref(0)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
|
|
||||||
let slideTimer = null
|
let slideTimer = null
|
||||||
let progressTimer = null
|
let progressTimer = null
|
||||||
let refreshTimer = null
|
let refreshTimer = null
|
||||||
|
|
||||||
const progressStyle = computed(() => ({
|
const progressStyle = computed(() => ({
|
||||||
width: `${progress.value}%`,
|
width: `${progress.value}%`,
|
||||||
transition: progress.value === 0 ? 'none' : `width ${INTERVAL}s linear`
|
// Matches the slide's own duration, or the bar finishes early and sits full.
|
||||||
}))
|
transition: progress.value === 0 ? 'none' : `width ${currentSlideSeconds()}s linear`
|
||||||
|
}))
|
||||||
onMounted(async () => {
|
|
||||||
await fetchSlides()
|
onMounted(async () => {
|
||||||
|
// fetchSlides starts the slideshow itself once it has more than one slide.
|
||||||
// Start slideshow if we have slides
|
// Starting it again here ran TWO timer chains at once, and the second
|
||||||
if (slides.value.length > 1) {
|
// assignment to slideTimer lost the handle to the first so nothing could stop
|
||||||
startSlideshow()
|
// it. Both chains called nextSlide a few milliseconds apart, so with exactly
|
||||||
}
|
// two slides the display advanced 0 -> 1 -> 0 every cycle and looked frozen.
|
||||||
|
await fetchSlides()
|
||||||
// Refresh slide list every 60 seconds
|
|
||||||
refreshTimer = setInterval(fetchSlides, 60000)
|
// Refresh slide list every 60 seconds
|
||||||
})
|
refreshTimer = setInterval(fetchSlides, 60000)
|
||||||
|
})
|
||||||
onUnmounted(() => {
|
|
||||||
if (slideTimer) clearTimeout(slideTimer)
|
onUnmounted(() => {
|
||||||
if (progressTimer) clearTimeout(progressTimer)
|
if (slideTimer) clearTimeout(slideTimer)
|
||||||
if (refreshTimer) clearInterval(refreshTimer)
|
if (progressTimer) clearTimeout(progressTimer)
|
||||||
})
|
if (refreshTimer) clearInterval(refreshTimer)
|
||||||
|
})
|
||||||
async function fetchSlides() {
|
|
||||||
try {
|
async function fetchSlides() {
|
||||||
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
|
try {
|
||||||
const response = await api.get('/slides/feed', { params: { surface } })
|
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
|
||||||
const data = response.data
|
const response = await api.get('/slides/feed', { params: { surface } })
|
||||||
|
const data = response.data
|
||||||
if (data.success && data.slides?.length > 0) {
|
|
||||||
slides.value = data.slides
|
if (data.success && data.slides?.length > 0) {
|
||||||
basePath.value = data.basepath || `/api/slides/img/${surface}/`
|
slides.value = data.slides
|
||||||
error.value = ''
|
basePath.value = data.basepath || `/api/slides/img/${surface}/`
|
||||||
|
error.value = ''
|
||||||
// Restart slideshow if slides changed
|
|
||||||
if (slides.value.length > 1 && !slideTimer) {
|
// Restart slideshow if slides changed
|
||||||
startSlideshow()
|
if (slides.value.length > 1 && !slideTimer) {
|
||||||
}
|
startSlideshow()
|
||||||
} else {
|
}
|
||||||
slides.value = []
|
} else {
|
||||||
error.value = 'No slides configured'
|
slides.value = []
|
||||||
}
|
error.value = 'No slides configured'
|
||||||
} catch (err) {
|
}
|
||||||
console.error('Error fetching slides:', err)
|
} catch (err) {
|
||||||
error.value = 'Unable to load slides'
|
console.error('Error fetching slides:', err)
|
||||||
}
|
error.value = 'Unable to load slides'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function startSlideshow() {
|
|
||||||
scheduleNextSlide()
|
function startSlideshow() {
|
||||||
}
|
scheduleNextSlide()
|
||||||
|
}
|
||||||
function scheduleNextSlide() {
|
|
||||||
// Reset progress bar
|
function scheduleNextSlide() {
|
||||||
progress.value = 0
|
// Cancel anything already pending. Belt and braces after the double-start
|
||||||
|
// bug above: any future caller that schedules twice now replaces the timer
|
||||||
// Start progress animation after a small delay
|
// rather than leaking an untracked one.
|
||||||
progressTimer = setTimeout(() => {
|
if (slideTimer) { clearTimeout(slideTimer); slideTimer = null }
|
||||||
progress.value = 100
|
if (progressTimer) { clearTimeout(progressTimer); progressTimer = null }
|
||||||
}, 50)
|
|
||||||
|
// Reset progress bar
|
||||||
// Schedule next slide
|
progress.value = 0
|
||||||
slideTimer = setTimeout(() => {
|
|
||||||
nextSlide()
|
// Start progress animation after a small delay
|
||||||
scheduleNextSlide()
|
progressTimer = setTimeout(() => {
|
||||||
}, INTERVAL * 1000)
|
progress.value = 100
|
||||||
}
|
}, 50)
|
||||||
|
|
||||||
function nextSlide() {
|
// The feed sends a per-slide duration and this used to ignore it, so a slide
|
||||||
if (slides.value.length === 0) return
|
// set to hold for a minute still changed after ten seconds.
|
||||||
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
const seconds = currentSlideSeconds()
|
||||||
}
|
|
||||||
</script>
|
slideTimer = setTimeout(() => {
|
||||||
|
nextSlide()
|
||||||
<style scoped>
|
scheduleNextSlide()
|
||||||
.tv-dashboard {
|
}, seconds * 1000)
|
||||||
background: #000;
|
}
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
function currentSlideSeconds() {
|
||||||
margin: 0;
|
const slide = slides.value[currentSlide.value]
|
||||||
padding: 0;
|
const seconds = Number(slide && slide.seconds)
|
||||||
overflow: hidden;
|
return seconds > 0 ? seconds : INTERVAL
|
||||||
}
|
}
|
||||||
|
|
||||||
.slideshow-container {
|
function nextSlide() {
|
||||||
position: relative;
|
if (slides.value.length === 0) return
|
||||||
width: 100%;
|
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
||||||
height: 100%;
|
}
|
||||||
}
|
</script>
|
||||||
|
|
||||||
.slide {
|
<style scoped>
|
||||||
position: absolute;
|
.tv-dashboard {
|
||||||
top: 0;
|
background: #000;
|
||||||
left: 0;
|
width: 100vw;
|
||||||
width: 100%;
|
height: 100vh;
|
||||||
height: 100%;
|
margin: 0;
|
||||||
opacity: 0;
|
padding: 0;
|
||||||
transition: opacity 1s ease-in-out;
|
overflow: hidden;
|
||||||
display: flex;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
.slideshow-container {
|
||||||
}
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
.slide.active {
|
height: 100%;
|
||||||
opacity: 1;
|
}
|
||||||
}
|
|
||||||
|
.slide {
|
||||||
.slide img {
|
position: absolute;
|
||||||
width: 100%;
|
top: 0;
|
||||||
height: 100%;
|
left: 0;
|
||||||
object-fit: contain;
|
width: 100%;
|
||||||
}
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
.progress-bar {
|
transition: opacity 1s ease-in-out;
|
||||||
position: fixed;
|
display: flex;
|
||||||
bottom: 0;
|
align-items: center;
|
||||||
left: 0;
|
justify-content: center;
|
||||||
height: 4px;
|
}
|
||||||
background: #4181ff;
|
|
||||||
z-index: 100;
|
.slide.active {
|
||||||
}
|
opacity: 1;
|
||||||
|
}
|
||||||
.error-message {
|
|
||||||
position: fixed;
|
.slide img {
|
||||||
top: 50%;
|
width: 100%;
|
||||||
left: 50%;
|
height: 100%;
|
||||||
transform: translate(-50%, -50%);
|
object-fit: contain;
|
||||||
text-align: center;
|
}
|
||||||
color: #fff;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
.progress-bar {
|
||||||
font-size: 24px;
|
position: fixed;
|
||||||
}
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
.error-message h2 {
|
height: 4px;
|
||||||
color: #ff4444;
|
background: #4181ff;
|
||||||
margin-bottom: 20px;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.error-message {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message h2 {
|
||||||
|
color: #ff4444;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
173
scripts/export_catalog.py
Normal file
173
scripts/export_catalog.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Export the reference CATALOG from a live instance to a portable JSON file.
|
||||||
|
|
||||||
|
A new site starts with almost nothing: `flask seed reference-data` writes a
|
||||||
|
dozen generic model types and no vendors or models at all. So the first thing
|
||||||
|
anyone does at a new facility is retype a vendor and model list that another
|
||||||
|
facility already spent a year building. This lets that catalog travel.
|
||||||
|
|
||||||
|
CATALOG ONLY. Vendors, models and the type vocabularies are descriptions of
|
||||||
|
equipment that exists in the world, and are the same at every site. Assets,
|
||||||
|
locations, employees, business units and anything with a serial number are that
|
||||||
|
site's own records and are deliberately NOT exported - nobody wants West
|
||||||
|
Jefferson's machines appearing at another plant.
|
||||||
|
|
||||||
|
Vendor contact details are also skipped: a rep's name and phone number belong to
|
||||||
|
whoever has the relationship, not to the catalog.
|
||||||
|
|
||||||
|
python scripts/export_catalog.py # to shopdb/data/catalog.json
|
||||||
|
python scripts/export_catalog.py --out mine.json
|
||||||
|
python scripts/export_catalog.py --pretty # readable diffs in git
|
||||||
|
|
||||||
|
Load it on another instance with: flask seed catalog
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
DEFAULT_OUT = os.path.join('shopdb', 'data', 'catalog.json')
|
||||||
|
|
||||||
|
|
||||||
|
def export(connection):
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
inspector = inspect(connection)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
data = OrderedDict()
|
||||||
|
|
||||||
|
def rows(sql):
|
||||||
|
return [dict(r._mapping) for r in connection.execute(text(sql))]
|
||||||
|
|
||||||
|
def has(table):
|
||||||
|
return table in tables
|
||||||
|
|
||||||
|
# --- type vocabularies -------------------------------------------------
|
||||||
|
# Each is a small controlled list, and a site with none of them cannot even
|
||||||
|
# classify what it owns.
|
||||||
|
if has('modeltypes'):
|
||||||
|
data['modeltypes'] = rows("""
|
||||||
|
SELECT modeltype, category, description, icon
|
||||||
|
FROM modeltypes ORDER BY modeltype""")
|
||||||
|
for table, column in (('machinetypes', 'machinetype'),
|
||||||
|
('computertypes', 'computertype'),
|
||||||
|
('printertypes', 'printertype'),
|
||||||
|
('networkdevicetypes', 'networkdevicetype'),
|
||||||
|
('locationtypes', 'locationtype')):
|
||||||
|
if has(table):
|
||||||
|
extra = ', color' if 'color' in {c['name'] for c in inspector.get_columns(table)} else ''
|
||||||
|
data[table] = rows('SELECT %s, description%s FROM %s ORDER BY %s'
|
||||||
|
% (column, extra, table, column))
|
||||||
|
|
||||||
|
if has('operatingsystems'):
|
||||||
|
columns = {c['name'] for c in inspector.get_columns('operatingsystems')}
|
||||||
|
wanted = [c for c in ('osname', 'osversion', 'osfamily', 'description') if c in columns]
|
||||||
|
data['operatingsystems'] = rows('SELECT %s FROM operatingsystems ORDER BY osname'
|
||||||
|
% ', '.join(wanted))
|
||||||
|
|
||||||
|
# --- vendors -----------------------------------------------------------
|
||||||
|
# Name and website only. Contact person, phone and address are a site's own
|
||||||
|
# relationship with that vendor, not a property of the vendor.
|
||||||
|
if has('vendors'):
|
||||||
|
columns = {c['name'] for c in inspector.get_columns('vendors')}
|
||||||
|
wanted = [c for c in ('vendor', 'website', 'description') if c in columns]
|
||||||
|
data['vendors'] = rows('SELECT %s FROM vendors ORDER BY vendor' % ', '.join(wanted))
|
||||||
|
|
||||||
|
# --- models ------------------------------------------------------------
|
||||||
|
# Referenced by NAME, not id: ids differ between databases, and a catalog
|
||||||
|
# keyed on them would silently attach models to the wrong vendor.
|
||||||
|
if has('models'):
|
||||||
|
data['models'] = rows("""
|
||||||
|
SELECT m.modelnumber AS modelnumber,
|
||||||
|
v.vendor AS vendor,
|
||||||
|
mt.modeltype AS modeltype,
|
||||||
|
m.description AS description,
|
||||||
|
m.documentationurl AS documentationurl,
|
||||||
|
m.imageurl AS imageurl
|
||||||
|
FROM models m
|
||||||
|
LEFT JOIN vendors v ON m.vendorid = v.vendorid
|
||||||
|
LEFT JOIN modeltypes mt ON m.modeltypeid = mt.modeltypeid
|
||||||
|
ORDER BY v.vendor, m.modelnumber""")
|
||||||
|
|
||||||
|
# --- printer supplies --------------------------------------------------
|
||||||
|
# The single most useful thing to inherit after the models themselves: a new
|
||||||
|
# site with printers gets every toner, drum and maintenance-kit part number
|
||||||
|
# already matched to the right model, instead of somebody reading them off
|
||||||
|
# cartridges. Keyed by the model's natural key for the same reason models
|
||||||
|
# are keyed by vendor name.
|
||||||
|
if has('modelsupplies') and has('models'):
|
||||||
|
data['modelsupplies'] = rows("""
|
||||||
|
SELECT mo.modelnumber AS modelnumber,
|
||||||
|
v.vendor AS vendor,
|
||||||
|
s.supplytype AS supplytype,
|
||||||
|
s.color AS color,
|
||||||
|
s.capacitytier AS capacitytier,
|
||||||
|
s.partnumber AS partnumber,
|
||||||
|
s.marketingname AS marketingname,
|
||||||
|
s.pageyield AS pageyield,
|
||||||
|
s.notes AS notes
|
||||||
|
FROM modelsupplies s
|
||||||
|
JOIN models mo ON s.modelnumberid = mo.modelnumberid
|
||||||
|
LEFT JOIN vendors v ON mo.vendorid = v.vendorid
|
||||||
|
ORDER BY v.vendor, mo.modelnumber, s.partnumber""")
|
||||||
|
|
||||||
|
# --- small generic vocabularies ---------------------------------------
|
||||||
|
# Each is a controlled list that means the same thing at any site.
|
||||||
|
if has('measuringtooltypes'):
|
||||||
|
data['measuringtooltypes'] = rows(
|
||||||
|
'SELECT name, description, color FROM measuringtooltypes ORDER BY name')
|
||||||
|
if has('notificationtypes'):
|
||||||
|
data['notificationtypes'] = rows(
|
||||||
|
'SELECT typename, typedescription, typecolor FROM notificationtypes ORDER BY typename')
|
||||||
|
if has('accessprotocols'):
|
||||||
|
columns = {c['name'] for c in inspector.get_columns('accessprotocols')}
|
||||||
|
wanted = [c for c in ('name', 'scheme', 'defaultport', 'linktemplate') if c in columns]
|
||||||
|
data['accessprotocols'] = rows('SELECT %s FROM accessprotocols ORDER BY name'
|
||||||
|
% ', '.join(wanted))
|
||||||
|
|
||||||
|
# Drop empty optional values so the file stays readable and a NULL does not
|
||||||
|
# arrive as the string "None".
|
||||||
|
for key, records in data.items():
|
||||||
|
cleaned = []
|
||||||
|
for record in records:
|
||||||
|
cleaned.append({k: v for k, v in record.items() if v not in (None, '')})
|
||||||
|
data[key] = cleaned
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument('--out', default=DEFAULT_OUT, help='output file')
|
||||||
|
parser.add_argument('--pretty', action='store_true',
|
||||||
|
help='indent the JSON so git diffs are readable')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
from shopdb import create_app
|
||||||
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
data = export(db.session.connection())
|
||||||
|
|
||||||
|
directory = os.path.dirname(os.path.abspath(args.out))
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
with open(args.out, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(data, handle, indent=2 if args.pretty else None,
|
||||||
|
ensure_ascii=False, sort_keys=False)
|
||||||
|
handle.write('\n')
|
||||||
|
|
||||||
|
print('Wrote %s' % args.out)
|
||||||
|
for key, records in data.items():
|
||||||
|
print(' %-22s %d' % (key, len(records)))
|
||||||
|
print('')
|
||||||
|
print('Load it on another instance with: flask seed catalog')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -88,6 +88,12 @@ def create_app(config_name: str = None) -> Flask:
|
|||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
init_extensions(app)
|
init_extensions(app)
|
||||||
|
|
||||||
|
# An optional unique column accepts any number of NULLs but exactly one
|
||||||
|
# empty string, so a second blank code collided and became a 500. Normalise
|
||||||
|
# blank to NULL once, at the mapper, rather than in each endpoint.
|
||||||
|
from .utils.blankunique import register_blank_unique_normaliser
|
||||||
|
register_blank_unique_normaliser()
|
||||||
|
|
||||||
# Initialize plugin manager
|
# Initialize plugin manager
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
plugin_manager.init_app(app, db)
|
plugin_manager.init_app(app, db)
|
||||||
@@ -186,6 +192,31 @@ def register_error_handlers(app: Flask):
|
|||||||
from .utils.responses import error_response, ErrorCodes
|
from .utils.responses import error_response, ErrorCodes
|
||||||
from .exceptions import ShopDBException
|
from .exceptions import ShopDBException
|
||||||
|
|
||||||
|
# A uniqueness violation is the caller's problem, not a server fault. Without
|
||||||
|
# this it surfaced as a bare 500 with a SQLAlchemy traceback in the log and
|
||||||
|
# nothing usable on screen - the operator saw "internal server error" for
|
||||||
|
# having reused a code that was already taken.
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
@app.errorhandler(IntegrityError)
|
||||||
|
def handle_integrity_error(error):
|
||||||
|
from .extensions import db
|
||||||
|
db.session.rollback()
|
||||||
|
message = str(getattr(error, 'orig', error))
|
||||||
|
app.logger.warning('integrity error: %s', message)
|
||||||
|
if 'Duplicate entry' in message or 'UNIQUE constraint' in message:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT,
|
||||||
|
'That value is already in use. Codes and identifiers must be unique.',
|
||||||
|
http_code=409)
|
||||||
|
if 'foreign key constraint' in message.lower():
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'That record refers to something that does not exist, or is still in use elsewhere.',
|
||||||
|
http_code=400)
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'The database rejected that change.', http_code=400)
|
||||||
|
|
||||||
@app.errorhandler(ShopDBException)
|
@app.errorhandler(ShopDBException)
|
||||||
def handle_shopdb_exception(error):
|
def handle_shopdb_exception(error):
|
||||||
http_codes = {
|
http_codes = {
|
||||||
|
|||||||
@@ -10,6 +10,58 @@ def db_cli():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@db_cli.command('seed-state')
|
||||||
|
@with_appcontext
|
||||||
|
def seed_state():
|
||||||
|
"""Report whether the seed data a working install depends on is present.
|
||||||
|
|
||||||
|
A server whose seeds never ran does not fail politely: settings endpoints
|
||||||
|
answer 404 for keys that were never created and most pages answer 500, which
|
||||||
|
reads as a broken application rather than an unfinished install. This gives
|
||||||
|
the operator console something definite to test, so it can say "run repair"
|
||||||
|
instead of leaving somebody to infer it from unrelated errors.
|
||||||
|
|
||||||
|
Prints one line per group and exits non-zero if anything is missing, so it
|
||||||
|
can be used as a gate as well as read by a person.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# Sentinels, not exhaustive counts. Each is created by one of the three seed
|
||||||
|
# commands, so a zero here means that command never ran.
|
||||||
|
checks = [
|
||||||
|
('permissions', 'SELECT COUNT(*) FROM permissions', 'flask seed permissions'),
|
||||||
|
('settings', 'SELECT COUNT(*) FROM settings', 'flask seed settings'),
|
||||||
|
('asset types', 'SELECT COUNT(*) FROM assettypes', 'flask seed reference-data'),
|
||||||
|
('location types', 'SELECT COUNT(*) FROM locationtypes', 'flask seed reference-data'),
|
||||||
|
]
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for label, sql, remedy in checks:
|
||||||
|
try:
|
||||||
|
count = db.session.execute(text(sql)).scalar() or 0
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(click.style(' MISSING ', fg='red') +
|
||||||
|
'%s - table unreadable (%s)' % (label, type(exc).__name__))
|
||||||
|
missing.append((label, remedy))
|
||||||
|
continue
|
||||||
|
if count == 0:
|
||||||
|
click.echo(click.style(' MISSING ', fg='red') +
|
||||||
|
'%s - none present, run: %s' % (label, remedy))
|
||||||
|
missing.append((label, remedy))
|
||||||
|
else:
|
||||||
|
click.echo(click.style(' OK ', fg='green') + '%s (%d)' % (label, count))
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
click.echo('')
|
||||||
|
click.echo(click.style('%d group(s) missing. This server is not fully provisioned.'
|
||||||
|
% len(missing), fg='red'))
|
||||||
|
sys.exit(1)
|
||||||
|
click.echo('')
|
||||||
|
click.echo(click.style('Seed data present.', fg='green'))
|
||||||
|
|
||||||
|
|
||||||
@db_cli.command('create-all')
|
@db_cli.command('create-all')
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
def create_all():
|
def create_all():
|
||||||
@@ -147,6 +199,189 @@ def seed_cli():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@seed_cli.command('catalog')
|
||||||
|
@click.option('--file', 'path', default=None,
|
||||||
|
help='catalog JSON to load (default shopdb/data/catalog.json)')
|
||||||
|
@click.option('--dry-run', is_flag=True, help='report what would be added, write nothing')
|
||||||
|
@with_appcontext
|
||||||
|
def seed_catalog(path, dry_run):
|
||||||
|
"""Load the shared vendor/model catalog shipped with the product.
|
||||||
|
|
||||||
|
`seed reference-data` writes a dozen generic model types and no vendors or
|
||||||
|
models, so every new site began by retyping a catalog another site had
|
||||||
|
already built. This loads that catalog instead.
|
||||||
|
|
||||||
|
IDEMPOTENT and ADDITIVE. Records are matched by natural key - a vendor by
|
||||||
|
name, a model by vendor plus model number, a type by its name - so running
|
||||||
|
it twice adds nothing the second time. It never updates or deletes an
|
||||||
|
existing record: a site that has corrected a description or pointed a model
|
||||||
|
at its own photo keeps its version.
|
||||||
|
|
||||||
|
Catalog only. Nothing here identifies a site: no assets, locations,
|
||||||
|
employees or serial numbers.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from shopdb.extensions import db
|
||||||
|
from shopdb.core.models import ModelType, OperatingSystem, LocationType, Vendor, Model
|
||||||
|
|
||||||
|
if not path:
|
||||||
|
path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
'data', 'catalog.json')
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
click.echo(click.style('No catalog file at %s' % path, fg='red'))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
with open(path, encoding='utf-8') as handle:
|
||||||
|
data = json.load(handle)
|
||||||
|
|
||||||
|
added = {}
|
||||||
|
|
||||||
|
def note(kind, count):
|
||||||
|
if count:
|
||||||
|
added[kind] = added.get(kind, 0) + count
|
||||||
|
|
||||||
|
def simple(key, model, column):
|
||||||
|
"""Type vocabularies: one row per name."""
|
||||||
|
records = data.get(key) or []
|
||||||
|
count = 0
|
||||||
|
for record in records:
|
||||||
|
name = record.get(column)
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if db.session.query(model).filter_by(**{column: name}).first():
|
||||||
|
continue
|
||||||
|
db.session.add(model(**{k: v for k, v in record.items()
|
||||||
|
if hasattr(model, k)}))
|
||||||
|
count += 1
|
||||||
|
note(key, count)
|
||||||
|
|
||||||
|
simple('modeltypes', ModelType, 'modeltype')
|
||||||
|
simple('locationtypes', LocationType, 'locationtype')
|
||||||
|
simple('operatingsystems', OperatingSystem, 'osname')
|
||||||
|
|
||||||
|
# Per-plugin type tables exist only where that plugin is installed, so they
|
||||||
|
# are loaded through the ORM registry rather than imported directly - a lean
|
||||||
|
# build without printers must not fail here.
|
||||||
|
plugin_types = [
|
||||||
|
('machinetypes', 'machinetype'),
|
||||||
|
('computertypes', 'computertype'),
|
||||||
|
('printertypes', 'printertype'),
|
||||||
|
('networkdevicetypes', 'networkdevicetype'),
|
||||||
|
]
|
||||||
|
by_table = {m.class_.__tablename__: m.class_ for m in db.Model.registry.mappers}
|
||||||
|
for key, column in plugin_types:
|
||||||
|
model = by_table.get(key)
|
||||||
|
if model is None:
|
||||||
|
continue
|
||||||
|
simple(key, model, column)
|
||||||
|
|
||||||
|
# Vendors before models, since a model resolves its vendor by name.
|
||||||
|
count = 0
|
||||||
|
for record in data.get('vendors') or []:
|
||||||
|
name = record.get('vendor')
|
||||||
|
if not name or Vendor.query.filter_by(vendor=name).first():
|
||||||
|
continue
|
||||||
|
db.session.add(Vendor(**{k: v for k, v in record.items() if hasattr(Vendor, k)}))
|
||||||
|
count += 1
|
||||||
|
note('vendors', count)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
vendor_ids = {v.vendor: v.vendorid for v in Vendor.query.all()}
|
||||||
|
modeltype_ids = {m.modeltype: m.modeltypeid for m in ModelType.query.all()}
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
skipped_vendor = 0
|
||||||
|
for record in data.get('models') or []:
|
||||||
|
modelnumber = record.get('modelnumber')
|
||||||
|
if not modelnumber:
|
||||||
|
continue
|
||||||
|
vendorid = vendor_ids.get(record.get('vendor'))
|
||||||
|
# The catalog's unique key is model number PLUS vendor, so the same
|
||||||
|
# number from two makers stays two records.
|
||||||
|
if Model.query.filter_by(modelnumber=modelnumber, vendorid=vendorid).first():
|
||||||
|
continue
|
||||||
|
if record.get('vendor') and vendorid is None:
|
||||||
|
skipped_vendor += 1
|
||||||
|
continue
|
||||||
|
db.session.add(Model(
|
||||||
|
modelnumber=modelnumber,
|
||||||
|
vendorid=vendorid,
|
||||||
|
modeltypeid=modeltype_ids.get(record.get('modeltype')),
|
||||||
|
description=record.get('description'),
|
||||||
|
documentationurl=record.get('documentationurl'),
|
||||||
|
imageurl=record.get('imageurl'),
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
note('models', count)
|
||||||
|
|
||||||
|
# Small plugin vocabularies, loaded through the registry so a lean build
|
||||||
|
# missing that plugin skips them instead of failing to import.
|
||||||
|
for key, model_table, column in (('measuringtooltypes', 'measuringtooltypes', 'name'),
|
||||||
|
('notificationtypes', 'notificationtypes', 'typename'),
|
||||||
|
('accessprotocols', 'accessprotocols', 'name')):
|
||||||
|
model = by_table.get(model_table)
|
||||||
|
if model is not None:
|
||||||
|
simple(key, model, column)
|
||||||
|
|
||||||
|
# Printer supplies. Resolved against the models loaded above, by the model's
|
||||||
|
# natural key - part numbers are useless attached to the wrong printer.
|
||||||
|
supply_model = by_table.get('modelsupplies')
|
||||||
|
if supply_model is not None and (data.get('modelsupplies') or []):
|
||||||
|
db.session.flush()
|
||||||
|
model_key = {}
|
||||||
|
for m in Model.query.all():
|
||||||
|
model_key[(m.modelnumber, m.vendorid)] = m.modelnumberid
|
||||||
|
vendor_ids = {v.vendor: v.vendorid for v in Vendor.query.all()}
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
orphaned = 0
|
||||||
|
for record in data['modelsupplies']:
|
||||||
|
partnumber = record.get('partnumber')
|
||||||
|
modelnumber = record.get('modelnumber')
|
||||||
|
if not partnumber or not modelnumber:
|
||||||
|
continue
|
||||||
|
modelnumberid = model_key.get((modelnumber, vendor_ids.get(record.get('vendor'))))
|
||||||
|
if modelnumberid is None:
|
||||||
|
orphaned += 1
|
||||||
|
continue
|
||||||
|
if db.session.query(supply_model).filter_by(
|
||||||
|
modelnumberid=modelnumberid, partnumber=partnumber).first():
|
||||||
|
continue
|
||||||
|
db.session.add(supply_model(
|
||||||
|
modelnumberid=modelnumberid,
|
||||||
|
supplytype=record.get('supplytype') or 'toner',
|
||||||
|
color=record.get('color') or 'none',
|
||||||
|
capacitytier=record.get('capacitytier') or 'standard',
|
||||||
|
partnumber=partnumber,
|
||||||
|
marketingname=record.get('marketingname'),
|
||||||
|
pageyield=record.get('pageyield'),
|
||||||
|
notes=record.get('notes'),
|
||||||
|
))
|
||||||
|
count += 1
|
||||||
|
note('modelsupplies', count)
|
||||||
|
if orphaned:
|
||||||
|
click.echo(click.style(' %d supply record(s) skipped: their model is not in this catalog'
|
||||||
|
% orphaned, fg='yellow'))
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
db.session.rollback()
|
||||||
|
click.echo(click.style('DRY RUN - nothing written.', fg='yellow'))
|
||||||
|
else:
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if not added:
|
||||||
|
click.echo(click.style('Catalog already present, nothing to add.', fg='green'))
|
||||||
|
else:
|
||||||
|
for kind in sorted(added):
|
||||||
|
click.echo(' %-22s +%d' % (kind, added[kind]))
|
||||||
|
click.echo('')
|
||||||
|
click.echo(click.style('Catalog loaded from %s' % os.path.basename(path), fg='green'))
|
||||||
|
if skipped_vendor:
|
||||||
|
click.echo(click.style(' %d model(s) skipped: their vendor is not in this catalog'
|
||||||
|
% skipped_vendor, fg='yellow'))
|
||||||
|
|
||||||
|
|
||||||
@seed_cli.command('reference-data')
|
@seed_cli.command('reference-data')
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
def seed_reference_data():
|
def seed_reference_data():
|
||||||
|
|||||||
2779
shopdb/data/catalog.json
Normal file
2779
shopdb/data/catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
81
shopdb/utils/blankunique.py
Normal file
81
shopdb/utils/blankunique.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Store an empty optional unique field as NULL, not as an empty string.
|
||||||
|
|
||||||
|
A column that is `unique=True` and nullable accepts any number of NULLs, which
|
||||||
|
is what makes "optional but unique" work at all. It accepts exactly ONE empty
|
||||||
|
string. So the first record saved with a blank code succeeds and the second
|
||||||
|
fails with "Duplicate entry '' for key", surfacing as a 500 from an endpoint
|
||||||
|
that did nothing wrong.
|
||||||
|
|
||||||
|
That is not a business-unit problem. A dozen columns across core and the plugins
|
||||||
|
are unique and nullable - asset numbers, host names, item codes, subnet names -
|
||||||
|
and every one of them is a blank web form away from the same collision. Fixing
|
||||||
|
them one endpoint at a time would leave the next one to be discovered by a user.
|
||||||
|
|
||||||
|
So it is fixed once, at the mapper: before any insert or update, a string column
|
||||||
|
that is unique and nullable and has been set to '' or whitespace is stored as
|
||||||
|
NULL instead. An empty string carries no information in a unique column - there
|
||||||
|
is no case where two records both meaning "blank" should collide - so nothing is
|
||||||
|
lost by normalising it.
|
||||||
|
|
||||||
|
Registered from create_app. Applies to every model, including plugin models
|
||||||
|
loaded later, because it hooks the shared Mapper class rather than a list of
|
||||||
|
models known at import time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import event, String, inspect
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _blankable_columns(mapper):
|
||||||
|
"""Unique, nullable, string columns on this mapper - worked out once each."""
|
||||||
|
cached = _CACHE.get(mapper)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
columns = []
|
||||||
|
for prop in mapper.column_attrs:
|
||||||
|
for column in prop.columns:
|
||||||
|
if not column.unique or not column.nullable:
|
||||||
|
continue
|
||||||
|
# Only text. A unique nullable integer cannot be handed '' by a form
|
||||||
|
# without failing type coercion long before it reaches here.
|
||||||
|
if not isinstance(column.type, String):
|
||||||
|
continue
|
||||||
|
columns.append(prop.key)
|
||||||
|
break
|
||||||
|
_CACHE[mapper] = columns
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_instance(obj):
|
||||||
|
try:
|
||||||
|
mapper = inspect(obj).mapper
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for key in _blankable_columns(mapper):
|
||||||
|
value = getattr(obj, key, None)
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
setattr(obj, key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _before_flush(session, _flush_context, _instances):
|
||||||
|
# Both new and modified: editing a record to clear its code has to become
|
||||||
|
# NULL for the same reason creating one with a blank code does.
|
||||||
|
for obj in list(session.new) + list(session.dirty):
|
||||||
|
_normalise_instance(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def register_blank_unique_normaliser():
|
||||||
|
"""Hook every session flush, for every model including plugins'.
|
||||||
|
|
||||||
|
Listening on Session rather than on individual mappers means models imported
|
||||||
|
later - which is every plugin model - are covered without registration, and
|
||||||
|
it avoids depending on mapper-level event semantics that differ across
|
||||||
|
SQLAlchemy versions.
|
||||||
|
"""
|
||||||
|
if getattr(register_blank_unique_normaliser, '_installed', False):
|
||||||
|
return
|
||||||
|
event.listen(Session, 'before_flush', _before_flush)
|
||||||
|
register_blank_unique_normaliser._installed = True
|
||||||
Reference in New Issue
Block a user