Fix bit(1) import coercion and subpath login redirect

The WJ import loader coerced MySQL bit(1) columns with bool(), which is
always true for the bytes pymysql returns; applications.isinstallable
and notifications.isshopfloor imported as 1 for every row. Route both
through the existing _truthy_bit helper. The loader also no longer
requires the employee source database: without it, notifications keep
the bare SSO and the verify stage skips the employee count.

Under a subpath mount, the session-expiry interceptor stored the
browser path (which includes the mount base) as the login redirect,
and the router applied its base again after login. Add stripBase()
and store base-free router paths; the login page also strips a stale
base from old bookmarked redirects.

Also point the external-plugin CI helper at the repo's GitHub URL.
This commit is contained in:
2026-07-16 14:08:19 -04:00
parent a9d0dfff4a
commit 95cfa9e939
6 changed files with 43 additions and 17 deletions

View File

@@ -1,5 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { withBase } from './../utils/basePath' import { withBase, stripBase } from './../utils/basePath'
// BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath // BASE_URL ends in '/', so this is '/api' at root or '/ops/api' under a subpath
// mount. Keeps the SPA, its API, and IIS all on the same mount path. // mount. Keeps the SPA, its API, and IIS all on the same mount path.
@@ -35,8 +35,10 @@ api.interceptors.response.use(
// Preserve the destination so login returns the user to this page. // Preserve the destination so login returns the user to this page.
if (hadToken) { if (hadToken) {
const loginPath = withBase('/login') const loginPath = withBase('/login')
const here = window.location.pathname + window.location.search // Router paths exclude the mount base; strip it or login's
const target = here && here !== loginPath // router.push double-prefixes under a subpath mount.
const here = stripBase(window.location.pathname) + window.location.search
const target = here && here !== '/login'
? loginPath + '?redirect=' + encodeURIComponent(here) ? loginPath + '?redirect=' + encodeURIComponent(here)
: loginPath : loginPath
window.location.href = target window.location.href = target

View File

@@ -11,3 +11,15 @@ export function withBase(path) {
if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path
return BASE_URL + String(path).replace(/^\//, '') return BASE_URL + String(path).replace(/^\//, '')
} }
// Inverse of withBase: turn a browser pathname (which includes the mount
// base, e.g. '/ops/computers') into a router path ('/computers'). Router
// navigation already applies the base; feeding it an un-stripped pathname
// double-prefixes ('/ops/ops/...').
export function stripBase(path) {
if (!path) return path
if (BASE_URL !== '/' && path.startsWith(BASE_URL)) {
return '/' + path.slice(BASE_URL.length)
}
return path
}

View File

@@ -52,7 +52,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api' import { setupApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings' import { getSiteLogo } from '../utils/siteSettings'
import { withBase } from '../utils/basePath' import { withBase, stripBase } from '../utils/basePath'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -63,7 +63,9 @@ const authStore = useAuthStore()
function postLoginTarget() { function postLoginTarget() {
const redirect = route.query.redirect const redirect = route.query.redirect
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) { if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
return redirect // Tolerate a redirect that still carries the mount base (old bookmarks,
// pre-fix interceptor URLs): router paths must be base-free.
return stripBase(redirect)
} }
return '/' return '/'
} }

View File

@@ -23,7 +23,7 @@
<code>common</code> manifest uses PC types to hit a subset; a per-type <code>common</code> manifest uses PC types to hit a subset; a per-type
manifest already only runs on its own type, so it shows just the niche manifest already only runs on its own type, so it shows just the niche
gate it needs (CMM version, a bay number, a subtype).</p> gate it needs (CMM version, a bay number, a subtype).</p>
<p>Full guide: the GE-ENFORCE page in the project wiki.</p> <p>Full guide: <code>the GE-ENFORCE wiki page</code>.</p>
</div> </div>
</details> </details>

View File

@@ -16,6 +16,8 @@ the machineid->assetid crosswalk this harness persists.
import argparse import argparse
import pymysql
from .harness import Harness from .harness import Harness
@@ -451,7 +453,7 @@ def stage_applications(h):
continue continue
payload = {'appname': name, 'appdescription': a['appdescription'], payload = {'appname': name, 'appdescription': a['appdescription'],
'supportteamid': h.ids.get('supportteam', a['supportteamid']), 'supportteamid': h.ids.get('supportteam', a['supportteamid']),
'isinstallable': bool(a['isinstallable']), 'isinstallable': _truthy_bit(a['isinstallable']),
'applicationnotes': a['applicationnotes'], 'installpath': a['installpath'], 'applicationnotes': a['applicationnotes'], 'installpath': a['installpath'],
'applicationlink': a['applicationlink'], 'applicationlink': a['applicationlink'],
'documentationpath': a['documentationpath']} 'documentationpath': a['documentationpath']}
@@ -534,13 +536,17 @@ def stage_notifications(h):
# SSO -> "First Last" from the employee source, so recognition/training # SSO -> "First Last" from the employee source, so recognition/training
# notifications display a name, not a bare SSO (the model shows employeename # notifications display a name, not a bare SSO (the model shows employeename
# or falls back to employeesso). # or falls back to employeesso). Employee source is optional: without it
# (shopdb-only import) notifications keep the bare SSO.
ssoname = {} ssoname = {}
try:
for e in h.source.rows('wjf_employees_src', for e in h.source.rows('wjf_employees_src',
'SELECT SSO, First_Name, Last_Name FROM employees'): 'SELECT SSO, First_Name, Last_Name FROM employees'):
full = f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip() full = f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip()
if full: if full:
ssoname[str(e['SSO']).strip()] = full ssoname[str(e['SSO']).strip()] = full
except pymysql.err.OperationalError:
print(' (no wjf_employees_src - notifications keep bare SSOs)')
def _names(employeesso): def _names(employeesso):
if not employeesso: if not employeesso:
@@ -566,7 +572,7 @@ def stage_notifications(h):
'appid': h.ids.get('app', n['appid']), 'appid': h.ids.get('app', n['appid']),
'starttime': str(n['starttime']) if n['starttime'] else None, 'starttime': str(n['starttime']) if n['starttime'] else None,
'endtime': endtime, 'ticketnumber': n['ticketnumber'], 'link': n['link'], 'endtime': endtime, 'ticketnumber': n['ticketnumber'], 'link': n['link'],
'isshopfloor': bool(n['isshopfloor']), 'employeesso': n['employeesso'], 'isshopfloor': _truthy_bit(n['isshopfloor']), 'employeesso': n['employeesso'],
'employeename': _names(n['employeesso'])} 'employeename': _names(n['employeesso'])}
status, _ = h.post('/api/notifications', payload) status, _ = h.post('/api/notifications', payload)
if status in (200, 201): if status in (200, 201):
@@ -759,7 +765,11 @@ def stage_verify(h):
from sqlalchemy import text from sqlalchemy import text
report = {} report = {}
for label, sdb, sql, target_table in checks: for label, sdb, sql, target_table in checks:
try:
src = h.source.rows(sdb, sql)[0]['c'] src = h.source.rows(sdb, sql)[0]['c']
except pymysql.err.OperationalError:
report[label] = 'source db absent - skipped'
continue
tgt = db.session.execute(text(f'SELECT COUNT(*) FROM {target_table}')).scalar() tgt = db.session.execute(text(f'SELECT COUNT(*) FROM {target_table}')).scalar()
report[label] = f'source~{src} target={tgt}' report[label] = f'source~{src} target={tgt}'
return report return report

View File

@@ -22,7 +22,7 @@
# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required. # PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required.
# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main. # FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main.
# FRAMEWORK_URL framework git URL for CI mode. # FRAMEWORK_URL framework git URL for CI mode.
# Default: https://<git-host>/ge-aerospace/shopdb-flask.git # Default: https://github.com/ge-aero/shopdb-flask.git
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline. # LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
# #
# Examples: # Examples:
@@ -43,7 +43,7 @@ set -eu
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}" PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}" FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
FRAMEWORK_URL="${FRAMEWORK_URL:-https://<git-host>/ge-aerospace/shopdb-flask.git}" FRAMEWORK_URL="${FRAMEWORK_URL:-https://github.com/ge-aero/shopdb-flask.git}"
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}" LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
if [ -z "$PLUGIN_DIR" ]; then if [ -z "$PLUGIN_DIR" ]; then