Add inventories with dispatcher

This commit is contained in:
Xavier Bustamante Talavera 2019-01-23 16:55:04 +01:00
parent 79feb33aa3
commit f570e9d3d0
27 changed files with 414 additions and 99 deletions

28
ereuse_devicehub/cli.py Normal file
View File

@ -0,0 +1,28 @@
import os
import click.testing
import flask.cli
from ereuse_devicehub.config import DevicehubConfig
from ereuse_devicehub.devicehub import Devicehub
class DevicehubGroup(flask.cli.FlaskGroup):
CONFIG = DevicehubConfig
def main(self, *args, **kwargs):
# todo this should be taken as an argument for the cli
inventory = os.environ.get('dhi')
if not inventory:
raise ValueError('Please do "export dhi={inventory}"')
self.create_app = self.create_app_factory(inventory)
return super().main(*args, **kwargs)
@staticmethod
def create_app_factory(inventory):
return lambda: Devicehub(inventory)
@click.group(cls=DevicehubGroup)
def cli():
pass

View File

@ -2,13 +2,12 @@ from distutils.version import StrictVersion
from itertools import chain from itertools import chain
from typing import Set from typing import Set
import boltons.urlutils
from teal.auth import TokenAuth from teal.auth import TokenAuth
from teal.config import Config from teal.config import Config
from teal.enums import Currency from teal.enums import Currency
from teal.utils import import_resource from teal.utils import import_resource
from ereuse_devicehub.resources import agent, event, lot, tag, user from ereuse_devicehub.resources import agent, event, inventory, lot, tag, user
from ereuse_devicehub.resources.device import definitions from ereuse_devicehub.resources.device import definitions
from ereuse_devicehub.resources.documents import documents from ereuse_devicehub.resources.documents import documents
from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware
@ -21,23 +20,16 @@ class DevicehubConfig(Config):
import_resource(tag), import_resource(tag),
import_resource(agent), import_resource(agent),
import_resource(lot), import_resource(lot),
import_resource(documents)) import_resource(documents),
import_resource(inventory)),
) )
PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str] PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str]
SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str
SCHEMA = 'dhub'
MIN_WORKBENCH = StrictVersion('11.0a1') # type: StrictVersion MIN_WORKBENCH = StrictVersion('11.0a1') # type: StrictVersion
""" """
the minimum version of ereuse.org workbench that this devicehub the minimum version of ereuse.org workbench that this devicehub
accepts. we recommend not changing this value. accepts. we recommend not changing this value.
""" """
ORGANIZATION_NAME = None # type: str
ORGANIZATION_TAX_ID = None # type: str
"""
The organization using this Devicehub.
It is used by default, for example, when creating tags.
"""
API_DOC_CONFIG_TITLE = 'Devicehub' API_DOC_CONFIG_TITLE = 'Devicehub'
API_DOC_CONFIG_VERSION = '0.2' API_DOC_CONFIG_VERSION = '0.2'
API_DOC_CONFIG_COMPONENTS = { API_DOC_CONFIG_COMPONENTS = {
@ -60,19 +52,3 @@ class DevicehubConfig(Config):
""" """
Official versions Official versions
""" """
TAG_BASE_URL = None
TAG_TOKEN = None
"""Access to the tag provider."""
def __init__(self, schema: str = None, token=None) -> None:
if not self.ORGANIZATION_NAME or not self.ORGANIZATION_TAX_ID:
raise ValueError('You need to set the main organization parameters.')
if not self.TAG_BASE_URL:
raise ValueError('You need a tag service.')
self.TAG_TOKEN = token or self.TAG_TOKEN
if not self.TAG_TOKEN:
raise ValueError('You need a tag token')
self.TAG_BASE_URL = boltons.urlutils.URL(self.TAG_BASE_URL)
if schema:
self.SCHEMA = schema
super().__init__()

View File

@ -1,16 +1,23 @@
import uuid
from typing import Type from typing import Type
import boltons.urlutils
import click
import click_spinner
import ereuse_utils.cli
from ereuse_utils.session import DevicehubClient from ereuse_utils.session import DevicehubClient
from flask.globals import _app_ctx_stack, g
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import event from sqlalchemy import event
from teal.config import Config as ConfigClass
from teal.teal import Teal from teal.teal import Teal
from ereuse_devicehub.auth import Auth from ereuse_devicehub.auth import Auth
from ereuse_devicehub.client import Client from ereuse_devicehub.client import Client
from ereuse_devicehub.config import DevicehubConfig
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.dummy.dummy import Dummy from ereuse_devicehub.dummy.dummy import Dummy
from ereuse_devicehub.resources.device.search import DeviceSearch from ereuse_devicehub.resources.device.search import DeviceSearch
from ereuse_devicehub.resources.inventory import Inventory, InventoryDef
class Devicehub(Teal): class Devicehub(Teal):
@ -18,7 +25,8 @@ class Devicehub(Teal):
Dummy = Dummy Dummy = Dummy
def __init__(self, def __init__(self,
config: ConfigClass, inventory: str,
config: DevicehubConfig = DevicehubConfig(),
db: SQLAlchemy = db, db: SQLAlchemy = db,
import_name=__name__.split('.')[0], import_name=__name__.split('.')[0],
static_url_path=None, static_url_path=None,
@ -31,27 +39,76 @@ class Devicehub(Teal):
instance_relative_config=False, instance_relative_config=False,
root_path=None, root_path=None,
Auth: Type[Auth] = Auth): Auth: Type[Auth] = Auth):
super().__init__(config, db, import_name, static_url_path, static_folder, static_host, assert inventory
super().__init__(config, db, inventory, import_name, static_url_path, static_folder,
static_host,
host_matching, subdomain_matching, template_folder, instance_path, host_matching, subdomain_matching, template_folder, instance_path,
instance_relative_config, root_path, Auth) instance_relative_config, root_path, Auth)
self.tag_provider = DevicehubClient(**self.config.get_namespace('TAG_')) self.id = inventory
"""The Inventory ID of this instance. In Teal is the app.schema."""
self.dummy = Dummy(self) self.dummy = Dummy(self)
self.before_request(self.register_db_events_listeners) self.before_request(self.register_db_events_listeners)
self.cli.command('regenerate-search')(self.regenerate_search) self.cli.command('regenerate-search')(self.regenerate_search)
self.cli.command('init-db')(self.init_db)
self.before_request(self._prepare_request)
def register_db_events_listeners(self): def register_db_events_listeners(self):
"""Registers the SQLAlchemy event listeners.""" """Registers the SQLAlchemy event listeners."""
# todo can I make it with a global Session only? # todo can I make it with a global Session only?
event.listen(db.session, 'before_commit', DeviceSearch.update_modified_devices) event.listen(db.session, 'before_commit', DeviceSearch.update_modified_devices)
def _init_db(self, exclude_schema=None, check=False): # noinspection PyMethodOverriding
created = super()._init_db(exclude_schema, check) @click.option('--name', '-n',
if created: default='Test 1',
help='The human name of the inventory.')
@click.option('--org-name', '-on',
default='My Organization',
help='The name of the default organization that owns this inventory.')
@click.option('--org-id', '-oi',
default='foo-bar',
help='The Tax ID of the organization.')
@click.option('--tag-url', '-tu',
type=ereuse_utils.cli.URL(scheme=True, host=True, path=False),
default='http://example.com',
help='The base url (scheme and host) of the tag provider.')
@click.option('--tag-token', '-tt',
type=click.UUID,
default='899c794e-1737-4cea-9232-fdc507ab7106',
help='The token provided by the tag provider. It is an UUID.')
@click.option('--erase/--no-erase',
default=False,
help='Delete the full database before? Including all schemas and users.')
@click.option('--common',
default=False,
help='Creates common databases. Only execute if the database is empty.')
def init_db(self, name: str,
org_name: str,
org_id: str,
tag_url: boltons.urlutils.URL,
tag_token: uuid.UUID,
erase: bool,
common: bool):
"""Initializes this inventory with the provided configurations."""
assert _app_ctx_stack.top, 'Use an app context.'
print('Initializing database...'.ljust(30), end='')
with click_spinner.spinner():
if erase:
self.db.drop_all()
exclude_schema = 'common' if not common else None
self._init_db(exclude_schema=exclude_schema)
InventoryDef.set_inventory_config(name, org_name, org_id, tag_url, tag_token)
DeviceSearch.set_all_devices_tokens_if_empty(self.db.session) DeviceSearch.set_all_devices_tokens_if_empty(self.db.session)
return created self._init_resources(exclude_schema=exclude_schema)
self.db.session.commit()
print('done.')
def regenerate_search(self): def regenerate_search(self):
"""Re-creates from 0 all the search tables.""" """Re-creates from 0 all the search tables."""
DeviceSearch.regenerate_search_table(self.db.session) DeviceSearch.regenerate_search_table(self.db.session)
db.session.commit() db.session.commit()
print('Done.') print('Done.')
def _prepare_request(self):
"""Prepares request stuff."""
inv = g.inventory = Inventory.current # type: Inventory
g.tag_provider = DevicehubClient(base_url=inv.tag_provider, token=inv.tag_token)

View File

@ -0,0 +1,46 @@
from threading import Lock
import sqlalchemy as sa
import werkzeug.exceptions
from werkzeug import wsgi
import ereuse_devicehub.config
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.inventory import Inventory
class PathDispatcher:
NOT_FOUND = werkzeug.exceptions.NotFound()
INV = Inventory
def __init__(self, config_cls=ereuse_devicehub.config.DevicehubConfig) -> None:
self.lock = Lock()
self.instances = {}
self.CONFIG = config_cls
self.engine = sa.create_engine(self.CONFIG.SQLALCHEMY_DATABASE_URI)
with self.lock:
self.instantiate()
if not self.instances:
raise ValueError('There are no Devicehub instances! Please, execute `dh init-db`.')
self.one_app = next(iter(self.instances.values()))
def __call__(self, environ, start_response):
if wsgi.get_path_info(environ).startswith('/users'):
# Not nice solution but it works well for now
# Return any app, as all apps can handle login
return self.call(self.one_app, environ, start_response)
inventory = wsgi.pop_path_info(environ)
with self.lock:
if inventory not in self.instances:
self.instantiate()
app = self.instances.get(inventory, self.NOT_FOUND)
return self.call(app, environ, start_response)
@staticmethod
def call(app, environ, start_response):
return app(environ, start_response)
def instantiate(self):
sel = sa.select([self.INV.id]).where(self.INV.id.notin_(self.instances.keys()))
for row in self.engine.execute(sel):
self.instances[row.id] = Devicehub(inventory=row.id)

View File

@ -5,6 +5,7 @@ from typing import Set
import click import click
import click_spinner import click_spinner
import ereuse_utils.cli
import yaml import yaml
from ereuse_utils.test import ANY from ereuse_utils.test import ANY
@ -41,11 +42,25 @@ class Dummy:
self.app = app self.app = app
self.app.cli.command('dummy', short_help='Creates dummy devices and users.')(self.run) self.app.cli.command('dummy', short_help='Creates dummy devices and users.')(self.run)
@click.option('--tag-url', '-tu',
type=ereuse_utils.cli.URL(scheme=True, host=True, path=False),
default='http://localhost:8081',
help='The base url (scheme and host) of the tag provider.')
@click.option('--tag-token', '-tt',
type=click.UUID,
default='899c794e-1737-4cea-9232-fdc507ab7106',
help='The token provided by the tag provider. It is an UUID.')
@click.confirmation_option(prompt='This command (re)creates the DB from scratch.' @click.confirmation_option(prompt='This command (re)creates the DB from scratch.'
'Do you want to continue?') 'Do you want to continue?')
def run(self): def run(self, tag_url, tag_token):
runner = self.app.test_cli_runner() runner = self.app.test_cli_runner()
self.app.init_db(erase=True) self.app.init_db('Dummy',
'ACME',
'acme-id',
tag_url,
tag_token,
erase=True,
common=True)
print('Creating stuff...'.ljust(30), end='') print('Creating stuff...'.ljust(30), end='')
with click_spinner.spinner(): with click_spinner.spinner():
out = runner.invoke(args=['create-org', *self.ORG], catch_exceptions=False).output out = runner.invoke(args=['create-org', *self.ORG], catch_exceptions=False).output

View File

@ -1,8 +1,6 @@
import json import json
import click import click
from flask import current_app as app
from teal.db import SQLAlchemy
from teal.resource import Converters, Resource from teal.resource import Converters, Resource
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
@ -46,11 +44,6 @@ class OrganizationDef(AgentDef):
print(json.dumps(o, indent=2)) print(json.dumps(o, indent=2))
return o return o
def init_db(self, db: SQLAlchemy, exclude_schema=None):
"""Creates the default organization."""
org = models.Organization(**app.config.get_namespace('ORGANIZATION_'))
db.session.add(org)
class Membership(Resource): class Membership(Resource):
SCHEMA = schemas.Membership SCHEMA = schemas.Membership

View File

@ -3,17 +3,17 @@ from operator import attrgetter
from uuid import uuid4 from uuid import uuid4
from citext import CIText from citext import CIText
from flask import current_app as app, g
from sqlalchemy import Column, Enum as DBEnum, ForeignKey, Unicode, UniqueConstraint from sqlalchemy import Column, Enum as DBEnum, ForeignKey, Unicode, UniqueConstraint
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, validates from sqlalchemy.orm import backref, relationship, validates
from sqlalchemy_utils import EmailType, PhoneNumberType from sqlalchemy_utils import EmailType, PhoneNumberType
from teal import enums from teal import enums
from teal.db import DBError, INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON, check_lower from teal.db import INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON, check_lower
from teal.marshmallow import ValidationError from teal.marshmallow import ValidationError
from werkzeug.exceptions import NotImplemented, UnprocessableEntity
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
from ereuse_devicehub.resources.user.models import User from ereuse_devicehub.resources.user.models import User
@ -46,6 +46,7 @@ class Agent(Thing):
__table_args__ = ( __table_args__ = (
UniqueConstraint(tax_id, country, name='Registration Number per country.'), UniqueConstraint(tax_id, country, name='Registration Number per country.'),
UniqueConstraint(tax_id, name, name='One tax ID with one name.')
) )
@declared_attr @declared_attr
@ -80,21 +81,18 @@ class Agent(Thing):
class Organization(JoinedTableMixin, Agent): class Organization(JoinedTableMixin, Agent):
default_of = db.relationship(Inventory,
single_parent=True,
uselist=False,
primaryjoin=lambda: Organization.id == Inventory.org_id)
def __init__(self, name: str, **kwargs) -> None: def __init__(self, name: str, **kwargs) -> None:
super().__init__(**kwargs, name=name) super().__init__(**kwargs, name=name)
@classmethod @classmethod
def get_default_org_id(cls) -> UUID: def get_default_org_id(cls) -> UUID:
"""Retrieves the default organization.""" """Retrieves the default organization."""
try: return cls.query.filter_by(default_of=Inventory.current).one().id
return g.setdefault('org_id',
Organization.query.filter_by(
**app.config.get_namespace('ORGANIZATION_')
).one().id)
except (DBError, UnprocessableEntity):
# todo test how well this works
raise NotImplemented('Error in getting the default organization. '
'Is the DB initialized?')
class Individual(JoinedTableMixin, Agent): class Individual(JoinedTableMixin, Agent):

View File

@ -21,6 +21,7 @@ class Agent(Thing):
class Organization(Agent): class Organization(Agent):
members = NestedOn('Membership') members = NestedOn('Membership')
default_of = NestedOn('Inventory')
class Membership(Thing): class Membership(Thing):

View File

@ -0,0 +1,79 @@
import uuid
import boltons.urlutils
import click
import ereuse_utils.cli
from flask import current_app
from teal.db import ResourceNotFound
from teal.resource import Resource
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.inventory import schema
from ereuse_devicehub.resources.inventory.model import Inventory
class InventoryDef(Resource):
SCHEMA = schema.Inventory
VIEW = None
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None):
cli_commands = (
(self.set_inventory_config_cli, 'set-inventory-config'),
)
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
@click.option('--name', '-n',
default='Test 1',
help='The human name of the inventory.')
@click.option('--org-name', '-on',
default=None,
help='The name of the default organization that owns this inventory.')
@click.option('--org-id', '-oi',
default=None,
help='The Tax ID of the organization.')
@click.option('--tag-url', '-tu',
type=ereuse_utils.cli.URL(scheme=True, host=True, path=False),
default=None,
help='The base url (scheme and host) of the tag provider.')
@click.option('--tag-token', '-tt',
type=click.UUID,
default=None,
help='The token provided by the tag provider. It is an UUID.')
def set_inventory_config_cli(self, **kwargs):
"""Sets the inventory configuration. Only updates passed-in
values.
"""
self.set_inventory_config(**kwargs)
db.session.commit()
@classmethod
def set_inventory_config(cls,
name: str = None,
org_name: str = None,
org_id: str = None,
tag_url: boltons.urlutils.URL = None,
tag_token: uuid.UUID = None):
try:
inventory = Inventory.current
except ResourceNotFound: # No inventory defined in db yet
inventory = Inventory(id=current_app.id,
name=name,
tag_provider=tag_url,
tag_token=tag_token)
db.session.add(inventory)
if org_name or org_id:
from ereuse_devicehub.resources.agent.models import Organization
try:
org = Organization.query.filter_by(tax_id=org_id, name=org_name).one()
except ResourceNotFound:
org = Organization(tax_id=org_id, name=org_name)
org.default_of = inventory
db.session.add(org)
if tag_url:
inventory.tag_provider = tag_url
if tag_token:
inventory.tag_token = tag_token

View File

@ -1,3 +1,6 @@
from boltons.typeutils import classproperty
from flask import current_app
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.resources.models import Thing from ereuse_devicehub.resources.models import Thing
@ -8,5 +11,12 @@ class Inventory(Thing):
id.comment = """The name of the inventory as in the URL and schema.""" id.comment = """The name of the inventory as in the URL and schema."""
name = db.Column(db.CIText(), nullable=False, unique=True) name = db.Column(db.CIText(), nullable=False, unique=True)
name.comment = """The human name of the inventory.""" name.comment = """The human name of the inventory."""
tag_token = db.Column(db.UUID(as_uuid=True), unique=True) tag_provider = db.Column(db.URL(), nullable=False)
tag_token = db.Column(db.UUID(as_uuid=True), unique=True, nullable=False)
tag_token.comment = """The token to access a Tag service.""" tag_token.comment = """The token to access a Tag service."""
org_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey('organization.id'), nullable=False)
@classproperty
def current(cls) -> 'Inventory':
"""The inventory of the current_app."""
return Inventory.query.filter_by(id=current_app.id).one()

