From f570e9d3d056436c900aa19e2af43f408c9df889 Mon Sep 17 00:00:00 2001 From: Xavier Bustamante Talavera Date: Wed, 23 Jan 2019 16:55:04 +0100 Subject: [PATCH] Add inventories with dispatcher --- ereuse_devicehub/cli.py | 28 +++++++ ereuse_devicehub/config.py | 30 +------ ereuse_devicehub/devicehub.py | 73 +++++++++++++++-- ereuse_devicehub/dispatchers.py | 46 +++++++++++ ereuse_devicehub/dummy/dummy.py | 19 ++++- ereuse_devicehub/resources/agent/__init__.py | 7 -- ereuse_devicehub/resources/agent/models.py | 22 +++--- ereuse_devicehub/resources/agent/schemas.py | 1 + .../resources/inventory/__init__.py | 79 +++++++++++++++++++ ereuse_devicehub/resources/inventory/model.py | 12 ++- .../resources/inventory/schema.py | 11 +++ ereuse_devicehub/resources/tag/view.py | 8 +- ereuse_devicehub/resources/user/__init__.py | 19 ++++- ereuse_devicehub/resources/user/models.py | 20 +++-- ereuse_devicehub/resources/user/models.pyi | 9 ++- ereuse_devicehub/resources/user/schemas.py | 2 + examples/apache.conf | 2 +- examples/app.py | 9 +-- requirements.txt | 6 +- setup.py | 9 ++- tests/conftest.py | 22 ++++-- tests/test_agent.py | 3 +- tests/test_basic.py | 2 +- tests/test_dispatcher.py | 48 +++++++++++ tests/test_inventory.py | 16 ++++ tests/test_tag.py | 4 +- tests/test_user.py | 6 ++ 27 files changed, 414 insertions(+), 99 deletions(-) create mode 100644 ereuse_devicehub/cli.py create mode 100644 ereuse_devicehub/dispatchers.py create mode 100644 ereuse_devicehub/resources/inventory/schema.py create mode 100644 tests/test_dispatcher.py create mode 100644 tests/test_inventory.py diff --git a/ereuse_devicehub/cli.py b/ereuse_devicehub/cli.py new file mode 100644 index 00000000..edf68df4 --- /dev/null +++ b/ereuse_devicehub/cli.py @@ -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 diff --git a/ereuse_devicehub/config.py b/ereuse_devicehub/config.py index 81cde720..5876da33 100644 --- a/ereuse_devicehub/config.py +++ b/ereuse_devicehub/config.py @@ -2,13 +2,12 @@ from distutils.version import StrictVersion from itertools import chain from typing import Set -import boltons.urlutils from teal.auth import TokenAuth from teal.config import Config from teal.enums import Currency 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.documents import documents from ereuse_devicehub.resources.enums import PriceSoftware, RatingSoftware @@ -21,23 +20,16 @@ class DevicehubConfig(Config): import_resource(tag), import_resource(agent), import_resource(lot), - import_resource(documents)) + import_resource(documents), + import_resource(inventory)), ) PASSWORD_SCHEMES = {'pbkdf2_sha256'} # type: Set[str] SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/devicehub' # type: str - SCHEMA = 'dhub' MIN_WORKBENCH = StrictVersion('11.0a1') # type: StrictVersion """ the minimum version of ereuse.org workbench that this devicehub 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_VERSION = '0.2' API_DOC_CONFIG_COMPONENTS = { @@ -60,19 +52,3 @@ class DevicehubConfig(Config): """ 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__() diff --git a/ereuse_devicehub/devicehub.py b/ereuse_devicehub/devicehub.py index 5fe370ed..88520f77 100644 --- a/ereuse_devicehub/devicehub.py +++ b/ereuse_devicehub/devicehub.py @@ -1,16 +1,23 @@ +import uuid from typing import Type +import boltons.urlutils +import click +import click_spinner +import ereuse_utils.cli from ereuse_utils.session import DevicehubClient +from flask.globals import _app_ctx_stack, g from flask_sqlalchemy import SQLAlchemy from sqlalchemy import event -from teal.config import Config as ConfigClass from teal.teal import Teal from ereuse_devicehub.auth import Auth from ereuse_devicehub.client import Client +from ereuse_devicehub.config import DevicehubConfig from ereuse_devicehub.db import db from ereuse_devicehub.dummy.dummy import Dummy from ereuse_devicehub.resources.device.search import DeviceSearch +from ereuse_devicehub.resources.inventory import Inventory, InventoryDef class Devicehub(Teal): @@ -18,7 +25,8 @@ class Devicehub(Teal): Dummy = Dummy def __init__(self, - config: ConfigClass, + inventory: str, + config: DevicehubConfig = DevicehubConfig(), db: SQLAlchemy = db, import_name=__name__.split('.')[0], static_url_path=None, @@ -31,27 +39,76 @@ class Devicehub(Teal): instance_relative_config=False, root_path=None, 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, 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.before_request(self.register_db_events_listeners) 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): """Registers the SQLAlchemy event listeners.""" # todo can I make it with a global Session only? event.listen(db.session, 'before_commit', DeviceSearch.update_modified_devices) - def _init_db(self, exclude_schema=None, check=False): - created = super()._init_db(exclude_schema, check) - if created: + # noinspection PyMethodOverriding + @click.option('--name', '-n', + 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) - return created + self._init_resources(exclude_schema=exclude_schema) + self.db.session.commit() + print('done.') def regenerate_search(self): """Re-creates from 0 all the search tables.""" DeviceSearch.regenerate_search_table(self.db.session) db.session.commit() 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) diff --git a/ereuse_devicehub/dispatchers.py b/ereuse_devicehub/dispatchers.py new file mode 100644 index 00000000..c7465138 --- /dev/null +++ b/ereuse_devicehub/dispatchers.py @@ -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) diff --git a/ereuse_devicehub/dummy/dummy.py b/ereuse_devicehub/dummy/dummy.py index c7b83345..7b24cd08 100644 --- a/ereuse_devicehub/dummy/dummy.py +++ b/ereuse_devicehub/dummy/dummy.py @@ -5,6 +5,7 @@ from typing import Set import click import click_spinner +import ereuse_utils.cli import yaml from ereuse_utils.test import ANY @@ -41,11 +42,25 @@ class Dummy: self.app = app 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.' 'Do you want to continue?') - def run(self): + def run(self, tag_url, tag_token): 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='') with click_spinner.spinner(): out = runner.invoke(args=['create-org', *self.ORG], catch_exceptions=False).output diff --git a/ereuse_devicehub/resources/agent/__init__.py b/ereuse_devicehub/resources/agent/__init__.py index 0914bfb4..f0b48d24 100644 --- a/ereuse_devicehub/resources/agent/__init__.py +++ b/ereuse_devicehub/resources/agent/__init__.py @@ -1,8 +1,6 @@ import json import click -from flask import current_app as app -from teal.db import SQLAlchemy from teal.resource import Converters, Resource from ereuse_devicehub.db import db @@ -46,11 +44,6 @@ class OrganizationDef(AgentDef): print(json.dumps(o, indent=2)) 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): SCHEMA = schemas.Membership diff --git a/ereuse_devicehub/resources/agent/models.py b/ereuse_devicehub/resources/agent/models.py index 02082843..19a76ee6 100644 --- a/ereuse_devicehub/resources/agent/models.py +++ b/ereuse_devicehub/resources/agent/models.py @@ -3,17 +3,17 @@ from operator import attrgetter from uuid import uuid4 from citext import CIText -from flask import current_app as app, g from sqlalchemy import Column, Enum as DBEnum, ForeignKey, Unicode, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import backref, relationship, validates from sqlalchemy_utils import EmailType, PhoneNumberType 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 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.user.models import User @@ -46,6 +46,7 @@ class Agent(Thing): __table_args__ = ( UniqueConstraint(tax_id, country, name='Registration Number per country.'), + UniqueConstraint(tax_id, name, name='One tax ID with one name.') ) @declared_attr @@ -80,21 +81,18 @@ class Agent(Thing): 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: super().__init__(**kwargs, name=name) @classmethod def get_default_org_id(cls) -> UUID: """Retrieves the default organization.""" - try: - 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?') + return cls.query.filter_by(default_of=Inventory.current).one().id class Individual(JoinedTableMixin, Agent): diff --git a/ereuse_devicehub/resources/agent/schemas.py b/ereuse_devicehub/resources/agent/schemas.py index 459319db..24109c18 100644 --- a/ereuse_devicehub/resources/agent/schemas.py +++ b/ereuse_devicehub/resources/agent/schemas.py @@ -21,6 +21,7 @@ class Agent(Thing): class Organization(Agent): members = NestedOn('Membership') + default_of = NestedOn('Inventory') class Membership(Thing): diff --git a/ereuse_devicehub/resources/inventory/__init__.py b/ereuse_devicehub/resources/inventory/__init__.py index e69de29b..28eed958 100644 --- a/ereuse_devicehub/resources/inventory/__init__.py +++ b/ereuse_devicehub/resources/inventory/__init__.py @@ -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 diff --git a/ereuse_devicehub/resources/inventory/model.py b/ereuse_devicehub/resources/inventory/model.py index b1c583ed..70a75aae 100644 --- a/ereuse_devicehub/resources/inventory/model.py +++ b/ereuse_devicehub/resources/inventory/model.py @@ -1,3 +1,6 @@ +from boltons.typeutils import classproperty +from flask import current_app + from ereuse_devicehub.db import db 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.""" name = db.Column(db.CIText(), nullable=False, unique=True) 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.""" + 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() diff --git a/ereuse_devicehub/resources/inventory/schema.py b/ereuse_devicehub/resources/inventory/schema.py new file mode 100644 index 00000000..7d7a7dea --- /dev/null +++ b/ereuse_devicehub/resources/inventory/schema.py @@ -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') diff --git a/ereuse_devicehub/resources/tag/view.py b/ereuse_devicehub/resources/tag/view.py index 6fae8e88..7140573b 100644 --- a/ereuse_devicehub/resources/tag/view.py +++ b/ereuse_devicehub/resources/tag/view.py @@ -1,5 +1,4 @@ -from ereuse_utils.session import DevicehubClient -from flask import Response, current_app, current_app as app, jsonify, redirect, request +from flask import Response, current_app as app, g, jsonify, redirect, request from teal.marshmallow import ValidationError from teal.resource import View, url_for_resource @@ -19,9 +18,8 @@ class TagView(View): return res def _create_many_regular_tags(self, num: int): - tag_provider = current_app.tag_provider # type: DevicehubClient - tags_id, _ = tag_provider.post('/', {}, query=[('num', num)]) - tags = [Tag(id=tag_id, provider=current_app.config['TAG_BASE_URL']) for tag_id in tags_id] + tags_id, _ = g.tag_provider.post('/', {}, query=[('num', num)]) + tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id] db.session.add_all(tags) db.session.commit() response = jsonify(items=self.schema.dump(tags, many=True, nested=1)) # type: Response diff --git a/ereuse_devicehub/resources/user/__init__.py b/ereuse_devicehub/resources/user/__init__.py index f3c4b61f..d749bb59 100644 --- a/ereuse_devicehub/resources/user/__init__.py +++ b/ereuse_devicehub/resources/user/__init__.py @@ -1,8 +1,11 @@ +from typing import Iterable + from click import argument, option 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 @@ -23,13 +26,21 @@ class UserDef(Resource): self.add_url_rule('/login/', view_func=login, methods={'POST'}) @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('-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).') @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, - telephone: str = None, tax_id: str = None) -> dict: + def create_user(self, email: str, + password: str, + inventory: Iterable[str] = tuple(), + agent: str = None, + country: str = None, + telephone: str = None, + tax_id: str = None) -> dict: """Creates an user. If ``--agent`` is passed, it creates an ``Individual`` agent @@ -38,7 +49,9 @@ class UserDef(Resource): from ereuse_devicehub.resources.agent.models import Individual u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \ .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( dict(name=agent, email=email, country=country, telephone=telephone, taxId=tax_id) )) diff --git a/ereuse_devicehub/resources/user/models.py b/ereuse_devicehub/resources/user/models.py index 26e641b2..da5555dd 100644 --- a/ereuse_devicehub/resources/user/models.py +++ b/ereuse_devicehub/resources/user/models.py @@ -19,16 +19,24 @@ class User(Thing): schemes=app.config['PASSWORD_SCHEMES'], **kwargs ))) - """ - Password field. - From `here `_ - """ - token = Column(UUID(as_uuid=True), default=uuid4, unique=True) + token = Column(UUID(as_uuid=True), default=uuid4, unique=True, nullable=False) inventories = db.relationship(Inventory, backref=db.backref('users', lazy=True, collection_class=set), secondary=lambda: UserInventory.__table__, 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: return ''.format(self) diff --git a/ereuse_devicehub/resources/user/models.pyi b/ereuse_devicehub/resources/user/models.pyi index f7057e2b..c6dd4754 100644 --- a/ereuse_devicehub/resources/user/models.pyi +++ b/ereuse_devicehub/resources/user/models.pyi @@ -2,9 +2,11 @@ from typing import Set, Union from uuid import UUID from sqlalchemy import Column +from sqlalchemy.orm import relationship from sqlalchemy_utils import Password from ereuse_devicehub.resources.agent.models import Individual +from ereuse_devicehub.resources.inventory import Inventory from ereuse_devicehub.resources.models import Thing @@ -13,14 +15,17 @@ class User(Thing): email = ... # type: Column password = ... # type: Column token = ... # type: Column + inventories = ... # type: relationship - def __init__(self, **kwargs) -> None: - super().__init__(**kwargs) + def __init__(self, email: str, password: str = None, + inventories: Set[Inventory] = None) -> None: + super().__init__() self.id = ... # type: UUID self.email = ... # type: str self.password = ... # type: Password self.individuals = ... # type: Set[Individual] self.token = ... # type: UUID + self.inventories = ... # type: Set[Inventory] @property def individual(self) -> Union[Individual, None]: diff --git a/ereuse_devicehub/resources/user/schemas.py b/ereuse_devicehub/resources/user/schemas.py index db6497af..91a7be92 100644 --- a/ereuse_devicehub/resources/user/schemas.py +++ b/ereuse_devicehub/resources/user/schemas.py @@ -5,6 +5,7 @@ from teal.marshmallow import SanitizedStr from ereuse_devicehub import auth from ereuse_devicehub.marshmallow import NestedOn from ereuse_devicehub.resources.agent.schemas import Individual +from ereuse_devicehub.resources.inventory.schema import Inventory from ereuse_devicehub.resources.schemas import Thing @@ -17,6 +18,7 @@ class User(Thing): token = String(dump_only=True, description='Use this token in an Authorization header to access the app.' 'The token can change overtime.') + inventories = NestedOn(Inventory, many=True, dump_only=True) def __init__(self, only=None, diff --git a/examples/apache.conf b/examples/apache.conf index cfda5300..01e2e403 100644 --- a/examples/apache.conf +++ b/examples/apache.conf @@ -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. Define wsgipath ${appdir}/wsgi.wsgi # 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) diff --git a/examples/app.py b/examples/app.py index cc057684..31608a8b 100644 --- a/examples/app.py +++ b/examples/app.py @@ -1,4 +1,3 @@ -from ereuse_devicehub.config import DevicehubConfig from ereuse_devicehub.devicehub import Devicehub """ @@ -7,10 +6,4 @@ Example app with minimal configuration. Use this as a starting point. """ - -class MyConfig(DevicehubConfig): - ORGANIZATION_NAME = 'My org' - ORGANIZATION_TAX_ID = 'foo-bar' - - -app = Devicehub(MyConfig()) +app = Devicehub(inventory='db1') diff --git a/requirements.txt b/requirements.txt index baa432ca..631e7e39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ click==6.7 click-spinner==0.1.8 colorama==0.3.9 colour==0.1.5 -ereuse-utils==0.4.0b14 +ereuse-utils[naming, test, session, cli]==0.4.0b14 Flask==1.0.2 Flask-Cors==3.0.6 Flask-SQLAlchemy==2.3.2 @@ -15,7 +15,6 @@ marshmallow==3.0.0b11 marshmallow-enum==1.4.1 passlib==1.7.1 phonenumbers==8.9.11 -pySMART.smartx==0.3.9 pytest==3.7.2 pytest-runner==4.2 python-dateutil==2.7.3 @@ -25,9 +24,10 @@ requests==2.19.1 requests-mock==1.5.2 SQLAlchemy==1.2.14 SQLAlchemy-Utils==0.33.6 -teal==0.2.0a34 +teal==0.2.0a35 webargs==4.0.0 Werkzeug==0.14.1 sqlalchemy-citext==1.3.post0 flask-weasyprint==0.5 weasyprint==44 +psycopg2-binary==2.7.5 diff --git a/setup.py b/setup.py index 54282ddc..1cce365e 100644 --- a/setup.py +++ b/setup.py @@ -29,10 +29,10 @@ setup( long_description=long_description, long_description_content_type='text/markdown', install_requires=[ - 'teal>=0.2.0a34', # teal always first + 'teal>=0.2.0a35', # teal always first 'click', 'click-spinner', - 'ereuse-utils[Naming]>=0.4b14', + 'ereuse-utils[naming, test, session, cli]>=0.4b14', 'hashids', 'marshmallow_enum', 'psycopg2-binary', @@ -57,6 +57,11 @@ setup( 'test': test_requires }, tests_require=test_requires, + entry_points={ + 'console_scripts': [ + 'dh = ereuse_devicehub.cli:cli' + ] + }, setup_requires=[ 'pytest-runner' ], diff --git a/tests/conftest.py b/tests/conftest.py index d4ec0e36..63005389 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ import io +import uuid from contextlib import redirect_stdout from datetime import datetime from pathlib import Path +import boltons.urlutils import pytest import yaml from psycopg2 import IntegrityError @@ -26,13 +28,8 @@ T = {'start_time': STARTT, 'end_time': ENDT} class TestConfig(DevicehubConfig): SQLALCHEMY_DATABASE_URI = 'postgresql://dhub:ereuse@localhost/dh_test' - SCHEMA = 'test' TESTING = True - ORGANIZATION_NAME = 'FooOrg' - ORGANIZATION_TAX_ID = 'foo-org-id' SERVER_NAME = 'localhost' - TAG_BASE_URL = 'https://example.com' - TAG_TOKEN = 'tagToken' @pytest.fixture(scope='session') @@ -42,7 +39,7 @@ def config(): @pytest.fixture(scope='session') def _app(config: TestConfig) -> Devicehub: - return Devicehub(config=config, db=db) + return Devicehub(inventory='test', config=config, db=db) @pytest.fixture() @@ -52,14 +49,23 @@ def app(request, _app: Devicehub) -> Devicehub: with _app.app_context(): 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(): try: with redirect_stdout(io.StringIO()): - _app.init_db() + _init() except (ProgrammingError, IntegrityError): print('Database was not correctly emptied. Re-empty and re-installing...') _drop() - _app.init_db() + _init() request.addfinalizer(_drop) return _app diff --git a/tests/test_agent.py b/tests/test_agent.py index d379c7b5..bb6b7714 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -107,8 +107,7 @@ def test_default_org_exists(config: DevicehubConfig): initialization and that is accessible for the method :meth:`ereuse_devicehub.resources.user.Organization.get_default_org`. """ - assert models.Organization.query.filter_by(name=config.ORGANIZATION_NAME, - tax_id=config.ORGANIZATION_TAX_ID).one() + assert models.Organization.query.filter_by(name='FooOrg', tax_id='foo-org-id').one() assert isinstance(models.Organization.get_default_org_id(), UUID) diff --git a/tests/test_basic.py b/tests/test_basic.py index 7fc1a29c..e1f3daab 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -42,4 +42,4 @@ def test_api_docs(client: Client): 'scheme': 'basic', 'name': 'Authorization' } - assert 95 == len(docs['definitions']) + assert len(docs['definitions']) == 96 diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py new file mode 100644 index 00000000..cf9a735e --- /dev/null +++ b/tests/test_dispatcher.py @@ -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' diff --git a/tests/test_inventory.py b/tests/test_inventory.py new file mode 100644 index 00000000..28e6c6d8 --- /dev/null +++ b/tests/test_inventory.py @@ -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 diff --git a/tests/test_tag.py b/tests/test_tag.py index faf525e4..5353db25 100644 --- a/tests/test_tag.py +++ b/tests/test_tag.py @@ -241,7 +241,9 @@ def test_crate_num_regular_tags(user: UserClient, requests_mock: requests_mock.m """ requests_mock.post('https://example.com/', # request - request_headers={'Authorization': 'Basic tagToken'}, + request_headers={ + 'Authorization': 'Basic 52dacef0-6bcb-4919-bfed-f10d2c96ecee' + }, # response json=['tag1id', 'tag2id'], status_code=201) diff --git a/tests/test_user.py b/tests/test_user.py index a92ecd70..8f56e66f 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -82,6 +82,7 @@ def test_login_success(client: Client, app: Devicehub): assert user['individuals'][0]['name'] == 'Timmy' assert user['individuals'][0]['type'] == 'Person' assert len(user['individuals']) == 1 + assert user['inventories'][0]['id'] == 'test' 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'}, uri='/users/login/', status=ValidationError) + + +@pytest.mark.xfail(reason='Test not developed') +def test_user_at_least_one_inventory(): + pass