Merge pull request #225 from eReuse/feature/list-snapshots-view-#3113

Feature/list snapshots view #3113
This commit is contained in:
cayop 2022-05-23 09:27:59 +02:00 committed by GitHub
commit 7ac6d9989a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 583 additions and 141 deletions

View File

@ -11,7 +11,7 @@ from werkzeug.exceptions import Unauthorized
from ereuse_devicehub.auth import Auth from ereuse_devicehub.auth import Auth
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.parser.models import SnapshotErrors from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.parser.parser import ParseSnapshotLsHw from ereuse_devicehub.parser.parser import ParseSnapshotLsHw
from ereuse_devicehub.parser.schemas import Snapshot_lite from ereuse_devicehub.parser.schemas import Snapshot_lite
from ereuse_devicehub.resources.action.views.snapshot import ( from ereuse_devicehub.resources.action.views.snapshot import (
@ -59,6 +59,17 @@ class InventoryView(LoginMixin, SnapshotMixin):
snapshot = self.build() snapshot = self.build()
db.session.add(snapshot) db.session.add(snapshot)
snap_log = SnapshotsLog(
description='Ok',
snapshot_uuid=snapshot.uuid,
severity=Severity.Info,
sid=snapshot.sid,
version=str(snapshot.version),
snapshot=snapshot,
)
snap_log.save()
db.session().final_flush() db.session().final_flush()
db.session.commit() db.session.commit()
self.response = jsonify( self.response = jsonify(
@ -80,8 +91,13 @@ class InventoryView(LoginMixin, SnapshotMixin):
txt = "{}".format(err) txt = "{}".format(err)
uuid = snapshot_json.get('uuid') uuid = snapshot_json.get('uuid')
sid = snapshot_json.get('sid') sid = snapshot_json.get('sid')
error = SnapshotErrors( version = snapshot_json.get('version')
description=txt, snapshot_uuid=uuid, severity=Severity.Error, sid=sid error = SnapshotsLog(
description=txt,
snapshot_uuid=uuid,
severity=Severity.Error,
sid=sid,
version=str(version),
) )
error.save(commit=True) error.save(commit=True)
# raise err # raise err

View File

@ -27,7 +27,7 @@ from wtforms import (
from wtforms.fields import FormField from wtforms.fields import FormField
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.parser.models import SnapshotErrors from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.parser.parser import ParseSnapshotLsHw from ereuse_devicehub.parser.parser import ParseSnapshotLsHw
from ereuse_devicehub.parser.schemas import Snapshot_lite from ereuse_devicehub.parser.schemas import Snapshot_lite
from ereuse_devicehub.resources.action.models import Snapshot, Trade from ereuse_devicehub.resources.action.models import Snapshot, Trade
@ -240,6 +240,9 @@ class UploadSnapshotForm(SnapshotMixin, FlaskForm):
path_snapshot = save_json(snapshot_json, self.tmp_snapshots, g.user.email) path_snapshot = save_json(snapshot_json, self.tmp_snapshots, g.user.email)
snapshot_json.pop('debug', None) snapshot_json.pop('debug', None)
version = snapshot_json.get('schema_api') version = snapshot_json.get('schema_api')
uuid = snapshot_json.get('uuid')
sid = snapshot_json.get('sid')
software_version = snapshot_json.get('version')
if self.is_wb_lite_snapshot(version): if self.is_wb_lite_snapshot(version):
self.snapshot_json = schema_lite.load(snapshot_json) self.snapshot_json = schema_lite.load(snapshot_json)
snapshot_json = ParseSnapshotLsHw(self.snapshot_json).snapshot_json snapshot_json = ParseSnapshotLsHw(self.snapshot_json).snapshot_json
@ -248,13 +251,12 @@ class UploadSnapshotForm(SnapshotMixin, FlaskForm):
snapshot_json = schema.load(snapshot_json) snapshot_json = schema.load(snapshot_json)
except ValidationError as err: except ValidationError as err:
txt = "{}".format(err) txt = "{}".format(err)
uuid = snapshot_json.get('uuid') error = SnapshotsLog(
sid = snapshot_json.get('sid')
error = SnapshotErrors(
description=txt, description=txt,
snapshot_uuid=uuid, snapshot_uuid=uuid,
severity=Severity.Error, severity=Severity.Error,
sid=sid, sid=sid,
version=software_version,
) )
error.save(commit=True) error.save(commit=True)
self.result[filename] = 'Error' self.result[filename] = 'Error'
@ -263,6 +265,15 @@ class UploadSnapshotForm(SnapshotMixin, FlaskForm):
response = self.build(snapshot_json) response = self.build(snapshot_json)
db.session.add(response) db.session.add(response)
devices.append(response.device) devices.append(response.device)
snap_log = SnapshotsLog(
description='Ok',
snapshot_uuid=uuid,
severity=Severity.Info,
sid=sid,
version=software_version,
snapshot=response,
)
snap_log.save()
if hasattr(response, 'type'): if hasattr(response, 'type'):
self.result[filename] = 'Ok' self.result[filename] = 'Ok'

View File

@ -25,6 +25,7 @@ from ereuse_devicehub.inventory.forms import (
UploadSnapshotForm, UploadSnapshotForm,
) )
from ereuse_devicehub.labels.forms import PrintLabelsForm from ereuse_devicehub.labels.forms import PrintLabelsForm
from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.resources.action.models import Trade from ereuse_devicehub.resources.action.models import Trade
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
from ereuse_devicehub.resources.documents.device_row import ActionRow, DeviceRow from ereuse_devicehub.resources.documents.device_row import ActionRow, DeviceRow
@ -512,6 +513,70 @@ class ExportsView(View):
return flask.render_template('inventory/erasure.html', **params) return flask.render_template('inventory/erasure.html', **params)
class SnapshotListView(GenericMixin):
template_name = 'inventory/snapshots_list.html'
def dispatch_request(self):
self.get_context()
self.context['page_title'] = "Snapshots Logs"
self.context['snapshots_log'] = self.get_snapshots_log()
return flask.render_template(self.template_name, **self.context)
def get_snapshots_log(self):
snapshots_log = SnapshotsLog.query.filter(
SnapshotsLog.owner == g.user
).order_by(SnapshotsLog.created.desc())
logs = {}
for snap in snapshots_log:
if snap.snapshot_uuid not in logs:
logs[snap.snapshot_uuid] = {
'sid': snap.sid,
'snapshot_uuid': snap.snapshot_uuid,
'version': snap.version,
'device': snap.get_device(),
'status': snap.get_status(),
'severity': snap.severity,
'created': snap.created,
}
continue
if snap.created > logs[snap.snapshot_uuid]['created']:
logs[snap.snapshot_uuid]['created'] = snap.created
if snap.severity > logs[snap.snapshot_uuid]['severity']:
logs[snap.snapshot_uuid]['severity'] = snap.severity
logs[snap.snapshot_uuid]['status'] = snap.get_status()
result = sorted(logs.values(), key=lambda d: d['created'])
result.reverse()
return result
class SnapshotDetailView(GenericMixin):
template_name = 'inventory/snapshot_detail.html'
def dispatch_request(self, snapshot_uuid):
self.snapshot_uuid = snapshot_uuid
self.get_context()
self.context['page_title'] = "Snapshot Detail"
self.context['snapshots_log'] = self.get_snapshots_log()
self.context['snapshot_uuid'] = snapshot_uuid
self.context['snapshot_sid'] = ''
if self.context['snapshots_log'].count():
self.context['snapshot_sid'] = self.context['snapshots_log'][0].sid
return flask.render_template(self.template_name, **self.context)
def get_snapshots_log(self):
return (
SnapshotsLog.query.filter(SnapshotsLog.owner == g.user)
.filter(SnapshotsLog.snapshot_uuid == self.snapshot_uuid)
.order_by(SnapshotsLog.created.desc())
)
devices.add_url_rule('/action/add/', view_func=NewActionView.as_view('action_add')) devices.add_url_rule('/action/add/', view_func=NewActionView.as_view('action_add'))
devices.add_url_rule('/action/trade/add/', view_func=NewTradeView.as_view('trade_add')) devices.add_url_rule('/action/trade/add/', view_func=NewTradeView.as_view('trade_add'))
devices.add_url_rule( devices.add_url_rule(
@ -558,3 +623,8 @@ devices.add_url_rule(
devices.add_url_rule( devices.add_url_rule(
'/export/<string:export_id>/', view_func=ExportsView.as_view('export') '/export/<string:export_id>/', view_func=ExportsView.as_view('export')
) )
devices.add_url_rule('/snapshots/', view_func=SnapshotListView.as_view('snapshotslist'))
devices.add_url_rule(
'/snapshots/<string:snapshot_uuid>/',
view_func=SnapshotDetailView.as_view('snapshot_detail'),
)

View File

@ -0,0 +1,102 @@
"""snapshot_log
Revision ID: 926865284103
Revises: 6f6771813f2e
Create Date: 2022-05-17 17:57:46.651106
"""
import citext
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '926865284103'
down_revision = '6f6771813f2e'
branch_labels = None
depends_on = None
def get_inv():
INV = context.get_x_argument(as_dictionary=True).get('inventory')
if not INV:
raise ValueError("Inventory value is not specified")
return INV
def upgrade():
op.create_table(
'snapshots_log',
sa.Column(
'updated',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='The last time Devicehub recorded a change for \n this thing.\n ',
),
sa.Column(
'created',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='When Devicehub created this.',
),
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('description', citext.CIText(), nullable=True),
sa.Column('version', citext.CIText(), nullable=True),
sa.Column('sid', citext.CIText(), nullable=True),
sa.Column('severity', sa.SmallInteger(), nullable=False),
sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('snapshot_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
['snapshot_id'],
[f'{get_inv()}.snapshot.id'],
),
sa.ForeignKeyConstraint(
['owner_id'],
['common.user.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}',
)
op.execute(f"CREATE SEQUENCE {get_inv()}.snapshots_log_seq START 1;")
op.drop_table('snapshot_errors', schema=f'{get_inv()}')
op.execute(f"DROP SEQUENCE {get_inv()}.snapshot_errors_seq;")
def downgrade():
op.drop_table('snapshots_log', schema=f'{get_inv()}')
op.execute(f"DROP SEQUENCE {get_inv()}.snapshots_log_seq;")
op.create_table(
'snapshot_errors',
sa.Column(
'updated',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='The last time Devicehub recorded a change for \n this thing.\n ',
),
sa.Column(
'created',
sa.TIMESTAMP(timezone=True),
server_default=sa.text('CURRENT_TIMESTAMP'),
nullable=False,
comment='When Devicehub created this.',
),
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('description', citext.CIText(), nullable=False),
sa.Column('snapshot_uuid', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('severity', sa.SmallInteger(), nullable=False),
sa.Column('sid', citext.CIText(), nullable=True),
sa.Column('owner_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
['owner_id'],
['common.user.id'],
),
sa.PrimaryKeyConstraint('id'),
schema=f'{get_inv()}',
)
op.execute(f"CREATE SEQUENCE {get_inv()}.snapshot_errors_seq START 1;")

View File

@ -14,7 +14,7 @@ from ereuse_utils.nested_lookup import (
) )
from ereuse_devicehub.parser import base2, unit, utils from ereuse_devicehub.parser import base2, unit, utils
from ereuse_devicehub.parser.models import SnapshotErrors from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.parser.utils import Dumpeable from ereuse_devicehub.parser.utils import Dumpeable
from ereuse_devicehub.resources.enums import Severity from ereuse_devicehub.resources.enums import Severity
@ -422,7 +422,7 @@ class Computer(Device):
self._ram = None self._ram = None
@classmethod @classmethod
def run(cls, lshw, hwinfo_raw, uuid=None, sid=None): def run(cls, lshw, hwinfo_raw, uuid=None, sid=None, version=None):
""" """
Gets hardware information from the computer and its components, Gets hardware information from the computer and its components,
like serial numbers or model names, and benchmarks them. like serial numbers or model names, and benchmarks them.
@ -447,18 +447,24 @@ class Computer(Device):
txt = "Error: Snapshot: {uuid}, sid: {sid}, type_error: {type}, error: {error}".format( txt = "Error: Snapshot: {uuid}, sid: {sid}, type_error: {type}, error: {error}".format(
uuid=uuid, sid=sid, type=err.__class__, error=err uuid=uuid, sid=sid, type=err.__class__, error=err
) )
cls.errors(txt, uuid=uuid, sid=sid) cls.errors(txt, uuid=uuid, sid=sid, version=version)
return computer, components return computer, components
@classmethod @classmethod
def errors(cls, txt=None, uuid=None, sid=None, severity=Severity.Error): def errors(
cls, txt=None, uuid=None, sid=None, version=None, severity=Severity.Error
):
if not txt: if not txt:
return return
logger.error(txt) logger.error(txt)
error = SnapshotErrors( error = SnapshotsLog(
description=txt, snapshot_uuid=uuid, severity=severity, sid=sid description=txt,
snapshot_uuid=uuid,
severity=severity,
sid=sid,
version=version,
) )
error.save() error.save()

View File

@ -4,25 +4,29 @@ from sqlalchemy import BigInteger, Column, Sequence, SmallInteger
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.resources.action.models import Snapshot
from ereuse_devicehub.resources.enums import Severity from ereuse_devicehub.resources.enums import Severity
from ereuse_devicehub.resources.models import Thing from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user.models import User from ereuse_devicehub.resources.user.models import User
class SnapshotErrors(Thing): class SnapshotsLog(Thing):
"""A Snapshot errors.""" """A Snapshot log."""
id = Column(BigInteger, Sequence('snapshot_errors_seq'), primary_key=True) id = Column(BigInteger, Sequence('snapshots_log_seq'), primary_key=True)
description = Column(CIText(), default='', nullable=False)
sid = Column(CIText(), nullable=True)
severity = Column(SmallInteger, default=Severity.Info, nullable=False) severity = Column(SmallInteger, default=Severity.Info, nullable=False)
snapshot_uuid = Column(UUID(as_uuid=True), nullable=False) version = Column(CIText(), default='', nullable=True)
description = Column(CIText(), default='', nullable=True)
sid = Column(CIText(), nullable=True)
snapshot_uuid = Column(UUID(as_uuid=True), nullable=True)
snapshot_id = Column(UUID(as_uuid=True), db.ForeignKey(Snapshot.id), nullable=True)
owner_id = db.Column( owner_id = db.Column(
UUID(as_uuid=True), UUID(as_uuid=True),
db.ForeignKey(User.id), db.ForeignKey(User.id),
nullable=False, nullable=False,
default=lambda: g.user.id, default=lambda: g.user.id,
) )
snapshot = db.relationship(Snapshot, primaryjoin=snapshot_id == Snapshot.id)
owner = db.relationship(User, primaryjoin=owner_id == User.id) owner = db.relationship(User, primaryjoin=owner_id == User.id)
def save(self, commit=False): def save(self, commit=False):
@ -30,3 +34,12 @@ class SnapshotErrors(Thing):
if commit: if commit:
db.session.commit() db.session.commit()
def get_status(self):
return Severity(self.severity)
def get_device(self):
if self.snapshot:
return self.snapshot.device.devicehub_id
return ''

View File

@ -8,7 +8,7 @@ from marshmallow.exceptions import ValidationError
from ereuse_devicehub.parser import base2 from ereuse_devicehub.parser import base2
from ereuse_devicehub.parser.computer import Computer from ereuse_devicehub.parser.computer import Computer
from ereuse_devicehub.parser.models import SnapshotErrors from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.resources.action.schemas import Snapshot from ereuse_devicehub.resources.action.schemas import Snapshot
from ereuse_devicehub.resources.enums import DataStorageInterface, Severity from ereuse_devicehub.resources.enums import DataStorageInterface, Severity
@ -320,6 +320,7 @@ class ParseSnapshotLsHw:
self.default = default self.default = default
self.uuid = snapshot.get("uuid") self.uuid = snapshot.get("uuid")
self.sid = snapshot.get("sid") self.sid = snapshot.get("sid")
self.version = str(snapshot.get("version"))
self.dmidecode_raw = snapshot["data"]["dmidecode"] self.dmidecode_raw = snapshot["data"]["dmidecode"]
self.smart = snapshot["data"]["smart"] self.smart = snapshot["data"]["smart"]
self.hwinfo_raw = snapshot["data"]["hwinfo"] self.hwinfo_raw = snapshot["data"]["hwinfo"]
@ -362,7 +363,7 @@ class ParseSnapshotLsHw:
def set_basic_datas(self): def set_basic_datas(self):
try: try:
pc, self.components_obj = Computer.run( pc, self.components_obj = Computer.run(
self.lshw, self.hwinfo_raw, self.uuid, self.sid self.lshw, self.hwinfo_raw, self.uuid, self.sid, self.version
) )
pc = pc.dump() pc = pc.dump()
minimum_hid = None in [pc['manufacturer'], pc['model'], pc['serialNumber']] minimum_hid = None in [pc['manufacturer'], pc['model'], pc['serialNumber']]
@ -417,11 +418,11 @@ class ParseSnapshotLsHw:
size = ram.get("Size") size = ram.get("Size")
if not len(size.split(" ")) == 2: if not len(size.split(" ")) == 2:
txt = ( txt = (
"Error: Snapshot: {uuid}, tag: {sid} have this ram Size: {size}".format( "Error: Snapshot: {uuid}, Sid: {sid} have this ram Size: {size}".format(
uuid=self.uuid, size=size, sid=self.sid uuid=self.uuid, size=size, sid=self.sid
) )
) )
self.errors(txt) self.errors(txt, severity=Severity.Warning)
return 128 return 128
size, units = size.split(" ") size, units = size.split(" ")
return base2.Quantity(float(size), units).to('MiB').m return base2.Quantity(float(size), units).to('MiB').m
@ -429,10 +430,10 @@ class ParseSnapshotLsHw:
def get_ram_speed(self, ram): def get_ram_speed(self, ram):
speed = ram.get("Speed", "100") speed = ram.get("Speed", "100")
if not len(speed.split(" ")) == 2: if not len(speed.split(" ")) == 2:
txt = "Error: Snapshot: {uuid}, tag: {sid} have this ram Speed: {speed}".format( txt = "Error: Snapshot: {uuid}, Sid: {sid} have this ram Speed: {speed}".format(
uuid=self.uuid, speed=speed, sid=self.sid uuid=self.uuid, speed=speed, sid=self.sid
) )
self.errors(txt) self.errors(txt, severity=Severity.Warning)
return 100 return 100
speed, units = speed.split(" ") speed, units = speed.split(" ")
return float(speed) return float(speed)
@ -464,10 +465,10 @@ class ParseSnapshotLsHw:
uuid.UUID(dmi_uuid) uuid.UUID(dmi_uuid)
except (ValueError, AttributeError) as err: except (ValueError, AttributeError) as err:
self.errors("{}".format(err)) self.errors("{}".format(err))
txt = "Error: Snapshot: {uuid} tag: {sid} have this uuid: {device}".format( txt = "Error: Snapshot: {uuid} sid: {sid} have this uuid: {device}".format(
uuid=self.uuid, device=dmi_uuid, sid=self.sid uuid=self.uuid, device=dmi_uuid, sid=self.sid
) )
self.errors(txt) self.errors(txt, severity=Severity.Warning)
dmi_uuid = None dmi_uuid = None
return dmi_uuid return dmi_uuid
@ -510,11 +511,11 @@ class ParseSnapshotLsHw:
try: try:
DataStorageInterface(interface.upper()) DataStorageInterface(interface.upper())
except ValueError as err: except ValueError as err:
txt = "tag: {}, interface {} is not in DataStorageInterface Enum".format( txt = "Sid: {}, interface {} is not in DataStorageInterface Enum".format(
interface, self.sid self.sid, interface
) )
self.errors("{}".format(err)) self.errors("{}".format(err))
self.errors(txt) self.errors(txt, severity=Severity.Warning)
return "ATA" return "ATA"
def get_data_storage_size(self, x): def get_data_storage_size(self, x):
@ -546,13 +547,17 @@ class ParseSnapshotLsHw:
return action return action
def errors(self, txt=None, severity=Severity.Info): def errors(self, txt=None, severity=Severity.Error):
if not txt: if not txt:
return self._errors return self._errors
logger.error(txt) logger.error(txt)
self._errors.append(txt) self._errors.append(txt)
error = SnapshotErrors( error = SnapshotsLog(
description=txt, snapshot_uuid=self.uuid, severity=severity, sid=self.sid description=txt,
snapshot_uuid=self.uuid,
severity=severity,
sid=self.sid,
version=self.version,
) )
error.save() error.save()

View File

@ -11,7 +11,6 @@ from marshmallow import ValidationError
from sqlalchemy.util import OrderedSet from sqlalchemy.util import OrderedSet
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.parser.models import SnapshotErrors
from ereuse_devicehub.resources.action.models import Snapshot from ereuse_devicehub.resources.action.models import Snapshot
from ereuse_devicehub.resources.device.models import Computer from ereuse_devicehub.resources.device.models import Computer
from ereuse_devicehub.resources.device.sync import Sync from ereuse_devicehub.resources.device.sync import Sync
@ -138,9 +137,10 @@ class SnapshotView(SnapshotMixin):
try: try:
self.snapshot_json = resource_def.schema.load(snapshot_json) self.snapshot_json = resource_def.schema.load(snapshot_json)
except ValidationError as err: except ValidationError as err:
from ereuse_devicehub.parser.models import SnapshotsLog
txt = "{}".format(err) txt = "{}".format(err)
uuid = snapshot_json.get('uuid') uuid = snapshot_json.get('uuid')
error = SnapshotErrors( error = SnapshotsLog(
description=txt, snapshot_uuid=uuid, severity=Severity.Error description=txt, snapshot_uuid=uuid, severity=Severity.Error
) )
error.save(commit=True) error.save(commit=True)

View File

@ -358,7 +358,6 @@ class Device(Thing):
from ereuse_devicehub.resources.device import states from ereuse_devicehub.resources.device import states
with suppress(LookupError, ValueError): with suppress(LookupError, ValueError):
# import pdb; pdb.set_trace()
return self.last_action_of(*states.Physical.actions()) return self.last_action_of(*states.Physical.actions())
@property @property

View File

@ -10,7 +10,7 @@ from sqlalchemy import TEXT
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy_utils import LtreeType from sqlalchemy_utils import LtreeType
from sqlalchemy_utils.types.ltree import LQUERY from sqlalchemy_utils.types.ltree import LQUERY
from teal.db import CASCADE_OWN, UUIDLtree, check_range, IntEnum from teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
from teal.resource import url_for_resource from teal.resource import url_for_resource
from ereuse_devicehub.db import create_view, db, exp, f from ereuse_devicehub.db import create_view, db, exp, f
@ -21,70 +21,88 @@ from ereuse_devicehub.resources.user.models import User
class Lot(Thing): class Lot(Thing):
id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default id = db.Column(
UUID(as_uuid=True), primary_key=True
) # uuid is generated on init by default
name = db.Column(CIText(), nullable=False) name = db.Column(CIText(), nullable=False)
description = db.Column(CIText()) description = db.Column(CIText())
description.comment = """A comment about the lot.""" description.comment = """A comment about the lot."""
closed = db.Column(db.Boolean, default=False, nullable=False) closed = db.Column(db.Boolean, default=False, nullable=False)
closed.comment = """A closed lot cannot be modified anymore.""" closed.comment = """A closed lot cannot be modified anymore."""
devices = db.relationship(Device, devices = db.relationship(
backref=db.backref('lots', lazy=True, collection_class=set), Device,
secondary=lambda: LotDevice.__table__, backref=db.backref('lots', lazy=True, collection_class=set),
lazy=True, secondary=lambda: LotDevice.__table__,
collection_class=set) lazy=True,
collection_class=set,
)
"""The **children** devices that the lot has. """The **children** devices that the lot has.
Note that the lot can have more devices, if they are inside Note that the lot can have more devices, if they are inside
descendant lots. descendant lots.
""" """
parents = db.relationship(lambda: Lot, parents = db.relationship(
viewonly=True, lambda: Lot,
lazy=True, viewonly=True,
collection_class=set, lazy=True,
secondary=lambda: LotParent.__table__, collection_class=set,
primaryjoin=lambda: Lot.id == LotParent.child_id, secondary=lambda: LotParent.__table__,
secondaryjoin=lambda: LotParent.parent_id == Lot.id, primaryjoin=lambda: Lot.id == LotParent.child_id,
cascade='refresh-expire', # propagate changes outside ORM secondaryjoin=lambda: LotParent.parent_id == Lot.id,
backref=db.backref('children', cascade='refresh-expire', # propagate changes outside ORM
viewonly=True, backref=db.backref(
lazy=True, 'children',
cascade='refresh-expire', viewonly=True,
collection_class=set) lazy=True,
) cascade='refresh-expire',
collection_class=set,
),
)
"""The parent lots.""" """The parent lots."""
all_devices = db.relationship(Device, all_devices = db.relationship(
viewonly=True, Device,
lazy=True, viewonly=True,
collection_class=set, lazy=True,
secondary=lambda: LotDeviceDescendants.__table__, collection_class=set,
primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id, secondary=lambda: LotDeviceDescendants.__table__,
secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id) primaryjoin=lambda: Lot.id == LotDeviceDescendants.ancestor_lot_id,
secondaryjoin=lambda: LotDeviceDescendants.device_id == Device.id,
)
"""All devices, including components, inside this lot and its """All devices, including components, inside this lot and its
descendants. descendants.
""" """
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0) amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
owner_id = db.Column(UUID(as_uuid=True), owner_id = db.Column(
db.ForeignKey(User.id), UUID(as_uuid=True),
nullable=False, db.ForeignKey(User.id),
default=lambda: g.user.id) nullable=False,
default=lambda: g.user.id,
)
owner = db.relationship(User, primaryjoin=owner_id == User.id) owner = db.relationship(User, primaryjoin=owner_id == User.id)
transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False) transfer_state = db.Column(
IntEnum(TransferState), default=TransferState.Initial, nullable=False
)
transfer_state.comment = TransferState.__doc__ transfer_state.comment = TransferState.__doc__
receiver_address = db.Column(CIText(), receiver_address = db.Column(
db.ForeignKey(User.email), CIText(),
nullable=False, db.ForeignKey(User.email),
default=lambda: g.user.email) nullable=False,
default=lambda: g.user.email,
)
receiver = db.relationship(User, primaryjoin=receiver_address == User.email) receiver = db.relationship(User, primaryjoin=receiver_address == User.email)
def __init__(self, name: str, closed: bool = closed.default.arg, def __init__(
description: str = None) -> None: self, name: str, closed: bool = closed.default.arg, description: str = None
) -> None:
"""Initializes a lot """Initializes a lot
:param name: :param name:
:param closed: :param closed:
""" """
super().__init__(id=uuid.uuid4(), name=name, closed=closed, description=description) super().__init__(
id=uuid.uuid4(), name=name, closed=closed, description=description
)
Path(self) # Lots have always one edge per default. Path(self) # Lots have always one edge per default.
@property @property
@ -106,16 +124,22 @@ class Lot(Thing):
@property @property
def is_incoming(self): def is_incoming(self):
return bool(self.trade and self.trade.user_to == current_user) if hasattr(self, 'trade'):
return self.trade.user_to == g.user
return False
@property @property
def is_outgoing(self): def is_outgoing(self):
return bool(self.trade and self.trade.user_from == current_user) if hasattr(self, 'trade'):
return self.trade.user_to == g.user
return False
@classmethod @classmethod
def descendantsq(cls, id): def descendantsq(cls, id):
_id = UUIDLtree.convert(id) _id = UUIDLtree.convert(id)
return (cls.id == Path.lot_id) & Path.path.lquery(exp.cast('*.{}.*'.format(_id), LQUERY)) return (cls.id == Path.lot_id) & Path.path.lquery(
exp.cast('*.{}.*'.format(_id), LQUERY)
)
@classmethod @classmethod
def roots(cls): def roots(cls):
@ -176,13 +200,17 @@ class Lot(Thing):
if isinstance(child, Lot): if isinstance(child, Lot):
return Path.has_lot(self.id, child.id) return Path.has_lot(self.id, child.id)
elif isinstance(child, Device): elif isinstance(child, Device):
device = db.session.query(LotDeviceDescendants) \ device = (
.filter(LotDeviceDescendants.device_id == child.id) \ db.session.query(LotDeviceDescendants)
.filter(LotDeviceDescendants.ancestor_lot_id == self.id) \ .filter(LotDeviceDescendants.device_id == child.id)
.filter(LotDeviceDescendants.ancestor_lot_id == self.id)
.one_or_none() .one_or_none()
)
return device return device
else: else:
raise TypeError('Lot only contains devices and lots, not {}'.format(child.__class__)) raise TypeError(
'Lot only contains devices and lots, not {}'.format(child.__class__)
)
def __repr__(self) -> str: def __repr__(self) -> str:
return '<Lot {0.name} devices={0.devices!r}>'.format(self) return '<Lot {0.name} devices={0.devices!r}>'.format(self)
@ -192,35 +220,44 @@ class LotDevice(db.Model):
device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id), primary_key=True) device_id = db.Column(db.BigInteger, db.ForeignKey(Device.id), primary_key=True)
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), primary_key=True) lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), primary_key=True)
created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) created = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
author_id = db.Column(UUID(as_uuid=True), author_id = db.Column(
db.ForeignKey(User.id), UUID(as_uuid=True),
nullable=False, db.ForeignKey(User.id),
default=lambda: g.user.id) nullable=False,
default=lambda: g.user.id,
)
author = db.relationship(User, primaryjoin=author_id == User.id) author = db.relationship(User, primaryjoin=author_id == User.id)
author_id.comment = """The user that put the device in the lot.""" author_id.comment = """The user that put the device in the lot."""
class Path(db.Model): class Path(db.Model):
id = db.Column(db.UUID(as_uuid=True), id = db.Column(
primary_key=True, db.UUID(as_uuid=True),
server_default=db.text('gen_random_uuid()')) primary_key=True,
server_default=db.text('gen_random_uuid()'),
)
lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False) lot_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
lot = db.relationship(Lot, lot = db.relationship(
backref=db.backref('paths', Lot,
lazy=True, backref=db.backref(
collection_class=set, 'paths', lazy=True, collection_class=set, cascade=CASCADE_OWN
cascade=CASCADE_OWN), ),
primaryjoin=Lot.id == lot_id) primaryjoin=Lot.id == lot_id,
)
path = db.Column(LtreeType, nullable=False) path = db.Column(LtreeType, nullable=False)
created = db.Column(db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP')) created = db.Column(
db.TIMESTAMP(timezone=True), server_default=db.text('CURRENT_TIMESTAMP')
)
created.comment = """When Devicehub created this.""" created.comment = """When Devicehub created this."""
__table_args__ = ( __table_args__ = (
# dag.delete_edge needs to disable internally/temporarily the unique constraint # dag.delete_edge needs to disable internally/temporarily the unique constraint
db.UniqueConstraint(path, name='path_unique', deferrable=True, initially='immediate'), db.UniqueConstraint(
path, name='path_unique', deferrable=True, initially='immediate'
),
db.Index('path_gist', path, postgresql_using='gist'), db.Index('path_gist', path, postgresql_using='gist'),
db.Index('path_btree', path, postgresql_using='btree'), db.Index('path_btree', path, postgresql_using='btree'),
db.Index('lot_id_index', lot_id, postgresql_using='hash') db.Index('lot_id_index', lot_id, postgresql_using='hash'),
) )
def __init__(self, lot: Lot) -> None: def __init__(self, lot: Lot) -> None:
@ -243,7 +280,9 @@ class Path(db.Model):
child_id = UUIDLtree.convert(child_id) child_id = UUIDLtree.convert(child_id)
return bool( return bool(
db.session.execute( db.session.execute(
"SELECT 1 from path where path ~ '*.{}.*.{}.*'".format(parent_id, child_id) "SELECT 1 from path where path ~ '*.{}.*.{}.*'".format(
parent_id, child_id
)
).first() ).first()
) )
@ -263,47 +302,73 @@ class LotDeviceDescendants(db.Model):
"""Ancestor lot table.""" """Ancestor lot table."""
_desc = Lot.__table__.alias() _desc = Lot.__table__.alias()
"""Descendant lot table.""" """Descendant lot table."""
lot_device = _desc \ lot_device = _desc.join(LotDevice, _desc.c.id == LotDevice.lot_id).join(
.join(LotDevice, _desc.c.id == LotDevice.lot_id) \ Path, _desc.c.id == Path.lot_id
.join(Path, _desc.c.id == Path.lot_id) )
"""Join: Path -- Lot -- LotDevice""" """Join: Path -- Lot -- LotDevice"""
descendants = "path.path ~ (CAST('*.'|| replace(CAST({}.id as text), '-', '_') " \ descendants = (
"|| '.*' AS LQUERY))".format(_ancestor.name) "path.path ~ (CAST('*.'|| replace(CAST({}.id as text), '-', '_') "
"|| '.*' AS LQUERY))".format(_ancestor.name)
)
"""Query that gets the descendants of the ancestor lot.""" """Query that gets the descendants of the ancestor lot."""
devices = db.select([ devices = (
LotDevice.device_id, db.select(
_desc.c.id.label('parent_lot_id'), [
_ancestor.c.id.label('ancestor_lot_id'), LotDevice.device_id,
None _desc.c.id.label('parent_lot_id'),
]).select_from(_ancestor).select_from(lot_device).where(db.text(descendants)) _ancestor.c.id.label('ancestor_lot_id'),
None,
]
)
.select_from(_ancestor)
.select_from(lot_device)
.where(db.text(descendants))
)
# Components # Components
_parent_device = Device.__table__.alias(name='parent_device') _parent_device = Device.__table__.alias(name='parent_device')
"""The device that has the access to the lot.""" """The device that has the access to the lot."""
lot_device_component = lot_device \ lot_device_component = lot_device.join(
.join(_parent_device, _parent_device.c.id == LotDevice.device_id) \ _parent_device, _parent_device.c.id == LotDevice.device_id
.join(Component, _parent_device.c.id == Component.parent_id) ).join(Component, _parent_device.c.id == Component.parent_id)
"""Join: Path -- Lot -- LotDevice -- ParentDevice (Device) -- Component""" """Join: Path -- Lot -- LotDevice -- ParentDevice (Device) -- Component"""
components = db.select([ components = (
Component.id.label('device_id'), db.select(
_desc.c.id.label('parent_lot_id'), [
_ancestor.c.id.label('ancestor_lot_id'), Component.id.label('device_id'),
LotDevice.device_id.label('device_parent_id'), _desc.c.id.label('parent_lot_id'),
]).select_from(_ancestor).select_from(lot_device_component).where(db.text(descendants)) _ancestor.c.id.label('ancestor_lot_id'),
LotDevice.device_id.label('device_parent_id'),
]
)
.select_from(_ancestor)
.select_from(lot_device_component)
.where(db.text(descendants))
)
__table__ = create_view('lot_device_descendants', devices.union(components)) __table__ = create_view('lot_device_descendants', devices.union(components))
class LotParent(db.Model): class LotParent(db.Model):
i = f.index(Path.path, db.func.text2ltree(f.replace(exp.cast(Path.lot_id, TEXT), '-', '_'))) i = f.index(
Path.path, db.func.text2ltree(f.replace(exp.cast(Path.lot_id, TEXT), '-', '_'))
)
__table__ = create_view( __table__ = create_view(
'lot_parent', 'lot_parent',
db.select([ db.select(
Path.lot_id.label('child_id'), [
exp.cast(f.replace(exp.cast(f.subltree(Path.path, i - 1, i), TEXT), '_', '-'), Path.lot_id.label('child_id'),
UUID).label('parent_id') exp.cast(
]).select_from(Path).where(i > 0), f.replace(
exp.cast(f.subltree(Path.path, i - 1, i), TEXT), '_', '-'
),
UUID,
).label('parent_id'),
]
)
.select_from(Path)
.where(i > 0),
) )

