Assign printers to a machine, and let the PC that drives it inherit them
Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.
New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.
Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.
GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".
PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.
One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.
printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
This commit is contained in:
@@ -638,6 +638,464 @@ def pc_default_printer():
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Printer assignment resolution (which printers belong on a PC)
|
||||
# =============================================================================
|
||||
|
||||
# The assignment edges. usesprinter says a printer is installed here;
|
||||
# defaultprinter says which of them Windows should default to.
|
||||
_USES_PRINTER = 'usesprinter'
|
||||
_DEFAULT_PRINTER = 'defaultprinter'
|
||||
_CONTROLS = 'controls'
|
||||
|
||||
|
||||
def _relationship_typeids(*names):
|
||||
"""{name: [relationshiptypeid, ...]} for the named relationship types.
|
||||
|
||||
A list per name, not an id: MySQL's default collation is case-insensitive,
|
||||
so a legacy 'Controls' row lives happily beside 'controls' and a walk that
|
||||
picked one of them would silently miss half the data. Names absent from the
|
||||
table map to an empty list, which resolves to no printers rather than an
|
||||
error - an un-seeded database is a deployment step missed, not a bad request.
|
||||
"""
|
||||
wanted = {name.lower(): [] for name in names}
|
||||
rows = RelationshipType.query.filter(
|
||||
RelationshipType.relationshiptype.in_(names)).all()
|
||||
for row in rows:
|
||||
key = (row.relationshiptype or '').lower()
|
||||
if key in wanted:
|
||||
wanted[key].append(row.relationshiptypeid)
|
||||
return wanted
|
||||
|
||||
|
||||
def _outgoing_rows(assetid, typeids):
|
||||
"""Active outgoing relationships of the given types, oldest first."""
|
||||
if not typeids:
|
||||
return []
|
||||
return (AssetRelationship.query
|
||||
.filter(AssetRelationship.sourceassetid == assetid,
|
||||
AssetRelationship.relationshiptypeid.in_(typeids),
|
||||
AssetRelationship.isactive == True)
|
||||
.order_by(AssetRelationship.relationshipid)
|
||||
.all())
|
||||
|
||||
|
||||
def _own_assignment(assetid, typeids):
|
||||
"""One asset's OWN assignment: (ordered printer assetids, default assetid).
|
||||
|
||||
On an asset with NO usesprinter rows, a defaultprinter row is the whole
|
||||
assignment. Those rows predate this feature - the installer preselect and
|
||||
the collector both write them - and ignoring them would take printers away
|
||||
from every PC recorded before assignment existed. Once an asset has
|
||||
usesprinter rows it is managed, and a default outside that set is stale
|
||||
rather than legacy, so it is dropped by _assignment_result.
|
||||
|
||||
Two active defaults cannot be prevented by the schema - the unique
|
||||
constraint is (source, target, type) - so the oldest row wins and the rest
|
||||
are ignored, which at least makes the answer the same on every read.
|
||||
"""
|
||||
printerassetids = []
|
||||
for rel in _outgoing_rows(assetid, typeids[_USES_PRINTER]):
|
||||
if rel.targetassetid not in printerassetids:
|
||||
printerassetids.append(rel.targetassetid)
|
||||
ismanaged = bool(printerassetids)
|
||||
|
||||
defaultassetid = None
|
||||
for rel in _outgoing_rows(assetid, typeids[_DEFAULT_PRINTER]):
|
||||
if not ismanaged and rel.targetassetid not in printerassetids:
|
||||
printerassetids.append(rel.targetassetid)
|
||||
if defaultassetid is None:
|
||||
defaultassetid = rel.targetassetid
|
||||
|
||||
return printerassetids, defaultassetid
|
||||
|
||||
|
||||
def resolve_asset_printers(asset):
|
||||
"""Which printers an asset gets, and which one is default.
|
||||
|
||||
Own rows first; only when the asset has none does the walk follow its
|
||||
outgoing controls edges one hop and take the assignment of whatever it
|
||||
controls.
|
||||
|
||||
THE INHERITANCE IS THE FEATURE. Printers are a property of the bay, not of
|
||||
the box sat next to it: the machine holds the assignment, and whichever PC
|
||||
controls that machine picks it up. So a PC that is reimaged, or swapped for
|
||||
a different chassis entirely, resolves the same printers on its next cycle
|
||||
with nothing backed up and nothing restored. A PC that controls no machine -
|
||||
an office PC - has only its own rows, which is the same code path with an
|
||||
empty walk.
|
||||
|
||||
A PC's own rows SHADOW what it would inherit rather than adding to it, so a
|
||||
one-off printer on a bay PC is expressed by assigning that PC everything it
|
||||
should have, not by hoping two sets merge.
|
||||
|
||||
Returns {'assignments': [{'assetid', 'isdefault', 'inheritedfromassetid'}],
|
||||
'source': 'self' | 'inherited' | 'none'}.
|
||||
"""
|
||||
assetid = getattr(asset, 'assetid', None)
|
||||
if assetid is None:
|
||||
return {'assignments': [], 'source': 'none'}
|
||||
|
||||
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
||||
|
||||
printerassetids, defaultassetid = _own_assignment(assetid, typeids)
|
||||
if printerassetids:
|
||||
return _assignment_result(printerassetids, defaultassetid, None)
|
||||
|
||||
# Nothing of its own: take the bay's. Outgoing controls only (PC -> machine,
|
||||
# the direction `flask relationships fix-controls-direction` enforces).
|
||||
inherited = []
|
||||
defaults = []
|
||||
suppliers = {}
|
||||
for rel in _outgoing_rows(assetid, typeids[_CONTROLS]):
|
||||
machine = rel.targetasset
|
||||
if machine is None or not getattr(machine, 'isactive', True):
|
||||
continue
|
||||
machineprinters, machinedefault = _own_assignment(machine.assetid, typeids)
|
||||
for printerassetid in machineprinters:
|
||||
if printerassetid not in inherited:
|
||||
inherited.append(printerassetid)
|
||||
suppliers[printerassetid] = machine.assetid
|
||||
if machinedefault is not None and machinedefault not in defaults:
|
||||
defaults.append(machinedefault)
|
||||
|
||||
if not inherited:
|
||||
return {'assignments': [], 'source': 'none'}
|
||||
|
||||
# A PC controlling several machines (or both bays of a dualpath pair) can
|
||||
# inherit two different defaults. Union the printers, but refuse to guess a
|
||||
# default: no default is a state the client already handles, a coin toss is
|
||||
# not.
|
||||
if len(defaults) > 1:
|
||||
logger.warning(
|
||||
'Asset %s inherits %d conflicting default printers; leaving default unset',
|
||||
assetid, len(defaults))
|
||||
inheriteddefault = None
|
||||
else:
|
||||
inheriteddefault = defaults[0] if defaults else None
|
||||
|
||||
return _assignment_result(inherited, inheriteddefault, suppliers)
|
||||
|
||||
|
||||
def _assignment_result(printerassetids, defaultassetid, suppliers):
|
||||
"""Shape the resolver's answer. suppliers is None for an asset's own rows."""
|
||||
# Settled rule: the default must be one of the assigned printers. A dangling
|
||||
# default happens when a printer is unassigned through the generic
|
||||
# relationships card, which knows nothing about this pairing.
|
||||
if defaultassetid not in printerassetids:
|
||||
defaultassetid = None
|
||||
return {
|
||||
'assignments': [{
|
||||
'assetid': printerassetid,
|
||||
'isdefault': printerassetid == defaultassetid,
|
||||
'inheritedfromassetid': (suppliers or {}).get(printerassetid),
|
||||
} for printerassetid in printerassetids],
|
||||
'source': 'inherited' if suppliers is not None else 'self',
|
||||
}
|
||||
|
||||
|
||||
def _printer_driver(printer, universaldrivers):
|
||||
"""Driver record to install this printer with, or None.
|
||||
|
||||
The printer's own model link first. Failing that, a driver with no model at
|
||||
all whose name carries the printer's vendor: HP and Xerox universal drivers
|
||||
cover the overwhelming majority of a floor, and per-model rows for each
|
||||
queue are a table nobody keeps true. printerdrivers cannot name a vendor of
|
||||
its own yet, so the vendor word in the driver's name is what there is.
|
||||
"""
|
||||
if printer.modelnumberid:
|
||||
driver = (PrinterDriver.query
|
||||
.filter_by(modelnumberid=printer.modelnumberid, isactive=True)
|
||||
.order_by(PrinterDriver.name).first())
|
||||
if driver:
|
||||
return driver
|
||||
|
||||
vendor = _printer_vendor(printer).lower()
|
||||
if not vendor:
|
||||
return None
|
||||
for driver in universaldrivers:
|
||||
if vendor in (driver.name or '').lower():
|
||||
return driver
|
||||
return None
|
||||
|
||||
|
||||
def _computer_by_hostname(hostname):
|
||||
"""Active computer asset matching a reported hostname, or None.
|
||||
|
||||
Case-folded on both sides: COMPUTERNAME arrives uppercase, MySQL forgives
|
||||
that and SQLite does not, so an uncompared case would work in production and
|
||||
fail in the tests (or the other way round on a binary collation).
|
||||
|
||||
A short name also matches a stored FQDN, and an FQDN matches a stored short
|
||||
name, because which of the two a site records is a matter of how its PCs
|
||||
were enrolled and the client only ever knows its own COMPUTERNAME.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
name = (hostname or '').strip().lower()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
query = db.session.query(Computer, Asset).join(
|
||||
Asset, Asset.assetid == Computer.assetid).filter(Asset.isactive == True)
|
||||
|
||||
row = query.filter(db.func.lower(Computer.hostname) == name).first()
|
||||
if row:
|
||||
return row
|
||||
|
||||
shortname = name.split('.')[0]
|
||||
if shortname != name:
|
||||
row = query.filter(db.func.lower(Computer.hostname) == shortname).first()
|
||||
if row:
|
||||
return row
|
||||
# Prefix match only for a plain hostname: LIKE wildcards in a path segment
|
||||
# would otherwise let '%' pull back somebody else's printers.
|
||||
if not re.match(r'^[a-z0-9-]+$', shortname):
|
||||
return None
|
||||
return query.filter(
|
||||
db.func.lower(Computer.hostname).like(shortname + '.%')).first()
|
||||
|
||||
|
||||
@printers_asset_bp.route('/for-host/<hostname>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def printers_for_host(hostname: str):
|
||||
"""Printers assigned to a PC, by hostname, with what it takes to install one.
|
||||
|
||||
The endpoint the convergence client asks on every cycle: give me the state
|
||||
this host should be in. Resolution is own rows, else the assignment of the
|
||||
machine this PC controls (see resolve_asset_printers) - which is why a
|
||||
reimaged bay reinstalls its own printers.
|
||||
|
||||
Resolved by hostname rather than machine number because the collector
|
||||
upserts PCs by hostname and an office PC has no machine number at all.
|
||||
|
||||
404 when the host is unknown. A known host with nothing assigned is an
|
||||
empty list and a null default, not an error: that is the client's no-op.
|
||||
|
||||
Each printer carries queuename (what to call the queue), hostname/ipaddress
|
||||
(where to point the port), port (null means the client's own default raw
|
||||
port), drivername (verbatim from the INF, what Add-PrinterDriver matches on)
|
||||
and driverlocation (where the package lives).
|
||||
"""
|
||||
try:
|
||||
row = _computer_by_hostname(hostname)
|
||||
except ImportError:
|
||||
# No computers plugin, no way to resolve a hostname to an asset.
|
||||
row = None
|
||||
|
||||
if not row:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No computer found with hostname {hostname}',
|
||||
http_code=404)
|
||||
|
||||
computer, asset = row
|
||||
resolved = resolve_asset_printers(asset)
|
||||
assignments = resolved['assignments']
|
||||
|
||||
printers = []
|
||||
if assignments:
|
||||
assetids = [item['assetid'] for item in assignments]
|
||||
rows = (db.session.query(Printer)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Printer.assetid.in_(assetids))
|
||||
.filter(Asset.isactive == True)
|
||||
.all())
|
||||
byassetid = {printer.assetid: printer for printer in rows}
|
||||
|
||||
# Fetched once: the universal-driver fallback would otherwise re-read
|
||||
# the same handful of rows per printer.
|
||||
universaldrivers = (PrinterDriver.query
|
||||
.filter(PrinterDriver.modelnumberid.is_(None),
|
||||
PrinterDriver.isactive == True)
|
||||
.order_by(PrinterDriver.name).all())
|
||||
|
||||
for item in assignments:
|
||||
printer = byassetid.get(item['assetid'])
|
||||
if not printer:
|
||||
# Assigned asset is retired, or is not a printer at all.
|
||||
continue
|
||||
printerasset = printer.asset
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=printer.assetid, isprimary=True).first() \
|
||||
or Communication.query.filter_by(assetid=printer.assetid).first()
|
||||
driver = _printer_driver(printer, universaldrivers)
|
||||
printers.append({
|
||||
'printerid': printer.printerid,
|
||||
'assetid': printer.assetid,
|
||||
'queuename': _install_name(printer, printerasset),
|
||||
'windowsname': printer.windowsname,
|
||||
'sharename': printer.sharename,
|
||||
'hostname': printer.hostname,
|
||||
'ipaddress': primary.ipaddress if primary else None,
|
||||
'port': primary.port if primary else None,
|
||||
'driverid': driver.driverid if driver else None,
|
||||
'drivername': driver.drivername if driver else None,
|
||||
'driverlocation': driver.location if driver else None,
|
||||
'installpath': printer.installpath,
|
||||
'isdefault': item['isdefault'],
|
||||
'inheritedfromassetid': item['inheritedfromassetid'],
|
||||
})
|
||||
|
||||
default = next((p for p in printers if p['isdefault']), None)
|
||||
|
||||
return success_response({
|
||||
'hostname': computer.hostname,
|
||||
'assetid': asset.assetid,
|
||||
'assetnumber': asset.assetnumber,
|
||||
# Where the assignment came from, so a technician reading a client log
|
||||
# can tell a bay's printers from the PC's own overrides.
|
||||
'source': resolved['source'],
|
||||
'defaultprinterid': default['printerid'] if default else None,
|
||||
'printers': printers,
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@printers_asset_bp.route('/assignments/for-asset/<int:asset_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def set_asset_printer_assignment(asset_id: int):
|
||||
"""Reconcile one asset's whole printer assignment in a single call.
|
||||
|
||||
Body: {"printerassetids": [...], "defaultprinterassetid": N or null}.
|
||||
|
||||
The WHOLE set, not a delta, because the caller knows the intended end state
|
||||
and a row-at-a-time edit is a non-atomic reconcile: an HTTP failure part way
|
||||
leaves an asset half-assigned, with nothing recording what was meant.
|
||||
|
||||
Written against the MACHINE for a bay - that is the point of the feature, so
|
||||
a reimaged PC inherits it - but an asset is an asset here, and writing to a
|
||||
PC deliberately shadows its machine (see resolve_asset_printers).
|
||||
|
||||
Rows that go away are SOFT-deleted and rows that come back are REACTIVATED
|
||||
rather than inserted: the unique constraint (source, target, type) spans
|
||||
inactive rows, so a blind insert after an unassign raises IntegrityError on
|
||||
MySQL while passing on SQLite.
|
||||
|
||||
Removal here uninstalls nothing. It changes what the bay is told to have;
|
||||
the client never deletes a queue.
|
||||
"""
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
if not asset or not asset.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
raw = data.get('printerassetids')
|
||||
if raw is None or not isinstance(raw, list):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'printerassetids must be a list of asset ids')
|
||||
|
||||
# Ordered, de-duplicated: the same printer twice is one assignment, and the
|
||||
# order is the order the client is told to install them in.
|
||||
wanted = []
|
||||
for value in raw:
|
||||
try:
|
||||
assetid = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'printerassetids must be integers')
|
||||
if assetid not in wanted:
|
||||
wanted.append(assetid)
|
||||
|
||||
defaultid = data.get('defaultprinterassetid')
|
||||
if defaultid is not None:
|
||||
try:
|
||||
defaultid = int(defaultid)
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'defaultprinterassetid must be an asset id or null')
|
||||
# Checked BEFORE any write, so a rejected request changes nothing. A
|
||||
# default outside the set tells the client to default to a queue it was
|
||||
# never told to install: it fails, and nothing in ShopDB says why.
|
||||
if defaultid not in wanted:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'defaultprinterassetid must be one of printerassetids')
|
||||
|
||||
# Every target must exist and be a printer. Assigning a machine to a machine
|
||||
# is a typo that would otherwise sit in the data until a bay tried it.
|
||||
if wanted:
|
||||
found = {row.assetid: row for row in
|
||||
Asset.query.filter(Asset.assetid.in_(wanted)).all()}
|
||||
missing = [assetid for assetid in wanted if assetid not in found]
|
||||
if missing:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'Unknown printer asset(s): {0}'.format(
|
||||
', '.join(str(assetid) for assetid in missing)),
|
||||
http_code=404)
|
||||
notprinters = [assetid for assetid, row in found.items()
|
||||
if not (row.assettype and row.assettype.assettype == 'printer')]
|
||||
if notprinters:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Not printer assets: {0}'.format(
|
||||
', '.join(str(assetid) for assetid in sorted(notprinters))))
|
||||
|
||||
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER)
|
||||
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
||||
# Seed data, not a migration. An un-seeded database cannot hold an
|
||||
# assignment, and saying so beats writing rows nothing can read.
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Relationship types are not seeded - run: flask seed reference-data',
|
||||
http_code=500)
|
||||
|
||||
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
||||
typeids[_USES_PRINTER], wanted)
|
||||
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
||||
typeids[_DEFAULT_PRINTER],
|
||||
[defaultid] if defaultid is not None else [])
|
||||
db.session.commit()
|
||||
|
||||
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
||||
return success_response({
|
||||
'assetid': asset_id,
|
||||
'printerassetids': printerassetids,
|
||||
'defaultprinterassetid': defaultassetid,
|
||||
}, message='Printer assignment updated')
|
||||
|
||||
|
||||
def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
|
||||
"""Make the active edges of one type be exactly `wantedtargets`.
|
||||
|
||||
Reads across every case-variant type id (a legacy 'DefaultPrinter' row is
|
||||
the same edge) but writes new rows with one, so the table converges on a
|
||||
single spelling instead of accumulating both.
|
||||
"""
|
||||
existing = {}
|
||||
rows = (AssetRelationship.query
|
||||
.filter(AssetRelationship.sourceassetid == sourceassetid,
|
||||
AssetRelationship.relationshiptypeid.in_(readtypeids))
|
||||
.order_by(AssetRelationship.relationshipid)
|
||||
.all())
|
||||
for row in rows:
|
||||
existing.setdefault(row.targetassetid, []).append(row)
|
||||
|
||||
for targetassetid, rowlist in existing.items():
|
||||
if targetassetid in wantedtargets:
|
||||
# Keep the oldest, retire any duplicate: two active rows for one
|
||||
# edge is how an asset ends up with two defaults.
|
||||
keep = rowlist[0]
|
||||
keep.isactive = True
|
||||
for extra in rowlist[1:]:
|
||||
extra.isactive = False
|
||||
else:
|
||||
for row in rowlist:
|
||||
row.isactive = False
|
||||
|
||||
for targetassetid in wantedtargets:
|
||||
if targetassetid not in existing:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=sourceassetid,
|
||||
targetassetid=targetassetid,
|
||||
relationshiptypeid=writetypeid,
|
||||
isactive=True))
|
||||
|
||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_printer(printer_id: int):
|
||||
|
||||
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
43
plugins/printers/migrations/versions/0003_drivername.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Add drivername to printerdrivers (exact INF driver name).
|
||||
|
||||
`location` points at the driver package; `name` is what a human calls it.
|
||||
Add-PrinterDriver needs neither - it needs the driver name exactly as the INF
|
||||
declares it ('HP Universal Printing PCL 6'), which nothing in the row carried.
|
||||
Nullable: existing rows have no INF name until someone types it in.
|
||||
|
||||
Guarded/idempotent: skips when the table is absent (plugin disabled) or the
|
||||
column already exists (e.g. a test DB built by db.create_all() from the model).
|
||||
|
||||
Revision ID: printers0003drivername
|
||||
Revises: printers0002supplyalerts
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printers0003drivername'
|
||||
down_revision = 'printers0002supplyalerts'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerdrivers' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||
if 'drivername' not in columns:
|
||||
op.add_column('printerdrivers',
|
||||
sa.Column('drivername', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerdrivers' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column['name'] for column in inspector.get_columns('printerdrivers')}
|
||||
if 'drivername' in columns:
|
||||
op.drop_column('printerdrivers', 'drivername')
|
||||
@@ -11,6 +11,9 @@ class PrinterDriver(db.Model):
|
||||
# SMB path (\\\\server\\share\\...) or HTTP URL to the driver package
|
||||
location = db.Column(db.String(500), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
# Exact driver name as the INF declares it: Add-PrinterDriver matches on
|
||||
# this string, not on `name`, which is ours to choose
|
||||
drivername = db.Column(db.String(255))
|
||||
# Optional: attach a driver to a specific printer model
|
||||
modelnumberid = db.Column(
|
||||
db.Integer,
|
||||
@@ -27,6 +30,7 @@ class PrinterDriver(db.Model):
|
||||
'name': self.name,
|
||||
'location': self.location,
|
||||
'description': self.description,
|
||||
'drivername': self.drivername,
|
||||
'modelnumberid': self.modelnumberid,
|
||||
'modelname': self.model.modelnumber if self.model else None,
|
||||
'isactive': bool(self.isactive),
|
||||
|
||||
Reference in New Issue
Block a user