Get resources correctly with polymorphic

This commit is contained in:
Xavier Bustamante Talavera 2018-05-11 18:58:48 +02:00
parent 78b5a230d4
commit ac26d8d610
21 changed files with 344 additions and 78 deletions

View File

@ -1,7 +1,11 @@
from typing import Type, Union
from boltons.typeutils import issubclass
from ereuse_utils.test import JSON
from flask import Response
from werkzeug.exceptions import HTTPException
from ereuse_devicehub.resources.models import Thing
from teal.client import Client as TealClient
@ -10,6 +14,26 @@ class Client(TealClient):
allow_subdomain_redirects=False):
super().__init__(application, response_wrapper, use_cookies, allow_subdomain_redirects)
def open(self, uri: str, res: str or Type[Thing] = None, status: int or HTTPException = 200,
query: dict = {}, accept=JSON, content_type=JSON, item=None, headers: dict = None,
token: str = None, **kw) -> (dict or str, Response):
if issubclass(res, Thing):
res = res.__name__
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, query: dict = {},
status: int or HTTPException = 200, item: Union[int, str] = None, accept: str = JSON,
headers: dict = None, token: str = None, **kw) -> (dict or str, Response):
return super().get(uri, res, query, status, item, accept, headers, token, **kw)
def post(self, data: str or dict, uri: str = '', res: Union[Type[Thing], str] = None,
query: dict = {}, status: int or HTTPException = 201, content_type: str = JSON,
accept: str = JSON, headers: dict = None, token: str = None, **kw) -> (
dict or str, Response):
return super().post(data, uri, res, query, status, content_type, accept, headers, token,
**kw)
def login(self, email: str, password: str):
assert isinstance(email, str)
assert isinstance(password, str)

View File

@ -2,7 +2,7 @@ from distutils.version import StrictVersion
from ereuse_devicehub.resources.device import ComponentDef, ComputerDef, DesktopDef, DeviceDef, \
GraphicCardDef, HardDriveDef, LaptopDef, MicrotowerDef, MotherboardDef, NetbookDef, \
NetworkAdapterDef, RamModuleDef, ServerDef
NetworkAdapterDef, ProcessorDef, RamModuleDef, ServerDef
from ereuse_devicehub.resources.event import EventDef, SnapshotDef
from ereuse_devicehub.resources.user import UserDef
from teal.config import Config
@ -12,7 +12,7 @@ class DevicehubConfig(Config):
RESOURCE_DEFINITIONS = (
DeviceDef, ComputerDef, DesktopDef, LaptopDef, NetbookDef, ServerDef, MicrotowerDef,
ComponentDef, GraphicCardDef, HardDriveDef, MotherboardDef, NetworkAdapterDef,
RamModuleDef, UserDef, EventDef, SnapshotDef
RamModuleDef, ProcessorDef, UserDef, EventDef, SnapshotDef
)
PASSWORD_SCHEMES = {'pbkdf2_sha256'}
SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/dh-db1'

View File

@ -6,6 +6,7 @@ from teal.marshmallow import NestedOn as TealNestedOn
class NestedOn(TealNestedOn):
__doc__ = TealNestedOn.__doc__
def __init__(self, nested, polymorphic_on='type', db: SQLAlchemy = db, default=missing_,
exclude=tuple(), only=None, **kwargs):

View File

@ -1,6 +1,6 @@
from ereuse_devicehub.resources.device.schemas import Component, Computer, Desktop, Device, \
GraphicCard, HardDrive, Laptop, Microtower, Motherboard, Netbook, NetworkAdapter, RamModule, \
Server
GraphicCard, HardDrive, Laptop, Microtower, Motherboard, Netbook, NetworkAdapter, Processor, \
RamModule, Server
from ereuse_devicehub.resources.device.views import DeviceView
from teal.resource import Converters, Resource
@ -58,3 +58,7 @@ class NetworkAdapterDef(ComponentDef):
class RamModuleDef(ComponentDef):
SCHEMA = RamModule
class ProcessorDef(ComponentDef):
SCHEMA = Processor

View File

@ -144,6 +144,13 @@ class NetworkAdapter(Component):
speed = Column(SmallInteger, check_range('speed', min=10, max=10000)) # type: int
class Processor(Component):
id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
speed = Column(Float, check_range('speed', 0.1, 15))
cores = Column(SmallInteger, check_range('cores', 1, 10))
address = Column(SmallInteger, check_range('address', 8, 256))
class RamModule(Component):
id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
size = Column(SmallInteger, check_range('size', min=128, max=17000))

