From 95cfa9e939c1ab76604a0339836c93c40608c6ac Mon Sep 17 00:00:00 2001
From: Cam Proudlock
Date: Thu, 16 Jul 2026 14:08:19 -0400
Subject: [PATCH] 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.
---
frontend/src/api/index.js | 8 ++++--
frontend/src/utils/basePath.js | 12 ++++++++
frontend/src/views/Login.vue | 6 ++--
.../src/views/geenforce/ManifestEditor.vue | 2 +-
scripts/site_imports/wjf/run.py | 28 +++++++++++++------
scripts/test-external-plugin.sh | 4 +--
6 files changed, 43 insertions(+), 17 deletions(-)
diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js
index e96c0cf1..ec19d772 100644
--- a/frontend/src/api/index.js
+++ b/frontend/src/api/index.js
@@ -1,5 +1,5 @@
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
// 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.
if (hadToken) {
const loginPath = withBase('/login')
- const here = window.location.pathname + window.location.search
- const target = here && here !== loginPath
+ // Router paths exclude the mount base; strip it or login's
+ // 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
window.location.href = target
diff --git a/frontend/src/utils/basePath.js b/frontend/src/utils/basePath.js
index c8e6318a..b77cbbbb 100644
--- a/frontend/src/utils/basePath.js
+++ b/frontend/src/utils/basePath.js
@@ -11,3 +11,15 @@ export function withBase(path) {
if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path
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
+}
diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue
index c1f2f6cb..a5c43be8 100644
--- a/frontend/src/views/Login.vue
+++ b/frontend/src/views/Login.vue
@@ -52,7 +52,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings'
-import { withBase } from '../utils/basePath'
+import { withBase, stripBase } from '../utils/basePath'
const router = useRouter()
const route = useRoute()
@@ -63,7 +63,9 @@ const authStore = useAuthStore()
function postLoginTarget() {
const redirect = route.query.redirect
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 '/'
}
diff --git a/frontend/src/views/geenforce/ManifestEditor.vue b/frontend/src/views/geenforce/ManifestEditor.vue
index 05dd0dd4..d16f3e74 100644
--- a/frontend/src/views/geenforce/ManifestEditor.vue
+++ b/frontend/src/views/geenforce/ManifestEditor.vue
@@ -23,7 +23,7 @@
common 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
gate it needs (CMM version, a bay number, a subtype).
- Full guide: the GE-ENFORCE page in the project wiki.
+ Full guide: the GE-ENFORCE wiki page.
diff --git a/scripts/site_imports/wjf/run.py b/scripts/site_imports/wjf/run.py
index fa88ec57..6ffcfc0c 100644
--- a/scripts/site_imports/wjf/run.py
+++ b/scripts/site_imports/wjf/run.py
@@ -16,6 +16,8 @@ the machineid->assetid crosswalk this harness persists.
import argparse
+import pymysql
+
from .harness import Harness
@@ -451,7 +453,7 @@ def stage_applications(h):
continue
payload = {'appname': name, 'appdescription': a['appdescription'],
'supportteamid': h.ids.get('supportteam', a['supportteamid']),
- 'isinstallable': bool(a['isinstallable']),
+ 'isinstallable': _truthy_bit(a['isinstallable']),
'applicationnotes': a['applicationnotes'], 'installpath': a['installpath'],
'applicationlink': a['applicationlink'],
'documentationpath': a['documentationpath']}
@@ -534,13 +536,17 @@ def stage_notifications(h):
# SSO -> "First Last" from the employee source, so recognition/training
# 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 = {}
- for e in h.source.rows('wjf_employees_src',
- 'SELECT SSO, First_Name, Last_Name FROM employees'):
- full = f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip()
- if full:
- ssoname[str(e['SSO']).strip()] = full
+ try:
+ for e in h.source.rows('wjf_employees_src',
+ 'SELECT SSO, First_Name, Last_Name FROM employees'):
+ full = f"{(e['First_Name'] or '').strip()} {(e['Last_Name'] or '').strip()}".strip()
+ if full:
+ ssoname[str(e['SSO']).strip()] = full
+ except pymysql.err.OperationalError:
+ print(' (no wjf_employees_src - notifications keep bare SSOs)')
def _names(employeesso):
if not employeesso:
@@ -566,7 +572,7 @@ def stage_notifications(h):
'appid': h.ids.get('app', n['appid']),
'starttime': str(n['starttime']) if n['starttime'] else None,
'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'])}
status, _ = h.post('/api/notifications', payload)
if status in (200, 201):
@@ -759,7 +765,11 @@ def stage_verify(h):
from sqlalchemy import text
report = {}
for label, sdb, sql, target_table in checks:
- src = h.source.rows(sdb, sql)[0]['c']
+ try:
+ 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()
report[label] = f'source~{src} target={tgt}'
return report
diff --git a/scripts/test-external-plugin.sh b/scripts/test-external-plugin.sh
index aa61e99d..4c10e014 100755
--- a/scripts/test-external-plugin.sh
+++ b/scripts/test-external-plugin.sh
@@ -22,7 +22,7 @@
# 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_URL framework git URL for CI mode.
-# Default: https:///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.
#
# Examples:
@@ -43,7 +43,7 @@ set -eu
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
-FRAMEWORK_URL="${FRAMEWORK_URL:-https:///ge-aerospace/shopdb-flask.git}"
+FRAMEWORK_URL="${FRAMEWORK_URL:-https://github.com/ge-aero/shopdb-flask.git}"
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
if [ -z "$PLUGIN_DIR" ]; then