Update inventory; pass many tests

This commit is contained in:
Xavier Bustamante Talavera 2018-06-14 15:14:23 +02:00
parent b56fbeeca7
commit e3a887d9fa
12 changed files with 271 additions and 93 deletions

View File

@ -2,7 +2,7 @@ Events
======
.. toctree::
:maxdepth: 4
:maxdepth: 4
event-diagram
@ -12,8 +12,8 @@ Rate
Devicehub generates an rating for a device taking into consideration the
visual, functional, and performance.
.. todo:: add performance as a result of component fusion + general tests in https://
github.com/eReuse/Rdevicescore/blob/master/img/input_process_output.png
.. todo:: add performance as a result of component fusion + general tests in `here <https://
github.com/eReuse/Rdevicescore/blob/master/img/input_process_output.png>`_.
A Workflow is as follows:
@ -172,12 +172,10 @@ There are four events for getting rid of devices:
been recovered under a new product.
.. note:: For usability purposes, users might not directly perform
``Dispose``, but this could automatically be done when
performing ``ToDispose`` + ``Receive`` to a
``RecyclingCenter``.
``Dispose``, but this could automatically be done when
performing ``ToDispose`` + ``Receive`` to a ``RecyclingCenter``.
.. todo:: Ensure that ``Dispose`` is a ``Trade`` event. An Org could
``Sell`` or ``Donate`` a device with the objective of
disposing them. Is ``Dispose`` ok, or do we want to keep
that extra ``Sell`` or ``Donate`` event? Could dispose
be a synonym of any of those?
``Sell`` or ``Donate`` a device with the objective of disposing them.
Is ``Dispose`` ok, or do we want to keep that extra ``Sell`` or
``Donate`` event? Could dispose be a synonym of any of those?

View File

@ -1,22 +0,0 @@
Getting
=======
Devicehub uses the same path to get devices and lots.
To get the lot information ::
GET /inventory/24
You can specifically filter devices::
GET /inventory?devices?
GET /inventory/24?type=24&type=44&status={"name": "Reserved", "updated": "2018-01-01"}
GET /inventory/25?price=24&price=21
GET /devices/4?
Returns devices that matches the filters and the lots that contain them.
If the filters are applied to the lots, it returns the matched lots
and the devices that contain them.
You can join filters.

View File

