Update to functional endpoint

This commit is contained in:
Xavier Bustamante Talavera 2018-04-27 19:16:43 +02:00
parent c4b6553c8c
commit 8723b379b0
38 changed files with 1165 additions and 242 deletions

View File

@ -1,6 +1,14 @@
from sqlalchemy.exc import DataError
from werkzeug.exceptions import Unauthorized
from ereuse_devicehub.resources.user.models import User
from teal.auth import TokenAuth from teal.auth import TokenAuth
from teal.db import ResourceNotFound
class Auth(TokenAuth): class Auth(TokenAuth):
pass def authenticate(self, token: str, *args, **kw) -> User:
try:
return User.query.filter_by(token=token).one()
except (ResourceNotFound, DataError):
raise Unauthorized('Provide a suitable token.')

View File

@ -1,5 +1,41 @@
from ereuse_utils.test import JSON
from flask import Response
from werkzeug.exceptions import HTTPException
from teal.client import Client as TealClient from teal.client import Client as TealClient
class Client(TealClient): class Client(TealClient):
pass def __init__(self, application, response_wrapper=None, use_cookies=False,
allow_subdomain_redirects=False):
super().__init__(application, response_wrapper, use_cookies, allow_subdomain_redirects)
def login(self, email: str, password: str):
assert isinstance(email, str)
assert isinstance(password, str)
return self.post({'email': email, 'password': password}, '/users/login', status=200)
class UserClient(Client):
"""
A client that identifies all of its requests with a specific user.
It will automatically perform login on the first request.
"""
def __init__(self, application,
email: str,
password: str,
response_wrapper=None,
use_cookies=False,
allow_subdomain_redirects=False):
super().__init__(application, response_wrapper, use_cookies, allow_subdomain_redirects)
self.email = email # type: str
self.password = password # type: str
self.user = None # type: dict
def open(self, uri: str, res: str = 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):
return super().open(uri, res, status, query, accept, content_type, item, headers,
self.user['token'] if self.user else token, **kw)

View File

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

View File

@ -1,5 +1,3 @@
from flask_sqlalchemy import SQLAlchemy from teal.db import SQLAlchemy
from teal.db import Model db = SQLAlchemy()
db = SQLAlchemy(model_class=Model)

View File

@ -1,6 +1,27 @@
from typing import Type
from flask_sqlalchemy import SQLAlchemy
from ereuse_devicehub.auth import Auth
from ereuse_devicehub.client import Client from ereuse_devicehub.client import Client
from ereuse_devicehub.db import db
from teal.config import Config as ConfigClass
from teal.teal import Teal from teal.teal import Teal
class Devicehub(Teal): class Devicehub(Teal):
test_client_class = Client test_client_class = Client
def __init__(self, config: ConfigClass,
db: SQLAlchemy = db,
import_name=__package__,
static_path=None,
static_url_path=None,
static_folder='static',
template_folder='templates',
instance_path=None,
instance_relative_config=False,
root_path=None,
Auth: Type[Auth] = Auth):
super().__init__(config, db, import_name, static_path, static_url_path, static_folder,
template_folder, instance_path, instance_relative_config, root_path, Auth)

View File

@ -0,0 +1,11 @@
from marshmallow.fields import missing_
from ereuse_devicehub.db import db
from teal.db import SQLAlchemy
from teal.marshmallow import NestedOn as TealNestedOn
class NestedOn(TealNestedOn):
def __init__(self, nested, polymorphic_on='type', default=missing_, exclude=tuple(),
only=None, db: SQLAlchemy = db, **kwargs):
super().__init__(nested, polymorphic_on, default, exclude, only, db, **kwargs)

View File

@ -1,9 +1,60 @@
from ereuse_devicehub.resources.device.schemas import Device from ereuse_devicehub.resources.device.schemas import Component, Computer, Desktop, Device, \
GraphicCard, HardDrive, Laptop, Microtower, Motherboard, Netbook, NetworkAdapter, RamModule, \
Server
from ereuse_devicehub.resources.device.views import DeviceView from ereuse_devicehub.resources.device.views import DeviceView
from teal.resource import Resource, Converters from teal.resource import Converters, Resource
class DeviceDef(Resource): class DeviceDef(Resource):
SCHEMA = Device SCHEMA = Device
VIEW = DeviceView VIEW = DeviceView
ID_CONVERTER = Converters.int ID_CONVERTER = Converters.int
AUTH = True
class ComputerDef(DeviceDef):
SCHEMA = Computer
class DesktopDef(ComputerDef):
SCHEMA = Desktop
class LaptopDef(ComputerDef):
SCHEMA = Laptop
class NetbookDef(ComputerDef):
SCHEMA = Netbook
class ServerDef(ComputerDef):
SCHEMA = Server
class MicrotowerDef(ComputerDef):
SCHEMA = Microtower
class ComponentDef(DeviceDef):
SCHEMA = Component
class GraphicCardDef(ComponentDef):
SCHEMA = GraphicCard
class HardDriveDef(ComponentDef):
SCHEMA = HardDrive
class MotherboardDef(ComponentDef):
SCHEMA = Motherboard
class NetworkAdapterDef(ComponentDef):
SCHEMA = NetworkAdapter
class RamModuleDef(ComponentDef):
SCHEMA = RamModule

View File

@ -0,0 +1,14 @@
from marshmallow import ValidationError
class MismatchBetweenIds(ValidationError):
def __init__(self, other_device_id: int, field: str, value: str):
message = 'The device {} has the same {} than this one ({}).'.format(other_device_id,
field, value)
super().__init__(message, field_names=[field])
class NeedsId(ValidationError):
def __init__(self):
message = 'We couldn\'t get an ID for this device. Is this a custom PC?'
super().__init__(message)

View File

