Add Tests managing inventories; small bugfixes

This commit is contained in:
Xavier Bustamante Talavera 2019-02-11 21:34:45 +01:00
parent 6c4c89ac48
commit 15f705dd50
18 changed files with 269 additions and 90 deletions

View file

@ -1,6 +1,7 @@
import os
import click.testing
import ereuse_utils
import flask.cli
from ereuse_devicehub.config import DevicehubConfig
@ -19,11 +20,35 @@ class DevicehubGroup(flask.cli.FlaskGroup):
self.create_app = self.create_app_factory(inventory)
return super().main(*args, **kwargs)
@staticmethod
def create_app_factory(inventory):
return lambda: Devicehub(inventory)
@classmethod
def create_app_factory(cls, inventory):
return lambda: Devicehub(inventory, config=cls.CONFIG())
@click.group(cls=DevicehubGroup)
def get_version(ctx, param, value):
if not value or ctx.resilient_parsing:
return
click.echo('Devicehub {}'.format(ereuse_utils.version('ereuse-devicehub')), color=ctx.color)
flask.cli.get_version(ctx, param, value)
@click.option('--version',
help='Devicehub version.',
expose_value=False,
callback=get_version,
is_flag=True,
is_eager=True)
@click.group(cls=DevicehubGroup,
context_settings=Devicehub.cli_context_settings,
add_version_option=False,
help="""
Manages the Devicehub of the inventory {}.
Use 'export dhi=xx' to set the inventory that this CLI
manages. For example 'export dhi=db1' and then executing
'dh tag add' adds a tag in the db1 database. Operations
that affect the common database (like creating an user)
are not affected by this.
""".format(os.environ.get('dhi')))
def cli():
pass

View file

@ -1,3 +1,4 @@
import os
import uuid
from typing import Type
@ -42,12 +43,19 @@ class Devicehub(Teal):
super().__init__(config, db, inventory, import_name, static_url_path, static_folder,
static_host,
host_matching, subdomain_matching, template_folder, instance_path,
instance_relative_config, root_path, Auth)
instance_relative_config, root_path, False, Auth)
self.id = inventory
"""The Inventory ID of this instance. In Teal is the app.schema."""
self.dummy = Dummy(self)
self.cli.command('regenerate-search')(self.regenerate_search)
self.cli.command('init-db')(self.init_db)
@self.cli.group(short_help='Inventory management.',
help='Manages the inventory {}.'.format(os.environ.get('dhi')))
def inv():
pass
inv.command('add')(self.init_db)
inv.command('del')(self.delete_inventory)
inv.command('search')(self.regenerate_search)
self.before_request(self._prepare_request)
# noinspection PyMethodOverriding
@ -82,12 +90,21 @@ class Devicehub(Teal):
tag_token: uuid.UUID,
erase: bool,
common: bool):
"""Initializes this inventory with the provided configurations."""
"""Creates an inventory.
This creates the database and adds the inventory to the
inventory tables with the passed-in settings, and does nothing if the
inventory already exists.
After you create the inventory you might want to create an user
executing *dh user add*.
"""
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(common_schema=common)
assert not db.has_schema(self.id), 'Schema {} already exists.'.format(self.id)
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)
@ -96,6 +113,20 @@ class Devicehub(Teal):
self.db.session.commit()
print('done.')
@click.confirmation_option(prompt='Are you sure you want to delete the inventory {}?'
.format(os.environ.get('dhi')))
def delete_inventory(self):
"""Erases an inventory.
This removes its private database and its entry in the common
inventory.
This deletes users that have only access to this inventory.
"""
InventoryDef.delete_inventory()
self.db.session.commit()
self.db.drop_all(common_schema=False)
def regenerate_search(self):
"""Re-creates from 0 all the search tables."""
DeviceSearch.regenerate_search_table(self.db.session)

View file

@ -63,27 +63,21 @@ class Dummy:
common=True)
print('Creating stuff...'.ljust(30), end='')
with click_spinner.spinner():
out = runner.invoke(args=['create-org', *self.ORG], catch_exceptions=False).output
out = runner.invoke('org', 'add', *self.ORG).output
org_id = json.loads(out)['id']
user = self.user_client('user@dhub.com', '1234')
# todo put user's agent into Org
for id in self.TAGS:
user.post({'id': id}, res=Tag)
for id, sec in self.ET:
runner.invoke(args=[
'create-tag', id,
runner.invoke('tag', 'add', id,
'-p', 'https://t.devicetag.io',
'-s', sec,
'-o', org_id
],
catch_exceptions=False)
'-o', org_id)
# create tag for pc-laudem
runner.invoke(args=[
'create-tag', 'tagA',
runner.invoke('tag', 'add', 'tagA',
'-p', 'https://t.devicetag.io',
'-s', 'tagA-secondary'
],
catch_exceptions=False)
'-s', 'tagA-secondary')
files = tuple(Path(__file__).parent.joinpath('files').iterdir())
print('done.')
sample_pc = None # We treat this one as a special sample for demonstrations