View File

@ -111,6 +111,15 @@
</a> </a>
</li><!-- End Dashboard Nav --> </li><!-- End Dashboard Nav -->
<li class="nav-heading">Snapshots</li>
<li class="nav-item">
<a class="nav-link collapsed" href="{{ url_for('inventory.snapshotslist') }}">
<i class="bi-menu-button-wide"></i>
<span>Uploaded Snapshots</span>
</a>
</li>
<li class="nav-heading">Devices</li> <li class="nav-heading">Devices</li>
<li class="nav-item"> <li class="nav-item">

View File

@ -0,0 +1,54 @@
{% extends "ereuse_devicehub/base_site.html" %}
{% block main %}
<div class="pagetitle">
<h1>Inventory</h1>
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('inventory.devicelist')}}">Inventory</a></li>
<li class="breadcrumb-item active">{{ page_title }}</li>
</ol>
</nav>
</div><!-- End Page Title -->
<section class="section profile">
<div class="row">
<div class="col-xl-12">
<div class="card">
<div class="card-body pt-3">
<h3>{{ snapshot_sid }} | {{ snapshot_uuid }}</h3>
<!-- Bordered Tabs -->
<div class="tab-content pt-2">
<div class="tab-pane fade show active">
<h5 class="card-title">Traceability log Details</h5>
<div class="list-group col-6">
{% for log in snapshots_log %}
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ log.get_status() }}</h5>
<small class="text-muted">{{ log.created.strftime('%H:%M %d-%m-%Y') }}</small>
</div>
<p class="mb-1">
Device:
{{ log.get_device() }}<br />
Version: {{ log.version }}<br />
</p>
<p>
<small class="text-muted">
{{ log.description }}
</small>
</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock main %}

