Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
- flask plugin new now scaffolds the frontend too: List/Detail/Form views on the global styles, a gated route module (ADR-009), and an api-client snippet emitted into the plugin dir. Views are written before the route file so a partially generated plugin cannot 500 the dev server. - docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a sister site develops a plugin in its own repo and runs the framework contract tests in CI against a pinned framework ref (script verified to fail on a broken core_version pin). - docs/CONTRACT-STABILITY.md: settled vs churning contract surface and the provisional 1.0 criteria. - CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340 tests, measuringtools done). Known limitation documented: Path.rglob does not descend symlinks, so the import-surface contract test skips symlinked external plugins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,9 +36,9 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
|
|||||||
|
|
||||||
### Active state
|
### Active state
|
||||||
|
|
||||||
- 206 tests passing, naming/style check green
|
- 340 tests passing, naming/style check green, Gitea Actions CI (backend + naming + frontend build)
|
||||||
- `__contract_version__` at 0.5.0
|
- `__contract_version__` at 0.6.0 (product `__version__` 0.5.0 - distinct series, ADR-007)
|
||||||
- 10 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty
|
- 11 bundled plugins all satisfy contract: computers, employees, equipment, knowledgebase, measuringtools, network, notifications, printers, slides, usb, warranty
|
||||||
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
|
- Single core Alembic chain: baseline `68b3947ae14f` -> head `7d16_directoryemployees` (23 migrations). A fresh site runs `flask db upgrade` from empty; it is reproducible and idempotent.
|
||||||
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
|
- Pre-1.0 framework; sister sites should pin tight `core_version` ranges until contract reaches 1.0
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) in progr
|
|||||||
|
|
||||||
- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run.
|
- Equipment data migration (one-shot script for legacy ASP shopdb -> assets). Per ADR-001, only `category='Equipment' AND machinenumber IS NOT NULL` migrates. Skill `migrating-asset-schema` documents the pattern; the actual one-shot script lives in `scripts/migration/` when run.
|
||||||
- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration.
|
- Printers retirement: legacy `PrinterData` model + frontend changes. Coordinated with the equipment data migration.
|
||||||
- `measuringtools` plugin (ADR-005). First plugin to be built using the scaffold.
|
- (DONE 2026-07-11) `measuringtools` plugin (ADR-005) is built and bundled; docs/PLUGIN-GUIDE.md narrates its construction as the plugin tutorial.
|
||||||
- Frontend hook contract for asset-detail, map markers, search results
|
- Frontend hook contract for asset-detail, map markers, search results
|
||||||
- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet)
|
- Alembic per-plugin migration chains (the framework supports them; bundled plugins haven't moved off `db.create_all()` yet)
|
||||||
|
|
||||||
|
|||||||
112
docs/CONTRACT-STABILITY.md
Normal file
112
docs/CONTRACT-STABILITY.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Path to contract 1.0
|
||||||
|
|
||||||
|
This page is an honest read of how stable the plugin contract is today, for a
|
||||||
|
sister site deciding how much to build on it. It is derived from the ADRs and
|
||||||
|
the live code, not aspiration. The authoritative hook reference is
|
||||||
|
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md); the versioning rules are in
|
||||||
|
[ADR-002](adr/ADR-002-plugin-versioning.md).
|
||||||
|
|
||||||
|
## Current version
|
||||||
|
|
||||||
|
The plugin contract is at **0.6.0**, declared in `shopdb/__init__.py` as
|
||||||
|
`__contract_version__`. It is pre-1.0, which under semver means any 0.x minor
|
||||||
|
bump is allowed to break the contract, and this project has used that latitude.
|
||||||
|
|
||||||
|
The product release version (`__version__`, currently 0.5.0) is a separate
|
||||||
|
series with its own bump rules; see [ADR-007](adr/ADR-007-product-versioning-and-releases.md).
|
||||||
|
Do not pin against it for compatibility - pin against `__contract_version__`.
|
||||||
|
|
||||||
|
### 0.x history
|
||||||
|
|
||||||
|
Recorded in the comment block in `shopdb/__init__.py`:
|
||||||
|
|
||||||
|
| Version | Change | Kind |
|
||||||
|
|---------|--------|------|
|
||||||
|
| 0.3.0 | `shopdb.api` expanded to the full plugin import surface (db, cache, model bases, core models, response + pagination helpers, `employee_connection`) so plugins stop importing internal core paths | additive (minor) |
|
||||||
|
| 0.4.0 | Removed the never-implemented `get_searchable_fields` hook (search is a core concern over the asset model) and wired `get_dashboard_widgets` to a real consumer (`/api/dashboard/widgets`) | pre-1.0 contract reduction |
|
||||||
|
| 0.6.0 | Added the `get_reports` hook, consumed by `GET /api/reports` to merge plugin report cards into the Reports hub | additive optional hook (minor) |
|
||||||
|
|
||||||
|
The source comment block documents 0.3.0, 0.4.0, and 0.6.0. Earlier points
|
||||||
|
(0.1.x / 0.2.x) predate that recorded rationale; `PluginMeta`'s fallback
|
||||||
|
`core_version` default of `>=0.2.0,<1.0.0` is the only remaining trace of the
|
||||||
|
0.2 baseline.
|
||||||
|
|
||||||
|
## Settled surface
|
||||||
|
|
||||||
|
These are unlikely to break before 1.0. A plugin can depend on them with
|
||||||
|
reasonable confidence; a breaking change to any would be a major bump and would
|
||||||
|
land with a new or amended ADR.
|
||||||
|
|
||||||
|
| Surface | What it is |
|
||||||
|
|---------|-----------|
|
||||||
|
| `meta` / manifest schema | `PluginMeta` fields and `manifest.json` (ADR-002 single source of truth) |
|
||||||
|
| `get_blueprint` | Flask Blueprint registration at the manifest `api_prefix` |
|
||||||
|
| `get_models` | SQLAlchemy model classes the plugin owns |
|
||||||
|
| `init_app` | Custom init after blueprint + models are known |
|
||||||
|
| `get_cli_commands` | Click commands added to the Flask CLI |
|
||||||
|
| `get_services` | Named service classes, consumed by `plugin_manager.get_service` |
|
||||||
|
| Lifecycle hooks | `on_install`, `on_uninstall`, `on_enable`, `on_disable` |
|
||||||
|
| `get_navigation_items` | Sidebar menu entries |
|
||||||
|
| `get_dashboard_widgets` | Dashboard widgets, consumed by `/api/dashboard/widgets` |
|
||||||
|
| `get_reports` | Report cards, consumed by `/api/reports` (added 0.6.0) |
|
||||||
|
| Collector pair | `get_collector_schema` + `apply_collector_payload` per [ADR-006](adr/ADR-006-collector-contract.md) |
|
||||||
|
| Settings helpers | `get_setting` / `set_setting`, namespaced to the plugin |
|
||||||
|
| `get_provisioning_note` | Setup-wizard transparency note for extra tables |
|
||||||
|
| `get_config_schema` | Setup-wizard config field declarations |
|
||||||
|
| `shopdb.api` import surface | The only core module plugins may import (plus `shopdb.plugins.base`); adding a name is minor, removing one is major |
|
||||||
|
|
||||||
|
The asset model itself - `Asset`, `AssetType`, `AssetStatus`,
|
||||||
|
`AssetRelationship`, and the shared reference models - is the platform contract
|
||||||
|
locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md).
|
||||||
|
|
||||||
|
## Expected churn before 1.0
|
||||||
|
|
||||||
|
Known-unstable areas. Building on these means expecting rework.
|
||||||
|
|
||||||
|
| Area | Status | Reference |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| Frontend hook contract | Not defined yet. There is no server-side hook for asset-detail panels, map markers, or search-result rendering. A plugin that needs custom UI still hand-edits the Vue frontend. This is the single biggest gap. | project charter / this doc |
|
||||||
|
| Per-plugin migrations | Brand new. The per-plugin Alembic engine exists and every bundled plugin now carries a chain, but the pattern has one release of production mileage, not years. | [ADR-008](adr/ADR-008-plugin-migration-ownership.md) (2026-07-10) |
|
||||||
|
| Pip distribution | Deferred to v2. External plugins install by clone / submodule / symlink; there is no entry-point discovery and no automatic update path yet. | [ADR-003](adr/ADR-003-plugin-distribution.md) |
|
||||||
|
|
||||||
|
## Bump rules
|
||||||
|
|
||||||
|
From [ADR-002](adr/ADR-002-plugin-versioning.md), applied to `__contract_version__`:
|
||||||
|
|
||||||
|
| Bump | Trigger |
|
||||||
|
|------|---------|
|
||||||
|
| major | Breaking change to the `BasePlugin` ABC, the `PluginMeta` schema, or any model in the platform contract (`Asset`, `AssetType`, `AssetStatus`, `AssetRelationship`, `Vendor`, `Location`, `BusinessUnit`, `Model`, `OperatingSystem`). Removing a name from `shopdb.api` is major. |
|
||||||
|
| minor | Additive change: a new optional hook, a new field on a contract model with a default, a new name added to `shopdb.api`. |
|
||||||
|
| patch | Bug fix with no change to the contract surface. |
|
||||||
|
|
||||||
|
### Deprecation policy
|
||||||
|
|
||||||
|
ADR-002 defines the bump classification above but is **silent** on any
|
||||||
|
deprecation window or notice period for pre-1.0 removals. In practice a removal
|
||||||
|
is simply a major (or, pre-1.0, a breaking minor) bump: the hook or name is
|
||||||
|
gone, and the loader fails loud in dev or excludes the mismatched plugin in prod
|
||||||
|
(see the load-time table in [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md)).
|
||||||
|
Because the ADR says nothing about a grace period, this document proposes none;
|
||||||
|
the honest guidance for sister sites is the mitigation that already exists - pin
|
||||||
|
a tight `core_version` range and re-test before widening it.
|
||||||
|
|
||||||
|
## Criteria for declaring 1.0
|
||||||
|
|
||||||
|
Provisional. This is the maintainer's working list, not a committed checklist,
|
||||||
|
and it will move. ADR-002's own open question ("When does the framework declare
|
||||||
|
1.0.0?") ties 1.0 to the `Machine` retirement from ADR-001 and the framework
|
||||||
|
being "ready for sister sites"; the items below expand that intent.
|
||||||
|
|
||||||
|
- Frontend hook contract defined via its own ADR (asset-detail panels, map
|
||||||
|
markers, search results), closing the biggest churn item above.
|
||||||
|
- `Machine` / legacy-model retirement complete per ADR-001, so the asset model
|
||||||
|
is the only contract.
|
||||||
|
- At least two external plugins running in production at a second site - the
|
||||||
|
bar ADR-003 already sets before pip distribution and sister-site readiness
|
||||||
|
are considered justified.
|
||||||
|
- The per-plugin migration pattern (ADR-008) proven across real upgrades, not
|
||||||
|
just fresh installs.
|
||||||
|
- No contract bump needed for N consecutive product releases (N to be fixed
|
||||||
|
when the list is firmed up), showing the surface has actually settled.
|
||||||
|
|
||||||
|
Until then: pre-1.0, pin tight, re-test on every framework bump.
|
||||||
340
docs/PLUGIN-EXTERNAL-REPO.md
Normal file
340
docs/PLUGIN-EXTERNAL-REPO.md
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
# Developing a plugin in its own repo
|
||||||
|
|
||||||
|
This guide is for a sister GE Aerospace site (or any third party) building a
|
||||||
|
shopdb-flask plugin in a git repo it owns, outside the framework tree. It is
|
||||||
|
the "external plugin" path in [ADR-003](adr/ADR-003-plugin-distribution.md):
|
||||||
|
the framework ships a bundled set, and you drop your own plugin into
|
||||||
|
`<framework>/plugins/<name>/` by clone, submodule, or symlink. No pip packaging
|
||||||
|
is required for v1 (pip distribution is deferred to v2 per ADR-003).
|
||||||
|
|
||||||
|
If you have not written a plugin before, start with
|
||||||
|
[PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) and the hook reference in
|
||||||
|
[PLUGIN-HOOKS.md](PLUGIN-HOOKS.md). This document only covers the parts that are
|
||||||
|
different when the plugin lives in its own repo.
|
||||||
|
|
||||||
|
## Recommended repo layout
|
||||||
|
|
||||||
|
Your repo root holds the plugin directory contents directly, so the whole repo
|
||||||
|
can be dropped in at `<framework>/plugins/<name>/`. A plugin named `shipping`
|
||||||
|
in a repo named `wjsf-shipping` looks like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
wjsf-shipping/ # your git repo root == the plugin directory
|
||||||
|
manifest.json # required: name, version, description, core_version
|
||||||
|
plugin.py # required: your BasePlugin subclass
|
||||||
|
__init__.py
|
||||||
|
models/
|
||||||
|
__init__.py # exports every model
|
||||||
|
shipping.py
|
||||||
|
api/
|
||||||
|
__init__.py
|
||||||
|
routes.py # Flask Blueprint returned by get_blueprint()
|
||||||
|
migrations/ # per-plugin Alembic chain (ADR-008), if you own tables
|
||||||
|
env.py
|
||||||
|
script.py.mako
|
||||||
|
versions/
|
||||||
|
0001_shipping_baseline.py
|
||||||
|
tests/ # your own tests (the CI harness runs these)
|
||||||
|
test_shipping.py
|
||||||
|
README.md # what it tracks, who maintains it, where to file issues
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `manifest.json` is the single source of truth for metadata (ADR-002). The
|
||||||
|
`name` field must match the directory name the site installs it under and
|
||||||
|
follows the framework naming convention (lowercase concatenated, no
|
||||||
|
underscores or dashes). Prefix site-specific names with the site code when a
|
||||||
|
collision across sites is possible, e.g. `wjsf-shipping`
|
||||||
|
(see [PLUGINS.md](PLUGINS.md) naming policy and ADR-003).
|
||||||
|
- `migrations/` is only needed if your plugin owns tables. A plugin built
|
||||||
|
outside the tree never had its tables created by the framework's core chain,
|
||||||
|
so its `0001` is a REAL baseline that creates them, not a stamp-only anchor.
|
||||||
|
This is the same "baseline vs anchor" distinction ADR-008 draws for
|
||||||
|
post-cutover plugins; see [ADR-008](adr/ADR-008-plugin-migration-ownership.md).
|
||||||
|
- Import core code ONLY through `shopdb.api` (plus `shopdb.plugins.base` for the
|
||||||
|
ABC). Deep imports of `shopdb.core.*`, `shopdb.extensions`, or `shopdb.utils.*`
|
||||||
|
are contract violations. See PLUGIN-HOOKS.md for the exposed surface.
|
||||||
|
|
||||||
|
## Local dev workflow
|
||||||
|
|
||||||
|
Symlinking lets you edit in your own repo while the framework loads the plugin
|
||||||
|
live. The loader discovers a symlinked directory the same as a real one.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Clone the framework and your plugin repo side by side.
|
||||||
|
git clone https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||||
|
git clone https://gitea.proudtech.net/wjsf/wjsf-shipping.git
|
||||||
|
|
||||||
|
# 2. Symlink your repo into the framework's plugins/ directory.
|
||||||
|
# The link name is the plugin name from your manifest.json.
|
||||||
|
cd shopdb-flask
|
||||||
|
ln -s ../../wjsf-shipping plugins/shipping
|
||||||
|
# (use an absolute path if you prefer: ln -s "$(pwd)/../wjsf-shipping" plugins/shipping)
|
||||||
|
|
||||||
|
# 3. Set up the framework as usual.
|
||||||
|
python3 -m venv venv
|
||||||
|
venv/bin/pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 4. Install (enable) your plugin.
|
||||||
|
venv/bin/flask plugin install shipping
|
||||||
|
|
||||||
|
# 5. If your plugin owns tables, apply its migrations.
|
||||||
|
venv/bin/flask db upgrade # core chain
|
||||||
|
venv/bin/flask plugin upgrade-all # plugin chains, including yours
|
||||||
|
|
||||||
|
# 6. Run tests.
|
||||||
|
venv/bin/python -m pytest tests/test_plugin_contract.py
|
||||||
|
venv/bin/python -m pytest ../wjsf-shipping/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Edits in `../wjsf-shipping` are picked up on the next framework restart, because
|
||||||
|
the symlink points back at your working tree.
|
||||||
|
|
||||||
|
## core_version pinning for sister sites
|
||||||
|
|
||||||
|
The framework is pre-1.0. Under semver, any 0.x minor bump is allowed to break
|
||||||
|
the contract, and this project uses that latitude (see the history in
|
||||||
|
[CONTRACT-STABILITY.md](CONTRACT-STABILITY.md)). So pin a TIGHT range that
|
||||||
|
admits only the contract minor you tested against, not the whole 0.x line.
|
||||||
|
|
||||||
|
The current contract version is declared in `shopdb/__init__.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
__contract_version__ = '0.6.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended pin in your `manifest.json`, per ADR-002 (pip-style `>=,<` ranges):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "shipping",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Tracks shipping-station scanners and label printers",
|
||||||
|
"core_version": ">=0.6.0,<0.7.0",
|
||||||
|
"dependencies": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT pin the loose `>=0.2.0,<1.0.0` default that `PluginMeta` falls back to.
|
||||||
|
That default exists so bundled plugins keep loading across minor bumps; an
|
||||||
|
external plugin should be deliberate and re-test before widening its range.
|
||||||
|
|
||||||
|
What happens at load time on a mismatch (ADR-002):
|
||||||
|
|
||||||
|
| Environment | Behavior on core_version mismatch |
|
||||||
|
|-------------|-----------------------------------|
|
||||||
|
| dev / test | The loader re-raises. Startup fails loud so you notice immediately. |
|
||||||
|
| production | The loader logs an error, marks the plugin incompatible, and excludes it from registration. The rest of the app still starts. |
|
||||||
|
|
||||||
|
When you move a site to a newer framework, bump your `core_version` upper bound
|
||||||
|
only after the harness below passes against the new ref.
|
||||||
|
|
||||||
|
## CI recipe
|
||||||
|
|
||||||
|
`scripts/test-external-plugin.sh` (in the framework repo) stands up a throwaway
|
||||||
|
framework at a pinned ref, drops your plugin in as a symlink, and runs the
|
||||||
|
framework contract tests plus your own `tests/`. It has two modes:
|
||||||
|
|
||||||
|
- **CI / remote** (default): clones the framework at `FRAMEWORK_REF` and builds
|
||||||
|
a fresh venv. Needs network access to git and PyPI.
|
||||||
|
- **Local / offline**: set `LOCAL_FRAMEWORK` to a framework checkout on disk.
|
||||||
|
The script exports that checkout at HEAD with `git archive` and reuses its
|
||||||
|
existing venv, so it runs with no internet. Useful for air-gapped verification.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CI: test the plugin in the current repo against a pinned tag
|
||||||
|
PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh
|
||||||
|
|
||||||
|
# Offline: test against a framework checkout already on disk
|
||||||
|
LOCAL_FRAMEWORK=/opt/shopdb-flask PLUGIN_DIR=. scripts/test-external-plugin.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The full script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# test-external-plugin.sh
|
||||||
|
#
|
||||||
|
# Verify an out-of-tree shopdb-flask plugin against a pinned framework build.
|
||||||
|
# Stand up a throwaway copy of the framework, drop the plugin into
|
||||||
|
# plugins/<name>/ as a symlink (the mechanism ADR-003 documents for sister
|
||||||
|
# sites), and run the framework contract tests plus the plugin's own tests.
|
||||||
|
# Nonzero exit means the plugin is not compatible with that framework ref.
|
||||||
|
#
|
||||||
|
# Two modes:
|
||||||
|
#
|
||||||
|
# CI / remote (default): clone the framework at FRAMEWORK_REF from
|
||||||
|
# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs
|
||||||
|
# network access to git and PyPI.
|
||||||
|
#
|
||||||
|
# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk.
|
||||||
|
# The script exports that checkout at HEAD with `git archive` (no network)
|
||||||
|
# and reuses the checkout's existing venv, so it runs with no internet.
|
||||||
|
#
|
||||||
|
# Inputs (env var, or positional):
|
||||||
|
# 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://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||||
|
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
|
||||||
|
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
|
||||||
|
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
|
||||||
|
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
|
||||||
|
|
||||||
|
if [ -z "$PLUGIN_DIR" ]; then
|
||||||
|
echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then
|
||||||
|
echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)"
|
||||||
|
PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)"
|
||||||
|
if [ -z "$PLUGIN_NAME" ]; then
|
||||||
|
PLUGIN_NAME="$(basename "$PLUGIN_ABS")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
cleanup() { rm -rf "$WORKDIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
FRAMEWORK="$WORKDIR/framework"
|
||||||
|
mkdir -p "$FRAMEWORK"
|
||||||
|
|
||||||
|
if [ -n "$LOCAL_FRAMEWORK" ]; then
|
||||||
|
echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)"
|
||||||
|
LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)"
|
||||||
|
git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK"
|
||||||
|
if [ -x "$LOCAL_ABS/venv/bin/python" ]; then
|
||||||
|
PYTHON="$LOCAL_ABS/venv/bin/python"
|
||||||
|
elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then
|
||||||
|
PYTHON="$LOCAL_ABS/.venv/bin/python"
|
||||||
|
else
|
||||||
|
echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF"
|
||||||
|
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK"
|
||||||
|
echo "==> Creating venv and installing requirements"
|
||||||
|
python3 -m venv "$WORKDIR/venv"
|
||||||
|
PYTHON="$WORKDIR/venv/bin/python"
|
||||||
|
"$PYTHON" -m pip install --upgrade pip >/dev/null
|
||||||
|
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
|
||||||
|
rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||||
|
ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||||
|
|
||||||
|
rm -f "$FRAMEWORK/instance/plugins.json"
|
||||||
|
|
||||||
|
RC=0
|
||||||
|
|
||||||
|
echo "==> Running framework contract tests"
|
||||||
|
( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1
|
||||||
|
|
||||||
|
if [ -d "$PLUGIN_ABS/tests" ]; then
|
||||||
|
echo "==> Running plugin's own tests"
|
||||||
|
( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1
|
||||||
|
else
|
||||||
|
echo "==> Plugin has no tests/ directory - skipping plugin test step"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RC" -eq 0 ]; then
|
||||||
|
echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'"
|
||||||
|
else
|
||||||
|
echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2
|
||||||
|
fi
|
||||||
|
exit "$RC"
|
||||||
|
```
|
||||||
|
|
||||||
|
### What the harness does and does not check
|
||||||
|
|
||||||
|
The symlinked plugin is discovered and loaded by the plugin loader when the app
|
||||||
|
starts under test. That validates the things that break a real install: the
|
||||||
|
manifest parses, the `core_version` range admits the framework's
|
||||||
|
`__contract_version__` (an out-of-range plugin makes startup fail loud, so the
|
||||||
|
contract test run errors and the script exits nonzero), models expose
|
||||||
|
`__tablename__`, and hooks return the right shapes.
|
||||||
|
|
||||||
|
One gap to know about: the framework's `test_plugins_only_import_contract_surface`
|
||||||
|
scans `plugins/` with `Path.rglob`, which does not descend symlinked directories
|
||||||
|
on CPython 3.12. So that particular sub-test does not see a symlinked external
|
||||||
|
plugin's source. Keep an equivalent import-surface assertion in your own
|
||||||
|
`tests/` so your CI still enforces "core imports only via `shopdb.api`". A
|
||||||
|
minimal version:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/test_import_surface.py
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ALLOWED = ('shopdb.api', 'shopdb.plugins.base')
|
||||||
|
IMPORT_RE = re.compile(r'^\s*(?:from (shopdb[\w.]*) import|import (shopdb[\w.]*))', re.MULTILINE)
|
||||||
|
|
||||||
|
def test_only_contract_surface_imports():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
bad = []
|
||||||
|
for path in root.rglob('*.py'):
|
||||||
|
if '__pycache__' in path.parts or 'migrations' in path.parts:
|
||||||
|
continue
|
||||||
|
for m in IMPORT_RE.finditer(path.read_text()):
|
||||||
|
mod = m.group(1) or m.group(2)
|
||||||
|
if mod and not any(mod == a or mod.startswith(a + '.') for a in ALLOWED):
|
||||||
|
bad.append(f'{path.name}: {mod}')
|
||||||
|
assert not bad, 'core imports outside shopdb.api / shopdb.plugins.base: ' + '; '.join(bad)
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub Actions example
|
||||||
|
|
||||||
|
For a plugin repo hosted on GitHub, a workflow calling the harness against a
|
||||||
|
pinned framework tag (config only, adjust URLs and ref to your setup):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: plugin-contract
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
contract:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
FRAMEWORK_REF: v0.5.0
|
||||||
|
FRAMEWORK_URL: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||||
|
steps:
|
||||||
|
- name: Check out the plugin
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Fetch the harness from the framework
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" /tmp/framework
|
||||||
|
- name: Run the contract harness against this plugin
|
||||||
|
run: |
|
||||||
|
PLUGIN_DIR="$GITHUB_WORKSPACE" \
|
||||||
|
FRAMEWORK_REF="$FRAMEWORK_REF" \
|
||||||
|
FRAMEWORK_URL="$FRAMEWORK_URL" \
|
||||||
|
bash /tmp/framework/scripts/test-external-plugin.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate and install a plugin fast
|
||||||
|
- [PLUGIN-HOOKS.md](PLUGIN-HOOKS.md) - the full hook and import-surface reference
|
||||||
|
- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - what is settled vs still churning before 1.0
|
||||||
|
- [ADR-002](adr/ADR-002-plugin-versioning.md) - contract versioning and core_version ranges
|
||||||
|
- [ADR-003](adr/ADR-003-plugin-distribution.md) - the bundled vs external distribution model
|
||||||
|
- [ADR-008](adr/ADR-008-plugin-migration-ownership.md) - per-plugin migration ownership
|
||||||
@@ -11,7 +11,7 @@ For the architectural decisions behind the contract, see [docs/adr/](../docs/adr
|
|||||||
flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
|
flask plugin new cameras --description "Tracks shop-floor surveillance cameras"
|
||||||
```
|
```
|
||||||
|
|
||||||
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, and a README.
|
Output: `plugins/cameras/` with manifest, plugin class, example model, example routes, schemas stub, tests, a README, and a paste-in `frontend-api-snippet.js`. When a `frontend/src/` tree is present, it also writes the frontend starting points: `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue`, and the auto-discovered route file `frontend/src/router/routes/cameras.js`. (A plugin developed in its own repo, with no frontend tree, gets the backend skeleton plus the snippet only.)
|
||||||
|
|
||||||
The generated plugin already passes the framework's contract tests. Verify before editing:
|
The generated plugin already passes the framework's contract tests. Verify before editing:
|
||||||
|
|
||||||
@@ -126,13 +126,13 @@ Override hooks on the plugin class as needed. See [PLUGIN-HOOKS.md](PLUGIN-HOOKS
|
|||||||
|
|
||||||
Each hook has a default that does nothing. Override only what your plugin needs.
|
Each hook has a default that does nothing. Override only what your plugin needs.
|
||||||
|
|
||||||
## Step 7: Frontend (manual checklist)
|
## Step 7: Frontend (finish the generated starting points)
|
||||||
|
|
||||||
Backend scaffolding is automated. The frontend is a manual checklist until a frontend scaffold ships. Copy from the closest bundled plugin (`network` is the cleanest) and work through these in order:
|
The scaffold generates the frontend starting points too: three views, a route file, and a paste-in api-client snippet (see Step 1). They build and run out of the box against the example model, so `npm run build` is green immediately after scaffolding. The list below is what you finish by hand once the views exist. Copy patterns from the closest bundled plugin (`network` is the cleanest) as you flesh them out, and work through these in order:
|
||||||
|
|
||||||
1. **View files** - create `frontend/src/views/cameras/CamerasList.vue`, `CameraDetail.vue`, `CameraForm.vue`. Copy from `frontend/src/views/network/` and rename. Use the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling.
|
1. **View files** - the scaffold created `frontend/src/views/cameras/CamerasList.vue`, `CamerasDetail.vue`, `CamerasForm.vue` from the example model. Replace the `examplefield` columns and inputs with your domain fields. Keep the global `.filters` / `.form-control` / `.card` styles; do not invent per-page input styling.
|
||||||
|
|
||||||
2. **Route file** - create `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Tag EVERY route with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled. Add `requiresAuth: true` on form routes:
|
2. **Route file** - the scaffold created `frontend/src/router/routes/cameras.js` exporting a route array. The router auto-discovers every file in `routes/` via `import.meta.glob`, so no registration edit is needed. Every route is already tagged with `meta: { plugin: 'cameras' }` - the ADR-009 guard redirects to the dashboard when the backend plugin is disabled - and the form routes already carry `requiresAuth: true`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
export default [
|
export default [
|
||||||
@@ -144,14 +144,14 @@ export default [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'cameras/:id/edit',
|
path: 'cameras/:id/edit',
|
||||||
name: 'camera-edit',
|
name: 'cameras-edit',
|
||||||
component: () => import('../../views/cameras/CameraForm.vue'),
|
component: () => import('../../views/cameras/CamerasForm.vue'),
|
||||||
meta: { requiresAuth: true, plugin: 'cameras' }
|
meta: { requiresAuth: true, plugin: 'cameras' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **API client** - add a `camerasApi` block to `frontend/src/api/index.js` wrapping your endpoints. Match an existing block's shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`).
|
3. **API client** - the scaffolded views ship with an inline `camerasApi` client so they run standalone. To graduate to the shared module, paste the generated `plugins/cameras/frontend-api-snippet.js` block into `frontend/src/api/index.js`, then delete the inline const in each view and `import { camerasApi } from '../../api'` instead. The snippet already matches the existing blocks' shape (`list(params)`, `get(id)`, `create(data)`, `update(id, data)`, `remove(id)`).
|
||||||
|
|
||||||
4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`.
|
4. **Sidebar entry** - implement `get_navigation_items` on the plugin class. No frontend edit: the sidebar builds itself from `/api/dashboard/navigation`.
|
||||||
|
|
||||||
@@ -182,3 +182,5 @@ export default [
|
|||||||
## Distribution
|
## Distribution
|
||||||
|
|
||||||
If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `<repo>/plugins/<name>/`. See [ADR-003](../docs/adr/ADR-003-plugin-distribution.md).
|
If you are building a plugin for a specific GE Aerospace site (sister-site adoption), ship it as its own git repo. The site running shopdb-flask clones or symlinks your plugin into `<repo>/plugins/<name>/`. See [ADR-003](../docs/adr/ADR-003-plugin-distribution.md).
|
||||||
|
|
||||||
|
For the full own-repo workflow (layout, symlink dev loop, CI recipe with `scripts/test-external-plugin.sh`, version pinning), see [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md). For what you can rely on staying stable before contract 1.0, see [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md).
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ These plugins are in `plugins/` in this repo. Enable per site with `flask plugin
|
|||||||
|
|
||||||
## Building your own
|
## Building your own
|
||||||
|
|
||||||
Two guides:
|
Guides:
|
||||||
|
|
||||||
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate, customize, install, and test a plugin in 30 minutes using `flask plugin new`.
|
- [PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - generate, customize, install, and test a plugin in 30 minutes using `flask plugin new`.
|
||||||
- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin, the exemplar that exercises every current framework feature (models, per-plugin migrations, authz, hooks, frontend integration, tests).
|
- [PLUGIN-GUIDE.md](PLUGIN-GUIDE.md) - the full narrative walkthrough of building the `measuringtools` plugin, the exemplar that exercises every current framework feature (models, per-plugin migrations, authz, hooks, frontend integration, tests).
|
||||||
|
- [PLUGIN-EXTERNAL-REPO.md](PLUGIN-EXTERNAL-REPO.md) - developing a plugin in its own repo per ADR-003: repo layout, symlink dev workflow, `core_version` pinning, and a runnable CI harness (`scripts/test-external-plugin.sh`) that tests the plugin against a pinned framework ref.
|
||||||
|
- [CONTRACT-STABILITY.md](CONTRACT-STABILITY.md) - path to contract 1.0: what is settled vs still churning, the bump rules, and how much a sister site can safely build on today.
|
||||||
|
|
||||||
The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md).
|
The contract is locked in [ADR-001](adr/ADR-001-asset-as-platform-contract.md) and versioned per [ADR-002](adr/ADR-002-plugin-versioning.md).
|
||||||
|
|
||||||
|
|||||||
121
scripts/test-external-plugin.sh
Executable file
121
scripts/test-external-plugin.sh
Executable file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# test-external-plugin.sh
|
||||||
|
#
|
||||||
|
# Verify an out-of-tree shopdb-flask plugin against a pinned framework build.
|
||||||
|
# Stand up a throwaway copy of the framework, drop the plugin into
|
||||||
|
# plugins/<name>/ as a symlink (the mechanism ADR-003 documents for sister
|
||||||
|
# sites), and run the framework contract tests plus the plugin's own tests.
|
||||||
|
# Nonzero exit means the plugin is not compatible with that framework ref.
|
||||||
|
#
|
||||||
|
# Two modes:
|
||||||
|
#
|
||||||
|
# CI / remote (default): clone the framework at FRAMEWORK_REF from
|
||||||
|
# FRAMEWORK_URL, build a fresh venv, pip install requirements. Needs
|
||||||
|
# network access to git and PyPI.
|
||||||
|
#
|
||||||
|
# Local / offline: set LOCAL_FRAMEWORK to a framework checkout on disk.
|
||||||
|
# The script exports that checkout at HEAD with `git archive` (no network)
|
||||||
|
# and reuses the checkout's existing venv, so it runs with no internet.
|
||||||
|
#
|
||||||
|
# Inputs (env var, or positional):
|
||||||
|
# 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://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||||
|
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# # CI: test the plugin in the current repo against a pinned tag
|
||||||
|
# PLUGIN_DIR=. FRAMEWORK_REF=v0.5.0 scripts/test-external-plugin.sh
|
||||||
|
#
|
||||||
|
# # Offline: test a bundled plugin against this checkout, no network
|
||||||
|
# LOCAL_FRAMEWORK=. PLUGIN_DIR=plugins/warranty scripts/test-external-plugin.sh
|
||||||
|
#
|
||||||
|
# Note on coverage: a symlinked plugin is discovered and loaded by the plugin
|
||||||
|
# loader (so loadability, manifest validity, and the core_version range are all
|
||||||
|
# checked), but the import-surface scan in test_plugin_contract.py uses
|
||||||
|
# Path.rglob over plugins/, which does not descend symlinked directories on
|
||||||
|
# CPython 3.12. To have that scan cover your plugin too, keep an equivalent
|
||||||
|
# import-surface assertion in your own tests/ (see docs/PLUGIN-EXTERNAL-REPO.md).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
|
||||||
|
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
|
||||||
|
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
|
||||||
|
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
|
||||||
|
|
||||||
|
if [ -z "$PLUGIN_DIR" ]; then
|
||||||
|
echo "ERROR: PLUGIN_DIR is required (env var or first argument)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [ ! -f "$PLUGIN_DIR/manifest.json" ]; then
|
||||||
|
echo "ERROR: $PLUGIN_DIR has no manifest.json - not a plugin directory." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Absolute path so the symlink still resolves after we cd into the temp tree.
|
||||||
|
PLUGIN_ABS="$(cd "$PLUGIN_DIR" && pwd)"
|
||||||
|
# Plugin name: prefer the manifest name, fall back to the directory basename.
|
||||||
|
PLUGIN_NAME="$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$PLUGIN_ABS/manifest.json" | head -n1)"
|
||||||
|
if [ -z "$PLUGIN_NAME" ]; then
|
||||||
|
PLUGIN_NAME="$(basename "$PLUGIN_ABS")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
cleanup() { rm -rf "$WORKDIR"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
FRAMEWORK="$WORKDIR/framework"
|
||||||
|
mkdir -p "$FRAMEWORK"
|
||||||
|
|
||||||
|
if [ -n "$LOCAL_FRAMEWORK" ]; then
|
||||||
|
echo "==> Local mode: exporting framework from $LOCAL_FRAMEWORK (HEAD)"
|
||||||
|
LOCAL_ABS="$(cd "$LOCAL_FRAMEWORK" && pwd)"
|
||||||
|
git -C "$LOCAL_ABS" archive HEAD | tar -x -C "$FRAMEWORK"
|
||||||
|
# Reuse the checkout's venv so no pip and no network are needed.
|
||||||
|
if [ -x "$LOCAL_ABS/venv/bin/python" ]; then
|
||||||
|
PYTHON="$LOCAL_ABS/venv/bin/python"
|
||||||
|
elif [ -x "$LOCAL_ABS/.venv/bin/python" ]; then
|
||||||
|
PYTHON="$LOCAL_ABS/.venv/bin/python"
|
||||||
|
else
|
||||||
|
echo "ERROR: no venv found under $LOCAL_ABS (looked for venv/ and .venv/)." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "==> CI mode: cloning $FRAMEWORK_URL @ $FRAMEWORK_REF"
|
||||||
|
git clone --depth 1 --branch "$FRAMEWORK_REF" "$FRAMEWORK_URL" "$FRAMEWORK"
|
||||||
|
echo "==> Creating venv and installing requirements"
|
||||||
|
python3 -m venv "$WORKDIR/venv"
|
||||||
|
PYTHON="$WORKDIR/venv/bin/python"
|
||||||
|
"$PYTHON" -m pip install --upgrade pip >/dev/null
|
||||||
|
"$PYTHON" -m pip install -r "$FRAMEWORK/requirements.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Linking plugin '$PLUGIN_NAME' into framework plugins/"
|
||||||
|
rm -rf "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||||
|
ln -s "$PLUGIN_ABS" "$FRAMEWORK/plugins/$PLUGIN_NAME"
|
||||||
|
|
||||||
|
# Drop any exported registry so the loader re-discovers and enables the plugin
|
||||||
|
# (the test conftest seeds a fresh registry from plugins/*/manifest.json).
|
||||||
|
rm -f "$FRAMEWORK/instance/plugins.json"
|
||||||
|
|
||||||
|
RC=0
|
||||||
|
|
||||||
|
echo "==> Running framework contract tests"
|
||||||
|
( cd "$FRAMEWORK" && "$PYTHON" -m pytest tests/test_plugin_contract.py -q ) || RC=1
|
||||||
|
|
||||||
|
if [ -d "$PLUGIN_ABS/tests" ]; then
|
||||||
|
echo "==> Running plugin's own tests"
|
||||||
|
( cd "$FRAMEWORK" && "$PYTHON" -m pytest "$PLUGIN_ABS/tests" -q ) || RC=1
|
||||||
|
else
|
||||||
|
echo "==> Plugin has no tests/ directory - skipping plugin test step"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RC" -eq 0 ]; then
|
||||||
|
echo "==> PASS: plugin '$PLUGIN_NAME' is compatible with framework ref '$FRAMEWORK_REF'"
|
||||||
|
else
|
||||||
|
echo "==> FAIL: plugin '$PLUGIN_NAME' - see output above" >&2
|
||||||
|
fi
|
||||||
|
exit "$RC"
|
||||||
@@ -57,12 +57,104 @@ def pascal_case(name: str) -> str:
|
|||||||
return name[:1].upper() + name[1:]
|
return name[:1].upper() + name[1:]
|
||||||
|
|
||||||
|
|
||||||
|
def _render_template(
|
||||||
|
template_path: Path,
|
||||||
|
out_path: Path,
|
||||||
|
substitutions: dict,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> bool:
|
||||||
|
"""Render one template to out_path.
|
||||||
|
|
||||||
|
Skips silently when out_path already exists and overwrite is False, so a
|
||||||
|
scaffold never clobbers a file the author has already edited. Returns True
|
||||||
|
when the file was written, False when it was skipped.
|
||||||
|
"""
|
||||||
|
if out_path.exists() and not overwrite:
|
||||||
|
return False
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
body = template_path.read_text()
|
||||||
|
out_path.write_text(Template(body).safe_substitute(substitutions))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _scaffold_frontend(
|
||||||
|
name: str,
|
||||||
|
substitutions: dict,
|
||||||
|
plugin_target: Path,
|
||||||
|
frontend_dir: Path,
|
||||||
|
template_root: Path,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Render the frontend starting points for a scaffolded plugin.
|
||||||
|
|
||||||
|
Writes the paste-in api-client snippet into the plugin directory, then the
|
||||||
|
Vue views and the router route file into the real frontend tree. The
|
||||||
|
snippet is emitted regardless of whether the frontend tree exists, because
|
||||||
|
it is a plugin-directory artifact useful even for external-repo plugins.
|
||||||
|
Views and the route file are skipped when frontend_dir is missing, which is
|
||||||
|
the normal case for a plugin developed in its own repository.
|
||||||
|
|
||||||
|
Ordering matters: every view is written before the route file. A route file
|
||||||
|
that lazy-imports a view that is not on disk crashes the Vite dev server,
|
||||||
|
so the views must land first.
|
||||||
|
"""
|
||||||
|
fe_templates = template_root / 'frontend'
|
||||||
|
if not fe_templates.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
plugin_name = substitutions['Name']
|
||||||
|
|
||||||
|
# snippet lands in the plugin dir; author pastes it into api/index.js
|
||||||
|
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
||||||
|
if snippet_template.exists():
|
||||||
|
_render_template(
|
||||||
|
snippet_template,
|
||||||
|
plugin_target / 'frontend-api-snippet.js',
|
||||||
|
substitutions,
|
||||||
|
overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
# views and route need the real frontend tree; external repos skip these
|
||||||
|
if not frontend_dir.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
views_dir = frontend_dir / 'views' / name
|
||||||
|
|
||||||
|
# views first: route file lazy-imports them, missing views 500 vite
|
||||||
|
view_templates = {
|
||||||
|
'List.vue.tmpl': f'{plugin_name}List.vue',
|
||||||
|
'Detail.vue.tmpl': f'{plugin_name}Detail.vue',
|
||||||
|
'Form.vue.tmpl': f'{plugin_name}Form.vue',
|
||||||
|
}
|
||||||
|
for template_name, out_name in view_templates.items():
|
||||||
|
template_path = fe_templates / 'views' / template_name
|
||||||
|
if template_path.exists():
|
||||||
|
_render_template(
|
||||||
|
template_path,
|
||||||
|
views_dir / out_name,
|
||||||
|
substitutions,
|
||||||
|
overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
# route file last: all referenced views now exist on disk
|
||||||
|
route_template = fe_templates / 'routes.js.tmpl'
|
||||||
|
if route_template.exists():
|
||||||
|
_render_template(
|
||||||
|
route_template,
|
||||||
|
frontend_dir / 'router' / 'routes' / f'{name}.js',
|
||||||
|
substitutions,
|
||||||
|
overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def scaffold_plugin(
|
def scaffold_plugin(
|
||||||
name: str,
|
name: str,
|
||||||
description: str,
|
description: str,
|
||||||
plugins_dir: Path,
|
plugins_dir: Path,
|
||||||
template_root: Optional[Path] = None,
|
template_root: Optional[Path] = None,
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
|
frontend: bool = True,
|
||||||
|
frontend_dir: Optional[Path] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Generate a new plugin from templates.
|
"""Generate a new plugin from templates.
|
||||||
|
|
||||||
@@ -71,7 +163,14 @@ def scaffold_plugin(
|
|||||||
description: One-sentence description for manifest.json + README
|
description: One-sentence description for manifest.json + README
|
||||||
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
|
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
|
||||||
template_root: Override template source dir (default: bundled templates)
|
template_root: Override template source dir (default: bundled templates)
|
||||||
overwrite: If True, overwrite an existing plugin directory
|
overwrite: If True, overwrite an existing plugin directory and any
|
||||||
|
existing generated frontend files
|
||||||
|
frontend: If True, also render the Vue frontend starting points (list,
|
||||||
|
detail, form views, a route file, and a paste-in api-client snippet)
|
||||||
|
frontend_dir: Frontend src directory to render views/routes into
|
||||||
|
(default: <plugins_dir>/../frontend/src). Views and the route file
|
||||||
|
are skipped when this directory does not exist, which is the normal
|
||||||
|
case for a plugin developed in its own repository.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to the generated plugin directory.
|
Path to the generated plugin directory.
|
||||||
@@ -106,6 +205,10 @@ def scaffold_plugin(
|
|||||||
for template_path in template_root.rglob('*.tmpl'):
|
for template_path in template_root.rglob('*.tmpl'):
|
||||||
rel = template_path.relative_to(template_root)
|
rel = template_path.relative_to(template_root)
|
||||||
|
|
||||||
|
# frontend templates render into the frontend tree, not the plugin dir
|
||||||
|
if rel.parts and rel.parts[0] == 'frontend':
|
||||||
|
continue
|
||||||
|
|
||||||
out_rel_str = str(rel.with_suffix(''))
|
out_rel_str = str(rel.with_suffix(''))
|
||||||
if 'model.py' in out_rel_str:
|
if 'model.py' in out_rel_str:
|
||||||
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
|
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
|
||||||
@@ -118,4 +221,16 @@ def scaffold_plugin(
|
|||||||
|
|
||||||
out_path.write_text(rendered)
|
out_path.write_text(rendered)
|
||||||
|
|
||||||
|
if frontend:
|
||||||
|
if frontend_dir is None:
|
||||||
|
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
|
||||||
|
_scaffold_frontend(
|
||||||
|
name=name,
|
||||||
|
substitutions=substitutions,
|
||||||
|
plugin_target=target,
|
||||||
|
frontend_dir=Path(frontend_dir),
|
||||||
|
template_root=template_root,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
|
|
||||||
return target
|
return target
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
shopdb/plugins/templates/frontend/routes.js.tmpl
Normal file
35
shopdb/plugins/templates/frontend/routes.js.tmpl
Normal 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}' }
|
||||||
|
}
|
||||||
|
]
|
||||||
149
shopdb/plugins/templates/frontend/views/Detail.vue.tmpl
Normal file
149
shopdb/plugins/templates/frontend/views/Detail.vue.tmpl
Normal 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>
|
||||||
228
shopdb/plugins/templates/frontend/views/Form.vue.tmpl
Normal file
228
shopdb/plugins/templates/frontend/views/Form.vue.tmpl
Normal 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>
|
||||||
142
shopdb/plugins/templates/frontend/views/List.vue.tmpl
Normal file
142
shopdb/plugins/templates/frontend/views/List.vue.tmpl
Normal 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>
|
||||||
252
tests/test_plugin_scaffold_frontend.py
Normal file
252
tests/test_plugin_scaffold_frontend.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""Canary tests for the plugin scaffolder's frontend generation.
|
||||||
|
|
||||||
|
`flask plugin new <name>` generates the backend skeleton and, when a frontend
|
||||||
|
tree is present, a matching Vue frontend starting point: list, detail, and form
|
||||||
|
views, a router route file, and a paste-in api-client snippet. These tests
|
||||||
|
guard the file names, the substitutions, the ADR-009 plugin gating on the
|
||||||
|
routes, the use of global CSS classes over per-page input styling, graceful
|
||||||
|
skipping when there is no frontend tree, and the no-clobber behaviour.
|
||||||
|
|
||||||
|
All generation runs against tmp_path. The real frontend/src tree is never
|
||||||
|
touched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.plugins.scaffolder import scaffold_plugin, ScaffoldError
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dirs(tmp_path: Path):
|
||||||
|
"""Create a tmp plugins dir and a tmp frontend/src tree.
|
||||||
|
|
||||||
|
Returns (plugins_dir, frontend_dir).
|
||||||
|
"""
|
||||||
|
plugins_dir = tmp_path / 'plugins'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
frontend_dir = tmp_path / 'frontend' / 'src'
|
||||||
|
frontend_dir.mkdir(parents=True)
|
||||||
|
return plugins_dir, frontend_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_files_generated_with_correct_names(tmp_path):
|
||||||
|
"""Views, route file, and api snippet appear with the expected names."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
|
||||||
|
target = scaffold_plugin(
|
||||||
|
name='widgets',
|
||||||
|
description='Test widgets plugin',
|
||||||
|
plugins_dir=plugins_dir,
|
||||||
|
frontend_dir=frontend_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists()
|
||||||
|
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue').exists()
|
||||||
|
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue').exists()
|
||||||
|
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
||||||
|
# paste-in snippet lands in the plugin dir, not the frontend tree
|
||||||
|
assert (target / 'frontend-api-snippet.js').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_substitutions_applied(tmp_path):
|
||||||
|
"""Name and Name placeholders are substituted, none left raw."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
|
||||||
|
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||||
|
assert "import('../../views/widgets/WidgetsList.vue')" in route
|
||||||
|
assert "import('../../views/widgets/WidgetsForm.vue')" in route
|
||||||
|
assert "import('../../views/widgets/WidgetsDetail.vue')" in route
|
||||||
|
|
||||||
|
list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text()
|
||||||
|
assert 'widgetsApi' in list_view
|
||||||
|
assert "api.get('/widgets'" in list_view
|
||||||
|
# no unresolved template placeholders for our known substitution keys
|
||||||
|
assert '${name}' not in list_view
|
||||||
|
assert '${Name}' not in list_view
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_file_has_plugin_gating_and_requiresauth(tmp_path):
|
||||||
|
"""Every route carries meta.plugin; form routes add requiresAuth."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
|
||||||
|
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||||
|
|
||||||
|
# ADR-009 gating on every route
|
||||||
|
assert route.count("plugin: 'widgets'") == 4
|
||||||
|
# form routes (new + edit) require auth; list + detail do not
|
||||||
|
assert route.count('requiresAuth: true') == 2
|
||||||
|
assert "path: 'widgets/new'" in route
|
||||||
|
assert "path: 'widgets/:id/edit'" in route
|
||||||
|
|
||||||
|
|
||||||
|
def test_views_use_global_classes_not_custom_input_css(tmp_path):
|
||||||
|
"""Views lean on global .filters / .form-control / .card classes and never
|
||||||
|
redefine input styling in a scoped block."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
|
||||||
|
views = frontend_dir / 'views' / 'widgets'
|
||||||
|
list_view = (views / 'WidgetsList.vue').read_text()
|
||||||
|
form_view = (views / 'WidgetsForm.vue').read_text()
|
||||||
|
|
||||||
|
assert 'class="filters"' in list_view
|
||||||
|
assert 'class="form-control"' in list_view
|
||||||
|
assert 'class="card"' in list_view
|
||||||
|
assert 'class="form-control"' in form_view
|
||||||
|
|
||||||
|
# per-page input styling is forbidden: no scoped rule targets .form-control
|
||||||
|
# on its own (a descendant layout helper like `.filters .form-control` is ok)
|
||||||
|
import re
|
||||||
|
for view in (list_view, form_view):
|
||||||
|
assert not re.search(r'(?m)^\s*\.form-control\s*\{', view)
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_frontend_dir_resolves_relative_to_plugins(tmp_path):
|
||||||
|
"""With no frontend_dir arg, views land in <plugins_dir>/../frontend/src."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
assert (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').exists()
|
||||||
|
assert (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_when_no_frontend_dir(tmp_path):
|
||||||
|
"""No frontend tree: views and route are skipped, backend still generated.
|
||||||
|
|
||||||
|
The api snippet is still emitted because it is a plugin-directory artifact.
|
||||||
|
"""
|
||||||
|
plugins_dir = tmp_path / 'plugins'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
missing_frontend = tmp_path / 'nowhere' / 'src'
|
||||||
|
|
||||||
|
target = scaffold_plugin(
|
||||||
|
name='widgets',
|
||||||
|
description='Test',
|
||||||
|
plugins_dir=plugins_dir,
|
||||||
|
frontend_dir=missing_frontend,
|
||||||
|
)
|
||||||
|
|
||||||
|
# backend skeleton is intact
|
||||||
|
assert (target / 'manifest.json').exists()
|
||||||
|
assert (target / 'plugin.py').exists()
|
||||||
|
# frontend views and route were skipped
|
||||||
|
assert not missing_frontend.exists()
|
||||||
|
# snippet still lands in the plugin dir
|
||||||
|
assert (target / 'frontend-api-snippet.js').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_can_be_disabled(tmp_path):
|
||||||
|
"""frontend=False generates neither views, route, nor snippet."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
|
||||||
|
target = scaffold_plugin(
|
||||||
|
name='widgets',
|
||||||
|
description='Test',
|
||||||
|
plugins_dir=plugins_dir,
|
||||||
|
frontend_dir=frontend_dir,
|
||||||
|
frontend=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (frontend_dir / 'views' / 'widgets').exists()
|
||||||
|
assert not (frontend_dir / 'router' / 'routes' / 'widgets.js').exists()
|
||||||
|
assert not (target / 'frontend-api-snippet.js').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_clobber_without_overwrite(tmp_path):
|
||||||
|
"""An existing frontend view is left untouched unless overwrite is set."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
|
||||||
|
# author already hand-edited a view before re-scaffolding
|
||||||
|
views_dir = frontend_dir / 'views' / 'widgets'
|
||||||
|
views_dir.mkdir(parents=True)
|
||||||
|
existing = views_dir / 'WidgetsList.vue'
|
||||||
|
existing.write_text('SENTINEL do not clobber')
|
||||||
|
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
|
||||||
|
# untouched without overwrite
|
||||||
|
assert existing.read_text() == 'SENTINEL do not clobber'
|
||||||
|
|
||||||
|
|
||||||
|
def test_overwrite_replaces_existing_frontend(tmp_path):
|
||||||
|
"""overwrite=True regenerates an existing frontend view."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
|
||||||
|
views_dir = frontend_dir / 'views' / 'widgets'
|
||||||
|
views_dir.mkdir(parents=True)
|
||||||
|
existing = views_dir / 'WidgetsList.vue'
|
||||||
|
existing.write_text('SENTINEL do not clobber')
|
||||||
|
|
||||||
|
# plugin dir must also exist to reach overwrite path
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
scaffold_plugin(
|
||||||
|
'widgets', 'Test', plugins_dir,
|
||||||
|
frontend_dir=frontend_dir, overwrite=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert existing.read_text() != 'SENTINEL do not clobber'
|
||||||
|
assert 'widgetsApi' in existing.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_references_only_existing_views(tmp_path):
|
||||||
|
"""Every view lazy-imported by the route file exists on disk.
|
||||||
|
|
||||||
|
Guards the write-views-before-route ordering: a route pointing at a missing
|
||||||
|
view crashes the Vite dev server.
|
||||||
|
"""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
|
||||||
|
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||||
|
import re
|
||||||
|
imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route)
|
||||||
|
assert imported
|
||||||
|
for rel in imported:
|
||||||
|
# rel is relative to router/routes/; resolve against that dir
|
||||||
|
resolved = (frontend_dir / 'router' / 'routes' / rel).resolve()
|
||||||
|
assert resolved.exists(), f'route imports missing view: {rel}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_frontend_is_ascii(tmp_path):
|
||||||
|
"""Generated frontend files contain plain ASCII only."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
target = scaffold_plugin(
|
||||||
|
'widgets', 'Test', plugins_dir, frontend_dir=frontend_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = [
|
||||||
|
frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue',
|
||||||
|
frontend_dir / 'views' / 'widgets' / 'WidgetsDetail.vue',
|
||||||
|
frontend_dir / 'views' / 'widgets' / 'WidgetsForm.vue',
|
||||||
|
frontend_dir / 'router' / 'routes' / 'widgets.js',
|
||||||
|
target / 'frontend-api-snippet.js',
|
||||||
|
]
|
||||||
|
for path in generated:
|
||||||
|
text = path.read_text()
|
||||||
|
text.encode('ascii') # raises if any non-ASCII slipped in
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_dir_not_created_when_absent(tmp_path):
|
||||||
|
"""Scaffolder does not fabricate a frontend tree that was not there."""
|
||||||
|
plugins_dir = tmp_path / 'plugins'
|
||||||
|
plugins_dir.mkdir()
|
||||||
|
|
||||||
|
# no frontend dir exists; default resolves to <tmp>/frontend/src (absent)
|
||||||
|
scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||||
|
|
||||||
|
assert not (tmp_path / 'frontend').exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scaffold_still_raises_on_existing_plugin(tmp_path):
|
||||||
|
"""Frontend generation does not weaken the plugin-dir overwrite guard."""
|
||||||
|
plugins_dir, frontend_dir = _make_dirs(tmp_path)
|
||||||
|
scaffold_plugin('widgets', 'first', plugins_dir, frontend_dir=frontend_dir)
|
||||||
|
|
||||||
|
with pytest.raises(ScaffoldError, match='already exists'):
|
||||||
|
scaffold_plugin(
|
||||||
|
'widgets', 'second', plugins_dir, frontend_dir=frontend_dir,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user