View File

@ -1,6 +1,8 @@
from marshmallow.fields import Float, Integer, Nested, Str
from marshmallow.validate import Length, Range
from marshmallow import post_dump
from marshmallow.fields import Float, Integer, Str
from marshmallow.validate import Length, OneOf, Range
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
@ -29,11 +31,21 @@ class Device(Thing):
height = Float(validate=Range(0.1, 3),
unit=UnitCodes.m,
description='The height of the device in meters.')
events = Nested('Event', many=True, dump_only=True, only='id')
events = NestedOn('Event', many=True, dump_only=True)
events_one = NestedOn('Event', many=True, dump_only=True, description='Not used.')
events_components = NestedOn('Event', many=True, dump_only=True, description='Not used.')
@post_dump
def merge_events(self, data: dict) -> dict:
if isinstance(data.get('events_one', None), list):
data.setdefault('events', []).extend(data.pop('events_one'))
if isinstance(data.get('events_components', None), list):
data.setdefault('events', []).extend(data.pop('events_components'))
return data
class Computer(Device):
components = Nested('Component', many=True, dump_only=True, only='id')
components = NestedOn('Component', many=True, dump_only=True)
pass
@ -58,7 +70,7 @@ class Microtower(Computer):
class Component(Device):
parent = Nested(Device, dump_only=True, only='id')
parent = NestedOn(Device, dump_only=True)
class GraphicCard(Component):
@ -71,9 +83,9 @@ class HardDrive(Component):
size = Integer(validate=Range(0, 10 ** 8),
unit=UnitCodes.mbyte,
description='The size of the hard-drive in MB.')
erasure = Nested('EraseBasic', load_only=True)
tests = Nested('TestHardDrive', many=True, load_only=True)
benchmarks = Nested('BenchmarkHardDrive', load_only=True, many=True)
erasure = NestedOn('EraseBasic', load_only=True)
tests = NestedOn('TestHardDrive', many=True, load_only=True)
benchmarks = NestedOn('BenchmarkHardDrive', load_only=True, many=True)
class Motherboard(Component):
@ -90,6 +102,12 @@ class NetworkAdapter(Component):
description='The maximum speed this network adapter can handle, in mbps.')
class Processor(Component):
speed = Float(validate=Range(min=0.1, max=15), unit=UnitCodes.ghz)
cores = Integer(validate=Range(min=1, max=10)) # todo from numberOfCores
address = Integer(validate=OneOf({8, 16, 32, 64, 128, 256}))
class RamModule(Component):
size = Integer(validate=Range(min=128, max=17000), unit=UnitCodes.mbyte)
speed = Float(validate=Range(min=100, max=10000), unit=UnitCodes.mhz)

View File