@ -1,24 +1,47 @@
from contextlib import suppress
from typing import Dict, Set
from ereuse_utils.naming import Naming
from sqlalchemy import BigInteger, Column, Float, ForeignKey, Integer, Sequence, SmallInteger, \ from sqlalchemy import BigInteger, Column, Float, ForeignKey, Integer, Sequence, SmallInteger, \
Unicode Unicode, inspect
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import backref, relationship from sqlalchemy.orm import backref, relationship
from ereuse_devicehub.resources.model import STR_BIG_SIZE, STR_SIZE, Thing, check_range from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE, STR_SM_SIZE, Thing, \
from teal.db import POLYMORPHIC_ID, POLYMORPHIC_ON, CASCADE check_range
from teal.db import CASCADE, POLYMORPHIC_ID, POLYMORPHIC_ON, ResourceNotFound
class Device(Thing): class Device(Thing):
id = Column(BigInteger, Sequence('device_seq'), primary_key=True) id = Column(BigInteger, Sequence('device_seq'), primary_key=True) # type: int
type = Column(Unicode) type = Column(Unicode(STR_SM_SIZE), nullable=False)
pid = Column(Unicode(STR_SIZE), unique=True) hid = Column(Unicode(STR_BIG_SIZE), unique=True) # type: str
gid = Column(Unicode(STR_SIZE), unique=True) pid = Column(Unicode(STR_SIZE)) # type: str
hid = Column(Unicode(STR_BIG_SIZE), unique=True) gid = Column(Unicode(STR_SIZE)) # type: str
model = Column(Unicode(STR_BIG_SIZE)) model = Column(Unicode(STR_BIG_SIZE)) # type: str
manufacturer = Column(Unicode(STR_SIZE)) manufacturer = Column(Unicode(STR_SIZE)) # type: str
serial_number = Column(Unicode(STR_SIZE)) serial_number = Column(Unicode(STR_SIZE)) # type: str
weight = Column(Float(precision=3), check_range('weight', min=0.1)) weight = Column(Float(precision=3, decimal_return_scale=3),
width = Column(Float(precision=3), check_range('width', min=0.1)) check_range('weight', 0.1, 3)) # type: float
height = Column(Float(precision=3), check_range('height', min=0.1)) width = Column(Float(precision=3, decimal_return_scale=3),
check_range('width', 0.1, 3)) # type: float
height = Column(Float(precision=3, decimal_return_scale=3),
check_range('height', 0.1, 3)) # type: float
@property
def physical_properties(self) -> Dict[Column, object or None]:
"""
Fields that describe the physical properties of a device.
:return A generator where each value is a tuple with tho fields:
- Column.
- Actual value of the column or None.
"""
# todo ensure to remove materialized values when start using them
# todo or self.__table__.columns if inspect fails
return {c: getattr(self, c.name, None)
for c in inspect(self.__class__).attrs
if not c.foreign_keys and c not in {self.id, self.type}}
@declared_attr @declared_attr
def __mapper_args__(cls): def __mapper_args__(cls):
@ -34,9 +57,14 @@ class Device(Thing):
args[POLYMORPHIC_ON] = cls.type args[POLYMORPHIC_ON] = cls.type
return args return args
def __init__(self, *args, **kw) -> None:
super().__init__(*args, **kw)
with suppress(TypeError):
self.hid = Naming.hid(self.manufacturer, self.serial_number, self.model)
class Computer(Device): class Computer(Device):
id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) # type: int
class Desktop(Computer): class Desktop(Computer):
@ -60,29 +88,58 @@ class Microtower(Computer):
class Component(Device): class Component(Device):
id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) id = Column(BigInteger, ForeignKey(Device.id), primary_key=True) # type: int
parent_id = Column(BigInteger, ForeignKey('computer.id')) parent_id = Column(BigInteger, ForeignKey('computer.id'))
parent = relationship(Computer, parent = relationship(Computer,
backref=backref('components', lazy=True, cascade=CASCADE), backref=backref('components', lazy=True, cascade=CASCADE),
primaryjoin='Component.parent_id == Computer.id') primaryjoin='Component.parent_id == Computer.id') # type: Device
def similar_one(self, parent: Computer, blacklist: Set[int]) -> 'Component':
"""
Gets a component that:
- has the same parent.
- Doesn't generate HID.
- Has same physical properties.
:param parent:
:param blacklist: A set of components to not to consider
when looking for similar ones.
"""
assert self.hid is None, 'Don\'t use this method with a component that has HID'
component = self.__class__.query \
.filter_by(parent=parent, hid=None, **self.physical_properties) \
.filter(~Component.id.in_(blacklist)) \
.first()
if not component:
raise ResourceNotFound(self.type)
return component
class GraphicCard(Component): class GraphicCard(Component):
memory = Column(SmallInteger, check_range('memory', min=0.1)) id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
memory = Column(SmallInteger, check_range('memory', min=1, max=10000)) # type: int
class HardDrive(Component): class HardDrive(Component):
size = Column(Integer, check_range('size', min=0.1)) id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
size = Column(Integer, check_range('size', min=1, max=10 ** 8)) # type: int
class Motherboard(Component): class Motherboard(Component):
slots = Column(SmallInteger, check_range('slots')) id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
usb = Column(SmallInteger, check_range('usb')) slots = Column(SmallInteger, check_range('slots')) # type: int
firewire = Column(SmallInteger, check_range('firewire')) usb = Column(SmallInteger, check_range('usb')) # type: int
serial = Column(SmallInteger, check_range('serial')) firewire = Column(SmallInteger, check_range('firewire')) # type: int
pcmcia = Column(SmallInteger, check_range('pcmcia')) serial = Column(SmallInteger, check_range('serial')) # type: int
pcmcia = Column(SmallInteger, check_range('pcmcia')) # type: int
class NetworkAdapter(Component): class NetworkAdapter(Component):
speed = Column(SmallInteger, check_range('speed')) id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
speed = Column(SmallInteger, check_range('speed', min=10, max=10000)) # type: int
class RamModule(Component):
id = Column(BigInteger, ForeignKey(Component.id), primary_key=True) # type: int
size = Column(SmallInteger, check_range('size', min=128, max=17000))
speed = Column(Float, check_range('speed', min=100, max=10000))

View File

@ -1,12 +1,13 @@
from marshmallow.fields import Float, Integer, Nested, Str from marshmallow.fields import Float, Integer, Nested, Str
from marshmallow.validate import Length, Range from marshmallow.validate import Length, Range
from ereuse_devicehub.resources.model import STR_BIG_SIZE, STR_SIZE from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
from ereuse_devicehub.resources.schema import Thing from ereuse_devicehub.resources.schemas import Thing, UnitCodes
class Device(Thing): class Device(Thing):
id = Str(dump_only=True) id = Integer(dump_only=True,
description='The identifier of the device for this database.')
hid = Str(dump_only=True, hid = Str(dump_only=True,
description='The Hardware ID is the unique ID traceability systems ' description='The Hardware ID is the unique ID traceability systems '
'use to ID a device globally.') 'use to ID a device globally.')
@ -17,11 +18,17 @@ class Device(Thing):
validate=Length(max=STR_SIZE)) validate=Length(max=STR_SIZE))
model = Str(validate=Length(max=STR_BIG_SIZE)) model = Str(validate=Length(max=STR_BIG_SIZE))
manufacturer = Str(validate=Length(max=STR_SIZE)) manufacturer = Str(validate=Length(max=STR_SIZE))
serial_number = Str(load_from='serialNumber', dump_to='serialNumber') serial_number = Str(data_key='serialNumber')
product_id = Str(load_from='productId', dump_to='productId') product_id = Str(data_key='productId')
weight = Float(validate=Range(0.1, 3)) weight = Float(validate=Range(0.1, 3),
width = Float(validate=Range(0.1, 3)) unit=UnitCodes.kgm,
height = Float(validate=Range(0.1, 3)) description='The weight of the device in Kgm.')
width = Float(validate=Range(0.1, 3),
unit=UnitCodes.m,
description='The width of the device in meters.')
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 = Nested('Event', many=True, dump_only=True, only='id')
@ -51,19 +58,22 @@ class Microtower(Computer):
class Component(Device): class Component(Device):
parent = Nested(Device, dump_only=True) parent = Nested(Device, dump_only=True, only='id')
class GraphicCard(Component): class GraphicCard(Component):
memory = Integer(validate=Range(0, 10000)) memory = Integer(validate=Range(0, 10000),
unit=UnitCodes.mbyte,
description='The amount of memory of the Graphic Card in MB.')
class HardDrive(Component): class HardDrive(Component):
size = Integer(validate=Range(0, 10 ** 8)) 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) erasure = Nested('EraseBasic', load_only=True)
erasures = Nested('EraseBasic', dump_only=True, many=True) tests = Nested('TestHardDrive', many=True, load_only=True)
tests = Nested('TestHardDrive', many=True) benchmarks = Nested('BenchmarkHardDrive', load_only=True, many=True)
benchmarks = Nested('BenchmarkHardDrive', many=True)
class Motherboard(Component): class Motherboard(Component):
@ -75,4 +85,11 @@ class Motherboard(Component):
class NetworkAdapter(Component): class NetworkAdapter(Component):
speed = Integer(validate=Range(min=10, max=10000)) speed = Integer(validate=Range(min=10, max=10000),
unit=UnitCodes.mbps,
description='The maximum speed this network adapter can handle, in mbps.')
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