View File

@ -0,0 +1,11 @@
import teal.marshmallow
from marshmallow import fields as mf
from ereuse_devicehub.resources.schemas import Thing
class Inventory(Thing):
id = mf.String(dump_only=True)
name = mf.String(dump_only=True)
tag_provider = teal.marshmallow.URL(dump_only=True, data_key='tagProvider')
tag_token = mf.UUID(dump_only=True, data_key='tagToken')

View File

@ -1,5 +1,4 @@
from ereuse_utils.session import DevicehubClient from flask import Response, current_app as app, g, jsonify, redirect, request
from flask import Response, current_app, current_app as app, jsonify, redirect, request
from teal.marshmallow import ValidationError from teal.marshmallow import ValidationError
from teal.resource import View, url_for_resource from teal.resource import View, url_for_resource
@ -19,9 +18,8 @@ class TagView(View):
return res return res
def _create_many_regular_tags(self, num: int): def _create_many_regular_tags(self, num: int):
tag_provider = current_app.tag_provider # type: DevicehubClient tags_id, _ = g.tag_provider.post('/', {}, query=[('num', num)])
tags_id, _ = tag_provider.post('/', {}, query=[('num', num)]) tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
tags = [Tag(id=tag_id, provider=current_app.config['TAG_BASE_URL']) for tag_id in tags_id]
db.session.add_all(tags) db.session.add_all(tags)
db.session.commit() db.session.commit()
response = jsonify(items=self.schema.dump(tags, many=True, nested=1)) # type: Response response = jsonify(items=self.schema.dump(tags, many=True, nested=1)) # type: Response