View file

@ -1,6 +1,7 @@
import json
import click
from boltons.typeutils import classproperty
from teal.resource import Converters, Resource
from ereuse_devicehub.db import db
@ -22,7 +23,7 @@ class OrganizationDef(AgentDef):
static_url_path=None,
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None):
cli_commands = ((self.create_org, 'create-org'),)
cli_commands = ((self.create_org, 'add'),)
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)
@ -44,6 +45,10 @@ class OrganizationDef(AgentDef):
print(json.dumps(o, indent=2))
return o
@classproperty
def cli_name(cls):
return 'org'
class Membership(Resource):
SCHEMA = schemas.Membership

View file

@ -83,8 +83,13 @@ class Agent(Thing):
class Organization(JoinedTableMixin, Agent):
default_of = db.relationship(Inventory,
single_parent=True,
uselist=False,
lazy=True,
backref=backref('org', lazy=True),
# We need to use this as we cannot do Inventory.foreign -> Org
# as foreign keys can only reference to one table
# and we have multiple organization table (one per schema)
foreign_keys=[Inventory.org_id],
primaryjoin=lambda: Organization.id == Inventory.org_id)
def __init__(self, name: str, **kwargs) -> None:

View file

@ -1,8 +1,6 @@
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
@ -20,35 +18,8 @@ class InventoryDef(Resource):
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()
url_prefix, subdomain, url_defaults, root_path)
@classmethod
def set_inventory_config(cls,
@ -72,8 +43,23 @@ class InventoryDef(Resource):
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
@classmethod
def delete_inventory(cls):
"""Removes an inventory alongside with the users that have
only access to this inventory.
"""
from ereuse_devicehub.resources.user.models import User, UserInventory
inv = Inventory.query.filter_by(id=current_app.id).one()
db.session.delete(inv)
db.session.flush()
# Remove users that end-up without any inventory
# todo this should be done in a trigger / event
users = User.query \
.filter(User.id.notin_(db.session.query(UserInventory.user_id).distinct()))
for user in users:
db.session.delete(user)

View file

@ -13,7 +13,8 @@ class Inventory(Thing):
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."""
org_id = db.Column(db.UUID(as_uuid=True), db.ForeignKey('organization.id'), nullable=False)
# todo no validation that UUID is from an existing organization
org_id = db.Column(db.UUID(as_uuid=True), nullable=False)
__table_args__ = (
db.Index('id_hash', id, postgresql_using='hash'),

View file

@ -29,8 +29,8 @@ class TagDef(Resource):
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
root_path=None):
cli_commands = (
(self.create_tag, 'create-tag'),
(self.create_tags_csv, 'create-tags-csv')
(self.create_tag, 'add'),
(self.create_tags_csv, 'add-csv')
)
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
url_prefix, subdomain, url_defaults, root_path, cli_commands)

View file

@ -5,7 +5,6 @@ from flask import current_app
from teal.resource import Converters, Resource
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.models import User
from ereuse_devicehub.resources.user.views import UserView, login
@ -20,7 +19,7 @@ class UserDef(Resource):
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.create_user, 'create-user'),)
cli_commands = ((self.create_user, 'add'),)
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'})
@ -29,7 +28,9 @@ class UserDef(Resource):
@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='Create too an Individual agent representing this user, '
'and give a name to this individual.')
@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', '--tax-id', help='The tax id of the agent (if --agent is set).')
@ -41,15 +42,16 @@ class UserDef(Resource):
country: str = None,
telephone: str = None,
tax_id: str = None) -> dict:
"""Creates an user.
"""Create an user.
If ``--agent`` is passed, it creates an ``Individual`` agent
that represents the user.
If ``--agent`` is passed, it creates too an ``Individual``
agent that represents the user.
"""
from ereuse_devicehub.resources.agent.models import Individual
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \
.load({'email': email, 'password': password})
if inventory:
from ereuse_devicehub.resources.inventory import Inventory
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
user = User(**u, inventories=inventory)
agent = Individual(**current_app.resources[Individual.t].schema.load(

View file

@ -5,6 +5,7 @@ from sqlalchemy import Column
from sqlalchemy.orm import relationship
from sqlalchemy_utils import Password
from ereuse_devicehub.db import db
from ereuse_devicehub.resources.agent.models import Individual
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.models import Thing
@ -30,3 +31,7 @@ class User(Thing):
@property
def individual(self) -> Union[Individual, None]:
pass
class UserInventory(db.Model):
pass

View file

@ -5,7 +5,7 @@ click==6.7
click-spinner==0.1.8
colorama==0.3.9
colour==0.1.5
ereuse-utils[naming, test, session, cli]==0.4.0b20
ereuse-utils[naming, test, session, cli]==0.4.0b21
Flask==1.0.2
Flask-Cors==3.0.6
Flask-SQLAlchemy==2.3.2
@ -24,7 +24,7 @@ requests[security]==2.19.1
requests-mock==1.5.2
SQLAlchemy==1.2.17
SQLAlchemy-Utils==0.33.11
teal==0.2.0a35
teal==0.2.0a36
webargs==4.0.0
Werkzeug==0.14.1
sqlalchemy-citext==1.3.post0

View file

@ -29,10 +29,10 @@ setup(
long_description=long_description,
long_description_content_type='text/markdown',
install_requires=[
'teal>=0.2.0a35', # teal always first
'teal>=0.2.0a36', # teal always first
'click',
'click-spinner',
'ereuse-utils[naming, test, session, cli]>=0.4b20',
'ereuse-utils[naming, test, session, cli]>=0.4b21',
'hashids',
'marshmallow_enum',
'psycopg2-binary',

View file

@ -62,7 +62,7 @@ def app(request, _app: Devicehub) -> Devicehub:
try:
with redirect_stdout(io.StringIO()):
_init()
except (ProgrammingError, IntegrityError):
except (ProgrammingError, IntegrityError, AssertionError):
print('Database was not correctly emptied. Re-empty and re-installing...')
_drop()
_init()

View file

@ -209,7 +209,7 @@ def test_device_search_regenerate_table(app: DeviceSearch, user: UserClient):
i, _ = user.get(res=Device, query=[('search', 'Desktop')])
assert not i['items'], 'Truncate deleted all items'
runner = app.test_cli_runner()
runner.invoke(args=['regenerate-search'], catch_exceptions=False)
runner.invoke('inv', 'search')
i, _ = user.get(res=Device, query=[('search', 'Desktop')])
assert i['items'], 'Regenerated re-made the table'

View file

@ -13,7 +13,6 @@ def noop():
@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)

View file

@ -4,6 +4,6 @@ from ereuse_devicehub.devicehub import Devicehub
def test_dummy(_app: Devicehub):
"""Tests the dummy cli command."""
runner = _app.test_cli_runner()
runner.invoke(args=['dummy', '--yes'], catch_exceptions=False)
runner.invoke('dummy', '--yes')
with _app.app_context():
_app.db.drop_all()

View file

@ -1,19 +1,147 @@
from typing import List
from uuid import UUID
import click.testing
import pytest
from boltons.urlutils import URL
import ereuse_devicehub.cli
from ereuse_devicehub.db import db
from ereuse_devicehub.devicehub import Devicehub
from ereuse_devicehub.resources.agent.models import Organization
from ereuse_devicehub.resources.inventory import Inventory
from ereuse_devicehub.resources.user import User
from tests.conftest import TestConfig
@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():
"""Tests deleting an inventory without
disturbing other inventories (ex. keeping commmon db), and
removing its traces in common (no inventory row in inventory table).
"""
Tests the management of inventories in a multi-inventory environment
(several Devicehub instances that point at different schemas).
"""
class NoExcCliRunner(click.testing.CliRunner):
"""Runner that interfaces with the Devicehub CLI."""
def invoke(self, *args, input=None, env=None, catch_exceptions=False, color=False,
**extra):
r = super().invoke(ereuse_devicehub.cli.cli,
args, input, env, catch_exceptions, color, **extra)
assert r.exit_code == 0, 'CLI code {}: {}'.format(r.exit_code, r.output)
return r
def inv(self, name: str):
"""Set an inventory as an environment variable."""
self.env = {'dhi': name}
@pytest.fixture()
def cli(config, _app):
"""Returns an interface for the dh CLI client,
cleaning the database afterwards.
"""
def drop_schemas():
with _app.app_context():
_app.db.drop_schema(schema='tdb1')
_app.db.drop_schema(schema='tdb2')
_app.db.drop_schema(schema='common')
drop_schemas()
ereuse_devicehub.cli.DevicehubGroup.CONFIG = TestConfig
yield NoExcCliRunner()
drop_schemas()
@pytest.fixture()
def tdb1(config):
return Devicehub(inventory='tdb1', config=config, db=db)
@pytest.fixture()
def tdb2(config):
return Devicehub(inventory='tdb2', config=config, db=db)
def test_inventory_create_delete_user(cli, tdb1, tdb2):
"""Tests creating two inventories with users, one user has
access to the first inventory and the other to both. Finally, deletes
the first inventory, deleting only the first user too.
"""
# Create first DB
cli.inv('tdb1')
cli.invoke('inv', 'add',
'-n', 'Test DB1',
'-on', 'ACME DB1',
'-oi', 'acme-id',
'-tu', 'https://example.com',
'-tt', '3c66a6ad-22de-4db6-ac46-d8982522ec40',
'--common')
# Create an user for first DB
cli.invoke('user', 'add', 'foo@foo.com', '-a', 'Foo', '-c', 'ES', '-p', 'Such password')
with tdb1.app_context():
# There is a row for the inventory
inv = Inventory.query.one() # type: Inventory
assert inv.id == 'tdb1'
assert inv.name == 'Test DB1'
assert inv.tag_provider == URL('https://example.com')
assert inv.tag_token == UUID('3c66a6ad-22de-4db6-ac46-d8982522ec40')
assert db.has_schema('tdb1')
org = Organization.query.one() # type: Organization
# assert inv.org_id == org.id
assert org.name == 'ACME DB1'
assert org.tax_id == 'acme-id'
user = User.query.one() # type: User
assert user.email == 'foo@foo.com'
cli.inv('tdb2')
# Create a second DB
# Note how we don't create common anymore
cli.invoke('inv', 'add',
'-n', 'Test DB2',
'-on', 'ACME DB2',
'-oi', 'acme-id-2',
'-tu', 'https://example.com',
'-tt', 'fbad1c08-ffdc-4a61-be49-464962c186a8')
# Create an user for with access for both DB
cli.invoke('user', 'add', 'bar@bar.com', '-a', 'Bar', '-p', 'Wow password')
with tdb2.app_context():
inventories = Inventory.query.all() # type: List[Inventory]
assert len(inventories) == 2
assert inventories[0].id == 'tdb1'
assert inventories[1].id == 'tdb2'
assert db.has_schema('tdb2')
org_db2 = Organization.query.one()
assert org_db2 != org
assert org_db2.name == 'ACME DB2'
users = User.query.all() # type: List[User]
assert users[0].email == 'foo@foo.com'
assert users[1].email == 'bar@bar.com'
# Delete tdb1
cli.inv('tdb1')
cli.invoke('inv', 'del', '--yes')
with tdb2.app_context():
# There is only tdb2 as inventory
inv = Inventory.query.one() # type: Inventory
assert inv.id == 'tdb2'
# User foo@foo.com is deleted because it only
# existed in tdb1, but not bar@bar.com which existed
# in another inventory too (tdb2)
user = User.query.one() # type: User
assert user.email == 'bar@bar.com'
assert not db.has_schema('tdb1')
assert db.has_schema('tdb2')
def test_create_existing_inventory(cli, tdb1):
"""Tries to create twice the same inventory."""
cli.inv('tdb1')
cli.invoke('inv', 'add', '--common')
with tdb1.app_context():
assert db.has_schema('tdb1')
with pytest.raises(AssertionError, message='Schema tdb1 already exists.'):
cli.invoke('inv', 'add', '--common')

View file

@ -137,7 +137,7 @@ def test_tag_get_device_from_tag_endpoint_multiple_tags(app: Devicehub, user: Us
def test_tag_create_tags_cli(app: Devicehub, user: UserClient):
"""Checks creating tags with the CLI endpoint."""
runner = app.test_cli_runner()
runner.invoke(args=['create-tag', 'id1'], catch_exceptions=False)
runner.invoke('tag', 'add', 'id1')
with app.app_context():
tag = Tag.query.one() # type: Tag
assert tag.id == 'id1'
@ -148,8 +148,7 @@ def test_tag_create_etags_cli(app: Devicehub, user: UserClient):
"""Creates an eTag through the CLI."""
# todo what happens to organization?
runner = app.test_cli_runner()
runner.invoke(args=['create-tag', '-p', 'https://t.ereuse.org', '-s', 'foo', 'DT-BARBAR'],
catch_exceptions=False)
runner.invoke('tag', 'add', '-p', 'https://t.ereuse.org', '-s', 'foo', 'DT-BARBAR')
with app.app_context():
tag = Tag.query.one() # type: Tag
assert tag.id == 'dt-barbar'
@ -222,8 +221,7 @@ def test_tag_create_tags_cli_csv(app: Devicehub, user: UserClient):
"""Checks creating tags with the CLI endpoint using a CSV."""
csv = pathlib.Path(__file__).parent / 'files' / 'tags-cli.csv'
runner = app.test_cli_runner()
runner.invoke(args=['create-tags-csv', str(csv)],
catch_exceptions=False)
runner.invoke('tag', 'add-csv', str(csv))
with app.app_context():
t1 = Tag.from_an_id('id1').one()
t2 = Tag.from_an_id('sec1').one()