Merge remote-tracking branch 'origin/main' into wilm
This commit is contained in:
134
CHANGELOG.md
134
CHANGELOG.md
@@ -12,6 +12,45 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Added
|
||||
|
||||
- **`flask catalog dedupe`, `merge` and `rename`: catalog hygiene.** The legacy
|
||||
data carried space-stripped twins - `DellInc.` beside `Dell Inc.`,
|
||||
`Windows10` beside `Windows 10` - which rode into the shipped seed catalog
|
||||
and out to every site that ran `seed catalog`. `dedupe` merges only
|
||||
whitespace-and-punctuation twins, because those are one name typed two ways;
|
||||
`Dell` and `Dell Inc.` normalise apart and are deliberately left alone, since
|
||||
that is a naming choice only a person can make. The surviving row is the most
|
||||
referenced, tie-broken toward the spaced spelling. `merge` makes that
|
||||
judgment call explicitly and refuses to invent a target that does not exist.
|
||||
`rename` fixes a stripped name with no twin, which neither of the others can
|
||||
reach. All three report and change nothing unless `--apply` is passed, and
|
||||
`dedupe` is idempotent. References are discovered from the live schema, not
|
||||
listed in code: there are 15 foreign keys into these three tables, and
|
||||
`machines.controllervendorid` is not one anybody would enumerate by hand.
|
||||
|
||||
- **Secure Send: hand somebody a password without leaving it in a chat
|
||||
window.** A link in Tech Tools that carries one credential, dies when it is
|
||||
opened, and expires on its own if nobody opens it. The secret is encrypted in
|
||||
the sender's browser and the key rides in the URL fragment, which browsers
|
||||
never transmit, so the server stores ciphertext it cannot read and a
|
||||
compromised database yields nothing. Nobody can recover a lost secret either,
|
||||
including an administrator, which is the same property seen from the other
|
||||
side. Redeeming is a POST because chat and mail clients prefetch links to
|
||||
build previews, and a one-view secret consumed by a preview window is a secret
|
||||
the recipient never sees; a GET reports availability only. Expiry defaults to
|
||||
24 hours (maximum a week) and views to one, whichever comes first, and a spent
|
||||
row is deleted. Creating needs a login and `tools.secrets`; reading needs
|
||||
neither, because the recipient usually has no account and the 128-bit token is
|
||||
the authorisation. The 4096-character ciphertext limit is deliberately the
|
||||
whole server-side enforcement: a zero-knowledge server cannot tell a password
|
||||
from a document, so size is the only control it has, and raising it turns this
|
||||
into the file transfer it was scoped not to be. Documented in
|
||||
`docs/SECURE-SEND.md`.
|
||||
- **A site can let people without an account create a Secure Send link.**
|
||||
New `securesend_allow_guests` setting (Settings > Tools > Secure Send), off by
|
||||
default because a guest secret has no creator and the audit trail can then say
|
||||
when and from where but never who. Guest creation is rate limited per address
|
||||
and the setting fails closed if it cannot be read, so an error can never be
|
||||
what opens an unauthenticated write endpoint.
|
||||
- **The code generator can print a back side.** A badge card is two-sided and
|
||||
the generator only ever emitted one page per card. The Back side section
|
||||
turns on a second page per card carrying either the same code again, a fixed
|
||||
@@ -51,6 +90,48 @@ ADR-007 and ADR-002.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The shipped seed catalog contained duplicate vendors, models and operating
|
||||
systems.** Four space-stripped twins, each with a correctly spaced original
|
||||
already present, so a new site inherited the same company listed twice.
|
||||
`OptiPlex5000` had no twin and was renamed to `OptiPlex 5000` rather than
|
||||
dropped. Vendors 53 to 51, models 128 to 127, operating systems 14 to 12.
|
||||
Not a code fault: the collector strips only leading and trailing whitespace
|
||||
and treats the OS list as lookup-only, so it neither created these nor is
|
||||
creating more. They came in with the classic-ASP import.
|
||||
|
||||
- **A maintenance script could not find the database the site actually uses.**
|
||||
`python scripts/export_catalog.py` on a deployed site died with 1045 "Access
|
||||
denied for user 'root'@'localhost'", naming a user that site does not have.
|
||||
Nothing loads `.env` for a plain script run: `wsgi.py` does it for the served
|
||||
app and the `flask` CLI does it for itself, so both reach the real database,
|
||||
while a bare `python scripts/...` gets neither and falls back to a hardcoded
|
||||
`root@localhost` URL. The read-only failure is the harmless version - six of
|
||||
the seven scripts write, and on a box where those fallback credentials happen
|
||||
to resolve they would have silently rewritten the wrong database. All seven
|
||||
now load `.env` from the repo root, resolved from the script's own location
|
||||
rather than the working directory.
|
||||
|
||||
- **A search result opened the list it sits in, not the record it found.** A
|
||||
NOTICE linked to `/notifications`, so clicking the row search had already
|
||||
identified sent the reader to the list page to find it again by hand. The id
|
||||
was in the payload the whole time and only the URL ignored it. The subnet
|
||||
searcher had the same defect: an IP search resolves which subnet an address
|
||||
falls inside, then linked to the `/network` hub rather than that subnet.
|
||||
Both now link to the record. Every other result type already carried its id.
|
||||
|
||||
- **`flask plugin new` scaffolded a frontend that no lean build could prune.**
|
||||
It wrote views to `frontend/src/views/<name>/` and a route file to
|
||||
`frontend/src/router/routes/<name>.js`, while all 16 bundled plugins keep
|
||||
their frontend in `plugins/<name>/frontend/`, staged by
|
||||
`stage-frontend.mjs`. Nothing looked broken, which is why it lasted: the
|
||||
router still globs `./routes/*.js` during the Phase 4 dual-location
|
||||
transition, so the pages loaded. What a new plugin author silently lost was
|
||||
ADR-013 lean builds, since pruning works off the staged tree, plus stranded
|
||||
views on deleting the plugin. The templates for the right layout already
|
||||
existed; only the output paths were wrong. Their core imports moved to the
|
||||
`@` alias, which resolves the same before and after staging, where the
|
||||
previous `../../api` was correct only in the old location.
|
||||
|
||||
- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
|
||||
it: the value went straight into a CSS length, `height: 0in` is perfectly
|
||||
valid, and an image at `height: 100%` of a zero-height box is zero pixels
|
||||
@@ -81,9 +162,62 @@ ADR-007 and ADR-002.
|
||||
the wrong code. The page index now travels with the page.
|
||||
- The back label's dotted preview outline outspecified the print reset and drew
|
||||
a dashed rule down the edge of every second card.
|
||||
- **The warranty list could not be searched by machine number.** The number was
|
||||
in the payload and rendered in the table as a chip, but the search box looked
|
||||
only at vendor, service level, service tag and asset number. That made the
|
||||
identifier the feature was built around - a shopfloor PC is warranted as a PC
|
||||
but found by the machine it drives - the one thing the box could not find.
|
||||
Machine number and machine name are now searched too, and the placeholder
|
||||
names them.
|
||||
- **`flask plugin upgrade-all` failed a plugin whose tables already existed**
|
||||
("table securesendsecrets already exists" on the tools plugin). Every
|
||||
post-cutover baseline emitted a plain `op.create_table`, which is correct only
|
||||
while a table's existence and its plugin's Alembic stamp cannot disagree.
|
||||
They can: anything that builds tables from the models stamps nothing, and a
|
||||
run that creates a table then fails before its stamp commits leaves the same
|
||||
split, where re-running is the obvious response. Creates and indexes across
|
||||
all nine table-creating plugin migrations now skip what is already there.
|
||||
Indexes are matched on their COLUMNS rather than their name, because
|
||||
`db.create_all()` names them by SQLAlchemy's convention and a migration by
|
||||
this repo's, so a name-keyed check would silently add a duplicate index
|
||||
instead of skipping. Note the remaining limit: this makes a baseline
|
||||
idempotent, not a whole chain. A longer chain can still meet an `ALTER` whose
|
||||
column is already present, which no guard on `create_table` addresses.
|
||||
- **Saving a machine that has a controlling PC answered "this relationship
|
||||
already exists" and refused the save.** Reported against a map position
|
||||
change, but the map was incidental: the form re-POSTed the controlling-PC
|
||||
link on every save, and the row was already there. It used "do I know the
|
||||
existing link's id" as a stand-in for "is there an existing link", and read
|
||||
that id from a key the API does not emit, so the two disagreed on any machine
|
||||
that had a PC. The same conflation meant CLEARING a machine's controlling PC
|
||||
silently did nothing, which was the more dangerous half and had not been
|
||||
reported. The loaded PC is now what decides, and re-selecting the same PC is
|
||||
a no-op rather than a delete-and-recreate.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The computers collector no longer accepts `defaultprinter` or `printers`,
|
||||
and can no longer write a printer assignment.** Those keys used to become
|
||||
`defaultprinter`/`connectedto` relationship rows straight from a report, which
|
||||
contradicts the rule the whole printer feature rests on: a collector report is
|
||||
OBSERVED state and never ASSIGNED state, or a bay that installed the wrong
|
||||
printer makes itself right simply by reporting it. It was not a dormant code
|
||||
path - the collector schema advertised both keys and the example client in
|
||||
`docs/COLLECTOR-INTEGRATION.md` set `defaultprinter`, while another page of
|
||||
the same document promised nothing on that path writes an assignment row.
|
||||
**A client still sending them keeps working**: the keys are ignored, not
|
||||
rejected, and the response carries a warning naming them and pointing at
|
||||
`POST /api/collector/printers` for reporting queues and
|
||||
`seed-from-observed` for adopting them. Silently dropping them would have left
|
||||
an integrator believing assignments were still being written.
|
||||
- **Tech Tools no longer asks people to sign in for tools that need no
|
||||
account.** `/tools`, `/tools/codes` and `/tools/secure-send` were all behind
|
||||
`requiresAuth`. The label generator runs entirely in the browser - no API, no
|
||||
data, nothing to protect - so gating it only ever cost a guest the use of it,
|
||||
and gating the Secure Send page contradicted the guest setting that lets
|
||||
somebody with no account create a link. The server still decides who may
|
||||
create a secret: making a page reachable is not the same as allowing the
|
||||
action, and the two were conflated.
|
||||
- **Settings on the left, labels on the right.** The controls were a narrow
|
||||
column with the preview trailing underneath, so the labels being adjusted were
|
||||
off the bottom of the screen. The preview is now a column of its own that
|
||||
|
||||
@@ -12,6 +12,11 @@ time (NoReferencedTableError). Same shape autogenerate produces. Tables inherit
|
||||
the connection's default charset, matching how the core chain creates its own.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -23,7 +28,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'backuprevisions',
|
||||
sa.Column('backuprevisionid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
@@ -42,15 +47,15 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('backuprevisionid'),
|
||||
)
|
||||
op.create_index('ixbackuprevisionsassetid', 'backuprevisions', ['assetid'])
|
||||
op.create_index('ixbackuprevisionsbackupkind', 'backuprevisions',
|
||||
['backupkind'])
|
||||
op.create_index('ixbackuprevisionscontenthash', 'backuprevisions',
|
||||
['contenthash'])
|
||||
create_index_if_missing('ixbackuprevisionsassetid', 'backuprevisions', ['assetid'])
|
||||
create_index_if_missing('ixbackuprevisionsbackupkind', 'backuprevisions',
|
||||
['backupkind'])
|
||||
create_index_if_missing('ixbackuprevisionscontenthash', 'backuprevisions',
|
||||
['contenthash'])
|
||||
# The dedup read path is "latest revision for this asset+kind", so the
|
||||
# composite index is the one that actually gets used on every collector post.
|
||||
op.create_index('ixbackuprevisionsassetkind', 'backuprevisions',
|
||||
['assetid', 'backupkind'])
|
||||
create_index_if_missing('ixbackuprevisionsassetkind', 'backuprevisions',
|
||||
['assetid', 'backupkind'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -16,16 +16,14 @@ from .api import computers_bp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Marker stamped on PC->printer links this collector creates. The stale-link
|
||||
# archive only touches rows carrying this label, so manually-created printer
|
||||
# relationships are never removed by a collector push. Stored in the
|
||||
# assetrelationships.label column (no origin column exists; see BUILD notes).
|
||||
PRINTER_LINK_ORIGIN = 'collector:printers'
|
||||
|
||||
# Marker stamped on PC->measuringtool "controls" links this collector creates
|
||||
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
|
||||
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
|
||||
# by a collector push, so hand-made tool links survive.
|
||||
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Only rows carrying
|
||||
# this label are archived by a collector push, so hand-made tool links survive.
|
||||
# Stored in assetrelationships.label (no origin column exists; see BUILD notes).
|
||||
#
|
||||
# There is deliberately no printer equivalent any more. PRINTER_LINK_ORIGIN went
|
||||
# with _sync_printer_links: printer ASSIGNMENT is never collected, so there are
|
||||
# no collector-owned printer rows left to label or archive.
|
||||
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
|
||||
# Marker stamped on the PC->machine "controls" link built from the reported
|
||||
@@ -363,26 +361,10 @@ class ComputersPlugin(BasePlugin):
|
||||
'type': 'array',
|
||||
'items': {'name': 'string', 'version': 'string'},
|
||||
},
|
||||
# Printer identifiers reported by the GE-Enforce side, which
|
||||
# runs Get-CimInstance Win32_Printer and marks the default with
|
||||
# the Default flag. An identifier is the printer's windows name,
|
||||
# share name, hostname, or port/IP; the collector resolves it
|
||||
# flexibly to a printer asset. Both optional. Presence of either
|
||||
# key drives the PC->printer relationship sync (and stale-link
|
||||
# archive); absence leaves existing printer links untouched.
|
||||
'defaultprinter': {
|
||||
'type': 'string',
|
||||
'description': ('Default printer identifier (Win32_Printer '
|
||||
'with Default=true): windows name, share '
|
||||
'name, hostname, or port IP.'),
|
||||
},
|
||||
'printers': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
'description': ('All installed network printer identifiers '
|
||||
'(Win32_Printer): windows name / share / '
|
||||
'hostname / IP. Unresolved -> warning.'),
|
||||
},
|
||||
# RETIRED: 'defaultprinter' and 'printers' used to live here and
|
||||
# wrote PC->printer assignment rows from a report. That is the
|
||||
# one thing this path must never do - see _RETIRED_PRINTER_KEYS.
|
||||
# A client still sending them is warned, not rejected.
|
||||
'accessprotocols': {
|
||||
'type': 'array',
|
||||
'items': {'type': 'string'},
|
||||
@@ -647,8 +629,9 @@ class ComputersPlugin(BasePlugin):
|
||||
else:
|
||||
machinelinks = []
|
||||
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||
# Printer assignment is NOT collected. See _warn_retired_printer_keys.
|
||||
self._warn_retired_printer_keys(payload, warnings)
|
||||
printerlinks = []
|
||||
|
||||
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
|
||||
# get an attached MeasuringTool asset auto-created and linked.
|
||||
@@ -742,134 +725,38 @@ class ComputersPlugin(BasePlugin):
|
||||
AccessProtocol.protocolid.in_(wanted)).all()] if wanted else []
|
||||
return sorted(names)
|
||||
|
||||
def _sync_printer_links(self, pcasset, payload, warnings):
|
||||
"""Idempotently sync PC->printer relationships from collector printer data.
|
||||
def _warn_retired_printer_keys(self, payload, warnings):
|
||||
"""Refuse to turn a report into an assignment, out loud.
|
||||
|
||||
Default printer -> 'defaultprinter' (directional). Other reported
|
||||
printers -> 'connectedto' (symmetric). Resolves each identifier to a
|
||||
printer asset by windows name / share / hostname / asset number / name
|
||||
or a communications IP. Unresolved identifiers add a warning and never
|
||||
fail the push.
|
||||
RETIRED 2026-08-24. This used to be _sync_printer_links, which wrote
|
||||
'defaultprinter' and 'connectedto' rows straight from a collector
|
||||
payload. That contradicted the rule the whole printer feature rests on:
|
||||
ASSIGNED and OBSERVED never mix. A bay that installed the wrong printer
|
||||
would have made itself right simply by reporting it, and enforcement
|
||||
would stop meaning anything.
|
||||
|
||||
Stale-link archive: on each push, collector-tagged links (label ==
|
||||
PRINTER_LINK_ORIGIN) whose (target, type) pair is not in the reported
|
||||
desired set are set inactive. Only tagged rows are touched, so manual
|
||||
links survive. Runs only when the payload carried a printer key
|
||||
('defaultprinter' or 'printers'); a PC that reports without printer data
|
||||
keeps its existing links. Returns the desired-link list (created + kept).
|
||||
Worse than dormant, it was ADVERTISED: the collector schema listed both
|
||||
keys and COLLECTOR-INTEGRATION.md's example client set defaultprinter,
|
||||
while the same document promised on another page that nothing on this
|
||||
path writes an assignment row. The document contradicted itself and the
|
||||
code sided with the wrong half.
|
||||
|
||||
Observed queues have their own path (POST /api/collector/printers) and
|
||||
become assignments only when a person asks, through
|
||||
seed-from-observed after reviewing the comparison.
|
||||
|
||||
WARNS RATHER THAN REJECTS. Unknown keys are not validated here, so a
|
||||
client written against the old docs keeps pushing successfully; only its
|
||||
printer keys stop doing anything. Silently ignoring them would leave an
|
||||
integrator believing assignments were being written.
|
||||
"""
|
||||
from shopdb.api import (
|
||||
AssetRelationship, RelationshipType, Asset, Communication)
|
||||
|
||||
has_default = 'defaultprinter' in payload
|
||||
has_list = 'printers' in payload
|
||||
if not has_default and not has_list:
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.printers.models import Printer
|
||||
except ImportError:
|
||||
warnings.append('printers plugin unavailable; printer links skipped')
|
||||
return []
|
||||
|
||||
dp_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='defaultprinter').first()
|
||||
ct_type = RelationshipType.query.filter_by(
|
||||
relationshiptype='connectedto').first()
|
||||
if not dp_type or not ct_type:
|
||||
warnings.append('printer relationship types missing; '
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
def resolve(identifier):
|
||||
# first match wins: printer text identity, then a printer IP.
|
||||
ident = (identifier or '').strip()
|
||||
if not ident:
|
||||
return None
|
||||
printer = (
|
||||
Printer.query.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.filter(db.or_(
|
||||
Printer.windowsname.ilike(ident),
|
||||
Printer.hostname.ilike(ident),
|
||||
Printer.sharename.ilike(ident),
|
||||
Asset.assetnumber.ilike(ident),
|
||||
Asset.name.ilike(ident),
|
||||
)).first())
|
||||
if printer:
|
||||
return printer.asset
|
||||
comm = (
|
||||
db.session.query(Communication)
|
||||
.join(Printer, Printer.assetid == Communication.assetid)
|
||||
.filter(Communication.ipaddress == ident)
|
||||
.first())
|
||||
if comm:
|
||||
return db.session.get(Asset, comm.assetid)
|
||||
return None
|
||||
|
||||
pcid = pcasset.assetid
|
||||
desired = set() # (targetassetid, relationshiptypeid) to keep
|
||||
printerlinks = []
|
||||
|
||||
default_id = None
|
||||
default_ident = (payload.get('defaultprinter') or '').strip()
|
||||
if default_ident:
|
||||
target = resolve(default_ident)
|
||||
if target:
|
||||
default_id = target.assetid
|
||||
desired.add((default_id, dp_type.relationshiptypeid))
|
||||
self._sync_one(pcid, default_id, dp_type)
|
||||
printerlinks.append({'assetid': default_id,
|
||||
'relationshiptype': 'defaultprinter'})
|
||||
else:
|
||||
warnings.append(f'unresolved default printer: {default_ident}')
|
||||
|
||||
for ident in payload.get('printers') or []:
|
||||
target = resolve(ident)
|
||||
if not target:
|
||||
warnings.append(f'unresolved printer: {ident}')
|
||||
continue
|
||||
if target.assetid == default_id:
|
||||
continue # already the default link
|
||||
desired.add((target.assetid, ct_type.relationshiptypeid))
|
||||
self._sync_one(pcid, target.assetid, ct_type)
|
||||
printerlinks.append({'assetid': target.assetid,
|
||||
'relationshiptype': 'connectedto'})
|
||||
|
||||
# Archive collector-tagged links no longer reported (manual links, with
|
||||
# a NULL/other label, are never matched here).
|
||||
collector_rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcid,
|
||||
AssetRelationship.relationshiptypeid.in_(
|
||||
[dp_type.relationshiptypeid, ct_type.relationshiptypeid]),
|
||||
AssetRelationship.isactive == True,
|
||||
AssetRelationship.label == PRINTER_LINK_ORIGIN,
|
||||
).all()
|
||||
for rel in collector_rows:
|
||||
if (rel.targetassetid, rel.relationshiptypeid) not in desired:
|
||||
rel.isactive = False
|
||||
|
||||
return printerlinks
|
||||
|
||||
def _sync_one(self, pcid, printerid, reltype):
|
||||
"""Reactivate or create one collector PC->printer link (idempotent)."""
|
||||
from shopdb.api import AssetRelationship
|
||||
existing = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid).first()
|
||||
if existing:
|
||||
# do not re-stamp label: a pre-existing manual row stays manual.
|
||||
if not existing.isactive:
|
||||
existing.isactive = True
|
||||
return existing
|
||||
rel = AssetRelationship(
|
||||
sourceassetid=pcid, targetassetid=printerid,
|
||||
relationshiptypeid=reltype.relationshiptypeid,
|
||||
label=PRINTER_LINK_ORIGIN)
|
||||
db.session.add(rel)
|
||||
return rel
|
||||
|
||||
# -- measuring-tool sync -----------------------------------------------
|
||||
sent = [key for key in ('defaultprinter', 'printers') if key in payload]
|
||||
if sent:
|
||||
warnings.append(
|
||||
'%s ignored: a collector report never sets printer assignment. '
|
||||
'Report queues to POST /api/collector/printers, then adopt them '
|
||||
'with POST /api/printers/assignments/seed-from-observed.'
|
||||
% ' and '.join(sent))
|
||||
|
||||
def _sync_machine_link(self, comp, machinenumber, warnings,
|
||||
origin_label=MACHINE_LINK_ORIGIN, preview=False):
|
||||
|
||||
@@ -8,6 +8,11 @@ tables are created parent-first. computertypeid / measuringtooltypeid /
|
||||
publishedby are SOFT references (plain columns, no FK) to optional plugins/core.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -19,7 +24,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestscopes',
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
@@ -37,7 +42,7 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('scopeid'),
|
||||
sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
||||
)
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestentries',
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
@@ -80,10 +85,10 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('entryid'),
|
||||
sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
||||
)
|
||||
op.create_index('idx_entry_scope', 'manifestentries', ['scopeid'])
|
||||
op.create_index('idx_entry_scope_order', 'manifestentries',
|
||||
['scopeid', 'sortorder'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_entry_scope', 'manifestentries', ['scopeid'])
|
||||
create_index_if_missing('idx_entry_scope_order', 'manifestentries',
|
||||
['scopeid', 'sortorder'])
|
||||
create_table_if_missing(
|
||||
'manifestentrypctypes',
|
||||
sa.Column('entrypctypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -93,8 +98,8 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrypctypeid'),
|
||||
)
|
||||
op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestentryhostnames',
|
||||
sa.Column('entryhostnameid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -104,8 +109,8 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entryhostnameid'),
|
||||
)
|
||||
op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestentrymachinenumbers',
|
||||
sa.Column('entrymachinenumberid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -115,9 +120,9 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrymachinenumberid'),
|
||||
)
|
||||
op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers',
|
||||
['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_machinenumber_entry', 'manifestentrymachinenumbers',
|
||||
['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestinusechecks',
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -127,7 +132,7 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('inusecheckid'),
|
||||
sa.UniqueConstraint('entryid'),
|
||||
)
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestinusecheckprocesses',
|
||||
sa.Column('inusecheckprocessid', sa.Integer(), nullable=False),
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
@@ -140,9 +145,9 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('inusecheckprocessid'),
|
||||
)
|
||||
op.create_index('idx_process_check', 'manifestinusecheckprocesses',
|
||||
['inusecheckid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_process_check', 'manifestinusecheckprocesses',
|
||||
['inusecheckid'])
|
||||
create_table_if_missing(
|
||||
'manifestpublishedversions',
|
||||
sa.Column('publishedversionid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
@@ -158,9 +163,9 @@ def upgrade():
|
||||
sa.UniqueConstraint('scopeid', 'versionnumber',
|
||||
name='uq_published_scope_version'),
|
||||
)
|
||||
op.create_index('idx_published_scope', 'manifestpublishedversions',
|
||||
['scopeid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_published_scope', 'manifestpublishedversions',
|
||||
['scopeid'])
|
||||
create_table_if_missing(
|
||||
'manifestpayloads',
|
||||
sa.Column('payloadid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
@@ -173,8 +178,8 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('payloadid'),
|
||||
)
|
||||
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
create_table_if_missing(
|
||||
'manifestenforcementreports',
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=100), nullable=False),
|
||||
@@ -192,13 +197,13 @@ def upgrade():
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('reportid'),
|
||||
)
|
||||
op.create_index('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
op.create_index('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
create_index_if_missing('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
create_index_if_missing('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
create_table_if_missing(
|
||||
'manifestenforcementresults',
|
||||
sa.Column('resultid', sa.Integer(), nullable=False),
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
@@ -212,9 +217,9 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('resultid'),
|
||||
)
|
||||
op.create_index('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
op.create_table(
|
||||
create_index_if_missing('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
create_table_if_missing(
|
||||
'pctypealiases',
|
||||
sa.Column('aliasid', sa.Integer(), nullable=False),
|
||||
sa.Column('aliasgroup', sa.Integer(), nullable=False),
|
||||
@@ -223,7 +228,7 @@ def upgrade():
|
||||
sa.UniqueConstraint('aliasgroup', 'aliasname',
|
||||
name='uq_alias_group_name'),
|
||||
)
|
||||
op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup'])
|
||||
create_index_if_missing('idx_alias_group', 'pctypealiases', ['aliasgroup'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -10,6 +10,11 @@ Create Date: 2026-07-21
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -24,7 +29,7 @@ def upgrade():
|
||||
insp = sa.inspect(bind)
|
||||
if 'manifestblobs' in insp.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'manifestblobs',
|
||||
sa.Column('sha256', sa.String(length=64), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
|
||||
@@ -379,6 +379,7 @@ import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
@@ -440,6 +441,9 @@ const relationshipTypes = ref([])
|
||||
const controllingPcId = ref(null)
|
||||
const relationshipTypeId = ref(null)
|
||||
const existingRelationshipId = ref(null)
|
||||
// Which PC the link named when the form loaded, so an unchanged one can be left
|
||||
// alone. Editing a machine is mostly editing something else about it.
|
||||
const loadedControllingPcId = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected machine vendor
|
||||
@@ -575,7 +579,16 @@ onMounted(async () => {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
|
||||
controllingPcId.value = rel.sourceasset.assetid
|
||||
relationshipTypeId.value = rel.relationshiptypeid
|
||||
existingRelationshipId.value = rel.assetrelationshipid
|
||||
// relationshipid. `assetrelationshipid` is not a key the API has
|
||||
// ever emitted - to_dict serialises the column names and the
|
||||
// column is relationshipid - so this read undefined every time,
|
||||
// and saveRelationship then took the "there is nothing to
|
||||
// replace" path on a machine that had a controlling PC. Saving
|
||||
// ANY change, a map position included, re-POSTed the link that
|
||||
// was already there and the server correctly answered 409 "this
|
||||
// relationship already exists".
|
||||
existingRelationshipId.value = rel.relationshipid
|
||||
loadedControllingPcId.value = rel.sourceasset.assetid
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -693,31 +706,40 @@ async function saveMachine() {
|
||||
}
|
||||
|
||||
async function saveRelationship(assetId) {
|
||||
// If no PC selected and no existing relationship, nothing to do
|
||||
if (!controllingPcId.value && !existingRelationshipId.value) {
|
||||
// The decision lives in controllingPc.js, where it is covered by tests.
|
||||
// See that file for why "is there a link" and "do I know its id" are two
|
||||
// different questions and what happened when they were treated as one.
|
||||
const plan = planControllingPcChange({
|
||||
selected: controllingPcId.value,
|
||||
loaded: loadedControllingPcId.value,
|
||||
existingRelationshipId: existingRelationshipId.value,
|
||||
})
|
||||
|
||||
if (plan.action === 'none') {
|
||||
return
|
||||
}
|
||||
|
||||
// If clearing the relationship
|
||||
if (!controllingPcId.value && existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
if (plan.deleteId !== null) {
|
||||
await assetsApi.deleteRelationship(plan.deleteId)
|
||||
existingRelationshipId.value = null
|
||||
loadedControllingPcId.value = null
|
||||
}
|
||||
|
||||
if (plan.action === 'unlink') {
|
||||
return
|
||||
}
|
||||
|
||||
// If PC is selected
|
||||
if (controllingPcId.value) {
|
||||
// If there's an existing relationship, delete it first
|
||||
if (existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
}
|
||||
// PC controls Machine, so the PC is the source and the machine the target.
|
||||
const created = await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
|
||||
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
}
|
||||
// Keep the form honest about what is now stored, so a second save without a
|
||||
// reload does not repeat the create it just did.
|
||||
existingRelationshipId.value = created?.data?.data?.relationshipid ?? null
|
||||
loadedControllingPcId.value = controllingPcId.value
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
68
plugins/machines/frontend/views/controllingPc.js
Normal file
68
plugins/machines/frontend/views/controllingPc.js
Normal file
@@ -0,0 +1,68 @@
|
||||
// What saving the machine form should do about its controlling-PC link.
|
||||
//
|
||||
// Pulled out of MachineForm.vue because the component decided this inline and
|
||||
// got it wrong in a way nothing could catch: it treated "I know the id of the
|
||||
// existing link" as "there IS an existing link". The id was read from
|
||||
// `rel.assetrelationshipid`, a key the API has never emitted - the column is
|
||||
// `relationshipid` and to_dict serialises column names - so it was undefined on
|
||||
// every load, and the two questions gave opposite answers on a machine that
|
||||
// plainly had a controlling PC.
|
||||
//
|
||||
// Both failures followed from that single conflation:
|
||||
// - Saving ANY change, a map position included, re-POSTed the link that was
|
||||
// already there. The server answered 409 "this relationship already
|
||||
// exists", correctly, and the form surfaced it as a failed save.
|
||||
// - Clearing the controlling PC did nothing at all, because "no selection and
|
||||
// no known id" reads as "nothing to do".
|
||||
//
|
||||
// So THE LOADED PC IS THE AUTHORITY on whether a link exists, and the
|
||||
// relationship id is only ever used to delete a row. If the id goes missing
|
||||
// again the worst case is a link that cannot be removed, never a duplicate
|
||||
// create.
|
||||
|
||||
/**
|
||||
* Decide what to do with the controlling-PC link on save.
|
||||
*
|
||||
* @param {object} state
|
||||
* @param {number|string|null} state.selected - PC id chosen in the form now.
|
||||
* @param {number|string|null} state.loaded - PC id the link named on load.
|
||||
* @param {number|null} state.existingRelationshipId - row id to delete, if known.
|
||||
* @returns {{action: 'none'|'unlink'|'link'|'relink', deleteId: number|null}}
|
||||
*/
|
||||
export function planControllingPcChange({ selected, loaded, existingRelationshipId } = {}) {
|
||||
const now = normalize(selected)
|
||||
const before = normalize(loaded)
|
||||
const deleteId = existingRelationshipId ?? null
|
||||
|
||||
// Unchanged is not a change. Somebody moving a marker on the map is not
|
||||
// asking to be re-linked to the same PC, and delete-then-create on an
|
||||
// untouched link opens a window where the machine has no controlling PC -
|
||||
// which resolve_asset_position walks across, so a failure between the two
|
||||
// calls would lose the machine's inherited position along with the link.
|
||||
if (now === before) {
|
||||
return { action: 'none', deleteId: null }
|
||||
}
|
||||
|
||||
if (now === null) {
|
||||
// Cleared. Nothing to delete means nothing to do, rather than an error:
|
||||
// the link is already absent, which is the state being asked for.
|
||||
return deleteId === null
|
||||
? { action: 'none', deleteId: null }
|
||||
: { action: 'unlink', deleteId }
|
||||
}
|
||||
|
||||
if (before === null) {
|
||||
return { action: 'link', deleteId: null }
|
||||
}
|
||||
|
||||
// Swapped to a different PC. The old row goes first: 'controls' is singular
|
||||
// per source, not per target, so leaving it would leave the machine with two.
|
||||
return { action: 'relink', deleteId }
|
||||
}
|
||||
|
||||
// Ids arrive as numbers from the API and strings from a <select>, and '' means
|
||||
// "nothing chosen". Comparing those raw is how an unchanged PC looks changed.
|
||||
function normalize(value) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return String(value)
|
||||
}
|
||||
81
plugins/machines/frontend/views/controllingPc.spec.js
Normal file
81
plugins/machines/frontend/views/controllingPc.spec.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
// Reported: moving machine 3016 on the map, then saving, answered "this
|
||||
// relationship already exists" and refused the save. The machine had a
|
||||
// controlling PC and the form re-created that link on every save, because it
|
||||
// asked "do I know the existing link's id" instead of "is there an existing
|
||||
// link" - and the id was read from a key the API does not emit, so it was
|
||||
// always undefined.
|
||||
//
|
||||
// The first two specs are that bug. The rest pin the behaviour it was hiding.
|
||||
|
||||
describe('planControllingPcChange', () => {
|
||||
it('does nothing when the controlling PC is unchanged', () => {
|
||||
// The reported case: only the map position moved.
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('still does nothing when the existing row id was never learned', () => {
|
||||
// The exact shape of the bug: the link is there, its id is not known. The
|
||||
// answer must be "leave it alone", never "create it again" - a create here
|
||||
// is the 409.
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: 42, existingRelationshipId: undefined,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('removes the link when the PC is cleared', () => {
|
||||
// This never fired before either: no selection and no known id read as
|
||||
// "nothing to do", so clearing a controlling PC silently kept it.
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'unlink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('creates a link when there was none', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: 42, loaded: null, existingRelationshipId: null,
|
||||
})).toEqual({ action: 'link', deleteId: null })
|
||||
})
|
||||
|
||||
it('replaces the old row when the PC is swapped', () => {
|
||||
// controls is singular per SOURCE, not per target, so the old row has to go
|
||||
// or the machine ends up with two controlling PCs.
|
||||
expect(planControllingPcChange({
|
||||
selected: 99, loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'relink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('treats a select string and an API number as the same PC', () => {
|
||||
// A <select> hands back '42' while the relationship carried 42. Comparing
|
||||
// those raw makes an untouched link look like a swap, which is the same
|
||||
// duplicate POST by another route.
|
||||
expect(planControllingPcChange({
|
||||
selected: '42', loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('treats an empty selection as cleared, not as a PC named ""', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: '', loaded: 42, existingRelationshipId: 7,
|
||||
})).toEqual({ action: 'unlink', deleteId: 7 })
|
||||
})
|
||||
|
||||
it('does nothing when there was no PC and none was chosen', () => {
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: null, existingRelationshipId: null,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
|
||||
it('does not attempt a delete it cannot perform', () => {
|
||||
// Cleared, but the row id was never learned. Asking the API to delete
|
||||
// `undefined` produced a request against /relationships/undefined.
|
||||
expect(planControllingPcChange({
|
||||
selected: null, loaded: 42, existingRelationshipId: undefined,
|
||||
})).toEqual({ action: 'none', deleteId: null })
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,11 @@ connection's default charset, so on a utf8mb4 database they are utf8mb4, matchin
|
||||
how the core chain creates its tables.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -28,7 +33,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'measuringtooltypes',
|
||||
sa.Column('measuringtooltypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
@@ -40,7 +45,7 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('measuringtooltypeid'),
|
||||
sa.UniqueConstraint('name'),
|
||||
)
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'measuringtools',
|
||||
sa.Column('measuringtoolid', sa.Integer(), nullable=False),
|
||||
sa.Column('assetid', sa.Integer(), nullable=False),
|
||||
@@ -59,8 +64,8 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('measuringtoolid'),
|
||||
sa.UniqueConstraint('assetid'),
|
||||
)
|
||||
op.create_index('idx_measuringtool_type', 'measuringtools',
|
||||
['measuringtooltypeid'])
|
||||
create_index_if_missing('idx_measuringtool_type', 'measuringtools',
|
||||
['measuringtooltypeid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -11,6 +11,11 @@ ops are written out explicitly anyway to match the measuringtools exemplar
|
||||
and keep the baseline reviewable.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -22,7 +27,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printeditems',
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
sa.Column('itemcode', sa.String(length=20), nullable=True),
|
||||
@@ -39,9 +44,9 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('printeditemid'),
|
||||
sa.UniqueConstraint('itemcode'),
|
||||
)
|
||||
op.create_index('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
||||
create_index_if_missing('ix_printeditems_itemcode', 'printeditems', ['itemcode'])
|
||||
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printeditemtransactions',
|
||||
sa.Column('transactionid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
@@ -58,12 +63,12 @@ def upgrade():
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('transactionid'),
|
||||
)
|
||||
op.create_index('ix_printeditemtransactions_printeditemid',
|
||||
'printeditemtransactions', ['printeditemid'])
|
||||
op.create_index('ix_printeditemtransactions_employeesso',
|
||||
'printeditemtransactions', ['employeesso'])
|
||||
op.create_index('ix_printeditemtransactions_transactiondate',
|
||||
'printeditemtransactions', ['transactiondate'])
|
||||
create_index_if_missing('ix_printeditemtransactions_printeditemid',
|
||||
'printeditemtransactions', ['printeditemid'])
|
||||
create_index_if_missing('ix_printeditemtransactions_employeesso',
|
||||
'printeditemtransactions', ['employeesso'])
|
||||
create_index_if_missing('ix_printeditemtransactions_transactiondate',
|
||||
'printeditemtransactions', ['transactiondate'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -5,6 +5,11 @@ the ADR-008 payoff: the plugin evolves its own schema without touching
|
||||
the core chain. Applied by `flask plugin upgrade-all`.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -16,7 +21,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printeditemfiles',
|
||||
sa.Column('fileid', sa.Integer(), nullable=False),
|
||||
sa.Column('printeditemid', sa.Integer(), nullable=False),
|
||||
@@ -35,8 +40,8 @@ def upgrade():
|
||||
sa.PrimaryKeyConstraint('fileid'),
|
||||
sa.UniqueConstraint('storedfilename'),
|
||||
)
|
||||
op.create_index('ix_printeditemfiles_printeditemid',
|
||||
'printeditemfiles', ['printeditemid'])
|
||||
create_index_if_missing('ix_printeditemfiles_printeditemid',
|
||||
'printeditemfiles', ['printeditemid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -4,6 +4,11 @@ Stores the last tier alerted per (printer, supply) so the poller fires once
|
||||
on a downward crossing and re-arms after a refill. Idempotent create.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -19,7 +24,7 @@ def upgrade():
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printersupplyalerts' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printersupplyalerts',
|
||||
sa.Column('printersupplyalertid', sa.Integer(), primary_key=True),
|
||||
sa.Column('printerid', sa.Integer(), nullable=False),
|
||||
@@ -35,8 +40,8 @@ def upgrade():
|
||||
sa.UniqueConstraint('printerid', 'supplykey',
|
||||
name='uq_printersupplyalert_printer_key'),
|
||||
)
|
||||
op.create_index('ix_printersupplyalerts_printerid',
|
||||
'printersupplyalerts', ['printerid'])
|
||||
create_index_if_missing('ix_printersupplyalerts_printerid',
|
||||
'printersupplyalerts', ['printerid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -21,6 +21,11 @@ Revision ID: printers0005observedqueues
|
||||
Revises: printers0004drivervendor
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -36,7 +41,7 @@ def upgrade():
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerobservedqueues' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'printerobservedqueues',
|
||||
sa.Column('printerobservedqueueid', sa.Integer(), nullable=False),
|
||||
# Nullable and no index of its own beyond the explicit one below: an
|
||||
@@ -67,10 +72,10 @@ def upgrade():
|
||||
)
|
||||
# Port address is the primary match key from an observed queue back to a
|
||||
# printer asset, so every comparison read hits it.
|
||||
op.create_index('idx_printerobservedqueues_portaddress',
|
||||
'printerobservedqueues', ['portaddress'])
|
||||
op.create_index('idx_printerobservedqueues_assetid',
|
||||
'printerobservedqueues', ['assetid'])
|
||||
create_index_if_missing('idx_printerobservedqueues_portaddress',
|
||||
'printerobservedqueues', ['portaddress'])
|
||||
create_index_if_missing('idx_printerobservedqueues_assetid',
|
||||
'printerobservedqueues', ['assetid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -6,9 +6,15 @@ on first read needs somewhere to keep the ciphertext until then.
|
||||
|
||||
TWO DIFFERENT DOORS, on purpose.
|
||||
|
||||
CREATE needs a login and a permission. Anonymous creation would be an
|
||||
untraceable way to move data out of a system that otherwise records who did
|
||||
what, and the creator is the only person these rows can ever name.
|
||||
CREATE needs a LOGIN and nothing more. Sharing a password is something anybody
|
||||
at the site may legitimately need to do, and a permission somebody has to be
|
||||
granted first turns a two-minute job into a ticket - which is how people end up
|
||||
pasting credentials into chat instead.
|
||||
|
||||
The login stays, though, and is not a formality: anonymous creation would make
|
||||
this an unattributable write endpoint that stores arbitrary ciphertext, and the
|
||||
creator is the only person these rows can ever name. Everybody browsing this app
|
||||
is already signed in, so requiring it costs nothing.
|
||||
|
||||
READ needs neither, because the recipient usually has no account here and the
|
||||
link is normally handed over in a call or a chat. The unguessable token IS the
|
||||
@@ -27,12 +33,12 @@ import secrets as pysecrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from flask import current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
|
||||
from shopdb.api import (
|
||||
db,
|
||||
db, cache, Setting,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
)
|
||||
|
||||
from ..models import SecureSecret
|
||||
@@ -50,18 +56,113 @@ MAX_CIPHERTEXT = 4096
|
||||
DEFAULT_EXPIRY_HOURS = 24
|
||||
MAX_EXPIRY_HOURS = 168
|
||||
|
||||
MAX_VIEWS = 5
|
||||
# Generous, because "how many people need this password" is the site's business
|
||||
# and not a number worth arguing with. The floor matters more than the ceiling:
|
||||
# a secret readable an unlimited number of times is a password on a web page.
|
||||
MAX_VIEWS = 100
|
||||
|
||||
# Guest creation, when a site enables it, is an unauthenticated write. Bounded
|
||||
# per address so it cannot be used to fill a table or park data: a person
|
||||
# sharing passwords does it a handful of times an hour, not hundreds.
|
||||
GUEST_CREATE_MAX = 10
|
||||
GUEST_CREATE_WINDOW_SECONDS = 600
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _sweep_expired():
|
||||
"""Delete secrets whose window has passed, whoever asked.
|
||||
|
||||
Redeeming removes a row, and so does touching an expired one - but a secret
|
||||
nobody ever opens was removed by NOTHING. The recipient goes on holiday, the
|
||||
link is never clicked, and the ciphertext sits there for good: not a
|
||||
disclosure risk, since it cannot be read, but it makes "nothing lingers"
|
||||
untrue and grows the table in exactly the case where the feature failed.
|
||||
|
||||
Swept opportunistically rather than on a schedule: creating a secret is
|
||||
authenticated and infrequent, and an air-gapped site is not going to have a
|
||||
cron job anybody remembers to add.
|
||||
"""
|
||||
stale = SecureSecret.query.filter(SecureSecret.expiresat < _utcnow()).all()
|
||||
for secret in stale:
|
||||
db.session.delete(secret)
|
||||
return len(stale)
|
||||
|
||||
|
||||
def _guests_allowed():
|
||||
"""May somebody with no account create a secret? Default NO.
|
||||
|
||||
Read straight from the row rather than the settings cache: this decides
|
||||
whether an unauthenticated caller may write, and being wrong on the
|
||||
permissive side for five minutes is the wrong way round to be wrong.
|
||||
"""
|
||||
try:
|
||||
row = Setting.query.filter_by(key='securesend_allow_guests').first()
|
||||
except Exception:
|
||||
# FAIL CLOSED. If the setting cannot be read - the row is missing, the
|
||||
# table is not there yet on a half-migrated site, the database is
|
||||
# unhappy - the answer is no. An error must never be the thing that
|
||||
# opens an unauthenticated write endpoint.
|
||||
current_app.logger.exception('could not read securesend_allow_guests')
|
||||
return False
|
||||
return bool(row and str(row.value).strip().lower() in ('true', '1', 'yes'))
|
||||
|
||||
|
||||
def _guest_ratelimited():
|
||||
"""Bounded guest creation, per address, in a fixed window.
|
||||
|
||||
Same shape as the login limiter. Not a security boundary - a determined
|
||||
person has more addresses - but it stops an open endpoint being an easy
|
||||
place to park data or fill a disk.
|
||||
"""
|
||||
address = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
|
||||
or request.remote_addr or 'unknown')
|
||||
bucket = int(_utcnow().timestamp()) // GUEST_CREATE_WINDOW_SECONDS
|
||||
key = f'securesendguest:{address}:{bucket}'
|
||||
used = cache.get(key) or 0
|
||||
if used >= GUEST_CREATE_MAX:
|
||||
return True
|
||||
cache.set(key, used + 1, timeout=GUEST_CREATE_WINDOW_SECONDS)
|
||||
return False
|
||||
|
||||
|
||||
@tools_bp.route('/secrets', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('tools.secrets')
|
||||
@jwt_required(optional=True)
|
||||
def create_secret():
|
||||
"""Store an already-encrypted secret and return its token."""
|
||||
"""Store an already-encrypted secret and return its token.
|
||||
|
||||
OPTIONAL jwt so the SETTING decides rather than the decorator: a guest has
|
||||
to reach the handler to be told whether this site lets them in.
|
||||
"""
|
||||
# WHO MAY CREATE. Signed in: the tools.secrets permission, with the usual
|
||||
# admin bypass. Not signed in: only when the site has deliberately opened
|
||||
# it, and then within a rate limit.
|
||||
# get_jwt_identity(), NOT `current_user is None`: current_user is a
|
||||
# LocalProxy that is never None itself, so that test silently took the
|
||||
# signed-in branch for a guest and then blew up dereferencing it.
|
||||
identity = get_jwt_identity()
|
||||
if identity is None:
|
||||
if not _guests_allowed():
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Sign in to create a secret link.',
|
||||
http_code=401)
|
||||
if _guest_ratelimited():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Too many links from this address. Try again shortly.',
|
||||
http_code=429)
|
||||
elif not current_user.haspermission('tools.secrets'):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to create secret links.',
|
||||
http_code=403)
|
||||
# Tidy up on the way past. Cheap, and it keeps the table bounded without a
|
||||
# scheduler existing anywhere.
|
||||
_sweep_expired()
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
ciphertext = (data.get('ciphertext') or '').strip()
|
||||
iv = (data.get('iv') or '').strip()
|
||||
@@ -173,4 +274,9 @@ def peek_secret(token):
|
||||
"""
|
||||
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
|
||||
live = bool(secret and not secret.is_expired() and not secret.is_spent())
|
||||
if secret is not None and not live:
|
||||
# A late click tidies its own row, so a dead secret does not need
|
||||
# somebody to create a new one before it is cleaned up.
|
||||
db.session.delete(secret)
|
||||
db.session.commit()
|
||||
return success_response({'available': live})
|
||||
|
||||
23
plugins/tools/frontend/api.js
Normal file
23
plugins/tools/frontend/api.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Tech Tools API client.
|
||||
//
|
||||
// Only secure send needs one - every other tool runs entirely in the browser
|
||||
// and talks to nothing.
|
||||
import api from '@/api'
|
||||
|
||||
export const toolsApi = {
|
||||
createSecret(payload) {
|
||||
return api.post('/tools/secrets', payload)
|
||||
},
|
||||
|
||||
// Is the link still live? Spends nothing, so it is safe for the read page to
|
||||
// ask before anyone clicks.
|
||||
peekSecret(token) {
|
||||
return api.get(`/tools/secrets/${token}`)
|
||||
},
|
||||
|
||||
// POST, not GET, and that is deliberate: chat and mail clients prefetch GET
|
||||
// urls to build previews, and a preview must not consume a one-view secret.
|
||||
redeemSecret(token) {
|
||||
return api.post(`/tools/secrets/${token}`)
|
||||
},
|
||||
}
|
||||
@@ -15,12 +15,48 @@ export default [
|
||||
path: 'tools',
|
||||
name: 'tools',
|
||||
component: () => import('./views/ToolsIndex.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'tools' }
|
||||
// NO requiresAuth. Every tool here is usable without an account: the label
|
||||
// generator runs entirely in the browser, and secure send decides for
|
||||
// itself (a site can let guests create, and receiving never needs a login).
|
||||
// Gating the index turned a public tool into one nobody could reach.
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'tools/codes',
|
||||
name: 'tools-codes',
|
||||
component: () => import('./views/CodeGenerator.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'tools' }
|
||||
// Browser-only: no API, no data, nothing to protect.
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'settings/secure-send',
|
||||
name: 'secure-send-settings',
|
||||
component: () => import('./views/SecureSendSettings.vue'),
|
||||
// Admin, like every other settings page: this decides whether an
|
||||
// unauthenticated caller may write to the site.
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'tools' }
|
||||
},
|
||||
{
|
||||
path: 'tools/secure-send',
|
||||
name: 'tools-secure-send',
|
||||
component: () => import('./views/SecureSendCreate.vue'),
|
||||
// Reachable by anyone; the SERVER decides who may actually create. A signed
|
||||
// in user needs tools.secrets, and a guest needs the site to have turned on
|
||||
// securesend_allow_guests. The page reads that setting so a guest who
|
||||
// cannot create is told so, instead of being handed a form that 401s.
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
]
|
||||
|
||||
// PUBLIC and OUTSIDE AppLayout. The recipient of a secret usually has no account
|
||||
// here, so this page carries no navigation and asks for no login - the
|
||||
// unguessable token in the path, and the key in the fragment, are what let them
|
||||
// read it. Short path because the whole link gets pasted into a chat message.
|
||||
export const toplevel = [
|
||||
{
|
||||
path: '/s/:token',
|
||||
name: 'secure-send-read',
|
||||
component: () => import('./views/SecureSendRead.vue'),
|
||||
meta: { plugin: 'tools' }
|
||||
},
|
||||
]
|
||||
|
||||
136
plugins/tools/frontend/securesend.js
Normal file
136
plugins/tools/frontend/securesend.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// Client-side crypto for secure send.
|
||||
//
|
||||
// THE KEY NEVER LEAVES THIS FILE'S PROCESS. It is generated here, put in the
|
||||
// URL fragment, and read back out of the fragment on the other side. Browsers
|
||||
// do not transmit fragments, so it reaches the recipient's browser and nowhere
|
||||
// else - not the server, not its logs, not a proxy in between. That is what
|
||||
// makes the stored ciphertext useless to anyone who reads the database.
|
||||
//
|
||||
// AES-GCM because it authenticates as well as encrypts: a tampered ciphertext
|
||||
// fails to decrypt rather than producing plausible rubbish.
|
||||
//
|
||||
// Web Crypto only, no dependency. It is available on every browser the fleet
|
||||
// runs and requires a secure context, which is the right constraint anyway -
|
||||
// the page and its scripts still cross the network even though the key does not.
|
||||
|
||||
const ALGORITHM = 'AES-GCM'
|
||||
const KEY_BITS = 256
|
||||
const IV_BYTES = 12
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
const binary = atob(text)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
// base64url for the fragment: '+' and '/' are legal in a fragment but survive
|
||||
// copy-paste and link rewriting far less reliably than the url-safe alphabet.
|
||||
function toBase64Url(bytes) {
|
||||
return toBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function fromBase64Url(text) {
|
||||
const padded = text.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return fromBase64(padded + '='.repeat((4 - (padded.length % 4)) % 4))
|
||||
}
|
||||
|
||||
export function cryptoAvailable() {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle
|
||||
}
|
||||
|
||||
/** Encrypt text. Returns the payload for the server AND the key for the link. */
|
||||
export async function sealSecret(plaintext) {
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: ALGORITHM, length: KEY_BITS }, true, ['encrypt', 'decrypt'])
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES))
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: ALGORITHM, iv }, key, new TextEncoder().encode(plaintext))
|
||||
const raw = await crypto.subtle.exportKey('raw', key)
|
||||
return {
|
||||
ciphertext: toBase64(ciphertext),
|
||||
iv: toBase64(iv),
|
||||
// Goes in the fragment. Never sent anywhere.
|
||||
fragmentKey: toBase64Url(raw),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt what the server handed back, using the key from the fragment. */
|
||||
export async function openSecret({ ciphertext, iv, fragmentKey }) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', fromBase64Url(fragmentKey),
|
||||
{ name: ALGORITHM, length: KEY_BITS }, false, ['decrypt'])
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: ALGORITHM, iv: fromBase64(iv) }, key, fromBase64(ciphertext))
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
// -- Keeping this from becoming a document transfer ---------------------------
|
||||
//
|
||||
// The server can only bound SIZE, because it cannot read what it holds. These
|
||||
// run here, where the plaintext is still visible, and are advisory: they guide
|
||||
// somebody who is about to do the wrong thing without realising. A determined
|
||||
// person can POST directly, which is why the size limit on the server is the
|
||||
// control that actually enforces.
|
||||
|
||||
// Matches MAX_CIPHERTEXT server-side, minus room for base64 expansion and the
|
||||
// GCM tag, so the browser refuses before a round trip rather than after one.
|
||||
export const MAX_PLAINTEXT = 2800
|
||||
|
||||
const MAX_LINES = 20
|
||||
|
||||
// A pasted file, recognised by how it starts. Cheap, high signal.
|
||||
const FILE_SIGNATURES = [
|
||||
['%PDF', 'a PDF'],
|
||||
['PK\x03\x04', 'a zip or Office file'],
|
||||
['{\\rtf', 'an RTF document'],
|
||||
['\x89PNG', 'a PNG image'],
|
||||
// JPEG magic bytes as escapes rather than literals: the naming gate bans
|
||||
// non-ASCII source, and these are bytes rather than text anyway.
|
||||
['\xff\xd8\xff', 'a JPEG image'],
|
||||
['data:', 'a data URI'],
|
||||
]
|
||||
|
||||
/** Why this text should not be sent, or null when it is fine. */
|
||||
export function refuseReason(text) {
|
||||
const value = text || ''
|
||||
if (!value.trim()) return 'Nothing to send yet.'
|
||||
|
||||
if (value.length > MAX_PLAINTEXT) {
|
||||
return `That is ${value.length} characters. This is for passwords and short `
|
||||
+ `text, not documents - the limit is ${MAX_PLAINTEXT}.`
|
||||
}
|
||||
|
||||
for (const [signature, description] of FILE_SIGNATURES) {
|
||||
if (value.startsWith(signature)) {
|
||||
return `That looks like ${description} pasted as text. Secure send is for `
|
||||
+ 'credentials, not file transfer.'
|
||||
}
|
||||
}
|
||||
|
||||
const lines = value.split('\n').length
|
||||
if (lines > MAX_LINES) {
|
||||
return `That is ${lines} lines. A password is one - this looks like a `
|
||||
+ 'document, and secure send is not a file transfer.'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a paste that carries FILES rather than text.
|
||||
*
|
||||
* Removes the easiest wrong path - dragging a PDF in, or Ctrl+V on a copied
|
||||
* file - without anyone having to read a rule about it.
|
||||
*/
|
||||
export function pasteCarriesFiles(event) {
|
||||
const items = event?.clipboardData?.items
|
||||
if (!items) return false
|
||||
return Array.from(items).some(item => item.kind === 'file')
|
||||
}
|
||||
@@ -24,11 +24,21 @@ export const TOOLS = [
|
||||
standalone: true,
|
||||
keywords: ['qr', 'barcode', 'code128', 'label', 'zebra', 'sticker', 'csv', 'print'],
|
||||
},
|
||||
{
|
||||
id: 'secure-send',
|
||||
name: 'Secure Send',
|
||||
description: 'Hand somebody a password by a link that works once and then destroys it.',
|
||||
category: 'security',
|
||||
route: '/tools/secure-send',
|
||||
keywords: ['password', 'secret', 'credential', 'share', 'link', 'expire',
|
||||
'one time', 'burn', 'pusher'],
|
||||
},
|
||||
]
|
||||
|
||||
export const CATEGORY_LABELS = {
|
||||
labels: 'Labels & Printing',
|
||||
convert: 'Conversion',
|
||||
security: 'Secrets & Access',
|
||||
network: 'Network',
|
||||
}
|
||||
|
||||
|
||||
251
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
251
plugins/tools/frontend/views/SecureSendCreate.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Secure Send</h1>
|
||||
<router-link to="/tools" class="btn btn-secondary">Back to Tech Tools</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card intro">
|
||||
<p>
|
||||
Hand somebody a password without leaving it in a chat window. The text is
|
||||
encrypted in this browser and the key travels in the link, never to the
|
||||
server - so nobody here can read what you send, including an administrator.
|
||||
</p>
|
||||
<p class="muted">
|
||||
The link works once, then the secret is destroyed. For passwords and short
|
||||
text only, not documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- A guest at a site that has not opened guest creation. Told here rather
|
||||
than after typing a password into a form that was always going to be
|
||||
refused. -->
|
||||
<div v-if="supported && !mayCreate" class="card">
|
||||
<p>Signing in is needed to create a secret link at this site.</p>
|
||||
<p class="muted">
|
||||
Receiving one never needs an account - if somebody sent you a link, open
|
||||
that instead.
|
||||
</p>
|
||||
<router-link to="/login" class="btn btn-primary">Sign in</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="!supported" class="card control-note">
|
||||
This browser cannot encrypt in page (Web Crypto is unavailable, which
|
||||
usually means the site is not being served over HTTPS). Secure send needs
|
||||
it, because the whole point is that the server never sees your text.
|
||||
</div>
|
||||
|
||||
<template v-else-if="mayCreate && !link">
|
||||
<div class="card">
|
||||
<label class="field">
|
||||
<span class="field-label">Secret</span>
|
||||
<textarea
|
||||
v-model="plaintext"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Password, connection string, short note..."
|
||||
@paste="onPaste"
|
||||
></textarea>
|
||||
</label>
|
||||
<p class="counter" :class="{ over: plaintext.length > MAX_PLAINTEXT }">
|
||||
{{ plaintext.length }} / {{ MAX_PLAINTEXT }} characters
|
||||
</p>
|
||||
<p v-if="refusal" class="control-note">{{ refusal }}</p>
|
||||
|
||||
<div class="options">
|
||||
<label class="field">
|
||||
<span class="field-label">Expires after</span>
|
||||
<select v-model.number="expiryhours" class="form-control">
|
||||
<option :value="1">1 hour</option>
|
||||
<option :value="8">8 hours</option>
|
||||
<option :value="24">1 day</option>
|
||||
<option :value="72">3 days</option>
|
||||
<option :value="168">7 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Can be opened</span>
|
||||
<input
|
||||
v-model.number="views"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="1"
|
||||
:max="MAX_VIEWS"
|
||||
/>
|
||||
<span class="field-hint">
|
||||
{{ views === 1 ? 'Once, then destroyed' : views + ' times, then destroyed' }}
|
||||
(1 to {{ MAX_VIEWS }})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" :disabled="busy || !!refusal" @click="createLink">
|
||||
{{ busy ? 'Encrypting...' : 'Create link' }}
|
||||
</button>
|
||||
<p v-if="error" class="control-note">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<h3>Your link</h3>
|
||||
<p class="muted">
|
||||
Send this to the person who needs it. It opens
|
||||
{{ views === 1 ? 'once' : views + ' times' }} and expires {{ expiryLabel }}.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<input ref="linkInput" class="form-control mono" :value="link" readonly @focus="selectAll" />
|
||||
<button class="btn btn-primary" @click="copyLink">{{ copied ? 'Copied' : 'Copy' }}</button>
|
||||
</div>
|
||||
<p class="control-hint">
|
||||
This is the only time the link is shown - it is not stored anywhere you can
|
||||
look it up, and the key in it exists nowhere else.
|
||||
</p>
|
||||
<button class="btn btn-secondary" @click="reset">Send another</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { toolsApi } from '../api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { getSiteBaseUrl, getSetting } from '@/utils/siteSettings'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
sealSecret, refuseReason, pasteCarriesFiles, cryptoAvailable, MAX_PLAINTEXT,
|
||||
} from '../securesend'
|
||||
|
||||
const supported = cryptoAvailable()
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Whether THIS visitor can create one. A signed-in user always sees the form -
|
||||
// the server still enforces tools.secrets and says so if they lack it, which is
|
||||
// a different message from "sign in". A guest sees it only where the site has
|
||||
// opened guest creation.
|
||||
const mayCreate = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isAuthenticated) return
|
||||
const allowed = await getSetting('securesend_allow_guests', 'false')
|
||||
mayCreate.value = String(allowed).toLowerCase() === 'true'
|
||||
})
|
||||
|
||||
// Matches the server's ceiling. A free number rather than a dropdown because
|
||||
// "how many people need this password" is the sender's business - a shift
|
||||
// handover is not the same as one colleague on a call.
|
||||
const MAX_VIEWS = 100
|
||||
|
||||
const plaintext = ref('')
|
||||
const expiryhours = ref(24)
|
||||
const views = ref(1)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const link = ref('')
|
||||
const copied = ref(false)
|
||||
const linkInput = ref(null)
|
||||
|
||||
// Only nag once there is something to nag about: an empty box is not a mistake.
|
||||
const refusal = computed(() => (plaintext.value ? refuseReason(plaintext.value) : null))
|
||||
|
||||
const expiryLabel = computed(() => {
|
||||
const hours = expiryhours.value
|
||||
if (hours < 24) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
||||
const days = hours / 24
|
||||
return `in ${days} day${days === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
function onPaste(event) {
|
||||
// A pasted FILE is the easiest wrong path into a tool that is not a file
|
||||
// transfer. Refuse it here rather than letting its bytes become "text".
|
||||
if (pasteCarriesFiles(event)) {
|
||||
event.preventDefault()
|
||||
error.value = 'Secure send takes text, not files. Paste the password itself.'
|
||||
}
|
||||
}
|
||||
|
||||
async function createLink() {
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const sealed = await sealSecret(plaintext.value)
|
||||
const response = await toolsApi.createSecret({
|
||||
ciphertext: sealed.ciphertext,
|
||||
iv: sealed.iv,
|
||||
expiryhours: expiryhours.value,
|
||||
views: views.value,
|
||||
})
|
||||
const token = response.data.data.token
|
||||
|
||||
// THE SITE'S CONFIGURED URL, not the one this browser happens to be using.
|
||||
// site_base_url exists for exactly this - "used for QR codes and any
|
||||
// absolute link the app emits" - because somebody creating a secret may
|
||||
// have reached the app by an IP, a short hostname, or a name that only
|
||||
// resolves inside one VLAN, and the recipient has to open the link from
|
||||
// wherever they are. It falls back to the browsing origin when a site has
|
||||
// not set one.
|
||||
//
|
||||
// withBase for the path: a site served under /shopdb or /ops carries that
|
||||
// in front of every route, and leaving it off produced a 404 from IIS for a
|
||||
// secret that existed - with the sender shown a link that could never work.
|
||||
// site_base_url is DOCUMENTED as scheme + host, but sites really do set the
|
||||
// full base including the subpath - and appending the mount to one that
|
||||
// already carries it produced /shopdb/shopdb/s/<token>. Tolerate both
|
||||
// rather than trusting the description: add the mount only when it is not
|
||||
// there already.
|
||||
const configured = (await getSiteBaseUrl()).replace(/\/+$/, '')
|
||||
const mount = withBase('/').replace(/\/+$/, '')
|
||||
const needsMount = mount && !configured.endsWith(mount)
|
||||
|
||||
// The key rides in the FRAGMENT. Browsers never send it, so it reaches the
|
||||
// recipient and nowhere else.
|
||||
link.value =
|
||||
`${configured}${needsMount ? mount : ''}/s/${token}#${sealed.fragmentKey}`
|
||||
// Drop the plaintext as soon as it is sealed: no reason to keep it in memory
|
||||
// behind the link that replaced it.
|
||||
plaintext.value = ''
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.data?.error?.message || 'Could not create the link.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard access is refused in some hardened configurations. Select the
|
||||
// text instead so Ctrl+C still works rather than leaving a dead button.
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
linkInput.value?.select()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
link.value = ''
|
||||
error.value = ''
|
||||
copied.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intro p { margin: 0 0 0.5rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.field { display: block; margin-bottom: 1rem; }
|
||||
.field-label { display: block; font-size: 0.8125rem; color: var(--text-light); margin-bottom: 4px; }
|
||||
.options { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.options .field { flex: 1 1 12rem; }
|
||||
.counter { font-size: 0.8125rem; color: var(--text-light); margin: 0 0 0.5rem; }
|
||||
.field-hint { display: block; font-size: 0.8125rem; color: var(--text-light); margin-top: 4px; }
|
||||
.counter.over { color: var(--warning); font-weight: 600; }
|
||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 8px 0; }
|
||||
.control-hint { color: var(--text-light); font-size: 0.8125rem; margin: 8px 0 12px; }
|
||||
.link-row { display: flex; gap: 8px; align-items: center; margin: 0.5rem 0; }
|
||||
.link-row .form-control { flex: 1; }
|
||||
.mono { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 0.875rem; }
|
||||
</style>
|
||||
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
154
plugins/tools/frontend/views/SecureSendRead.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="reveal-page">
|
||||
<div class="reveal-card card">
|
||||
<h1>A secret was shared with you</h1>
|
||||
|
||||
<!-- CHECKING, not revealing. The page asks whether the link is still live
|
||||
before anyone clicks, so it can say what will happen rather than
|
||||
spending the only view to find out. -->
|
||||
<p v-if="state === 'checking'" class="muted">Checking the link...</p>
|
||||
|
||||
<template v-else-if="state === 'ready'">
|
||||
<p>
|
||||
Opening this destroys it. Make sure you can save or use the secret now -
|
||||
it cannot be shown twice, and nobody can recover it for you.
|
||||
</p>
|
||||
<!-- An explicit click, never automatic. Mail and chat scanners fetch
|
||||
links to check them; a page that revealed on load would burn the
|
||||
secret before its recipient ever saw it. -->
|
||||
<button class="btn btn-primary" :disabled="busy" @click="reveal">
|
||||
{{ busy ? 'Opening...' : 'Show me the secret' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'revealed'">
|
||||
<p class="muted">Here it is. It is gone from the server now.</p>
|
||||
<pre class="secret">{{ plaintext }}</pre>
|
||||
<button class="btn btn-primary" @click="copySecret">
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'nokey'">
|
||||
<p class="problem">This link is missing its key.</p>
|
||||
<p class="muted">
|
||||
The part after the # is what decrypts the secret, and it is not here.
|
||||
Some chat and mail systems trim it when they rewrite links. Ask the
|
||||
sender to share the link again - by a route that keeps it whole, or
|
||||
pasted as plain text - and to create a new secret if this one has since
|
||||
expired.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'undecryptable'">
|
||||
<p class="problem">The secret could not be decrypted.</p>
|
||||
<p class="muted">
|
||||
The link opened, but the key in it does not match. That usually means
|
||||
the link was altered in transit. The secret has now been used up, so
|
||||
ask the sender for a new one.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="problem">This link is not valid.</p>
|
||||
<p class="muted">
|
||||
It may have been opened already, or it may have expired. Secrets are
|
||||
destroyed once read, so a link only works the number of times the
|
||||
sender chose. Ask them for a new one.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { toolsApi } from '../api'
|
||||
import { openSecret, cryptoAvailable } from '../securesend'
|
||||
|
||||
const route = useRoute()
|
||||
const state = ref('checking')
|
||||
const plaintext = ref('')
|
||||
const busy = ref(false)
|
||||
const copied = ref(false)
|
||||
|
||||
const token = route.params.token
|
||||
// The key lives in the fragment, which the router does not parse and the server
|
||||
// never receives. Read it off the raw location.
|
||||
const fragmentKey = (window.location.hash || '').replace(/^#/, '')
|
||||
|
||||
onMounted(async () => {
|
||||
if (!cryptoAvailable()) {
|
||||
state.value = 'undecryptable'
|
||||
return
|
||||
}
|
||||
if (!fragmentKey) {
|
||||
state.value = 'nokey'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await toolsApi.peekSecret(token)
|
||||
state.value = response.data.data.available ? 'ready' : 'gone'
|
||||
} catch (err) {
|
||||
state.value = 'gone'
|
||||
}
|
||||
})
|
||||
|
||||
async function reveal() {
|
||||
busy.value = true
|
||||
try {
|
||||
const response = await toolsApi.redeemSecret(token)
|
||||
const { ciphertext, iv } = response.data.data
|
||||
plaintext.value = await openSecret({ ciphertext, iv, fragmentKey })
|
||||
state.value = 'revealed'
|
||||
} catch (err) {
|
||||
// A failed DECRYPT is different from a missing secret, and the difference
|
||||
// matters to the person reading: one means the link was mangled, the other
|
||||
// means it was already used. The secret is spent either way.
|
||||
state.value = err.response ? 'gone' : 'undecryptable'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plaintext.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch (err) {
|
||||
copied.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone: this page is opened by somebody who may have no account here, so
|
||||
it carries no navigation and nothing to click into. */
|
||||
.reveal-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.reveal-card { max-width: 32rem; width: 100%; padding: 2rem; }
|
||||
.reveal-card h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||
.muted { color: var(--text-light); font-size: 0.9rem; }
|
||||
.problem { color: var(--warning); font-weight: 600; margin: 0 0 0.5rem; }
|
||||
|
||||
.secret {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.95rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
77
plugins/tools/frontend/views/SecureSendSettings.vue
Normal file
77
plugins/tools/frontend/views/SecureSendSettings.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Secure Send</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card loading">Loading...</div>
|
||||
|
||||
<div v-else class="card">
|
||||
<label class="switch-row">
|
||||
<input v-model="allowGuests" type="checkbox" />
|
||||
<span>
|
||||
<strong>Let people without an account create links</strong>
|
||||
<span class="hint">
|
||||
Off by default. With it off, creating a link needs a login and the
|
||||
<code>tools.secrets</code> permission. Receiving one never needs an
|
||||
account either way.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- The cost, stated where the decision is made rather than in a document
|
||||
nobody opens. -->
|
||||
<p class="control-note" v-if="allowGuests">
|
||||
A link created by a guest has no creator recorded. The audit trail can
|
||||
say when it was made and from where, but never who made it.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<span v-if="saved" class="saved">Saved</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
|
||||
const KEY = 'securesend_allow_guests'
|
||||
|
||||
const allowGuests = ref(false)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'tools' })
|
||||
const rows = response.data.data || []
|
||||
const row = rows.find(setting => setting.key === KEY)
|
||||
allowGuests.value = String(row?.value).toLowerCase() === 'true'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await settingsApi.update(KEY, allowGuests.value ? 'true' : 'false')
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch-row { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 1rem; }
|
||||
.switch-row span { display: block; }
|
||||
.hint { display: block; color: var(--text-light); font-size: 0.875rem; margin-top: 4px; }
|
||||
.control-note { color: var(--warning); font-size: 0.875rem; margin: 0 0 1rem; }
|
||||
.saved { margin-left: 10px; color: var(--text-light); font-size: 0.875rem; }
|
||||
</style>
|
||||
@@ -12,6 +12,11 @@ plugin's tables, so the foreign key to the core users table cannot resolve at
|
||||
CreateTable-compile time.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
from shopdb.plugins.alembic_template import (
|
||||
create_index_if_missing,
|
||||
create_table_if_missing,
|
||||
)
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@@ -23,7 +28,7 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
create_table_if_missing(
|
||||
'securesendsecrets',
|
||||
sa.Column('secretid', sa.Integer(), nullable=False),
|
||||
# 128 bits of randomness as hex. With no login on the read side this is
|
||||
@@ -47,13 +52,13 @@ def upgrade():
|
||||
)
|
||||
# Unique, because the token is looked up on every read and two rows sharing
|
||||
# one would make a link ambiguous.
|
||||
op.create_index('ixsecuresendsecretstoken', 'securesendsecrets',
|
||||
['token'], unique=True)
|
||||
create_index_if_missing('ixsecuresendsecretstoken', 'securesendsecrets',
|
||||
['token'], unique=True)
|
||||
# Expiry sweeps scan on this.
|
||||
op.create_index('ixsecuresendsecretsexpiresat', 'securesendsecrets',
|
||||
['expiresat'])
|
||||
op.create_index('ixsecuresendsecretscreatedby', 'securesendsecrets',
|
||||
['createdbyuserid'])
|
||||
create_index_if_missing('ixsecuresendsecretsexpiresat', 'securesendsecrets',
|
||||
['expiresat'])
|
||||
create_index_if_missing('ixsecuresendsecretscreatedby', 'securesendsecrets',
|
||||
['createdbyuserid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -54,21 +54,66 @@ class ToolsPlugin(BasePlugin):
|
||||
return [SecureSecret]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""One permission, on CREATING a secret only.
|
||||
"""Who may CREATE a secret, when guests are not allowed.
|
||||
|
||||
There is deliberately no read permission: the recipient usually has no
|
||||
account here, and the unguessable token is what authorises the read.
|
||||
Gating that side would make the tool useless for the case it exists for -
|
||||
handing somebody a credential while you are on a call with them.
|
||||
The default access model: signed in, and holding this. Reading needs no
|
||||
permission at all - the recipient usually has no account here and the
|
||||
unguessable token is the authorisation.
|
||||
|
||||
No delete permission either. A secret is spent by being read or by
|
||||
expiring, and both remove the row; a hand-delete route would only add a
|
||||
way to destroy the record of a secret that had not been read yet.
|
||||
A site that turns on securesend_allow_guests bypasses this for people
|
||||
with no account; it still applies to anyone who IS signed in, so a
|
||||
member without it cannot create one while a guest can. That is
|
||||
deliberate: opening the door to guests is a decision about the door, not
|
||||
about revoking what a role does or does not hold.
|
||||
"""
|
||||
return [
|
||||
('tools.secrets', 'Create secure send links', 'tools'),
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""The one switch this plugin owns.
|
||||
|
||||
Public so the create page can read it BEFORE login and show a guest the
|
||||
form rather than a sign-in wall. It says nothing sensitive: whether the
|
||||
door is open is visible to anyone who tries the door.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'key': 'securesend_allow_guests',
|
||||
'value': 'false',
|
||||
'valuetype': 'boolean',
|
||||
'category': 'tools',
|
||||
'public': True,
|
||||
'description': ('Let people with no account create secure send '
|
||||
'links. Off by default: a guest secret has no '
|
||||
'creator, so the audit trail can say when and '
|
||||
'from where but never who. Receiving a secret '
|
||||
'never needs an account either way.'),
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
"""A page for the one switch this plugin owns (ADR-010).
|
||||
|
||||
get_settings_defaults SEEDS the row; this is what gives somebody a place
|
||||
to change it. Declaring only the first left the setting in the database
|
||||
with no way to reach it, which is indistinguishable from the feature not
|
||||
existing.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Tools',
|
||||
'to': '/settings/secure-send',
|
||||
# From the settings iconMap; an unknown key falls back to a
|
||||
# puzzle piece, which reads as "nobody chose this".
|
||||
'icon': 'wrench',
|
||||
'title': 'Secure Send',
|
||||
'description': 'Whether people without an account can create '
|
||||
'one-view password links',
|
||||
'position': 60,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[dict]:
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset..." />
|
||||
<input v-model="search" type="text" class="form-control" placeholder="Search vendor, service level, tag, asset number, machine..." />
|
||||
<span class="result-count">{{ filteredItems.length }} of {{ items.length }}</span>
|
||||
</div>
|
||||
|
||||
@@ -186,6 +186,7 @@ import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import MachineMapChip from '../components/MachineMapChip.vue'
|
||||
import { matchesWarrantySearch } from './warrantySearch'
|
||||
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
@@ -197,16 +198,11 @@ const search = ref('')
|
||||
const page = ref(1)
|
||||
const perPage = 25
|
||||
|
||||
// Client-side search across vendor / service level / tag / linked asset numbers.
|
||||
const filteredItems = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return items.value
|
||||
return items.value.filter(w =>
|
||||
(w.vendor || '').toLowerCase().includes(term) ||
|
||||
(w.servicelevel || '').toLowerCase().includes(term) ||
|
||||
(w.servicetag || '').toLowerCase().includes(term) ||
|
||||
(w.assets || []).some(a => (a.assetnumber || '').toLowerCase().includes(term)))
|
||||
})
|
||||
// Client-side search. The field list lives in warrantySearch.js, where it is
|
||||
// covered by tests - the machine number was rendered in this table for a long
|
||||
// time before anything let you search for it.
|
||||
const filteredItems = computed(() =>
|
||||
items.value.filter(w => matchesWarrantySearch(w, search.value)))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / perPage)))
|
||||
const paginatedItems = computed(() => filteredItems.value.slice((page.value - 1) * perPage, page.value * perPage))
|
||||
|
||||
|
||||
44
plugins/warranty/frontend/views/warrantySearch.js
Normal file
44
plugins/warranty/frontend/views/warrantySearch.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// What the warranty list's search box matches against.
|
||||
//
|
||||
// Pulled out of WarrantiesList.vue so the field list is somewhere a test can
|
||||
// see it. It went wrong the way a field list usually does: silently, by a
|
||||
// column being added to the table and not to the filter.
|
||||
//
|
||||
// THE MACHINE NUMBER IS THE POINT. _related_machine in the warranty API exists
|
||||
// precisely because "a shopfloor PC is bought, warranted and replaced as a PC,
|
||||
// but it is FOUND by the machine it drives - nobody walks the floor looking for
|
||||
// an asset number". The payload carries it, the table renders it in a chip, and
|
||||
// the filter did not look at it - so the one identifier the feature was built
|
||||
// around was the one thing the search box could not find.
|
||||
|
||||
/**
|
||||
* Does this warranty match the search term?
|
||||
*
|
||||
* @param {object} warranty - a row from GET /api/warranties
|
||||
* @param {string} term - the raw search box contents
|
||||
* @returns {boolean} true when the term is empty or any searched field contains it
|
||||
*/
|
||||
export function matchesWarrantySearch(warranty, term) {
|
||||
const needle = (term || '').trim().toLowerCase()
|
||||
if (!needle) return true
|
||||
if (!warranty) return false
|
||||
|
||||
const has = (value) => String(value ?? '').toLowerCase().includes(needle)
|
||||
|
||||
if (has(warranty.vendor) || has(warranty.servicelevel) || has(warranty.servicetag)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (warranty.assets || []).some((asset) => {
|
||||
if (has(asset?.assetnumber)) return true
|
||||
// The related machine is optional: a warranty on something that drives no
|
||||
// machine has none, and a machine that was never linked leaves it null.
|
||||
const machine = asset?.machine
|
||||
return !!machine && (has(machine.machinenumber) || has(machine.name))
|
||||
})
|
||||
}
|
||||
|
||||
/** The fields above, in the order the placeholder should name them. */
|
||||
export const WARRANTY_SEARCH_FIELDS = [
|
||||
'vendor', 'service level', 'tag', 'asset number', 'machine',
|
||||
]
|
||||
79
plugins/warranty/frontend/views/warrantySearch.spec.js
Normal file
79
plugins/warranty/frontend/views/warrantySearch.spec.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { matchesWarrantySearch } from './warrantySearch'
|
||||
|
||||
// Reported: the warranty page could not be searched by machine number.
|
||||
//
|
||||
// The number was in the payload (_related_machine puts it there) and rendered
|
||||
// in the table as a chip, but the filter looked only at vendor, service level,
|
||||
// service tag and asset number. So the identifier the feature was built around
|
||||
// - a PC is warranted as a PC but FOUND by the machine it drives - was the one
|
||||
// thing the box could not find.
|
||||
|
||||
function warranty(overrides = {}) {
|
||||
return {
|
||||
vendor: 'Dell',
|
||||
servicelevel: 'ProSupport',
|
||||
servicetag: 'ABC1234',
|
||||
assets: [{
|
||||
assetnumber: 'PC-00912',
|
||||
name: 'Bay 3105 workstation',
|
||||
machine: { machinenumber: '3016', name: 'Makino a61nx' },
|
||||
}],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('matchesWarrantySearch', () => {
|
||||
it('matches on the machine number', () => {
|
||||
// The reported case.
|
||||
expect(matchesWarrantySearch(warranty(), '3016')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches on the machine name', () => {
|
||||
expect(matchesWarrantySearch(warranty(), 'makino')).toBe(true)
|
||||
})
|
||||
|
||||
it('still matches the fields it always did', () => {
|
||||
expect(matchesWarrantySearch(warranty(), 'dell')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), 'prosupport')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), 'abc1234')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), 'pc-00912')).toBe(true)
|
||||
})
|
||||
|
||||
it('is case insensitive and ignores surrounding space', () => {
|
||||
expect(matchesWarrantySearch(warranty(), ' MAKINO ')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match an unrelated term', () => {
|
||||
expect(matchesWarrantySearch(warranty(), '9999')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps every row when the term is empty', () => {
|
||||
expect(matchesWarrantySearch(warranty(), '')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), ' ')).toBe(true)
|
||||
expect(matchesWarrantySearch(warranty(), undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('handles a warranty whose asset drives no machine', () => {
|
||||
// machine is null whenever the covered asset has no controls link - a
|
||||
// printer, or a PC nobody linked yet. It must not throw.
|
||||
const row = warranty({ assets: [{ assetnumber: 'PR-42', machine: null }] })
|
||||
expect(matchesWarrantySearch(row, 'PR-42')).toBe(true)
|
||||
expect(matchesWarrantySearch(row, '3016')).toBe(false)
|
||||
})
|
||||
|
||||
it('handles a warranty with no linked assets at all', () => {
|
||||
const row = warranty({ assets: [] })
|
||||
expect(matchesWarrantySearch(row, 'dell')).toBe(true)
|
||||
expect(matchesWarrantySearch(row, '3016')).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates missing fields rather than matching everything', () => {
|
||||
// String(undefined) is 'undefined', which would make a search for 'und'
|
||||
// match every row with an empty field.
|
||||
const row = { vendor: null, servicelevel: undefined, assets: null }
|
||||
expect(matchesWarrantySearch(row, 'und')).toBe(false)
|
||||
expect(matchesWarrantySearch(row, 'null')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -33,9 +33,22 @@ Dry run by default; nothing is written without --commit.
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
# Each asset table that carries both a model link and its own vendor, with the
|
||||
# column that identifies a row in reports and logs.
|
||||
TARGETS = [
|
||||
|
||||
@@ -38,11 +38,24 @@ database backup before --commit anyway; this deletes rows.
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
# Run from anywhere: put the repo root (parent of scripts/) on the path.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models import Asset
|
||||
|
||||
@@ -25,10 +25,23 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import OrderedDict
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
DEFAULT_OUT = os.path.join('shopdb', 'data', 'catalog.json')
|
||||
|
||||
|
||||
|
||||
@@ -21,10 +21,23 @@ Dry run by default. Nothing is written without --commit.
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
|
||||
@@ -19,10 +19,23 @@ Usage (on the target instance, in the app dir):
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Run from anywhere: put the repo root (parent of scripts/) on the path.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.api import db
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
|
||||
@@ -35,11 +35,24 @@ Dry run by default. Take a database backup before --commit.
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
# Run from anywhere: put the repo root (parent of scripts/) on the path.
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
from shopdb import create_app
|
||||
from shopdb.api import db, Asset, AssetRelationship, RelationshipType
|
||||
from plugins.backups.models import BackupRevision
|
||||
|
||||
@@ -33,9 +33,22 @@ import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env before anything reads config. wsgi.py does this for the served app
|
||||
# and the `flask` CLI does it for itself, but a plain `python scripts/...` run
|
||||
# gets neither: DATABASE_URL stays unset, FLASK_ENV defaults to development,
|
||||
# and config falls back to a hardcoded root@localhost URL. That fails on a real
|
||||
# deployment with a bare 1045 Access denied for 'root', naming a user the site
|
||||
# does not use - and on a box where those root credentials happen to work, it
|
||||
# is worse, because the script silently reads or WRITES the wrong database.
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / '.env')
|
||||
|
||||
|
||||
# Each asset class, its own type vocabulary, and the modeltypes.category that a
|
||||
# newly created model type should carry.
|
||||
CLASSES = {
|
||||
|
||||
@@ -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}`) },
|
||||
|
||||
239
tests/test_core/test_catalog_dedupe.py
Normal file
239
tests/test_core/test_catalog_dedupe.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Catalog hygiene: merging vendor/model/OS rows that are the same name twice.
|
||||
|
||||
Legacy data arrived carrying space-stripped twins - 'DellInc.' beside
|
||||
'Dell Inc.', 'Windows10' beside 'Windows 10'. They rode into the shipped seed
|
||||
catalog and out to every site that ran `seed catalog`, and stay invisible until
|
||||
somebody opens a vendor picker and finds the same company listed twice.
|
||||
|
||||
The distinction these tests exist to protect: a twin is the same name TYPED two
|
||||
ways and may be merged mechanically, while 'Dell' and 'Dell Inc.' are two
|
||||
different names and merging them is a judgment only a person can make.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Vendor, Model
|
||||
from shopdb.cli import _catalog_norm
|
||||
|
||||
|
||||
def _run(app, args):
|
||||
return app.test_cli_runner().invoke(args=args)
|
||||
|
||||
|
||||
# --- the normaliser draws the line ------------------------------------------
|
||||
|
||||
def test_whitespace_and_punctuation_twins_normalise_together():
|
||||
assert _catalog_norm('Dell Inc.') == _catalog_norm('DellInc.')
|
||||
assert _catalog_norm('Windows 10') == _catalog_norm('Windows10')
|
||||
assert _catalog_norm('OptiPlex 7080') == _catalog_norm('OptiPlex7080')
|
||||
|
||||
|
||||
def test_a_different_name_does_not_normalise_together():
|
||||
"""'Dell' is not a typo of 'Dell Inc.', it is a shorter name.
|
||||
|
||||
If these collapsed, `dedupe` would silently pick one and delete a name the
|
||||
site may have chosen deliberately.
|
||||
"""
|
||||
assert _catalog_norm('Dell') != _catalog_norm('Dell Inc.')
|
||||
assert _catalog_norm('Precision 5560') != _catalog_norm('Precision 5570')
|
||||
|
||||
|
||||
# --- dedupe ------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def dell_twins(db):
|
||||
"""The real shape found in production: a twin, plus a genuine third name."""
|
||||
canonical = Vendor(vendor='Dell Inc.')
|
||||
twin = Vendor(vendor='DellInc.')
|
||||
shortname = Vendor(vendor='Dell')
|
||||
db.session.add_all([canonical, twin, shortname])
|
||||
db.session.flush()
|
||||
# the twin owns a model, so the merge has a reference to repoint
|
||||
db.session.add(Model(modelnumber='OptiPlex7080', vendorid=twin.vendorid))
|
||||
db.session.add(Model(modelnumber='OptiPlex 7080', vendorid=canonical.vendorid))
|
||||
db.session.commit()
|
||||
return {'canonical': canonical.vendorid, 'twin': twin.vendorid,
|
||||
'shortname': shortname.vendorid}
|
||||
|
||||
|
||||
def test_dedupe_reports_without_changing_anything(app, dell_twins):
|
||||
"""The default is a dry run: a cleanup that writes by surprise is a trap."""
|
||||
result = _run(app, ['catalog', 'dedupe'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'DellInc.' in result.output
|
||||
assert 'Dry run' in result.output
|
||||
# every row still stands
|
||||
assert db.session.get(Vendor, dell_twins['twin']) is not None
|
||||
assert Vendor.query.filter_by(vendor='DellInc.').count() == 1
|
||||
|
||||
|
||||
def test_dedupe_merges_the_twin_and_repoints_its_model(app, dell_twins):
|
||||
"""The twin disappears and its model moves, rather than being orphaned."""
|
||||
result = _run(app, ['catalog', 'dedupe', '--apply'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert Vendor.query.filter_by(vendor='DellInc.').count() == 0
|
||||
assert Vendor.query.filter_by(vendor='Dell Inc.').count() == 1
|
||||
|
||||
# nothing points at a vendor that no longer exists
|
||||
vendorids = {v.vendorid for v in Vendor.query.all()}
|
||||
for model in Model.query.all():
|
||||
assert model.vendorid is None or model.vendorid in vendorids
|
||||
|
||||
|
||||
def test_dedupe_leaves_a_genuinely_different_name_alone(app, dell_twins):
|
||||
"""'Dell' survives. It is a naming choice, not a typo."""
|
||||
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||
|
||||
assert Vendor.query.filter_by(vendor='Dell').count() == 1
|
||||
|
||||
|
||||
def test_dedupe_keeps_the_spelling_a_person_typed(app, db):
|
||||
"""With no references either way, the spaced spelling wins.
|
||||
|
||||
Whitespace is evidence of intent: somebody pressed the space bar.
|
||||
"""
|
||||
db.session.add_all([Vendor(vendor='AcmeCorp'), Vendor(vendor='Acme Corp')])
|
||||
db.session.commit()
|
||||
|
||||
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||
|
||||
assert Vendor.query.filter_by(vendor='Acme Corp').count() == 1
|
||||
assert Vendor.query.filter_by(vendor='AcmeCorp').count() == 0
|
||||
|
||||
|
||||
def test_dedupe_keeps_the_most_referenced_row(app, db):
|
||||
"""References outrank spelling: the row in use is the one that survives."""
|
||||
bare = Vendor(vendor='ZZTop')
|
||||
spaced = Vendor(vendor='ZZ Top')
|
||||
db.session.add_all([bare, spaced])
|
||||
db.session.flush()
|
||||
for i in range(3):
|
||||
db.session.add(Model(modelnumber=f'ZZ-{i}', vendorid=bare.vendorid))
|
||||
db.session.commit()
|
||||
|
||||
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||
|
||||
assert Vendor.query.filter_by(vendor='ZZTop').count() == 1
|
||||
assert Vendor.query.filter_by(vendor='ZZ Top').count() == 0
|
||||
|
||||
|
||||
def test_dedupe_is_idempotent(app, dell_twins):
|
||||
"""Running it twice is a no-op, so it is safe in a deploy script."""
|
||||
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||
second = _run(app, ['catalog', 'dedupe', '--apply'])
|
||||
|
||||
assert second.exit_code == 0
|
||||
assert 'No catalog twins found' in second.output
|
||||
|
||||
|
||||
def test_dedupe_on_a_clean_catalog_says_so(app, db):
|
||||
db.session.add(Vendor(vendor='Solo Vendor'))
|
||||
db.session.commit()
|
||||
|
||||
result = _run(app, ['catalog', 'dedupe'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'No catalog twins found' in result.output
|
||||
|
||||
|
||||
# --- explicit merge ----------------------------------------------------------
|
||||
|
||||
def test_merge_absorbs_a_deliberately_different_name(app, dell_twins):
|
||||
"""The judgment call dedupe refuses: 'Dell' means 'Dell Inc.' at this site."""
|
||||
result = _run(app, ['catalog', 'merge', 'vendors', 'Dell', 'Dell Inc.',
|
||||
'--apply'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert Vendor.query.filter_by(vendor='Dell').count() == 0
|
||||
assert Vendor.query.filter_by(vendor='Dell Inc.').count() == 1
|
||||
|
||||
|
||||
def test_merge_dry_runs_by_default(app, dell_twins):
|
||||
result = _run(app, ['catalog', 'merge', 'vendors', 'Dell', 'Dell Inc.'])
|
||||
|
||||
assert 'Dry run' in result.output
|
||||
assert Vendor.query.filter_by(vendor='Dell').count() == 1
|
||||
|
||||
|
||||
def test_merge_refuses_to_invent_the_target(app, dell_twins):
|
||||
"""A typo in the target must not create a new vendor and move rows into it."""
|
||||
result = _run(app, ['catalog', 'merge', 'vendors', 'Dell', 'Dell Incorporated',
|
||||
'--apply'])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert 'Refusing to invent' in result.output
|
||||
assert Vendor.query.filter_by(vendor='Dell').count() == 1
|
||||
|
||||
|
||||
def test_merge_reports_a_missing_source_without_failing(app, dell_twins):
|
||||
"""Already merged is not an error: the deploy script can just run again."""
|
||||
result = _run(app, ['catalog', 'merge', 'vendors', 'Nonexistent',
|
||||
'Dell Inc.', '--apply'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert 'No vendors row named' in result.output
|
||||
|
||||
|
||||
# --- rename ------------------------------------------------------------------
|
||||
|
||||
def test_rename_fixes_a_stripped_name_with_no_twin(app, db):
|
||||
"""'OptiPlex5000' has nothing to merge into, so dedupe cannot reach it."""
|
||||
vendor = Vendor(vendor='Dell Inc.')
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
db.session.add(Model(modelnumber='OptiPlex5000', vendorid=vendor.vendorid))
|
||||
db.session.commit()
|
||||
|
||||
_run(app, ['catalog', 'dedupe', '--apply'])
|
||||
assert Model.query.filter_by(modelnumber='OptiPlex5000').count() == 1, \
|
||||
'dedupe should not touch a name with no twin'
|
||||
|
||||
result = _run(app, ['catalog', 'rename', 'models', 'OptiPlex5000',
|
||||
'OptiPlex 5000', '--apply'])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert Model.query.filter_by(modelnumber='OptiPlex 5000').count() == 1
|
||||
assert Model.query.filter_by(modelnumber='OptiPlex5000').count() == 0
|
||||
|
||||
|
||||
def test_rename_keeps_the_row_and_its_references(app, db):
|
||||
"""A rename is in place: the id does not change, so nothing is repointed."""
|
||||
vendor = Vendor(vendor='Acme')
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
model = Model(modelnumber='Widget100', vendorid=vendor.vendorid)
|
||||
db.session.add(model)
|
||||
db.session.commit()
|
||||
original = model.modelnumberid
|
||||
|
||||
_run(app, ['catalog', 'rename', 'models', 'Widget100', 'Widget 100', '--apply'])
|
||||
|
||||
renamed = Model.query.filter_by(modelnumber='Widget 100').one()
|
||||
assert renamed.modelnumberid == original
|
||||
assert renamed.vendorid == vendor.vendorid
|
||||
|
||||
|
||||
def test_rename_refuses_when_the_target_already_exists(app, db):
|
||||
"""Renaming onto an existing name would need a merge, not an UPDATE."""
|
||||
db.session.add_all([Vendor(vendor='Acme'), Vendor(vendor='Acme Corp')])
|
||||
db.session.commit()
|
||||
|
||||
result = _run(app, ['catalog', 'rename', 'vendors', 'Acme', 'Acme Corp',
|
||||
'--apply'])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert 'use `catalog merge`' in result.output
|
||||
assert Vendor.query.filter_by(vendor='Acme').count() == 1
|
||||
|
||||
|
||||
def test_rename_dry_runs_by_default(app, db):
|
||||
db.session.add(Vendor(vendor='Acme'))
|
||||
db.session.commit()
|
||||
|
||||
result = _run(app, ['catalog', 'rename', 'vendors', 'Acme', 'Acme Corp'])
|
||||
|
||||
assert 'Dry run' in result.output
|
||||
assert Vendor.query.filter_by(vendor='Acme').count() == 1
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Tests for the computers collector PC->printer relationship sync.
|
||||
"""The computers collector must NEVER write a printer assignment.
|
||||
|
||||
A collector payload carrying defaultprinter/printers creates AssetRelationship
|
||||
rows linking the PC asset to the resolved printer assets ('defaultprinter' for
|
||||
the default, 'connectedto' for the rest). Stale collector links archive on
|
||||
re-report; manual links survive; unresolved identifiers warn without failing.
|
||||
This file used to pin the opposite: `_sync_printer_links` turned a payload's
|
||||
`defaultprinter`/`printers` into AssetRelationship rows. That contradicted the
|
||||
rule the printer feature rests on - ASSIGNED and OBSERVED never mix - because a
|
||||
bay that installed the wrong printer would have made itself right just by
|
||||
reporting it.
|
||||
|
||||
It was not dormant. The collector schema advertised both keys and
|
||||
COLLECTOR-INTEGRATION.md's example client set `defaultprinter`, while another
|
||||
page of the same document promised nothing on this path writes an assignment
|
||||
row. Retired 2026-08-24; these tests are the guard that it stays retired.
|
||||
|
||||
Observed queues have their own path (POST /api/collector/printers) and become
|
||||
assignments only when a person asks, via seed-from-observed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import (
|
||||
@@ -81,119 +89,75 @@ def _active_links(db, pcid):
|
||||
sourceassetid=pcid, isactive=True).all()
|
||||
|
||||
|
||||
def test_creates_default_and_connected_links(client, db, collector_key, scene):
|
||||
"""Default -> defaultprinter, others -> connectedto, resolved by three id forms."""
|
||||
resp = _post(client, {
|
||||
'hostname': 'WJPC-P1',
|
||||
'defaultprinter': 'HP-Materials', # windowsname
|
||||
'printers': ['prn-front.wjs.local', '10.1.2.3'], # hostname, IP
|
||||
})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['printerlinkcount'] == 3
|
||||
assert not data['warnings']
|
||||
|
||||
pc = _pc_asset(db)
|
||||
links = {(r.targetassetid, r.relationshiptype.relationshiptype)
|
||||
for r in _active_links(db, pc.assetid)}
|
||||
assert (scene['a'].assetid, 'defaultprinter') in links
|
||||
assert (scene['b'].assetid, 'connectedto') in links
|
||||
assert (scene['c'].assetid, 'connectedto') in links
|
||||
|
||||
|
||||
def test_unresolved_identifier_warns_not_fails(client, db, collector_key, scene):
|
||||
"""An unknown printer identifier is a warning, the rest still link."""
|
||||
resp = _post(client, {
|
||||
'hostname': 'WJPC-P2',
|
||||
def test_printer_keys_create_no_relationships(client, db, collector_key, scene):
|
||||
"""The reported case, inverted: sending them must change nothing."""
|
||||
response = _post(client, {
|
||||
'hostname': 'BAY-3105-PC',
|
||||
'defaultprinter': 'HP-Materials',
|
||||
'printers': ['does-not-exist'],
|
||||
'printers': ['HP-Materials', 'prn-front.wjs.local', '10.1.2.3'],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()['data']
|
||||
assert any('unresolved printer: does-not-exist' in w
|
||||
for w in data['warnings'])
|
||||
pc = _pc_asset(db)
|
||||
assert data['printerlinkcount'] == 1
|
||||
assert len(_active_links(db, pc.assetid)) == 1
|
||||
assert response.status_code in (200, 201), response.get_json()
|
||||
|
||||
pcid = _pc_asset(db).assetid
|
||||
assert _active_links(db, pcid) == [], (
|
||||
'a collector report wrote printer relationships')
|
||||
|
||||
|
||||
def test_idempotent_no_duplicates(client, db, collector_key, scene):
|
||||
"""Re-report the same set: no new rows, still one link per printer."""
|
||||
payload = {'hostname': 'WJPC-P3', 'defaultprinter': 'HP-Materials',
|
||||
'printers': ['Zebra-Ship']}
|
||||
assert _post(client, payload).status_code == 200
|
||||
assert _post(client, payload).status_code == 200
|
||||
def test_the_push_still_succeeds_and_says_why(client, db, collector_key, scene):
|
||||
"""Warn, do not reject.
|
||||
|
||||
pc = _pc_asset(db)
|
||||
all_rows = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).all()
|
||||
assert len(all_rows) == 2 # one default + one connected, no dupes
|
||||
assert all(r.isactive for r in all_rows)
|
||||
Unknown keys are not validated, so a client written against the old docs
|
||||
keeps pushing successfully - only its printer keys stop doing anything.
|
||||
Silently dropping them would leave an integrator believing assignments were
|
||||
still being written, which is worse than the original bug.
|
||||
"""
|
||||
response = _post(client, {
|
||||
'hostname': 'BAY-3105-PC',
|
||||
'defaultprinter': 'HP-Materials',
|
||||
})
|
||||
assert response.status_code in (200, 201)
|
||||
warnings = ' '.join(response.get_json()['data']['warnings']).lower()
|
||||
assert 'defaultprinter' in warnings
|
||||
assert 'seed-from-observed' in warnings, (
|
||||
'the warning should say where assignment actually comes from')
|
||||
|
||||
|
||||
def test_dropped_printer_is_archived(client, db, collector_key, scene):
|
||||
"""A printer that stops being reported loses its collector link."""
|
||||
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials',
|
||||
'printers': ['Zebra-Ship']})
|
||||
pc = _pc_asset(db)
|
||||
assert len(_active_links(db, pc.assetid)) == 2
|
||||
|
||||
# Re-report without Zebra-Ship.
|
||||
_post(client, {'hostname': 'WJPC-P4', 'defaultprinter': 'HP-Materials',
|
||||
'printers': []})
|
||||
active = _active_links(db, pc.assetid)
|
||||
assert len(active) == 1
|
||||
assert active[0].targetassetid == scene['a'].assetid
|
||||
# the dropped row still exists but is inactive
|
||||
zebra = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
|
||||
assert zebra is not None and zebra.isactive is False
|
||||
def test_a_payload_without_printer_keys_warns_about_nothing(
|
||||
client, db, collector_key, scene):
|
||||
"""The normal case must stay quiet - this is every push in the fleet."""
|
||||
response = _post(client, {'hostname': 'BAY-3105-PC'})
|
||||
assert response.status_code in (200, 201)
|
||||
warnings = ' '.join(response.get_json()['data']['warnings']).lower()
|
||||
assert 'printer' not in warnings
|
||||
|
||||
|
||||
def test_default_change_archives_old_default(client, db, collector_key, scene):
|
||||
"""Changing the default printer archives the previous defaultprinter link."""
|
||||
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'HP-Materials'})
|
||||
pc = _pc_asset(db)
|
||||
_post(client, {'hostname': 'WJPC-P5', 'defaultprinter': 'prn-front.wjs.local'})
|
||||
|
||||
active = _active_links(db, pc.assetid)
|
||||
assert len(active) == 1
|
||||
assert active[0].targetassetid == scene['b'].assetid
|
||||
assert active[0].relationshiptype.relationshiptype == 'defaultprinter'
|
||||
|
||||
|
||||
def test_manual_link_survives_collector_sync(client, db, collector_key, scene):
|
||||
"""A manually-created link (no collector label) is never archived."""
|
||||
# PC created first via a bare report.
|
||||
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials',
|
||||
'printers': ['Zebra-Ship']})
|
||||
pc = _pc_asset(db)
|
||||
|
||||
# Manual connectedto link PC -> printer B (label stays NULL).
|
||||
def test_a_manual_assignment_survives_a_report_that_names_printers(
|
||||
client, db, collector_key, scene):
|
||||
"""The row a person made is the one that matters, and it stays untouched."""
|
||||
_post(client, {'hostname': 'BAY-3105-PC'})
|
||||
pcasset = _pc_asset(db)
|
||||
manual = AssetRelationship(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['b'].assetid,
|
||||
relationshiptypeid=scene['ct_type'].relationshiptypeid, notes='by hand')
|
||||
sourceassetid=pcasset.assetid, targetassetid=scene['a'].assetid,
|
||||
relationshiptypeid=scene['dp_type'].relationshiptypeid)
|
||||
db.session.add(manual)
|
||||
db.session.commit()
|
||||
manual_id = manual.relationshipid
|
||||
|
||||
# Re-report dropping Zebra-Ship and never mentioning printer B.
|
||||
_post(client, {'hostname': 'WJPC-P6', 'defaultprinter': 'HP-Materials',
|
||||
'printers': []})
|
||||
_post(client, {
|
||||
'hostname': 'BAY-3105-PC',
|
||||
'defaultprinter': 'prn-front.wjs.local', # a DIFFERENT printer
|
||||
'printers': ['prn-front.wjs.local'],
|
||||
})
|
||||
|
||||
surviving = db.session.get(AssetRelationship, manual_id)
|
||||
assert surviving.isactive is True # manual link untouched
|
||||
# Zebra (collector) archived.
|
||||
zebra = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, targetassetid=scene['c'].assetid).first()
|
||||
assert zebra.isactive is False
|
||||
links = _active_links(db, pcasset.assetid)
|
||||
assert len(links) == 1
|
||||
assert links[0].targetassetid == scene['a'].assetid, (
|
||||
'the report retargeted a human-made default printer')
|
||||
|
||||
|
||||
def test_no_printer_keys_leaves_links_untouched(client, db, collector_key, scene):
|
||||
"""A report with no printer keys does not archive existing links."""
|
||||
_post(client, {'hostname': 'WJPC-P7', 'defaultprinter': 'HP-Materials'})
|
||||
pc = _pc_asset(db)
|
||||
assert len(_active_links(db, pc.assetid)) == 1
|
||||
def test_the_retired_keys_are_gone_from_the_collector_schema(app):
|
||||
"""The schema advertised them, which is how integrators found them."""
|
||||
from plugins.computers.plugin import ComputersPlugin
|
||||
|
||||
# A plain update (no defaultprinter, no printers key) must not archive.
|
||||
_post(client, {'hostname': 'WJPC-P7', 'currentuser': 'alice'})
|
||||
assert len(_active_links(db, pc.assetid)) == 1
|
||||
fields = ComputersPlugin().get_collector_schema()['fields']
|
||||
assert 'defaultprinter' not in fields
|
||||
assert 'printers' not in fields
|
||||
|
||||
@@ -199,3 +199,70 @@ def test_a_machine_is_found_by_its_maintenance_reference(client, taggedmachine):
|
||||
type - a field the UI collects and search could not find."""
|
||||
rows = [r for r in search(client, 'ZZMAINT888') if r['title'] == 'ZZMACH01']
|
||||
assert rows, 'a maintenance reference matched nothing'
|
||||
|
||||
|
||||
# --- a search result must land on the record, not its list -------------------
|
||||
#
|
||||
# A result carries an id, so sending the reader to the plugin's list page makes
|
||||
# them find by hand the row search had already identified. Both cases below
|
||||
# shipped that way: the id was in the payload and the URL ignored it.
|
||||
|
||||
@pytest.fixture
|
||||
def noticerow(db):
|
||||
"""One notification with a distinctive body and its own type."""
|
||||
from plugins.notifications.models import Notification, NotificationType
|
||||
notice = NotificationType(typename='Notice', typecolor='#17a2b8',
|
||||
isactive=True)
|
||||
db.session.add(notice)
|
||||
db.session.flush()
|
||||
row = Notification(
|
||||
notificationtypeid=notice.notificationtypeid,
|
||||
notification='ZZNOTICE7788 press brake down for repair',
|
||||
ticketnumber='INC7788',
|
||||
isactive=True,
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row.notificationid
|
||||
|
||||
|
||||
def test_a_notice_result_links_to_the_notice(client, noticerow):
|
||||
"""Clicking a NOTICE opens that notification, not /notifications.
|
||||
|
||||
`/notifications/<id>` is the `notification-detail` route; the list page
|
||||
cannot show which of its rows the search matched.
|
||||
"""
|
||||
rows = [r for r in search(client, 'ZZNOTICE7788')
|
||||
if r['type'] == 'notification']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == noticerow
|
||||
assert rows[0]['url'] == f'/notifications/{noticerow}'
|
||||
|
||||
|
||||
def test_a_notice_found_by_ticket_number_links_the_same_way(client, noticerow):
|
||||
"""The ticket number is the strongest match and must land as precisely."""
|
||||
rows = [r for r in search(client, 'INC7788')
|
||||
if r['type'] == 'notification']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['url'] == f'/notifications/{noticerow}'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subnetrow(db):
|
||||
"""A subnet an IP search can fall inside."""
|
||||
from plugins.network.models import Subnet
|
||||
row = Subnet(cidr='10.77.88.0/24', name='ZZ Test subnet', isactive=True)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row.subnetid
|
||||
|
||||
|
||||
def test_an_ip_search_links_to_the_matched_subnet(client, subnetrow):
|
||||
"""Searching an IP already picked WHICH subnet; land on it.
|
||||
|
||||
The detail route is `/networks/<id>` (plural) - `/network` is the hub.
|
||||
"""
|
||||
rows = [r for r in search(client, '10.77.88.42') if r['type'] == 'subnet']
|
||||
assert len(rows) == 1
|
||||
assert rows[0]['id'] == subnetrow
|
||||
assert rows[0]['url'] == f'/networks/{subnetrow}'
|
||||
|
||||
94
tests/test_maintenance_scripts.py
Normal file
94
tests/test_maintenance_scripts.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Standalone maintenance scripts must load .env before reading config.
|
||||
|
||||
`wsgi.py` calls `load_dotenv()` for the served app, and the `flask` CLI loads
|
||||
.env for itself, so both reach the site's real database. A plain
|
||||
`python scripts/<name>.py` run gets neither. Without an explicit load,
|
||||
DATABASE_URL is unset, FLASK_ENV defaults to 'development', and config.py falls
|
||||
back to a hardcoded `root@localhost` URL.
|
||||
|
||||
On a real deployment that surfaced as a bare 1045 "Access denied for user
|
||||
'root'@'localhost'" from `export_catalog.py`, naming a user the site does not
|
||||
use. The read-only case is merely confusing. The dangerous case is a box where
|
||||
those root credentials DO resolve: six of these scripts write, and they would
|
||||
have silently rewritten the wrong database.
|
||||
|
||||
This went unnoticed for so long because the dev .env happens to point at root,
|
||||
so the fallback matched what the developer expected.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent.parent / 'scripts'
|
||||
|
||||
|
||||
def _scripts_that_build_the_app():
|
||||
"""Every script that calls create_app, so it resolves a database URL."""
|
||||
found = []
|
||||
for path in sorted(SCRIPTS_DIR.glob('*.py')):
|
||||
if 'create_app' in path.read_text(encoding='utf-8'):
|
||||
found.append(path)
|
||||
return found
|
||||
|
||||
|
||||
def test_there_are_scripts_to_check():
|
||||
"""Guard the guard: a glob that matches nothing passes every test below."""
|
||||
assert _scripts_that_build_the_app(), \
|
||||
'no scripts call create_app - has the layout moved?'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', _scripts_that_build_the_app(),
|
||||
ids=lambda p: p.name)
|
||||
def test_script_loads_dotenv_before_building_the_app(path):
|
||||
"""The script calls load_dotenv, and does so before create_app runs.
|
||||
|
||||
Order matters as much as presence: config is read when create_app builds
|
||||
the app, so a load_dotenv underneath it changes nothing.
|
||||
"""
|
||||
source = path.read_text(encoding='utf-8')
|
||||
assert 'load_dotenv' in source, (
|
||||
f'{path.name} builds the app but never loads .env, so a plain '
|
||||
f'python run falls back to the hardcoded root@localhost URL'
|
||||
)
|
||||
|
||||
tree = ast.parse(source)
|
||||
load_lines = [
|
||||
node.lineno for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and getattr(node.func, 'id', None) == 'load_dotenv'
|
||||
]
|
||||
assert load_lines, f'{path.name} imports load_dotenv but never calls it'
|
||||
|
||||
create_lines = [
|
||||
node.lineno for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Call)
|
||||
and getattr(node.func, 'id', None) == 'create_app'
|
||||
]
|
||||
assert create_lines, f'{path.name} references create_app but never calls it'
|
||||
|
||||
assert min(load_lines) < min(create_lines), (
|
||||
f'{path.name} calls load_dotenv at line {min(load_lines)}, after '
|
||||
f'create_app at line {min(create_lines)} - config is already read'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', _scripts_that_build_the_app(),
|
||||
ids=lambda p: p.name)
|
||||
def test_dotenv_is_located_from_the_script_not_the_cwd(path):
|
||||
"""load_dotenv is given an explicit repo-root path.
|
||||
|
||||
A bare `load_dotenv()` searches upward from the CURRENT DIRECTORY, so the
|
||||
same command works from the repo root and silently finds nothing when run
|
||||
from anywhere else - the failure these scripts are most likely to hit,
|
||||
since they are run by hand during a deployment.
|
||||
"""
|
||||
tree = ast.parse(path.read_text(encoding='utf-8'))
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call)
|
||||
and getattr(node.func, 'id', None) == 'load_dotenv'):
|
||||
assert node.args or node.keywords, (
|
||||
f'{path.name} calls load_dotenv() with no path, so it resolves '
|
||||
f'.env relative to the working directory'
|
||||
)
|
||||
@@ -276,3 +276,89 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
|
||||
|
||||
# Plugins whose whole chain is a single baseline. Their tables existing already
|
||||
# is the ONLY divergence possible between schema and stamp, so upgrade-all must
|
||||
# handle it end to end. A longer chain can also meet an ALTER whose column is
|
||||
# already present, which no amount of guarding a create_table fixes - see the
|
||||
# test below for why that is a different problem.
|
||||
SINGLE_BASELINE_PLUGINS = tuple(
|
||||
plugin for plugin in POST_CUTOVER_PLUGINS
|
||||
if len(list((PLUGINS_DIR / plugin / 'migrations' / 'versions').glob('0*.py'))) == 1
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('plugin', SINGLE_BASELINE_PLUGINS)
|
||||
def test_baseline_skips_tables_that_already_exist(plugin, tmp_path, monkeypatch):
|
||||
"""A baseline must stamp, not fail, when its table is already there.
|
||||
|
||||
Reported: `flask plugin upgrade-all` failed the tools plugin with "table
|
||||
securesendsecrets already exists". Every post-cutover baseline was a plain
|
||||
op.create_table, correct only while a table's existence and its plugin's
|
||||
Alembic stamp can never disagree. They can - anything that builds tables
|
||||
from the models (db.create_all) stamps nothing, and a run that creates a
|
||||
table then fails before its stamp commits leaves the same split.
|
||||
|
||||
The other upgrade-all test DROPS these tables before running, so it only
|
||||
ever covered the state where they are absent. This is the other state.
|
||||
"""
|
||||
from sqlalchemy import inspect, text
|
||||
from shopdb.config import TestingConfig
|
||||
from shopdb import create_app
|
||||
from shopdb.extensions import db
|
||||
from shopdb.plugins import plugin_manager
|
||||
|
||||
monkeypatch.setattr(TestingConfig, 'SQLALCHEMY_DATABASE_URI',
|
||||
f'sqlite:///{tmp_path / "preexisting.db"}')
|
||||
|
||||
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes)
|
||||
try:
|
||||
app = create_app('testing')
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Leave THIS plugin's tables in place - that is the whole point -
|
||||
# and drop the other post-cutover ones so their own (longer) chains
|
||||
# run the way the sibling test already covers.
|
||||
insp0 = inspect(db.engine)
|
||||
for other in POST_CUTOVER_PLUGINS:
|
||||
if other == plugin:
|
||||
continue
|
||||
for tablename in PLUGIN_TABLE_OWNERS[other]:
|
||||
if insp0.has_table(tablename):
|
||||
db.session.execute(text(f'DROP TABLE {tablename}'))
|
||||
db.session.commit()
|
||||
|
||||
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
|
||||
assert inspect(db.engine).has_table(tablename), (
|
||||
f'{tablename} should have been built by create_all; '
|
||||
f'this test is meaningless without it')
|
||||
|
||||
first = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
assert first[plugin] == 'ok', first[plugin]
|
||||
|
||||
second = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
assert second[plugin] == 'ok', second[plugin]
|
||||
|
||||
insp = inspect(db.engine)
|
||||
row = db.session.execute(
|
||||
text(f'SELECT version_num FROM alembic_version_{plugin}')
|
||||
).fetchone()
|
||||
assert row and row[0] == EXPECTED_HEAD_REVISION[plugin]
|
||||
|
||||
# No second index over the same columns: create_all names indexes by
|
||||
# SQLAlchemy's convention and the baseline by this repo's, so a
|
||||
# name-keyed guard would have added a duplicate rather than skipped.
|
||||
for tablename in PLUGIN_TABLE_OWNERS[plugin]:
|
||||
seen = [tuple(ix.get('column_names') or ())
|
||||
for ix in insp.get_indexes(tablename)]
|
||||
assert len(seen) == len(set(seen)), (
|
||||
f'{tablename} has two indexes over the same columns: {seen}')
|
||||
finally:
|
||||
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
|
||||
plugin_manager.loader, plugin_manager.migration_manager,
|
||||
plugin_manager._registered_prefixes) = saved
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"""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.
|
||||
`flask plugin new <name>` generates the backend skeleton and a matching Vue
|
||||
frontend starting point: list, detail, and form views, a route file, and a
|
||||
paste-in api-client snippet. All of it lands INSIDE the plugin, under
|
||||
`plugins/<name>/frontend/`, which is what lets stage-frontend.mjs stage it and
|
||||
a lean build prune it (ADR-013).
|
||||
|
||||
These tests guard the file names and their location, the substitutions, the
|
||||
ADR-009 plugin gating on the routes, the alias-vs-relative import split that
|
||||
has to survive staging, the use of global CSS classes over per-page input
|
||||
styling, and the no-clobber behaviour.
|
||||
|
||||
All generation runs against tmp_path. The real frontend/src tree is never
|
||||
touched.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -18,178 +23,172 @@ 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).
|
||||
"""
|
||||
def _make_plugins_dir(tmp_path: Path) -> Path:
|
||||
"""Create a tmp plugins 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
|
||||
return plugins_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)
|
||||
def test_frontend_files_generated_inside_the_plugin(tmp_path):
|
||||
"""Views, route file, and api snippet appear with the expected names.
|
||||
|
||||
Location is the point of the test: the frontend belongs to the plugin, not
|
||||
to the core tree.
|
||||
"""
|
||||
plugins_dir = _make_plugins_dir(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' / 'views' / 'WidgetsList.vue').exists()
|
||||
assert (target / 'frontend' / 'views' / 'WidgetsDetail.vue').exists()
|
||||
assert (target / 'frontend' / 'views' / 'WidgetsForm.vue').exists()
|
||||
assert (target / 'frontend' / 'routes.js').exists()
|
||||
assert (target / 'frontend-api-snippet.js').exists()
|
||||
|
||||
|
||||
def test_nothing_is_written_into_the_core_frontend_tree(tmp_path):
|
||||
"""The scaffolder never touches frontend/src.
|
||||
|
||||
This is the regression that prompted the move. Writing views to
|
||||
`frontend/src/views/<name>/` and a route to
|
||||
`frontend/src/router/routes/<name>.js` still *renders* - the router globs
|
||||
that directory - but the result is invisible to stage-frontend.mjs, so the
|
||||
plugin cannot be pruned from a lean build and deleting it strands its
|
||||
views in the core tree.
|
||||
"""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
core_frontend = tmp_path / 'frontend' / 'src'
|
||||
core_frontend.mkdir(parents=True)
|
||||
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
assert not (core_frontend / 'views' / 'widgets').exists()
|
||||
assert not (core_frontend / 'router' / 'routes' / 'widgets.js').exists()
|
||||
assert list(core_frontend.iterdir()) == []
|
||||
|
||||
|
||||
def test_frontend_generated_with_no_core_tree_at_all(tmp_path):
|
||||
"""A plugin developed in its own repo gets the same frontend.
|
||||
|
||||
There is no core frontend tree to depend on, and none is fabricated. The
|
||||
plugin's frontend starts working the moment the directory is dropped into
|
||||
a host repo's `plugins/`.
|
||||
"""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
assert (target / 'frontend' / 'routes.js').exists()
|
||||
assert (target / 'frontend' / 'views' / 'WidgetsList.vue').exists()
|
||||
assert not (tmp_path / 'frontend').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)
|
||||
"""Name and Name substitutions land in the route file and the views."""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_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
|
||||
route = (target / 'frontend' / 'routes.js').read_text()
|
||||
assert "import('./views/WidgetsList.vue')" in route
|
||||
assert "import('./views/WidgetsForm.vue')" in route
|
||||
assert "import('./views/WidgetsDetail.vue')" in route
|
||||
|
||||
list_view = (frontend_dir / 'views' / 'widgets' / 'WidgetsList.vue').read_text()
|
||||
list_view = (target / 'frontend' / 'views' / '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
|
||||
# No unresolved placeholders for our known substitution keys. Checked by
|
||||
# key, not for a bare '${': the views carry real JS template literals
|
||||
# (`/widgets/${item.assetid}`) that must survive.
|
||||
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)
|
||||
"""ADR-009 gating on every route; requiresAuth on the form routes."""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||
|
||||
# ADR-009 gating on every route
|
||||
route = (target / 'frontend' / 'routes.js').read_text()
|
||||
# every route is gated on the plugin being enabled
|
||||
assert route.count("plugin: 'widgets'") == 4
|
||||
# form routes (new + edit) require auth; list + detail do not
|
||||
# create and edit are behind auth
|
||||
assert route.count('requiresAuth: true') == 2
|
||||
assert "path: 'widgets/new'" in route
|
||||
assert "path: 'widgets/:id/edit'" in route
|
||||
|
||||
|
||||
def test_route_file_exports_toplevel(tmp_path):
|
||||
"""`toplevel` is exported even when empty.
|
||||
|
||||
routes.gen.js reads `.toplevel` off every staged route module; a module
|
||||
without the export is a Rollup warning under strict ESM.
|
||||
"""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
route = (target / 'frontend' / 'routes.js').read_text()
|
||||
assert 'export const toplevel' in route
|
||||
|
||||
|
||||
def test_view_imports_survive_staging(tmp_path):
|
||||
"""Core imports use the @ alias; sibling views are relative.
|
||||
|
||||
stage-frontend.mjs copies `plugins/<name>/frontend/` to
|
||||
`frontend/src/.plugins-staged/<name>/`, so a `../../api` written for the
|
||||
old location resolves somewhere else entirely once staged. `@/api` is
|
||||
correct in both places, and `./views/...` travels with the route file.
|
||||
"""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
views = target / 'frontend' / 'views'
|
||||
for view in views.iterdir():
|
||||
text = view.read_text()
|
||||
assert '../../api' not in text
|
||||
assert '../../components' not in text
|
||||
assert '../../stores' not in text
|
||||
|
||||
route = (target / 'frontend' / 'routes.js').read_text()
|
||||
assert './views/' in route
|
||||
assert '../../views' not 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 lean on the global form/filter styles, not per-page input CSS."""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
views = frontend_dir / 'views' / 'widgets'
|
||||
views = target / 'frontend' / 'views'
|
||||
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
|
||||
assert '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)
|
||||
plugins_dir = _make_plugins_dir(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').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()
|
||||
# backend skeleton is still intact
|
||||
assert (target / 'manifest.json').exists()
|
||||
assert (target / 'plugin.py').exists()
|
||||
|
||||
|
||||
def test_route_references_only_existing_views(tmp_path):
|
||||
@@ -198,55 +197,54 @@ def test_route_references_only_existing_views(tmp_path):
|
||||
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)
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
route = (frontend_dir / 'router' / 'routes' / 'widgets.js').read_text()
|
||||
import re
|
||||
imported = re.findall(r"import\('(\.\./\.\./views/[^']+)'\)", route)
|
||||
route_dir = target / 'frontend'
|
||||
route = (route_dir / 'routes.js').read_text()
|
||||
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}'
|
||||
# rel is relative to the route file, which sits at frontend/routes.js
|
||||
assert (route_dir / rel).resolve().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,
|
||||
)
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_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' / 'views' / 'WidgetsList.vue',
|
||||
target / 'frontend' / 'views' / 'WidgetsDetail.vue',
|
||||
target / 'frontend' / 'views' / 'WidgetsForm.vue',
|
||||
target / 'frontend' / 'routes.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()
|
||||
path.read_text().encode('ascii') # raises if any non-ASCII slipped in
|
||||
|
||||
|
||||
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)
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
scaffold_plugin('widgets', 'first', plugins_dir)
|
||||
|
||||
with pytest.raises(ScaffoldError, match='already exists'):
|
||||
scaffold_plugin(
|
||||
'widgets', 'second', plugins_dir, frontend_dir=frontend_dir,
|
||||
)
|
||||
scaffold_plugin('widgets', 'second', plugins_dir)
|
||||
|
||||
|
||||
def test_overwrite_replaces_existing_frontend(tmp_path):
|
||||
"""--overwrite regenerates the plugin's frontend files."""
|
||||
plugins_dir = _make_plugins_dir(tmp_path)
|
||||
target = scaffold_plugin('widgets', 'Test', plugins_dir)
|
||||
|
||||
list_view = target / 'frontend' / 'views' / 'WidgetsList.vue'
|
||||
list_view.write_text('CLOBBER ME')
|
||||
|
||||
scaffold_plugin('widgets', 'Test', plugins_dir, overwrite=True)
|
||||
|
||||
regenerated = list_view.read_text()
|
||||
assert regenerated != 'CLOBBER ME'
|
||||
assert 'widgetsApi' in regenerated
|
||||
|
||||
@@ -159,3 +159,86 @@ def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers):
|
||||
assert spent.status_code == never.status_code == 404
|
||||
assert (spent.get_json()['data']['error']['message']
|
||||
== never.get_json()['data']['error']['message'])
|
||||
|
||||
|
||||
# ------------------------------------------------- guests, when a site allows
|
||||
|
||||
def _allow_guests(db, allowed=True):
|
||||
from shopdb.core.models import Setting
|
||||
row = Setting.query.filter_by(key='securesend_allow_guests').first()
|
||||
if row is None:
|
||||
row = Setting(key='securesend_allow_guests', valuetype='boolean',
|
||||
category='tools')
|
||||
db.session.add(row)
|
||||
row.value = 'true' if allowed else 'false'
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_a_guest_is_refused_by_default(client, db):
|
||||
"""Default OFF. An unauthenticated write endpoint is not something to get
|
||||
by accident."""
|
||||
_allow_guests(db, False)
|
||||
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
|
||||
assert resp.status_code in (401, 403, 422)
|
||||
|
||||
|
||||
def test_a_guest_can_create_once_a_site_allows_it(client, db):
|
||||
_allow_guests(db, True)
|
||||
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
|
||||
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
||||
assert resp.get_json()['data']['token']
|
||||
|
||||
|
||||
def test_a_guest_secret_has_no_creator(client, db):
|
||||
"""The cost of the setting, pinned: the audit trail can say when and from
|
||||
where, but never who."""
|
||||
_allow_guests(db, True)
|
||||
token = client.post('/api/tools/secrets',
|
||||
json={'ciphertext': CIPHER, 'iv': IV}
|
||||
).get_json()['data']['token']
|
||||
secret = SecureSecret.query.filter_by(token=token).first()
|
||||
assert secret.createdbyuserid is None
|
||||
|
||||
|
||||
def test_guest_creation_is_rate_limited(client, db):
|
||||
"""An open endpoint must not be an easy place to park data."""
|
||||
from plugins.tools.api.routes import GUEST_CREATE_MAX
|
||||
_allow_guests(db, True)
|
||||
codes = [client.post('/api/tools/secrets',
|
||||
json={'ciphertext': CIPHER, 'iv': IV}).status_code
|
||||
for _ in range(GUEST_CREATE_MAX + 2)]
|
||||
assert 429 in codes, codes
|
||||
|
||||
|
||||
def test_a_signed_in_user_still_needs_the_permission(client, db, member_headers):
|
||||
"""Opening the door to guests is a decision about the DOOR, not about
|
||||
revoking what a role holds."""
|
||||
_allow_guests(db, True)
|
||||
resp = client.post('/api/tools/secrets',
|
||||
json={'ciphertext': CIPHER, 'iv': IV},
|
||||
headers=member_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- retention
|
||||
|
||||
def test_an_expired_secret_nobody_opened_is_swept(client, db, auth_headers):
|
||||
"""Nothing removed a secret that was never clicked: the recipient goes on
|
||||
holiday and the ciphertext sits there for good."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
token = _create(client, auth_headers).get_json()['data']['token']
|
||||
secret = SecureSecret.query.filter_by(token=token).first()
|
||||
secret.expiresat = (datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
- timedelta(hours=1))
|
||||
db.session.commit()
|
||||
|
||||
# Any later create sweeps it, without a scheduler existing anywhere.
|
||||
_create(client, auth_headers)
|
||||
assert SecureSecret.query.filter_by(token=token).first() is None
|
||||
|
||||
|
||||
def test_a_custom_view_count_is_allowed(client, auth_headers):
|
||||
from plugins.tools.api.routes import MAX_VIEWS
|
||||
assert _create(client, auth_headers, views=25).status_code in (200, 201)
|
||||
assert _create(client, auth_headers, views=MAX_VIEWS).status_code in (200, 201)
|
||||
assert _create(client, auth_headers, views=MAX_VIEWS + 1).status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user