View File

@ -1,8 +1,11 @@
from typing import Iterable
from click import argument, option from click import argument, option
from flask import current_app from flask import current_app
from teal.resource import Converters, Resource from teal.resource import Converters, Resource
from ereuse_devicehub.db import db from ereuse_devicehub.db import db
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.user import schemas from ereuse_devicehub.resources.user import schemas
from ereuse_devicehub.resources.user.models import User from ereuse_devicehub.resources.user.models import User
from ereuse_devicehub.resources.user.views import UserView, login from ereuse_devicehub.resources.user.views import UserView, login
@ -23,13 +26,21 @@ class UserDef(Resource):
self.add_url_rule('/login/', view_func=login, methods={'POST'}) self.add_url_rule('/login/', view_func=login, methods={'POST'})
@argument('email') @argument('email')
@option('-i', '--inventory',
multiple=True,
help='Inventories user has access to. By default this one.')
@option('-a', '--agent', help='The name of an agent to create with the user.') @option('-a', '--agent', help='The name of an agent to create with the user.')
@option('-c', '--country', help='The country of the agent (if --agent is set).') @option('-c', '--country', help='The country of the agent (if --agent is set).')
@option('-t', '--telephone', help='The telephone of the agent (if --agent is set).') @option('-t', '--telephone', help='The telephone of the agent (if --agent is set).')
@option('-t', '--tax-id', help='The tax id of the agent (if --agent is set).') @option('-t', '--tax-id', help='The tax id of the agent (if --agent is set).')
@option('-p', '--password', prompt=True, hide_input=True, confirmation_prompt=True) @option('-p', '--password', prompt=True, hide_input=True, confirmation_prompt=True)
def create_user(self, email: str, password: str, agent: str = None, country: str = None, def create_user(self, email: str,
telephone: str = None, tax_id: str = None) -> dict: password: str,
inventory: Iterable[str] = tuple(),
agent: str = None,
country: str = None,
telephone: str = None,
tax_id: str = None) -> dict:
"""Creates an user. """Creates an user.
If ``--agent`` is passed, it creates an ``Individual`` agent If ``--agent`` is passed, it creates an ``Individual`` agent
@ -38,7 +49,9 @@ class UserDef(Resource):
from ereuse_devicehub.resources.agent.models import Individual from ereuse_devicehub.resources.agent.models import Individual
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \ u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \
.load({'email': email, 'password': password}) .load({'email': email, 'password': password})
user = User(**u) if inventory:
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
user = User(**u, inventories=inventory)
agent = Individual(**current_app.resources[Individual.t].schema.load( agent = Individual(**current_app.resources[Individual.t].schema.load(
dict(name=agent, email=email, country=country, telephone=telephone, taxId=tax_id) dict(name=agent, email=email, country=country, telephone=telephone, taxId=tax_id)
)) ))

