From 3ba09ac9f38c5bb78dc750658ad09b828d729a59 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 16 Jul 2026 13:56:38 -0400 Subject: [PATCH] Fix bit(1) import coercion and subpath login redirect; add GitHub export script Loader: bool() on pymysql bit(1) bytes is always true - isinstallable and isshopfloor imported as 1 for every row; route through _truthy_bit. The employee source DB is now optional (shopdb-only imports). Frontend: under a subpath mount the 401 interceptor stored the browser path (mount base included) as the login redirect and the router applied its base again (/ops/ops). New stripBase() keeps redirects base-free. tools/export-github.sh automates the publication flow: prune + scrub + commit into ~/projects/shopdb-flask-pub and emit a transfer bundle. --- README.md | 2 +- frontend/src/api/index.js | 8 +- frontend/src/utils/basePath.js | 12 +++ frontend/src/views/Login.vue | 6 +- scripts/site_imports/wjf/run.py | 28 ++++--- tools/export-github.sh | 127 ++++++++++++++++++++++++++++++++ 6 files changed, 168 insertions(+), 15 deletions(-) create mode 100755 tools/export-github.sh diff --git a/README.md b/README.md index 8ab665a..b510556 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ SQLite). Do not run dev or production against SQLite. ### Distribution -The application is distributed internally through the GE Aerospace Gitea. Clone +The application is distributed through the internal GE Aerospace git server. Clone it from there; there is no public package or image registry. ### Fast path (Docker) diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e96c0cf..ec19d77 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 c8e6318..b77cbbb 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 c1f2f6c..a5c43be 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/scripts/site_imports/wjf/run.py b/scripts/site_imports/wjf/run.py index fa88ec5..6ffcfc0 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/tools/export-github.sh b/tools/export-github.sh new file mode 100755 index 0000000..42f36b1 --- /dev/null +++ b/tools/export-github.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Export the working repo to the GitHub publication repo and emit a bundle. +# +# Pipeline: working repo (full history, internal refs) -> pruned/scrubbed +# tree -> commit in ~/projects/shopdb-flask-pub (the local mirror of what +# enterprise GitHub holds) -> full git bundle in /home/camp/pxe-images/ for +# transfer to the work PC, which pushes it to GitHub. +# +# Usage: +# tools/export-github.sh "Commit message for the publication commit" +# tools/export-github.sh --dist # also rebuild the /ops frontend dist +# +# This script lives in tools/, which is itself excluded from publication. +set -euo pipefail + +WORK=/home/camp/projects/shopdb-flask +PUB=/home/camp/projects/shopdb-flask-pub +OUT=/home/camp/pxe-images + +BUILD_DIST=0 +MSG="" +for arg in "$@"; do + case "$arg" in + --dist) BUILD_DIST=1 ;; + *) MSG="$arg" ;; + esac +done +[ -n "$MSG" ] || { echo "usage: $0 [--dist] \"commit message\""; exit 1; } +[ -d "$PUB/.git" ] || { echo "publication repo missing at $PUB"; exit 1; } + +# --- 1. sync the tree (working -> pub), minus everything never published --- +rsync -a --delete \ + --exclude '.git' \ + --exclude '.gitea' \ + --exclude 'docs' \ + --exclude 'tools' \ + --exclude 'start-api.sh' \ + --exclude 'start-ui.sh' \ + --exclude 'CLAUDE.md' \ + --exclude 'frontend/CLAUDE.md' \ + --exclude 'tests/test_docs_contract.py' \ + --exclude 'tests/test_plugins/test_geenforce_parity.py' \ + --exclude 'tests/test_plugins/test_zabbix_live.py' \ + --exclude 'venv' \ + --exclude 'node_modules' \ + --exclude 'frontend/dist*' \ + --exclude 'instance' \ + --exclude '.env' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude 'scripts/site_imports/wjf/idmap.json' \ + "$WORK/" "$PUB/" + +cd "$PUB" + +# --- 2. re-apply the publication wording (idempotent) --- +# docs/ lives only in the wiki on the GitHub side. +grep -rlZ 'docs/' --include='*.py' --include='*.md' --include='*.sh' \ + --include='*.tmpl' --include='*.vue' --include='*.example' . 2>/dev/null | + while IFS= read -r -d '' f; do + case "$f" in ./CHANGELOG.md) continue ;; esac + sed -i -E \ + -e 's/\[`?docs\/([A-Za-z0-9_-]+)\.md`?\]\((\.\.\/)*docs\/[A-Za-z0-9_-]+\.md\)/the \1 page in the project wiki/g' \ + -e 's/docs\/proposals\/ge-enforce-plugin\.md/the ge-enforce-plugin proposal in the project wiki/g' \ + -e 's/`docs\/([A-Za-z0-9_-]+)\.md`/the \1 wiki page/g' \ + -e 's/docs\/([A-Za-z0-9_-]+)\.md/the \1 wiki page/g' "$f" + done +sed -i 's|docs/GE-ENFORCE.md|the GE-ENFORCE page in the project wiki|' \ + frontend/src/views/geenforce/ManifestEditor.vue 2>/dev/null || true +# internal infra never named on GitHub; this repo's own URL maps to the +# real GitHub home, anything else degrades to a placeholder. +GITHUB_URL='https://github.com/ge-aero/shopdb-flask' +grep -rlZ 'gitea\.proudtech\.net' . 2>/dev/null | while IFS= read -r -d '' f; do + sed -i -e "s|https://gitea\.proudtech\.net/ge-aerospace/shopdb-flask|$GITHUB_URL|g" \ + -e 's|gitea\.proudtech\.net||g' "$f" +done +grep -rlZi 'gitea' --exclude-dir=.git . 2>/dev/null | while IFS= read -r -d '' f; do + sed -i -e 's/the GE Aerospace Gitea/the internal GE Aerospace git server/g' \ + -e 's/Gitea Actions CI/CI/g' \ + -e 's/Gitea Actions/CI/g' "$f" +done +# CHANGELOG compare/release link definitions reference tags that do not +# exist on GitHub (history is squashed there) - drop them. +sed -i '/^\[[^]]*\]: .*\/\(compare\|releases\)\//d' CHANGELOG.md +# frontend/CLAUDE.md publishes under a neutral name +if [ -f "$WORK/frontend/CLAUDE.md" ]; then + cp "$WORK/frontend/CLAUDE.md" frontend/DEVELOPMENT-STANDARDS.md +fi +sed -i "s/rootpassword/changeme/g" shopdb/config.py 2>/dev/null || true + +# --- 3. scrub gate: refuse to commit if anything internal leaks --- +LEAKS=$(grep -rlIiE 'claude|anthropic|fable 5|gitea|proudtech|home/camp|rootpassword' \ + --exclude-dir=.git . || true) +if [ -n "$LEAKS" ]; then + echo "SCRUB GATE FAILED - internal references in:"; echo "$LEAKS"; exit 1 +fi + +# --- 4. commit (no-op safe) --- +git add -A +if git diff --cached --quiet; then + echo "no changes vs publication tree - nothing to export" +else + git commit -m "$MSG" +fi +git log --oneline -3 + +# --- 5. bundle for transfer (full bundle: stateless, fetch takes only new) --- +git bundle create "$OUT/shopdb-flask-pub.bundle" HEAD main --tags +git bundle verify "$OUT/shopdb-flask-pub.bundle" >/dev/null +echo "bundle: $OUT/shopdb-flask-pub.bundle" + +# --- 6. optional /ops dist for the prod server --- +if [ "$BUILD_DIST" = 1 ]; then + cd "$WORK/frontend" + VITE_BASE_PATH=/ops/ npm run build --silent + rm -rf "$OUT/frontend-dist-subpath-ops" + cp -r dist "$OUT/frontend-dist-subpath-ops" + echo "dist: $OUT/frontend-dist-subpath-ops/" +fi + +cat <<'EOF' + +On the work PC (in the shopdb-flask-pub clone): + git fetch \shopdb-flask-pub.bundle main + git merge --ff-only FETCH_HEAD + git push origin main +EOF