add teal as module
This commit is contained in:
parent
e624ab7a7a
commit
01ef359bd4
|
@ -1,9 +1,9 @@
|
||||||
from sqlalchemy.exc import DataError
|
from sqlalchemy.exc import DataError
|
||||||
from teal.auth import TokenAuth
|
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from werkzeug.exceptions import Unauthorized
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
from ereuse_devicehub.resources.user.models import User, Session
|
from ereuse_devicehub.resources.user.models import Session, User
|
||||||
|
from ereuse_devicehub.teal.auth import TokenAuth
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
|
||||||
|
|
||||||
class Auth(TokenAuth):
|
class Auth(TokenAuth):
|
||||||
|
|
|
@ -4,11 +4,11 @@ from typing import Dict, Iterable, Type, Union
|
||||||
from ereuse_utils.test import JSON, Res
|
from ereuse_utils.test import JSON, Res
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
from flask_wtf.csrf import generate_csrf
|
from flask_wtf.csrf import generate_csrf
|
||||||
from teal.client import Client as TealClient
|
|
||||||
from teal.client import Query, Status
|
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from ereuse_devicehub.resources import models, schemas
|
from ereuse_devicehub.resources import models, schemas
|
||||||
|
from ereuse_devicehub.teal.client import Client as TealClient
|
||||||
|
from ereuse_devicehub.teal.client import Query, Status
|
||||||
|
|
||||||
ResourceLike = Union[Type[Union[models.Thing, schemas.Thing]], str]
|
ResourceLike = Union[Type[Union[models.Thing, schemas.Thing]], str]
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,6 @@ from distutils.version import StrictVersion
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
|
||||||
from decouple import config
|
from decouple import config
|
||||||
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 (
|
from ereuse_devicehub.resources import (
|
||||||
action,
|
action,
|
||||||
|
@ -23,6 +19,10 @@ from ereuse_devicehub.resources.licences import licences
|
||||||
from ereuse_devicehub.resources.metric import definitions as metric_def
|
from ereuse_devicehub.resources.metric import definitions as metric_def
|
||||||
from ereuse_devicehub.resources.tradedocument import definitions as tradedocument
|
from ereuse_devicehub.resources.tradedocument import definitions as tradedocument
|
||||||
from ereuse_devicehub.resources.versions import versions
|
from ereuse_devicehub.resources.versions import versions
|
||||||
|
from ereuse_devicehub.teal.auth import TokenAuth
|
||||||
|
from ereuse_devicehub.teal.config import Config
|
||||||
|
from ereuse_devicehub.teal.enums import Currency
|
||||||
|
from ereuse_devicehub.teal.utils import import_resource
|
||||||
|
|
||||||
|
|
||||||
class DevicehubConfig(Config):
|
class DevicehubConfig(Config):
|
||||||
|
|
|
@ -4,7 +4,8 @@ from sqlalchemy.dialects import postgresql
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.sql import expression
|
from sqlalchemy.sql import expression
|
||||||
from sqlalchemy_utils import view
|
from sqlalchemy_utils import view
|
||||||
from teal.db import SchemaSQLAlchemy, SchemaSession
|
|
||||||
|
from ereuse_devicehub.teal.db import SchemaSession, SchemaSQLAlchemy
|
||||||
|
|
||||||
|
|
||||||
class DhSession(SchemaSession):
|
class DhSession(SchemaSession):
|
||||||
|
@ -23,6 +24,7 @@ class DhSession(SchemaSession):
|
||||||
# flush, all the new / dirty interesting things in a variable
|
# flush, all the new / dirty interesting things in a variable
|
||||||
# until DeviceSearch is executed
|
# until DeviceSearch is executed
|
||||||
from ereuse_devicehub.resources.device.search import DeviceSearch
|
from ereuse_devicehub.resources.device.search import DeviceSearch
|
||||||
|
|
||||||
DeviceSearch.update_modified_devices(session=self)
|
DeviceSearch.update_modified_devices(session=self)
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,6 +33,7 @@ class SQLAlchemy(SchemaSQLAlchemy):
|
||||||
schema of the database, as it is in the `search_path`
|
schema of the database, as it is in the `search_path`
|
||||||
defined in teal.
|
defined in teal.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# todo add here all types of columns used so we don't have to
|
# todo add here all types of columns used so we don't have to
|
||||||
# manually import them all the time
|
# manually import them all the time
|
||||||
UUID = postgresql.UUID
|
UUID = postgresql.UUID
|
||||||
|
@ -60,7 +63,9 @@ def create_view(name, selectable):
|
||||||
# We need to ensure views are created / destroyed before / after
|
# We need to ensure views are created / destroyed before / after
|
||||||
# SchemaSQLAlchemy's listeners execute
|
# SchemaSQLAlchemy's listeners execute
|
||||||
# That is why insert=True in 'after_create'
|
# That is why insert=True in 'after_create'
|
||||||
event.listen(db.metadata, 'after_create', view.CreateView(name, selectable), insert=True)
|
event.listen(
|
||||||
|
db.metadata, 'after_create', view.CreateView(name, selectable), insert=True
|
||||||
|
)
|
||||||
event.listen(db.metadata, 'before_drop', view.DropView(name))
|
event.listen(db.metadata, 'before_drop', view.DropView(name))
|
||||||
return table
|
return table
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,6 @@ from ereuse_utils.session import DevicehubClient
|
||||||
from flask import _app_ctx_stack, g
|
from flask import _app_ctx_stack, g
|
||||||
from flask_login import LoginManager, current_user
|
from flask_login import LoginManager, current_user
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from teal.db import ResourceNotFound, SchemaSQLAlchemy
|
|
||||||
from teal.teal import Teal
|
|
||||||
|
|
||||||
from ereuse_devicehub.auth import Auth
|
from ereuse_devicehub.auth import Auth
|
||||||
from ereuse_devicehub.client import Client, UserClient
|
from ereuse_devicehub.client import Client, UserClient
|
||||||
|
@ -24,6 +22,8 @@ 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
|
from ereuse_devicehub.resources.inventory import Inventory, InventoryDef
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound, SchemaSQLAlchemy
|
||||||
|
from ereuse_devicehub.teal.teal import Teal
|
||||||
from ereuse_devicehub.templating import Environment
|
from ereuse_devicehub.templating import Environment
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,11 +5,11 @@ from flask import g
|
||||||
from sqlalchemy import Column, Integer
|
from sqlalchemy import Column, Integer
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref, relationship
|
from sqlalchemy.orm import backref, relationship
|
||||||
from teal.db import CASCADE_OWN, URL
|
|
||||||
|
|
||||||
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
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
|
||||||
|
|
||||||
|
|
||||||
class Transfer(Thing):
|
class Transfer(Thing):
|
||||||
|
|
|
@ -1,14 +1,33 @@
|
||||||
from marshmallow.fields import missing_
|
from marshmallow.fields import missing_
|
||||||
from teal.db import SQLAlchemy
|
|
||||||
from teal.marshmallow import NestedOn as TealNestedOn
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
from ereuse_devicehub.teal.db import SQLAlchemy
|
||||||
|
from ereuse_devicehub.teal.marshmallow import NestedOn as TealNestedOn
|
||||||
|
|
||||||
|
|
||||||
class NestedOn(TealNestedOn):
|
class NestedOn(TealNestedOn):
|
||||||
__doc__ = TealNestedOn.__doc__
|
__doc__ = TealNestedOn.__doc__
|
||||||
|
|
||||||
def __init__(self, nested, polymorphic_on='type', db: SQLAlchemy = db, collection_class=list,
|
def __init__(
|
||||||
default=missing_, exclude=tuple(), only_query: str = None, only=None, **kwargs):
|
self,
|
||||||
super().__init__(nested, polymorphic_on, db, collection_class, default, exclude,
|
nested,
|
||||||
only_query, only, **kwargs)
|
polymorphic_on='type',
|
||||||
|
db: SQLAlchemy = db,
|
||||||
|
collection_class=list,
|
||||||
|
default=missing_,
|
||||||
|
exclude=tuple(),
|
||||||
|
only_query: str = None,
|
||||||
|
only=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
nested,
|
||||||
|
polymorphic_on,
|
||||||
|
db,
|
||||||
|
collection_class,
|
||||||
|
default,
|
||||||
|
exclude,
|
||||||
|
only_query,
|
||||||
|
only,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from flask import Response, jsonify, request
|
from flask import Response, jsonify, request
|
||||||
from teal.query import NestedQueryFlaskParser
|
|
||||||
from webargs.flaskparser import FlaskParser
|
from webargs.flaskparser import FlaskParser
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.query import NestedQueryFlaskParser
|
||||||
|
|
||||||
|
|
||||||
class SearchQueryParser(NestedQueryFlaskParser):
|
class SearchQueryParser(NestedQueryFlaskParser):
|
||||||
|
|
||||||
def parse_querystring(self, req, name, field):
|
def parse_querystring(self, req, name, field):
|
||||||
if name == 'search':
|
if name == 'search':
|
||||||
v = FlaskParser.parse_querystring(self, req, name, field)
|
v = FlaskParser.parse_querystring(self, req, name, field)
|
||||||
|
@ -15,29 +15,33 @@ class SearchQueryParser(NestedQueryFlaskParser):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
def things_response(items: List[Dict],
|
def things_response(
|
||||||
page: int = None,
|
items: List[Dict],
|
||||||
per_page: int = None,
|
page: int = None,
|
||||||
total: int = None,
|
per_page: int = None,
|
||||||
previous: int = None,
|
total: int = None,
|
||||||
next: int = None,
|
previous: int = None,
|
||||||
url: str = None,
|
next: int = None,
|
||||||
code: int = 200) -> Response:
|
url: str = None,
|
||||||
|
code: int = 200,
|
||||||
|
) -> Response:
|
||||||
"""Generates a Devicehub API list conformant response for multiple
|
"""Generates a Devicehub API list conformant response for multiple
|
||||||
things.
|
things.
|
||||||
"""
|
"""
|
||||||
response = jsonify({
|
response = jsonify(
|
||||||
'items': items,
|
{
|
||||||
# todo pagination should be in Header like github
|
'items': items,
|
||||||
# https://developer.github.com/v3/guides/traversing-with-pagination/
|
# todo pagination should be in Header like github
|
||||||
'pagination': {
|
# https://developer.github.com/v3/guides/traversing-with-pagination/
|
||||||
'page': page,
|
'pagination': {
|
||||||
'perPage': per_page,
|
'page': page,
|
||||||
'total': total,
|
'perPage': per_page,
|
||||||
'previous': previous,
|
'total': total,
|
||||||
'next': next
|
'previous': previous,
|
||||||
},
|
'next': next,
|
||||||
'url': url or request.path
|
},
|
||||||
})
|
'url': url or request.path,
|
||||||
|
}
|
||||||
|
)
|
||||||
response.status_code = code
|
response.status_code = code
|
||||||
return response
|
return response
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.action import schemas
|
from ereuse_devicehub.resources.action import schemas
|
||||||
from ereuse_devicehub.resources.action.views.views import (ActionView, AllocateView, DeallocateView,
|
from ereuse_devicehub.resources.action.views.views import (
|
||||||
LiveView)
|
ActionView,
|
||||||
|
AllocateView,
|
||||||
|
DeallocateView,
|
||||||
|
LiveView,
|
||||||
|
)
|
||||||
from ereuse_devicehub.resources.device.sync import Sync
|
from ereuse_devicehub.resources.device.sync import Sync
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class ActionDef(Resource):
|
class ActionDef(Resource):
|
||||||
|
@ -169,13 +172,32 @@ class SnapshotDef(ActionDef):
|
||||||
VIEW = None
|
VIEW = None
|
||||||
SCHEMA = schemas.Snapshot
|
SCHEMA = schemas.Snapshot
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None, cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
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: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
url_prefix = '/{}'.format(ActionDef.resource)
|
url_prefix = '/{}'.format(ActionDef.resource)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
super().__init__(
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
self.sync = Sync()
|
self.sync = Sync()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,6 @@ from typing import Optional, Set, Union
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import inflection
|
import inflection
|
||||||
import teal.db
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from dateutil.tz import tzutc
|
from dateutil.tz import tzutc
|
||||||
|
@ -45,19 +44,8 @@ from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
from sqlalchemy.orm import backref, relationship, validates
|
from sqlalchemy.orm import backref, relationship, validates
|
||||||
from sqlalchemy.orm.events import AttributeEvents as Events
|
from sqlalchemy.orm.events import AttributeEvents as Events
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import (
|
|
||||||
CASCADE_OWN,
|
|
||||||
INHERIT_COND,
|
|
||||||
POLYMORPHIC_ID,
|
|
||||||
POLYMORPHIC_ON,
|
|
||||||
URL,
|
|
||||||
StrictVersionType,
|
|
||||||
check_lower,
|
|
||||||
check_range,
|
|
||||||
)
|
|
||||||
from teal.enums import Currency
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
|
import ereuse_devicehub.teal.db
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.agent.models import Agent
|
from ereuse_devicehub.resources.agent.models import Agent
|
||||||
from ereuse_devicehub.resources.device.metrics import TradeMetrics
|
from ereuse_devicehub.resources.device.metrics import TradeMetrics
|
||||||
|
@ -88,6 +76,18 @@ from ereuse_devicehub.resources.enums import (
|
||||||
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
from ereuse_devicehub.resources.models import STR_SM_SIZE, Thing
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
CASCADE_OWN,
|
||||||
|
INHERIT_COND,
|
||||||
|
POLYMORPHIC_ID,
|
||||||
|
POLYMORPHIC_ON,
|
||||||
|
URL,
|
||||||
|
StrictVersionType,
|
||||||
|
check_lower,
|
||||||
|
check_range,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.enums import Currency
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class JoinedTableMixin:
|
class JoinedTableMixin:
|
||||||
|
@ -119,7 +119,11 @@ class Action(Thing):
|
||||||
name.comment = """A name or title for the action. Used when searching
|
name.comment = """A name or title for the action. Used when searching
|
||||||
for actions.
|
for actions.
|
||||||
"""
|
"""
|
||||||
severity = Column(teal.db.IntEnum(Severity), default=Severity.Info, nullable=False)
|
severity = Column(
|
||||||
|
ereuse_devicehub.teal.db.IntEnum(Severity),
|
||||||
|
default=Severity.Info,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
severity.comment = Severity.__doc__
|
severity.comment = Severity.__doc__
|
||||||
closed = Column(Boolean, default=True, nullable=False)
|
closed = Column(Boolean, default=True, nullable=False)
|
||||||
closed.comment = """Whether the author has finished the action.
|
closed.comment = """Whether the author has finished the action.
|
||||||
|
@ -548,7 +552,11 @@ class Step(db.Model):
|
||||||
)
|
)
|
||||||
type = Column(Unicode(STR_SM_SIZE), nullable=False)
|
type = Column(Unicode(STR_SM_SIZE), nullable=False)
|
||||||
num = Column(SmallInteger, primary_key=True)
|
num = Column(SmallInteger, primary_key=True)
|
||||||
severity = Column(teal.db.IntEnum(Severity), default=Severity.Info, nullable=False)
|
severity = Column(
|
||||||
|
ereuse_devicehub.teal.db.IntEnum(Severity),
|
||||||
|
default=Severity.Info,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
start_time = Column(db.TIMESTAMP(timezone=True), nullable=False)
|
start_time = Column(db.TIMESTAMP(timezone=True), nullable=False)
|
||||||
start_time.comment = Action.start_time.comment
|
start_time.comment = Action.start_time.comment
|
||||||
end_time = Column(
|
end_time = Column(
|
||||||
|
|
|
@ -21,9 +21,6 @@ from marshmallow.fields import (
|
||||||
)
|
)
|
||||||
from marshmallow.validate import Length, OneOf, Range
|
from marshmallow.validate import Length, OneOf, Range
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.enums import Country, Currency, Subdivision
|
|
||||||
from teal.marshmallow import IP, URL, EnumField, SanitizedStr, Version
|
|
||||||
from teal.resource import Schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources import enums
|
from ereuse_devicehub.resources import enums
|
||||||
|
@ -48,6 +45,9 @@ from ereuse_devicehub.resources.tradedocument import schemas as s_document
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.user import schemas as s_user
|
from ereuse_devicehub.resources.user import schemas as s_user
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.enums import Country, Currency, Subdivision
|
||||||
|
from ereuse_devicehub.teal.marshmallow import IP, URL, EnumField, SanitizedStr, Version
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class Action(Thing):
|
class Action(Thing):
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from flask import g
|
from flask import g
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.inventory.models import Transfer
|
from ereuse_devicehub.inventory.models import Transfer
|
||||||
|
@ -13,6 +12,7 @@ from ereuse_devicehub.resources.action.models import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.lot.views import delete_from_trade
|
from ereuse_devicehub.resources.lot.views import delete_from_trade
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class TradeView:
|
class TradeView:
|
||||||
|
|
|
@ -8,9 +8,6 @@ import ereuse_utils
|
||||||
import jwt
|
import jwt
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.query import things_response
|
from ereuse_devicehub.query import things_response
|
||||||
|
@ -35,6 +32,9 @@ from ereuse_devicehub.resources.action.views.snapshot import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
||||||
from ereuse_devicehub.resources.enums import Severity
|
from ereuse_devicehub.resources.enums import Severity
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
SUPPORTED_WORKBENCH = StrictVersion('11.0')
|
SUPPORTED_WORKBENCH = StrictVersion('11.0')
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,10 @@ import json
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from boltons.typeutils import classproperty
|
from boltons.typeutils import classproperty
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.agent import models, schemas
|
from ereuse_devicehub.resources.agent import models, schemas
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class AgentDef(Resource):
|
class AgentDef(Resource):
|
||||||
|
@ -19,26 +19,40 @@ class OrganizationDef(AgentDef):
|
||||||
SCHEMA = schemas.Organization
|
SCHEMA = schemas.Organization
|
||||||
VIEW = None
|
VIEW = None
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None):
|
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_org, 'add'),)
|
cli_commands = ((self.create_org, 'add'),)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
super().__init__(
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
||||||
@click.argument('name')
|
@click.argument('name')
|
||||||
@click.option('--tax_id', '-t')
|
@click.option('--tax_id', '-t')
|
||||||
@click.option('--country', '-c')
|
@click.option('--country', '-c')
|
||||||
def create_org(self, name: str, tax_id: str = None, country: str = None) -> dict:
|
def create_org(self, name: str, tax_id: str = None, country: str = None) -> dict:
|
||||||
"""Creates an organization."""
|
"""Creates an organization."""
|
||||||
org = models.Organization(**self.schema.load(
|
org = models.Organization(
|
||||||
{
|
**self.schema.load({'name': name, 'taxId': tax_id, 'country': country})
|
||||||
'name': name,
|
)
|
||||||
'taxId': tax_id,
|
|
||||||
'country': country
|
|
||||||
}
|
|
||||||
))
|
|
||||||
db.session.add(org)
|
db.session.add(org)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
o = self.schema.dump(org)
|
o = self.schema.dump(org)
|
||||||
|
|
|
@ -10,14 +10,19 @@ 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.db import INHERIT_COND, POLYMORPHIC_ID, POLYMORPHIC_ON, check_lower
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.inventory import Inventory
|
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
|
||||||
|
from ereuse_devicehub.teal import enums
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
INHERIT_COND,
|
||||||
|
POLYMORPHIC_ID,
|
||||||
|
POLYMORPHIC_ON,
|
||||||
|
check_lower,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class JoinedTableMixin:
|
class JoinedTableMixin:
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
from marshmallow import fields as ma_fields, validate as ma_validate
|
from marshmallow import fields as ma_fields
|
||||||
|
from marshmallow import validate as ma_validate
|
||||||
from marshmallow.fields import Email
|
from marshmallow.fields import Email
|
||||||
from teal import enums
|
|
||||||
from teal.marshmallow import EnumField, Phone, SanitizedStr
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE, STR_SM_SIZE
|
from ereuse_devicehub.resources.models import STR_SIZE, STR_SM_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
from ereuse_devicehub.teal import enums
|
||||||
|
from ereuse_devicehub.teal.marshmallow import EnumField, Phone, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class Agent(Thing):
|
class Agent(Thing):
|
||||||
id = ma_fields.UUID(dump_only=True)
|
id = ma_fields.UUID(dump_only=True)
|
||||||
name = SanitizedStr(validate=ma_validate.Length(max=STR_SM_SIZE))
|
name = SanitizedStr(validate=ma_validate.Length(max=STR_SM_SIZE))
|
||||||
tax_id = SanitizedStr(lower=True,
|
tax_id = SanitizedStr(
|
||||||
validate=ma_validate.Length(max=STR_SM_SIZE),
|
lower=True, validate=ma_validate.Length(max=STR_SM_SIZE), data_key='taxId'
|
||||||
data_key='taxId')
|
)
|
||||||
country = EnumField(enums.Country)
|
country = EnumField(enums.Country)
|
||||||
telephone = Phone()
|
telephone = Phone()
|
||||||
email = Email()
|
email = Email()
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.deliverynote import schemas
|
from ereuse_devicehub.resources.deliverynote import schemas
|
||||||
from ereuse_devicehub.resources.deliverynote.views import DeliverynoteView
|
from ereuse_devicehub.resources.deliverynote.views import DeliverynoteView
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class DeliverynoteDef(Resource):
|
class DeliverynoteDef(Resource):
|
||||||
|
@ -12,15 +11,28 @@ class DeliverynoteDef(Resource):
|
||||||
AUTH = True
|
AUTH = True
|
||||||
ID_CONVERTER = Converters.uuid
|
ID_CONVERTER = Converters.uuid
|
||||||
|
|
||||||
def __init__(self, app,
|
def __init__(
|
||||||
import_name=__name__.split('.')[0],
|
self,
|
||||||
static_folder=None,
|
app,
|
||||||
static_url_path=None,
|
import_name=__name__.split('.')[0],
|
||||||
template_folder=None,
|
static_folder=None,
|
||||||
url_prefix=None,
|
static_url_path=None,
|
||||||
subdomain=None,
|
template_folder=None,
|
||||||
url_defaults=None,
|
url_prefix=None,
|
||||||
root_path=None,
|
subdomain=None,
|
||||||
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
url_defaults=None,
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
root_path=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
|
@ -5,35 +5,47 @@ from typing import Iterable
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from flask import g
|
from flask import g
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from teal.db import check_range, IntEnum
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.enums import TransferState
|
from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import IntEnum, check_range
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class Deliverynote(Thing):
|
class Deliverynote(Thing):
|
||||||
id = db.Column(UUID(as_uuid=True), primary_key=True) # uuid is generated on init by default
|
id = db.Column(
|
||||||
|
UUID(as_uuid=True), primary_key=True
|
||||||
|
) # uuid is generated on init by default
|
||||||
document_id = db.Column(CIText(), nullable=False)
|
document_id = db.Column(CIText(), nullable=False)
|
||||||
creator_id = db.Column(UUID(as_uuid=True),
|
creator_id = db.Column(
|
||||||
db.ForeignKey(User.id),
|
UUID(as_uuid=True),
|
||||||
nullable=False,
|
db.ForeignKey(User.id),
|
||||||
default=lambda: g.user.id)
|
nullable=False,
|
||||||
|
default=lambda: g.user.id,
|
||||||
|
)
|
||||||
creator = db.relationship(User, primaryjoin=creator_id == User.id)
|
creator = db.relationship(User, primaryjoin=creator_id == User.id)
|
||||||
supplier_email = db.Column(CIText(),
|
supplier_email = db.Column(
|
||||||
db.ForeignKey(User.email),
|
CIText(),
|
||||||
nullable=False,
|
db.ForeignKey(User.email),
|
||||||
default=lambda: g.user.email)
|
nullable=False,
|
||||||
supplier = db.relationship(User, primaryjoin=lambda: Deliverynote.supplier_email == User.email)
|
default=lambda: g.user.email,
|
||||||
receiver_address = db.Column(CIText(),
|
)
|
||||||
db.ForeignKey(User.email),
|
supplier = db.relationship(
|
||||||
nullable=False,
|
User, primaryjoin=lambda: Deliverynote.supplier_email == User.email
|
||||||
default=lambda: g.user.email)
|
)
|
||||||
receiver = db.relationship(User, primaryjoin=lambda: Deliverynote.receiver_address == User.email)
|
receiver_address = db.Column(
|
||||||
|
CIText(),
|
||||||
|
db.ForeignKey(User.email),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: g.user.email,
|
||||||
|
)
|
||||||
|
receiver = db.relationship(
|
||||||
|
User, primaryjoin=lambda: Deliverynote.receiver_address == User.email
|
||||||
|
)
|
||||||
date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
date.comment = 'The date the DeliveryNote initiated'
|
date.comment = 'The date the DeliveryNote initiated'
|
||||||
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
|
amount = db.Column(db.Integer, check_range('amount', min=0, max=100), default=0)
|
||||||
|
@ -44,27 +56,37 @@ class Deliverynote(Thing):
|
||||||
expected_devices = db.Column(JSONB, nullable=False)
|
expected_devices = db.Column(JSONB, nullable=False)
|
||||||
# expected_devices = db.Column(db.ARRAY(JSONB, dimensions=1), nullable=False)
|
# expected_devices = db.Column(db.ARRAY(JSONB, dimensions=1), nullable=False)
|
||||||
transferred_devices = db.Column(db.ARRAY(db.Integer, dimensions=1), nullable=True)
|
transferred_devices = db.Column(db.ARRAY(db.Integer, dimensions=1), nullable=True)
|
||||||
transfer_state = db.Column(IntEnum(TransferState), default=TransferState.Initial, nullable=False)
|
transfer_state = db.Column(
|
||||||
|
IntEnum(TransferState), default=TransferState.Initial, nullable=False
|
||||||
|
)
|
||||||
transfer_state.comment = TransferState.__doc__
|
transfer_state.comment = TransferState.__doc__
|
||||||
lot_id = db.Column(UUID(as_uuid=True),
|
lot_id = db.Column(UUID(as_uuid=True), db.ForeignKey(Lot.id), nullable=False)
|
||||||
db.ForeignKey(Lot.id),
|
lot = db.relationship(
|
||||||
nullable=False)
|
Lot,
|
||||||
lot = db.relationship(Lot,
|
backref=db.backref('deliverynote', uselist=False, lazy=True),
|
||||||
backref=db.backref('deliverynote', uselist=False, lazy=True),
|
lazy=True,
|
||||||
lazy=True,
|
primaryjoin=Lot.id == lot_id,
|
||||||
primaryjoin=Lot.id == lot_id)
|
)
|
||||||
|
|
||||||
def __init__(self, document_id: str, amount: str, date,
|
def __init__(
|
||||||
supplier_email: str,
|
self,
|
||||||
expected_devices: Iterable,
|
document_id: str,
|
||||||
transfer_state: TransferState) -> None:
|
amount: str,
|
||||||
"""Initializes a delivery note
|
date,
|
||||||
"""
|
supplier_email: str,
|
||||||
super().__init__(id=uuid.uuid4(),
|
expected_devices: Iterable,
|
||||||
document_id=document_id, amount=amount, date=date,
|
transfer_state: TransferState,
|
||||||
supplier_email=supplier_email,
|
) -> None:
|
||||||
expected_devices=expected_devices,
|
"""Initializes a delivery note"""
|
||||||
transfer_state=transfer_state)
|
super().__init__(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
document_id=document_id,
|
||||||
|
amount=amount,
|
||||||
|
date=date,
|
||||||
|
supplier_email=supplier_email,
|
||||||
|
expected_devices=expected_devices,
|
||||||
|
transfer_state=transfer_state,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> str:
|
def type(self) -> str:
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from teal.marshmallow import SanitizedStr, EnumField
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.deliverynote import models as m
|
from ereuse_devicehub.resources.deliverynote import models as m
|
||||||
|
@ -7,20 +6,30 @@ from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE
|
from ereuse_devicehub.resources.models import STR_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.user import schemas as s_user
|
from ereuse_devicehub.resources.user import schemas as s_user
|
||||||
|
from ereuse_devicehub.teal.marshmallow import EnumField, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class Deliverynote(Thing):
|
class Deliverynote(Thing):
|
||||||
id = f.UUID(dump_only=True)
|
id = f.UUID(dump_only=True)
|
||||||
document_id = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
|
document_id = SanitizedStr(
|
||||||
required=True, data_key='documentID')
|
validate=f.validate.Length(max=STR_SIZE), required=True, data_key='documentID'
|
||||||
|
)
|
||||||
creator = NestedOn(s_user.User, dump_only=True)
|
creator = NestedOn(s_user.User, dump_only=True)
|
||||||
supplier_email = SanitizedStr(validate=f.validate.Length(max=STR_SIZE),
|
supplier_email = SanitizedStr(
|
||||||
load_only=True, required=True, data_key='supplierEmail')
|
validate=f.validate.Length(max=STR_SIZE),
|
||||||
|
load_only=True,
|
||||||
|
required=True,
|
||||||
|
data_key='supplierEmail',
|
||||||
|
)
|
||||||
supplier = NestedOn(s_user.User, dump_only=True)
|
supplier = NestedOn(s_user.User, dump_only=True)
|
||||||
receiver = NestedOn(s_user.User, dump_only=True)
|
receiver = NestedOn(s_user.User, dump_only=True)
|
||||||
date = f.DateTime('iso', required=True)
|
date = f.DateTime('iso', required=True)
|
||||||
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
|
amount = f.Integer(
|
||||||
description=m.Deliverynote.amount.__doc__)
|
validate=f.validate.Range(min=0, max=100),
|
||||||
|
description=m.Deliverynote.amount.__doc__,
|
||||||
|
)
|
||||||
expected_devices = f.List(f.Dict, required=True, data_key='expectedDevices')
|
expected_devices = f.List(f.Dict, required=True, data_key='expectedDevices')
|
||||||
transferred_devices = f.List(f.Integer(), required=False, data_key='transferredDevices')
|
transferred_devices = f.List(
|
||||||
|
f.Integer(), required=False, data_key='transferredDevices'
|
||||||
|
)
|
||||||
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
||||||
|
|
|
@ -2,21 +2,22 @@ import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from flask import Response, request
|
from flask import Response, request
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.deliverynote.models import Deliverynote
|
from ereuse_devicehub.resources.deliverynote.models import Deliverynote
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class DeliverynoteView(View):
|
class DeliverynoteView(View):
|
||||||
|
|
||||||
def post(self):
|
def post(self):
|
||||||
# Create delivery note
|
# Create delivery note
|
||||||
dn = request.get_json()
|
dn = request.get_json()
|
||||||
dlvnote = Deliverynote(**dn)
|
dlvnote = Deliverynote(**dn)
|
||||||
# Create a lot
|
# Create a lot
|
||||||
lot_name = dlvnote.document_id + "_" + datetime.datetime.utcnow().strftime("%Y-%m-%d")
|
lot_name = (
|
||||||
|
dlvnote.document_id + "_" + datetime.datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
new_lot = Lot(name=lot_name)
|
new_lot = Lot(name=lot_name)
|
||||||
dlvnote.lot_id = new_lot.id
|
dlvnote.lot_id = new_lot.id
|
||||||
db.session.add(new_lot)
|
db.session.add(new_lot)
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.device import schemas
|
from ereuse_devicehub.resources.device import schemas
|
||||||
from ereuse_devicehub.resources.device.models import Manufacturer
|
from ereuse_devicehub.resources.device.models import Manufacturer
|
||||||
from ereuse_devicehub.resources.device.views import (
|
from ereuse_devicehub.resources.device.views import (
|
||||||
|
@ -9,6 +7,7 @@ from ereuse_devicehub.resources.device.views import (
|
||||||
DeviceView,
|
DeviceView,
|
||||||
ManufacturerView,
|
ManufacturerView,
|
||||||
)
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class DeviceDef(Resource):
|
class DeviceDef(Resource):
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
from teal.marshmallow import ValidationError
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class MismatchBetweenIds(ValidationError):
|
class MismatchBetweenIds(ValidationError):
|
||||||
def __init__(self, other_device_id: int, field: str, value: str):
|
def __init__(self, other_device_id: int, field: str, value: str):
|
||||||
message = 'The device {} has the same {} than this one ({}).'.format(other_device_id,
|
message = 'The device {} has the same {} than this one ({}).'.format(
|
||||||
field, value)
|
other_device_id, field, value
|
||||||
|
)
|
||||||
super().__init__(message, field_names=[field])
|
super().__init__(message, field_names=[field])
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,13 +16,15 @@ class NeedsId(ValidationError):
|
||||||
|
|
||||||
|
|
||||||
class DeviceIsInAnotherDevicehub(ValidationError):
|
class DeviceIsInAnotherDevicehub(ValidationError):
|
||||||
def __init__(self,
|
def __init__(
|
||||||
tag_id,
|
self,
|
||||||
message=None,
|
tag_id,
|
||||||
field_names=None,
|
message=None,
|
||||||
fields=None,
|
field_names=None,
|
||||||
data=None,
|
fields=None,
|
||||||
valid_data=None,
|
data=None,
|
||||||
**kwargs):
|
valid_data=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
message = message or 'Device {} is from another Devicehub.'.format(tag_id)
|
message = message or 'Device {} is from another Devicehub.'.format(tag_id)
|
||||||
super().__init__(message, field_names, fields, data, valid_data, **kwargs)
|
super().__init__(message, field_names, fields, data, valid_data, **kwargs)
|
||||||
|
|
|
@ -35,19 +35,6 @@ from sqlalchemy.orm import ColumnProperty, backref, relationship, validates
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from sqlalchemy_utils import ColorType
|
from sqlalchemy_utils import ColorType
|
||||||
from stdnum import imei, meid
|
from stdnum import imei, meid
|
||||||
from teal.db import (
|
|
||||||
CASCADE_DEL,
|
|
||||||
POLYMORPHIC_ID,
|
|
||||||
POLYMORPHIC_ON,
|
|
||||||
URL,
|
|
||||||
IntEnum,
|
|
||||||
ResourceNotFound,
|
|
||||||
check_lower,
|
|
||||||
check_range,
|
|
||||||
)
|
|
||||||
from teal.enums import Layouts
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.device.metrics import Metrics
|
from ereuse_devicehub.resources.device.metrics import Metrics
|
||||||
|
@ -70,6 +57,19 @@ from ereuse_devicehub.resources.models import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
from ereuse_devicehub.resources.utils import hashcode
|
from ereuse_devicehub.resources.utils import hashcode
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
CASCADE_DEL,
|
||||||
|
POLYMORPHIC_ID,
|
||||||
|
POLYMORPHIC_ON,
|
||||||
|
URL,
|
||||||
|
IntEnum,
|
||||||
|
ResourceNotFound,
|
||||||
|
check_lower,
|
||||||
|
check_range,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.enums import Layouts
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
def create_code(context):
|
def create_code(context):
|
||||||
|
|
|
@ -17,9 +17,6 @@ from marshmallow.fields import (
|
||||||
from marshmallow.validate import Length, OneOf, Range
|
from marshmallow.validate import Length, OneOf, Range
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from stdnum import imei, meid
|
from stdnum import imei, meid
|
||||||
from teal.enums import Layouts
|
|
||||||
from teal.marshmallow import URL, EnumField, SanitizedStr, ValidationError
|
|
||||||
from teal.resource import Schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources import enums
|
from ereuse_devicehub.resources import enums
|
||||||
|
@ -27,6 +24,14 @@ from ereuse_devicehub.resources.device import models as m
|
||||||
from ereuse_devicehub.resources.device import states
|
from ereuse_devicehub.resources.device import states
|
||||||
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
|
from ereuse_devicehub.resources.models import STR_BIG_SIZE, STR_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
|
from ereuse_devicehub.resources.schemas import Thing, UnitCodes
|
||||||
|
from ereuse_devicehub.teal.enums import Layouts
|
||||||
|
from ereuse_devicehub.teal.marshmallow import (
|
||||||
|
URL,
|
||||||
|
EnumField,
|
||||||
|
SanitizedStr,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class Device(Thing):
|
class Device(Thing):
|
||||||
|
|
|
@ -8,8 +8,6 @@ from flask import g
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.action.models import Remove
|
from ereuse_devicehub.resources.action.models import Remove
|
||||||
|
@ -21,6 +19,8 @@ from ereuse_devicehub.resources.device.models import (
|
||||||
Placeholder,
|
Placeholder,
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
# DEVICES_ALLOW_DUPLICITY = [
|
# DEVICES_ALLOW_DUPLICITY = [
|
||||||
# 'RamModule',
|
# 'RamModule',
|
||||||
|
|
|
@ -14,11 +14,6 @@ from marshmallow import fields
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from marshmallow import validate as v
|
from marshmallow import validate as v
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal import query
|
|
||||||
from teal.cache import cache
|
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -32,6 +27,11 @@ from ereuse_devicehub.resources.device.search import DeviceSearch
|
||||||
from ereuse_devicehub.resources.enums import SnapshotSoftware
|
from ereuse_devicehub.resources.enums import SnapshotSoftware
|
||||||
from ereuse_devicehub.resources.lot.models import LotDeviceDescendants
|
from ereuse_devicehub.resources.lot.models import LotDeviceDescendants
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
|
from ereuse_devicehub.teal import query
|
||||||
|
from ereuse_devicehub.teal.cache import cache
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class OfType(f.Str):
|
class OfType(f.Str):
|
||||||
|
|
|
@ -11,14 +11,12 @@ from typing import Callable, Iterable, Tuple
|
||||||
import boltons
|
import boltons
|
||||||
import flask
|
import flask
|
||||||
import flask_weasyprint
|
import flask_weasyprint
|
||||||
import teal.marshmallow
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from flask import g, make_response, request
|
from flask import g, make_response, request
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from teal.cache import cache
|
|
||||||
from teal.resource import Resource, View
|
|
||||||
|
|
||||||
|
import ereuse_devicehub.teal.marshmallow
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.action import models as evs
|
from ereuse_devicehub.resources.action import models as evs
|
||||||
|
@ -37,6 +35,8 @@ from ereuse_devicehub.resources.hash_reports import ReportHash, insert_hash, ver
|
||||||
from ereuse_devicehub.resources.lot import LotView
|
from ereuse_devicehub.resources.lot import LotView
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
from ereuse_devicehub.resources.user.models import Session
|
from ereuse_devicehub.resources.user.models import Session
|
||||||
|
from ereuse_devicehub.teal.cache import cache
|
||||||
|
from ereuse_devicehub.teal.resource import Resource, View
|
||||||
|
|
||||||
|
|
||||||
class Format(enum.Enum):
|
class Format(enum.Enum):
|
||||||
|
@ -46,7 +46,7 @@ class Format(enum.Enum):
|
||||||
|
|
||||||
class DocumentView(DeviceView):
|
class DocumentView(DeviceView):
|
||||||
class FindArgs(DeviceView.FindArgs):
|
class FindArgs(DeviceView.FindArgs):
|
||||||
format = teal.marshmallow.EnumField(Format, missing=None)
|
format = ereuse_devicehub.teal.marshmallow.EnumField(Format, missing=None)
|
||||||
|
|
||||||
def get(self, id):
|
def get(self, id):
|
||||||
"""Get a collection of resources or a specific one.
|
"""Get a collection of resources or a specific one.
|
||||||
|
@ -71,7 +71,7 @@ class DocumentView(DeviceView):
|
||||||
|
|
||||||
if not ids and not id:
|
if not ids and not id:
|
||||||
msg = 'Document must be an ID or UUID.'
|
msg = 'Document must be an ID or UUID.'
|
||||||
raise teal.marshmallow.ValidationError(msg)
|
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
|
||||||
|
|
||||||
if id:
|
if id:
|
||||||
try:
|
try:
|
||||||
|
@ -81,7 +81,7 @@ class DocumentView(DeviceView):
|
||||||
ids.append(int(id))
|
ids.append(int(id))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
msg = 'Document must be an ID or UUID.'
|
msg = 'Document must be an ID or UUID.'
|
||||||
raise teal.marshmallow.ValidationError(msg)
|
raise ereuse_devicehub.teal.marshmallow.ValidationError(msg)
|
||||||
else:
|
else:
|
||||||
query = devs.Device.query.filter(Device.id.in_(ids))
|
query = devs.Device.query.filter(Device.id.in_(ids))
|
||||||
else:
|
else:
|
||||||
|
@ -98,7 +98,7 @@ class DocumentView(DeviceView):
|
||||||
# try:
|
# try:
|
||||||
# id = int(id)
|
# id = int(id)
|
||||||
# except ValueError:
|
# except ValueError:
|
||||||
# raise teal.marshmallow.ValidationError('Document must be an ID or UUID.')
|
# raise ereuse_devicehub.teal.marshmallow.ValidationError('Document must be an ID or UUID.')
|
||||||
# else:
|
# else:
|
||||||
# query = devs.Device.query.filter_by(id=id)
|
# query = devs.Device.query.filter_by(id=id)
|
||||||
# else:
|
# else:
|
||||||
|
|
|
@ -1,20 +1,19 @@
|
||||||
|
from citext import CIText
|
||||||
from flask import g
|
from flask import g
|
||||||
from citext import CIText
|
|
||||||
from sortedcontainers import SortedSet
|
from sortedcontainers import SortedSet
|
||||||
from sqlalchemy import BigInteger, Column, Sequence, Unicode, Boolean, ForeignKey
|
from sqlalchemy import BigInteger, Boolean, Column, ForeignKey, Sequence, Unicode
|
||||||
from sqlalchemy.ext.declarative import declared_attr
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.ext.declarative import declared_attr
|
||||||
from sqlalchemy.orm import backref
|
from sqlalchemy.orm import backref
|
||||||
from teal.db import CASCADE_OWN, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
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
|
||||||
from ereuse_devicehub.resources.models import Thing, STR_SM_SIZE
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
|
||||||
|
|
||||||
|
|
||||||
_sorted_documents = {
|
_sorted_documents = {
|
||||||
'order_by': lambda: Document.created,
|
'order_by': lambda: Document.created,
|
||||||
'collection_class': SortedSet
|
'collection_class': SortedSet,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,11 +29,15 @@ class Document(Thing):
|
||||||
date.comment = """The date of document, some documents need to have one date
|
date.comment = """The date of document, some documents need to have one date
|
||||||
"""
|
"""
|
||||||
id_document = Column(CIText(), nullable=True)
|
id_document = Column(CIText(), nullable=True)
|
||||||
id_document.comment = """The id of one document like invoice so they can be linked."""
|
id_document.comment = (
|
||||||
owner_id = db.Column(UUID(as_uuid=True),
|
"""The id of one document like invoice so they can be linked."""
|
||||||
db.ForeignKey(User.id),
|
)
|
||||||
nullable=False,
|
owner_id = db.Column(
|
||||||
default=lambda: g.user.id)
|
UUID(as_uuid=True),
|
||||||
|
db.ForeignKey(User.id),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: g.user.id,
|
||||||
|
)
|
||||||
owner = db.relationship(User, primaryjoin=owner_id == User.id)
|
owner = db.relationship(User, primaryjoin=owner_id == User.id)
|
||||||
file_name = Column(db.CIText(), nullable=False)
|
file_name = Column(db.CIText(), nullable=False)
|
||||||
file_name.comment = """This is the name of the file when user up the document."""
|
file_name.comment = """This is the name of the file when user up the document."""
|
||||||
|
|
|
@ -1,34 +1,43 @@
|
||||||
from marshmallow.fields import DateTime, Integer, validate, Boolean, Float
|
|
||||||
from marshmallow import post_load
|
from marshmallow import post_load
|
||||||
|
from marshmallow.fields import Boolean, DateTime, Float, Integer, validate
|
||||||
from marshmallow.validate import Range
|
from marshmallow.validate import Range
|
||||||
from teal.marshmallow import SanitizedStr, URL
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
|
from ereuse_devicehub.resources.documents import models as m
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.documents import models as m
|
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class DataWipeDocument(Thing):
|
class DataWipeDocument(Thing):
|
||||||
__doc__ = m.DataWipeDocument.__doc__
|
__doc__ = m.DataWipeDocument.__doc__
|
||||||
id = Integer(description=m.DataWipeDocument.id.comment, dump_only=True)
|
id = Integer(description=m.DataWipeDocument.id.comment, dump_only=True)
|
||||||
url = URL(required= False, description=m.DataWipeDocument.url.comment)
|
url = URL(required=False, description=m.DataWipeDocument.url.comment)
|
||||||
success = Boolean(required=False, default=False, description=m.DataWipeDocument.success.comment)
|
success = Boolean(
|
||||||
|
required=False, default=False, description=m.DataWipeDocument.success.comment
|
||||||
|
)
|
||||||
software = SanitizedStr(description=m.DataWipeDocument.software.comment)
|
software = SanitizedStr(description=m.DataWipeDocument.software.comment)
|
||||||
date = DateTime(data_key='endTime',
|
date = DateTime(
|
||||||
required=False,
|
data_key='endTime', required=False, description=m.DataWipeDocument.date.comment
|
||||||
description=m.DataWipeDocument.date.comment)
|
)
|
||||||
id_document = SanitizedStr(data_key='documentId',
|
id_document = SanitizedStr(
|
||||||
required=False,
|
data_key='documentId',
|
||||||
default='',
|
required=False,
|
||||||
description=m.DataWipeDocument.id_document.comment)
|
default='',
|
||||||
file_name = SanitizedStr(data_key='filename',
|
description=m.DataWipeDocument.id_document.comment,
|
||||||
default='',
|
)
|
||||||
description=m.DataWipeDocument.file_name.comment,
|
file_name = SanitizedStr(
|
||||||
validate=validate.Length(max=100))
|
data_key='filename',
|
||||||
file_hash = SanitizedStr(data_key='hash',
|
default='',
|
||||||
default='',
|
description=m.DataWipeDocument.file_name.comment,
|
||||||
description=m.DataWipeDocument.file_hash.comment,
|
validate=validate.Length(max=100),
|
||||||
validate=validate.Length(max=64))
|
)
|
||||||
|
file_hash = SanitizedStr(
|
||||||
|
data_key='hash',
|
||||||
|
default='',
|
||||||
|
description=m.DataWipeDocument.file_hash.comment,
|
||||||
|
validate=validate.Length(max=64),
|
||||||
|
)
|
||||||
|
|
||||||
@post_load
|
@post_load
|
||||||
def get_trade_document(self, data):
|
def get_trade_document(self, data):
|
||||||
|
|
|
@ -1,28 +1,34 @@
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from citext import CIText
|
from citext import CIText
|
||||||
from sqlalchemy import BigInteger, Column, Enum as DBEnum, ForeignKey
|
from sqlalchemy import BigInteger, Column
|
||||||
|
from sqlalchemy import Enum as DBEnum
|
||||||
|
from sqlalchemy import ForeignKey
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref, relationship
|
from sqlalchemy.orm import backref, relationship
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import CASCADE_OWN
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.device.models import Device
|
from ereuse_devicehub.resources.device.models import Device
|
||||||
from ereuse_devicehub.resources.enums import ImageMimeTypes, Orientation
|
from ereuse_devicehub.resources.enums import ImageMimeTypes, Orientation
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN
|
||||||
|
|
||||||
|
|
||||||
class ImageList(Thing):
|
class ImageList(Thing):
|
||||||
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
|
||||||
device_id = Column(BigInteger, ForeignKey(Device.id), nullable=False)
|
device_id = Column(BigInteger, ForeignKey(Device.id), nullable=False)
|
||||||
device = relationship(Device,
|
device = relationship(
|
||||||
primaryjoin=Device.id == device_id,
|
Device,
|
||||||
backref=backref('images',
|
primaryjoin=Device.id == device_id,
|
||||||
lazy=True,
|
backref=backref(
|
||||||
cascade=CASCADE_OWN,
|
'images',
|
||||||
order_by=lambda: ImageList.created,
|
lazy=True,
|
||||||
collection_class=OrderedSet))
|
cascade=CASCADE_OWN,
|
||||||
|
order_by=lambda: ImageList.created,
|
||||||
|
collection_class=OrderedSet,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Image(Thing):
|
class Image(Thing):
|
||||||
|
@ -32,12 +38,16 @@ class Image(Thing):
|
||||||
file_format = db.Column(DBEnum(ImageMimeTypes), nullable=False)
|
file_format = db.Column(DBEnum(ImageMimeTypes), nullable=False)
|
||||||
orientation = db.Column(DBEnum(Orientation), nullable=False)
|
orientation = db.Column(DBEnum(Orientation), nullable=False)
|
||||||
image_list_id = Column(UUID(as_uuid=True), ForeignKey(ImageList.id), nullable=False)
|
image_list_id = Column(UUID(as_uuid=True), ForeignKey(ImageList.id), nullable=False)
|
||||||
image_list = relationship(ImageList,
|
image_list = relationship(
|
||||||
primaryjoin=ImageList.id == image_list_id,
|
ImageList,
|
||||||
backref=backref('images',
|
primaryjoin=ImageList.id == image_list_id,
|
||||||
cascade=CASCADE_OWN,
|
backref=backref(
|
||||||
order_by=lambda: Image.created,
|
'images',
|
||||||
collection_class=OrderedSet))
|
cascade=CASCADE_OWN,
|
||||||
|
order_by=lambda: Image.created,
|
||||||
|
collection_class=OrderedSet,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# todo make an image Field that converts to/from image object
|
# todo make an image Field that converts to/from image object
|
||||||
# todo which metadata we get from Photobox?
|
# todo which metadata we get from Photobox?
|
||||||
|
|
|
@ -2,42 +2,61 @@ import uuid
|
||||||
|
|
||||||
import boltons.urlutils
|
import boltons.urlutils
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.resource import Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.inventory import schema
|
from ereuse_devicehub.resources.inventory import schema
|
||||||
from ereuse_devicehub.resources.inventory.model import Inventory
|
from ereuse_devicehub.resources.inventory.model import Inventory
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
class InventoryDef(Resource):
|
class InventoryDef(Resource):
|
||||||
SCHEMA = schema.Inventory
|
SCHEMA = schema.Inventory
|
||||||
VIEW = None
|
VIEW = None
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None):
|
import_name=__name__.split('.')[0],
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
static_folder=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path)
|
static_url_path=None,
|
||||||
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def set_inventory_config(cls,
|
def set_inventory_config(
|
||||||
name: str = None,
|
cls,
|
||||||
org_name: str = None,
|
name: str = None,
|
||||||
org_id: str = None,
|
org_name: str = None,
|
||||||
tag_url: boltons.urlutils.URL = None,
|
org_id: str = None,
|
||||||
tag_token: uuid.UUID = None):
|
tag_url: boltons.urlutils.URL = None,
|
||||||
|
tag_token: uuid.UUID = None,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
inventory = Inventory.current
|
inventory = Inventory.current
|
||||||
except ResourceNotFound: # No inventory defined in db yet
|
except ResourceNotFound: # No inventory defined in db yet
|
||||||
inventory = Inventory(id=current_app.id,
|
inventory = Inventory(
|
||||||
name=name,
|
id=current_app.id, name=name, tag_provider=tag_url, tag_token=tag_token
|
||||||
tag_provider=tag_url,
|
)
|
||||||
tag_token=tag_token)
|
|
||||||
db.session.add(inventory)
|
db.session.add(inventory)
|
||||||
if org_name or org_id:
|
if org_name or org_id:
|
||||||
from ereuse_devicehub.resources.agent.models import Organization
|
from ereuse_devicehub.resources.agent.models import Organization
|
||||||
|
|
||||||
try:
|
try:
|
||||||
org = Organization.query.filter_by(tax_id=org_id, name=org_name).one()
|
org = Organization.query.filter_by(tax_id=org_id, name=org_name).one()
|
||||||
except ResourceNotFound:
|
except ResourceNotFound:
|
||||||
|
@ -54,12 +73,14 @@ class InventoryDef(Resource):
|
||||||
only access to this inventory.
|
only access to this inventory.
|
||||||
"""
|
"""
|
||||||
from ereuse_devicehub.resources.user.models import User, UserInventory
|
from ereuse_devicehub.resources.user.models import User, UserInventory
|
||||||
|
|
||||||
inv = Inventory.query.filter_by(id=current_app.id).one()
|
inv = Inventory.query.filter_by(id=current_app.id).one()
|
||||||
db.session.delete(inv)
|
db.session.delete(inv)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
# Remove users that end-up without any inventory
|
# Remove users that end-up without any inventory
|
||||||
# todo this should be done in a trigger / action
|
# todo this should be done in a trigger / action
|
||||||
users = User.query \
|
users = User.query.filter(
|
||||||
.filter(User.id.notin_(db.session.query(UserInventory.user_id).distinct()))
|
User.id.notin_(db.session.query(UserInventory.user_id).distinct())
|
||||||
|
)
|
||||||
for user in users:
|
for user in users:
|
||||||
db.session.delete(user)
|
db.session.delete(user)
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from teal.resource import Resource, View
|
|
||||||
|
from ereuse_devicehub.teal.resource import Resource, View
|
||||||
|
|
||||||
|
|
||||||
class LicenceView(View):
|
class LicenceView(View):
|
||||||
|
@ -23,18 +25,31 @@ class LicencesDef(Resource):
|
||||||
VIEW = None # We do not want to create default / documents endpoint
|
VIEW = None # We do not want to create default / documents endpoint
|
||||||
AUTH = False
|
AUTH = False
|
||||||
|
|
||||||
def __init__(self, app,
|
def __init__(
|
||||||
import_name=__name__,
|
self,
|
||||||
static_folder=None,
|
app,
|
||||||
static_url_path=None,
|
import_name=__name__,
|
||||||
template_folder=None,
|
static_folder=None,
|
||||||
url_prefix=None,
|
static_url_path=None,
|
||||||
subdomain=None,
|
template_folder=None,
|
||||||
url_defaults=None,
|
url_prefix=None,
|
||||||
root_path=None,
|
subdomain=None,
|
||||||
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
url_defaults=None,
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
root_path=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
||||||
get = {'GET'}
|
get = {'GET'}
|
||||||
d = {}
|
d = {}
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import pathlib
|
import pathlib
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
|
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.lot import schemas
|
from ereuse_devicehub.resources.lot import schemas
|
||||||
from ereuse_devicehub.resources.lot.views import LotBaseChildrenView, LotChildrenView, \
|
from ereuse_devicehub.resources.lot.views import (
|
||||||
LotDeviceView, LotView
|
LotBaseChildrenView,
|
||||||
|
LotChildrenView,
|
||||||
|
LotDeviceView,
|
||||||
|
LotView,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class LotDef(Resource):
|
class LotDef(Resource):
|
||||||
|
@ -15,24 +18,49 @@ class LotDef(Resource):
|
||||||
AUTH = True
|
AUTH = True
|
||||||
ID_CONVERTER = Converters.uuid
|
ID_CONVERTER = Converters.uuid
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app,
|
||||||
root_path=None, cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
import_name=__name__.split('.')[0],
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
static_folder=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
static_url_path=None,
|
||||||
lot_children = LotChildrenView.as_view('lot-children', definition=self, auth=app.auth)
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
lot_children = LotChildrenView.as_view(
|
||||||
|
'lot-children', definition=self, auth=app.auth
|
||||||
|
)
|
||||||
if self.AUTH:
|
if self.AUTH:
|
||||||
lot_children = app.auth.requires_auth(lot_children)
|
lot_children = app.auth.requires_auth(lot_children)
|
||||||
self.add_url_rule('/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
self.add_url_rule(
|
||||||
view_func=lot_children,
|
'/<{}:{}>/children'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
||||||
methods={'POST', 'DELETE'})
|
view_func=lot_children,
|
||||||
|
methods={'POST', 'DELETE'},
|
||||||
|
)
|
||||||
lot_device = LotDeviceView.as_view('lot-device', definition=self, auth=app.auth)
|
lot_device = LotDeviceView.as_view('lot-device', definition=self, auth=app.auth)
|
||||||
if self.AUTH:
|
if self.AUTH:
|
||||||
lot_device = app.auth.requires_auth(lot_device)
|
lot_device = app.auth.requires_auth(lot_device)
|
||||||
self.add_url_rule('/<{}:{}>/devices'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
self.add_url_rule(
|
||||||
view_func=lot_device,
|
'/<{}:{}>/devices'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
||||||
methods={'POST', 'DELETE'})
|
view_func=lot_device,
|
||||||
|
methods={'POST', 'DELETE'},
|
||||||
|
)
|
||||||
|
|
||||||
def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None):
|
def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None):
|
||||||
# Create functions
|
# Create functions
|
||||||
|
|
|
@ -10,14 +10,14 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy_utils import LtreeType
|
from sqlalchemy_utils import LtreeType
|
||||||
from sqlalchemy_utils.types.ltree import LQUERY
|
from sqlalchemy_utils.types.ltree import LQUERY
|
||||||
from teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import create_view, db, exp, f
|
from ereuse_devicehub.db import create_view, db, exp, f
|
||||||
from ereuse_devicehub.resources.device.models import Component, Device
|
from ereuse_devicehub.resources.device.models import Component, Device
|
||||||
from ereuse_devicehub.resources.enums import TransferState
|
from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, IntEnum, UUIDLtree, check_range
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class Lot(Thing):
|
class Lot(Thing):
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from teal.marshmallow import SanitizedStr, URL, EnumField
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
|
from ereuse_devicehub.resources.action import schemas as s_action
|
||||||
from ereuse_devicehub.resources.deliverynote import schemas as s_deliverynote
|
from ereuse_devicehub.resources.deliverynote import schemas as s_deliverynote
|
||||||
from ereuse_devicehub.resources.device import schemas as s_device
|
from ereuse_devicehub.resources.device import schemas as s_device
|
||||||
from ereuse_devicehub.resources.action import schemas as s_action
|
|
||||||
from ereuse_devicehub.resources.enums import TransferState
|
from ereuse_devicehub.resources.enums import TransferState
|
||||||
from ereuse_devicehub.resources.lot import models as m
|
from ereuse_devicehub.resources.lot import models as m
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE
|
from ereuse_devicehub.resources.models import STR_SIZE
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL, EnumField, SanitizedStr
|
||||||
|
|
||||||
TRADE_VALUES = (
|
TRADE_VALUES = (
|
||||||
'id',
|
'id',
|
||||||
|
@ -18,16 +17,11 @@ TRADE_VALUES = (
|
||||||
'user_from.id',
|
'user_from.id',
|
||||||
'user_to.id',
|
'user_to.id',
|
||||||
'user_to.code',
|
'user_to.code',
|
||||||
'user_from.code'
|
'user_from.code',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
DOCUMENTS_VALUES = (
|
DOCUMENTS_VALUES = ('id', 'file_name', 'total_weight', 'trading')
|
||||||
'id',
|
|
||||||
'file_name',
|
|
||||||
'total_weight',
|
|
||||||
'trading'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Old_Lot(Thing):
|
class Old_Lot(Thing):
|
||||||
|
@ -39,8 +33,9 @@ class Old_Lot(Thing):
|
||||||
children = NestedOn('Lot', many=True, dump_only=True)
|
children = NestedOn('Lot', many=True, dump_only=True)
|
||||||
parents = NestedOn('Lot', many=True, dump_only=True)
|
parents = NestedOn('Lot', many=True, dump_only=True)
|
||||||
url = URL(dump_only=True, description=m.Lot.url.__doc__)
|
url = URL(dump_only=True, description=m.Lot.url.__doc__)
|
||||||
amount = f.Integer(validate=f.validate.Range(min=0, max=100),
|
amount = f.Integer(
|
||||||
description=m.Lot.amount.__doc__)
|
validate=f.validate.Range(min=0, max=100), description=m.Lot.amount.__doc__
|
||||||
|
)
|
||||||
# author_id = NestedOn(s_user.User,only_query='author_id')
|
# author_id = NestedOn(s_user.User,only_query='author_id')
|
||||||
owner_id = f.UUID(data_key='ownerID')
|
owner_id = f.UUID(data_key='ownerID')
|
||||||
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
transfer_state = EnumField(TransferState, description=m.Lot.transfer_state.comment)
|
||||||
|
@ -54,4 +49,6 @@ class Lot(Thing):
|
||||||
name = SanitizedStr(validate=f.validate.Length(max=STR_SIZE), required=True)
|
name = SanitizedStr(validate=f.validate.Length(max=STR_SIZE), required=True)
|
||||||
description = SanitizedStr(description=m.Lot.description.comment)
|
description = SanitizedStr(description=m.Lot.description.comment)
|
||||||
trade = f.Nested(s_action.Trade, dump_only=True, only=TRADE_VALUES)
|
trade = f.Nested(s_action.Trade, dump_only=True, only=TRADE_VALUES)
|
||||||
documents = f.Nested('TradeDocument', many=True, dump_only=True, only=DOCUMENTS_VALUES)
|
documents = f.Nested(
|
||||||
|
'TradeDocument', many=True, dump_only=True, only=DOCUMENTS_VALUES
|
||||||
|
)
|
||||||
|
|
|
@ -9,8 +9,6 @@ from marshmallow import Schema as MarshmallowSchema
|
||||||
from marshmallow import fields as f
|
from marshmallow import fields as f
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.marshmallow import EnumField
|
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.inventory.models import Transfer
|
from ereuse_devicehub.inventory.models import Transfer
|
||||||
|
@ -18,6 +16,8 @@ from ereuse_devicehub.query import things_response
|
||||||
from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade
|
from ereuse_devicehub.resources.action.models import Confirm, Revoke, Trade
|
||||||
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
from ereuse_devicehub.resources.device.models import Computer, DataStorage, Device
|
||||||
from ereuse_devicehub.resources.lot.models import Lot, Path
|
from ereuse_devicehub.resources.lot.models import Lot, Path
|
||||||
|
from ereuse_devicehub.teal.marshmallow import EnumField
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class LotFormat(Enum):
|
class LotFormat(Enum):
|
||||||
|
@ -79,7 +79,7 @@ class LotView(View):
|
||||||
lot = Lot.query.filter_by(id=id).one() # type: Lot
|
lot = Lot.query.filter_by(id=id).one() # type: Lot
|
||||||
return self.schema.jsonify(lot, nested=2)
|
return self.schema.jsonify(lot, nested=2)
|
||||||
|
|
||||||
# @teal.cache.cache(datetime.timedelta(minutes=5))
|
# @ereuse_devicehub.teal.cache.cache(datetime.timedelta(minutes=5))
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
"""Gets lots.
|
"""Gets lots.
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from teal.resource import Resource
|
|
||||||
from ereuse_devicehub.resources.metric.schema import Metric
|
from ereuse_devicehub.resources.metric.schema import Metric
|
||||||
from ereuse_devicehub.resources.metric.views import MetricsView
|
from ereuse_devicehub.resources.metric.views import MetricsView
|
||||||
|
from ereuse_devicehub.teal.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
class MetricDef(Resource):
|
class MetricDef(Resource):
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
from teal.resource import Schema
|
|
||||||
from marshmallow.fields import DateTime
|
from marshmallow.fields import DateTime
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class Metric(Schema):
|
class Metric(Schema):
|
||||||
"""
|
"""
|
||||||
This schema filter dates for search the metrics
|
This schema filter dates for search the metrics
|
||||||
"""
|
"""
|
||||||
start_time = DateTime(data_key='start_time', required=True,
|
|
||||||
description="Start date for search metrics")
|
start_time = DateTime(
|
||||||
end_time = DateTime(data_key='end_time', required=True,
|
data_key='start_time',
|
||||||
description="End date for search metrics")
|
required=True,
|
||||||
|
description="Start date for search metrics",
|
||||||
|
)
|
||||||
|
end_time = DateTime(
|
||||||
|
data_key='end_time', required=True, description="End date for search metrics"
|
||||||
|
)
|
||||||
|
|
|
@ -1,31 +1,38 @@
|
||||||
from flask import request, g, jsonify
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from teal.resource import View
|
|
||||||
|
from flask import g, jsonify, request
|
||||||
|
|
||||||
from ereuse_devicehub.resources.action import schemas
|
from ereuse_devicehub.resources.action import schemas
|
||||||
from ereuse_devicehub.resources.action.models import Allocate, Live, Action, ToRepair, ToPrepare
|
from ereuse_devicehub.resources.action.models import (
|
||||||
|
Action,
|
||||||
|
Allocate,
|
||||||
|
Live,
|
||||||
|
ToPrepare,
|
||||||
|
ToRepair,
|
||||||
|
)
|
||||||
from ereuse_devicehub.resources.device import models as m
|
from ereuse_devicehub.resources.device import models as m
|
||||||
from ereuse_devicehub.resources.metric.schema import Metric
|
from ereuse_devicehub.resources.metric.schema import Metric
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class MetricsView(View):
|
class MetricsView(View):
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
|
|
||||||
metrics = {
|
metrics = {
|
||||||
"allocateds": self.allocated(),
|
"allocateds": self.allocated(),
|
||||||
"live": self.live(),
|
"live": self.live(),
|
||||||
}
|
}
|
||||||
return jsonify(metrics)
|
return jsonify(metrics)
|
||||||
|
|
||||||
def allocated(self):
|
def allocated(self):
|
||||||
# TODO @cayop we need uncomment when the pr/83 is approved
|
# TODO @cayop we need uncomment when the pr/83 is approved
|
||||||
# return m.Device.query.filter(m.Device.allocated==True, owner==g.user).count()
|
# return m.Device.query.filter(m.Device.allocated==True, owner==g.user).count()
|
||||||
return m.Device.query.filter(m.Device.allocated==True).count()
|
return m.Device.query.filter(m.Device.allocated == True).count()
|
||||||
|
|
||||||
def live(self):
|
def live(self):
|
||||||
# TODO @cayop we need uncomment when the pr/83 is approved
|
# TODO @cayop we need uncomment when the pr/83 is approved
|
||||||
# devices = m.Device.query.filter(m.Device.allocated==True, owner==g.user)
|
# devices = m.Device.query.filter(m.Device.allocated==True, owner==g.user)
|
||||||
devices = m.Device.query.filter(m.Device.allocated==True)
|
devices = m.Device.query.filter(m.Device.allocated == True)
|
||||||
count = 0
|
count = 0
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
live = allocate = None
|
live = allocate = None
|
||||||
|
@ -41,4 +48,3 @@ class MetricsView(View):
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,10 @@ from typing import Any
|
||||||
from marshmallow import post_load
|
from marshmallow import post_load
|
||||||
from marshmallow.fields import DateTime, List, String
|
from marshmallow.fields import DateTime, List, String
|
||||||
from marshmallow.schema import SchemaMeta
|
from marshmallow.schema import SchemaMeta
|
||||||
from teal.marshmallow import URL
|
|
||||||
from teal.resource import Schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources import models as m
|
from ereuse_devicehub.resources import models as m
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
class UnitCodes(Enum):
|
class UnitCodes(Enum):
|
||||||
|
@ -38,8 +38,8 @@ class UnitCodes(Enum):
|
||||||
# Then the directive in our docs/config.py file reads these variables
|
# Then the directive in our docs/config.py file reads these variables
|
||||||
# generating the documentation.
|
# generating the documentation.
|
||||||
|
|
||||||
class Meta(type):
|
|
||||||
|
|
||||||
|
class Meta(type):
|
||||||
def __new__(cls, *args, **kw) -> Any:
|
def __new__(cls, *args, **kw) -> Any:
|
||||||
base_name = args[1][0].__name__
|
base_name = args[1][0].__name__
|
||||||
y = super().__new__(cls, *args, **kw)
|
y = super().__new__(cls, *args, **kw)
|
||||||
|
@ -47,7 +47,7 @@ class Meta(type):
|
||||||
return y
|
return y
|
||||||
|
|
||||||
|
|
||||||
SchemaMeta.__bases__ = Meta,
|
SchemaMeta.__bases__ = (Meta,)
|
||||||
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -70,9 +70,7 @@ value.
|
||||||
|
|
||||||
class Thing(Schema):
|
class Thing(Schema):
|
||||||
type = String(description=_type_description)
|
type = String(description=_type_description)
|
||||||
same_as = List(URL(dump_only=True),
|
same_as = List(URL(dump_only=True), dump_only=True, data_key='sameAs')
|
||||||
dump_only=True,
|
|
||||||
data_key='sameAs')
|
|
||||||
updated = DateTime('iso', dump_only=True, description=m.Thing.updated.comment)
|
updated = DateTime('iso', dump_only=True, description=m.Thing.updated.comment)
|
||||||
created = DateTime('iso', dump_only=True, description=m.Thing.created.comment)
|
created = DateTime('iso', dump_only=True, description=m.Thing.created.comment)
|
||||||
|
|
||||||
|
|
|
@ -3,14 +3,18 @@ import pathlib
|
||||||
|
|
||||||
from click import argument, option
|
from click import argument, option
|
||||||
from ereuse_utils import cli
|
from ereuse_utils import cli
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
from teal.teal import Teal
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.device.definitions import DeviceDef
|
from ereuse_devicehub.resources.device.definitions import DeviceDef
|
||||||
from ereuse_devicehub.resources.tag import schema
|
from ereuse_devicehub.resources.tag import schema
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
from ereuse_devicehub.resources.tag.view import TagDeviceView, TagView, get_device_from_tag
|
from ereuse_devicehub.resources.tag.view import (
|
||||||
|
TagDeviceView,
|
||||||
|
TagView,
|
||||||
|
get_device_from_tag,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
from ereuse_devicehub.teal.teal import Teal
|
||||||
|
|
||||||
|
|
||||||
class TagDef(Resource):
|
class TagDef(Resource):
|
||||||
|
@ -25,48 +29,77 @@ class TagDef(Resource):
|
||||||
'By default set to the actual Devicehub.'
|
'By default set to the actual Devicehub.'
|
||||||
CLI_SCHEMA = schema.Tag(only=('id', 'provider', 'org', 'secondary'))
|
CLI_SCHEMA = schema.Tag(only=('id', 'provider', 'org', 'secondary'))
|
||||||
|
|
||||||
def __init__(self, app: Teal, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None,
|
self,
|
||||||
template_folder=None, url_prefix=None, subdomain=None, url_defaults=None,
|
app: Teal,
|
||||||
root_path=None):
|
import_name=__name__.split('.')[0],
|
||||||
cli_commands = (
|
static_folder=None,
|
||||||
(self.create_tag, 'add'),
|
static_url_path=None,
|
||||||
(self.create_tags_csv, 'add-csv')
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
):
|
||||||
|
cli_commands = ((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,
|
||||||
)
|
)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
|
||||||
|
|
||||||
# DeviceTagView URLs
|
# DeviceTagView URLs
|
||||||
device_view = TagDeviceView.as_view('tag-device-view', definition=self, auth=app.auth)
|
device_view = TagDeviceView.as_view(
|
||||||
|
'tag-device-view', definition=self, auth=app.auth
|
||||||
|
)
|
||||||
if self.AUTH:
|
if self.AUTH:
|
||||||
device_view = app.auth.requires_auth(device_view)
|
device_view = app.auth.requires_auth(device_view)
|
||||||
self.add_url_rule('/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
|
self.add_url_rule(
|
||||||
view_func=device_view,
|
'/<{0.ID_CONVERTER.value}:{0.ID_NAME}>/device'.format(self),
|
||||||
methods={'GET'})
|
view_func=device_view,
|
||||||
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
|
methods={'GET'},
|
||||||
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
)
|
||||||
view_func=device_view,
|
self.add_url_rule(
|
||||||
methods={'PUT'})
|
'/<{0.ID_CONVERTER.value}:tag_id>/'.format(self)
|
||||||
self.add_url_rule('/<{0.ID_CONVERTER.value}:tag_id>/'.format(self) +
|
+ 'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
||||||
'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
view_func=device_view,
|
||||||
view_func=device_view,
|
methods={'PUT'},
|
||||||
methods={'DELETE'})
|
)
|
||||||
|
self.add_url_rule(
|
||||||
|
'/<{0.ID_CONVERTER.value}:tag_id>/'.format(self)
|
||||||
|
+ 'device/<{0.ID_CONVERTER.value}:device_id>'.format(DeviceDef),
|
||||||
|
view_func=device_view,
|
||||||
|
methods={'DELETE'},
|
||||||
|
)
|
||||||
|
|
||||||
@option('-u', '--owner', help=OWNER_H)
|
@option('-u', '--owner', help=OWNER_H)
|
||||||
@option('-o', '--org', help=ORG_H)
|
@option('-o', '--org', help=ORG_H)
|
||||||
@option('-p', '--provider', help=PROV_H)
|
@option('-p', '--provider', help=PROV_H)
|
||||||
@option('-s', '--sec', help=Tag.secondary.comment)
|
@option('-s', '--sec', help=Tag.secondary.comment)
|
||||||
@argument('id')
|
@argument('id')
|
||||||
def create_tag(self,
|
def create_tag(
|
||||||
id: str,
|
self,
|
||||||
org: str = None,
|
id: str,
|
||||||
owner: str = None,
|
org: str = None,
|
||||||
sec: str = None,
|
owner: str = None,
|
||||||
provider: str = None):
|
sec: str = None,
|
||||||
|
provider: str = None,
|
||||||
|
):
|
||||||
"""Create a tag with the given ID."""
|
"""Create a tag with the given ID."""
|
||||||
db.session.add(Tag(**self.schema.load(
|
db.session.add(
|
||||||
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
|
Tag(
|
||||||
)))
|
**self.schema.load(
|
||||||
|
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
@option('-u', '--owner', help=OWNER_H)
|
@option('-u', '--owner', help=OWNER_H)
|
||||||
|
@ -83,7 +116,17 @@ class TagDef(Resource):
|
||||||
"""
|
"""
|
||||||
with path.open() as f:
|
with path.open() as f:
|
||||||
for id, sec in csv.reader(f):
|
for id, sec in csv.reader(f):
|
||||||
db.session.add(Tag(**self.schema.load(
|
db.session.add(
|
||||||
dict(id=id, owner=owner, org=org, secondary=sec, provider=provider)
|
Tag(
|
||||||
)))
|
**self.schema.load(
|
||||||
|
dict(
|
||||||
|
id=id,
|
||||||
|
owner=owner,
|
||||||
|
org=org,
|
||||||
|
secondary=sec,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -3,12 +3,9 @@ from typing import Set
|
||||||
|
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from flask import g
|
from flask import g
|
||||||
from sqlalchemy import BigInteger, Column, ForeignKey, UniqueConstraint, Sequence
|
from sqlalchemy import BigInteger, Column, ForeignKey, Sequence, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref, relationship, validates
|
from sqlalchemy.orm import backref, relationship, validates
|
||||||
from teal.db import DB_CASCADE_SET_NULL, Query, URL
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.agent.models import Organization
|
from ereuse_devicehub.resources.agent.models import Organization
|
||||||
|
@ -16,6 +13,9 @@ from ereuse_devicehub.resources.device.models import Device
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
from ereuse_devicehub.resources.utils import hashcode
|
from ereuse_devicehub.resources.utils import hashcode
|
||||||
|
from ereuse_devicehub.teal.db import DB_CASCADE_SET_NULL, URL, Query
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class Tags(Set['Tag']):
|
class Tags(Set['Tag']):
|
||||||
|
@ -26,51 +26,59 @@ class Tags(Set['Tag']):
|
||||||
return ', '.join(format(tag, format_spec) for tag in self).strip()
|
return ', '.join(format(tag, format_spec) for tag in self).strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(Thing):
|
class Tag(Thing):
|
||||||
internal_id = Column(BigInteger, Sequence('tag_internal_id_seq'), unique=True, nullable=False)
|
internal_id = Column(
|
||||||
|
BigInteger, Sequence('tag_internal_id_seq'), unique=True, nullable=False
|
||||||
|
)
|
||||||
internal_id.comment = """The identifier of the tag for this database. Used only
|
internal_id.comment = """The identifier of the tag for this database. Used only
|
||||||
internally for software; users should not use this.
|
internally for software; users should not use this.
|
||||||
"""
|
"""
|
||||||
id = Column(db.CIText(), primary_key=True)
|
id = Column(db.CIText(), primary_key=True)
|
||||||
id.comment = """The ID of the tag."""
|
id.comment = """The ID of the tag."""
|
||||||
owner_id = Column(UUID(as_uuid=True),
|
owner_id = Column(
|
||||||
ForeignKey(User.id),
|
UUID(as_uuid=True),
|
||||||
primary_key=True,
|
ForeignKey(User.id),
|
||||||
nullable=False,
|
primary_key=True,
|
||||||
default=lambda: g.user.id)
|
nullable=False,
|
||||||
|
default=lambda: g.user.id,
|
||||||
|
)
|
||||||
owner = relationship(User, primaryjoin=owner_id == User.id)
|
owner = relationship(User, primaryjoin=owner_id == User.id)
|
||||||
org_id = Column(UUID(as_uuid=True),
|
org_id = Column(
|
||||||
ForeignKey(Organization.id),
|
UUID(as_uuid=True),
|
||||||
# If we link with the Organization object this instance
|
ForeignKey(Organization.id),
|
||||||
# will be set as persistent and added to session
|
# If we link with the Organization object this instance
|
||||||
# which is something we don't want to enforce by default
|
# will be set as persistent and added to session
|
||||||
default=lambda: Organization.get_default_org_id())
|
# which is something we don't want to enforce by default
|
||||||
org = relationship(Organization,
|
default=lambda: Organization.get_default_org_id(),
|
||||||
backref=backref('tags', lazy=True),
|
)
|
||||||
primaryjoin=Organization.id == org_id,
|
org = relationship(
|
||||||
collection_class=set)
|
Organization,
|
||||||
|
backref=backref('tags', lazy=True),
|
||||||
|
primaryjoin=Organization.id == org_id,
|
||||||
|
collection_class=set,
|
||||||
|
)
|
||||||
"""The organization that issued the tag."""
|
"""The organization that issued the tag."""
|
||||||
provider = Column(URL())
|
provider = Column(URL())
|
||||||
provider.comment = """The tag provider URL. If None, the provider is
|
provider.comment = """The tag provider URL. If None, the provider is
|
||||||
this Devicehub.
|
this Devicehub.
|
||||||
"""
|
"""
|
||||||
device_id = Column(BigInteger,
|
device_id = Column(
|
||||||
# We don't want to delete the tag on device deletion, only set to null
|
BigInteger,
|
||||||
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL))
|
# We don't want to delete the tag on device deletion, only set to null
|
||||||
device = relationship(Device,
|
ForeignKey(Device.id, ondelete=DB_CASCADE_SET_NULL),
|
||||||
backref=backref('tags', lazy=True, collection_class=Tags),
|
)
|
||||||
primaryjoin=Device.id == device_id)
|
device = relationship(
|
||||||
|
Device,
|
||||||
|
backref=backref('tags', lazy=True, collection_class=Tags),
|
||||||
|
primaryjoin=Device.id == device_id,
|
||||||
|
)
|
||||||
"""The device linked to this tag."""
|
"""The device linked to this tag."""
|
||||||
secondary = Column(db.CIText(), index=True)
|
secondary = Column(db.CIText(), index=True)
|
||||||
secondary.comment = """A secondary identifier for this tag.
|
secondary.comment = """A secondary identifier for this tag.
|
||||||
It has the same constraints as the main one. Only needed in special cases.
|
It has the same constraints as the main one. Only needed in special cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (db.Index('device_id_index', device_id, postgresql_using='hash'),)
|
||||||
db.Index('device_id_index', device_id, postgresql_using='hash'),
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, id: str, **kwargs) -> None:
|
def __init__(self, id: str, **kwargs) -> None:
|
||||||
super().__init__(id=id, **kwargs)
|
super().__init__(id=id, **kwargs)
|
||||||
|
@ -99,13 +107,16 @@ class Tag(Thing):
|
||||||
@validates('provider')
|
@validates('provider')
|
||||||
def use_only_domain(self, _, url: URL):
|
def use_only_domain(self, _, url: URL):
|
||||||
if url.path:
|
if url.path:
|
||||||
raise ValidationError('Provider can only contain scheme and host',
|
raise ValidationError(
|
||||||
field_names=['provider'])
|
'Provider can only contain scheme and host', field_names=['provider']
|
||||||
|
)
|
||||||
return url
|
return url
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(id, owner_id, name='one tag id per owner'),
|
UniqueConstraint(id, owner_id, name='one tag id per owner'),
|
||||||
UniqueConstraint(secondary, owner_id, name='one secondary tag per organization')
|
UniqueConstraint(
|
||||||
|
secondary, owner_id, name='one secondary tag per organization'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
from marshmallow.fields import Boolean
|
from marshmallow.fields import Boolean
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.marshmallow import SanitizedStr, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.agent.schemas import Organization
|
from ereuse_devicehub.resources.agent.schemas import Organization
|
||||||
|
@ -8,6 +7,7 @@ from ereuse_devicehub.resources.device.schemas import Device
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.tag import model as m
|
from ereuse_devicehub.resources.tag import model as m
|
||||||
from ereuse_devicehub.resources.user.schemas import User
|
from ereuse_devicehub.resources.user.schemas import User
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
def without_slash(x: str) -> bool:
|
def without_slash(x: str) -> bool:
|
||||||
|
@ -16,12 +16,10 @@ def without_slash(x: str) -> bool:
|
||||||
|
|
||||||
|
|
||||||
class Tag(Thing):
|
class Tag(Thing):
|
||||||
id = SanitizedStr(lower=True,
|
id = SanitizedStr(
|
||||||
description=m.Tag.id.comment,
|
lower=True, description=m.Tag.id.comment, validator=without_slash, required=True
|
||||||
validator=without_slash,
|
)
|
||||||
required=True)
|
provider = URL(description=m.Tag.provider.comment, validator=without_slash)
|
||||||
provider = URL(description=m.Tag.provider.comment,
|
|
||||||
validator=without_slash)
|
|
||||||
device = NestedOn(Device, dump_only=True)
|
device = NestedOn(Device, dump_only=True)
|
||||||
owner = NestedOn(User, only_query='id')
|
owner = NestedOn(User, only_query='id')
|
||||||
org = NestedOn(Organization, collection_class=OrderedSet, only_query='id')
|
org = NestedOn(Organization, collection_class=OrderedSet, only_query='id')
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
from flask import Response, current_app as app, g, redirect, request
|
from flask import Response
|
||||||
|
from flask import current_app as app
|
||||||
|
from flask import g, redirect, request
|
||||||
from flask_sqlalchemy import Pagination
|
from flask_sqlalchemy import Pagination
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from teal.resource import View, url_for_resource
|
|
||||||
|
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.query import things_response
|
from ereuse_devicehub.query import things_response
|
||||||
from ereuse_devicehub.resources.utils import hashcode
|
|
||||||
from ereuse_devicehub.resources.device.models import Device
|
from ereuse_devicehub.resources.device.models import Device
|
||||||
from ereuse_devicehub.resources.tag import Tag
|
from ereuse_devicehub.resources.tag import Tag
|
||||||
|
from ereuse_devicehub.resources.utils import hashcode
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
from ereuse_devicehub.teal.resource import View, url_for_resource
|
||||||
|
|
||||||
|
|
||||||
class TagView(View):
|
class TagView(View):
|
||||||
|
@ -34,13 +36,19 @@ class TagView(View):
|
||||||
|
|
||||||
@auth.Auth.requires_auth
|
@auth.Auth.requires_auth
|
||||||
def find(self, args: dict):
|
def find(self, args: dict):
|
||||||
tags = Tag.query.filter(Tag.is_printable_q()) \
|
tags = (
|
||||||
.filter_by(owner=g.user) \
|
Tag.query.filter(Tag.is_printable_q())
|
||||||
.order_by(Tag.created.desc()) \
|
.filter_by(owner=g.user)
|
||||||
.paginate(per_page=200) # type: Pagination
|
.order_by(Tag.created.desc())
|
||||||
|
.paginate(per_page=200)
|
||||||
|
) # type: Pagination
|
||||||
return things_response(
|
return things_response(
|
||||||
self.schema.dump(tags.items, many=True, nested=0),
|
self.schema.dump(tags.items, many=True, nested=0),
|
||||||
tags.page, tags.per_page, tags.total, tags.prev_num, tags.next_num
|
tags.page,
|
||||||
|
tags.per_page,
|
||||||
|
tags.total,
|
||||||
|
tags.prev_num,
|
||||||
|
tags.next_num,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_many_regular_tags(self, num: int):
|
def _create_many_regular_tags(self, num: int):
|
||||||
|
@ -48,7 +56,9 @@ class TagView(View):
|
||||||
tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
|
tags = [Tag(id=tag_id, provider=g.inventory.tag_provider) for tag_id in tags_id]
|
||||||
db.session.add_all(tags)
|
db.session.add_all(tags)
|
||||||
db.session().final_flush()
|
db.session().final_flush()
|
||||||
response = things_response(self.schema.dump(tags, many=True, nested=1), code=201)
|
response = things_response(
|
||||||
|
self.schema.dump(tags, many=True, nested=1), code=201
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from teal.resource import Converters, Resource
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.tradedocument import schemas
|
from ereuse_devicehub.resources.tradedocument import schemas
|
||||||
from ereuse_devicehub.resources.tradedocument.views import TradeDocumentView
|
from ereuse_devicehub.resources.tradedocument.views import TradeDocumentView
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class TradeDocumentDef(Resource):
|
class TradeDocumentDef(Resource):
|
||||||
SCHEMA = schemas.TradeDocument
|
SCHEMA = schemas.TradeDocument
|
||||||
|
|
|
@ -7,12 +7,12 @@ from sortedcontainers import SortedSet
|
||||||
from sqlalchemy import BigInteger, Column, Sequence
|
from sqlalchemy import BigInteger, Column, Sequence
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import backref
|
from sqlalchemy.orm import backref
|
||||||
from teal.db import CASCADE_OWN, URL
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.enums import Severity
|
from ereuse_devicehub.resources.enums import Severity
|
||||||
from ereuse_devicehub.resources.models import Thing
|
from ereuse_devicehub.resources.models import Thing
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL
|
||||||
|
|
||||||
_sorted_documents = {
|
_sorted_documents = {
|
||||||
'order_by': lambda: TradeDocument.created,
|
'order_by': lambda: TradeDocument.created,
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
from marshmallow.fields import DateTime, Integer, Float, validate
|
from marshmallow.fields import DateTime, Float, Integer, validate
|
||||||
from teal.marshmallow import SanitizedStr, URL
|
|
||||||
# from marshmallow import ValidationError, validates_schema
|
|
||||||
|
|
||||||
from ereuse_devicehub.marshmallow import NestedOn
|
from ereuse_devicehub.marshmallow import NestedOn
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
from ereuse_devicehub.resources.tradedocument import models as m
|
from ereuse_devicehub.resources.tradedocument import models as m
|
||||||
|
from ereuse_devicehub.teal.marshmallow import URL, SanitizedStr
|
||||||
|
|
||||||
|
# from marshmallow import ValidationError, validates_schema
|
||||||
|
|
||||||
|
|
||||||
# from ereuse_devicehub.resources.lot import schemas as s_lot
|
# from ereuse_devicehub.resources.lot import schemas as s_lot
|
||||||
|
|
||||||
|
|
||||||
|
@ -12,20 +15,28 @@ class TradeDocument(Thing):
|
||||||
__doc__ = m.TradeDocument.__doc__
|
__doc__ = m.TradeDocument.__doc__
|
||||||
id = Integer(description=m.TradeDocument.id.comment, dump_only=True)
|
id = Integer(description=m.TradeDocument.id.comment, dump_only=True)
|
||||||
date = DateTime(required=False, description=m.TradeDocument.date.comment)
|
date = DateTime(required=False, description=m.TradeDocument.date.comment)
|
||||||
id_document = SanitizedStr(data_key='documentId',
|
id_document = SanitizedStr(
|
||||||
default='',
|
data_key='documentId',
|
||||||
description=m.TradeDocument.id_document.comment)
|
default='',
|
||||||
description = SanitizedStr(default='',
|
description=m.TradeDocument.id_document.comment,
|
||||||
description=m.TradeDocument.description.comment,
|
)
|
||||||
validate=validate.Length(max=500))
|
description = SanitizedStr(
|
||||||
file_name = SanitizedStr(data_key='filename',
|
default='',
|
||||||
default='',
|
description=m.TradeDocument.description.comment,
|
||||||
description=m.TradeDocument.file_name.comment,
|
validate=validate.Length(max=500),
|
||||||
validate=validate.Length(max=100))
|
)
|
||||||
file_hash = SanitizedStr(data_key='hash',
|
file_name = SanitizedStr(
|
||||||
default='',
|
data_key='filename',
|
||||||
description=m.TradeDocument.file_hash.comment,
|
default='',
|
||||||
validate=validate.Length(max=64))
|
description=m.TradeDocument.file_name.comment,
|
||||||
|
validate=validate.Length(max=100),
|
||||||
|
)
|
||||||
|
file_hash = SanitizedStr(
|
||||||
|
data_key='hash',
|
||||||
|
default='',
|
||||||
|
description=m.TradeDocument.file_hash.comment,
|
||||||
|
validate=validate.Length(max=64),
|
||||||
|
)
|
||||||
url = URL(description=m.TradeDocument.url.comment)
|
url = URL(description=m.TradeDocument.url.comment)
|
||||||
lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__)
|
lot = NestedOn('Lot', only_query='id', description=m.TradeDocument.lot.__doc__)
|
||||||
trading = SanitizedStr(dump_only=True, description='')
|
trading = SanitizedStr(dump_only=True, description='')
|
||||||
|
|
|
@ -1,18 +1,20 @@
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import current_app as app, request, g, Response
|
|
||||||
|
from flask import Response
|
||||||
|
from flask import current_app as app
|
||||||
|
from flask import g, request
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
|
||||||
from ereuse_devicehub.resources.action.models import ConfirmDocument
|
from ereuse_devicehub.resources.action.models import ConfirmDocument
|
||||||
from ereuse_devicehub.resources.hash_reports import ReportHash
|
from ereuse_devicehub.resources.hash_reports import ReportHash
|
||||||
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class TradeDocumentView(View):
|
class TradeDocumentView(View):
|
||||||
|
|
||||||
def one(self, id: str):
|
def one(self, id: str):
|
||||||
doc = TradeDocument.query.filter_by(id=id, owner=g.user).one()
|
doc = TradeDocument.query.filter_by(id=id, owner=g.user).one()
|
||||||
return self.schema.jsonify(doc)
|
return self.schema.jsonify(doc)
|
||||||
|
@ -33,10 +35,9 @@ class TradeDocumentView(View):
|
||||||
trade = doc.lot.trade
|
trade = doc.lot.trade
|
||||||
if trade:
|
if trade:
|
||||||
trade.documents.add(doc)
|
trade.documents.add(doc)
|
||||||
confirm = ConfirmDocument(action=trade,
|
confirm = ConfirmDocument(
|
||||||
user=g.user,
|
action=trade, user=g.user, devices=set(), documents={doc}
|
||||||
devices=set(),
|
)
|
||||||
documents={doc})
|
|
||||||
db.session.add(confirm)
|
db.session.add(confirm)
|
||||||
db.session.add(doc)
|
db.session.add(doc)
|
||||||
db.session().final_flush()
|
db.session().final_flush()
|
||||||
|
|
|
@ -2,12 +2,12 @@ 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 ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
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, logout
|
from ereuse_devicehub.resources.user.views import UserView, login, logout
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, Resource
|
||||||
|
|
||||||
|
|
||||||
class UserDef(Resource):
|
class UserDef(Resource):
|
||||||
|
@ -16,49 +16,88 @@ class UserDef(Resource):
|
||||||
ID_CONVERTER = Converters.uuid
|
ID_CONVERTER = Converters.uuid
|
||||||
AUTH = True
|
AUTH = True
|
||||||
|
|
||||||
def __init__(self, app, import_name=__name__.split('.')[0], static_folder=None,
|
def __init__(
|
||||||
static_url_path=None, template_folder=None, url_prefix=None, subdomain=None,
|
self,
|
||||||
url_defaults=None, root_path=None):
|
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, 'add'),)
|
cli_commands = ((self.create_user, 'add'),)
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
super().__init__(
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
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'})
|
self.add_url_rule('/login/', view_func=login, methods={'POST'})
|
||||||
logout_view = app.auth.requires_auth(logout)
|
logout_view = app.auth.requires_auth(logout)
|
||||||
self.add_url_rule('/logout/', view_func=logout_view, methods={'GET'})
|
self.add_url_rule('/logout/', view_func=logout_view, methods={'GET'})
|
||||||
|
|
||||||
@argument('email')
|
@argument('email')
|
||||||
@option('-i', '--inventory',
|
@option(
|
||||||
multiple=True,
|
'-i',
|
||||||
help='Inventories user has access to. By default this one.')
|
'--inventory',
|
||||||
@option('-a', '--agent',
|
multiple=True,
|
||||||
help='Create too an Individual agent representing this user, '
|
help='Inventories user has access to. By default this one.',
|
||||||
'and give a name to this individual.')
|
)
|
||||||
|
@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('-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,
|
def create_user(
|
||||||
password: str,
|
self,
|
||||||
inventory: Iterable[str] = tuple(),
|
email: str,
|
||||||
agent: str = None,
|
password: str,
|
||||||
country: str = None,
|
inventory: Iterable[str] = tuple(),
|
||||||
telephone: str = None,
|
agent: str = None,
|
||||||
tax_id: str = None) -> dict:
|
country: str = None,
|
||||||
|
telephone: str = None,
|
||||||
|
tax_id: str = None,
|
||||||
|
) -> dict:
|
||||||
"""Create an user.
|
"""Create an user.
|
||||||
|
|
||||||
If ``--agent`` is passed, it creates too an ``Individual``
|
If ``--agent`` is passed, it creates too an ``Individual``
|
||||||
agent that represents the user.
|
agent that represents the user.
|
||||||
"""
|
"""
|
||||||
from ereuse_devicehub.resources.agent.models import Individual
|
from ereuse_devicehub.resources.agent.models import Individual
|
||||||
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)) \
|
|
||||||
.load({'email': email, 'password': password})
|
u = self.SCHEMA(only={'email', 'password'}, exclude=('token',)).load(
|
||||||
|
{'email': email, 'password': password}
|
||||||
|
)
|
||||||
if inventory:
|
if inventory:
|
||||||
from ereuse_devicehub.resources.inventory import Inventory
|
from ereuse_devicehub.resources.inventory import Inventory
|
||||||
|
|
||||||
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
|
inventory = Inventory.query.filter(Inventory.id.in_(inventory))
|
||||||
user = User(**u, inventories=inventory)
|
user = User(**u, inventories=inventory)
|
||||||
agent = Individual(**current_app.resources[Individual.t].schema.load(
|
agent = Individual(
|
||||||
dict(name=agent, email=email, country=country, telephone=telephone, taxId=tax_id)
|
**current_app.resources[Individual.t].schema.load(
|
||||||
))
|
dict(
|
||||||
|
name=agent,
|
||||||
|
email=email,
|
||||||
|
country=country,
|
||||||
|
telephone=telephone,
|
||||||
|
taxId=tax_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
user.individuals.add(agent)
|
user.individuals.add(agent)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -6,12 +6,12 @@ from flask_login import UserMixin
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, Sequence
|
from sqlalchemy import BigInteger, Boolean, Column, Sequence
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy_utils import EmailType, PasswordType
|
from sqlalchemy_utils import EmailType, PasswordType
|
||||||
from teal.db import CASCADE_OWN, URL, IntEnum
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.enums import SessionType
|
from ereuse_devicehub.resources.enums import SessionType
|
||||||
from ereuse_devicehub.resources.inventory.model import Inventory
|
from ereuse_devicehub.resources.inventory.model import Inventory
|
||||||
from ereuse_devicehub.resources.models import STR_SIZE, Thing
|
from ereuse_devicehub.resources.models import STR_SIZE, Thing
|
||||||
|
from ereuse_devicehub.teal.db import CASCADE_OWN, URL, IntEnum
|
||||||
|
|
||||||
|
|
||||||
class User(UserMixin, Thing):
|
class User(UserMixin, Thing):
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
from marshmallow import post_dump
|
from marshmallow import post_dump
|
||||||
from marshmallow.fields import Email, String, UUID
|
from marshmallow.fields import UUID, Email, String
|
||||||
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.inventory.schema import Inventory
|
||||||
from ereuse_devicehub.resources.schemas import Thing
|
from ereuse_devicehub.resources.schemas import Thing
|
||||||
|
from ereuse_devicehub.teal.marshmallow import SanitizedStr
|
||||||
|
|
||||||
|
|
||||||
class Session(Thing):
|
class Session(Thing):
|
||||||
|
@ -19,27 +19,33 @@ class User(Thing):
|
||||||
password = SanitizedStr(load_only=True, required=True)
|
password = SanitizedStr(load_only=True, required=True)
|
||||||
individuals = NestedOn(Individual, many=True, dump_only=True)
|
individuals = NestedOn(Individual, many=True, dump_only=True)
|
||||||
name = SanitizedStr()
|
name = SanitizedStr()
|
||||||
token = String(dump_only=True,
|
token = String(
|
||||||
description='Use this token in an Authorization header to access the app.'
|
dump_only=True,
|
||||||
'The token can change overtime.')
|
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)
|
inventories = NestedOn(Inventory, many=True, dump_only=True)
|
||||||
code = String(dump_only=True, description='Code of inactive accounts')
|
code = String(dump_only=True, description='Code of inactive accounts')
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(
|
||||||
only=None,
|
self,
|
||||||
exclude=('token',),
|
only=None,
|
||||||
prefix='',
|
exclude=('token',),
|
||||||
many=False,
|
prefix='',
|
||||||
context=None,
|
many=False,
|
||||||
load_only=(),
|
context=None,
|
||||||
dump_only=(),
|
load_only=(),
|
||||||
partial=False):
|
dump_only=(),
|
||||||
|
partial=False,
|
||||||
|
):
|
||||||
"""Instantiates the User.
|
"""Instantiates the User.
|
||||||
|
|
||||||
By default we exclude token from both load/dump
|
By default we exclude token from both load/dump
|
||||||
so they are not taken / set in normal usage by mistake.
|
so they are not taken / set in normal usage by mistake.
|
||||||
"""
|
"""
|
||||||
super().__init__(only, exclude, prefix, many, context, load_only, dump_only, partial)
|
super().__init__(
|
||||||
|
only, exclude, prefix, many, context, load_only, dump_only, partial
|
||||||
|
)
|
||||||
|
|
||||||
@post_dump
|
@post_dump
|
||||||
def base64encode_token(self, data: dict):
|
def base64encode_token(self, data: dict):
|
||||||
|
|
|
@ -2,11 +2,11 @@ from uuid import UUID, uuid4
|
||||||
|
|
||||||
from flask import g, request
|
from flask import g, request
|
||||||
from flask.json import jsonify
|
from flask.json import jsonify
|
||||||
from teal.resource import View
|
|
||||||
|
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
|
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.resource import View
|
||||||
|
|
||||||
|
|
||||||
class UserView(View):
|
class UserView(View):
|
||||||
|
@ -19,7 +19,9 @@ def login():
|
||||||
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
|
user_s = g.resource_def.SCHEMA(only=('email', 'password')) # type: UserS
|
||||||
# noinspection PyArgumentList
|
# noinspection PyArgumentList
|
||||||
u = request.get_json(schema=user_s)
|
u = request.get_json(schema=user_s)
|
||||||
user = User.query.filter_by(email=u['email'], active=True, phantom=False).one_or_none()
|
user = User.query.filter_by(
|
||||||
|
email=u['email'], active=True, phantom=False
|
||||||
|
).one_or_none()
|
||||||
if user and user.password == u['password']:
|
if user and user.password == u['password']:
|
||||||
schema_with_token = g.resource_def.SCHEMA(exclude=set())
|
schema_with_token = g.resource_def.SCHEMA(exclude=set())
|
||||||
return schema_with_token.jsonify(user)
|
return schema_with_token.jsonify(user)
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import flask
|
|
||||||
import json
|
import json
|
||||||
import requests
|
|
||||||
import teal.marshmallow
|
|
||||||
|
|
||||||
from typing import Callable, Iterable, Tuple
|
from typing import Callable, Iterable, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from flask import make_response, g
|
|
||||||
from flask.json import jsonify
|
|
||||||
from teal.resource import Resource, View
|
|
||||||
|
|
||||||
from ereuse_devicehub.resources.inventory.model import Inventory
|
import flask
|
||||||
|
import requests
|
||||||
|
from flask import g, make_response
|
||||||
|
from flask.json import jsonify
|
||||||
|
|
||||||
|
import ereuse_devicehub.teal.marshmallow
|
||||||
from ereuse_devicehub import __version__
|
from ereuse_devicehub import __version__
|
||||||
|
from ereuse_devicehub.resources.inventory.model import Inventory
|
||||||
|
from ereuse_devicehub.teal.resource import Resource, View
|
||||||
|
|
||||||
|
|
||||||
def get_tag_version(app):
|
def get_tag_version(app):
|
||||||
|
@ -29,6 +29,7 @@ def get_tag_version(app):
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class VersionView(View):
|
class VersionView(View):
|
||||||
def get(self, *args, **kwargs):
|
def get(self, *args, **kwargs):
|
||||||
"""Get version of DeviceHub and ereuse-tag."""
|
"""Get version of DeviceHub and ereuse-tag."""
|
||||||
|
@ -48,18 +49,31 @@ class VersionDef(Resource):
|
||||||
VIEW = None # We do not want to create default / documents endpoint
|
VIEW = None # We do not want to create default / documents endpoint
|
||||||
AUTH = False
|
AUTH = False
|
||||||
|
|
||||||
def __init__(self, app,
|
def __init__(
|
||||||
import_name=__name__,
|
self,
|
||||||
static_folder=None,
|
app,
|
||||||
static_url_path=None,
|
import_name=__name__,
|
||||||
template_folder=None,
|
static_folder=None,
|
||||||
url_prefix=None,
|
static_url_path=None,
|
||||||
subdomain=None,
|
template_folder=None,
|
||||||
url_defaults=None,
|
url_prefix=None,
|
||||||
root_path=None,
|
subdomain=None,
|
||||||
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple()):
|
url_defaults=None,
|
||||||
super().__init__(app, import_name, static_folder, static_url_path, template_folder,
|
root_path=None,
|
||||||
url_prefix, subdomain, url_defaults, root_path, cli_commands)
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
cli_commands,
|
||||||
|
)
|
||||||
|
|
||||||
d = {'devicehub': __version__, "ereuse_tag": "0.0.0"}
|
d = {'devicehub': __version__, "ereuse_tag": "0.0.0"}
|
||||||
get = {'GET'}
|
get = {'GET'}
|
||||||
|
|
0
ereuse_devicehub/teal/__init__.py
Normal file
0
ereuse_devicehub/teal/__init__.py
Normal file
93
ereuse_devicehub/teal/auth.py
Normal file
93
ereuse_devicehub/teal/auth.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
import base64
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from flask import current_app, g, request
|
||||||
|
from werkzeug.datastructures import Authorization
|
||||||
|
from werkzeug.exceptions import Unauthorized
|
||||||
|
|
||||||
|
|
||||||
|
class Auth:
|
||||||
|
"""
|
||||||
|
Authentication handler for Teal.
|
||||||
|
|
||||||
|
To authenticate the user (perform login):
|
||||||
|
1. Set Resource.AUTH to True, or manually decorate the view with
|
||||||
|
@auth.requires_auth
|
||||||
|
2. Extend any subclass of this one (like TokenAuth).
|
||||||
|
3. Implement the authenticate method with the authentication logic.
|
||||||
|
For example, in TokenAuth here you get the user from the token.
|
||||||
|
5. Set in your teal the Auth class you have created so
|
||||||
|
teal can use it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
API_DOCS = {
|
||||||
|
'type': 'http',
|
||||||
|
'description:': 'HTTP Basic scheme',
|
||||||
|
'name': 'Authorization',
|
||||||
|
'in': 'header',
|
||||||
|
'scheme': 'basic',
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_auth(cls, f: Callable):
|
||||||
|
"""
|
||||||
|
Decorate a view enforcing authentication (logged in user).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
auth = request.authorization
|
||||||
|
if not auth:
|
||||||
|
raise Unauthorized('Provide proper authorization credentials')
|
||||||
|
current_app.auth.perform_auth(auth)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
def perform_auth(self, auth: Authorization):
|
||||||
|
"""
|
||||||
|
Authenticate an user. This loads the user.
|
||||||
|
|
||||||
|
An exception (expected Unauthorized) is raised if
|
||||||
|
authentication failed.
|
||||||
|
"""
|
||||||
|
g.user = self.authenticate(auth.username, auth.password)
|
||||||
|
|
||||||
|
def authenticate(self, username: str, password: str) -> object:
|
||||||
|
"""
|
||||||
|
The authentication logic. The result of this method is
|
||||||
|
a user or a raised exception, like Werkzeug's Unauthorized,
|
||||||
|
if authentication failed.
|
||||||
|
|
||||||
|
:raise: Unauthorized Authentication failed.
|
||||||
|
:return: The user object.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class TokenAuth(Auth):
|
||||||
|
API_DOCS = Auth.API_DOCS.copy()
|
||||||
|
API_DOCS['description'] = 'Basic scheme with token.'
|
||||||
|
|
||||||
|
def authenticate(self, token: str, *args, **kw) -> object:
|
||||||
|
"""
|
||||||
|
The result of this method is
|
||||||
|
a user or a raised exception if authentication failed.
|
||||||
|
|
||||||
|
:raise: Unauthorized Authentication failed.
|
||||||
|
:return The user object.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def encode(value: str):
|
||||||
|
"""Creates a suitable Token that can be sent to a client
|
||||||
|
and sent back.
|
||||||
|
"""
|
||||||
|
return base64.b64encode(str.encode(str(value) + ':')).decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def decode(value: str):
|
||||||
|
"""Decodes a token generated by ``encode``."""
|
||||||
|
return base64.b64decode(value.encode()).decode()[:-1]
|
28
ereuse_devicehub/teal/cache.py
Normal file
28
ereuse_devicehub/teal/cache.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import datetime
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from flask import Response, make_response
|
||||||
|
|
||||||
|
|
||||||
|
def cache(expires: datetime.timedelta = None):
|
||||||
|
"""Sets HTTP cache for now + passed-in time.
|
||||||
|
|
||||||
|
Example usage::
|
||||||
|
|
||||||
|
@app.route('/map')
|
||||||
|
@header_cache(expires=datetime.datetime(seconds=50))
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
"""
|
||||||
|
|
||||||
|
def cache_decorator(view):
|
||||||
|
@wraps(view)
|
||||||
|
def cache_func(*args, **kwargs):
|
||||||
|
r = make_response(view(*args, **kwargs)) # type: Response
|
||||||
|
r.expires = datetime.datetime.now(datetime.timezone.utc) + expires
|
||||||
|
r.cache_control.public = True
|
||||||
|
return r
|
||||||
|
|
||||||
|
return cache_func
|
||||||
|
|
||||||
|
return cache_decorator
|
13
ereuse_devicehub/teal/cli.py
Normal file
13
ereuse_devicehub/teal/cli.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
|
||||||
|
class TealCliRunner(FlaskCliRunner):
|
||||||
|
"""The same as FlaskCliRunner but with invoke's
|
||||||
|
'catch_exceptions' as False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def invoke(self, *args, cli=None, **kwargs):
|
||||||
|
kwargs.setdefault('catch_exceptions', False)
|
||||||
|
r = super().invoke(cli, args, **kwargs)
|
||||||
|
assert r.exit_code == 0, 'CLI code {}: {}'.format(r.exit_code, r.output)
|
||||||
|
return r
|
181
ereuse_devicehub/teal/client.py
Normal file
181
ereuse_devicehub/teal/client.py
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
from typing import Any, Iterable, Tuple, Type, Union
|
||||||
|
|
||||||
|
from boltons.urlutils import URL
|
||||||
|
from ereuse_utils.test import JSON
|
||||||
|
from ereuse_utils.test import Client as EreuseUtilsClient
|
||||||
|
from ereuse_utils.test import Res
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
|
|
||||||
|
Status = Union[int, Type[HTTPException], Type[ValidationError]]
|
||||||
|
Query = Iterable[Tuple[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class Client(EreuseUtilsClient):
|
||||||
|
"""A REST interface to a Teal app."""
|
||||||
|
|
||||||
|
def open(
|
||||||
|
self,
|
||||||
|
uri: str,
|
||||||
|
res: str = None,
|
||||||
|
status: Status = 200,
|
||||||
|
query: Query = tuple(),
|
||||||
|
accept=JSON,
|
||||||
|
content_type=JSON,
|
||||||
|
item=None,
|
||||||
|
headers: dict = None,
|
||||||
|
token: str = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
headers = headers or {}
|
||||||
|
if res:
|
||||||
|
resource_url = self.application.resources[res].url_prefix + '/'
|
||||||
|
uri = URL(uri).navigate(resource_url).to_text()
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = 'Basic {}'.format(token)
|
||||||
|
res = super().open(
|
||||||
|
uri, status, query, accept, content_type, item, headers, **kw
|
||||||
|
)
|
||||||
|
# ereuse-utils checks for status code
|
||||||
|
# here we check for specific type
|
||||||
|
# (when response: {'type': 'foobar', 'code': 422})
|
||||||
|
_status = getattr(status, 'code', status)
|
||||||
|
if not isinstance(status, int) and res[1].status_code == _status:
|
||||||
|
assert (
|
||||||
|
status.__name__ == res[0]['type']
|
||||||
|
), 'Expected exception {0} but it was {1}'.format(
|
||||||
|
status.__name__, res[0]['type']
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get(
|
||||||
|
self,
|
||||||
|
uri: str = '',
|
||||||
|
res: str = None,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 200,
|
||||||
|
item=None,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token: str = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
"""
|
||||||
|
Performs GET.
|
||||||
|
|
||||||
|
:param uri: The uri where to GET from. This is optional, as you
|
||||||
|
can build the URI too through ``res`` and ``item``.
|
||||||
|
:param res: The resource where to GET from, if any.
|
||||||
|
If this is set, the client will try to get the
|
||||||
|
url from the resource definition.
|
||||||
|
:param query: The query params in a dict. This method
|
||||||
|
automatically converts the dict to URL params,
|
||||||
|
and if the dict had nested dictionaries, those
|
||||||
|
are converted to JSON.
|
||||||
|
:param status: A status code or exception to assert.
|
||||||
|
:param item: The id of a resource to GET from, if any.
|
||||||
|
:param accept: The accept headers. By default
|
||||||
|
``application/json``.
|
||||||
|
:param headers: A dictionary of header name - header value.
|
||||||
|
:param token: A token to add to an ``Authentication`` header.
|
||||||
|
:return: A tuple containing 1. a dict (if content-type is JSON)
|
||||||
|
or a str with the data, and 2. the ``Response`` object.
|
||||||
|
"""
|
||||||
|
kw['res'] = res
|
||||||
|
kw['token'] = token
|
||||||
|
return super().get(uri, query, item, status, accept, headers, **kw)
|
||||||
|
|
||||||
|
def post(
|
||||||
|
self,
|
||||||
|
data: str or dict,
|
||||||
|
uri: str = '',
|
||||||
|
res: str = None,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 201,
|
||||||
|
content_type: str = JSON,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token: str = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
kw['res'] = res
|
||||||
|
kw['token'] = token
|
||||||
|
return super().post(
|
||||||
|
uri, data, query, status, content_type, accept, headers, **kw
|
||||||
|
)
|
||||||
|
|
||||||
|
def patch(
|
||||||
|
self,
|
||||||
|
data: str or dict,
|
||||||
|
uri: str = '',
|
||||||
|
res: str = None,
|
||||||
|
query: Query = tuple(),
|
||||||
|
item=None,
|
||||||
|
status: Status = 200,
|
||||||
|
content_type: str = JSON,
|
||||||
|
accept: str = JSON,
|
||||||
|
token: str = None,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
kw['res'] = res
|
||||||
|
kw['token'] = token
|
||||||
|
return super().patch(
|
||||||
|
uri, data, query, status, content_type, item, accept, headers, **kw
|
||||||
|
)
|
||||||
|
|
||||||
|
def put(
|
||||||
|
self,
|
||||||
|
data: str or dict,
|
||||||
|
uri: str = '',
|
||||||
|
res: str = None,
|
||||||
|
query: Query = tuple(),
|
||||||
|
item=None,
|
||||||
|
status: Status = 201,
|
||||||
|
content_type: str = JSON,
|
||||||
|
accept: str = JSON,
|
||||||
|
token: str = None,
|
||||||
|
headers: dict = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
kw['res'] = res
|
||||||
|
kw['token'] = token
|
||||||
|
return super().put(
|
||||||
|
uri, data, query, status, content_type, item, accept, headers, **kw
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(
|
||||||
|
self,
|
||||||
|
uri: str = '',
|
||||||
|
res: str = None,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 204,
|
||||||
|
item=None,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
token: str = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
kw['res'] = res
|
||||||
|
kw['token'] = token
|
||||||
|
return super().delete(uri, query, item, status, accept, headers, **kw)
|
||||||
|
|
||||||
|
def post_get(
|
||||||
|
self,
|
||||||
|
res: str,
|
||||||
|
data: str or dict,
|
||||||
|
query: Query = tuple(),
|
||||||
|
status: Status = 200,
|
||||||
|
content_type: str = JSON,
|
||||||
|
accept: str = JSON,
|
||||||
|
headers: dict = None,
|
||||||
|
key='id',
|
||||||
|
token: str = None,
|
||||||
|
**kw,
|
||||||
|
) -> Res:
|
||||||
|
"""Performs post and then gets the resource through its key."""
|
||||||
|
r, _ = self.post(
|
||||||
|
'', data, res, query, status, content_type, accept, token, headers, **kw
|
||||||
|
)
|
||||||
|
return self.get(res=res, item=r[key])
|
72
ereuse_devicehub/teal/config.py
Normal file
72
ereuse_devicehub/teal/config.py
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
from typing import Dict, Set, Type
|
||||||
|
|
||||||
|
from boltons.typeutils import issubclass
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.resource import Resource
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""
|
||||||
|
The configuration class.
|
||||||
|
|
||||||
|
Subclass and set here your config values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
RESOURCE_DEFINITIONS = set() # type: Set[Type[Resource]]
|
||||||
|
"""
|
||||||
|
A list of resource definitions to load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URI = None # type: str
|
||||||
|
"""
|
||||||
|
The access to the main Database.
|
||||||
|
"""
|
||||||
|
SQLALCHEMY_BINDS = {} # type: Dict[str, str]
|
||||||
|
"""
|
||||||
|
Optional extra databases. See `here <http://flask-sqlalchemy.pocoo.org
|
||||||
|
/2.3/binds/#referring-to-binds>`_ how bind your models to different
|
||||||
|
databases.
|
||||||
|
"""
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
"""
|
||||||
|
Disables flask-sqlalchemy notification system.
|
||||||
|
Save resources and hides a warning by flask-sqlalchemy itself.
|
||||||
|
|
||||||
|
See `this answer in Stackoverflow for more info
|
||||||
|
<https://stackoverflow.com/a/33790196>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
API_DOC_CONFIG_TITLE = 'Teal'
|
||||||
|
API_DOC_CONFIG_VERSION = '0.1'
|
||||||
|
"""
|
||||||
|
Configuration options for the api docs. They are the parameters
|
||||||
|
passed to `apispec <http://apispec.readthedocs.io/en/
|
||||||
|
latest/api_core.html#apispec.APISpec>`_. Prefix the configuration
|
||||||
|
names with ``API_DOC_CONFIG_``.
|
||||||
|
"""
|
||||||
|
API_DOC_CLASS_DISCRIMINATOR = None
|
||||||
|
"""
|
||||||
|
Configuration options for the api docs class definitions.
|
||||||
|
|
||||||
|
You can pass any `schema definition <https://github.com/OAI/
|
||||||
|
OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject>`_
|
||||||
|
prefiex by ``API_DOC_CLASS_`` like in the example above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CORS_ORIGINS = '*'
|
||||||
|
CORS_EXPOSE_HEADERS = 'Authorization'
|
||||||
|
CORS_ALLOW_HEADERS = 'Content-Type', 'Authorization'
|
||||||
|
"""
|
||||||
|
Configuration for CORS. See the options you can pass by in `Flask-Cors
|
||||||
|
<https://flask-cors.corydolphin.com/en/latest/api.html#extension>`_,
|
||||||
|
exactly in **Parameters**, like the ones above.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""
|
||||||
|
:param db: Optional. Set the ``SQLALCHEMY_DATABASE_URI`` param.
|
||||||
|
"""
|
||||||
|
for r in self.RESOURCE_DEFINITIONS:
|
||||||
|
assert issubclass(
|
||||||
|
r, Resource
|
||||||
|
), '{0!r} is not a subclass of Resource'.format(r)
|
382
ereuse_devicehub/teal/db.py
Normal file
382
ereuse_devicehub/teal/db.py
Normal file
|
@ -0,0 +1,382 @@
|
||||||
|
import enum
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from distutils.version import StrictVersion
|
||||||
|
from typing import Any, Type, Union
|
||||||
|
|
||||||
|
from boltons.typeutils import classproperty
|
||||||
|
from boltons.urlutils import URL as BoltonsUrl
|
||||||
|
from ereuse_utils import if_none_return_none
|
||||||
|
from flask_sqlalchemy import BaseQuery
|
||||||
|
from flask_sqlalchemy import Model as _Model
|
||||||
|
from flask_sqlalchemy import SignallingSession
|
||||||
|
from flask_sqlalchemy import SQLAlchemy as FlaskSQLAlchemy
|
||||||
|
from sqlalchemy import CheckConstraint, SmallInteger, cast, event, types
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, INET
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
|
||||||
|
from sqlalchemy_utils import Ltree
|
||||||
|
from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceNotFound(NotFound):
|
||||||
|
# todo show id
|
||||||
|
def __init__(self, resource: str) -> None:
|
||||||
|
super().__init__('The {} doesn\'t exist.'.format(resource))
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleResourcesFound(UnprocessableEntity):
|
||||||
|
# todo show id
|
||||||
|
def __init__(self, resource: str) -> None:
|
||||||
|
super().__init__(
|
||||||
|
'Expected only one {} but multiple where found'.format(resource)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
POLYMORPHIC_ID = 'polymorphic_identity'
|
||||||
|
POLYMORPHIC_ON = 'polymorphic_on'
|
||||||
|
INHERIT_COND = 'inherit_condition'
|
||||||
|
DEFAULT_CASCADE = 'save-update, merge'
|
||||||
|
CASCADE_DEL = '{}, delete'.format(DEFAULT_CASCADE)
|
||||||
|
CASCADE_OWN = '{}, delete-orphan'.format(CASCADE_DEL)
|
||||||
|
DB_CASCADE_SET_NULL = 'SET NULL'
|
||||||
|
|
||||||
|
|
||||||
|
class Query(BaseQuery):
|
||||||
|
def one(self):
|
||||||
|
try:
|
||||||
|
return super().one()
|
||||||
|
except NoResultFound:
|
||||||
|
raise ResourceNotFound(self._entities[0]._label_name)
|
||||||
|
except MultipleResultsFound:
|
||||||
|
raise MultipleResourcesFound(self._entities[0]._label_name)
|
||||||
|
|
||||||
|
|
||||||
|
class Model(_Model):
|
||||||
|
# Just provide typing
|
||||||
|
query_class = Query # type: Type[Query]
|
||||||
|
query = None # type: Query
|
||||||
|
|
||||||
|
@classproperty
|
||||||
|
def t(cls):
|
||||||
|
return cls.__name__
|
||||||
|
|
||||||
|
|
||||||
|
class Session(SignallingSession):
|
||||||
|
"""A SQLAlchemy session that raises better exceptions."""
|
||||||
|
|
||||||
|
def _flush(self, objects=None):
|
||||||
|
try:
|
||||||
|
super()._flush(objects)
|
||||||
|
except IntegrityError as e:
|
||||||
|
raise DBError(e) # This creates a suitable subclass
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaSession(Session):
|
||||||
|
"""Session that is configured to use a PostgreSQL's Schema.
|
||||||
|
|
||||||
|
Idea from `here <https://stackoverflow.com/a/9299021>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db, autocommit=False, autoflush=True, **options):
|
||||||
|
super().__init__(db, autocommit, autoflush, **options)
|
||||||
|
self.execute('SET search_path TO {}, public'.format(self.app.schema))
|
||||||
|
|
||||||
|
|
||||||
|
class StrictVersionType(types.TypeDecorator):
|
||||||
|
"""StrictVersion support for SQLAlchemy as Unicode.
|
||||||
|
|
||||||
|
Idea `from official documentation <http://docs.sqlalchemy.org/en/
|
||||||
|
latest/core/custom_types.html#augmenting-existing-types>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
impl = types.Unicode
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return StrictVersion(value)
|
||||||
|
|
||||||
|
|
||||||
|
class URL(types.TypeDecorator):
|
||||||
|
"""bolton's URL support for SQLAlchemy as Unicode."""
|
||||||
|
|
||||||
|
impl = types.Unicode
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value: BoltonsUrl, dialect):
|
||||||
|
return value.to_text()
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return BoltonsUrl(value)
|
||||||
|
|
||||||
|
|
||||||
|
class IP(types.TypeDecorator):
|
||||||
|
"""ipaddress support for SQLAlchemy as PSQL INET."""
|
||||||
|
|
||||||
|
impl = INET
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return ipaddress.ip_address(value)
|
||||||
|
|
||||||
|
|
||||||
|
class IntEnum(types.TypeDecorator):
|
||||||
|
"""SmallInteger -- IntEnum"""
|
||||||
|
|
||||||
|
impl = SmallInteger
|
||||||
|
|
||||||
|
def __init__(self, enumeration: Type[enum.IntEnum], *args, **kwargs):
|
||||||
|
self.enum = enumeration
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_bind_param(self, value, dialect):
|
||||||
|
assert isinstance(value, self.enum), 'Value should be instance of {}'.format(
|
||||||
|
self.enum
|
||||||
|
)
|
||||||
|
return value.value
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def process_result_value(self, value, dialect):
|
||||||
|
return self.enum(value)
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDLtree(Ltree):
|
||||||
|
"""This Ltree only wants UUIDs as paths elements."""
|
||||||
|
|
||||||
|
def __init__(self, path_or_ltree: Union[Ltree, uuid.UUID]):
|
||||||
|
"""
|
||||||
|
Creates a new Ltree. If the passed-in value is an UUID,
|
||||||
|
it automatically generates a suitable string for Ltree.
|
||||||
|
"""
|
||||||
|
if not isinstance(path_or_ltree, Ltree):
|
||||||
|
if isinstance(path_or_ltree, uuid.UUID):
|
||||||
|
path_or_ltree = self.convert(path_or_ltree)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
'Ltree does not accept {}'.format(path_or_ltree.__class__)
|
||||||
|
)
|
||||||
|
super().__init__(path_or_ltree)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def convert(id: uuid.UUID) -> str:
|
||||||
|
"""Transforms an uuid to a ready-to-ltree str representation."""
|
||||||
|
return str(id).replace('-', '_')
|
||||||
|
|
||||||
|
|
||||||
|
def check_range(column: str, min=1, max=None) -> CheckConstraint:
|
||||||
|
"""Database constraint for ranged values."""
|
||||||
|
constraint = (
|
||||||
|
'>= {}'.format(min) if max is None else 'BETWEEN {} AND {}'.format(min, max)
|
||||||
|
)
|
||||||
|
return CheckConstraint('{} {}'.format(column, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def check_lower(field_name: str):
|
||||||
|
"""Constraint that checks if the string is lower case."""
|
||||||
|
return CheckConstraint(
|
||||||
|
'{0} = lower({0})'.format(field_name),
|
||||||
|
name='{} must be lower'.format(field_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArrayOfEnum(ARRAY):
|
||||||
|
"""
|
||||||
|
Allows to use Arrays of Enums for psql.
|
||||||
|
|
||||||
|
From `the docs <http://docs.sqlalchemy.org/en/latest/dialects/
|
||||||
|
postgresql.html?highlight=array#postgresql-array-of-enum>`_
|
||||||
|
and `this issue <https://bitbucket.org/zzzeek/sqlalchemy/issues/
|
||||||
|
3467/array-of-enums-does-not-allow-assigning>`_.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def bind_expression(self, bindvalue):
|
||||||
|
return cast(bindvalue, self)
|
||||||
|
|
||||||
|
def result_processor(self, dialect, coltype):
|
||||||
|
super_rp = super(ArrayOfEnum, self).result_processor(dialect, coltype)
|
||||||
|
|
||||||
|
def handle_raw_string(value):
|
||||||
|
inner = re.match(r'^{(.*)}$', value).group(1)
|
||||||
|
return inner.split(',') if inner else []
|
||||||
|
|
||||||
|
def process(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return super_rp(handle_raw_string(value))
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
class SQLAlchemy(FlaskSQLAlchemy):
|
||||||
|
"""
|
||||||
|
Enhances :class:`flask_sqlalchemy.SQLAlchemy` by adding our
|
||||||
|
Session and Model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
StrictVersionType = StrictVersionType
|
||||||
|
URL = URL
|
||||||
|
IP = IP
|
||||||
|
IntEnum = IntEnum
|
||||||
|
UUIDLtree = UUIDLtree
|
||||||
|
ArrayOfEnum = ArrayOfEnum
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app=None,
|
||||||
|
use_native_unicode=True,
|
||||||
|
session_options=None,
|
||||||
|
metadata=None,
|
||||||
|
query_class=BaseQuery,
|
||||||
|
model_class=Model,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app, use_native_unicode, session_options, metadata, query_class, model_class
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_session(self, options):
|
||||||
|
"""As parent's create_session but adding our Session."""
|
||||||
|
return sessionmaker(class_=Session, db=self, **options)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaSQLAlchemy(SQLAlchemy):
|
||||||
|
"""
|
||||||
|
Enhances :class:`flask_sqlalchemy.SQLAlchemy` by using PostgreSQL's
|
||||||
|
schemas when creating/dropping tables.
|
||||||
|
|
||||||
|
See :attr:`teal.config.SCHEMA` for more info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app=None,
|
||||||
|
use_native_unicode=True,
|
||||||
|
session_options=None,
|
||||||
|
metadata=None,
|
||||||
|
query_class=Query,
|
||||||
|
model_class=Model,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
app, use_native_unicode, session_options, metadata, query_class, model_class
|
||||||
|
)
|
||||||
|
# The following listeners set psql's search_path to the correct
|
||||||
|
# schema and create the schemas accordingly
|
||||||
|
|
||||||
|
# Specifically:
|
||||||
|
# 1. Creates the schemas and set ``search_path`` to app's config SCHEMA
|
||||||
|
event.listen(self.metadata, 'before_create', self.create_schemas)
|
||||||
|
# Set ``search_path`` to default (``public``)
|
||||||
|
event.listen(self.metadata, 'after_create', self.revert_connection)
|
||||||
|
# Set ``search_path`` to app's config SCHEMA
|
||||||
|
event.listen(self.metadata, 'before_drop', self.set_search_path)
|
||||||
|
# Set ``search_path`` to default (``public``)
|
||||||
|
event.listen(self.metadata, 'after_drop', self.revert_connection)
|
||||||
|
|
||||||
|
def create_all(self, bind='__all__', app=None, exclude_schema=None):
|
||||||
|
"""Create all tables.
|
||||||
|
|
||||||
|
:param exclude_schema: Do not create tables in this schema.
|
||||||
|
"""
|
||||||
|
app = self.get_app(app)
|
||||||
|
# todo how to pass exclude_schema without contaminating self?
|
||||||
|
self._exclude_schema = exclude_schema
|
||||||
|
super().create_all(bind, app)
|
||||||
|
|
||||||
|
def _execute_for_all_tables(self, app, bind, operation, skip_tables=False):
|
||||||
|
# todo how to pass app to our event listeners without contaminating self?
|
||||||
|
self._app = self.get_app(app)
|
||||||
|
super()._execute_for_all_tables(app, bind, operation, skip_tables)
|
||||||
|
|
||||||
|
def get_tables_for_bind(self, bind=None):
|
||||||
|
"""As super method, but only getting tales that are not
|
||||||
|
part of exclude_schema, if set.
|
||||||
|
"""
|
||||||
|
tables = super().get_tables_for_bind(bind)
|
||||||
|
if getattr(self, '_exclude_schema', None):
|
||||||
|
tables = [t for t in tables if t.schema != self._exclude_schema]
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def create_schemas(self, target, connection, **kw):
|
||||||
|
"""
|
||||||
|
Create the schemas and set the active schema.
|
||||||
|
|
||||||
|
From `here <https://bitbucket.org/zzzeek/sqlalchemy/issues/3914/
|
||||||
|
extend-create_all-drop_all-to-include#comment-40129850>`_.
|
||||||
|
"""
|
||||||
|
schemas = set(table.schema for table in target.tables.values() if table.schema)
|
||||||
|
if self._app.schema:
|
||||||
|
schemas.add(self._app.schema)
|
||||||
|
for schema in schemas:
|
||||||
|
connection.execute('CREATE SCHEMA IF NOT EXISTS {}'.format(schema))
|
||||||
|
self.set_search_path(target, connection)
|
||||||
|
|
||||||
|
def set_search_path(self, _, connection, **kw):
|
||||||
|
app = self.get_app()
|
||||||
|
if app.schema:
|
||||||
|
connection.execute('SET search_path TO {}, public'.format(app.schema))
|
||||||
|
|
||||||
|
def revert_connection(self, _, connection, **kw):
|
||||||
|
connection.execute('SET search_path TO public')
|
||||||
|
|
||||||
|
def create_session(self, options):
|
||||||
|
"""As parent's create_session but adding our SchemaSession."""
|
||||||
|
return sessionmaker(class_=SchemaSession, db=self, **options)
|
||||||
|
|
||||||
|
def drop_schema(self, app=None, schema=None):
|
||||||
|
"""Nukes a schema and everything that depends on it."""
|
||||||
|
app = self.get_app(app)
|
||||||
|
schema = schema or app.schema
|
||||||
|
with self.engine.begin() as conn:
|
||||||
|
conn.execute('DROP SCHEMA IF EXISTS {} CASCADE'.format(schema))
|
||||||
|
|
||||||
|
def has_schema(self, schema: str) -> bool:
|
||||||
|
"""Does the db have the passed-in schema?"""
|
||||||
|
return self.engine.execute(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_namespace WHERE nspname='{}')".format(
|
||||||
|
schema
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
class DBError(BadRequest):
|
||||||
|
"""An Error from the database.
|
||||||
|
|
||||||
|
This helper error is used to map SQLAlchemy's IntegrityError
|
||||||
|
to more precise errors (like UniqueViolation) that are understood
|
||||||
|
as a client-ready HTTP Error.
|
||||||
|
|
||||||
|
When instantiating the class it auto-selects the best error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, origin: IntegrityError):
|
||||||
|
super().__init__(str(origin))
|
||||||
|
self._origin = origin
|
||||||
|
|
||||||
|
def __new__(cls, origin: IntegrityError) -> Any:
|
||||||
|
msg = str(origin)
|
||||||
|
if 'unique constraint' in msg.lower():
|
||||||
|
return super().__new__(UniqueViolation)
|
||||||
|
return super().__new__(cls)
|
||||||
|
|
||||||
|
|
||||||
|
class UniqueViolation(DBError):
|
||||||
|
def __init__(self, origin: IntegrityError):
|
||||||
|
super().__init__(origin)
|
||||||
|
self.constraint = self.description.split('"')[1]
|
||||||
|
self.field_name = None
|
||||||
|
self.field_value = None
|
||||||
|
if isinstance(origin.params, dict):
|
||||||
|
self.field_name, self.field_value = next(
|
||||||
|
(k, v) for k, v in origin.params.items() if k in self.constraint
|
||||||
|
)
|
4421
ereuse_devicehub/teal/enums.py
Normal file
4421
ereuse_devicehub/teal/enums.py
Normal file
File diff suppressed because it is too large
Load diff
11
ereuse_devicehub/teal/json_util.py
Normal file
11
ereuse_devicehub/teal/json_util.py
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import ereuse_utils
|
||||||
|
from flask.json import JSONEncoder as FlaskJSONEncoder
|
||||||
|
from sqlalchemy.ext.baked import Result
|
||||||
|
from sqlalchemy.orm import Query
|
||||||
|
|
||||||
|
|
||||||
|
class TealJSONEncoder(ereuse_utils.JSONEncoder, FlaskJSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, (Result, Query)):
|
||||||
|
return tuple(obj)
|
||||||
|
return super().default(obj)
|
346
ereuse_devicehub/teal/marshmallow.py
Normal file
346
ereuse_devicehub/teal/marshmallow.py
Normal file
|
@ -0,0 +1,346 @@
|
||||||
|
import ipaddress
|
||||||
|
from distutils.version import StrictVersion
|
||||||
|
from typing import Type, Union
|
||||||
|
|
||||||
|
import colour
|
||||||
|
from boltons import strutils, urlutils
|
||||||
|
from ereuse_utils import if_none_return_none
|
||||||
|
from flask import current_app as app
|
||||||
|
from flask import g
|
||||||
|
from marshmallow import utils
|
||||||
|
from marshmallow.fields import Field
|
||||||
|
from marshmallow.fields import Nested as MarshmallowNested
|
||||||
|
from marshmallow.fields import String
|
||||||
|
from marshmallow.fields import ValidationError as _ValidationError
|
||||||
|
from marshmallow.fields import missing_
|
||||||
|
from marshmallow.validate import Validator
|
||||||
|
from marshmallow_enum import EnumField as _EnumField
|
||||||
|
from sqlalchemy_utils import PhoneNumber
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal import db as tealdb
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class Version(Field):
|
||||||
|
"""A python StrictVersion field, like '1.0.1'."""
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _serialize(self, value, attr, obj):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
return StrictVersion(value)
|
||||||
|
|
||||||
|
|
||||||
|
class Color(Field):
|
||||||
|
"""Any color field that can be accepted by the colour package."""
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _serialize(self, value, attr, obj):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
return colour.Color(value)
|
||||||
|
|
||||||
|
|
||||||
|
class URL(Field):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
require_path=False,
|
||||||
|
default=missing_,
|
||||||
|
attribute=None,
|
||||||
|
data_key=None,
|
||||||
|
error=None,
|
||||||
|
validate=None,
|
||||||
|
required=False,
|
||||||
|
allow_none=None,
|
||||||
|
load_only=False,
|
||||||
|
dump_only=False,
|
||||||
|
missing=missing_,
|
||||||
|
error_messages=None,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
default,
|
||||||
|
attribute,
|
||||||
|
data_key,
|
||||||
|
error,
|
||||||
|
validate,
|
||||||
|
required,
|
||||||
|
allow_none,
|
||||||
|
load_only,
|
||||||
|
dump_only,
|
||||||
|
missing,
|
||||||
|
error_messages,
|
||||||
|
**metadata,
|
||||||
|
)
|
||||||
|
self.require_path = require_path
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _serialize(self, value, attr, obj):
|
||||||
|
return value.to_text()
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
url = urlutils.URL(value)
|
||||||
|
if url.scheme or url.host:
|
||||||
|
if self.require_path:
|
||||||
|
if url.path and url.path != '/':
|
||||||
|
return url
|
||||||
|
else:
|
||||||
|
return url
|
||||||
|
raise ValueError('Not a valid URL.')
|
||||||
|
|
||||||
|
|
||||||
|
class IP(Field):
|
||||||
|
@if_none_return_none
|
||||||
|
def _serialize(
|
||||||
|
self, value: Union[ipaddress.IPv4Address, ipaddress.IPv6Address], attr, obj
|
||||||
|
):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _deserialize(self, value: str, attr, data):
|
||||||
|
return ipaddress.ip_address(value)
|
||||||
|
|
||||||
|
|
||||||
|
class Phone(Field):
|
||||||
|
@if_none_return_none
|
||||||
|
def _serialize(self, value: PhoneNumber, attr, obj):
|
||||||
|
return value.international
|
||||||
|
|
||||||
|
@if_none_return_none
|
||||||
|
def _deserialize(self, value: str, attr, data):
|
||||||
|
phone = PhoneNumber(value)
|
||||||
|
if not phone.is_valid_number():
|
||||||
|
raise ValueError('The phone number is invalid.')
|
||||||
|
return phone
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizedStr(String):
|
||||||
|
"""String field that only has regular user strings.
|
||||||
|
|
||||||
|
A String that removes whitespaces,
|
||||||
|
optionally makes it lower, and invalidates HTML or ANSI codes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
lower=False,
|
||||||
|
default=missing_,
|
||||||
|
attribute=None,
|
||||||
|
data_key=None,
|
||||||
|
error=None,
|
||||||
|
validate=None,
|
||||||
|
required=False,
|
||||||
|
allow_none=None,
|
||||||
|
load_only=False,
|
||||||
|
dump_only=False,
|
||||||
|
missing=missing_,
|
||||||
|
error_messages=None,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
default,
|
||||||
|
attribute,
|
||||||
|
data_key,
|
||||||
|
error,
|
||||||
|
validate,
|
||||||
|
required,
|
||||||
|
allow_none,
|
||||||
|
load_only,
|
||||||
|
dump_only,
|
||||||
|
missing,
|
||||||
|
error_messages,
|
||||||
|
**metadata,
|
||||||
|
)
|
||||||
|
self.lower = lower
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
out = super()._deserialize(value, attr, data)
|
||||||
|
out = out.strip()
|
||||||
|
if self.lower:
|
||||||
|
out = out.lower()
|
||||||
|
if strutils.html2text(out) != out:
|
||||||
|
self.fail('invalid')
|
||||||
|
elif strutils.strip_ansi(out) != out:
|
||||||
|
self.fail('invalid')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class NestedOn(MarshmallowNested):
|
||||||
|
"""A relationship with a resource schema that emulates the
|
||||||
|
relationships in SQLAlchemy.
|
||||||
|
|
||||||
|
It allows instantiating SQLA models when deserializing NestedOn
|
||||||
|
values in two fashions:
|
||||||
|
|
||||||
|
- If the :attr:`.only_query` is set, NestedOn expects a scalar
|
||||||
|
(str, int...) value when deserializing, and tries to get
|
||||||
|
an existing model that has such value. Typical case is setting
|
||||||
|
:attr:`.only_query` to ``id``, and then pass-in the id
|
||||||
|
of a nested model. In such case NestedOn will change the id
|
||||||
|
for the model representing the ID.
|
||||||
|
- If :attr:`.only_query` is not set, NestedOn expects the
|
||||||
|
value to deserialize to be a dictionary, and instantiates
|
||||||
|
the model with the values of the dictionary. In this case
|
||||||
|
NestedOn requires :attr:`.polymorphic_on` to be set as a field,
|
||||||
|
usually called ``type``, that references a subclass of Model;
|
||||||
|
ex. {'type': 'SpecificDevice', ...}.
|
||||||
|
|
||||||
|
When serializing from :meth:`teal.resource.Schema.jsonify` it
|
||||||
|
serializes nested relationships up to a defined limit.
|
||||||
|
|
||||||
|
:param polymorphic_on: The field name that discriminates
|
||||||
|
the type of object. For example ``type``.
|
||||||
|
Then ``type`` contains the class name
|
||||||
|
of a subschema of ``nested``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NESTED_LEVEL = '_level'
|
||||||
|
NESTED_LEVEL_MAX = '_level_max'
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nested,
|
||||||
|
polymorphic_on: str,
|
||||||
|
db: tealdb.SQLAlchemy,
|
||||||
|
collection_class=list,
|
||||||
|
default=missing_,
|
||||||
|
exclude=tuple(),
|
||||||
|
only_query: str = None,
|
||||||
|
only=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.polymorphic_on = polymorphic_on
|
||||||
|
self.collection_class = collection_class
|
||||||
|
self.only_query = only_query
|
||||||
|
assert isinstance(polymorphic_on, str)
|
||||||
|
assert isinstance(only, str) or only is None
|
||||||
|
super().__init__(nested, default, exclude, only, **kwargs)
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
if self.many and not utils.is_collection(value):
|
||||||
|
self.fail('type', input=value, type=value.__class__.__name__)
|
||||||
|
|
||||||
|
if isinstance(self.only, str): # self.only is a field name
|
||||||
|
if self.many:
|
||||||
|
value = self.collection_class({self.only: v} for v in value)
|
||||||
|
else:
|
||||||
|
value = {self.only: value}
|
||||||
|
# New code:
|
||||||
|
parent_schema = app.resources[super().schema.t].SCHEMA
|
||||||
|
if self.many:
|
||||||
|
return self.collection_class(
|
||||||
|
self._deserialize_one(single, parent_schema, attr) for single in value
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return self._deserialize_one(value, parent_schema, attr)
|
||||||
|
|
||||||
|
def _deserialize_one(self, value, parent_schema: Type[Schema], attr):
|
||||||
|
if isinstance(value, dict) and self.polymorphic_on in value:
|
||||||
|
type = value[self.polymorphic_on]
|
||||||
|
resource = app.resources[type]
|
||||||
|
if not issubclass(resource.SCHEMA, parent_schema):
|
||||||
|
raise ValidationError(
|
||||||
|
'{} is not a sub-type of {}'.format(type, parent_schema.t),
|
||||||
|
field_names=[attr],
|
||||||
|
)
|
||||||
|
schema = resource.SCHEMA(
|
||||||
|
only=self.only,
|
||||||
|
exclude=self.exclude,
|
||||||
|
context=getattr(self.parent, 'context', {}),
|
||||||
|
load_only=self._nested_normalized_option('load_only'),
|
||||||
|
dump_only=self._nested_normalized_option('dump_only'),
|
||||||
|
)
|
||||||
|
schema.ordered = getattr(self.parent, 'ordered', False)
|
||||||
|
value = schema.load(value)
|
||||||
|
model = self._model(type)(**value)
|
||||||
|
elif self.only_query: # todo test only_query
|
||||||
|
model = (
|
||||||
|
self._model(parent_schema.t)
|
||||||
|
.query.filter_by(**{self.only_query: value})
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValidationError(
|
||||||
|
'\'Type\' field required to disambiguate resources.', field_names=[attr]
|
||||||
|
)
|
||||||
|
assert isinstance(model, tealdb.Model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
def _model(self, type: str) -> Type[tealdb.Model]:
|
||||||
|
"""Given the type of a model it returns the model class."""
|
||||||
|
return self.db.Model._decl_class_registry.data[type]()
|
||||||
|
|
||||||
|
def serialize(self, attr: str, obj, accessor=None) -> dict:
|
||||||
|
"""See class docs."""
|
||||||
|
if g.get(NestedOn.NESTED_LEVEL) == g.get(NestedOn.NESTED_LEVEL_MAX):
|
||||||
|
# Idea from https://marshmallow-sqlalchemy.readthedocs.io
|
||||||
|
# /en/latest/recipes.html#smart-nested-field
|
||||||
|
# Gets the FK of the relationship instead of the full object
|
||||||
|
# This won't work for many-many relationships (as they are lists)
|
||||||
|
# In such case return None
|
||||||
|
# todo is this the behaviour we want?
|
||||||
|
return getattr(obj, attr + '_id', None)
|
||||||
|
setattr(g, NestedOn.NESTED_LEVEL, g.get(NestedOn.NESTED_LEVEL) + 1)
|
||||||
|
ret = super().serialize(attr, obj, accessor)
|
||||||
|
setattr(g, NestedOn.NESTED_LEVEL, g.get(NestedOn.NESTED_LEVEL) - 1)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class IsType(Validator):
|
||||||
|
"""
|
||||||
|
Validator which succeeds if the value it is passed is a registered
|
||||||
|
resource type.
|
||||||
|
|
||||||
|
:param parent: If set, type must be a subtype of such resource.
|
||||||
|
By default accept any resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# todo remove if not needed
|
||||||
|
no_type = 'Type does not exist.'
|
||||||
|
no_subtype = 'Type is not a descendant type of {parent}'
|
||||||
|
|
||||||
|
def __init__(self, parent: str = None) -> None:
|
||||||
|
self.parent = parent # type: str
|
||||||
|
|
||||||
|
def _repr_args(self):
|
||||||
|
return 'parent={0!r}'.format(self.parent)
|
||||||
|
|
||||||
|
def __call__(self, type: str):
|
||||||
|
assert not self.parent or self.parent in app.resources
|
||||||
|
try:
|
||||||
|
r = app.resources[type]
|
||||||
|
if self.parent:
|
||||||
|
if not issubclass(r.__class__, app.resources[self.parent].__class__):
|
||||||
|
raise ValidationError(self.no_subtype.format(self.parent))
|
||||||
|
except KeyError:
|
||||||
|
raise ValidationError(self.no_type)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(_ValidationError):
|
||||||
|
code = 422
|
||||||
|
|
||||||
|
|
||||||
|
class EnumField(_EnumField):
|
||||||
|
"""
|
||||||
|
An EnumField that allows
|
||||||
|
generating OpenApi enums through Apispec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
enum,
|
||||||
|
by_value=False,
|
||||||
|
load_by=None,
|
||||||
|
dump_by=None,
|
||||||
|
error='',
|
||||||
|
*args,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
super().__init__(enum, by_value, load_by, dump_by, error, *args, **kwargs)
|
||||||
|
self.metadata['enum'] = [e.name for e in enum]
|
294
ereuse_devicehub/teal/query.py
Normal file
294
ereuse_devicehub/teal/query.py
Normal file
|
@ -0,0 +1,294 @@
|
||||||
|
import json
|
||||||
|
from json import JSONDecodeError
|
||||||
|
|
||||||
|
from ereuse_utils import flatten_mixed
|
||||||
|
from marshmallow import Schema as MarshmallowSchema
|
||||||
|
from marshmallow.fields import Boolean, Field, List, Nested, Str, missing_
|
||||||
|
from sqlalchemy import Column, between, or_
|
||||||
|
from webargs.flaskparser import FlaskParser
|
||||||
|
|
||||||
|
|
||||||
|
class ListQuery(List):
|
||||||
|
"""Base class for list-based queries."""
|
||||||
|
|
||||||
|
def __init__(self, column: Column, cls_or_instance, **kwargs):
|
||||||
|
self.column = column
|
||||||
|
super().__init__(cls_or_instance, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Between(ListQuery):
|
||||||
|
"""
|
||||||
|
Generates a `Between` SQL statement.
|
||||||
|
|
||||||
|
This method wants the user to provide exactly two parameters:
|
||||||
|
min and max::
|
||||||
|
|
||||||
|
f = Between(Model.foo, Integer())
|
||||||
|
...
|
||||||
|
Query().loads({'f': [0, 100]}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
l = super()._deserialize(value, attr, data)
|
||||||
|
return between(self.column, *l)
|
||||||
|
|
||||||
|
|
||||||
|
class Equal(Field):
|
||||||
|
"""
|
||||||
|
Generates an SQL equal ``==`` clause for a given column and value::
|
||||||
|
|
||||||
|
class MyArgs(Query):
|
||||||
|
f = Equal(MyModel.foo, Integer())
|
||||||
|
MyArgs().load({'f': 24}) -> SQL: ``MyModel.foo == 24``
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
column: Column,
|
||||||
|
field: Field,
|
||||||
|
default=missing_,
|
||||||
|
attribute=None,
|
||||||
|
data_key=None,
|
||||||
|
error=None,
|
||||||
|
validate=None,
|
||||||
|
required=False,
|
||||||
|
allow_none=None,
|
||||||
|
load_only=False,
|
||||||
|
dump_only=False,
|
||||||
|
missing=missing_,
|
||||||
|
error_messages=None,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
default,
|
||||||
|
attribute,
|
||||||
|
data_key,
|
||||||
|
error,
|
||||||
|
validate,
|
||||||
|
required,
|
||||||
|
allow_none,
|
||||||
|
load_only,
|
||||||
|
dump_only,
|
||||||
|
missing,
|
||||||
|
error_messages,
|
||||||
|
**metadata,
|
||||||
|
)
|
||||||
|
self.column = column
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
v = super()._deserialize(value, attr, data)
|
||||||
|
return self.column == self.field.deserialize(v)
|
||||||
|
|
||||||
|
|
||||||
|
class Or(List):
|
||||||
|
"""
|
||||||
|
Generates an `OR` SQL statement. This is like a Marshmallow List field,
|
||||||
|
so you can specify the type of value of the OR and validations.
|
||||||
|
|
||||||
|
As an example, you can define with this a list of options::
|
||||||
|
|
||||||
|
f = Or(Equal(Model.foo, Str(validates=OneOf(['option1', 'option2'])))
|
||||||
|
|
||||||
|
Where the user can select one or more::
|
||||||
|
|
||||||
|
{'f': ['option1']}
|
||||||
|
|
||||||
|
And with ``Length`` you can enforce the user to only choose one option::
|
||||||
|
|
||||||
|
f = Or(..., validates=Length(equal=1))
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
l = super()._deserialize(value, attr, data)
|
||||||
|
return or_(v for v in l)
|
||||||
|
|
||||||
|
|
||||||
|
class ILike(Str):
|
||||||
|
"""
|
||||||
|
Generates a insensitive `LIKE` statement for strings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
column: Column,
|
||||||
|
default=missing_,
|
||||||
|
attribute=None,
|
||||||
|
data_key=None,
|
||||||
|
error=None,
|
||||||
|
validate=None,
|
||||||
|
required=False,
|
||||||
|
allow_none=None,
|
||||||
|
load_only=False,
|
||||||
|
dump_only=False,
|
||||||
|
missing=missing_,
|
||||||
|
error_messages=None,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
default,
|
||||||
|
attribute,
|
||||||
|
data_key,
|
||||||
|
error,
|
||||||
|
validate,
|
||||||
|
required,
|
||||||
|
allow_none,
|
||||||
|
load_only,
|
||||||
|
dump_only,
|
||||||
|
missing,
|
||||||
|
error_messages,
|
||||||
|
**metadata,
|
||||||
|
)
|
||||||
|
self.column = column
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
v = super()._deserialize(value, attr, data)
|
||||||
|
return self.column.ilike('{}%'.format(v))
|
||||||
|
|
||||||
|
|
||||||
|
class QueryField(Field):
|
||||||
|
"""A field whose first parameter is a function that when
|
||||||
|
executed by passing only the value returns a SQLAlchemy query
|
||||||
|
expression.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
query,
|
||||||
|
field: Field,
|
||||||
|
default=missing_,
|
||||||
|
attribute=None,
|
||||||
|
data_key=None,
|
||||||
|
error=None,
|
||||||
|
validate=None,
|
||||||
|
required=False,
|
||||||
|
allow_none=None,
|
||||||
|
load_only=False,
|
||||||
|
dump_only=False,
|
||||||
|
missing=missing_,
|
||||||
|
error_messages=None,
|
||||||
|
**metadata,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
default,
|
||||||
|
attribute,
|
||||||
|
data_key,
|
||||||
|
error,
|
||||||
|
validate,
|
||||||
|
required,
|
||||||
|
allow_none,
|
||||||
|
load_only,
|
||||||
|
dump_only,
|
||||||
|
missing,
|
||||||
|
error_messages,
|
||||||
|
**metadata,
|
||||||
|
)
|
||||||
|
self.query = query
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
v = super()._deserialize(value, attr, data)
|
||||||
|
return self.query(v)
|
||||||
|
|
||||||
|
|
||||||
|
class Join(Nested):
|
||||||
|
# todo Joins are manual: they should be able to use ORM's join
|
||||||
|
def __init__(
|
||||||
|
self, join, nested, default=missing_, exclude=tuple(), only=None, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(nested, default, exclude, only, **kwargs)
|
||||||
|
self.join = join
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
v = list(super()._deserialize(value, attr, data))
|
||||||
|
v.append(self.join)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class Query(MarshmallowSchema):
|
||||||
|
"""
|
||||||
|
A Marshmallow schema that outputs SQLAlchemy queries when ``loading``
|
||||||
|
dictionaries::
|
||||||
|
|
||||||
|
class MyQuery(Query):
|
||||||
|
foo = Like(Mymodel.foocolumn)
|
||||||
|
|
||||||
|
Mymodel.query.filter(*MyQuery().load({'foo': 'bar'})).all()
|
||||||
|
# Executes query SELECT ... WHERE foocolumn IS LIKE 'bar%'
|
||||||
|
|
||||||
|
When used with ``webargs`` library you can pass generate queries
|
||||||
|
directly from the browser: ``foo.com/foo/?filter={'foo': 'bar'}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def load(self, data, many=None, partial=None):
|
||||||
|
"""
|
||||||
|
Flatten ``Nested`` ``Query`` and add the list of results to
|
||||||
|
a SQL ``AND``.
|
||||||
|
"""
|
||||||
|
values = super().load(data, many, partial).values()
|
||||||
|
return flatten_mixed(values)
|
||||||
|
|
||||||
|
def dump(self, obj, many=None, update_fields=True):
|
||||||
|
raise NotImplementedError('Why would you want to dump a query?')
|
||||||
|
|
||||||
|
|
||||||
|
class Sort(MarshmallowSchema):
|
||||||
|
"""
|
||||||
|
A Marshmallow schema that outputs SQLAlchemy order clauses::
|
||||||
|
|
||||||
|
class MySort(Sort):
|
||||||
|
foo = SortField(MyModel.foocolumn)
|
||||||
|
MyModel.query.filter(...).order_by(*MyQuery().load({'foo': 0})).all()
|
||||||
|
|
||||||
|
When used with ``webargs`` library you can pass generate sorts
|
||||||
|
directly from the browser: ``foo.com/foo/?sort={'foo': 1, 'bar': 0}``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ASCENDING = True
|
||||||
|
"""Sort in ascending order."""
|
||||||
|
DESCENDING = False
|
||||||
|
"""Sort in descending order."""
|
||||||
|
|
||||||
|
def load(self, data, many=None, partial=None):
|
||||||
|
values = super().load(data, many, partial).values()
|
||||||
|
return flatten_mixed(values)
|
||||||
|
|
||||||
|
|
||||||
|
class SortField(Boolean):
|
||||||
|
"""A field that outputs a SQLAlchemy order clause."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, column: Column, truthy=Boolean.truthy, falsy=Boolean.falsy, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(truthy, falsy, **kwargs)
|
||||||
|
self.column = column
|
||||||
|
|
||||||
|
def _deserialize(self, value, attr, data):
|
||||||
|
v = super()._deserialize(value, attr, data)
|
||||||
|
return self.column.asc() if v else self.column.desc()
|
||||||
|
|
||||||
|
|
||||||
|
class NestedQueryFlaskParser(FlaskParser):
|
||||||
|
"""
|
||||||
|
Parses JSON-encoded URL parameters like
|
||||||
|
``.../foo?param={"x": "y"}¶m2=["x", "y"]``, and it still allows
|
||||||
|
normal non-JSON-encoded params ``../foo?param=23¶m2={"a": "b"}``.
|
||||||
|
|
||||||
|
You can keep a value always a string, regardless if it is a valid
|
||||||
|
JSON, by overriding the following method and setting per-case
|
||||||
|
actions by checking `name` property.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def parse_querystring(self, req, name, field):
|
||||||
|
v = super().parse_querystring(req, name, field)
|
||||||
|
try:
|
||||||
|
return json.loads(v)
|
||||||
|
except (JSONDecodeError, TypeError):
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class FullTextSearch(Str):
|
||||||
|
# todo this is dummy for now
|
||||||
|
pass
|
28
ereuse_devicehub/teal/request.py
Normal file
28
ereuse_devicehub/teal/request.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from flask import Request as _Request
|
||||||
|
from flask import current_app as app
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.resource import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class Request(_Request):
|
||||||
|
def get_json(
|
||||||
|
self,
|
||||||
|
force=False,
|
||||||
|
silent=False,
|
||||||
|
cache=True,
|
||||||
|
validate=True,
|
||||||
|
schema: Schema = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
As :meth:`flask.Request.get_json` but parsing
|
||||||
|
the resulting json through passed-in ``schema`` (or by default
|
||||||
|
``g.schema``).
|
||||||
|
"""
|
||||||
|
json = super().get_json(force, silent, cache)
|
||||||
|
if validate:
|
||||||
|
json = (
|
||||||
|
schema.load(json)
|
||||||
|
if schema
|
||||||
|
else app.resources[self.blueprint].schema.load(json)
|
||||||
|
)
|
||||||
|
return json
|
429
ereuse_devicehub/teal/resource.py
Normal file
429
ereuse_devicehub/teal/resource.py
Normal file
|
@ -0,0 +1,429 @@
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Callable, Iterable, Iterator, Tuple, Type, Union
|
||||||
|
|
||||||
|
import inflection
|
||||||
|
from anytree import PreOrderIter
|
||||||
|
from boltons.typeutils import classproperty, issubclass
|
||||||
|
from ereuse_utils.naming import Naming
|
||||||
|
from flask import Blueprint, current_app, g, request, url_for
|
||||||
|
from flask.json import jsonify
|
||||||
|
from flask.views import MethodView
|
||||||
|
from marshmallow import Schema as MarshmallowSchema
|
||||||
|
from marshmallow import SchemaOpts as MarshmallowSchemaOpts
|
||||||
|
from marshmallow import ValidationError, post_dump, pre_load, validates_schema
|
||||||
|
from werkzeug.exceptions import MethodNotAllowed
|
||||||
|
from werkzeug.routing import UnicodeConverter
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal import db, query
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaOpts(MarshmallowSchemaOpts):
|
||||||
|
"""
|
||||||
|
Subclass of Marshmallow's SchemaOpts that provides
|
||||||
|
options for Teal's schemas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, meta, ordered=False):
|
||||||
|
super().__init__(meta, ordered)
|
||||||
|
self.PREFIX = meta.PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
class Schema(MarshmallowSchema):
|
||||||
|
"""
|
||||||
|
The definition of the fields of a resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
OPTIONS_CLASS = SchemaOpts
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
PREFIX = None
|
||||||
|
"""Optional. A prefix for the type; ex. devices:Computer."""
|
||||||
|
|
||||||
|
# noinspection PyMethodParameters
|
||||||
|
@classproperty
|
||||||
|
def t(cls: Type['Schema']) -> str:
|
||||||
|
"""The type for this schema, auto-computed from its name."""
|
||||||
|
name, *_ = cls.__name__.split('Schema')
|
||||||
|
return Naming.new_type(name, cls.Meta.PREFIX)
|
||||||
|
|
||||||
|
# noinspection PyMethodParameters
|
||||||
|
@classproperty
|
||||||
|
def resource(cls: Type['Schema']) -> str:
|
||||||
|
"""The resource name of this schema."""
|
||||||
|
return Naming.resource(cls.t)
|
||||||
|
|
||||||
|
@validates_schema(pass_original=True)
|
||||||
|
def check_unknown_fields(self, _, original_data: dict):
|
||||||
|
"""
|
||||||
|
Raises a validationError when user sends extra fields.
|
||||||
|
|
||||||
|
From `Marshmallow docs<http://marshmallow.readthedocs.io/en/
|
||||||
|
latest/extending.html#validating-original-input-data>`_.
|
||||||
|
"""
|
||||||
|
unknown_fields = set(original_data) - set(
|
||||||
|
f.data_key or n for n, f in self.fields.items()
|
||||||
|
)
|
||||||
|
if unknown_fields:
|
||||||
|
raise ValidationError('Unknown field', unknown_fields)
|
||||||
|
|
||||||
|
@validates_schema(pass_original=True)
|
||||||
|
def check_dump_only(self, _, orig_data: dict):
|
||||||
|
"""
|
||||||
|
Raises a ValidationError if the user is submitting
|
||||||
|
'read-only' fields.
|
||||||
|
"""
|
||||||
|
# Note that validates_schema does not execute when dumping
|
||||||
|
dump_only_fields = (
|
||||||
|
name for name, field in self.fields.items() if field.dump_only
|
||||||
|
)
|
||||||
|
non_writable = set(orig_data).intersection(dump_only_fields)
|
||||||
|
if non_writable:
|
||||||
|
raise ValidationError('Non-writable field', non_writable)
|
||||||
|
|
||||||
|
@pre_load
|
||||||
|
@post_dump
|
||||||
|
def remove_none_values(self, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Skip from dumping and loading values that are None.
|
||||||
|
|
||||||
|
A value that is None will be the same as a value that has not
|
||||||
|
been set.
|
||||||
|
|
||||||
|
`From here <https://github.com/marshmallow-code/marshmallow/
|
||||||
|
issues/229#issuecomment-134387999>`_.
|
||||||
|
"""
|
||||||
|
# Will I always want this?
|
||||||
|
# maybe this could be a setting in the future?
|
||||||
|
return {key: value for key, value in data.items() if value is not None}
|
||||||
|
|
||||||
|
def dump(
|
||||||
|
self,
|
||||||
|
model: Union['db.Model', Iterable['db.Model']],
|
||||||
|
many=None,
|
||||||
|
update_fields=True,
|
||||||
|
nested=None,
|
||||||
|
polymorphic_on='t',
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Like marshmallow's dump but with nested resource support and
|
||||||
|
it only works for Models.
|
||||||
|
|
||||||
|
This can load model relationships up to ``nested`` level. For
|
||||||
|
example, if ``nested`` is ``1`` and we pass in a model of
|
||||||
|
``User`` that has a relationship with a table of ``Post``, it
|
||||||
|
will load ``User`` and ``User.posts`` with all posts objects
|
||||||
|
populated, but it won't load relationships inside the
|
||||||
|
``Post`` object. If, at the same time the ``Post`` has
|
||||||
|
an ``author`` relationship with ``author_id`` being the FK,
|
||||||
|
``user.posts[n].author`` will be the value of ``author_id``.
|
||||||
|
|
||||||
|
Define nested fields with the
|
||||||
|
:class:`ereuse_devicehub.teal.marshmallow.NestedOn`
|
||||||
|
|
||||||
|
This method requires an active application context as it needs
|
||||||
|
to store some stuff in ``g``.
|
||||||
|
|
||||||
|
:param nested: How many layers of nested relationships to load?
|
||||||
|
By default only loads 1 nested relationship.
|
||||||
|
"""
|
||||||
|
from ereuse_devicehub.teal.marshmallow import NestedOn
|
||||||
|
|
||||||
|
if nested is not None:
|
||||||
|
setattr(g, NestedOn.NESTED_LEVEL, 0)
|
||||||
|
setattr(g, NestedOn.NESTED_LEVEL_MAX, nested)
|
||||||
|
if many:
|
||||||
|
# todo this breaks with normal dicts. Maybe this should go
|
||||||
|
# in NestedOn in the same way it happens when loading
|
||||||
|
if isinstance(model, dict):
|
||||||
|
return super().dump(model, update_fields=update_fields)
|
||||||
|
else:
|
||||||
|
return [
|
||||||
|
self._polymorphic_dump(o, update_fields, polymorphic_on)
|
||||||
|
for o in model
|
||||||
|
]
|
||||||
|
|
||||||
|
else:
|
||||||
|
if isinstance(model, dict):
|
||||||
|
return super().dump(model, update_fields=update_fields)
|
||||||
|
else:
|
||||||
|
return self._polymorphic_dump(model, update_fields, polymorphic_on)
|
||||||
|
|
||||||
|
def _polymorphic_dump(self, obj: 'db.Model', update_fields, polymorphic_on='t'):
|
||||||
|
schema = current_app.resources[getattr(obj, polymorphic_on)].schema
|
||||||
|
if schema.t != self.t:
|
||||||
|
return super(schema.__class__, schema).dump(obj, False, update_fields)
|
||||||
|
else:
|
||||||
|
return super().dump(obj, False, update_fields)
|
||||||
|
|
||||||
|
def jsonify(
|
||||||
|
self,
|
||||||
|
model: Union['db.Model', Iterable['db.Model']],
|
||||||
|
nested=1,
|
||||||
|
many=False,
|
||||||
|
update_fields: bool = True,
|
||||||
|
polymorphic_on='t',
|
||||||
|
**kw,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Like flask's jsonify but with model / marshmallow schema
|
||||||
|
support.
|
||||||
|
|
||||||
|
:param nested: How many layers of nested relationships to load?
|
||||||
|
By default only loads 1 nested relationship.
|
||||||
|
"""
|
||||||
|
return jsonify(self.dump(model, many, update_fields, nested, polymorphic_on))
|
||||||
|
|
||||||
|
|
||||||
|
class View(MethodView):
|
||||||
|
"""
|
||||||
|
A REST interface for resources.
|
||||||
|
"""
|
||||||
|
|
||||||
|
QUERY_PARSER = query.NestedQueryFlaskParser()
|
||||||
|
|
||||||
|
class FindArgs(MarshmallowSchema):
|
||||||
|
"""
|
||||||
|
Allowed arguments for the ``find``
|
||||||
|
method (GET collection) endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, definition: 'Resource', **kw) -> None:
|
||||||
|
self.resource_def = definition
|
||||||
|
"""The ResourceDefinition tied to this view."""
|
||||||
|
self.schema = None # type: Schema
|
||||||
|
"""The schema tied to this view."""
|
||||||
|
self.find_args = self.FindArgs()
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def dispatch_request(self, *args, **kwargs):
|
||||||
|
# This is unique for each view call
|
||||||
|
self.schema = g.schema
|
||||||
|
"""
|
||||||
|
The default schema in this resource.
|
||||||
|
Added as an attr for commodity; you can always use g.schema.
|
||||||
|
"""
|
||||||
|
return super().dispatch_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def get(self, id):
|
||||||
|
"""Get a collection of resources or a specific one.
|
||||||
|
---
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: The identifier of the resource.
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Return the collection or the specific one.
|
||||||
|
"""
|
||||||
|
if id:
|
||||||
|
response = self.one(id)
|
||||||
|
else:
|
||||||
|
args = self.QUERY_PARSER.parse(
|
||||||
|
self.find_args, request, locations=('querystring',)
|
||||||
|
)
|
||||||
|
response = self.find(args)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def one(self, id):
|
||||||
|
"""GET one specific resource (ex. /cars/1)."""
|
||||||
|
raise MethodNotAllowed()
|
||||||
|
|
||||||
|
def find(self, args: dict):
|
||||||
|
"""GET a list of resources (ex. /cars)."""
|
||||||
|
raise MethodNotAllowed()
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
raise MethodNotAllowed()
|
||||||
|
|
||||||
|
def delete(self, id):
|
||||||
|
raise MethodNotAllowed()
|
||||||
|
|
||||||
|
def put(self, id):
|
||||||
|
raise MethodNotAllowed()
|
||||||
|
|
||||||
|
def patch(self, id):
|
||||||
|
raise MethodNotAllowed()
|
||||||
|
|
||||||
|
|
||||||
|
class Converters(Enum):
|
||||||
|
"""An enumeration of available URL converters."""
|
||||||
|
|
||||||
|
string = 'string'
|
||||||
|
int = 'int'
|
||||||
|
float = 'float'
|
||||||
|
path = 'path'
|
||||||
|
any = 'any'
|
||||||
|
uuid = 'uuid'
|
||||||
|
lower = 'lower'
|
||||||
|
|
||||||
|
|
||||||
|
class LowerStrConverter(UnicodeConverter):
|
||||||
|
"""Like StringConverter but lowering the string."""
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
return super().to_python(value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(Blueprint):
|
||||||
|
"""
|
||||||
|
Main resource class. Defines the schema, views,
|
||||||
|
authentication, database and collection of a resource.
|
||||||
|
|
||||||
|
A ``ResourceDefinition`` is a Flask
|
||||||
|
:class:`flask.blueprints.Blueprint` that provides everything
|
||||||
|
needed to set a REST endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
VIEW = None # type: Type[View]
|
||||||
|
"""
|
||||||
|
Resource view linked to this definition or None.
|
||||||
|
If none, this resource does not generate any view.
|
||||||
|
"""
|
||||||
|
SCHEMA = Schema # type: Type[Schema]
|
||||||
|
"""The Schema that validates a submitting resource at the entry point."""
|
||||||
|
AUTH = False
|
||||||
|
"""
|
||||||
|
If true, authentication is required for all the endpoints of this
|
||||||
|
resource defined in ``VIEW``.
|
||||||
|
"""
|
||||||
|
ID_NAME = 'id'
|
||||||
|
"""
|
||||||
|
The variable name for GET *one* operations that is used as an id.
|
||||||
|
"""
|
||||||
|
ID_CONVERTER = Converters.string
|
||||||
|
"""
|
||||||
|
The converter for the id.
|
||||||
|
|
||||||
|
Note that converters do **cast** the value, so the converter
|
||||||
|
``uuid`` will return an ``UUID`` object.
|
||||||
|
"""
|
||||||
|
__type__ = None # type: str
|
||||||
|
"""
|
||||||
|
The type of resource.
|
||||||
|
If none, it is used the type of the Schema (``Schema.type``)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
app,
|
||||||
|
import_name=__name__,
|
||||||
|
static_folder=None,
|
||||||
|
static_url_path=None,
|
||||||
|
template_folder=None,
|
||||||
|
url_prefix=None,
|
||||||
|
subdomain=None,
|
||||||
|
url_defaults=None,
|
||||||
|
root_path=None,
|
||||||
|
cli_commands: Iterable[Tuple[Callable, str or None]] = tuple(),
|
||||||
|
):
|
||||||
|
assert not self.VIEW or issubclass(
|
||||||
|
self.VIEW, View
|
||||||
|
), 'VIEW should be a subclass of View'
|
||||||
|
assert not self.SCHEMA or issubclass(
|
||||||
|
self.SCHEMA, Schema
|
||||||
|
), 'SCHEMA should be a subclass of Schema or None.'
|
||||||
|
# todo test for cases where self.SCHEMA is None
|
||||||
|
url_prefix = (
|
||||||
|
url_prefix if url_prefix is not None else '/{}'.format(self.resource)
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
self.type,
|
||||||
|
import_name,
|
||||||
|
static_folder,
|
||||||
|
static_url_path,
|
||||||
|
template_folder,
|
||||||
|
url_prefix,
|
||||||
|
subdomain,
|
||||||
|
url_defaults,
|
||||||
|
root_path,
|
||||||
|
)
|
||||||
|
# todo __name__ in import_name forces subclasses to override the constructor
|
||||||
|
# otherwise import_name equals to teal.resource not project1.myresource
|
||||||
|
# and it is not very elegant...
|
||||||
|
|
||||||
|
self.app = app
|
||||||
|
self.schema = self.SCHEMA() if self.SCHEMA else None
|
||||||
|
# Views
|
||||||
|
if self.VIEW:
|
||||||
|
view = self.VIEW.as_view('main', definition=self, auth=app.auth)
|
||||||
|
if self.AUTH:
|
||||||
|
view = app.auth.requires_auth(view)
|
||||||
|
self.add_url_rule(
|
||||||
|
'/', defaults={'id': None}, view_func=view, methods={'GET'}
|
||||||
|
)
|
||||||
|
self.add_url_rule('/', view_func=view, methods={'POST'})
|
||||||
|
self.add_url_rule(
|
||||||
|
'/<{}:{}>'.format(self.ID_CONVERTER.value, self.ID_NAME),
|
||||||
|
view_func=view,
|
||||||
|
methods={'GET', 'PUT', 'DELETE', 'PATCH'},
|
||||||
|
)
|
||||||
|
self.cli_commands = cli_commands
|
||||||
|
self.before_request(self.load_resource)
|
||||||
|
|
||||||
|
@classproperty
|
||||||
|
def type(cls):
|
||||||
|
t = cls.__type__ or cls.SCHEMA.t
|
||||||
|
assert t, 'Resource needs a type: either from SCHEMA or manually from __type__.'
|
||||||
|
return t
|
||||||
|
|
||||||
|
@classproperty
|
||||||
|
def t(cls):
|
||||||
|
return cls.type
|
||||||
|
|
||||||
|
@classproperty
|
||||||
|
def resource(cls):
|
||||||
|
return Naming.resource(cls.type)
|
||||||
|
|
||||||
|
@classproperty
|
||||||
|
def cli_name(cls):
|
||||||
|
"""The name used to generate the CLI Click group for this
|
||||||
|
resource."""
|
||||||
|
return inflection.singularize(cls.resource)
|
||||||
|
|
||||||
|
def load_resource(self):
|
||||||
|
"""
|
||||||
|
Loads a schema and resource_def into the current request so it
|
||||||
|
can be used easily by functions outside view.
|
||||||
|
"""
|
||||||
|
g.schema = self.schema
|
||||||
|
g.resource_def = self
|
||||||
|
|
||||||
|
def init_db(self, db: 'db.SQLAlchemy', exclude_schema=None):
|
||||||
|
"""
|
||||||
|
Put here code to execute when initializing the database for this
|
||||||
|
resource.
|
||||||
|
|
||||||
|
We guarantee this to be executed in an app_context.
|
||||||
|
|
||||||
|
No need to commit.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subresources_types(self) -> Iterator[str]:
|
||||||
|
"""Gets the types of the subresources."""
|
||||||
|
return (node.name for node in PreOrderIter(self.app.tree[self.t]))
|
||||||
|
|
||||||
|
|
||||||
|
TYPE = Union[
|
||||||
|
Resource, Schema, 'db.Model', str, Type[Resource], Type[Schema], Type['db.Model']
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def url_for_resource(resource: TYPE, item_id=None, method='GET') -> str:
|
||||||
|
"""
|
||||||
|
As Flask's ``url_for``, this generates an URL but specifically for
|
||||||
|
a View endpoint of the given resource.
|
||||||
|
:param method: The method whose view URL should be generated.
|
||||||
|
:param resource:
|
||||||
|
:param item_id: If given, append the ID of the resource in the URL,
|
||||||
|
ex. GET /devices/1
|
||||||
|
:return: An URL.
|
||||||
|
"""
|
||||||
|
type = getattr(resource, 't', resource)
|
||||||
|
values = {}
|
||||||
|
if item_id:
|
||||||
|
values[current_app.resources[type].ID_NAME] = item_id
|
||||||
|
return url_for('{}.main'.format(type), _method=method, **values)
|
308
ereuse_devicehub/teal/teal.py
Normal file
308
ereuse_devicehub/teal/teal.py
Normal file
|
@ -0,0 +1,308 @@
|
||||||
|
import inspect
|
||||||
|
from typing import Dict, Type
|
||||||
|
|
||||||
|
import click_spinner
|
||||||
|
import ereuse_utils
|
||||||
|
import flask_cors
|
||||||
|
from anytree import Node
|
||||||
|
from apispec import APISpec
|
||||||
|
from click import option
|
||||||
|
from ereuse_utils import ensure_utf8
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
from flask.globals import _app_ctx_stack
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from marshmallow import ValidationError
|
||||||
|
from werkzeug.exceptions import HTTPException, UnprocessableEntity
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal.auth import Auth
|
||||||
|
from ereuse_devicehub.teal.cli import TealCliRunner
|
||||||
|
from ereuse_devicehub.teal.client import Client
|
||||||
|
from ereuse_devicehub.teal.config import Config as ConfigClass
|
||||||
|
from ereuse_devicehub.teal.db import SchemaSQLAlchemy
|
||||||
|
from ereuse_devicehub.teal.json_util import TealJSONEncoder
|
||||||
|
from ereuse_devicehub.teal.request import Request
|
||||||
|
from ereuse_devicehub.teal.resource import Converters, LowerStrConverter, Resource
|
||||||
|
|
||||||
|
|
||||||
|
class Teal(Flask):
|
||||||
|
"""
|
||||||
|
An opinionated REST and JSON first server built on Flask using
|
||||||
|
MongoDB and Marshmallow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
test_client_class = Client
|
||||||
|
request_class = Request
|
||||||
|
json_encoder = TealJSONEncoder
|
||||||
|
cli_context_settings = {'help_option_names': ('-h', '--help')}
|
||||||
|
test_cli_runner_class = TealCliRunner
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ConfigClass,
|
||||||
|
db: SQLAlchemy,
|
||||||
|
schema: str = None,
|
||||||
|
import_name=__name__.split('.')[0],
|
||||||
|
static_url_path=None,
|
||||||
|
static_folder='static',
|
||||||
|
static_host=None,
|
||||||
|
host_matching=False,
|
||||||
|
subdomain_matching=False,
|
||||||
|
template_folder='templates',
|
||||||
|
instance_path=None,
|
||||||
|
instance_relative_config=False,
|
||||||
|
root_path=None,
|
||||||
|
use_init_db=True,
|
||||||
|
Auth: Type[Auth] = Auth,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
|
||||||
|
:param config:
|
||||||
|
:param db:
|
||||||
|
:param schema: A string describing the main PostgreSQL's schema.
|
||||||
|
``None`` disables this functionality.
|
||||||
|
If you use a factory of apps (for example by using
|
||||||
|
:func:`teal.teal.prefixed_database_factory`) and then set this
|
||||||
|
value differently per each app (as each app has a separate config)
|
||||||
|
you effectively create a `multi-tenant app <https://
|
||||||
|
news.ycombinator.com/item?id=4268792>`_.
|
||||||
|
Your models by default will be created in this ``SCHEMA``,
|
||||||
|
unless you set something like::
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
__table_args__ = {'schema': 'users'}
|
||||||
|
|
||||||
|
In which case this will be created in the ``users`` schema.
|
||||||
|
Schemas are interesting over having multiple databases (i.e. using
|
||||||
|
flask-sqlalchemy's data binding) because you can have relationships
|
||||||
|
between them.
|
||||||
|
|
||||||
|
Note that this only works with PostgreSQL.
|
||||||
|
:param import_name:
|
||||||
|
:param static_url_path:
|
||||||
|
:param static_folder:
|
||||||
|
:param static_host:
|
||||||
|
:param host_matching:
|
||||||
|
:param subdomain_matching:
|
||||||
|
:param template_folder:
|
||||||
|
:param instance_path:
|
||||||
|
:param instance_relative_config:
|
||||||
|
:param root_path:
|
||||||
|
:param Auth:
|
||||||
|
"""
|
||||||
|
self.schema = schema
|
||||||
|
ensure_utf8(self.__class__.__name__)
|
||||||
|
super().__init__(
|
||||||
|
import_name,
|
||||||
|
static_url_path,
|
||||||
|
static_folder,
|
||||||
|
static_host,
|
||||||
|
host_matching,
|
||||||
|
subdomain_matching,
|
||||||
|
template_folder,
|
||||||
|
instance_path,
|
||||||
|
instance_relative_config,
|
||||||
|
root_path,
|
||||||
|
)
|
||||||
|
self.config.from_object(config)
|
||||||
|
flask_cors.CORS(self)
|
||||||
|
# Load databases
|
||||||
|
self.auth = Auth()
|
||||||
|
self.url_map.converters[Converters.lower.name] = LowerStrConverter
|
||||||
|
self.load_resources()
|
||||||
|
self.register_error_handler(HTTPException, self._handle_standard_error)
|
||||||
|
self.register_error_handler(ValidationError, self._handle_validation_error)
|
||||||
|
self.db = db
|
||||||
|
db.init_app(self)
|
||||||
|
if use_init_db:
|
||||||
|
self.cli.command('init-db', context_settings=self.cli_context_settings)(
|
||||||
|
self.init_db
|
||||||
|
)
|
||||||
|
self.spec = None # type: APISpec
|
||||||
|
self.apidocs()
|
||||||
|
|
||||||
|
# noinspection PyAttributeOutsideInit
|
||||||
|
def load_resources(self):
|
||||||
|
self.resources = {} # type: Dict[str, Resource]
|
||||||
|
"""
|
||||||
|
The resources definitions loaded on this App, referenced by their
|
||||||
|
type name.
|
||||||
|
"""
|
||||||
|
self.tree = {} # type: Dict[str, Node]
|
||||||
|
"""
|
||||||
|
A tree representing the hierarchy of the instances of
|
||||||
|
ResourceDefinitions. ResourceDefinitions use these nodes to
|
||||||
|
traverse their hierarchy.
|
||||||
|
|
||||||
|
Do not use the normal python class hierarchy as it is global,
|
||||||
|
thus unreliable if you run different apps with different
|
||||||
|
schemas (for example, an extension that is only added on the
|
||||||
|
third app adds a new type of user).
|
||||||
|
"""
|
||||||
|
for ResourceDef in self.config['RESOURCE_DEFINITIONS']:
|
||||||
|
resource_def = ResourceDef(self) # type: Resource
|
||||||
|
self.register_blueprint(resource_def)
|
||||||
|
|
||||||
|
if resource_def.cli_commands:
|
||||||
|
|
||||||
|
@self.cli.group(
|
||||||
|
resource_def.cli_name,
|
||||||
|
context_settings=self.cli_context_settings,
|
||||||
|
short_help='{} management.'.format(resource_def.type),
|
||||||
|
)
|
||||||
|
def dummy_group():
|
||||||
|
pass
|
||||||
|
|
||||||
|
for (
|
||||||
|
cli_command,
|
||||||
|
*args,
|
||||||
|
) in resource_def.cli_commands: # Register CLI commands
|
||||||
|
# todo cli commands with multiple arguments end-up reversed
|
||||||
|
# when teal has been executed multiple times (ex. testing)
|
||||||
|
# see _param_memo func in click package
|
||||||
|
dummy_group.command(*args)(cli_command)
|
||||||
|
|
||||||
|
# todo should we use resource_def.name instead of type?
|
||||||
|
# are we going to have collisions? (2 resource_def -> 1 schema)
|
||||||
|
self.resources[resource_def.type] = resource_def
|
||||||
|
self.tree[resource_def.type] = Node(resource_def.type)
|
||||||
|
# Link tree nodes between them
|
||||||
|
for _type, node in self.tree.items():
|
||||||
|
resource_def = self.resources[_type]
|
||||||
|
_, Parent, *superclasses = inspect.getmro(resource_def.__class__)
|
||||||
|
if Parent is not Resource:
|
||||||
|
node.parent = self.tree[Parent.type]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _handle_standard_error(e: HTTPException):
|
||||||
|
"""
|
||||||
|
Handles HTTPExceptions by transforming them to JSON.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = jsonify(e)
|
||||||
|
response.status_code = e.code
|
||||||
|
except (AttributeError, TypeError) as e:
|
||||||
|
code = getattr(e, 'code', 500)
|
||||||
|
response = jsonify(
|
||||||
|
{'message': str(e), 'code': code, 'type': e.__class__.__name__}
|
||||||
|
)
|
||||||
|
response.status_code = code
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _handle_validation_error(e: ValidationError):
|
||||||
|
data = {
|
||||||
|
'message': e.messages,
|
||||||
|
'code': UnprocessableEntity.code,
|
||||||
|
'type': e.__class__.__name__,
|
||||||
|
}
|
||||||
|
response = jsonify(data)
|
||||||
|
response.status_code = UnprocessableEntity.code
|
||||||
|
return response
|
||||||
|
|
||||||
|
@option(
|
||||||
|
'--erase/--no-erase',
|
||||||
|
default=False,
|
||||||
|
help='Delete all contents from the database (including common schemas)?',
|
||||||
|
)
|
||||||
|
@option(
|
||||||
|
'--exclude-schema',
|
||||||
|
default=None,
|
||||||
|
help='Schema to exclude creation (and deletion if --erase is set). '
|
||||||
|
'Required the SchemaSQLAlchemy.',
|
||||||
|
)
|
||||||
|
def init_db(self, erase: bool = False, exclude_schema=None):
|
||||||
|
"""
|
||||||
|
Initializes a database from scratch,
|
||||||
|
creating tables and needed resources.
|
||||||
|
|
||||||
|
Note that this does not create the database per se.
|
||||||
|
|
||||||
|
If executing this directly, remember to use an app_context.
|
||||||
|
|
||||||
|
Resources can hook functions that will be called when this
|
||||||
|
method executes, by subclassing :meth:`teal.resource.
|
||||||
|
Resource.load_resource`.
|
||||||
|
"""
|
||||||
|
assert _app_ctx_stack.top, 'Use an app context.'
|
||||||
|
print('Initializing database...'.ljust(30), end='')
|
||||||
|
with click_spinner.spinner():
|
||||||
|
if erase:
|
||||||
|
if exclude_schema: # Using then a schema teal sqlalchemy
|
||||||
|
assert isinstance(self.db, SchemaSQLAlchemy)
|
||||||
|
self.db.drop_schema()
|
||||||
|
else: # using regular flask sqlalchemy
|
||||||
|
self.db.drop_all()
|
||||||
|
self._init_db(exclude_schema)
|
||||||
|
self._init_resources()
|
||||||
|
self.db.session.commit()
|
||||||
|
print('done.')
|
||||||
|
|
||||||
|
def _init_db(self, exclude_schema=None) -> bool:
|
||||||
|
"""Where the database is initialized. You can override this.
|
||||||
|
|
||||||
|
:return: A flag stating if the database has been created (can
|
||||||
|
be False in case check is True and the schema already
|
||||||
|
exists).
|
||||||
|
"""
|
||||||
|
if exclude_schema: # Using then a schema teal sqlalchemy
|
||||||
|
assert isinstance(self.db, SchemaSQLAlchemy)
|
||||||
|
self.db.create_all(exclude_schema=exclude_schema)
|
||||||
|
else: # using regular flask sqlalchemy
|
||||||
|
self.db.create_all()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _init_resources(self, **kw):
|
||||||
|
for resource in self.resources.values():
|
||||||
|
resource.init_db(self.db, **kw)
|
||||||
|
|
||||||
|
def apidocs(self):
|
||||||
|
"""Apidocs configuration and generation."""
|
||||||
|
self.spec = APISpec(
|
||||||
|
plugins=(
|
||||||
|
'apispec.ext.flask',
|
||||||
|
'apispec.ext.marshmallow',
|
||||||
|
),
|
||||||
|
**self.config.get_namespace('API_DOC_CONFIG_'),
|
||||||
|
)
|
||||||
|
for name, resource in self.resources.items():
|
||||||
|
if resource.SCHEMA:
|
||||||
|
self.spec.definition(
|
||||||
|
name,
|
||||||
|
schema=resource.SCHEMA,
|
||||||
|
extra_fields=self.config.get_namespace('API_DOC_CLASS_'),
|
||||||
|
)
|
||||||
|
self.add_url_rule('/apidocs', view_func=self.apidocs_endpoint)
|
||||||
|
|
||||||
|
def apidocs_endpoint(self):
|
||||||
|
"""An endpoint that prints a JSON OpenApi 2.0 specification."""
|
||||||
|
if not getattr(self, '_apidocs', None):
|
||||||
|
# We are forced to to this under a request context
|
||||||
|
for path, view_func in self.view_functions.items():
|
||||||
|
if path != 'static':
|
||||||
|
self.spec.add_path(view=view_func)
|
||||||
|
self._apidocs = self.spec.to_dict()
|
||||||
|
return jsonify(self._apidocs)
|
||||||
|
|
||||||
|
|
||||||
|
class DumpeableHTTPException(ereuse_utils.Dumpeable):
|
||||||
|
"""Exceptions that inherit this class will be able to dump
|
||||||
|
to dicts and JSONs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dump(self):
|
||||||
|
# todo this is heavily ad-hoc and should be more generic
|
||||||
|
value = super().dump()
|
||||||
|
value['type'] = self.__class__.__name__
|
||||||
|
value['code'] = self.code
|
||||||
|
value.pop('exc', None)
|
||||||
|
value.pop('response', None)
|
||||||
|
if 'data' in value:
|
||||||
|
value['fields'] = value['data']['messages']
|
||||||
|
del value['data']
|
||||||
|
if 'message' not in value:
|
||||||
|
value['message'] = value.pop('description', str(self))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Add dump capacity to Werkzeug's HTTPExceptions
|
||||||
|
HTTPException.__bases__ = HTTPException.__bases__ + (DumpeableHTTPException,)
|
33
ereuse_devicehub/teal/utils.py
Normal file
33
ereuse_devicehub/teal/utils.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import inspect
|
||||||
|
from typing import Dict, Iterator, Tuple
|
||||||
|
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from ereuse_devicehub.teal import resource
|
||||||
|
|
||||||
|
|
||||||
|
def compiled(Model, query) -> Tuple[str, Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
Generates a SQL statement.
|
||||||
|
|
||||||
|
:return A tuple with 1. the SQL statement and 2. the params for it.
|
||||||
|
"""
|
||||||
|
c = Model.query.filter(*query).statement.compile(dialect=postgresql.dialect())
|
||||||
|
return str(c), c.params
|
||||||
|
|
||||||
|
|
||||||
|
def import_resource(module) -> Iterator['resource.Resource']:
|
||||||
|
"""
|
||||||
|
Gets the resource classes from the passed-in module.
|
||||||
|
|
||||||
|
This method yields subclasses of :class:`teal.resource.Resource`
|
||||||
|
found in the given module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for obj in vars(module).values():
|
||||||
|
if (
|
||||||
|
inspect.isclass(obj)
|
||||||
|
and issubclass(obj, resource.Resource)
|
||||||
|
and obj != resource.Resource
|
||||||
|
):
|
||||||
|
yield obj
|
|
@ -14,7 +14,6 @@ from flask import current_app as app
|
||||||
from flask import g
|
from flask import g
|
||||||
from pytest import raises
|
from pytest import raises
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.enums import Currency
|
|
||||||
|
|
||||||
from ereuse_devicehub.client import Client, UserClient
|
from ereuse_devicehub.client import Client, UserClient
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -39,6 +38,7 @@ from ereuse_devicehub.resources.enums import (
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
from ereuse_devicehub.resources.tradedocument.models import TradeDocument
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.enums import Currency
|
||||||
from tests import conftest
|
from tests import conftest
|
||||||
from tests.conftest import create_user, file, json_encode, yaml2json
|
from tests.conftest import create_user, file, json_encode, yaml2json
|
||||||
|
|
||||||
|
|
|
@ -3,25 +3,32 @@ from uuid import UUID
|
||||||
import pytest
|
import pytest
|
||||||
from marshmallow import ValidationError
|
from marshmallow import ValidationError
|
||||||
from sqlalchemy_utils import PhoneNumber
|
from sqlalchemy_utils import PhoneNumber
|
||||||
from teal.db import UniqueViolation, DBError
|
|
||||||
from teal.enums import Country
|
|
||||||
|
|
||||||
from ereuse_devicehub.config import DevicehubConfig
|
from ereuse_devicehub.config import DevicehubConfig
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.devicehub import Devicehub
|
from ereuse_devicehub.devicehub import Devicehub
|
||||||
from ereuse_devicehub.resources.agent import OrganizationDef, models, schemas
|
from ereuse_devicehub.resources.agent import OrganizationDef, models, schemas
|
||||||
from ereuse_devicehub.resources.agent.models import Membership, Organization, Person, System
|
from ereuse_devicehub.resources.agent.models import (
|
||||||
|
Membership,
|
||||||
|
Organization,
|
||||||
|
Person,
|
||||||
|
System,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.db import DBError, UniqueViolation
|
||||||
|
from ereuse_devicehub.teal.enums import Country
|
||||||
from tests.conftest import app_context, create_user
|
from tests.conftest import app_context, create_user
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(app_context.__name__)
|
@pytest.mark.usefixtures(app_context.__name__)
|
||||||
def test_agent():
|
def test_agent():
|
||||||
"""Tests creating an person."""
|
"""Tests creating an person."""
|
||||||
person = Person(name='Timmy',
|
person = Person(
|
||||||
tax_id='xyz',
|
name='Timmy',
|
||||||
country=Country.ES,
|
tax_id='xyz',
|
||||||
telephone=PhoneNumber('+34666666666'),
|
country=Country.ES,
|
||||||
email='foo@bar.com')
|
telephone=PhoneNumber('+34666666666'),
|
||||||
|
email='foo@bar.com',
|
||||||
|
)
|
||||||
db.session.add(person)
|
db.session.add(person)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -36,8 +43,7 @@ def test_agent():
|
||||||
@pytest.mark.usefixtures(app_context.__name__)
|
@pytest.mark.usefixtures(app_context.__name__)
|
||||||
def test_system():
|
def test_system():
|
||||||
"""Tests creating a system."""
|
"""Tests creating a system."""
|
||||||
system = System(name='Workbench',
|
system = System(name='Workbench', email='hello@ereuse.org')
|
||||||
email='hello@ereuse.org')
|
|
||||||
db.session.add(system)
|
db.session.add(system)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -49,10 +55,9 @@ def test_system():
|
||||||
@pytest.mark.usefixtures(app_context.__name__)
|
@pytest.mark.usefixtures(app_context.__name__)
|
||||||
def test_organization():
|
def test_organization():
|
||||||
"""Tests creating an organization."""
|
"""Tests creating an organization."""
|
||||||
org = Organization(name='ACME',
|
org = Organization(
|
||||||
tax_id='xyz',
|
name='ACME', tax_id='xyz', country=Country.ES, email='contact@acme.com'
|
||||||
country=Country.ES,
|
)
|
||||||
email='contact@acme.com')
|
|
||||||
db.session.add(org)
|
db.session.add(org)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,8 @@ import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from teal.db import UniqueViolation
|
|
||||||
|
from ereuse_devicehub.teal.db import UniqueViolation
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.mvp
|
@pytest.mark.mvp
|
||||||
|
@ -12,9 +13,10 @@ def test_unique_violation():
|
||||||
self.params = {
|
self.params = {
|
||||||
'uuid': UUID('f5efd26e-8754-46bc-87bf-fbccc39d60d9'),
|
'uuid': UUID('f5efd26e-8754-46bc-87bf-fbccc39d60d9'),
|
||||||
'version': '11.0',
|
'version': '11.0',
|
||||||
'software': 'Workbench', 'elapsed': datetime.timedelta(0, 4),
|
'software': 'Workbench',
|
||||||
|
'elapsed': datetime.timedelta(0, 4),
|
||||||
'expected_actions': None,
|
'expected_actions': None,
|
||||||
'id': UUID('dbdef3d8-2cac-48cb-adb8-419bc3e59687')
|
'id': UUID('dbdef3d8-2cac-48cb-adb8-419bc3e59687'),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
@ -9,8 +9,6 @@ from ereuse_utils.test import ANY
|
||||||
from flask import g
|
from flask import g
|
||||||
from pytest import raises
|
from pytest import raises
|
||||||
from sqlalchemy.util import OrderedSet
|
from sqlalchemy.util import OrderedSet
|
||||||
from teal.db import ResourceNotFound
|
|
||||||
from teal.enums import Layouts
|
|
||||||
|
|
||||||
from ereuse_devicehub.client import Client, UserClient
|
from ereuse_devicehub.client import Client, UserClient
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -34,6 +32,8 @@ from ereuse_devicehub.resources.enums import (
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.tag.model import Tag
|
from ereuse_devicehub.resources.tag.model import Tag
|
||||||
from ereuse_devicehub.resources.user import User
|
from ereuse_devicehub.resources.user import User
|
||||||
|
from ereuse_devicehub.teal.db import ResourceNotFound
|
||||||
|
from ereuse_devicehub.teal.enums import Layouts
|
||||||
from tests import conftest
|
from tests import conftest
|
||||||
from tests.conftest import file, json_encode, yaml2json
|
from tests.conftest import file, json_encode, yaml2json
|
||||||
|
|
||||||
|
|
|
@ -1,40 +1,46 @@
|
||||||
import pytest
|
|
||||||
import uuid
|
import uuid
|
||||||
from teal.utils import compiled
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from ereuse_devicehub.client import UserClient
|
from ereuse_devicehub.client import UserClient
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
from ereuse_devicehub.devicehub import Devicehub
|
from ereuse_devicehub.devicehub import Devicehub
|
||||||
from ereuse_devicehub.resources.action.models import Snapshot
|
from ereuse_devicehub.resources.action.models import Snapshot
|
||||||
from ereuse_devicehub.resources.device.models import Desktop, Device, GraphicCard, Laptop, Server, \
|
from ereuse_devicehub.resources.device.models import (
|
||||||
SolidStateDrive
|
Desktop,
|
||||||
|
Device,
|
||||||
|
GraphicCard,
|
||||||
|
Laptop,
|
||||||
|
Server,
|
||||||
|
SolidStateDrive,
|
||||||
|
)
|
||||||
from ereuse_devicehub.resources.device.search import DeviceSearch
|
from ereuse_devicehub.resources.device.search import DeviceSearch
|
||||||
from ereuse_devicehub.resources.device.views import Filters, Sorting
|
from ereuse_devicehub.resources.device.views import Filters, Sorting
|
||||||
from ereuse_devicehub.resources.enums import ComputerChassis
|
from ereuse_devicehub.resources.enums import ComputerChassis
|
||||||
from ereuse_devicehub.resources.lot.models import Lot
|
from ereuse_devicehub.resources.lot.models import Lot
|
||||||
|
from ereuse_devicehub.teal.utils import compiled
|
||||||
from tests import conftest
|
from tests import conftest
|
||||||
from tests.conftest import file, yaml2json, json_encode
|
from tests.conftest import file, json_encode, yaml2json
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.mvp
|
@pytest.mark.mvp
|
||||||
@pytest.mark.usefixtures(conftest.app_context.__name__)
|
@pytest.mark.usefixtures(conftest.app_context.__name__)
|
||||||
def test_device_filters():
|
def test_device_filters():
|
||||||
schema = Filters()
|
schema = Filters()
|
||||||
q = schema.load({
|
q = schema.load(
|
||||||
'type': ['Computer', 'Laptop'],
|
{
|
||||||
'manufacturer': 'Dell',
|
'type': ['Computer', 'Laptop'],
|
||||||
'rating': {
|
'manufacturer': 'Dell',
|
||||||
'rating': [3, 6],
|
'rating': {'rating': [3, 6], 'appearance': [2, 4]},
|
||||||
'appearance': [2, 4]
|
'tag': {'id': ['bcn-', 'activa-02']},
|
||||||
},
|
|
||||||
'tag': {
|
|
||||||
'id': ['bcn-', 'activa-02']
|
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
s, params = compiled(Device, q)
|
s, params = compiled(Device, q)
|
||||||
# Order between query clauses can change
|
# Order between query clauses can change
|
||||||
assert '(device.type IN (%(type_1)s, %(type_2)s, %(type_3)s, %(type_4)s) ' \
|
assert (
|
||||||
'OR device.type IN (%(type_5)s))' in s
|
'(device.type IN (%(type_1)s, %(type_2)s, %(type_3)s, %(type_4)s) '
|
||||||
|
'OR device.type IN (%(type_5)s))' in s
|
||||||
|
)
|
||||||
assert 'device.manufacturer ILIKE %(manufacturer_1)s' in s
|
assert 'device.manufacturer ILIKE %(manufacturer_1)s' in s
|
||||||
assert 'rate.rating BETWEEN %(rating_1)s AND %(rating_2)s' in s
|
assert 'rate.rating BETWEEN %(rating_1)s AND %(rating_2)s' in s
|
||||||
assert 'rate.appearance BETWEEN %(appearance_1)s AND %(appearance_2)s' in s
|
assert 'rate.appearance BETWEEN %(appearance_1)s AND %(appearance_2)s' in s
|
||||||
|
@ -42,11 +48,33 @@ def test_device_filters():
|
||||||
|
|
||||||
# type_x can be assigned at different values
|
# type_x can be assigned at different values
|
||||||
# ex: type_1 can be 'Desktop' in one execution but the next one 'Laptop'
|
# ex: type_1 can be 'Desktop' in one execution but the next one 'Laptop'
|
||||||
assert set(params.keys()) == {'id_2', 'appearance_1', 'type_1', 'type_4', 'rating_2', 'type_5',
|
assert set(params.keys()) == {
|
||||||
'type_3', 'type_2', 'appearance_2', 'id_1', 'rating_1',
|
'id_2',
|
||||||
'manufacturer_1'}
|
'appearance_1',
|
||||||
assert set(params.values()) == {2.0, 'Laptop', 4.0, 3.0, 6.0, 'Desktop', 'activa-02%',
|
'type_1',
|
||||||
'Server', 'Dell%', 'Computer', 'bcn-%'}
|
'type_4',
|
||||||
|
'rating_2',
|
||||||
|
'type_5',
|
||||||
|
'type_3',
|
||||||
|
'type_2',
|
||||||
|
'appearance_2',
|
||||||
|
'id_1',
|
||||||
|
'rating_1',
|
||||||
|
'manufacturer_1',
|
||||||
|
}
|
||||||
|
assert set(params.values()) == {
|
||||||
|
2.0,
|
||||||
|
'Laptop',
|
||||||
|
4.0,
|
||||||
|
3.0,
|
||||||
|
6.0,
|
||||||
|
'Desktop',
|
||||||
|
'activa-02%',
|
||||||
|
'Server',
|
||||||
|
'Dell%',
|
||||||
|
'Computer',
|
||||||
|
'bcn-%',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures(conftest.app_context.__name__)
|
@pytest.mark.usefixtures(conftest.app_context.__name__)
|
||||||
|
@ -70,22 +98,30 @@ def device_query_dummy(app: Devicehub):
|
||||||
"""
|
"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
devices = ( # The order matters ;-)
|
devices = ( # The order matters ;-)
|
||||||
Desktop(serial_number='1',
|
Desktop(
|
||||||
model='ml1',
|
serial_number='1',
|
||||||
manufacturer='mr1',
|
model='ml1',
|
||||||
chassis=ComputerChassis.Tower),
|
manufacturer='mr1',
|
||||||
Desktop(serial_number='2',
|
chassis=ComputerChassis.Tower,
|
||||||
model='ml2',
|
),
|
||||||
manufacturer='mr2',
|
Desktop(
|
||||||
chassis=ComputerChassis.Microtower),
|
serial_number='2',
|
||||||
Laptop(serial_number='3',
|
model='ml2',
|
||||||
model='ml3',
|
manufacturer='mr2',
|
||||||
manufacturer='mr3',
|
chassis=ComputerChassis.Microtower,
|
||||||
chassis=ComputerChassis.Detachable),
|
),
|
||||||
Server(serial_number='4',
|
Laptop(
|
||||||
model='ml4',
|
serial_number='3',
|
||||||
manufacturer='mr4',
|
model='ml3',
|
||||||
chassis=ComputerChassis.Tower),
|
manufacturer='mr3',
|
||||||
|
chassis=ComputerChassis.Detachable,
|
||||||
|
),
|
||||||
|
Server(
|
||||||
|
serial_number='4',
|
||||||
|
model='ml4',
|
||||||
|
manufacturer='mr4',
|
||||||
|
chassis=ComputerChassis.Tower,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
devices[0].components.add(
|
devices[0].components.add(
|
||||||
GraphicCard(serial_number='1-gc', model='s1ml', manufacturer='s1mr')
|
GraphicCard(serial_number='1-gc', model='s1ml', manufacturer='s1mr')
|
||||||
|
@ -116,10 +152,13 @@ def test_device_query_filter_type(user: UserClient):
|
||||||
|
|
||||||
@pytest.mark.usefixtures(device_query_dummy.__name__)
|
@pytest.mark.usefixtures(device_query_dummy.__name__)
|
||||||
def test_device_query_filter_sort(user: UserClient):
|
def test_device_query_filter_sort(user: UserClient):
|
||||||
i, _ = user.get(res=Device, query=[
|
i, _ = user.get(
|
||||||
('sort', {'created': Sorting.DESCENDING}),
|
res=Device,
|
||||||
('filter', {'type': ['Computer']})
|
query=[
|
||||||
])
|
('sort', {'created': Sorting.DESCENDING}),
|
||||||
|
('filter', {'type': ['Computer']}),
|
||||||
|
],
|
||||||
|
)
|
||||||
assert ('4', '3', '2', '1') == tuple(d['serialNumber'] for d in i['items'])
|
assert ('4', '3', '2', '1') == tuple(d['serialNumber'] for d in i['items'])
|
||||||
|
|
||||||
|
|
||||||
|
@ -128,46 +167,49 @@ def test_device_query_filter_lots(user: UserClient):
|
||||||
parent, _ = user.post({'name': 'Parent'}, res=Lot)
|
parent, _ = user.post({'name': 'Parent'}, res=Lot)
|
||||||
child, _ = user.post({'name': 'Child'}, res=Lot)
|
child, _ = user.post({'name': 'Child'}, res=Lot)
|
||||||
|
|
||||||
i, _ = user.get(res=Device, query=[
|
i, _ = user.get(res=Device, query=[('filter', {'lot': {'id': [parent['id']]}})])
|
||||||
('filter', {'lot': {'id': [parent['id']]}})
|
|
||||||
])
|
|
||||||
assert not i['items'], 'No devices in lot'
|
assert not i['items'], 'No devices in lot'
|
||||||
|
|
||||||
parent, _ = user.post({},
|
parent, _ = user.post(
|
||||||
res=Lot,
|
{},
|
||||||
item='{}/children'.format(parent['id']),
|
res=Lot,
|
||||||
query=[('id', child['id'])])
|
item='{}/children'.format(parent['id']),
|
||||||
i, _ = user.get(res=Device, query=[
|
query=[('id', child['id'])],
|
||||||
('filter', {'type': ['Computer']})
|
)
|
||||||
])
|
i, _ = user.get(res=Device, query=[('filter', {'type': ['Computer']})])
|
||||||
assert ('1', '2', '3', '4') == tuple(d['serialNumber'] for d in i['items'])
|
assert ('1', '2', '3', '4') == tuple(d['serialNumber'] for d in i['items'])
|
||||||
parent, _ = user.post({},
|
parent, _ = user.post(
|
||||||
res=Lot,
|
{},
|
||||||
item='{}/devices'.format(parent['id']),
|
res=Lot,
|
||||||
query=[('id', d['id']) for d in i['items'][:2]])
|
item='{}/devices'.format(parent['id']),
|
||||||
child, _ = user.post({},
|
query=[('id', d['id']) for d in i['items'][:2]],
|
||||||
res=Lot,
|
)
|
||||||
item='{}/devices'.format(child['id']),
|
child, _ = user.post(
|
||||||
query=[('id', d['id']) for d in i['items'][2:]])
|
{},
|
||||||
i, _ = user.get(res=Device, query=[
|
res=Lot,
|
||||||
('filter', {'lot': {'id': [parent['id']]}})
|
item='{}/devices'.format(child['id']),
|
||||||
])
|
query=[('id', d['id']) for d in i['items'][2:]],
|
||||||
|
)
|
||||||
|
i, _ = user.get(res=Device, query=[('filter', {'lot': {'id': [parent['id']]}})])
|
||||||
assert ('1', '2', '3', '4', '1-gc', '2-ssd', '4-ssd') == tuple(
|
assert ('1', '2', '3', '4', '1-gc', '2-ssd', '4-ssd') == tuple(
|
||||||
x['serialNumber'] for x in i['items']
|
x['serialNumber'] for x in i['items']
|
||||||
), 'The parent lot contains 2 items plus indirectly the other ' \
|
), (
|
||||||
'2 from the child lot, with all their 2 components'
|
'The parent lot contains 2 items plus indirectly the other '
|
||||||
|
'2 from the child lot, with all their 2 components'
|
||||||
|
)
|
||||||
|
|
||||||
i, _ = user.get(res=Device, query=[
|
i, _ = user.get(
|
||||||
('filter', {'type': ['Computer'], 'lot': {'id': [parent['id']]}}),
|
res=Device,
|
||||||
])
|
query=[
|
||||||
|
('filter', {'type': ['Computer'], 'lot': {'id': [parent['id']]}}),
|
||||||
|
],
|
||||||
|
)
|
||||||
assert ('1', '2', '3', '4') == tuple(x['serialNumber'] for x in i['items'])
|
assert ('1', '2', '3', '4') == tuple(x['serialNumber'] for x in i['items'])
|
||||||
s, _ = user.get(res=Device, query=[
|
s, _ = user.get(res=Device, query=[('filter', {'lot': {'id': [child['id']]}})])
|
||||||
('filter', {'lot': {'id': [child['id']]}})
|
|
||||||
])
|
|
||||||
assert ('3', '4', '4-ssd') == tuple(x['serialNumber'] for x in s['items'])
|
assert ('3', '4', '4-ssd') == tuple(x['serialNumber'] for x in s['items'])
|
||||||
s, _ = user.get(res=Device, query=[
|
s, _ = user.get(
|
||||||
('filter', {'lot': {'id': [child['id'], parent['id']]}})
|
res=Device, query=[('filter', {'lot': {'id': [child['id'], parent['id']]}})]
|
||||||
])
|
)
|
||||||
assert ('1', '2', '3', '4', '1-gc', '2-ssd', '4-ssd') == tuple(
|
assert ('1', '2', '3', '4', '1-gc', '2-ssd', '4-ssd') == tuple(
|
||||||
x['serialNumber'] for x in s['items']
|
x['serialNumber'] for x in s['items']
|
||||||
), 'Adding both lots is redundant in this case and we have the 4 elements.'
|
), 'Adding both lots is redundant in this case and we have the 4 elements.'
|
||||||
|
|
|
@ -12,8 +12,6 @@ import pytest
|
||||||
from boltons import urlutils
|
from boltons import urlutils
|
||||||
from ereuse_utils.test import ANY
|
from ereuse_utils.test import ANY
|
||||||
from requests.exceptions import HTTPError
|
from requests.exceptions import HTTPError
|
||||||
from teal.db import DBError, UniqueViolation
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.client import Client, UserClient
|
from ereuse_devicehub.client import Client, UserClient
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -42,6 +40,8 @@ from ereuse_devicehub.resources.documents import documents
|
||||||
from ereuse_devicehub.resources.enums import ComputerChassis, SnapshotSoftware
|
from ereuse_devicehub.resources.enums import ComputerChassis, SnapshotSoftware
|
||||||
from ereuse_devicehub.resources.tag import Tag
|
from ereuse_devicehub.resources.tag import Tag
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import DBError, UniqueViolation
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
from tests import conftest
|
from tests import conftest
|
||||||
from tests.conftest import file, file_json, json_encode, yaml2json
|
from tests.conftest import file, file_json, json_encode, yaml2json
|
||||||
|
|
||||||
|
|
|
@ -6,8 +6,6 @@ from boltons.urlutils import URL
|
||||||
from ereuse_utils.session import DevicehubClient
|
from ereuse_utils.session import DevicehubClient
|
||||||
from flask import g
|
from flask import g
|
||||||
from pytest import raises
|
from pytest import raises
|
||||||
from teal.db import DBError, MultipleResourcesFound, ResourceNotFound, UniqueViolation
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
|
|
||||||
from ereuse_devicehub.client import Client, UserClient
|
from ereuse_devicehub.client import Client, UserClient
|
||||||
from ereuse_devicehub.db import db
|
from ereuse_devicehub.db import db
|
||||||
|
@ -23,6 +21,13 @@ from ereuse_devicehub.resources.tag.view import (
|
||||||
TagNotLinked,
|
TagNotLinked,
|
||||||
)
|
)
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.db import (
|
||||||
|
DBError,
|
||||||
|
MultipleResourcesFound,
|
||||||
|
ResourceNotFound,
|
||||||
|
UniqueViolation,
|
||||||
|
)
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
from tests import conftest
|
from tests import conftest
|
||||||
from tests.conftest import json_encode, yaml2json
|
from tests.conftest import json_encode, yaml2json
|
||||||
|
|
||||||
|
|
|
@ -2,8 +2,6 @@ from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy_utils import Password
|
from sqlalchemy_utils import Password
|
||||||
from teal.enums import Country
|
|
||||||
from teal.marshmallow import ValidationError
|
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
from ereuse_devicehub import auth
|
from ereuse_devicehub import auth
|
||||||
|
@ -13,6 +11,8 @@ from ereuse_devicehub.devicehub import Devicehub
|
||||||
from ereuse_devicehub.resources.user import UserDef
|
from ereuse_devicehub.resources.user import UserDef
|
||||||
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
|
from ereuse_devicehub.resources.user.exceptions import WrongCredentials
|
||||||
from ereuse_devicehub.resources.user.models import User
|
from ereuse_devicehub.resources.user.models import User
|
||||||
|
from ereuse_devicehub.teal.enums import Country
|
||||||
|
from ereuse_devicehub.teal.marshmallow import ValidationError
|
||||||
from tests.conftest import app_context, create_user
|
from tests.conftest import app_context, create_user
|
||||||
|
|
||||||
|
|
||||||
|
@ -24,12 +24,14 @@ def test_create_user_method_with_agent(app: Devicehub):
|
||||||
This method checks that the token is correct, too.
|
This method checks that the token is correct, too.
|
||||||
"""
|
"""
|
||||||
user_def = app.resources['User'] # type: UserDef
|
user_def = app.resources['User'] # type: UserDef
|
||||||
u = user_def.create_user(email='foo@foo.com',
|
u = user_def.create_user(
|
||||||
password='foo',
|
email='foo@foo.com',
|
||||||
agent='Nice Person',
|
password='foo',
|
||||||
country=Country.ES.name,
|
agent='Nice Person',
|
||||||
telephone='+34 666 66 66 66',
|
country=Country.ES.name,
|
||||||
tax_id='1234')
|
telephone='+34 666 66 66 66',
|
||||||
|
tax_id='1234',
|
||||||
|
)
|
||||||
user = User.query.filter_by(id=u['id']).one() # type: User
|
user = User.query.filter_by(id=u['id']).one() # type: User
|
||||||
assert user.email == 'foo@foo.com'
|
assert user.email == 'foo@foo.com'
|
||||||
assert isinstance(user.token, UUID)
|
assert isinstance(user.token, UUID)
|
||||||
|
@ -75,9 +77,9 @@ def test_login_success(client: Client, app: Devicehub):
|
||||||
"""
|
"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
create_user()
|
create_user()
|
||||||
user, _ = client.post({'email': 'foo@foo.com', 'password': 'foo'},
|
user, _ = client.post(
|
||||||
uri='/users/login/',
|
{'email': 'foo@foo.com', 'password': 'foo'}, uri='/users/login/', status=200
|
||||||
status=200)
|
)
|
||||||
assert user['email'] == 'foo@foo.com'
|
assert user['email'] == 'foo@foo.com'
|
||||||
assert UUID(auth.Auth.decode(user['token']))
|
assert UUID(auth.Auth.decode(user['token']))
|
||||||
assert 'password' not in user
|
assert 'password' not in user
|
||||||
|
@ -126,16 +128,20 @@ def test_login_failure(client: Client, app: Devicehub):
|
||||||
# Wrong password
|
# Wrong password
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
create_user()
|
create_user()
|
||||||
client.post({'email': 'foo@foo.com', 'password': 'wrong pass'},
|
client.post(
|
||||||
uri='/users/login/',
|
{'email': 'foo@foo.com', 'password': 'wrong pass'},
|
||||||
status=WrongCredentials)
|
uri='/users/login/',
|
||||||
|
status=WrongCredentials,
|
||||||
|
)
|
||||||
# Wrong URI
|
# Wrong URI
|
||||||
client.post({}, uri='/wrong-uri', status=NotFound)
|
client.post({}, uri='/wrong-uri', status=NotFound)
|
||||||
# Malformed data
|
# Malformed data
|
||||||
client.post({}, uri='/users/login/', status=ValidationError)
|
client.post({}, uri='/users/login/', status=ValidationError)
|
||||||
client.post({'email': 'this is not an email', 'password': 'nope'},
|
client.post(
|
||||||
uri='/users/login/',
|
{'email': 'this is not an email', 'password': 'nope'},
|
||||||
status=ValidationError)
|
uri='/users/login/',
|
||||||
|
status=ValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason='Test not developed')
|
@pytest.mark.xfail(reason='Test not developed')
|
||||||
|
|
Reference in a new issue