@ -14,6 +14,7 @@ This is the documentation and API of the `eReuse.org DeviceHub
events
tags
inventory
* :ref:`genindex`
* :ref:`modindex`

61
docs/inventory.rst Normal file
View File

@ -0,0 +1,61 @@
Inventory
=======
Devicehub uses the same path to get devices and lots.
To get all devices and groups: ``GET /inventory`` or the devices of a
specific groups: ``GET /inventory/24``.
You can **filter** devices ``GET /inventory/24?filter={"type": "Computer"}``,
and **sort** them ``GET /inventory?sort={"created": 1}``, and of course
you can combine both in the same query. You only get the groups that
contain the devices that pass the filters. So, if a group contains
only one device that is filtered, you don't get that group neither.
Results are **paginated**; you get up to 30 devices and up to 30
groups in a page. Select the actual page by ``GET /inventory?page=3``.
By default you get the page number ``1``.
Query
-----
The query consists of 4 optional params:
- **search**: Filters devices by performing a full-text search over their
physical properties, events, tags, and groups they are in:
- Device.type
- Device.serial_number
- Device.model
- Device.manufacturer
- Device.color
- Tag.id
- Tag.org
- Group.name
Search is a string.
- **filter**: Filters devices field-by-field. Each field can be
filtered in different ways, see them in
:class:`ereuse_devicehub.resources.inventory.Filters`. Filter is
a JSON-encoded object whose keys are the filters. By default
is empty (no filter applied).
- **sort**: Sorts the devices. You can specify multiple sort clauses
as it is a JSON-encoded object whose keys are fields and values
are truthy for *ascending* order, or falsy for *descending* order.
By default it is sorted by ``Device.created`` descending (newest
devices first).
- **page**: A natural number that specifies the page to retrieve.
By default is ``1``; the first page.
Result
------
The result is a JSON object with the following fields:
- **devices**: A list of devices.
- **groups**: A list of groups.
- **widgets**: A dictionary of widgets.
- **pagination**: Pagination information:
- **page**: The page you requested in the ``page`` param of the query,
or ``1``.
- **perPage**: How many devices are in every page, fixed to ``30``.
- **total**: How many total devices passed the filters.

View File

@ -1,10 +1,10 @@
from typing import Any, Dict, Iterable, Tuple, Type, Union, Generator
from inspect import isclass
from typing import Any, Dict, Iterable, Tuple, Type, Union
from boltons.typeutils import issubclass
from flask import Response
from werkzeug.exceptions import HTTPException
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources import models, schemas
from ereuse_utils.test import JSON
from teal.client import Client as TealClient
from teal.marshmallow import ValidationError
@ -21,7 +21,7 @@ class Client(TealClient):
def open(self,
uri: str,
res: Union[str, Type[Thing]] = None,
res: Union[str, Type[Union[models.Thing, schemas.Thing]]] = None,
status: Union[int, Type[HTTPException], Type[ValidationError]] = 200,
query: Iterable[Tuple[str, Any]] = tuple(),
accept=JSON,
@ -30,14 +30,14 @@ class Client(TealClient):
headers: dict = None,
token: str = None,
**kw) -> Tuple[Union[Dict[str, Any], str], Response]:
if issubclass(res, Thing):
res = res.__name__
if isclass(res) and issubclass(res, (models.Thing, schemas.Thing)):
res = res.t
return super().open(uri, res, status, query, accept, content_type, item, headers, token,
**kw)
def get(self,
uri: str = '',
res: Union[Type[Thing], str] = None,
res: Union[Type[Union[models.Thing, schemas.Thing]], str] = None,
query: Iterable[Tuple[str, Any]] = tuple(),
status: Union[int, Type[HTTPException], Type[ValidationError]] = 200,
item: Union[int, str] = None,
@ -50,7 +50,7 @@ class Client(TealClient):
def post(self,
data: str or dict,
uri: str = '',
res: Union[Type[Thing], str] = None,
res: Union[Type[Union[models.Thing, schemas.Thing]], str] = None,
query: Iterable[Tuple[str, Any]] = tuple(),
status: Union[int, Type[HTTPException], Type[ValidationError]] = 201,
content_type: str = JSON,
@ -67,7 +67,7 @@ class Client(TealClient):
return self.post({'email': email, 'password': password}, '/users/login', status=200)
def get_many(self,
res: Union[Type[Thing], str],
res: Union[Type[Union[models.Thing, schemas.Thing]], str],
resources: Iterable[dict],
key: str = None,
headers: dict = None,
@ -101,7 +101,7 @@ class UserClient(Client):
def open(self,
uri: str,
res: Union[str, Type[Thing]] = None,
res: Union[str, Type[Union[models.Thing, schemas.Thing]]] = None,
status: int or HTTPException = 200,
query: Iterable[Tuple[str, Any]] = tuple(),
accept=JSON,

View File

@ -3,14 +3,14 @@ from itertools import chain
from operator import attrgetter
from typing import Dict, Set
from sqlalchemy import BigInteger, Column, Float, ForeignKey, Integer, Sequence, SmallInteger, \
Unicode, inspect, Enum as DBEnum
from sqlalchemy import BigInteger, Column, Enum as DBEnum, Float, ForeignKey, Integer, Sequence, \
SmallInteger, Unicode, inspect
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import ColumnProperty, backref, relationship
from sqlalchemy.util import OrderedSet
from sqlalchemy_utils import ColorType
from ereuse_devicehub.resources.enums import DataStorageInterface, RamInterface, RamFormat
from ereuse_devicehub.resources.enums import DataStorageInterface, RamFormat, RamInterface
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE, STR_SM_SIZE, Thing
from ereuse_utils.naming import Naming
from teal.db import CASCADE, POLYMORPHIC_ID, POLYMORPHIC_ON, ResourceNotFound, check_range

View File

@ -3,6 +3,7 @@ from typing import Dict, List, Set
from colour import Color
from sqlalchemy import Column
from ereuse_devicehub.resources.enums import RamInterface, RamFormat, DataStorageInterface
from ereuse_devicehub.resources.event.models import Event, EventWithMultipleDevices, \
EventWithOneDevice
from ereuse_devicehub.resources.image.models import ImageList
@ -91,10 +92,12 @@ class GraphicCard(Component):
class DataStorage(Component):
size = ... # type: Column
interface = ... # type: Column
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.size = ... # type: int
self.interface = ... # type: DataStorageInterface
class HardDrive(DataStorage):
@ -144,8 +147,12 @@ class Processor(Component):
class RamModule(Component):
size = ... # type: Column
speed = ... # type: Column
interface = ... # type: Column
format = ... # type: Column
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.size = ... # type: int
self.speed = ... # type: float
self.interface = ... # type: RamInterface
self.format = ... # type: RamFormat

View File

@ -366,16 +366,18 @@ class StressTest(Test):
pass
class Benchmark(EventWithOneDevice):
class Benchmark(JoinedTableMixin, EventWithOneDevice):
pass
class BenchmarkDataStorage(Benchmark):
readSpeed = Column(Float(decimal_return_scale=2), nullable=False)
writeSpeed = Column(Float(decimal_return_scale=2), nullable=False)
id = Column(UUID(as_uuid=True), ForeignKey(Benchmark.id), primary_key=True)
read_speed = Column(Float(decimal_return_scale=2), nullable=False)
write_speed = Column(Float(decimal_return_scale=2), nullable=False)
class BenchmarkWithRate(Benchmark):
id = Column(UUID(as_uuid=True), ForeignKey(Benchmark.id), primary_key=True)
rate = Column(SmallInteger, nullable=False)
@ -395,7 +397,7 @@ class BenchmarkRamSysbench(BenchmarkWithRate):
@event.listens_for(TestDataStorage.device, 'set', retval=True, propagate=True)
@event.listens_for(Install.device, 'set', retval=True, propagate=True)
@event.listens_for(EraseBasic.device, 'set', retval=True, propagate=True)
def validate_device_is_data_storage(target, value, old_value, initiator):
def validate_device_is_data_storage(target: Event, value: DataStorage, old_value, initiator):
if not isinstance(value, DataStorage):
raise TypeError('{} must be a DataStorage but you passed {}'.format(initiator.impl, value))
return value

View File

@ -8,7 +8,7 @@ from sqlalchemy.orm import relationship
from ereuse_devicehub.resources.device.models import Component, Computer, Device
from ereuse_devicehub.resources.enums import AppearanceRange, Bios, FunctionalityRange, \
RatingSoftware, SnapshotSoftware, TestHardDriveLength, SnapshotExpectedEvents
RatingSoftware, SnapshotExpectedEvents, SnapshotSoftware, TestHardDriveLength
from ereuse_devicehub.resources.image.models import Image
from ereuse_devicehub.resources.models import Thing
from ereuse_devicehub.resources.user import User
@ -112,6 +112,7 @@ class Rate(EventWithOneDevice):
rating = ... # type: Column
appearance = ... # type: Column
functionality = ... # type: Column
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.rating = ... # type: float
@ -216,3 +217,37 @@ class EraseBasic(EventWithOneDevice):
class EraseSectors(EraseBasic):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
class Benchmark(EventWithOneDevice):
pass
class BenchmarkDataStorage(Benchmark):
read_speed = ... # type: Column
write_speed = ... # type: Column
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.read_speed = ... # type: float
self.write_speed = ... # type: float
class BenchmarkWithRate(Benchmark):
rate = ... # type: Column
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.rate = ... # type: int
class BenchmarkProcessor(BenchmarkWithRate):
pass
class BenchmarkProcessorSysbench(BenchmarkProcessor):
pass
class BenchmarkRamSysbench(BenchmarkWithRate):
pass

View File

@ -1,16 +1,19 @@
from flask import current_app as app, jsonify
from flask import current_app, current_app as app, jsonify
from flask_sqlalchemy import Pagination
from marshmallow import Schema as MarshmallowSchema
from marshmallow.fields import Float, Nested, Str
from marshmallow.fields import Float, Integer, Nested, Str
from marshmallow.validate import Range
from sqlalchemy import Column
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.event.models import Rate
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.resources.tag import Tag
from teal.marshmallow import IsType
from teal.query import Between, Equal, ILike, Or, Query
from teal.resource import Resource, Schema, View
from teal.query import Between, FullTextSearch, ILike, Join, Or, Query, Sort, SortField
from teal.resource import Resource, View
class Inventory(Schema):
class Inventory(Thing):
pass
@ -25,23 +28,56 @@ class TagQ(Query):
org = ILike(Tag.org)
class OfType(Str):
def __init__(self, column: Column, *args, **kwargs):
super().__init__(*args, **kwargs)
self.column = column
def _deserialize(self, value, attr, data):
v = super()._deserialize(value, attr, data)
return self.column.in_(current_app.resources[v].subresources_types)
class Filters(Query):
type = Or(Equal(Device.type, Str(validate=IsType(Device.t))))
type = Or(OfType(Device.type))
model = ILike(Device.model)
manufacturer = ILike(Device.manufacturer)
serialNumber = ILike(Device.serial_number)
rating = Nested(RateQ) # todo db join
tag = Nested(TagQ) # todo db join
rating = Join(Device.id == Rate.device_id, RateQ)
tag = Join(Device.id == Tag.id, TagQ)
class Sorting(Sort):
created = SortField(Device.created)
class InventoryView(View):
class FindArgs(MarshmallowSchema):
where = Nested(Filters, default={})
search = FullTextSearch() # todo Develop this. See more at docs/inventory.
filter = Nested(Filters, missing=[])
sort = Nested(Sorting, missing=[Device.created.desc()])
page = Integer(validate=Range(min=1), missing=1)
def find(self, args):
devices = Device.query.filter_by()
def find(self, args: dict):
"""
Supports the inventory view of ``devicehub-client``; returns
all the devices, groups and widgets of this Devicehub instance.
The result can be filtered, sorted, and paginated.
"""
devices = Device.query \
.filter(*args['filter']) \
.order_by(*args['sort']) \
.paginate(page=args['page'], per_page=30) # type: Pagination
inventory = {
'devices': app.resources[Device.t].schema.dump()
'devices': app.resources[Device.t].schema.dump(devices.items, many=True),
'groups': [],
'widgets': {},
'pagination': {
'page': devices.page,
'perPage': devices.per_page,
'total': devices.total,
}
}
return jsonify(inventory)

View File

@ -3,8 +3,6 @@ from uuid import UUID
import pytest
from colour import Color
from ereuse_utils.naming import Naming
from pytest import raises
from sqlalchemy.util import OrderedSet
@ -20,6 +18,7 @@ from ereuse_devicehub.resources.device.sync import MismatchBetweenTags, Mismatch
from ereuse_devicehub.resources.event.models import Remove, Test
from ereuse_devicehub.resources.tag.model import Tag
from ereuse_devicehub.resources.user import User
from ereuse_utils.naming import Naming
from teal.db import ResourceNotFound
from tests.conftest import file
@ -133,7 +132,7 @@ def test_add_remove():
# c4 is not with any pc
values = file('pc-components.db')
pc = values['device']
c1, c2 = [Component(**c) for c in values['components']]
c1, c2 = (Component(**c) for c in values['components'])
pc = Computer(**pc, components=OrderedSet([c1, c2]))
db.session.add(pc)
c3 = Component(serial_number='nc1')

View File

@ -1,8 +1,11 @@
import pytest
from sqlalchemy.sql.elements import BinaryExpression
from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.inventory import Filters, InventoryView
from ereuse_devicehub.client import UserClient
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.device.models import Desktop, Device, Laptop, Microtower, \
SolidStateDrive
from ereuse_devicehub.resources.inventory import Filters, Inventory, Sorting
from teal.utils import compiled
@ -10,7 +13,7 @@ from teal.utils import compiled
def test_inventory_filters():
schema = Filters()
q = schema.load({
'type': ['Microtower', 'Laptop'],
'type': ['Computer', 'Laptop'],
'manufacturer': 'Dell',
'rating': {
'rating': [3, 6],
@ -22,28 +25,86 @@ def test_inventory_filters():
})
s, params = compiled(Device, q)
# Order between query clauses can change
assert '(device.type = %(type_1)s OR device.type = %(type_2)s)' in s
assert '(device.type IN (%(type_1)s, %(type_2)s, %(type_3)s, %(type_4)s, ' \
'%(type_5)s, %(type_6)s) OR device.type IN (%(type_7)s))' in s
assert 'device.manufacturer ILIKE %(manufacturer_1)s' in s
assert 'rate.rating BETWEEN %(rating_1)s AND %(rating_2)s' in s
assert 'rate.appearance BETWEEN %(appearance_1)s AND %(appearance_2)s' in s
assert '(tag.id ILIKE %(id_1)s OR tag.id ILIKE %(id_2)s)' in s
assert params == {
'type_1': 'Microtower',
'rating_2': 6.0,
'manufacturer_1': 'Dell%',
'appearance_1': 2.0,
'appearance_2': 4.0,
'id_1': 'bcn-%',
'rating_1': 3.0,
'id_2': 'activa-02%',
'type_2': 'Laptop'
# type_x can be assigned at different values
# ex: type_1 can be 'Desktop' in one execution but the next one 'Laptop'
assert set(params.keys()) == {
'id_1',
'manufacturer_1',
'type_4',
'type_3',
'id_2',
'type_1',
'rating_1',
'type_5',
'appearance_2',
'type_6',
'type_7',
'appearance_1',
'rating_2',
'type_2'
}
assert set(params.values()) == {
'bcn-%',
'Dell%',
'Laptop',
'Server',
'activa-02%',
'Computer',
3.0,
'Microtower',
4.0,
'Netbook',
'Laptop',
2.0,
6.0,
'Desktop'
}
@pytest.mark.usefixtures('app_context')
def test_inventory_query():
schema = InventoryView.FindArgs()
args = schema.load({
'where': {'type': ['Computer']}
})
assert isinstance(args['where'], BinaryExpression), '``where`` must be a SQLAlchemy query'
def test_inventory_sort():
schema = Sorting()
r = next(schema.load({'created': True}))
assert str(r) == 'device.created ASC'
@pytest.fixture()
def inventory_query_dummy(app: Devicehub):
with app.app_context():
db.session.add_all(( # The order matters ;-)
Desktop(serial_number='s1', model='ml1', manufacturer='mr1'),
Laptop(serial_number='s3', model='ml3', manufacturer='mr3'),
Microtower(serial_number='s2', model='ml2', manufacturer='mr2'),
SolidStateDrive(serial_number='s4', model='ml4', manufacturer='mr4')
))
db.session.commit()
@pytest.mark.usefixtures('inventory_query_dummy')
def test_inventory_query_no_filters(user: UserClient):
i, _ = user.get(res=Inventory)
assert tuple(d['type'] for d in i['devices']) == (
'SolidStateDrive', 'Microtower', 'Laptop', 'Desktop'
)
@pytest.mark.usefixtures('inventory_query_dummy')
def test_inventory_query_filter_type(user: UserClient):
i, _ = user.get(res=Inventory, query=[('filter', {'type': ['Computer', 'Microtower']})])
assert tuple(d['type'] for d in i['devices']) == ('Microtower', 'Laptop', 'Desktop')
@pytest.mark.usefixtures('inventory_query_dummy')
def test_inventory_query_filter_sort(user: UserClient):
i, _ = user.get(res=Inventory, query=[
('sort', {'created': Sorting.ASCENDING}),
('filter', {'type': ['Computer']})
])
assert tuple(d['type'] for d in i['devices']) == ('Desktop', 'Laptop', 'Microtower')