Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# $Name plugin
$description
This plugin was generated by `flask plugin new $name`. It satisfies the framework contract out of the box. Replace the example model and routes with your domain.
## What's here
- `plugin.py` - the `${Name}Plugin` class extending `BasePlugin`. Edit `init_app` for custom setup, `on_install` to seed reference data.
- `models/$name.py` - example Asset extension table. Replace `examplefield` with your domain fields.
- `api/routes.py` - example list and detail endpoints. Add CRUD as needed.
- `schemas/__init__.py` - marshmallow schema stub for request/response validation.
- `tests/test_plugin.py` - smoke tests asserting contract compliance.
- `manifest.json` - plugin metadata. Bump `version` on changes; keep `core_version` range broad.
## Common edits
| You want to... | Do this |
|---|---|
| Add a hook (search, navigation, dashboard widget) | Override the method in `${Name}Plugin`. See the PLUGIN-HOOKS wiki page. |
| Accept external collector data | Override `get_collector_schema()` to return a JSON Schema. See ADR-006. |
| Add another model | Create `models/<other>.py`, export it in `models/__init__.py`, return it in `get_models()`. |
| Add a CLI command | Override `get_cli_commands()` returning a list of Click commands. |
## Frontend
Vue components for this plugin live under `frontend/src/views/$name/` (per project convention). Backend scaffolding does not generate frontend yet; copy from an existing plugin's view files (e.g., `frontend/src/views/network/`) as a starting point.
## Install and run
```bash
flask plugin install $name
flask db migrate -m "Add $name plugin tables"
flask db upgrade
pytest plugins/$name/tests/
```
## References
- the PLUGIN-HOOKS wiki page - canonical hook reference
- the PLUGIN-QUICKSTART wiki page - 30-minute walkthrough
- `migrations/adr/ADR-001-asset-as-platform-contract.md` - the platform contract
- `migrations/adr/ADR-002-plugin-versioning.md` - versioning rules

View File

@@ -0,0 +1,5 @@
"""$Name plugin package."""
from .plugin import ${Name}Plugin
__all__ = ['${Name}Plugin']

View File

@@ -0,0 +1,5 @@
"""$Name plugin API package."""
from .routes import ${name}_bp
__all__ = ['${name}_bp']

View File