@ -160,7 +160,6 @@ class Sync:
"""
events = []
old_components = set(device.components)
adding = components - old_components
if adding:
add = Add(device=device, components=list(adding))
@ -177,5 +176,4 @@ class Sync:
removing = old_components - components
if removing:
events.append(Remove(device=device, components=list(removing)))
return events

View File

@ -5,4 +5,10 @@ from teal.resource import View
class DeviceView(View):
def one(self, id: int):
"""Gets one device."""
return Device.query.filter_by(id=id).one()
device = Device.query.filter_by(id=id).one()
return self.schema.jsonify_polymorphic(device)
def find(self, args: dict):
"""Gets many devices"""
devices = Device.query.all()
return self.schema.jsonify_polymorphic_many(devices)

View File

@ -30,17 +30,17 @@ class Event(Thing):
'hardware without margin of doubt.')
incidence = Boolean(default=False,
description='Was something wrong in this event?')
snapshot = Nested('Snapshot', dump_only=True, only='id')
snapshot = NestedOn('Snapshot', dump_only=True, only='id')
description = String(default='', description='A comment about the event.')
components = Nested(Component, dump_only=True, only='id', many=True)
components = NestedOn(Component, dump_only=True, many=True)
class EventWithOneDevice(Event):
device = Nested(Device, only='id')
device = NestedOn(Device, only='id')
class EventWithMultipleDevices(Event):
device = Nested(Device, many=True, only='id')
device = NestedOn(Device, many=True, only='id')
class Add(EventWithOneDevice):
@ -52,14 +52,14 @@ class Remove(EventWithOneDevice):
class Allocate(EventWithMultipleDevices):
to = Nested(User, only='id',
to = NestedOn(User,
description='The user the devices are allocated to.')
organization = String(validate=Length(STR_SIZE),
description='The organization where the user was when this happened.')
class Deallocate(EventWithMultipleDevices):
from_rel = Nested(User, only='id',
from_rel = Nested(User,
data_key='from',
description='The user where the devices are not allocated to anymore.')
organization = String(validate=Length(STR_SIZE),
@ -138,6 +138,7 @@ class Snapshot(EventWithOneDevice):
color = Color(description='Main color of the device.')
orientation = EnumField(Orientation, description='Is the device main stand wider or larger?')
force_creation = Boolean(data_key='forceCreation')
events = NestedOn(Event, many=True)
@validates_schema
def validate_workbench_version(self, data: dict):

View File

@ -1,6 +1,6 @@
from distutils.version import StrictVersion
from flask import request, Response
from flask import request
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.sync import Sync
@ -28,12 +28,14 @@ class SnapshotView(View):
device = s.pop('device')
components = s.pop('components') if s['software'] == SoftwareType.Workbench else None
# noinspection PyArgumentList
del s['type']
snapshot = Snapshot(**s)
snapshot.device, snapshot.events = Sync.run(device, components, snapshot.force_creation)
snapshot.components = snapshot.device.components
db.session.add(snapshot)
# transform it back
return Response(status=201)
db.session.flush() # Take to DB so we get db-generated values
ret = self.schema.jsonify(snapshot) # transform it back
ret.status_code = 201
return ret
class TestHardDriveView(View):

View File

@ -1,7 +1,9 @@
from enum import Enum
from marshmallow.fields import DateTime, List, Nested, URL, String
from marshmallow import post_load
from marshmallow.fields import DateTime, List, String, URL
from ereuse_devicehub.marshmallow import NestedOn
from teal.resource import Schema
@ -22,4 +24,8 @@ class Thing(Schema):
same_as = List(URL(dump_only=True), dump_only=True, data_key='sameAs')
updated = DateTime('iso', dump_only=True)
created = DateTime('iso', dump_only=True)
author = Nested('User', only='id', dump_only=True)
author = NestedOn('User', dump_only=True, exclude=('token',))
@post_load
def remove_type(self, data: dict):
data.pop('type', None)

View File

@ -28,9 +28,10 @@ class UserDef(Resource):
"""
Creates an user.
"""
with self.app.test_request_context():
self.schema.load({'email': email, 'password': password})
with self.app.app_context():
self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \
.load({'email': email, 'password': password})
user = User(email=email, password=password)
db.session.add(user)
db.session.commit()
return user.dump()
return self.schema.dump(user)

View File

@ -1,6 +1,6 @@
from base64 import b64encode
from marshmallow import pre_dump
from marshmallow import post_dump
from marshmallow.fields import Email, String, UUID
from ereuse_devicehub.resources.schemas import Thing
@ -10,13 +10,33 @@ class User(Thing):
id = UUID(dump_only=True)
email = Email(required=True)
password = String(load_only=True, required=True)
name = String()
token = String(dump_only=True,
description='Use this token in an Authorization header to access the app.'
'The token can change overtime.')
@pre_dump
def __init__(self,
only=None,
exclude=('token',),
prefix='',
many=False,
context=None,
load_only=(),
dump_only=(),
partial=False):
"""
Instantiates the User.
By default we exclude token from both load/dump
so they are not taken / set in normal usage by mistake.
"""
super().__init__(only, exclude, prefix, many, context, load_only, dump_only, partial)
@post_dump
def base64encode_token(self, data: dict):
"""Encodes the token to base64 so clients don't have to."""
# framework needs ':' at the end
data['token'] = b64encode(str.encode(str(data['token']) + ':'))
if 'token' in data:
# In many cases we don't dump the token (ex. relationships)
# Framework needs ':' at the end
data['token'] = b64encode(str.encode(str(data['token']) + ':')).decode()
return data

View File

@ -1,6 +1,6 @@
from uuid import UUID
from flask import current_app as app, request
from flask import g, request
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
from ereuse_devicehub.resources.user.models import User
@ -14,10 +14,13 @@ class UserView(View):
def login():
user_s = app.resources['User'].schema # type: UserS
u = user_s.load(request.get_json(), partial=('email', 'password'))
# We use custom schema as we only want to parse a subset of user
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
# noinspection PyArgumentList
u = request.get_json(schema=user_s)
user = User.query.filter_by(email=u['email']).one_or_none()
if user and user.password == u['password']:
return user_s.jsonify(user)
schema_with_token = g.resource_def.SCHEMA(exclude=set())
return schema_with_token.jsonify(user)
else:
raise WrongCredentials()