View File

@ -0,0 +1,88 @@
{% extends "ereuse_devicehub/base_site.html" %}
{% block main %}
<div class="pagetitle">
<h1>Inventory</h1>
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('inventory.devicelist')}}">Inventory</a></li>
<li class="breadcrumb-item active">Snapshots</li>
</ol>
</nav>
</div><!-- End Page Title -->
<section class="section profile">
<div class="row">
<div class="col-xl-12">
<div class="card">
<div class="card-body pt-3" style="min-height: 650px;">
<!-- Bordered Tabs -->
<div class="tab-content pt-5">
<div id="devices-list" class="tab-pane fade devices-list active show">
<div class="tab-content pt-2">
<table class="table">
<thead>
<tr>
<th scope="col">SID</th>
<th scope="col">Snapshot id</th>
<th scope="col">Version</th>
<th scope="col">Device</th>
<th scope="col">Status</th>
<th scope="col" data-type="date" data-format="DD-MM-YYYY">Time</th>
</tr>
</thead>
<tbody>
{% for snap in snapshots_log %}
<tr>
<td>
{% if snap.sid %}
<a href="{{ url_for('inventory.snapshot_detail', snapshot_uuid=snap.snapshot_uuid) }}">
{{ snap.sid }}
</a>
{% endif %}
</td>
<td>
<a href="{{ url_for('inventory.snapshot_detail', snapshot_uuid=snap.snapshot_uuid) }}">
{{ snap.snapshot_uuid }}
</a>
</td>
<td>
{{ snap.version }}
</td>
<td>
{% if snap.device %}
<a href="{{ url_for('inventory.device_details', id=snap.device) }}">
{{ snap.device }}
</a>
{% endif %}
</td>
<td>
{{ snap.status }}
</td>
<td>{{ snap.created.strftime('%H:%M %d-%m-%Y') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div><!-- End Bordered Tabs -->
</div>
</div>
</div>
<div id="NotificationsContainer" style="position: absolute; bottom: 0; right: 0; margin: 10px; margin-top: 70px; width: calc(100% - 310px);"></div>
</div>
</div>
</section>
<!-- Custom Code -->
<script>
const table = new simpleDatatables.DataTable("table")
</script>
{% endblock main %}

View File

@ -50,6 +50,7 @@ class LogoutView(View):
class GenericMixin(View): class GenericMixin(View):
methods = ['GET']
decorators = [login_required] decorators = [login_required]
def get_lots(self): def get_lots(self):

View File

@ -15,10 +15,10 @@ from requests.exceptions import HTTPError
from teal.db import DBError, UniqueViolation from teal.db import DBError, UniqueViolation
from teal.marshmallow import ValidationError from teal.marshmallow import ValidationError
from ereuse_devicehub.client import UserClient, Client from ereuse_devicehub.client import Client, UserClient
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.parser.models import SnapshotErrors from ereuse_devicehub.parser.models import SnapshotsLog
from ereuse_devicehub.resources.action.models import ( from ereuse_devicehub.resources.action.models import (
Action, Action,
BenchmarkDataStorage, BenchmarkDataStorage,
@ -977,8 +977,9 @@ def test_snapshot_wb_lite(user: UserClient):
assert '00:28:f8:a6:d5:7e' in dev.hid assert '00:28:f8:a6:d5:7e' in dev.hid
assert dev.actions[0].power_on_hours == 6032 assert dev.actions[0].power_on_hours == 6032
errors = SnapshotErrors.query.filter().all() errors = SnapshotsLog.query.filter().all()
assert errors == [] assert len(errors) == 1
assert errors[0].description == 'Ok'
@pytest.mark.mvp @pytest.mark.mvp
@ -986,9 +987,7 @@ def test_snapshot_wb_lite(user: UserClient):
def test_snapshot_wb_lite_qemu(user: UserClient): def test_snapshot_wb_lite_qemu(user: UserClient):
"""This test check the minimum validation of json that come from snapshot""" """This test check the minimum validation of json that come from snapshot"""
snapshot = file_json( snapshot = file_json("qemu-cc9927a9-55ad-4937-b36b-7185147d9fa9.json")
"qemu-cc9927a9-55ad-4937-b36b-7185147d9fa9.json"
)
body, res = user.post(snapshot, uri="/api/inventory/") body, res = user.post(snapshot, uri="/api/inventory/")
assert body['sid'] == "VL0L5" assert body['sid'] == "VL0L5"
@ -1172,8 +1171,8 @@ def test_snapshot_lite_error_in_components(user: UserClient):
dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one() dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one()
assert 'Motherboard' not in [x.type for x in dev.components] assert 'Motherboard' not in [x.type for x in dev.components]
error = SnapshotErrors.query.all()[0] error = SnapshotsLog.query.all()
assert 'StopIteration' in error.description assert 'StopIteration' in error[0].description
@pytest.mark.mvp @pytest.mark.mvp
@ -1225,12 +1224,12 @@ def test_snapshot_errors(user: UserClient):
}, },
} }
assert SnapshotErrors.query.all() == [] assert SnapshotsLog.query.all() == []
body11, res = user.post(snapshot_11, res=Snapshot) body11, res = user.post(snapshot_11, res=Snapshot)
assert SnapshotErrors.query.all() == [] assert SnapshotsLog.query.all() == []
bodyLite, res = user.post(snapshot_lite, uri="/api/inventory/") bodyLite, res = user.post(snapshot_lite, uri="/api/inventory/")
dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one() dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one()
assert len(SnapshotErrors.query.all()) == 2 assert len(SnapshotsLog.query.all()) == 3
assert body11['device'].get('hid') == dev.hid assert body11['device'].get('hid') == dev.hid
assert body11['device']['id'] == dev.id assert body11['device']['id'] == dev.id
@ -1267,7 +1266,9 @@ def test_snapshot_errors_no_serial_number(user: UserClient):
bodyLite, res = user.post(snapshot_lite, uri="/api/inventory/") bodyLite, res = user.post(snapshot_lite, uri="/api/inventory/")
assert res.status_code == 201 assert res.status_code == 201
assert len(SnapshotErrors.query.all()) == 0 logs = SnapshotsLog.query.all()
assert len(logs) == 1
assert logs[0].description == 'Ok'
dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one() dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one()
assert not dev.model assert not dev.model
assert not dev.manufacturer assert not dev.manufacturer
@ -1286,9 +1287,11 @@ def test_snapshot_errors_no_serial_number(user: UserClient):
@pytest.mark.usefixtures(conftest.app_context.__name__) @pytest.mark.usefixtures(conftest.app_context.__name__)
def test_snapshot_check_tests_lite(user: UserClient): def test_snapshot_check_tests_lite(user: UserClient):
"""This test check the minimum validation of json that come from snapshot""" """This test check the minimum validation of json that come from snapshot"""
snapshot_lite = file_json('test_lite/2022-4-13-19-5_user@dhub.com_b27dbf43-b88a-4505-ae27-10de5a95919e.json') snapshot_lite = file_json(
'test_lite/2022-4-13-19-5_user@dhub.com_b27dbf43-b88a-4505-ae27-10de5a95919e.json'
)
bodyLite, res = user.post(snapshot_lite, uri="/api/inventory/") bodyLite, res = user.post(snapshot_lite, uri="/api/inventory/")
assert res.status_code == 201 assert res.status_code == 201
SnapshotErrors.query.all() SnapshotsLog.query.all()
dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one() dev = m.Device.query.filter_by(devicehub_id=bodyLite['dhid']).one()