@@ -0,0 +1,45 @@
"""$Name plugin API routes."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
success_response,
error_response,
paginated_response,
ErrorCodes,
get_pagination_params,
paginate_query,
)
from ..models import $Name
${name}_bp = Blueprint('$name', __name__)
@${name}_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_${name}():
"""List $name assets, paginated."""
page, per_page = get_pagination_params(request)
query = ${Name}.query
items, total = paginate_query(query, page, per_page)
data = [item.to_dict() for item in items]
return paginated_response(data, page, per_page, total)
@${name}_bp.route('/<int:assetid>', methods=['GET'])
@jwt_required(optional=True)
def get_${name}(assetid: int):
"""Get a single $name by assetid."""
item = ${Name}.query.get(assetid)
if not item:
return error_response(
ErrorCodes.NOT_FOUND,
f'$Name with assetid {assetid} not found',
http_code=404,
)
return success_response(item.to_dict())

View File

@@ -0,0 +1,35 @@
/*
* ${Name} API client snippet.
*
* Paste this ${name}Api block into frontend/src/api/index.js (next to the
* other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail,
* and ${Name}Form views, delete the local ${name}Api const and import the shared
* one instead:
*
* import { ${name}Api } from '../../api'
*
* The scaffolded views ship with an identical inline client so they build and
* run before you touch the shared api module. This file is NOT auto-merged into
* api/index.js on purpose; that module is hand-maintained and shared.
*
* The create/update/delete calls assume matching POST/PUT/DELETE routes exist
* on the backend. The scaffolded api/routes.py only ships list and get; add the
* write endpoints when you wire up the form.
*/
export const ${name}Api = {
list(params = {}) {
return api.get('/${name}', { params })
},
get(itemId) {
return api.get(`/${name}/${itemId}`)
},
create(data) {
return api.post('/${name}', data)
},
update(itemId, data) {
return api.put(`/${name}/${itemId}`, data)
},
remove(itemId) {
return api.delete(`/${name}/${itemId}`)
}
}

View File

@@ -0,0 +1,35 @@
/**
* ${Name} plugin routes.
*
* Auto-discovered by the router via import.meta.glob, so no registration
* edit is needed. Every route carries meta.plugin '${name}' so the ADR-009
* guard redirects to the dashboard when the ${name} backend plugin is
* disabled. Form routes add requiresAuth so anonymous users cannot reach
* create or edit.
*/
export default [
{
path: '${name}',
name: '${name}',
component: () => import('../../views/${name}/${Name}List.vue'),
meta: { plugin: '${name}' }
},
{
path: '${name}/new',
name: '${name}-new',
component: () => import('../../views/${name}/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' }
},
{
path: '${name}/:id',
name: '${name}-detail',
component: () => import('../../views/${name}/${Name}Detail.vue'),
meta: { plugin: '${name}' }
},
{
path: '${name}/:id/edit',
name: '${name}-edit',
component: () => import('../../views/${name}/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' }
}
]

View File

@@ -0,0 +1,149 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || '${Name}' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/${name}/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">${Name} Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ item.assetnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ item.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/${name}/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading...</div>
</div>
<div v-else class="error-container">
<p>Record not found</p>
<router-link to="/${name}" class="btn btn-secondary">Back to ${Name}</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },
create(data) { return api.post('/${name}', data) },
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await ${name}Api.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading ${name}:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await ${name}Api.remove(itemId)
router.push('/${name}')
} catch (error) {
console.error('Error deleting ${name}:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit ${Name}' : 'Add ${Name}' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>${Name} Details</legend>
<div class="form-row">
<div class="form-group">
<label for="examplefield">Example Field</label>
<input
id="examplefield"
v-model="form.examplefield"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },
create(data) { return api.post('/${name}', data) },
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await ${name}Api.get(itemId)
const data = response.data.data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.examplefield = data.examplefield || ''
} catch (loadError) {
console.error('Error loading ${name}:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await ${name}Api.update(itemId, payload)
} else {
const response = await ${name}Api.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/${name}/${redirectId}` : '/${name}')
} catch (submitError) {
console.error('Error saving ${name}:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/${name}/${itemId}`)
} else {
router.push('/${name}')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>${Name}</h2>
<router-link to="/${name}/new" class="btn btn-primary">Add ${Name}</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/${name}/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No ${name} records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },
create(data) { return api.post('/${name}', data) },
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await ${name}Api.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading ${name}:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>

View File

@@ -0,0 +1,9 @@
{
"name": "$name",
"version": "0.1.0",
"description": "$description",
"author": "",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/$name"
}

View File

@@ -0,0 +1,5 @@
"""$Name plugin models."""
from .$name import $Name
__all__ = ['$Name']

View File

@@ -0,0 +1,32 @@
"""$Name model.
This is an Asset extension table keyed by assetid. The Asset row holds
the platform fields (assetnumber, name, vendorid, locationid, etc.);
this table holds the $name-specific fields. Replace the example fields
below with your domain model.
"""
from shopdb.api import db, BaseModel
class $Name(BaseModel):
"""$Name domain entity, extending Asset by assetid."""
__tablename__ = '$name'
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
primary_key=True,
)
# TODO: replace these example fields with your domain fields.
examplefield = db.Column(db.String(255), nullable=True)
asset = db.relationship('Asset', backref=db.backref('$name', uselist=False))
def to_dict(self):
return {
'assetid': self.assetid,
'examplefield': self.examplefield,
}

View File

@@ -0,0 +1,70 @@
"""$Name plugin main class.
$description
"""
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 shopdb.api import db, AssetType
from .models import $Name
from .api import ${name}_bp
logger = logging.getLogger(__name__)
class ${Name}Plugin(BasePlugin):
"""$Name plugin.
$description
"""
def __init__(self):
manifest_path = Path(__file__).parent / 'manifest.json'
with open(manifest_path) as f:
self._manifest = json.load(f)
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name=self._manifest['name'],
version=self._manifest['version'],
description=self._manifest['description'],
author=self._manifest.get('author', ''),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=0.1.0'),
api_prefix=self._manifest.get('api_prefix'),
)
def get_blueprint(self) -> Optional[Blueprint]:
return ${name}_bp
def get_models(self) -> List[Type]:
return [$Name]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f'$Name plugin initialized (v{self.meta.version})')
def on_install(self, app: Flask) -> None:
with app.app_context():
self._ensure_asset_type()
logger.info('$Name plugin installed')
def _ensure_asset_type(self) -> None:
existing = AssetType.query.filter_by(assettype='$name').first()
if not existing:
asset_type = AssetType(
assettype='$name',
pluginname='$name',
tablename='$name',
description=self.meta.description,
)
db.session.add(asset_type)
db.session.commit()
logger.debug('Created asset type: $name')

View File

@@ -0,0 +1,6 @@
"""$Name plugin schemas (marshmallow).
Add schema classes here when you need request/response validation
beyond the simple to_dict() output. The framework wires marshmallow
into the response helpers; see the PLUGIN-HOOKS wiki page for details.
"""

View File

@@ -0,0 +1,30 @@
"""$Name plugin smoke tests.
Asserts the plugin loads cleanly and satisfies the framework contract.
Replace and extend with domain tests as you build the plugin out.
"""
from plugins.$name.plugin import ${Name}Plugin
def test_${name}_plugin_meta_is_valid():
"""${Name}Plugin.meta returns a PluginMeta with the expected name."""
plugin = ${Name}Plugin()
assert plugin.meta.name == '$name'
assert plugin.meta.api_prefix == '/api/$name'
def test_${name}_plugin_get_blueprint_returns_blueprint():
"""get_blueprint returns a Flask Blueprint, not None."""
from flask import Blueprint
plugin = ${Name}Plugin()
assert isinstance(plugin.get_blueprint(), Blueprint)
def test_${name}_plugin_get_models_returns_a_model():
"""get_models returns a list with at least one SQLAlchemy model."""
plugin = ${Name}Plugin()
models = plugin.get_models()
assert len(models) >= 1
for model in models:
assert hasattr(model, '__tablename__')