View File

@ -28,7 +28,6 @@ def _app(config: TestConfig) -> Devicehub:
@pytest.fixture()
def app(request, _app: Devicehub) -> Devicehub:
db.drop_all(app=_app) # In case the test before was killed
db.create_all(app=_app)
# More robust than 'yield'
request.addfinalizer(lambda *args, **kw: db.drop_all(app=_app))

View File

@ -0,0 +1,27 @@
device:
manufacturer: 'p1'
serialNumber: 'p1'
model: 'p1'
type: 'Desktop'
secured: False
components:
- manufacturer: 'p1c1m'
serialNumber: 'p1c1s'
type: 'Motherboard'
- manufacturer: 'p1c2m'
serialNumber: 'p1c2s'
model: 'p1c2'
speed: 1.23
cores: 2
type: 'Processor'
- manufacturer: 'p1c3m'
serialNumber: 'p1c3s'
type: 'GraphicCard'
memory: 1.5
condition:
appearance: 'A'
functionality: 'B'
elapsed: 25
software: 'Workbench'
uuid: '76860eca-c3fd-41f6-a801-6af7bd8cf832'
version: '11.0'

View File

@ -0,0 +1,16 @@
device:
manufacturer: 'p2m'
serialNumber: 'p2s'
model: 'p2'
type: 'Microtower'
secured: False
components:
- manufacturer: 'p2c1m'
serialNumber: 'p2c1s'
type: 'Motherboard'
- manufacturer: 'p1c2m'
serialNumber: 'p1c2s'
model: 'p1'
speed: 1.23
cores: 2
type: 'Processor'

View File

@ -0,0 +1,17 @@
device:
manufactuer: 'p1'
serialNumber: 'p1'
model: 'p1'
type: 'Desktop'
secured: False
components:
- manufacturer: 'p1c2m'
serialNumber: 'p1c2s'
model: 'p1'
type: 'Processor'
cores: 2
speed: 1.23
- manufacturer: 'p1c3m'
serialNumber: 'p1c3s'
type: 'GraphicCard'
memory: 1.5

View File

@ -0,0 +1,15 @@
device:
manufactuer: 'p1'
serialNumber: 'p1'
model: 'p1'
type: 'Desktop'
secured: False
components:
- manufacturer: 'p1c4m'
serialNumber: 'p1c4s'
type: 'NetworkAdapter'
speed: 1000
- manufacturer: 'p1c3m'
serialNumber: 'p1c3s'
type: 'GraphicCard'
memory: 1.5

View File

