Merge remote-tracking branch 'origin/main' into wilm
This commit is contained in:
@@ -251,13 +251,15 @@ def register_blueprints(app: Flask):
|
||||
def register_cli_commands(app: Flask):
|
||||
"""Register Flask CLI commands."""
|
||||
from .plugins.cli import plugin_cli
|
||||
from .cli import db_cli, seed_cli, relationships_cli, csv_cli
|
||||
from .cli import (db_cli, seed_cli, relationships_cli, csv_cli,
|
||||
catalog_cli)
|
||||
|
||||
app.cli.add_command(plugin_cli)
|
||||
app.cli.add_command(db_cli)
|
||||
app.cli.add_command(seed_cli)
|
||||
app.cli.add_command(relationships_cli)
|
||||
app.cli.add_command(csv_cli)
|
||||
app.cli.add_command(catalog_cli)
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask):
|
||||
|
||||
@@ -1535,3 +1535,303 @@ def audit_relationships():
|
||||
'knowing which side is authoritative.')
|
||||
else:
|
||||
click.echo(click.style('No relationship faults found.', fg='green'))
|
||||
|
||||
|
||||
# --- catalog hygiene --------------------------------------------------------
|
||||
|
||||
@click.group('catalog')
|
||||
def catalog_cli():
|
||||
"""Clean up the shared vendor/model/OS catalog."""
|
||||
pass
|
||||
|
||||
|
||||
# Catalog tables that carry a human-typed name, with the column that names them.
|
||||
# Each is free vocabulary somebody can add to, which is how twins appear.
|
||||
_CATALOG_TABLES = (
|
||||
('vendors', 'vendor'),
|
||||
('models', 'modelnumber'),
|
||||
('operatingsystems', 'osname'),
|
||||
)
|
||||
|
||||
|
||||
def _catalog_norm(value):
|
||||
"""Collapse a name to what it would be with no spacing or punctuation.
|
||||
|
||||
Only whitespace and punctuation are removed, and case is folded. That is
|
||||
deliberately narrow: 'Dell Inc.' and 'DellInc.' are the SAME name typed two
|
||||
ways, while 'Dell' and 'Dell Inc.' are a naming CHOICE and must never be
|
||||
merged automatically. Use `catalog merge` for that.
|
||||
"""
|
||||
import re
|
||||
return re.sub(r'[\s\-_.,/]+', '', str(value or '').strip().lower())
|
||||
|
||||
|
||||
def _inbound_foreign_keys(inspector, table):
|
||||
"""Every (table, column) in the schema that points at `table`'s key.
|
||||
|
||||
Discovered from the live schema rather than listed here, because a plugin
|
||||
can add one: `machines.controllervendorid` and `printerdrivers.
|
||||
modelnumberid` both reference the catalog and neither is obvious.
|
||||
"""
|
||||
refs = []
|
||||
for candidate in inspector.get_table_names():
|
||||
for fk in inspector.get_foreign_keys(candidate):
|
||||
if fk['referred_table'] == table:
|
||||
refs.append((candidate,
|
||||
fk['constrained_columns'][0],
|
||||
fk['referred_columns'][0]))
|
||||
return refs
|
||||
|
||||
|
||||
def _reference_counts(connection, refs, keyvalue):
|
||||
"""How many rows across the schema point at one catalog row."""
|
||||
import sqlalchemy as sa
|
||||
total = 0
|
||||
detail = []
|
||||
for tablename, column, _ in refs:
|
||||
count = connection.execute(
|
||||
sa.text(f'SELECT COUNT(*) FROM {tablename} WHERE {column} = :k'),
|
||||
{'k': keyvalue}).scalar()
|
||||
if count:
|
||||
detail.append(f'{tablename}.{column}={count}')
|
||||
total += count
|
||||
return total, detail
|
||||
|
||||
|
||||
def _merge_catalog_rows(connection, table, keycolumn, refs, loser, winner):
|
||||
"""Repoint every reference off `loser` onto `winner`, then delete it."""
|
||||
import sqlalchemy as sa
|
||||
moved = 0
|
||||
for tablename, column, _ in refs:
|
||||
result = connection.execute(
|
||||
sa.text(f'UPDATE {tablename} SET {column} = :w WHERE {column} = :l'),
|
||||
{'w': winner, 'l': loser})
|
||||
moved += result.rowcount or 0
|
||||
connection.execute(
|
||||
sa.text(f'DELETE FROM {table} WHERE {keycolumn} = :l'), {'l': loser})
|
||||
return moved
|
||||
|
||||
|
||||
@catalog_cli.command('dedupe')
|
||||
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||
help='Write the changes. Without this the command only reports.')
|
||||
@with_appcontext
|
||||
def catalog_dedupe(apply_changes):
|
||||
"""Merge catalog rows that are the same name typed two ways.
|
||||
|
||||
Legacy data arrived carrying space-stripped twins - 'DellInc.' beside
|
||||
'Dell Inc.', 'Windows10' beside 'Windows 10' - which then rode into the
|
||||
shipped seed catalog and out to every site that ran `seed catalog`. They
|
||||
are invisible until somebody opens a vendor picker and finds the same
|
||||
company twice.
|
||||
|
||||
ONLY whitespace-and-punctuation twins are touched. 'Dell' and 'Dell Inc.'
|
||||
are a naming choice, not a typo, and are left alone; use `catalog merge`
|
||||
to make that call explicitly.
|
||||
|
||||
The surviving row is the one with the most references, tie-broken toward
|
||||
the spaced spelling, because that is the one a person typed deliberately.
|
||||
Every foreign key pointing at the loser is repointed first, so nothing is
|
||||
orphaned; references are counted across the LIVE schema, which picks up
|
||||
plugin tables such as machines.controllervendorid.
|
||||
|
||||
Reports only unless --apply is passed. Idempotent.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from collections import defaultdict
|
||||
from shopdb.extensions import db
|
||||
|
||||
engine = db.session.get_bind()
|
||||
inspector = sa.inspect(engine)
|
||||
existing = set(inspector.get_table_names())
|
||||
|
||||
planned = []
|
||||
with engine.begin() as connection:
|
||||
for table, namecolumn in _CATALOG_TABLES:
|
||||
if table not in existing:
|
||||
continue
|
||||
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
|
||||
refs = [r for r in _inbound_foreign_keys(inspector, table)
|
||||
if r[0] in existing]
|
||||
|
||||
rows = connection.execute(sa.text(
|
||||
f'SELECT {keycolumn}, {namecolumn} FROM {table}')).fetchall()
|
||||
groups = defaultdict(list)
|
||||
for key, name in rows:
|
||||
if name:
|
||||
groups[_catalog_norm(name)].append((key, name))
|
||||
|
||||
for _, members in sorted(groups.items()):
|
||||
if len(members) < 2:
|
||||
continue
|
||||
scored = []
|
||||
for key, name in members:
|
||||
count, detail = _reference_counts(connection, refs, key)
|
||||
# most-referenced wins; then the spelling with whitespace,
|
||||
# which is the one somebody typed on purpose; then longest.
|
||||
scored.append((count, ' ' in str(name), len(str(name)),
|
||||
key, name, detail))
|
||||
scored.sort(reverse=True)
|
||||
winner = scored[0]
|
||||
for loser in scored[1:]:
|
||||
planned.append({
|
||||
'table': table, 'keycolumn': keycolumn, 'refs': refs,
|
||||
'winner_key': winner[3], 'winner_name': winner[4],
|
||||
'loser_key': loser[3], 'loser_name': loser[4],
|
||||
'loser_refs': loser[0], 'loser_detail': loser[5],
|
||||
})
|
||||
|
||||
if not planned:
|
||||
click.echo(click.style('No catalog twins found.', fg='green'))
|
||||
return
|
||||
|
||||
click.echo(f'{len(planned)} duplicate row(s):')
|
||||
click.echo('')
|
||||
for item in planned:
|
||||
where = (', '.join(item['loser_detail']) if item['loser_detail']
|
||||
else 'nothing references it')
|
||||
click.echo(f" {item['table']}: {item['loser_name']!r} "
|
||||
f"-> {item['winner_name']!r}")
|
||||
click.echo(f" {item['loser_refs']} reference(s): {where}")
|
||||
|
||||
if not apply_changes:
|
||||
click.echo('')
|
||||
click.echo(click.style(
|
||||
'Dry run. Nothing was changed. Re-run with --apply to write.',
|
||||
fg='yellow'))
|
||||
return
|
||||
|
||||
moved = 0
|
||||
for item in planned:
|
||||
moved += _merge_catalog_rows(
|
||||
connection, item['table'], item['keycolumn'], item['refs'],
|
||||
item['loser_key'], item['winner_key'])
|
||||
|
||||
click.echo('')
|
||||
click.echo(click.style(
|
||||
f'Merged {len(planned)} row(s), repointed {moved} reference(s).',
|
||||
fg='green'))
|
||||
|
||||
|
||||
@catalog_cli.command('merge')
|
||||
@click.argument('table', type=click.Choice([t for t, _ in _CATALOG_TABLES]))
|
||||
@click.argument('source')
|
||||
@click.argument('target')
|
||||
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||
help='Write the changes. Without this the command only reports.')
|
||||
@with_appcontext
|
||||
def catalog_merge(table, source, target, apply_changes):
|
||||
"""Merge one catalog row into another by name: SOURCE is absorbed by TARGET.
|
||||
|
||||
For the judgment calls `catalog dedupe` deliberately refuses to make -
|
||||
'Dell' and 'Dell Inc.' are different names, and only a person knows the
|
||||
site means one company.
|
||||
|
||||
flask catalog merge vendors "Dell" "Dell Inc." --apply
|
||||
|
||||
Every reference to SOURCE is repointed to TARGET, then SOURCE is deleted.
|
||||
Reports only unless --apply is passed.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from shopdb.extensions import db
|
||||
|
||||
namecolumn = dict(_CATALOG_TABLES)[table]
|
||||
engine = db.session.get_bind()
|
||||
inspector = sa.inspect(engine)
|
||||
existing = set(inspector.get_table_names())
|
||||
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
|
||||
refs = [r for r in _inbound_foreign_keys(inspector, table) if r[0] in existing]
|
||||
|
||||
with engine.begin() as connection:
|
||||
def lookup(value):
|
||||
return connection.execute(
|
||||
sa.text(f'SELECT {keycolumn} FROM {table} '
|
||||
f'WHERE {namecolumn} = :v'), {'v': value}).fetchone()
|
||||
|
||||
sourcerow, targetrow = lookup(source), lookup(target)
|
||||
if not sourcerow:
|
||||
click.echo(click.style(f'No {table} row named {source!r}.', fg='yellow'))
|
||||
return
|
||||
if not targetrow:
|
||||
click.echo(click.style(f'No {table} row named {target!r}. '
|
||||
f'Refusing to invent it.', fg='red'))
|
||||
raise SystemExit(1)
|
||||
if sourcerow[0] == targetrow[0]:
|
||||
click.echo(click.style('Source and target are the same row.', fg='yellow'))
|
||||
return
|
||||
|
||||
count, detail = _reference_counts(connection, refs, sourcerow[0])
|
||||
where = ', '.join(detail) if detail else 'nothing references it'
|
||||
click.echo(f'{table}: {source!r} -> {target!r}')
|
||||
click.echo(f' {count} reference(s): {where}')
|
||||
|
||||
if not apply_changes:
|
||||
click.echo(click.style(
|
||||
'Dry run. Nothing was changed. Re-run with --apply to write.',
|
||||
fg='yellow'))
|
||||
return
|
||||
|
||||
moved = _merge_catalog_rows(connection, table, keycolumn, refs,
|
||||
sourcerow[0], targetrow[0])
|
||||
|
||||
click.echo(click.style(
|
||||
f'Merged {source!r} into {target!r}, repointed {moved} reference(s).',
|
||||
fg='green'))
|
||||
|
||||
|
||||
@catalog_cli.command('rename')
|
||||
@click.argument('table', type=click.Choice([t for t, _ in _CATALOG_TABLES]))
|
||||
@click.argument('source')
|
||||
@click.argument('target')
|
||||
@click.option('--apply', 'apply_changes', is_flag=True,
|
||||
help='Write the change. Without this the command only reports.')
|
||||
@with_appcontext
|
||||
def catalog_rename(table, source, target, apply_changes):
|
||||
"""Rename one catalog row in place, keeping every reference to it.
|
||||
|
||||
A space-stripped name with NO twin cannot be deduped - there is nothing to
|
||||
merge it into - so 'OptiPlex5000' would survive a `catalog dedupe` that
|
||||
correctly removed 'OptiPlex7080'. This fixes that case.
|
||||
|
||||
flask catalog rename models "OptiPlex5000" "OptiPlex 5000" --apply
|
||||
|
||||
Refuses when the target name already exists, because that is a merge and
|
||||
has to move references rather than rename a row: use `catalog merge`.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from shopdb.extensions import db
|
||||
|
||||
namecolumn = dict(_CATALOG_TABLES)[table]
|
||||
engine = db.session.get_bind()
|
||||
inspector = sa.inspect(engine)
|
||||
keycolumn = inspector.get_pk_constraint(table)['constrained_columns'][0]
|
||||
|
||||
with engine.begin() as connection:
|
||||
def lookup(value):
|
||||
return connection.execute(
|
||||
sa.text(f'SELECT {keycolumn} FROM {table} '
|
||||
f'WHERE {namecolumn} = :v'), {'v': value}).fetchone()
|
||||
|
||||
sourcerow = lookup(source)
|
||||
if not sourcerow:
|
||||
click.echo(click.style(f'No {table} row named {source!r}.', fg='yellow'))
|
||||
return
|
||||
if lookup(target):
|
||||
click.echo(click.style(
|
||||
f'{table} already has a row named {target!r}. That is a merge, '
|
||||
f'not a rename - use `catalog merge`.', fg='red'))
|
||||
raise SystemExit(1)
|
||||
|
||||
click.echo(f'{table}: rename {source!r} -> {target!r} '
|
||||
f'(id {sourcerow[0]}, references unaffected)')
|
||||
if not apply_changes:
|
||||
click.echo(click.style(
|
||||
'Dry run. Nothing was changed. Re-run with --apply to write.',
|
||||
fg='yellow'))
|
||||
return
|
||||
|
||||
connection.execute(
|
||||
sa.text(f'UPDATE {table} SET {namecolumn} = :t WHERE {keycolumn} = :k'),
|
||||
{'t': target, 'k': sourcerow[0]})
|
||||
|
||||
click.echo(click.style(f'Renamed {source!r} to {target!r}.', fg='green'))
|
||||
|
||||
@@ -658,7 +658,10 @@ def _search_subnets(query):
|
||||
'id': subnet.subnetid,
|
||||
'title': f'{subnet.name} ({subnet.cidr})',
|
||||
'subtitle': subnet.description or subnet.subnettype,
|
||||
'url': f'/network',
|
||||
# the matched subnet, not the hub: searching an IP
|
||||
# already identified WHICH subnet, so landing on the
|
||||
# hub makes the reader find it again by hand
|
||||
'url': f'/networks/{subnet.subnetid}',
|
||||
'relevance': 70
|
||||
})
|
||||
except ValueError:
|
||||
@@ -779,7 +782,9 @@ def _search_notifications(query, search_term):
|
||||
'id': notif.notificationid,
|
||||
'title': notif.title,
|
||||
'subtitle': notif.notificationtype.typename if notif.notificationtype else None,
|
||||
'url': f'/notifications',
|
||||
# the notification itself, not the list it sits in: the
|
||||
# `notification-detail` route renders one by id
|
||||
'url': f'/notifications/{notif.notificationid}',
|
||||
'relevance': min(int(base_relevance), 100),
|
||||
'ticketnumber': notif.ticketnumber,
|
||||
'iscurrent': notif.is_current
|
||||
|
||||
@@ -777,6 +777,13 @@ def build_default_settings():
|
||||
# QR codes and any absolute link the app emits - and facility name shown on
|
||||
# the shopfloor dashboard. Blank site_base_url falls back to the browsing
|
||||
# origin so nothing breaks before a site configures it.
|
||||
#
|
||||
# THE SUBPATH BELONGS IN THE VALUE. Consumers append the in-app route and
|
||||
# add nothing themselves (resolveCodeText in print/assetLabel.js, buildQrUrl
|
||||
# in utils/qrTarget.js), so a site served at /shopdb must carry that here or
|
||||
# every generated link 404s. This said "scheme + host" for a while, which is
|
||||
# true only of a site mounted at the root - and the email copy of the same
|
||||
# setting has always said https://host/ops.
|
||||
sitedefaults = [
|
||||
{
|
||||
'key': 'setup_complete',
|
||||
@@ -811,7 +818,7 @@ def build_default_settings():
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'site',
|
||||
'description': 'Public base URL of this site (scheme + host), e.g. https://shopdb.example.net. Used for QR codes and absolute links. Blank = use the browsing origin.'
|
||||
'description': 'Public base URL of this site INCLUDING any subpath it is served under, e.g. https://shopdb.example.net or https://host/shopdb. Consumers append the in-app route to this and add nothing themselves, so a subpath left off here produces links that 404. Blank = use the browsing origin.'
|
||||
},
|
||||
{
|
||||
'key': 'facility_name',
|
||||
|
||||
@@ -383,9 +383,6 @@
|
||||
{
|
||||
"osname": "Microsoft Windows 11 Enterprise"
|
||||
},
|
||||
{
|
||||
"osname": "MicrosoftWindows10Enterprise"
|
||||
},
|
||||
{
|
||||
"osname": "TBD"
|
||||
},
|
||||
@@ -404,9 +401,6 @@
|
||||
{
|
||||
"osname": "Windows Server 2022",
|
||||
"osversion": "2022"
|
||||
},
|
||||
{
|
||||
"osname": "Windows10"
|
||||
}
|
||||
],
|
||||
"vendors": [
|
||||
@@ -432,15 +426,9 @@
|
||||
{
|
||||
"vendor": "Cisco"
|
||||
},
|
||||
{
|
||||
"vendor": "Dell"
|
||||
},
|
||||
{
|
||||
"vendor": "Dell Inc."
|
||||
},
|
||||
{
|
||||
"vendor": "DellInc."
|
||||
},
|
||||
{
|
||||
"vendor": "DMG Mori"
|
||||
},
|
||||
@@ -620,8 +608,8 @@
|
||||
"modeltype": "Switch"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex5000",
|
||||
"vendor": "Dell"
|
||||
"modelnumber": "OptiPlex 5000",
|
||||
"vendor": "Dell Inc."
|
||||
},
|
||||
{
|
||||
"modelnumber": "Dell Pro 13 Plus PB13250",
|
||||
@@ -775,10 +763,6 @@
|
||||
"vendor": "Dell Inc.",
|
||||
"modeltype": "LocationOnly"
|
||||
},
|
||||
{
|
||||
"modelnumber": "OptiPlex7080",
|
||||
"vendor": "DellInc."
|
||||
},
|
||||
{
|
||||
"modelnumber": "NT-4300",
|
||||
"vendor": "DMG Mori",
|
||||
|
||||
@@ -104,7 +104,12 @@ def create_plugin_tables(plugin_name: str):
|
||||
this is safe on an existing database that has the table from the pre-cutover
|
||||
core baseline as well as on a fresh install (ADR-014 Phase 2).
|
||||
|
||||
Called from each plugin's 0001 baseline.py upgrade().
|
||||
NOT actually called from anywhere: every post-cutover baseline emits
|
||||
explicit Alembic ops instead, because this helper's per-plugin MetaData
|
||||
cannot resolve a foreign key to a CORE table at CreateTable-compile time.
|
||||
Kept because it is still the reference for what idempotent means here, and
|
||||
because create_table_if_missing below is the same rule in a form the
|
||||
baselines can use. See that function for what went wrong without it.
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
@@ -120,6 +125,67 @@ def create_plugin_tables(plugin_name: str):
|
||||
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
|
||||
|
||||
|
||||
def create_table_if_missing(table_name: str, *columns, **kwargs) -> bool:
|
||||
"""op.create_table, skipped when the table is already there.
|
||||
|
||||
WHY EVERY POST-CUTOVER BASELINE NEEDS THIS. A baseline is the sole creator
|
||||
of its plugin's tables, so it was written as a plain op.create_table - which
|
||||
is correct only if the table's existence and the plugin's Alembic stamp can
|
||||
never disagree. They can, and routinely do:
|
||||
|
||||
* `flask db-utils create-all` runs db.create_all(), which builds EVERY
|
||||
table registered on the metadata - plugin tables included - and stamps
|
||||
NOTHING. `flask plugin upgrade-all` then runs the baseline against
|
||||
tables that already exist.
|
||||
* A run that creates the table and then fails before its stamp commits
|
||||
leaves the same split, and re-running is the obvious thing to try.
|
||||
|
||||
Reported as `flask plugin upgrade-all` failing the tools plugin with
|
||||
"table securesendsecrets already exists". Nothing caught it because the
|
||||
migration test drops the post-cutover tables db.create_all() made before
|
||||
running upgrade-all, which arranges the one state where an unguarded create
|
||||
works and never the state a real database is in.
|
||||
|
||||
Returns True when it created the table, so a caller can skip the indexes
|
||||
that belong to it.
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
if inspect(op.get_bind()).has_table(table_name):
|
||||
logger.info('%s already exists, skipping create', table_name)
|
||||
return False
|
||||
op.create_table(table_name, *columns, **kwargs)
|
||||
return True
|
||||
|
||||
|
||||
def create_index_if_missing(index_name: str, table_name: str, columns, **kwargs):
|
||||
"""op.create_index, skipped when an equivalent index already exists.
|
||||
|
||||
Matched on the COLUMN LIST rather than the name, because the two creators
|
||||
do not agree on names. db.create_all() takes them from SQLAlchemy's default
|
||||
convention (`ix_securesendsecrets_token`) while a baseline spells them the
|
||||
way this repo's naming rules require (`ixsecuresendsecretstoken`). Matching
|
||||
on the name would call the index missing and add a second one over the same
|
||||
column - MySQL permits that, with only a warning, so the duplicate would
|
||||
accumulate silently on every affected site.
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
wanted = [str(column) for column in columns]
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
op.create_index(index_name, table_name, columns, **kwargs)
|
||||
return
|
||||
for existing in inspector.get_indexes(table_name):
|
||||
if list(existing.get('column_names') or []) == wanted:
|
||||
logger.info('index over %s(%s) already exists as %s, skipping',
|
||||
table_name, ', '.join(wanted), existing.get('name'))
|
||||
return
|
||||
op.create_index(index_name, table_name, columns, **kwargs)
|
||||
|
||||
|
||||
def drop_plugin_tables(plugin_name: str):
|
||||
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
|
||||
order."""
|
||||
|
||||
@@ -81,18 +81,28 @@ 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.
|
||||
Everything lands INSIDE the plugin, under `plugins/<name>/frontend/`:
|
||||
`routes.js` plus `views/<Name>{List,Detail,Form}.vue`, and the paste-in
|
||||
api-client snippet at the plugin root.
|
||||
|
||||
The plugin owns its frontend because that is what makes it prunable.
|
||||
scripts/stage-frontend.mjs copies each plugin's `frontend/` into
|
||||
`frontend/src/.plugins-staged/<name>/` and aggregates the route modules
|
||||
into `routes.gen.js`; a plugin left out of SITE_PLUGINS is simply not
|
||||
copied, so its frontend leaves the build with it (ADR-013). A route file
|
||||
written into the CORE tree instead is still picked up - the router globs
|
||||
`./routes/*.js` during the Phase 4 dual-location transition - but it is
|
||||
invisible to that pruning, and deleting the plugin strands its views.
|
||||
|
||||
This is also why nothing here depends on the core frontend tree existing.
|
||||
A plugin developed in its own repo gets exactly the same layout, and its
|
||||
frontend starts working the moment the plugin directory is dropped into
|
||||
`plugins/`.
|
||||
|
||||
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,
|
||||
@@ -104,7 +114,7 @@ def _scaffold_frontend(
|
||||
|
||||
plugin_name = substitutions['Name']
|
||||
|
||||
# snippet lands in the plugin dir; author pastes it into api/index.js
|
||||
# snippet lands at the plugin root; author pastes it into api/index.js
|
||||
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
|
||||
if snippet_template.exists():
|
||||
_render_template(
|
||||
@@ -114,11 +124,8 @@ def _scaffold_frontend(
|
||||
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
|
||||
frontend_dir = plugin_target / 'frontend'
|
||||
views_dir = frontend_dir / 'views'
|
||||
|
||||
# views first: route file lazy-imports them, missing views 500 vite
|
||||
view_templates = {
|
||||
@@ -141,7 +148,7 @@ def _scaffold_frontend(
|
||||
if route_template.exists():
|
||||
_render_template(
|
||||
route_template,
|
||||
frontend_dir / 'router' / 'routes' / f'{name}.js',
|
||||
frontend_dir / 'routes.js',
|
||||
substitutions,
|
||||
overwrite,
|
||||
)
|
||||
@@ -154,7 +161,6 @@ def scaffold_plugin(
|
||||
template_root: Optional[Path] = None,
|
||||
overwrite: bool = False,
|
||||
frontend: bool = True,
|
||||
frontend_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""Generate a new plugin from templates.
|
||||
|
||||
@@ -166,11 +172,10 @@ def scaffold_plugin(
|
||||
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.
|
||||
detail, form views, a route file, and a paste-in api-client
|
||||
snippet) into `plugins/<name>/frontend/`. There is no separate
|
||||
output directory: the plugin owns its frontend so that
|
||||
stage-frontend.mjs can stage it and a lean build can prune it.
|
||||
|
||||
Returns:
|
||||
Path to the generated plugin directory.
|
||||
@@ -205,7 +210,9 @@ def scaffold_plugin(
|
||||
for template_path in template_root.rglob('*.tmpl'):
|
||||
rel = template_path.relative_to(template_root)
|
||||
|
||||
# frontend templates render into the frontend tree, not the plugin dir
|
||||
# frontend templates are handled by _scaffold_frontend: they are
|
||||
# renamed on the way out (List.vue.tmpl -> <Name>List.vue) and are
|
||||
# skipped entirely when frontend=False
|
||||
if rel.parts and rel.parts[0] == 'frontend':
|
||||
continue
|
||||
|
||||
@@ -222,13 +229,10 @@ def scaffold_plugin(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,35 +1,49 @@
|
||||
/**
|
||||
* ${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.
|
||||
* This file lives in the plugin, not in the core frontend tree.
|
||||
* scripts/stage-frontend.mjs copies plugins/${name}/frontend/ into
|
||||
* frontend/src/.plugins-staged/${name}/ and aggregates this default export
|
||||
* into routes.gen.js, which the router spreads in - so no registration edit
|
||||
* is needed, and pruning the plugin for a lean build removes its frontend
|
||||
* with it (ADR-013).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* View imports are relative (./views/...) because they travel with this file.
|
||||
* Anything from the CORE tree uses the @ alias (@/api, @/components/...),
|
||||
* which resolves the same before and after staging.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '${name}',
|
||||
name: '${name}',
|
||||
component: () => import('../../views/${name}/${Name}List.vue'),
|
||||
component: () => import('./views/${Name}List.vue'),
|
||||
meta: { plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/new',
|
||||
name: '${name}-new',
|
||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
||||
component: () => import('./views/${Name}Form.vue'),
|
||||
meta: { requiresAuth: true, plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/:id',
|
||||
name: '${name}-detail',
|
||||
component: () => import('../../views/${name}/${Name}Detail.vue'),
|
||||
component: () => import('./views/${Name}Detail.vue'),
|
||||
meta: { plugin: '${name}' }
|
||||
},
|
||||
{
|
||||
path: '${name}/:id/edit',
|
||||
name: '${name}-edit',
|
||||
component: () => import('../../views/${name}/${Name}Form.vue'),
|
||||
component: () => import('./views/${Name}Form.vue'),
|
||||
meta: { requiresAuth: true, plugin: '${name}' }
|
||||
}
|
||||
]
|
||||
|
||||
// Full-screen routes that render OUTSIDE AppLayout (kiosk, print pages).
|
||||
// Declared even when empty so routes.gen.js can read `.toplevel` off the
|
||||
// module without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -78,12 +78,12 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
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'
|
||||
// import { ${name}Api } from '@/api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
|
||||
@@ -77,11 +77,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import api from '../../api'
|
||||
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'
|
||||
// import { ${name}Api } from '@/api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
|
||||
@@ -66,12 +66,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.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'
|
||||
// import { ${name}Api } from '@/api'
|
||||
const ${name}Api = {
|
||||
list(params = {}) { return api.get('/${name}', { params }) },
|
||||
get(itemId) { return api.get(`/${name}/${itemId}`) },
|
||||
|
||||
Reference in New Issue
Block a user