Update to functional endpoint
This commit is contained in:
parent
c4b6553c8c
commit
8723b379b0
|
@ -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.')
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
|
@ -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)
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
11
ereuse_devicehub/marshmallow.py
Normal file
11
ereuse_devicehub/marshmallow.py
Normal 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)
|
|
@ -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
|
||||||
|
|
14
ereuse_devicehub/resources/device/exceptions.py
Normal file
14
ereuse_devicehub/resources/device/exceptions.py
Normal 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)
|
|
@ -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))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
158
ereuse_devicehub/resources/device/sync.py
Normal file
158
ereuse_devicehub/resources/device/sync.py
Normal 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
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
51
ereuse_devicehub/resources/event/enums.py
Normal file
51
ereuse_devicehub/resources/event/enums.py
Normal 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'
|
|
@ -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)
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
|
|
||||||
class Remove(View):
|
|
||||||
def post(self):
|
|
||||||
"""Removes a component from a computer."""
|
|
||||||
pass
|
|
171
ereuse_devicehub/resources/event/schemas.py
Normal file
171
ereuse_devicehub/resources/event/schemas.py
Normal 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
|
|
@ -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
|
|
|
@ -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)
|
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
25
ereuse_devicehub/resources/schemas.py
Normal file
25
ereuse_devicehub/resources/schemas.py
Normal 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)
|
|
@ -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()
|
5
ereuse_devicehub/resources/user/exceptions.py
Normal file
5
ereuse_devicehub/resources/user/exceptions.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
class WrongCredentials(Unauthorized):
|
||||||
|
description = 'There is not an user with the matching username/password'
|
|
@ -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)
|
|
26
ereuse_devicehub/resources/user/models.py
Normal file
26
ereuse_devicehub/resources/user/models.py
Normal 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)
|
22
ereuse_devicehub/resources/user/schemas.py
Normal file
22
ereuse_devicehub/resources/user/schemas.py
Normal 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
|
23
ereuse_devicehub/resources/user/views.py
Normal file
23
ereuse_devicehub/resources/user/views.py
Normal 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()
|
|
@ -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)
|
||||||
|
|
24
tests/files/basic.snapshot.yaml
Normal file
24
tests/files/basic.snapshot.yaml
Normal 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
36
tests/test_auth.py
Normal 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)
|
|
@ -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
|
||||||
|
|
|
@ -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})
|
||||||
|
|
|
@ -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
61
tests/test_snapshot.py
Normal 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
82
tests/test_user.py
Normal 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)
|
Reference in a new issue