@ -0,0 +1,158 @@
from contextlib import suppress
from itertools import groupby
from typing import Iterable, List, Set
from psycopg2.errorcodes import UNIQUE_VIOLATION
from sqlalchemy.exc import IntegrityError
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.exceptions import NeedsId
from ereuse_devicehub.resources.device.models import Component, Computer, Device
from ereuse_devicehub.resources.event.models import Add, Remove
from teal.db import ResourceNotFound
class Sync:
"""Synchronizes the device and components with the database."""
@classmethod
def run(cls, device: Device,
components: Iterable[Component] or None,
force_creation: bool = False) -> (Device, List[Component], List[Add or Remove]):
"""
Synchronizes the device and components with the database.
Identifies if the device and components exist in the database
and updates / inserts them as necessary.
This performs Add / Remove as necessary.
:param device: The device to add / update to the database.
:param components: Components that are inside of the device.
This method performs Add and Remove events
so the device ends up with these components.
Components are added / updated accordingly.
If this is empty, all components are removed.
If this is None, it means that there is
no info about components and the already
existing components of the device (in case
the device already exists) won't be touch.
:param force_creation: Shall we create the device even if
it doesn't generate HID or have an ID?
Only for the device param.
:return: A tuple of:
1. The device from the database (with an ID).
2. The same passed-in components from the database (with
ids).
3. A list of Add / Remove (not yet added to session).
"""
blacklist = set() # Helper for execute_register()
db_device = cls.execute_register(device, blacklist, force_creation)
if id(device) != id(db_device):
# Did I get another device from db?
# In such case update the device from db with new stuff
cls.merge(device, db_device)
db_components = []
for component in components:
db_component = cls.execute_register(component, blacklist, parent=db_device)
if id(component) != id(db_component):
cls.merge(component, db_component)
db_components.append(db_component)
events = tuple()
if components is not None:
# Only perform Add / Remove when
events = cls.add_remove(db_device, set(db_components))
return db_device, db_components, events
@staticmethod
def execute_register(device: Device,
blacklist: Set[int],
force_creation: bool = False,
parent: Computer = None) -> Device:
"""
Synchronizes one device to the DB.
This method tries to update the device in the database if it
already exists, otherwise it creates a new one.
:param device: The device to synchronize to the DB.
:param blacklist: A set of components already found by
Component.similar_one(). Pass-in an empty Set.
:param force_creation: Allow creating a device even if it
doesn't generate HID or doesn't have an
ID. Only valid for non-components.
Usually used when creating non-branded
custom computers (as they don't have
S/N).
:param parent: For components, the computer that contains them.
Helper used by Component.similar_one().
:return: A synchronized device with the DB. It can be a new
device or an already existing one.
:raise NeedsId: The device has not any identifier we can use.
To still create the device use
``force_creation``.
:raise DatabaseError: Any other error from the DB.
"""
# Let's try to create the device
if not device.hid and not device.id:
# We won't be able to surely identify this device
if isinstance(device, Component):
with suppress(ResourceNotFound):
# Is there a component similar to ours?
db_component = device.similar_one(parent, blacklist)
# We blacklist this component so we
# ensure we don't get it again for another component
# with the same physical properties
blacklist.add(db_component.id)
return db_component
elif not force_creation:
raise NeedsId()
db.session.begin_nested() # Create transaction savepoint to auto-rollback on insertion err
try:
# Let's try to insert or update
db.session.insert(device)
db.session.flush()
except IntegrityError as e:
if e.orig.diag.sqlstate == UNIQUE_VIOLATION:
# This device already exists in the DB
field, value = 'az' # todo get from e.orig.diag
return Device.query.find(getattr(device.__class__, field) == value).one()
else:
raise e
else:
return device # Our device is new
@classmethod
def merge(cls, device: Device, db_device: Device):
"""
Copies the physical properties of the device to the db_device.
"""
for field, value in device.physical_properties:
if value is not None:
setattr(db_device, field.name, value)
return db_device
@classmethod
def add_remove(cls, device: Device,
new_components: Set[Component]) -> List[Add or Remove]:
"""
Generates the Add and Remove events by evaluating the
differences between the components the
:param device:
:param new_components:
:return:
"""
old_components = set(device.components)
add = Add(device=Device, components=list(new_components - old_components))
events = [
Remove(device=device, components=list(old_components - new_components)),
add
]
# For the components we are adding, let's remove them from their old parents
def get_parent(component: Component):
return component.parent
for parent, components in groupby(sorted(add.components, key=get_parent), key=get_parent):
if parent is not None:
events.append(Remove(device=parent, components=list(components)))
return events

View File

@ -1,8 +1,8 @@
from ereuse_devicehub.resources.device.models import Device
from teal.resource import View from teal.resource import View
class DeviceView(View): class DeviceView(View):
def one(self, id: int):
def one(self, id):
"""Gets one device.""" """Gets one device."""
raise NotImplementedError return Device.query.filter_by(id=id).one()

View File

@ -1,7 +1,15 @@
from ereuse_devicehub.resources.event.views import EventView from ereuse_devicehub.resources.event.schemas import Snapshot, Event
from teal.resource import Resource from ereuse_devicehub.resources.event.views import EventView, SnapshotView
from teal.resource import Converters, Resource
class EventDef(Resource): class EventDef(Resource):
SCHEMA = None SCHEMA = Event
VIEW = EventView VIEW = EventView
AUTH = True
ID_CONVERTER = Converters.int
class SnapshotDef(EventDef):
SCHEMA = Snapshot
VIEW = SnapshotView

View File