View File

@ -19,16 +19,24 @@ class User(Thing):
schemes=app.config['PASSWORD_SCHEMES'], schemes=app.config['PASSWORD_SCHEMES'],
**kwargs **kwargs
))) )))
""" token = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False)
Password field.
From `here <https://sqlalchemy-utils.readthedocs.io/en/latest/
data_types.html#module-sqlalchemy_utils.types.password>`_
"""
token = Column(UUID(as_uuid=True), default=uuid4, unique=True)
inventories = db.relationship(Inventory, inventories = db.relationship(Inventory,
backref=db.backref('users', lazy=True, collection_class=set), backref=db.backref('users', lazy=True, collection_class=set),
secondary=lambda: UserInventory.__table__, secondary=lambda: UserInventory.__table__,
collection_class=set) collection_class=set)
# todo set restriction that user has, at least, one active db
def __init__(self, email, password=None, inventories=None) -> None:
"""
Creates an user.
:param email:
:param password:
:param inventories: A set of Inventory where the user has
access to. If none, the user is granted access to the current
inventory.
"""
inventories = inventories or {Inventory.current}
super().__init__(email=email, password=password, inventories=inventories)
def __repr__(self) -> str: def __repr__(self) -> str:
return '<User {0.email}>'.format(self) return '<User {0.email}>'.format(self)