@ -1,10 +1,11 @@
import pytest
from ereuse_devicehub.client import UserClient
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.device.exceptions import NeedsId
from ereuse_devicehub.resources.device.models import Component, Computer, Desktop, Device, \
GraphicCard, Motherboard, NetworkAdapter
GraphicCard, Laptop, Microtower, Motherboard, NetworkAdapter
from ereuse_devicehub.resources.device.schemas import Device as DeviceS
from ereuse_devicehub.resources.device.sync import Sync
from ereuse_devicehub.resources.event.models import Add, Remove
@ -12,11 +13,11 @@ from teal.db import ResourceNotFound
from tests.conftest import file
def test_device_model(app: Devicehub):
@pytest.mark.usefixtures('app_context')
def test_device_model():
"""
Tests that the correctness of the device model and its relationships.
"""
with app.test_request_context():
pc = Desktop(model='p1mo', manufacturer='p1ma', serial_number='p1s')
pc.components = components = [
NetworkAdapter(model='c1mo', manufacturer='c1ma', serial_number='c1s'),
@ -52,6 +53,7 @@ def test_device_model(app: Devicehub):
assert GraphicCard.query.first() is None, 'We should have deleted it it was inside the pc'
@pytest.mark.usefixtures('app_context')
def test_device_schema():
"""Ensures the user does not upload non-writable or extra fields."""
device_s = DeviceS()
@ -172,3 +174,44 @@ def test_execute_register_computer_no_hid():
# 2: device has no HID and we force it
db_pc, _ = Sync.execute_register(pc, set(), force_creation=True)
assert pc.physical_properties == db_pc.physical_properties
def test_get_device(app: Devicehub, user: UserClient):
"""Checks GETting a Desktop with its components."""
with app.app_context():
pc = Desktop(model='p1mo', manufacturer='p1ma', serial_number='p1s')
pc.components = [
NetworkAdapter(model='c1mo', manufacturer='c1ma', serial_number='c1s'),
GraphicCard(model='c2mo', manufacturer='c2ma', memory=1500)
]
db.session.add(pc)
db.session.commit()
pc, _ = user.get(res=Device, item=1)
assert pc['events'] == []
assert 'events_components' not in pc, 'events_components are internal use only'
assert 'events_one' not in pc, 'they are internal use only'
assert 'author' not in pc
assert tuple(c['id'] for c in pc['components']) == (2, 3)
assert pc['hid'] == 'p1ma-p1s-p1mo'
assert pc['model'] == 'p1mo'
assert pc['manufacturer'] == 'p1ma'
assert pc['serialNumber'] == 'p1s'
assert pc['type'] == 'Desktop'
def test_get_devices(app: Devicehub, user: UserClient):
"""Checks GETting multiple devices."""
with app.app_context():
pc = Desktop(model='p1mo', manufacturer='p1ma', serial_number='p1s')
pc.components = [
NetworkAdapter(model='c1mo', manufacturer='c1ma', serial_number='c1s'),
GraphicCard(model='c2mo', manufacturer='c2ma', memory=1500)
]
pc1 = Microtower(model='p2mo', manufacturer='p2ma', serial_number='p2s')
pc2 = Laptop(model='p3mo', manufacturer='p3ma', serial_number='p3s')
db.session.add_all((pc, pc1, pc2))
db.session.commit()
devices, _ = user.get(res=Device)
assert tuple(d['id'] for d in devices) == (1, 2, 3, 4, 5)
assert tuple(d['type'] for d in devices) == ('Desktop', 'Microtower',
'Laptop', 'NetworkAdapter', 'GraphicCard')

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from typing import List
from uuid import uuid4
import pytest
@ -13,6 +14,44 @@ from ereuse_devicehub.resources.user.models import User
from tests.conftest import file
def assert_similar_device(device1: dict, device2: dict):
"""
Like Model.is_similar() but adapted for testing.
"""
assert isinstance(device1, dict) and device1
assert isinstance(device2, dict) and device2
for key in 'serialNumber', 'model', 'manufacturer', 'type':
assert device1.get(key, None) == device2.get(key, None)
def assert_similar_components(components1: List[dict], components2: List[dict]):
"""
Asserts that the components in components1 are
similar than the components in components2.
"""
assert len(components1) == len(components2)
for c1, c2 in zip(components1, components2):
assert_similar_device(c1, c2)
def snapshot_and_check(user: UserClient,
input_snapshot: dict,
num_events: int = 0,
perform_second_snapshot=True) -> dict:
"""
"""
snapshot, _ = user.post(res=Snapshot, data=input_snapshot)
assert len(snapshot['events']) == num_events
assert input_snapshot['device']
assert_similar_device(input_snapshot['device'], snapshot['device'])
assert_similar_components(input_snapshot['components'], snapshot['components'])
if perform_second_snapshot:
return snapshot_and_check(user, input_snapshot, num_events, False)
else:
return snapshot
@pytest.mark.usefixtures('auth_app_context')
def test_snapshot_model():
"""
@ -56,6 +95,25 @@ def test_snapshot_schema(app: Devicehub):
def test_snapshot_post(user: UserClient):
"""Tests the post snapshot endpoint (validation, etc)."""
s = file('basic.snapshot')
snapshot, _ = user.post(s, res=Snapshot.__name__)
"""
Tests the post snapshot endpoint (validation, etc)
and data correctness.
"""
snapshot = snapshot_and_check(user, file('basic.snapshot'))
assert snapshot['software'] == 'Workbench'
assert snapshot['version'] == '11.0'
assert snapshot['uuid'] == 'f5efd26e-8754-46bc-87bf-fbccc39d60d9'
assert snapshot['events'] == []
assert snapshot['elapsed'] == 4
assert snapshot['author']['id'] == user.user['id']
assert 'events' not in snapshot['device']
assert 'author' not in snapshot['device']
def test_snapshot_add_remove(user: UserClient):
s1 = file('1-device-with-components.snapshot')
snapshot_and_check(user, s1)
s2 = file('2-second-device-with-components-of-first.snapshot')
s3 = file('3-first-device-but-removing-motherboard-and-adding-processor-from-2.snapshot')
s4 = file('4-first-device-but-removing-processor.snapshot-and-adding-graphic-card')