@ -0,0 +1,51 @@
from enum import Enum
class StepTypes(Enum):
Zeros = 1
Random = 2
class SoftwareType(Enum):
"""The software used to perform the Snapshot."""
Workbench = 'Workbench'
AndroidApp = 'AndroidApp'
Web = 'Web'
DesktopApp = 'DesktopApp'
class Appearance(Enum):
"""Grades the imperfections that aesthetically affect the device, but not its usage."""
Z = '0. The device is new.'
A = 'A. Is like new (without visual damage)'
B = 'B. Is in really good condition (small visual damage in difficult places to spot)'
C = 'C. Is in good condition (small visual damage in parts that are easy to spot, not screens)'
D = 'D. Is acceptable (visual damage in visible parts, not screens)'
E = 'E. Is unacceptable (considerable visual damage that can affect usage)'
class Functionality(Enum):
"""Grades the defects of a device that affect its usage."""
A = 'A. Everything works perfectly (buttons, and in case of screens there are no scratches)'
B = 'B. There is a button difficult to press or a small scratch in an edge of a screen'
C = 'C. A non-important button (or similar) doesn\'t work; screen has multiple scratches in edges'
D = 'D. Multiple buttons don\'t work; screen has visual damage resulting in uncomfortable usage'
class Bios(Enum):
"""How difficult it has been to set the bios to boot from the network."""
A = 'A. If by pressing a key you could access a boot menu with the network boot'
B = 'B. You had to get into the BIOS, and in less than 5 steps you could set the network boot'
C = 'C. Like B, but with more than 5 steps'
D = 'D. Like B or C, but you had to unlock the BIOS (i.e. by removing the battery)'
E = 'E. The device could not be booted through the network.'
class Orientation(Enum):
Vertical = 'vertical'
Horizontal = 'Horizontal'
class TestHardDriveLength(Enum):
Short = 'Short'
Extended = 'Extended'

View File