View File

@ -2,9 +2,11 @@ from typing import Set, Union
from uuid import UUID from uuid import UUID
from sqlalchemy import Column from sqlalchemy import Column
from sqlalchemy.orm import relationship
from sqlalchemy_utils import Password from sqlalchemy_utils import Password
from ereuse_devicehub.resources.agent.models import Individual from ereuse_devicehub.resources.agent.models import Individual
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.models import Thing from ereuse_devicehub.resources.models import Thing
@ -13,14 +15,17 @@ class User(Thing):
email = ... # type: Column email = ... # type: Column
password = ... # type: Column password = ... # type: Column
token = ... # type: Column token = ... # type: Column
inventories = ... # type: relationship
def __init__(self, **kwargs) -> None: def __init__(self, email: str, password: str = None,
super().__init__(**kwargs) inventories: Set[Inventory] = None) -> None:
super().__init__()
self.id = ... # type: UUID self.id = ... # type: UUID
self.email = ... # type: str self.email = ... # type: str
self.password = ... # type: Password self.password = ... # type: Password
self.individuals = ... # type: Set[Individual] self.individuals = ... # type: Set[Individual]
self.token = ... # type: UUID self.token = ... # type: UUID
self.inventories = ... # type: Set[Inventory]
@property @property
def individual(self) -> Union[Individual, None]: def individual(self) -> Union[Individual, None]:

View File

@ -5,6 +5,7 @@ from teal.marshmallow import SanitizedStr
from ereuse_devicehub import auth from ereuse_devicehub import auth
from ereuse_devicehub.marshmallow import NestedOn from ereuse_devicehub.marshmallow import NestedOn
from ereuse_devicehub.resources.agent.schemas import Individual from ereuse_devicehub.resources.agent.schemas import Individual
from ereuse_devicehub.resources.inventory.schema import Inventory
from ereuse_devicehub.resources.schemas import Thing from ereuse_devicehub.resources.schemas import Thing
@ -17,6 +18,7 @@ class User(Thing):
token = String(dump_only=True, token = String(dump_only=True,
description='Use this token in an Authorization header to access the app.' description='Use this token in an Authorization header to access the app.'
'The token can change overtime.') 'The token can change overtime.')
inventories = NestedOn(Inventory, many=True, dump_only=True)
def __init__(self, def __init__(self,
only=None, only=None,

View File

@ -8,7 +8,7 @@ Define appdir /home/devicetag/sites/${servername}/source/
# The path where the app directory is. Apache must have access to this folder. # The path where the app directory is. Apache must have access to this folder.
Define wsgipath ${appdir}/wsgi.wsgi Define wsgipath ${appdir}/wsgi.wsgi
# The location of the .wsgi file # The location of the .wsgi file
Define pyvenv ${appdir}/venv/ Define pyvenv ${appdir}../venv/
# The path where the virtual environment is (the folder containing bin/activate) # The path where the virtual environment is (the folder containing bin/activate)
<VirtualHost *:80> <VirtualHost *:80>

View File

@ -1,4 +1,3 @@
from ereuse_devicehub.config import DevicehubConfig
from ereuse_devicehub.devicehub import Devicehub from ereuse_devicehub.devicehub import Devicehub
""" """
@ -7,10 +6,4 @@ Example app with minimal configuration.
Use this as a starting point. Use this as a starting point.
""" """
app = Devicehub(inventory='db1')
class MyConfig(DevicehubConfig):
ORGANIZATION_NAME = 'My org'
ORGANIZATION_TAX_ID = 'foo-bar'
app = Devicehub(MyConfig())

View File

@ -5,7 +5,7 @@ click==6.7
click-spinner==0.1.8 click-spinner==0.1.8
colorama==0.3.9 colorama==0.3.9
colour==0.1.5 colour==0.1.5
ereuse-utils==0.4.0b14 ereuse-utils[naming, test, session, cli]==0.4.0b14
Flask==1.0.2 Flask==1.0.2
Flask-Cors==3.0.6 Flask-Cors==3.0.6
Flask-SQLAlchemy==2.3.2 Flask-SQLAlchemy==2.3.2
@ -15,7 +15,6 @@ marshmallow==3.0.0b11
marshmallow-enum==1.4.1 marshmallow-enum==1.4.1
passlib==1.7.1 passlib==1.7.1
phonenumbers==8.9.11 phonenumbers==8.9.11
pySMART.smartx==0.3.9
pytest==3.7.2 pytest==3.7.2
pytest-runner==4.2 pytest-runner==4.2
python-dateutil==2.7.3 python-dateutil==2.7.3
@ -25,9 +24,10 @@ requests==2.19.1
requests-mock==1.5.2 requests-mock==1.5.2
SQLAlchemy==1.2.14 SQLAlchemy==1.2.14
SQLAlchemy-Utils==0.33.6 SQLAlchemy-Utils==0.33.6
teal==0.2.0a34 teal==0.2.0a35
webargs==4.0.0 webargs==4.0.0
Werkzeug==0.14.1 Werkzeug==0.14.1
sqlalchemy-citext==1.3.post0 sqlalchemy-citext==1.3.post0
flask-weasyprint==0.5 flask-weasyprint==0.5
weasyprint==44 weasyprint==44
psycopg2-binary==2.7.5

View File

@ -29,10 +29,10 @@ setup(
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type='text/markdown',
install_requires=[ install_requires=[
'teal>=0.2.0a34', # teal always first 'teal>=0.2.0a35', # teal always first
'click', 'click',
'click-spinner', 'click-spinner',
'ereuse-utils[Naming]>=0.4b14', 'ereuse-utils[naming, test, session, cli]>=0.4b14',
'hashids', 'hashids',
'marshmallow_enum', 'marshmallow_enum',
'psycopg2-binary', 'psycopg2-binary',
@ -57,6 +57,11 @@ setup(
'test': test_requires 'test': test_requires
}, },
tests_require=test_requires, tests_require=test_requires,
entry_points={
'console_scripts': [
'dh = ereuse_devicehub.cli:cli'
]
},
setup_requires=[ setup_requires=[
'pytest-runner' 'pytest-runner'
], ],

View File

@ -1,8 +1,10 @@
import io import io
import uuid
from contextlib import redirect_stdout from contextlib import redirect_stdout
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import boltons.urlutils
import pytest import pytest
import yaml import yaml
from psycopg2 import IntegrityError from psycopg2 import IntegrityError
@ -26,13 +28,8 @@ T = {'start_time': STARTT, 'end_time': ENDT}
class TestConfig(DevicehubConfig): class TestConfig(DevicehubConfig):
SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/dh_test' SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/dh_test'
SCHEMA = 'test'
TESTING = True TESTING = True
ORGANIZATION_NAME = 'FooOrg'
ORGANIZATION_TAX_ID = 'foo-org-id'
SERVER_NAME = 'localhost' SERVER_NAME = 'localhost'
TAG_BASE_URL = 'https://example.com'
TAG_TOKEN = 'tagToken'
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
@ -42,7 +39,7 @@ def config():
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def _app(config: TestConfig) -> Devicehub: def _app(config: TestConfig) -> Devicehub:
return Devicehub(config=config, db=db) return Devicehub(inventory='test', config=config, db=db)
@pytest.fixture() @pytest.fixture()
@ -52,14 +49,23 @@ def app(request, _app: Devicehub) -> Devicehub:
with _app.app_context(): with _app.app_context():
db.drop_all() db.drop_all()
def _init():
_app.init_db(name='Test Inventory',
org_name='FooOrg',
org_id='foo-org-id',
tag_url=boltons.urlutils.URL('https://example.com'),
tag_token=uuid.UUID('52dacef0-6bcb-4919-bfed-f10d2c96ecee'),
erase=False,
common=True)
with _app.app_context(): with _app.app_context():
try: try:
with redirect_stdout(io.StringIO()): with redirect_stdout(io.StringIO()):
_app.init_db() _init()
except (ProgrammingError, IntegrityError): except (ProgrammingError, IntegrityError):
print('Database was not correctly emptied. Re-empty and re-installing...') print('Database was not correctly emptied. Re-empty and re-installing...')
_drop() _drop()
_app.init_db() _init()
request.addfinalizer(_drop) request.addfinalizer(_drop)
return _app return _app

View File

@ -107,8 +107,7 @@ def test_default_org_exists(config: DevicehubConfig):
initialization and that is accessible for the method initialization and that is accessible for the method
:meth:`ereuse_devicehub.resources.user.Organization.get_default_org`. :meth:`ereuse_devicehub.resources.user.Organization.get_default_org`.
""" """
assert models.Organization.query.filter_by(name=config.ORGANIZATION_NAME, assert models.Organization.query.filter_by(name='FooOrg', tax_id='foo-org-id').one()
tax_id=config.ORGANIZATION_TAX_ID).one()
assert isinstance(models.Organization.get_default_org_id(), UUID) assert isinstance(models.Organization.get_default_org_id(), UUID)

View File

@ -42,4 +42,4 @@ def test_api_docs(client: Client):
'scheme': 'basic', 'scheme': 'basic',
'name': 'Authorization' 'name': 'Authorization'
} }
assert 95 == len(docs['definitions']) assert len(docs['definitions']) == 96

48
tests/test_dispatcher.py Normal file
View File

@ -0,0 +1,48 @@
from unittest.mock import Mock
import pytest
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.dispatchers import PathDispatcher
from tests.conftest import TestConfig
def noop():
pass
@pytest.fixture()
def dispatcher(app: Devicehub, config: TestConfig) -> PathDispatcher:
print('whoho')
PathDispatcher.call = Mock(side_effect=lambda *args: args[0])
return PathDispatcher(config_cls=config)
def test_dispatcher_default(dispatcher: PathDispatcher):
"""The dispatcher returns not found for an URL that does not
route to an app.
"""
app = dispatcher({'SCRIPT_NAME:': '/', 'PATH_INFO': '/'}, noop)
assert app == PathDispatcher.NOT_FOUND
app = dispatcher({'SCRIPT_NAME:': '/', 'PATH_INFO': '/foo/foo'}, noop)
assert app == PathDispatcher.NOT_FOUND
def test_dispatcher_return_app(dispatcher: PathDispatcher):
"""The dispatcher returns the correct app for the URL"""
# Note that the dispatcher does not check if the URL points
# to a well-known endpoint for the app.
# Only if can route it to an app. And then the app checks
# if the path exists
app = dispatcher({'SCRIPT_NAME:': '/', 'PATH_INFO': '/test/foo/'}, noop)
assert isinstance(app, Devicehub)
assert app.id == 'test'
def test_dispatcher_users(dispatcher: PathDispatcher):
"""Users special endpoint returns an app"""
# For now returns the first app, as all apps
# can answer {}/users/login
app = dispatcher({'SCRIPT_NAME:': '/', 'PATH_INFO': '/users/'}, noop)
assert isinstance(app, Devicehub)
assert app.id == 'test'

16
tests/test_inventory.py Normal file
View File

@ -0,0 +1,16 @@
import pytest
@pytest.mark.xfail(reason='Test not developed')
def test_create_inventory():
"""Tests creating an inventory with an user."""
@pytest.mark.xfail(reason='Test not developed')
def test_create_existing_inventory():
pass
@pytest.mark.xfail(reason='Test not developed')
def test_delete_inventory():
pass

View File

@ -241,7 +241,9 @@ def test_crate_num_regular_tags(user: UserClient, requests_mock: requests_mock.m
""" """
requests_mock.post('https://example.com/', requests_mock.post('https://example.com/',
# request # request
request_headers={'Authorization': 'Basic tagToken'}, request_headers={
'Authorization': 'Basic 52dacef0-6bcb-4919-bfed-f10d2c96ecee'
},
# response # response
json=['tag1id', 'tag2id'], json=['tag1id', 'tag2id'],
status_code=201) status_code=201)

View File

@ -82,6 +82,7 @@ def test_login_success(client: Client, app: Devicehub):
assert user['individuals'][0]['name'] == 'Timmy' assert user['individuals'][0]['name'] == 'Timmy'
assert user['individuals'][0]['type'] == 'Person' assert user['individuals'][0]['type'] == 'Person'
assert len(user['individuals']) == 1 assert len(user['individuals']) == 1
assert user['inventories'][0]['id'] == 'test'
def test_login_failure(client: Client, app: Devicehub): def test_login_failure(client: Client, app: Devicehub):
@ -99,3 +100,8 @@ def test_login_failure(client: Client, app: Devicehub):
client.post({'email': 'this is not an email', 'password': 'nope'}, client.post({'email': 'this is not an email', 'password': 'nope'},
uri='/users/login/', uri='/users/login/',
status=ValidationError) status=ValidationError)
@pytest.mark.xfail(reason='Test not developed')
def test_user_at_least_one_inventory():
pass