@ -1,15 +1,20 @@
from enum import Enum from datetime import timedelta
from colour import Color
from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, DateTime, Enum as DBEnum, \ from sqlalchemy import BigInteger, Boolean, CheckConstraint, Column, DateTime, Enum as DBEnum, \
ForeignKey, Integer, Interval, JSON, Sequence, SmallInteger, Unicode ForeignKey, Integer, Interval, JSON, Sequence, SmallInteger, Unicode
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import backref, relationship from sqlalchemy.orm import backref, relationship, validates
from sqlalchemy_utils import ColorType
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.models import Device from ereuse_devicehub.resources.device.models import Device
from ereuse_devicehub.resources.model import STR_SIZE, Thing, check_range from ereuse_devicehub.resources.event.enums import Appearance, Bios, Functionality, Orientation, \
from ereuse_devicehub.resources.user.model import User SoftwareType, StepTypes, TestHardDriveLength
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE, STR_SM_SIZE, Thing, \
check_range
from ereuse_devicehub.resources.user.models import User
from teal.db import CASCADE, CASCADE_OWN, INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON from teal.db import CASCADE, CASCADE_OWN, INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON
@ -21,10 +26,12 @@ class JoinedTableMixin:
class Event(Thing): class Event(Thing):
id = Column(BigInteger, Sequence('event_seq'), primary_key=True) id = Column(BigInteger, Sequence('event_seq'), primary_key=True)
title = Column(Unicode(STR_BIG_SIZE), default='', nullable=False)
date = Column(DateTime) date = Column(DateTime)
secured = Column(Boolean, default=False, nullable=False) secured = Column(Boolean, default=False, nullable=False)
type = Column(Unicode) type = Column(Unicode)
incidence = Column(Boolean, default=False, nullable=False) incidence = Column(Boolean, default=False, nullable=False)
description = Column(Unicode, default='', nullable=False)
snapshot_id = Column(BigInteger, ForeignKey('snapshot.id', snapshot_id = Column(BigInteger, ForeignKey('snapshot.id',
use_alter=True, use_alter=True,
@ -33,7 +40,7 @@ class Event(Thing):
backref=backref('events', lazy=True, cascade=CASCADE), backref=backref('events', lazy=True, cascade=CASCADE),
primaryjoin='Event.snapshot_id == Snapshot.id') primaryjoin='Event.snapshot_id == Snapshot.id')
author_id = Column(BigInteger, ForeignKey(User.id), nullable=False) author_id = Column(UUID(as_uuid=True), ForeignKey(User.id), nullable=False)
author = relationship(User, author = relationship(User,
backref=backref('events', lazy=True), backref=backref('events', lazy=True),
primaryjoin=author_id == User.id) primaryjoin=author_id == User.id)
@ -94,13 +101,15 @@ class Remove(EventWithOneDevice):
class Allocate(JoinedTableMixin, EventWithMultipleDevices): class Allocate(JoinedTableMixin, EventWithMultipleDevices):
to_id = Column(BigInteger, ForeignKey(User.id)) to_id = Column(UUID, ForeignKey(User.id))
to = relationship(User, primaryjoin=User.id == to_id) to = relationship(User, primaryjoin=User.id == to_id)
organization = Column(Unicode(STR_SIZE))
class Deallocate(JoinedTableMixin, EventWithMultipleDevices): class Deallocate(JoinedTableMixin, EventWithMultipleDevices):
from_id = Column(BigInteger, ForeignKey(User.id)) from_id = Column(UUID, ForeignKey(User.id))
from_rel = relationship(User, primaryjoin=User.id == from_id) from_rel = relationship(User, primaryjoin=User.id == from_id)
organization = Column(Unicode(STR_SIZE))
class EraseBasic(JoinedTableMixin, EventWithOneDevice): class EraseBasic(JoinedTableMixin, EventWithOneDevice):
@ -116,14 +125,9 @@ class EraseSectors(EraseBasic):
pass pass
class StepTypes(Enum):
Zeros = 1
Random = 2
class Step(db.Model): class Step(db.Model):
id = Column(BigInteger, Sequence('step_seq'), primary_key=True) id = Column(BigInteger, Sequence('step_seq'), primary_key=True)
num = Column(SmallInteger, primary_key=True) num = Column(SmallInteger, nullable=False)
type = Column(DBEnum(StepTypes), nullable=False) type = Column(DBEnum(StepTypes), nullable=False)
success = Column(Boolean, nullable=False) success = Column(Boolean, nullable=False)
starting_time = Column(DateTime, nullable=False) starting_time = Column(DateTime, nullable=False)
@ -136,55 +140,37 @@ class Step(db.Model):
erasure = relationship(EraseBasic, backref=backref('steps', cascade=CASCADE_OWN)) erasure = relationship(EraseBasic, backref=backref('steps', cascade=CASCADE_OWN))
class SoftwareType(Enum):
Workbench = 'Workbench'
AndroidApp = 'AndroidApp'
Web = 'Web'
DesktopApp = 'DesktopApp'
class Appearance(Enum):
"""Grades the imperfections that aesthetically affect the device, but not its usage."""
Z = '0. The device is new.'
A = 'A. Is like new (without visual damage)'
B = 'B. Is in really good condition (small visual damage in difficult places to spot)'
C = 'C. Is in good condition (small visual damage in parts that are easy to spot, not screens)'
D = 'D. Is acceptable (visual damage in visible parts, not ¬screens)'
E = 'E. Is unacceptable (considerable visual damage that can affect usage)'
class Functionality(Enum):
A = 'A. Everything works perfectly (buttons, and in case of screens there are no scratches)'
B = 'B. There is a button difficult to press or a small scratch in an edge of a screen'
C = 'C. A non-important button (or similar) doesn\'t work; screen has multiple scratches in edges'
D = 'D. Multiple buttons don\'t work; screen has visual damage resulting in uncomfortable usage'
class Bios(Enum):
A = 'A. If by pressing a key you could access a boot menu with the network boot'
B = 'B. You had to get into the BIOS, and in less than 5 steps you could set the network boot'
C = 'C. Like B, but with more than 5 steps'
D = 'D. Like B or C, but you had to unlock the BIOS (i.e. by removing the battery)'
E = 'E. The device could not be booted through the network.'
class Snapshot(JoinedTableMixin, EventWithOneDevice): class Snapshot(JoinedTableMixin, EventWithOneDevice):
uuid = Column(UUID(as_uuid=True), nullable=False, unique=True) uuid = Column(UUID(as_uuid=True), nullable=False, unique=True) # type: UUID
version = Column(Unicode, nullable=False) version = Column(Unicode(STR_SM_SIZE), nullable=False) # type: str
snapshot_software = Column(DBEnum(SoftwareType), nullable=False) software = Column(DBEnum(SoftwareType), nullable=False) # type: SoftwareType
appearance = Column(DBEnum(Appearance), nullable=False) appearance = Column(DBEnum(Appearance), nullable=False) # type: Appearance
appearance_score = Column(SmallInteger, nullable=False) appearance_score = Column(SmallInteger,
functionality = Column(DBEnum(Functionality), nullable=False) check_range('appearance_score', -3, 5),
functionality_score = Column(SmallInteger, check_range('functionality_score', min=0, max=5), nullable=False) # type: int
nullable=False) functionality = Column(DBEnum(Functionality), nullable=False) # type: Functionality
labelling = Column(Boolean, nullable=False) functionality_score = Column(SmallInteger,
bios = Column(DBEnum(Bios), nullable=False) check_range('functionality_score', min=-3, max=5),
condition = Column(SmallInteger, check_range('condition', min=0, max=5), nullable=False) nullable=False) # type: int
elapsed = Column(Interval, nullable=False) labelling = Column(Boolean) # type: bool
install_name = Column(Unicode) bios = Column(DBEnum(Bios)) # type: Bios
install_elapsed = Column(Interval) condition = Column(SmallInteger,
install_success = Column(Boolean) check_range('condition', min=0, max=5),
inventory_elapsed = Column(Interval) nullable=False) # type: int
elapsed = Column(Interval, nullable=False) # type: timedelta
install_name = Column(Unicode(STR_BIG_SIZE)) # type: str
install_elapsed = Column(Interval) # type: timedelta
install_success = Column(Boolean) # type: bool
inventory_elapsed = Column(Interval) # type: timedelta
color = Column(ColorType) # type: Color
orientation = DBEnum(Orientation) # type: Orientation
@validates('components')
def validate_components_only_workbench(self, _, components):
if self.software != SoftwareType.Workbench:
if components:
raise ValueError('Only Snapshots from Workbench can have components.')
return components
class SnapshotRequest(db.Model): class SnapshotRequest(db.Model):
@ -202,11 +188,6 @@ class Test(JoinedTableMixin, EventWithOneDevice):
snapshot = relationship(Snapshot, backref=backref('tests', lazy=True, cascade=CASCADE_OWN)) snapshot = relationship(Snapshot, backref=backref('tests', lazy=True, cascade=CASCADE_OWN))
class TestHardDriveLength(Enum):
Short = 'Short'
Extended = 'Extended'
class TestHardDrive(Test): class TestHardDrive(Test):
length = Column(DBEnum(TestHardDriveLength), nullable=False) # todo from type length = Column(DBEnum(TestHardDriveLength), nullable=False) # todo from type
status = Column(Unicode(STR_SIZE), nullable=False) status = Column(Unicode(STR_SIZE), nullable=False)

View File

@ -1,7 +0,0 @@
from teal.resource import View
class Remove(View):
def post(self):
"""Removes a component from a computer."""
pass

View File

@ -0,0 +1,171 @@
from flask import current_app as app
from marshmallow import ValidationError, validates_schema
from marshmallow.fields import Boolean, DateTime, Integer, Nested, String, TimeDelta, UUID
from marshmallow.validate import Length, Range
from marshmallow_enum import EnumField
from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.device.schemas import Component, Device
from ereuse_devicehub.resources.event.enums import Appearance, Bios, Functionality, Orientation, \
SoftwareType, StepTypes, TestHardDriveLength
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
from ereuse_devicehub.resources.schemas import Thing
from ereuse_devicehub.resources.user.schemas import User
from teal.marshmallow import Color, Version
from teal.resource import Schema
class Event(Thing):
id = Integer(dump_only=True)
title = String(default='',
validate=Length(STR_BIG_SIZE),
description='A name or title for the event. Used when searching for events.')
date = DateTime('iso', description='When this event happened. '
'Leave it blank if it is happening now. '
'This is used when creating events retroactively.')
secured = Boolean(default=False,
description='Can we ensure the info in this event is totally correct?'
'Devicehub will automatically set this too for some events,'
'for example in snapshots if it could detect the ids of the'
'hardware without margin of doubt.')
incidence = Boolean(default=False,
description='Was something wrong in this event?')
snapshot = Nested('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)
class EventWithOneDevice(Event):
device = Nested(Device, only='id')
class EventWithMultipleDevices(Event):
device = Nested(Device, many=True, only='id')
class Add(EventWithOneDevice):
pass
class Remove(EventWithOneDevice):
pass
class Allocate(EventWithMultipleDevices):
to = Nested(User, only='id',
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',
data_key='from',
description='The user where the devices are not allocated to anymore.')
organization = String(validate=Length(STR_SIZE),
description='The organization where the user was when this happened.')
class EraseBasic(EventWithOneDevice):
starting_time = DateTime(required=True, data_key='startingTime')
ending_time = DateTime(required=True, data_key='endingTime')
secure_random_steps = Integer(validate=Range(min=0), required=True,
data_key='secureRandomSteps')
success = Boolean(required=True)
clean_with_zeros = Boolean(required=True, data_key='cleanWithZeros')
class EraseSectors(EraseBasic):
pass
class Step(Schema):
id = Integer(dump_only=True)
type = EnumField(StepTypes, required=True)
starting_time = DateTime(required=True, data_key='startingTime')
ending_time = DateTime(required=True, data_key='endingTime')
secure_random_steps = Integer(validate=Range(min=0),
required=True,
data_key='secureRandomSteps')
success = Boolean(required=True)
clean_with_zeros = Boolean(required=True, data_key='cleanWithZeros')
class Condition(Schema):
appearance = EnumField(Appearance,
required=True,
description='Grades the imperfections that aesthetically '
'affect the device, but not its usage.')
appearance_score = Integer(validate=Range(-3, 5), dump_only=True)
functionality = EnumField(Functionality,
required=True,
description='Grades the defects of a device that affect its usage.')
functionality_score = Integer(validate=Range(-3, 5),
dump_only=True,
data_key='functionalityScore')
labelling = Boolean(description='Sets if there are labels stuck that should be removed.')
bios = EnumField(Bios, description='How difficult it has been to set the bios to '
'boot from the network.')
general = Integer(dump_only=True,
validate=Range(0, 5),
description='The grade of the device.')
class Installation(Schema):
name = String(validate=Length(STR_BIG_SIZE),
required=True,
description='The name of the OS installed.')
elapsed = TimeDelta(precision=TimeDelta.SECONDS, required=True)
success = Boolean(required=True)
class Inventory(Schema):
elapsed = TimeDelta(precision=TimeDelta.SECONDS, required=True)
class Snapshot(EventWithOneDevice):
device = NestedOn(Device) # todo and when dumping?
components = NestedOn(Component, many=True)
uuid = UUID(required=True)
version = Version(required=True, description='The version of the SnapshotSoftware.')
software = EnumField(SoftwareType,
required=True,
description='The software that generated this Snapshot.')
condition = Nested(Condition, required=True)
install = Nested(Installation)
elapsed = TimeDelta(precision=TimeDelta.SECONDS, required=True)
inventory = Nested(Inventory)
color = Color(description='Main color of the device.')
orientation = EnumField(Orientation, description='Is the device main stand wider or larger?')
@validates_schema
def validate_workbench_version(self, data: dict):
if data['software'] == SoftwareType.Workbench:
if data['version'] < app.config['MIN_WORKBENCH']:
raise ValidationError(
'Min. supported Workbench version is {}'.format(app.config['MIN_WORKBENCH']),
field_names=['version']
)
@validates_schema
def validate_components_only_workbench(self, data: dict):
if data['software'] != SoftwareType.Workbench:
if data['components'] is not None:
raise ValidationError('Only Workbench can add component info',
field_names=['components'])
class Test(EventWithOneDevice):
elapsed = TimeDelta(precision=TimeDelta.SECONDS, required=True)
success = Boolean(required=True)
class TestHardDrive(Test):
length = EnumField(TestHardDriveLength, required=True)
status = String(validate=Length(max=STR_SIZE), required=True)
lifetime = TimeDelta(precision=TimeDelta.DAYS, required=True)
first_error = Integer()
class StressTest(Test):
pass

View File

@ -1,7 +0,0 @@
from ereuse_devicehub.resources.event import EventDef
from ereuse_devicehub.resources.event.snapshot.views import SnapshotView
class SnapshotDef(EventDef):
VIEW = SnapshotView
SCHEMA = None

View File

@ -1,17 +0,0 @@
from teal.resource import View
class SnapshotView(View):
def post(self):
"""Creates a Snapshot."""
return super().post()
def delete(self, id):
"""Deletes a Snapshot"""
return super().delete(id)
def patch(self, id):
"""Modifies a Snapshot"""
return super().patch(id)

View File

@ -1,7 +1,43 @@
from distutils.version import StrictVersion
from flask import request
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.device.sync import Sync
from ereuse_devicehub.resources.event.enums import SoftwareType
from ereuse_devicehub.resources.event.models import Event, Snapshot, TestHardDrive
from teal.resource import View from teal.resource import View
class EventView(View): class EventView(View):
def one(self, id): def one(self, id: int):
"""Gets one event.""" """Gets one event."""
return super().one(id) return Event.query.filter_by(id=id).one()
SUPPORTED_WORKBENCH = StrictVersion('11.0')
class SnapshotView(View):
def post(self):
"""Creates a Snapshot."""
snapshot = Snapshot(**request.get_json()) # todo put this in schema.load()?
# noinspection PyArgumentList
c = snapshot.components if snapshot.software == SoftwareType.Workbench else None
snapshot.device, snapshot.components, snapshot.events = Sync.run(snapshot.device, c)
db.session.add(snapshot)
# transform it back
return self.schema.jsonify(snapshot)
class TestHardDriveView(View):
def post(self):
t = request.get_json() # type: dict
# noinspection PyArgumentList
test = TestHardDrive(snapshot_id=t.pop('snapshot'), device_id=t.pop('device'), **t)
return test
class StressTestView(View):
def post(self):
t = request.get_json() # type: dict

View File

@ -1,10 +0,0 @@
from marshmallow.fields import DateTime, List, Str, URL, Nested
from teal.resource import Schema
class Thing(Schema):
url = URL(dump_only=True, description='The URL of the resource.')
same_as = List(URL(dump_only=True), dump_only=True)
updated = DateTime('iso', dump_only=True)
created = DateTime('iso', dump_only=True)
author = Nested('User', only='id', dump_only=True)

View File

@ -0,0 +1,25 @@
from enum import Enum
from marshmallow.fields import DateTime, List, Nested, URL, String
from teal.resource import Schema
class UnitCodes(Enum):
mbyte = '4L'
mbps = 'E20'
mhz = 'MHZ'
gbyte = 'E34'
ghz = 'A86'
bit = 'A99'
kgm = 'KGM'
m = 'MTR'
class Thing(Schema):
type = String(description='Only required when it is nested.')
url = URL(dump_only=True, description='The URL of the resource.')
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)

View File

@ -0,0 +1,36 @@
from click import argument, option
from ereuse_devicehub import devicehub
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.resources.user.schemas import User as UserS
from ereuse_devicehub.resources.user.views import UserView, login
from teal.resource import Converters, Resource
class UserDef(Resource):
SCHEMA = UserS
VIEW = UserView
ID_CONVERTER = Converters.uid
AUTH = True
def __init__(self, app: 'devicehub.Devicehub', import_name=__package__, static_folder=None,
static_url_path=None, template_folder=None, url_prefix=None, subdomain=None,
url_defaults=None, root_path=None):
cli_commands = ((self.create_user, 'user create'),)
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
self.add_url_rule('/login', view_func=login, methods={'POST'})
@argument('email')
@option('--password', prompt=True, hide_input=True, confirmation_prompt=True)
def create_user(self, email: str, password: str) -> dict:
"""
Creates an user.
"""
with self.app.test_request_context():
self.schema.load({'email': email, 'password': password})
user = User(email=email, password=password)
db.session.add(user)
db.session.commit()
return user.dump()

View File

@ -0,0 +1,5 @@
from werkzeug.exceptions import Unauthorized
class WrongCredentials(Unauthorized):
description = 'There is not an user with the matching username/password'

View File

@ -1,9 +0,0 @@
from sqlalchemy import BigInteger, Column, Sequence
from sqlalchemy_utils import EmailType
from ereuse_devicehub.resources.model import Thing
class User(Thing):
id = Column(BigInteger, Sequence('user_seq'), primary_key=True)
email = Column(EmailType, nullable=False)

View File

@ -0,0 +1,26 @@
from uuid import uuid4
from flask import current_app
from sqlalchemy import Column, Unicode
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy_utils import EmailType, PasswordType
from ereuse_devicehub.resources.models import STR_SIZE, Thing
class User(Thing):
__table_args__ = {'schema': 'common'}
id = Column(UUID(as_uuid=True), default=uuid4, primary_key=True)
email = Column(EmailType, nullable=False, unique=True)
password = Column(PasswordType(max_length=STR_SIZE,
onload=lambda **kwargs: dict(
schemes=current_app.config['PASSWORD_SCHEMES'],
**kwargs
)))
"""
Password field.
From `here <https://sqlalchemy-utils.readthedocs.io/en/latest/
data_types.html#module-sqlalchemy_utils.types.password>`_
"""
name = Column(Unicode(length=STR_SIZE))
token = Column(UUID(as_uuid=True), default=uuid4, unique=True)

View File

@ -0,0 +1,22 @@
from base64 import b64encode
from marshmallow import pre_dump
from marshmallow.fields import Email, String, UUID
from ereuse_devicehub.resources.schemas import Thing
class User(Thing):
id = UUID(dump_only=True)
email = Email(required=True)
password = String(load_only=True, required=True)
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 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']) + ':'))
return data

View File

@ -0,0 +1,23 @@
from uuid import UUID
from flask import current_app as app, request
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.resources.user.schemas import User as UserS
from teal.resource import View
class UserView(View):
def one(self, id: UUID):
return self.schema.jsonify(User.query.filter_by(id=id).one())
def login():
user_s = app.resources['User'].schema # type: UserS
u = user_s.load(request.get_json(), partial=('email', 'password'))
user = User.query.filter_by(email=u['email']).one_or_none()
if user and user.password == u['password']:
return user_s.jsonify(user)
else:
raise WrongCredentials()

View File

@ -1,31 +1,67 @@
import pytest import json as stdlib_json
from pathlib import Path
from ereuse_devicehub.client import Client import pytest
import yaml
from ereuse_devicehub.client import Client, UserClient
from ereuse_devicehub.config import DevicehubConfig from ereuse_devicehub.config import DevicehubConfig
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.resources.user.models import User
class TestConfig(DevicehubConfig): class TestConfig(DevicehubConfig):
SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/dh_test' SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/dh_test'
SQLALCHEMY_BINDS = { SCHEMA = 'test'
'common': 'postgresql://localhost/dh_test_common' TESTING = True
}
@pytest.fixture() @pytest.fixture(scope='module')
def config(): def config():
return TestConfig() return TestConfig()
@pytest.fixture(scope='module')
def _app(config: TestConfig) -> Devicehub:
return Devicehub(config=config, db=db)
@pytest.fixture() @pytest.fixture()
def app(config: TestConfig) -> Devicehub: def app(request, _app: Devicehub) -> Devicehub:
app = Devicehub(config=config, db=db) db.drop_all(app=_app) # In case the test before was killed
db.create_all(app=app) db.create_all(app=_app)
yield app # More robust than 'yield'
db.drop_all(app=app) request.addfinalizer(lambda *args, **kw: db.drop_all(app=_app))
return _app
@pytest.fixture() @pytest.fixture()
def client(app: Devicehub) -> Client: def client(app: Devicehub) -> Client:
return app.test_client() return app.test_client()
@pytest.fixture()
def user(app: Devicehub) -> UserClient:
"""Gets a client with a logged-in dummy user."""
with app.app_context():
user = create_user()
client = UserClient(application=app,
response_wrapper=app.response_class,
email=user.email,
password='foo')
client.user, _ = client.login(client.email, client.password)
return client
def create_user(email='foo@foo.com', password='foo') -> User:
user = User(email=email, password=password)
db.session.add(user)
db.session.commit()
return user
def file(name: str) -> dict:
"""Opens and parses a JSON file from the ``files`` subdir."""
with Path(__file__).parent.joinpath('files').joinpath(name + '.yaml').open() as f:
return yaml.load(f)

View File

@ -0,0 +1,24 @@
uuid: 'f5efd26e-8754-46bc-87bf-fbccc39d60d9'
type: 'Snapshot'
version: '11.0'
software: 'Workbench'
condition:
appearance: 'A'
functionality: 'B'
labelling: True
bios: 'B'
elapsed: 4
device:
type: 'Microtower'
serialNumber: 'd1s'
model: 'd1ml'
manufacturer: 'd1mr'
components:
- type: 'GraphicCard'
serialNumber: 'gc1s'
model: 'gc1ml'
manufacturer: 'gc1mr'
- type: 'RamModule'
serialNumber: 'rm1s'
model: 'rm1ml'
manufacturer: 'rm1mr'

36
tests/test_auth.py Normal file
View File

@ -0,0 +1,36 @@
from uuid import uuid4
import pytest
from werkzeug.exceptions import Unauthorized
from ereuse_devicehub.client import UserClient, Client
from ereuse_devicehub.devicehub import Devicehub
from tests.conftest import create_user
def test_authenticate_success(app: Devicehub):
"""Checks the authenticate method."""
with app.app_context():
user = create_user()
response_user = app.auth.authenticate(token=str(user.token))
assert response_user == user
def test_authenticate_error(app: Devicehub):
"""Tests the authenticate method with wrong token values."""
with app.app_context():
MESSAGE = 'Provide a suitable token.'
create_user()
# Token doesn't exist
with pytest.raises(Unauthorized, message=MESSAGE):
app.auth.authenticate(token=str(uuid4()))
# Wrong token format
with pytest.raises(Unauthorized, message=MESSAGE):
app.auth.authenticate(token='this is a wrong uuid')
def test_auth_view(user: UserClient, client: Client):
"""Tests authentication at endpoint / view."""
user.get(res='User', item=user.user['id'], status=200)
client.get(res='User', item=user.user['id'], status=Unauthorized)
client.get(res='User', item=user.user['id'], token='wrong token', status=Unauthorized)

View File

@ -1,12 +1,14 @@
from datetime import datetime, timedelta import pytest
from uuid import uuid4
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.device.models import Desktop, NetworkAdapter
from ereuse_devicehub.resources.event.models import Snapshot, SoftwareType, Appearance, \
Functionality, Bios def test_dependencies():
from ereuse_devicehub.resources.user.model import User with pytest.raises(ImportError):
# Simplejson has a different signature than stdlib json
# should be fixed though
# noinspection PyUnresolvedReferences
import simplejson
# noinspection PyArgumentList # noinspection PyArgumentList

View File

@ -1,9 +1,13 @@
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.resources.device.models import Desktop, GraphicCard, NetworkAdapter, Device from ereuse_devicehub.resources.device.models import Desktop, Device, GraphicCard, NetworkAdapter
from ereuse_devicehub.resources.device.schemas import Device as DeviceS
def test_device_model(app: Devicehub): def test_device_model(app: Devicehub):
"""
Tests that the correctness of the device model and its relationships.
"""
with app.test_request_context(): with app.test_request_context():
pc = Desktop(model='p1mo', manufacturer='p1ma', serial_number='p1s') pc = Desktop(model='p1mo', manufacturer='p1ma', serial_number='p1s')
pc.components = components = [ pc.components = components = [
@ -38,3 +42,11 @@ def test_device_model(app: Devicehub):
assert NetworkAdapter.query.first() is not None, 'We removed the network adaptor' assert NetworkAdapter.query.first() is not None, 'We removed the network adaptor'
assert gcard.id == 3, 'We should still hold a reference to a zombie graphic card' assert gcard.id == 3, 'We should still hold a reference to a zombie graphic card'
assert GraphicCard.query.first() is None, 'We should have deleted it it was inside the pc' assert GraphicCard.query.first() is None, 'We should have deleted it it was inside the pc'
def test_device_schema():
"""Ensures the user does not upload non-writable or extra fields."""
device_s = DeviceS()
device_s.load({'serial_number': 'foo1', 'model': 'foo', 'manufacturer': 'bar2'})
device_s.dump({'id': 1})

View File

@ -1,48 +0,0 @@
from datetime import datetime, timedelta
from uuid import uuid4
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.device.models import Microtower, Device
from ereuse_devicehub.resources.event.models import Snapshot, SoftwareType, Appearance, \
Functionality, Bios, SnapshotRequest, TestHardDrive, StressTest
from ereuse_devicehub.resources.user.model import User
# noinspection PyArgumentList
def test_event_model(app: Devicehub):
"""
Tests creating a Snapshot with its relationships ensuring correct
DB mapping.
"""
with app.test_request_context():
user = User(email='foo@bar.com')
device = Microtower(serial_number='a1')
snapshot = Snapshot(uuid=uuid4(),
date=datetime.now(),
version='1.0',
snapshot_software=SoftwareType.DesktopApp,
appearance=Appearance.A,
appearance_score=5,
functionality=Functionality.A,
functionality_score=5,
labelling=False,
bios=Bios.C,
condition=5,
elapsed=timedelta(seconds=25))
snapshot.device = device
snapshot.author = user
snapshot.request = SnapshotRequest(request={'foo': 'bar'})
db.session.add(snapshot)
db.session.commit()
device = Microtower.query.one() # type: Microtower
assert device.events_one[0].type == Snapshot.__name__
db.session.delete(device)
db.session.commit()
assert Snapshot.query.one_or_none() is None
assert SnapshotRequest.query.one_or_none() is None
assert User.query.one() is not None
assert Microtower.query.one_or_none() is None
assert Device.query.one_or_none() is None

61
tests/test_snapshot.py Normal file
View File

@ -0,0 +1,61 @@
from datetime import datetime, timedelta
from uuid import uuid4
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 Device, Microtower
from ereuse_devicehub.resources.event.models import Appearance, Bios, Functionality, Snapshot, \
SnapshotRequest, SoftwareType
from ereuse_devicehub.resources.user.models import User
from tests.conftest import file
def test_snapshot_model(app: Devicehub):
"""
Tests creating a Snapshot with its relationships ensuring correct
DB mapping.
"""
with app.app_context():
user = User(email='foo@bar.com')
device = Microtower(serial_number='a1')
# noinspection PyArgumentList
snapshot = Snapshot(uuid=uuid4(),
date=datetime.now(),
version='1.0',
software=SoftwareType.DesktopApp,
appearance=Appearance.A,
appearance_score=5,
functionality=Functionality.A,
functionality_score=5,
labelling=False,
bios=Bios.C,
condition=5,
elapsed=timedelta(seconds=25))
snapshot.device = device
snapshot.author = user
snapshot.request = SnapshotRequest(request={'foo': 'bar'})
db.session.add(snapshot)
db.session.commit()
device = Microtower.query.one() # type: Microtower
assert device.events_one[0].type == Snapshot.__name__
db.session.delete(device)
db.session.commit()
assert Snapshot.query.one_or_none() is None
assert SnapshotRequest.query.one_or_none() is None
assert User.query.one() is not None
assert Microtower.query.one_or_none() is None
assert Device.query.one_or_none() is None
def test_snapshot_schema(app: Devicehub):
with app.app_context():
s = file('basic.snapshot')
app.resources['Snapshot'].schema.load(s)
def test_snapshot_post(user: UserClient):
"""Tests the post snapshot endpoint (validation, etc)."""
s = file('basic.snapshot')
snapshot, _ = user.post(s, res=Snapshot.__name__)

82
tests/test_user.py Normal file
View File

@ -0,0 +1,82 @@
from base64 import b64decode
from uuid import UUID
from sqlalchemy_utils import Password
from werkzeug.exceptions import NotFound, Unauthorized, UnprocessableEntity
from ereuse_devicehub.client import Client
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.user import UserDef
from ereuse_devicehub.resources.user.models import User
from tests.conftest import create_user
def test_create_user_method(app: Devicehub):
"""
Tests creating an user through the main method.
This method checks that the token is correct, too.
"""
with app.app_context():
user_def = app.resources['User'] # type: UserDef
u = user_def.create_user(email='foo@foo.com', password='foo')
user = User.query.filter_by(id=u['id']).one() # type: User
assert user.email == 'foo@foo.com'
assert isinstance(user.token, UUID)
assert User.query.filter_by(email='foo@foo.com').one() == user
def test_create_user_email_insensitive(app: Devicehub):
"""Ensures email is case insensitive."""
with app.app_context():
user = User(email='FOO@foo.com')
db.session.add(user)
# We search in case insensitive manner
u1 = User.query.filter_by(email='foo@foo.com').one()
assert u1 == user
assert u1.email == 'FOO@foo.com'
def test_hash_password(app: Devicehub):
"""Tests correct password hashing and equaling."""
with app.app_context():
user = create_user()
assert isinstance(user.password, Password)
assert user.password == 'foo'
def test_login_success(client: Client, app: Devicehub):
"""
Tests successfully performing login.
This checks that:
- User is returned.
- User has token.
- User has not the password.
"""
with app.app_context():
create_user()
user, _ = client.post({'email': 'foo@foo.com', 'password': 'foo'},
uri='/users/login',
status=200)
assert user['email'] == 'foo@foo.com'
assert UUID(b64decode(user['token'].encode()).decode()[:-1])
assert 'password' not in user
def test_login_failure(client: Client, app: Devicehub):
"""Tests performing wrong login."""
# Wrong password
with app.app_context():
create_user()
client.post({'email': 'foo@foo.com', 'password': 'wrong pass'},
uri='/users/login',
status=Unauthorized)
# Wrong URI
client.post({}, uri='/wrong-uri', status=NotFound)
# Malformed data
client.post({}, uri='/users/login', status=UnprocessableEntity)
client.post({'email': 'this is not an email', 'password': 'nope'},
uri='/users/login',
status=UnprocessableEntity)